mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-27 12:01:48 +01:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2cc34576c | ||
|
|
2797212d8c | ||
|
|
dab13fa876 | ||
|
|
36cef1096a | ||
|
|
fcae5d66de | ||
|
|
a160043c63 | ||
|
|
64938443c8 | ||
|
|
1911923afa | ||
|
|
2419279a97 | ||
|
|
d958148c73 | ||
|
|
a27e09e72c | ||
|
|
b3618690c2 | ||
|
|
c7979a862d | ||
|
|
c0d8cb9dc4 | ||
|
|
2888a45a30 | ||
|
|
39aa8d56fb | ||
|
|
5097a9f999 |
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.49",
|
||||
"version": "0.4.50",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.49",
|
||||
"version": "0.4.50",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.49",
|
||||
"version": "0.4.50",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"tag": "v0.4.50",
|
||||
"releaseId": 424993367,
|
||||
"tagNameAtAudit": "v0.4.50",
|
||||
"draftAtAudit": true,
|
||||
"sha256sumsSha256": "85383104a8f08f49ffde24ac867f9c2ead41951e602600a9d22d58bc19bf06f5",
|
||||
"assets": [
|
||||
{
|
||||
"name": "Frame-Control-mac-arm64.dmg",
|
||||
"size": 173090558,
|
||||
"sha256": "d402924394725bac2b28bef17c7945e2f7e4c3093227eace78a42ed80e864be9",
|
||||
"udifTrailer": "ok"
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-mac-arm64.zip",
|
||||
"size": 172911785,
|
||||
"sha256": "2583937fefdc315706018c1bf794b916726ee55b461e321c3334df21245a110d",
|
||||
"zipCrc": "ok",
|
||||
"members": 1666,
|
||||
"bundleVersion": "0.4.50"
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-mac-x64.dmg",
|
||||
"size": 180139223,
|
||||
"sha256": "2128c14b15dd1a2a59790c43d42640a25185562056796a3437910d6f88f996c8",
|
||||
"udifTrailer": "ok"
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-mac-x64.zip",
|
||||
"size": 179998207,
|
||||
"sha256": "ed684023ff087d6741e88d4ca4f8a47e67bec07255fc73fbd18a6bcaa42d14fe",
|
||||
"zipCrc": "ok",
|
||||
"members": 1662,
|
||||
"bundleVersion": "0.4.50"
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-Setup-x64.exe",
|
||||
"size": 147348168,
|
||||
"sha256": "7c46fcc759cefb9060e1dc2fce39682f6dad306f3666805f77ce707e16ef3e2c",
|
||||
"peMachine": 332
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-win-x64.zip",
|
||||
"size": 194302082,
|
||||
"sha256": "57e71304bae96dc8aab6b845a64c27ac428931d02bb41ae60b33b19fcd11193f",
|
||||
"zipCrc": "ok",
|
||||
"members": 1510
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-linux-x86_64.AppImage",
|
||||
"size": 175453095,
|
||||
"sha256": "d2196b9a50d0777203e60721dd19c6edbb5c18a7639148f0a496d420e231b053",
|
||||
"elfMachine": 62
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-linux-arm64.AppImage",
|
||||
"size": 167036625,
|
||||
"sha256": "49370f9c27931bf6c28676d627dc34553550b544ab38b7f4be8e8f6dbc9b7c4b",
|
||||
"elfMachine": 183
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-linux-amd64.deb",
|
||||
"size": 142209572,
|
||||
"sha256": "430d057f234557f5f9dbb3e2cff3469829d041d0d7be191e70e6ef7de6663256",
|
||||
"debVersion": "0.4.50",
|
||||
"architecture": "amd64"
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-linux-arm64.deb",
|
||||
"size": 129291236,
|
||||
"sha256": "6403f8caafcdd5832415547e146ce3886cf7358e0c3a8898f4ee6e9a0a7b05ea",
|
||||
"debVersion": "0.4.50",
|
||||
"architecture": "arm64"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
# v0.4.50 candidate gate, 27 October 2026
|
||||
|
||||
The version bump at `a160043` follows the merged Windows panel-response repair
|
||||
[#356](https://github.com/saphid/frame-control/pull/356) and the Framey changes
|
||||
[#353](https://github.com/saphid/frame-control/pull/353),
|
||||
[#354](https://github.com/saphid/frame-control/pull/354),
|
||||
[#355](https://github.com/saphid/frame-control/pull/355),
|
||||
[#357](https://github.com/saphid/frame-control/pull/357),
|
||||
[#358](https://github.com/saphid/frame-control/pull/358), and
|
||||
[#359](https://github.com/saphid/frame-control/pull/359).
|
||||
The exact DeckyFileServer catalog entry is **partial**; the shared Toggle fix
|
||||
does not promote any plugin status. Worn VR and physical controller behavior
|
||||
remain unverified.
|
||||
|
||||
## Locked real Frame smoke
|
||||
|
||||
Early `scripts/frame-smoke.sh` attempts exited before any headset read or test
|
||||
action because lxso2's Tailnet route was down and lxso1 lacked the authorized
|
||||
SSH key and pinned host record. No lock was taken by those attempts. The final
|
||||
run kept lxso2's existing private key and pinned Frame host key end to end,
|
||||
using lxso1 only as a TCP relay. A task-owned localhost tunnel reached the
|
||||
devkit endpoint after the runner acquired `/tmp/frame-test.lock`; no global
|
||||
SSH, network or security setting was changed.
|
||||
|
||||
At **02:25:14 UTC**, `scripts/frame-smoke.sh` ran from candidate commit
|
||||
`fcae5d6` on Frame OS **0.5.6**, build `20261022.6153909`. Its locked
|
||||
read-only preflight found SteamVR activity **3 (standby)**, proximity raw
|
||||
**3.25** and battery **100%**. The result was **12 passed / one optional
|
||||
x86_64 launch skipped / zero failed**. ARM64 and Windows/Proton test titles
|
||||
installed, launched and were removed. The x86_64 title installed and was
|
||||
removed, but Steam Linux Runtime 4 was absent, so no x86_64 launch is claimed.
|
||||
Both the initial leftover sweep and final owned cleanup passed; the runner
|
||||
released only its lock. The private result is
|
||||
`/tmp/frame-control-v0450-smoke-results/2026-10-27T022514Z-20261022.6153909.json`
|
||||
(SHA-256 `7b8d88aafb4459bf882edeb10f65c021c5aa25ad9ed2755faf04e3b4a95c1632`).
|
||||
|
||||
A separate read-only audit acquired a fresh unique lock after the smoke. It
|
||||
found activity **3**, proximity raw **3.25**, battery **100%**, no exact reserved
|
||||
`fc_smoke_*` or `fc-smoke-*` title directories or metadata, and no processes
|
||||
owned by those paths. That audit released its own lock. The local tunnel was
|
||||
stopped; the isolated lxso1 checkout and temporary SSH files were removed.
|
||||
There was no wearer confirmation, wake command or worn interaction test.
|
||||
|
||||
## Remaining release gates
|
||||
|
||||
The candidate needs green exact-head PR CI, e2e, native amd64/ARM64 Decky
|
||||
runtime checks and all four installer previews. After the candidate merges,
|
||||
its exact main commit needs green CI and e2e. The tagged release then needs
|
||||
every installer build, ten assets, matching checksums, release verification,
|
||||
independent package checks and publisher dry run. A failed, cancelled, skipped,
|
||||
pending or unverified release gate holds publication. The current published
|
||||
release remains v0.4.49.
|
||||
@@ -0,0 +1,75 @@
|
||||
# v0.4.50 tagged release verification, 27 October 2026
|
||||
|
||||
The annotated `v0.4.50` tag dereferences to merged main commit
|
||||
`2797212d8c95311deb8ba73043d0f90ba10bc962`. Its tree exactly matched
|
||||
the reviewed [candidate PR #360](https://github.com/saphid/frame-control/pull/360)
|
||||
head `dab13fa8763b4f836cd49d398d152124655ea8dc`. The candidate includes
|
||||
the Framey and Windows changes listed in the public release notes and the
|
||||
publisher gate that checks every workflow run attempt.
|
||||
|
||||
## Exact commit and tagged build gates
|
||||
|
||||
- [Main checks](https://github.com/saphid/frame-control/actions/runs/40440862876)
|
||||
completed successfully on the tag commit: seven of seven jobs, including
|
||||
e2e, iOS, Windows, macOS, Linux and the aggregate gate.
|
||||
- [Native Decky runtime](https://github.com/saphid/frame-control/actions/runs/40440862867)
|
||||
completed successfully on that commit: AMD64 and ARM64 actual Decky 3.3.0
|
||||
discovery and safe probes.
|
||||
- An exact-main [installer preview](https://github.com/saphid/frame-control/actions/runs/40441532929)
|
||||
built Windows, Linux, Apple Silicon Mac and Intel Mac installers successfully
|
||||
before tagging. Its draft and checksum jobs were inapplicable to the manual
|
||||
preview and skipped by their tag-only conditions; they were not release gates.
|
||||
- The [exact-tag release workflow](https://github.com/saphid/frame-control/actions/runs/40442228066)
|
||||
then passed all six jobs on its first attempt: draft, four platform builds,
|
||||
and checksums. No tagged job failed, was cancelled, skipped or overridden.
|
||||
|
||||
The draft contained exactly ten installer assets and `SHA256SUMS` before
|
||||
publication. Every installer was independently downloaded by GitHub asset ID,
|
||||
one file at a time. Its downloaded size and SHA-256 matched GitHub's asset
|
||||
metadata and the downloaded `SHA256SUMS`; the latter's SHA-256 was
|
||||
`85383104a8f08f49ffde24ac867f9c2ead41951e602600a9d22d58bc19bf06f5`.
|
||||
The [sanitized audit result](v0450-assets-2026-10-27.json) records all ten
|
||||
names, sizes, hashes and package checks. The audit also checked both Mac ZIP
|
||||
CRCs and bundle version `0.4.50`, both DMG UDIF trailers, the Windows ZIP
|
||||
CRC and PE installer header, both AppImage ELF architectures, and both DEB
|
||||
version and architecture fields. The Windows NSIS installer itself has a
|
||||
32-bit PE launcher; tagged Windows CI installed its x64 payload and checked
|
||||
packaged resources and Python libraries.
|
||||
|
||||
The independently downloaded Linux x86_64 AppImage had SHA-256
|
||||
`d2196b9a50d0777203e60721dd19c6edbb5c18a7639148f0a496d420e231b053`.
|
||||
Its first local launch exited inside the workspace sandbox before the server
|
||||
answered. The same bytes then passed outside that sandbox under isolated Xvfb:
|
||||
HTTP 200 with `FrameControl/1`, followed by a stopped server. The launcher
|
||||
used a temporary profile and disabled telemetry and update checks. Tagged
|
||||
Linux CI also launched its own built AppImage. No physical Mac or Windows
|
||||
launch of these exact installers was performed.
|
||||
|
||||
## Real Frame and publication
|
||||
|
||||
The [candidate evidence](v0450-candidate-2026-10-27.md) records a unique-lock
|
||||
real Frame smoke on the version-bumped source: **12 passed, one optional
|
||||
x86_64 launch skipped, zero failed**. Steam Linux Runtime 4 was absent, so no
|
||||
x86_64 launch is claimed. A separate fresh-lock cleanup audit found no owned
|
||||
test title directories, metadata or processes; both locks were released.
|
||||
SteamVR activity was 3 (standby), proximity raw 3.25 and battery 100%.
|
||||
There was no wearer confirmation, wake command or worn interaction test.
|
||||
The private smoke result's SHA-256 is
|
||||
`7b8d88aafb4459bf882edeb10f65c021c5aa25ad9ed2755faf04e3b4a95c1632`.
|
||||
|
||||
`scripts/publish-release.sh --dry-run v0.4.50` passed the exact main,
|
||||
native runtime and tagged release jobs, all ten installer hashes, regenerated
|
||||
`SHA256SUMS`, draft notes and update-manifest checks. The real publisher
|
||||
repeated those gates, attached `update.json`, and published
|
||||
[v0.4.50](https://github.com/saphid/frame-control/releases/tag/v0.4.50) at
|
||||
**03:19:21 UTC**. GitHub's latest-release API returned `draft=false`,
|
||||
`prerelease=false`, ten installers, `SHA256SUMS` and `update.json`. A fresh
|
||||
public latest-download of `update.json` reported version `0.4.50`, the
|
||||
correct release page, ten hashes matching the independent downloads, and the
|
||||
worn-headset limit in its notes.
|
||||
|
||||
The exact-version official Decky catalog is **6 works / 51 partial /
|
||||
48 broken / 11 N/A**. These statuses have the scoped evidence in
|
||||
[the compatibility table](../framey-compatibility.md). Worn headset
|
||||
visibility, physical controller input, VR keyboard and optical comfort remain
|
||||
unverified. The hourly shared-lock worn check remains enabled.
|
||||
@@ -64,7 +64,8 @@ none of these frontend controls has passed a worn-headset check yet.
|
||||
| `PanelSection`, rows, buttons, toggles, dropdowns | Local touch-sized controls; disabled controls stay inert during a backend call | Local browser and host unit test |
|
||||
| `TextField`, `SliderField`, grouped `Dropdown` options | Native text, range and select controls in the panel; disabled state, numeric slider callback and basic input types | Host unit test; worn VR use unverified |
|
||||
| Scrollable panel content | Visible Scroll plugin up/down buttons move the last region reached by a trusted pointer, or the document if that region disappears. Hold either button to repeat; keyboard activation still moves one step. Plugins may use ordinary CSS overflow containers. | Parent/iframe message tests and a real Frame synthetic OpenVR pointer hold; physical controller scroll and hold remain unverified |
|
||||
| `Tabs`, `SidebarNavigation`, spinners, dialog/menu wrappers, progress bars | Local tab navigation and ordinary HTML controls; no Steam navigation or controller focus tree | Host tests and catalog frontend smoke checks in local Chromium |
|
||||
| `Tabs`, `SidebarNavigation`, spinners, dialog/menu wrappers | Local tab navigation and ordinary HTML controls; no Steam navigation or controller focus tree | Host tests and catalog frontend smoke checks in local Chromium |
|
||||
| `ProgressBar`, `ProgressBarItem`, `ProgressBarWithInfo` | Native progress control; `indeterminate` omits its value so it animates as unfinished, and `sOperationText` appears with the bar. Estimated completion time is not shown. | Host contract tests; official plugin progress behavior in worn VR remains unverified. |
|
||||
| `SteamUIStore` / `DFL.Router.RunningApps`, `MainRunningApp` | Bounded detached Steam CEF reads while the panel is open; refresh about every two seconds; no MobX observation. Failed reads clear stale context. | Locked real Frame empty-list reads; nonempty local Chromium fixtures. Real running-game selection unverified. |
|
||||
| `GameSessions.RegisterForAppLifetimeNotifications` | Actual Steam callbacks, bounded queue and disposable `.unregister()` handle; no events inferred from snapshot changes; lease cleanup after lost panels | Real Frame registration/poll/close; launch/exit, retry, expiry and duplicate tests use explicit fixtures. Real game transitions unverified. |
|
||||
| Other `SteamClient` subscriptions | Explicit inert registration methods return disposable handles; no fabricated power/input/game-action events. Mutating Steam/power methods are unavailable. | Host contract tests; safe fallbacks only |
|
||||
@@ -76,7 +77,7 @@ none of these frontend controls has passed a worn-headset check yet.
|
||||
| `localStorage` | Synchronous, per-plugin cache backed by the Frame Control panel origin. Up to 64 keys, 64 KiB per value and 128 KiB total; `clear()` removes only this plugin's cache. Falls back to memory if panel storage is denied. | Parent/iframe round-trip and reopen in the local host test; real headset persistence remains unverified. |
|
||||
| `SteamClient.Storage.GetJSON` / `SetObject` | Uses the same bounded per-plugin cache; a missing key rejects with `Not found`. It cannot read Steam account or global storage. | Host contract tests and local Chromium settings views; headset persistence after reopen remains unverified. |
|
||||
| IndexedDB / localForage | IndexedDB is deliberately absent inside the opaque iframe so feature detection chooses the bounded localStorage driver. Plugins requiring IndexedDB directly remain unsupported. | HLTB official frontend in local Chromium: its previous `IDBFactory.open` SecurityError disappeared. No live HLTB data request was run. |
|
||||
| `routerHook.addRoute` / `removeRoute`, `Navigation.Navigate` / `NavigateBack`, `useParams` | Local routes inside this plugin panel, with named segments such as `/plugin/game/:appid/:view?`; `useParams()` reads the current segment values | Host unit test; VR navigation unverified |
|
||||
| `routerHook.addRoute` / `removeRoute`, `Navigation.Navigate` / `NavigateBack`, `useParams` | Local routes inside this plugin panel, with named segments such as `/plugin/game/:appid/:view?`; `useParams()` reads the current segment values. Back walks up to 32 local page transitions and skips routes removed since navigation. | Host unit test; VR navigation unverified |
|
||||
| `routerHook.addPatch`, global components | No-op; cannot patch Steam routes | Not applicable |
|
||||
| `findModule`, `findModuleChild`, `findModuleExport`, class lookup and module mapping | Searches a finite registry of Framey controls and CSS maps. Unknown Steam modules return `undefined` or an empty mapping. The optional UI-audio export cannot play sound. | Host contract tests; pinned TabMaster 2.15.1 QAM and documentation render in local Chromium. Steam library editing and worn VR remain unverified. |
|
||||
| `Navigation.CloseSideMenus` | Returns to this plugin's main panel | Pinned HLTB for Deck 2.0.12 shallow settings view in local host test. Live HLTB search remains unverified. |
|
||||
@@ -90,6 +91,9 @@ Frame Control store; only paths registered by that same plugin can navigate.
|
||||
Optional route segments must be at the end. Framey accepts local path segments
|
||||
only; query strings, external URLs, Steam library routes and duplicate parameter
|
||||
names are rejected. Removing an active route returns to the plugin panel.
|
||||
`Navigation.NavigateBack()` returns to the preceding local page, then the
|
||||
plugin root. `Navigation.CloseSideMenus()` returns directly to the root and
|
||||
clears that panel's history. Neither operation navigates Steam's own UI.
|
||||
Toasts stay inside the panel, so keep essential errors in the plugin UI too.
|
||||
Treat module lookup results as Framey-local exports. Do not depend on Steam
|
||||
webpack IDs, function-source signatures, or Steam-only CSS class names; a
|
||||
|
||||
+10
-7
@@ -203,12 +203,14 @@ the managed service is stopped; it does not restart the service.
|
||||
|
||||
Install, Update, and Remove show the current job stage in the headset panel.
|
||||
The store verifies each downloaded archive and the headset applies it with a
|
||||
rollback if Decky cannot start. The panel remembers the current job ID for this
|
||||
browser session, so **Check result** can resume polling after a navigation or
|
||||
temporary connection error. After a polling error, Install, Update, and Remove
|
||||
rollback if Decky cannot start. The panel saves a random request ID before
|
||||
submitting a change. The server uses that ID as the job ID and returns the
|
||||
same job for a repeated, identical request while its result is retained;
|
||||
reusing the ID for a different change is rejected. **Check result** can resume
|
||||
polling even if the POST response is lost. After a connection error, Install, Update, and Remove
|
||||
stay disabled while the saved job's result is unknown; use **Check result**
|
||||
before starting another change.
|
||||
If Chromium denies session storage, the open panel keeps the accepted job ID in
|
||||
If Chromium denies session storage, the open panel keeps the request ID in
|
||||
memory and **Check result** still works after a connection error. A page reload
|
||||
loses that memory fallback; wait for the old job to finish and refresh installed
|
||||
status before starting another change.
|
||||
@@ -216,9 +218,10 @@ Refresh the installed list before starting another
|
||||
operation after an error. The headset panel and desktop app send the hash of
|
||||
the version you saw; if the official store changes that version before the job
|
||||
starts, Frame Control stops before downloading or changing the headset. Refresh
|
||||
the store and review the new version before retrying. If Frame Control restarts, it cannot recover the old
|
||||
job result. The panel clears that job ID and asks you to refresh the installed
|
||||
list before making another change.
|
||||
the store and review the new version before retrying. Job results live in server
|
||||
memory for one hour after completion. If Frame Control restarts, or the request
|
||||
never reached it, the panel cannot recover the result. It clears that ID and
|
||||
asks you to refresh the installed list before making another change.
|
||||
|
||||
An open plugin panel closes before its own Update or Remove starts, so its old
|
||||
frontend cannot call the changing backend. Open panel is unavailable while a
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ count. So a build reaches people only when you publish it, after testing it.
|
||||
The script checks that the tag is on GitHub, that exact-commit main CI
|
||||
(including e2e), native amd64/ARM64 Decky runtime checks, and the tagged
|
||||
release workflow are green with no failed,
|
||||
cancelled, skipped or running jobs, and that all ten installers
|
||||
cancelled, skipped or running jobs in any run attempt, and that all ten installers
|
||||
are attached, each with the SHA-256 digest GitHub records. It also checks
|
||||
that the draft does not begin with a `WITHDRAWN` notice, so the preserved
|
||||
v0.4.48 draft cannot accidentally be offered again. It checks
|
||||
|
||||
@@ -23,6 +23,11 @@ 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`,
|
||||
@@ -54,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
@@ -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
@@ -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
|
||||
@@ -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>;
|
||||
|
||||
@@ -77,19 +77,25 @@ def successful_jobs(run, required):
|
||||
attempt = run.get("run_attempt")
|
||||
if not isinstance(attempt, int) or attempt < 1:
|
||||
raise GateError(f"run {run['id']} has no valid attempt number")
|
||||
data = api(f"actions/runs/{run['id']}/attempts/{attempt}/jobs?per_page=100")
|
||||
jobs = data.get("jobs") or []
|
||||
if data.get("total_count") != len(jobs):
|
||||
raise GateError(f"run {run['id']} has more jobs than the gate inspected")
|
||||
names = [job.get("name") for job in jobs]
|
||||
if len(names) != len(set(names)) or not required.issubset(names):
|
||||
latest_jobs = None
|
||||
for number in range(1, attempt + 1):
|
||||
data = api(f"actions/runs/{run['id']}/attempts/{number}/jobs?per_page=100")
|
||||
jobs = data.get("jobs") or []
|
||||
if data.get("total_count") != len(jobs):
|
||||
raise GateError(f"run {run['id']} attempt {number} has more jobs than the gate inspected")
|
||||
names = [job.get("name") for job in jobs]
|
||||
if not jobs or len(names) != len(set(names)):
|
||||
raise GateError(f"run {run['id']} attempt {number} has missing or duplicate jobs")
|
||||
bad = [f"{job.get('name')}: {job.get('status')}/{job.get('conclusion')}"
|
||||
for job in jobs if job.get("status") != "completed" or job.get("conclusion") != "success"]
|
||||
if bad:
|
||||
raise GateError(f"run {run['id']} attempt {number} has unfinished, failed, cancelled, or skipped jobs: {bad}")
|
||||
latest_jobs = jobs
|
||||
names = [job.get("name") for job in latest_jobs]
|
||||
if not required.issubset(names):
|
||||
missing = sorted(required.difference(names))
|
||||
raise GateError(f"run {run['id']} has missing or duplicate jobs: {missing}")
|
||||
bad = [f"{job.get('name')}: {job.get('status')}/{job.get('conclusion')}"
|
||||
for job in jobs if job.get("status") != "completed" or job.get("conclusion") != "success"]
|
||||
if bad:
|
||||
raise GateError(f"run {run['id']} has unfinished, failed, cancelled, or skipped jobs: {bad}")
|
||||
print(f"gate passed: {run.get('html_url')} ({len(jobs)} jobs)")
|
||||
raise GateError(f"run {run['id']} has missing jobs in its latest attempt: {missing}")
|
||||
print(f"gate passed: {run.get('html_url')} ({len(latest_jobs)} jobs, {attempt} attempts)")
|
||||
|
||||
|
||||
def main(tag):
|
||||
|
||||
@@ -107,9 +107,16 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
|
||||
self.assertEqual((catalogue[0]['version'], catalogue[0]['sha256']), ('1.0.0', HASHES[0]))
|
||||
status, _, _ = api('POST', '/api/decky/plugin', {'action':'install','id':999})
|
||||
self.assertEqual(status, 400) # Third-party code needs explicit acknowledgement.
|
||||
job = finished(ok('POST', '/api/decky/plugin',
|
||||
{'action':'install','id':999,'acknowledged':True}))
|
||||
request = {'action':'install','id':999,'acknowledged':True,
|
||||
'requestId':'0123456789abcdef' * 2}
|
||||
started = ok('POST', '/api/decky/plugin', request)
|
||||
self.assertEqual(ok('POST', '/api/decky/plugin', request)['job'], started['job'])
|
||||
status, conflict, _ = api('POST', '/api/decky/plugin', {**request, 'id':998})
|
||||
self.assertEqual(status, 409)
|
||||
self.assertIn('already used', conflict['error'])
|
||||
job = finished(started)
|
||||
self.assertFalse(job.get('error'), job)
|
||||
self.assertEqual(ok('POST', '/api/decky/plugin', request)['job'], started['job'])
|
||||
self.assertEqual(ok('GET', '/api/decky')['plugins'][0]['version'], '1.0.0')
|
||||
self.assertIn('Sample 1.0.0', ssh('cat ~/.local/share/frame-control/decky/plugins/sample-panel/dist/index.js'))
|
||||
receipt = HASHES[0]
|
||||
|
||||
+8
-3
@@ -91,17 +91,22 @@ elif method == "GET" and endpoint.startswith(repo + "actions/workflows/"):
|
||||
is_main = workflow != "release.yml"
|
||||
run_id = {"checks.yml": 501, "release.yml": 502, "decky-runtime.yml": 503}[workflow]
|
||||
run = {"id": run_id, "head_sha": gate.get("main_sha" if is_main else "release_sha", commit),
|
||||
"head_branch": branch, "event": "push", "run_attempt": 1,
|
||||
"head_branch": branch, "event": "push", "run_attempt": gate.get("release_attempts", 1) if run_id == 502 else 1,
|
||||
"status": gate.get("decky_status" if run_id == 503 else ("main_status" if is_main else "release_status"), "completed"),
|
||||
"conclusion": gate.get("decky_conclusion" if run_id == 503 else ("main_conclusion" if is_main else "release_conclusion"), "success"),
|
||||
"html_url": "https://github.com/saphid/frame-control/actions/runs/%s" % run_id}
|
||||
print(json.dumps({"workflow_runs": [run]}))
|
||||
elif method == "GET" and endpoint.startswith(repo + "actions/runs/"):
|
||||
run_id = endpoint[len(repo + "actions/runs/"):].split("/")[0]
|
||||
parts = endpoint[len(repo + "actions/runs/"):].split("/")
|
||||
run_id = parts[0]
|
||||
attempt = int(parts[2])
|
||||
names = {"501": main_jobs, "502": release_jobs, "503": decky_jobs}[run_id]
|
||||
names = [name for name in names if name != gate.get("omit_job")]
|
||||
jobs = [{"name": name, "status": "completed",
|
||||
"conclusion": gate.get("bad_job_conclusion", "success") if name == gate.get("bad_job") else "success"}
|
||||
"conclusion": (gate.get("prior_bad_job_conclusion", "failure")
|
||||
if run_id == "502" and attempt == 1 and name == gate.get("prior_bad_job")
|
||||
else gate.get("bad_job_conclusion", "success") if name == gate.get("bad_job")
|
||||
else "success")}
|
||||
for name in names]
|
||||
print(json.dumps({"total_count": len(jobs), "jobs": jobs}))
|
||||
elif method == "GET" and endpoint.startswith(repo + "releases?"):
|
||||
|
||||
@@ -269,6 +269,14 @@ nodes(tabs,'button')[2].props.onClick();
|
||||
assert.deepEqual(tabChanges,['one']);
|
||||
assert.equal(window.DFL.Spinner({}).props.role,'status');
|
||||
assert.equal(window.DFL.ProgressBar({nProgress:120}).props.value,100);
|
||||
assert.equal(window.DFL.ProgressBar({nProgress:40,indeterminate:true}).props.value,undefined,
|
||||
'an indeterminate progress element must omit its value');
|
||||
assert.equal(window.DFL.ProgressBarItem({nProgress:35}).props.value,35);
|
||||
const progressInfo=window.DFL.ProgressBarWithInfo({indeterminate:true,
|
||||
sOperationText:'Downloading plugin'});
|
||||
assert.equal(nodes(progressInfo,'progress')[0].props.value,undefined);
|
||||
assert.equal(nodes(progressInfo,'small')[0].props.role,'status');
|
||||
assert.equal(nodes(progressInfo,'small')[0].children[0],'Downloading plugin');
|
||||
assert.equal(window.DFL.joinClassNames('one',null,'two'),'one two');
|
||||
let eventCount=0;
|
||||
const unsubscribe=window.SteamClient.GameSessions.RegisterForAppLifetimeNotifications(()=>eventCount++);
|
||||
@@ -480,11 +488,47 @@ assert.equal(window.DFL.useParams().view,'art');
|
||||
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123/art/extra'),false);
|
||||
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123?admin=1'),false);
|
||||
assert.equal(window.DFL.Navigation.Navigate('/plugin/game//art'),false);
|
||||
assert.equal(window.DFL.Navigation.NavigateBack(),true);
|
||||
assert.equal(window.DFL.useParams().appid,'123');
|
||||
assert.equal(window.DFL.useParams().view,undefined,'Back returns to the previous local page');
|
||||
assert.equal(window.DFL.Navigation.NavigateBack(),true);
|
||||
assert.equal(window.DFL.useParams().appid,undefined,'A second Back returns to the plugin root');
|
||||
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123'),true);
|
||||
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123'),true);
|
||||
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123/art'),true);
|
||||
assert.equal(window.DFL.Navigation.NavigateBack(),true);
|
||||
assert.equal(window.DFL.useParams().view,undefined,'Navigating to the same page did not add a history entry');
|
||||
assert.equal(window.DFL.Navigation.NavigateBack(),true);
|
||||
assert.equal(window.DFL.useParams().appid,undefined);
|
||||
assert.throws(()=>api.routerHook.addRoute('/plugin/:id/:id',GameRoute));
|
||||
assert.throws(()=>api.routerHook.addRoute('/plugin/:id?/art',GameRoute));
|
||||
api.routerHook.addRoute('/plugin/other',LocalRoute);
|
||||
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123'),true);
|
||||
assert.equal(window.DFL.Navigation.Navigate('/plugin/other'),true);
|
||||
removeGame();
|
||||
assert.equal(window.DFL.Navigation.NavigateBack(),true);
|
||||
assert.equal(nodes(window.__surface({plugin}),LocalRoute).length,0,
|
||||
'Back skips a route that the plugin unregistered');
|
||||
assert.equal(window.DFL.useParams().appid,undefined);
|
||||
assert.equal(nodes(window.__surface({plugin}),GameRoute).length,0);
|
||||
api.routerHook.removeRoute('/plugin/other');
|
||||
api.routerHook.addRoute('/plugin/first',LocalRoute);
|
||||
api.routerHook.addRoute('/plugin/second',LocalRoute);
|
||||
for(let index=0;index<40;index++)
|
||||
assert.equal(window.DFL.Navigation.Navigate(index%2 ? '/plugin/first' : '/plugin/second'),true);
|
||||
for(let index=0;index<32;index++)window.DFL.Navigation.NavigateBack();
|
||||
assert.equal(nodes(window.__surface({plugin}),LocalRoute).length,1);
|
||||
window.DFL.Navigation.NavigateBack();
|
||||
assert.equal(nodes(window.__surface({plugin}),LocalRoute).length,0,
|
||||
'local route history is bounded and falls back to the plugin root');
|
||||
window.DFL.Navigation.Navigate('/plugin/first');
|
||||
window.DFL.Navigation.Navigate('/plugin/second');
|
||||
window.DFL.Navigation.CloseSideMenus();
|
||||
window.DFL.Navigation.NavigateBack();
|
||||
assert.equal(nodes(window.__surface({plugin}),LocalRoute).length,0,
|
||||
'CloseSideMenus clears the local route history');
|
||||
api.routerHook.removeRoute('/plugin/first');
|
||||
api.routerHook.removeRoute('/plugin/second');
|
||||
|
||||
// Event subscription is acknowledged before a method that immediately emits.
|
||||
const onMessage=listeners.find(x=>x.type==='message').handler;
|
||||
|
||||
@@ -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 {
|
||||
@@ -79,6 +80,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);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {webcrypto} from 'node:crypto';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
@@ -17,6 +18,7 @@ const document = {getElementById(id) {if(!elements.has(id)) elements.set(id,new
|
||||
createElement() {return new Element();}, hidden:false};
|
||||
const session = new Map();
|
||||
const ctx = vm.createContext({document, location:{hash:'',pathname:'/'},
|
||||
crypto:webcrypto,
|
||||
sessionStorage:{getItem:key=>session.get(key)||'',setItem:(key,value)=>session.set(key,value),removeItem:key=>session.delete(key)},
|
||||
history:{replaceState(){}}, confirm:()=>true,
|
||||
window:{addEventListener(){}}, setInterval(){}, setTimeout(){},
|
||||
@@ -61,6 +63,45 @@ assert.equal(el('deckyJobProgress').value,100);
|
||||
assert.equal(el('deckyJobCheck').hidden,true);
|
||||
assert.equal(session.has('frameyPluginJob'),false);
|
||||
|
||||
// If the server accepts the POST but its response is lost, the panel already
|
||||
// has the ID needed to find the job. It must not offer a second install.
|
||||
let lostJobId, lostPosts=0;
|
||||
ctx.mockApi=async (path,body) => {
|
||||
if(path==='/api/decky/plugin'){
|
||||
lostPosts++; lostJobId=body.requestId;
|
||||
assert.match(lostJobId,/^[0-9a-f]{32}$/);
|
||||
throw Error('POST response lost');
|
||||
}
|
||||
if(path.startsWith('/api/job')){
|
||||
assert.equal(new URL(path,'http://localhost').searchParams.get('id'),lostJobId);
|
||||
return {done:true,message:'Recovered install'};
|
||||
}
|
||||
if(path==='/api/decky')return {managed:true,running:true,plugins:[]};
|
||||
throw Error(`unexpected ${path}`);
|
||||
};
|
||||
vm.runInContext('api=mockApi',ctx);
|
||||
await vm.runInContext('deckyChange("install",{id:2,name:"Plugin 2",sha256:"a".repeat(64)})',ctx);
|
||||
assert.equal(JSON.parse(session.get('frameyPluginJob')).job,lostJobId);
|
||||
assert.equal(el('deckyJobCheck').hidden,false);
|
||||
await vm.runInContext('deckyChange("install",{id:2,name:"Plugin 2",sha256:"a".repeat(64)})',ctx);
|
||||
assert.equal(lostPosts,1);
|
||||
await vm.runInContext('checkDeckyJob()',ctx);
|
||||
assert.equal(el('deckyJobStatus').textContent,'Recovered install');
|
||||
assert.equal(session.has('frameyPluginJob'),false);
|
||||
|
||||
// A definite validation rejection never reserved a job on the server.
|
||||
ctx.mockApi=async path => {
|
||||
if(path==='/api/decky/plugin'){
|
||||
const error=Error('Invalid reviewed store version');error.status=400;throw error;
|
||||
}
|
||||
if(path==='/api/decky')return {managed:true,running:true,plugins:[]};
|
||||
throw Error(`unexpected ${path}`);
|
||||
};
|
||||
vm.runInContext('api=mockApi',ctx);
|
||||
await vm.runInContext('deckyChange("install",{id:2,name:"Plugin 2",sha256:"bad"})',ctx);
|
||||
assert.equal(session.has('frameyPluginJob'),false);
|
||||
assert.equal(el('deckyJobCheck').hidden,true);
|
||||
|
||||
// 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'}]} :
|
||||
@@ -149,7 +190,7 @@ ctx.mockApi=async path => {
|
||||
};
|
||||
vm.runInContext('api=mockApi',ctx);
|
||||
await vm.runInContext('checkDeckyJob()',ctx);
|
||||
assert.match(el('deckyJobStatus').textContent,/app restarted and lost this job result/);
|
||||
assert.match(el('deckyJobStatus').textContent,/server has no record of this request/);
|
||||
assert.equal(el('deckyJobCheck').hidden,true);
|
||||
assert.equal(session.has('frameyPluginJob'),false);
|
||||
assert.equal(el('deckyStoreList').children[0].children[1].disabled,false);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Store requests can reconnect when the headset panel loses the POST response."""
|
||||
import sandbox # noqa: F401
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
||||
import server
|
||||
|
||||
|
||||
class StoreJobs(unittest.TestCase):
|
||||
def test_same_request_id_runs_once_and_conflicting_reuse_is_rejected(self):
|
||||
request_id = 'a' * 32
|
||||
started = threading.Event()
|
||||
finish = threading.Event()
|
||||
calls = []
|
||||
|
||||
def change(_ssh, action, plugin_id, report, reviewed_hash=None):
|
||||
calls.append((action, plugin_id, reviewed_hash))
|
||||
started.set()
|
||||
self.assertTrue(finish.wait(5))
|
||||
report('Installed', 100)
|
||||
return {'message': 'Installed once'}
|
||||
|
||||
body = {'action': 'install', 'id': 2, 'sha256': 'b' * 64,
|
||||
'acknowledged': True, 'requestId': request_id}
|
||||
try:
|
||||
with patch.object(server.frame_decky_store, 'change', side_effect=change):
|
||||
first = server.decky_plugin(body)
|
||||
self.assertTrue(started.wait(5))
|
||||
self.assertEqual(server.decky_plugin(body), first)
|
||||
for changed in ({'action': 'update'}, {'id': 3}, {'sha256': 'c' * 64}):
|
||||
with self.subTest(changed=changed), self.assertRaises(server.Failure) as error:
|
||||
server.decky_plugin({**body, **changed})
|
||||
self.assertEqual(error.exception.status, 409)
|
||||
self.assertEqual(calls, [('install', 2, 'b' * 64)])
|
||||
self.assertNotIn('_request', server.job_status('id=' + request_id))
|
||||
finish.set()
|
||||
for _ in range(100):
|
||||
if server.job_status('id=' + request_id)['done']:
|
||||
break
|
||||
time.sleep(0.01)
|
||||
self.assertEqual(server.job_status('id=' + request_id)['message'], 'Installed once')
|
||||
self.assertEqual(server.decky_plugin(body), first)
|
||||
self.assertEqual(len(calls), 1)
|
||||
finally:
|
||||
finish.set()
|
||||
with server._jobs_lock:
|
||||
server._jobs.pop(request_id, None)
|
||||
|
||||
def test_invalid_request_id_does_not_start_work(self):
|
||||
with patch.object(server.frame_decky_store, 'change') as change:
|
||||
for request_id in (1, '', 'A' * 32, 'f' * 31, 'f' * 33, '../' * 11):
|
||||
with self.subTest(request_id=request_id), self.assertRaises(server.Failure) as error:
|
||||
server.decky_plugin({'action': 'install', 'id': 2,
|
||||
'acknowledged': True, 'requestId': request_id})
|
||||
self.assertEqual(error.exception.status, 400)
|
||||
change.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -134,6 +134,17 @@ class PublishRelease(unittest.TestCase):
|
||||
self.assertIsNone(upload)
|
||||
self.assertFalse(any("-X" in call for call in log))
|
||||
|
||||
def test_refuses_a_prior_cancelled_installer_after_successful_rerun(self):
|
||||
gates = {"release_attempts": 2,
|
||||
"prior_bad_job": "build (macos-latest, dist, app/dist/*.dmg app/dist/*.zip)",
|
||||
"prior_bad_job_conclusion": "cancelled"}
|
||||
p, log, upload = self.run_script([draft()], "v9.8.7", gates=gates)
|
||||
self.assertNotEqual(p.returncode, 0)
|
||||
self.assertIn("attempt 1", p.stderr)
|
||||
self.assertIn("cancelled", p.stderr)
|
||||
self.assertIsNone(upload)
|
||||
self.assertFalse(any("-X" in call for call in log))
|
||||
|
||||
def test_refuses_missing_or_nonexact_gate_results(self):
|
||||
for gates in ({"omit_job": "e2e"}, {"main_sha": "c" * 40},
|
||||
{"decky_conclusion": "failure"}, {"release_conclusion": "failure"},
|
||||
|
||||
+27
-8
@@ -56,6 +56,7 @@
|
||||
const pending = new Map();
|
||||
const routes = new Map();
|
||||
const routeListeners = new Set();
|
||||
const routeHistory = [];
|
||||
let currentRoute = null;
|
||||
let currentParams = {};
|
||||
let nextId = 0;
|
||||
@@ -283,8 +284,10 @@
|
||||
title && R.createElement('h2', null, title), R.createElement(Tabs, {
|
||||
tabs:pages.filter(page=>page.visible!==false).map((page,index)=>({
|
||||
id:page.route || String(index), title:page.title, content:page.content}))}));
|
||||
const ProgressBar = ({nProgress=0, value, max=100}) => R.createElement('progress', {
|
||||
value:Math.max(0,Math.min(max,Number(value ?? nProgress)||0)), max});
|
||||
const ProgressBar = ({nProgress=0, value, max=100, indeterminate=false}) => R.createElement('progress', {
|
||||
value:indeterminate ? undefined : Math.max(0,Math.min(max,Number(value ?? nProgress)||0)), max});
|
||||
const ProgressBarWithInfo = ({sOperationText, ...props}) => R.createElement('div', null,
|
||||
ProgressBar(props), sOperationText == null ? null : R.createElement('small', {role:'status'}, sOperationText));
|
||||
const MenuItem = ({children, disabled, bDisabled, onClick, onSelected}) =>
|
||||
R.createElement('button', {type:'button',disabled:!!(disabled || bDisabled),
|
||||
onClick:event=>{if(!disabled && !bDisabled)(onClick || onSelected)?.(event);}}, children);
|
||||
@@ -373,15 +376,31 @@
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function navigate(path) {
|
||||
if (path !== null && !localPath(path)) return false;
|
||||
const match = path === null ? null : activeRoute(path);
|
||||
if (path !== null && !match) return false;
|
||||
function showRoute(path, match) {
|
||||
currentRoute = path;
|
||||
currentParams = match?.params || {};
|
||||
for (const listener of routeListeners) listener(path);
|
||||
return true;
|
||||
}
|
||||
function navigate(path) {
|
||||
if (path !== null && !localPath(path)) return false;
|
||||
const match = path === null ? null : activeRoute(path);
|
||||
if (path !== null && !match) return false;
|
||||
if (path === null) routeHistory.length = 0;
|
||||
else if (path !== currentRoute) {
|
||||
routeHistory.push(currentRoute);
|
||||
if (routeHistory.length > 32) routeHistory.shift();
|
||||
}
|
||||
return showRoute(path, match);
|
||||
}
|
||||
function navigateBack() {
|
||||
while (routeHistory.length) {
|
||||
const previous = routeHistory.pop();
|
||||
const match = previous === null ? null : activeRoute(previous);
|
||||
if (previous === null || match) return showRoute(previous, match);
|
||||
}
|
||||
return navigate(null);
|
||||
}
|
||||
const routerHook = {
|
||||
addRoute(path, component) {
|
||||
if (!routePattern(path) ||
|
||||
@@ -489,14 +508,14 @@
|
||||
DialogControlsSectionHeader:passthrough('h3'), ModalRoot:passthrough('section'),
|
||||
ModalPosition:passthrough('div'),
|
||||
Menu:passthrough('div'), MenuGroup:passthrough('section'), MenuItem,
|
||||
ProgressBar, ProgressBarWithInfo:ProgressBar,
|
||||
ProgressBar, ProgressBarItem:ProgressBar, ProgressBarWithInfo,
|
||||
gamepadDialogClasses:{}, quickAccessMenuClasses:{}, gamepadTabbedPageClasses:{},
|
||||
gamepadSliderClasses:{}, gamepadContextMenuClasses:{}, scrollPanelClasses:{},
|
||||
quickAccessControlsClasses:{}, mainMenuAppRunningClasses:{}, libraryAssetImageClasses:{},
|
||||
joinClassNames:(...names)=>names.filter(Boolean).join(' '),
|
||||
sleep:ms=>new Promise(resolve=>setTimeout(resolve,Math.max(0,Number(ms)||0))),
|
||||
staticClasses: {Title:'framey-title'},
|
||||
Navigation: {Navigate:navigate, NavigateBack:()=>navigate(null), CloseSideMenus:()=>navigate(null),
|
||||
Navigation: {Navigate:navigate, NavigateBack:navigateBack, CloseSideMenus:()=>navigate(null),
|
||||
NavigateToExternalWeb: url => { if (/^https:\/\//.test(url)) window.open(url, '_blank', 'noopener'); }},
|
||||
showModal, showContextMenu:showModal,
|
||||
// Steam app, library, and store patches have no target inside this panel.
|
||||
|
||||
+12
-7
@@ -72,7 +72,8 @@ const byId=id=>document.getElementById(id);
|
||||
let latestStatus=null, activeExtension=null, recordBusy=false;
|
||||
async function api(path,body){const response=await fetch(path,{method:body?'POST':'GET',
|
||||
headers:{'X-Frame-UI':key,'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});
|
||||
const data=await response.json();if(!response.ok)throw Error(data.error||'Frame request failed');return data;}
|
||||
const data=await response.json();if(!response.ok){const error=Error(data.error||'Frame request failed');
|
||||
error.status=response.status;throw error;}return data;}
|
||||
async function refresh(){try{const status=latestStatus=await api('/api/status');
|
||||
byId('battery').textContent=status.battery?.percent==null?'Unavailable':`${status.battery.percent}%`;
|
||||
const free=status.disk?.home?.free;
|
||||
@@ -107,9 +108,8 @@ async function refreshGames(){const list=byId('games');try{const data=await api(
|
||||
row.append(name,quit);list.append(row);}if(!data.running.length)list.textContent='No games are running.';
|
||||
}catch(error){list.textContent=error.message;}}
|
||||
function compatibilityText(plugin){const c=plugin.compatibility;return c ? `${c.status === 'na' ? 'N/A' : c.status}: ${c.reason}${c.verification?.length ? ' Verified: '+c.verification.join(', ')+'.' : ''}` : 'Unverified on Frame';}
|
||||
// Some Chromium profiles deny sessionStorage. Keep the accepted job ID in
|
||||
// memory so this open panel can still reconnect instead of offering a second
|
||||
// install while the first one may be running.
|
||||
// Some Chromium profiles deny sessionStorage. Keep the reserved request ID in
|
||||
// memory so this open panel can still reconnect if the POST response is lost.
|
||||
let deckyPendingJob=null;
|
||||
function savedDeckyJob(){
|
||||
if(deckyPendingJob)return deckyPendingJob;
|
||||
@@ -124,6 +124,8 @@ function clearDeckyJob(){
|
||||
try{sessionStorage.removeItem('frameyPluginJob');}catch(_){}
|
||||
}
|
||||
function deckyJobPending(){return !!savedDeckyJob();}
|
||||
function newDeckyRequestId(){return Array.from(crypto.getRandomValues(new Uint8Array(16)),
|
||||
byte=>byte.toString(16).padStart(2,'0')).join('');}
|
||||
let deckyState=null, deckyStore=[], deckyStoreIndex=0, deckyStoreRequest=0,
|
||||
deckyStatusRequest=0,
|
||||
activeFrameyId=null, activeFrameyReceipt=null, frameySession=null, deckyBusy=false;
|
||||
@@ -200,7 +202,7 @@ async function followDeckyJob(job,action,name){
|
||||
catch(error){
|
||||
if(/no such job/i.test(error.message)){
|
||||
clearDeckyJob();
|
||||
throw Error('The app restarted and lost this job result. Refresh installed plugins to see what changed.');
|
||||
throw Error('The server has no record of this request. It may not have arrived, or the app may have restarted. Refresh installed plugins before trying again.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -224,14 +226,17 @@ async function deckyChange(action,plugin){if(deckyBusy)return;
|
||||
if(panelClosed)closeFrameyPanel();
|
||||
deckyJobView(`${action} ${plugin.name}`,
|
||||
panelClosed?'Starting… Old plugin panel closed until the change finishes.':'Starting…',0);
|
||||
const requestId=newDeckyRequestId();
|
||||
rememberDeckyJob({job:requestId,action,name:plugin.name});
|
||||
try{const started=await api('/api/decky/plugin',{action,id:plugin.id,
|
||||
sha256:action==='remove'?undefined:plugin.sha256,acknowledged:true});
|
||||
sha256:action==='remove'?undefined:plugin.sha256,acknowledged:true,requestId});
|
||||
if(!started.job)throw Error('The server did not return a job ID');
|
||||
rememberDeckyJob({job:started.job,action,name:plugin.name});
|
||||
await followDeckyJob(started.job,action,plugin.name);
|
||||
clearDeckyJob();
|
||||
if(panelNote)byId('deckyJobStatus').textContent+=panelNote;
|
||||
}catch(error){deckyJobView(`${action} ${plugin.name}`,`Error: ${error.message}${panelNote}`,null,true);}
|
||||
}catch(error){if(error.status>=400&&error.status<500)clearDeckyJob();
|
||||
deckyJobView(`${action} ${plugin.name}`,`Error: ${error.message}${panelNote}`,null,true);}
|
||||
finally{deckyBusy=false;await refreshDecky();}
|
||||
}
|
||||
async function checkDeckyJob(){const saved=savedDeckyJob();
|
||||
|
||||
+25
-6
@@ -345,11 +345,16 @@ def backfill_art(apps=(), titles=(), meant=None):
|
||||
return True
|
||||
|
||||
|
||||
def _new_job_locked(label, **fields):
|
||||
def _prune_jobs_locked():
|
||||
now = time.time()
|
||||
for j in [j for j, v in _jobs.items() if v["done"] and now - v["time"] > JOB_TTL]:
|
||||
del _jobs[j]
|
||||
job = secrets.token_hex(8)
|
||||
|
||||
|
||||
def _new_job_locked(label, job_id=None, **fields):
|
||||
_prune_jobs_locked()
|
||||
now = time.time()
|
||||
job = job_id or secrets.token_hex(8)
|
||||
_jobs[job] = {"label": label, "done": False, "error": None, "message": None, "result": None, "time": now, **fields}
|
||||
return job
|
||||
|
||||
@@ -376,10 +381,18 @@ def _run_job(job, label, work, progress):
|
||||
_jobs[job].update(fields, done=True, time=time.time())
|
||||
|
||||
|
||||
def start_job(label, work, progress=False):
|
||||
def start_job(label, work, progress=False, *, request_id=None, request=None):
|
||||
"""Run work() in the background. It returns a dict with a "message"."""
|
||||
with _jobs_lock:
|
||||
job = _new_job_locked(label)
|
||||
if request_id is not None:
|
||||
_prune_jobs_locked()
|
||||
existing = _jobs.get(request_id)
|
||||
if existing:
|
||||
if existing.get('_request') != request:
|
||||
raise Failure('This plugin request ID was already used for a different change', 409)
|
||||
return {"message": f"{label}…", "job": request_id}
|
||||
job = _new_job_locked(label, job_id=request_id,
|
||||
**({'_request': request} if request_id is not None else {}))
|
||||
busy_thread(_run_job, job, label, work, progress).start()
|
||||
return {"message": f"{label}…", "job": job}
|
||||
|
||||
@@ -462,7 +475,7 @@ def queue_action(body):
|
||||
def job_status(query):
|
||||
with _jobs_lock:
|
||||
job = _jobs.get((parse_qs(query).get("id") or [""])[0])
|
||||
snapshot = job and {k: v for k, v in job.items() if k != "time"}
|
||||
snapshot = job and {k: v for k, v in job.items() if k not in ("time", "_request")}
|
||||
if not snapshot:
|
||||
raise Failure("no such job (the app may have restarted)", 404)
|
||||
return snapshot
|
||||
@@ -572,9 +585,15 @@ def decky_plugin(body):
|
||||
if body.get('acknowledged') is not True:
|
||||
raise Failure('Confirm that store plugins run code as your Frame user', 400)
|
||||
reviewed_hash = body.get('sha256')
|
||||
request_id = body.get('requestId')
|
||||
if request_id is not None and (not isinstance(request_id, str) or
|
||||
not re.fullmatch(r'[0-9a-f]{32}', request_id)):
|
||||
raise Failure('Invalid plugin request ID', 400)
|
||||
device = LINK.active_device()['id'] if LINK else FRAME
|
||||
return start_job('Decky plugin ' + action,
|
||||
lambda report: frame_decky_store.change(ssh, action, plugin_id, report,
|
||||
reviewed_hash=reviewed_hash), progress=True)
|
||||
reviewed_hash=reviewed_hash), progress=True,
|
||||
request_id=request_id, request=(device, action, plugin_id, reviewed_hash))
|
||||
|
||||
|
||||
def framey_method_action(body):
|
||||
|
||||
Reference in new issue
Block a user