mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-27 03:01:52 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92a84ea683 | ||
|
|
f31c6871e4 | ||
|
|
efc55919b6 | ||
|
|
285b92573a | ||
|
|
80d3244b05 | ||
|
|
e9844473ea | ||
|
|
33838da03a | ||
|
|
2970f5da61 | ||
|
|
5741ae6243 | ||
|
|
6cfaabbe8f | ||
|
|
0905842ded | ||
|
|
b3de6ecf71 | ||
|
|
f4cc9ce0a1 | ||
|
|
617661b7a2 | ||
|
|
38db6a2846 | ||
|
|
a17ebf96fe | ||
|
|
6247dfbf89 | ||
|
|
02fec484d6 | ||
|
|
7ed59a9b25 | ||
|
|
847509fa7e | ||
|
|
175b814e1b | ||
|
|
134ceea2b7 | ||
|
|
1824b54140 | ||
|
|
14d677f527 | ||
|
|
fe8a860afb | ||
|
|
c6fec613d6 | ||
|
|
72f8152525 | ||
|
|
62e27a2450 | ||
|
|
4c3381f006 | ||
|
|
1cd1c07b7a | ||
|
|
55fb9226ba | ||
|
|
5418c6f094 | ||
|
|
14f9506e74 | ||
|
|
0447fe217c | ||
|
|
4967b13dca | ||
|
|
0026095096 | ||
|
|
1309a0883d | ||
|
|
04cde721c8 | ||
|
|
d1a4270b45 | ||
|
|
1e6d742b98 | ||
|
|
818ec2dcba | ||
|
|
1ddb6278b8 | ||
|
|
cbc20f0933 | ||
|
|
b8d57eb6bd | ||
|
|
4252d22975 | ||
|
|
5438e1d9d0 | ||
|
|
9418a340eb | ||
|
|
a8bf01d660 | ||
|
|
5e3f9559c7 | ||
|
|
a31b4a426c | ||
|
|
ff465accc5 | ||
|
|
d9c2cc3f27 | ||
|
|
910d847573 | ||
|
|
89f986b91c | ||
|
|
3ca8de985e |
No files matched your search
@@ -0,0 +1,32 @@
|
||||
name: Decky backend runtime
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
decky-runtime:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, ubuntu-24.04-arm]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Actual Decky 3.3.0 discovery and safe backend probes
|
||||
run: python3 scripts/framey-test-decky-runtime.py --output "$RUNNER_TEMP/decky-runtime.json"
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: decky-runtime-${{ matrix.os }}
|
||||
path: ${{ runner.temp }}/decky-runtime.json
|
||||
if-no-files-found: warn
|
||||
@@ -6,6 +6,7 @@ ship other people's software, unchanged, each under its own licence:
|
||||
| What | Where | Licence | Details |
|
||||
|---|---|---|---|
|
||||
| KDE Connect 24.02.2 and five libraries, as Valve builds them for the Frame | Desktop apps and the iPhone app; copied to the Frame for the keyboard and trackpad | GPL and LGPL (per package) | [frame/kdeconnect/NOTICE.md](frame/kdeconnect/NOTICE.md), licence texts in [frame/kdeconnect/LICENSES](frame/kdeconnect/LICENSES), complete source in the [kdeconnect-frame-24.02.2-1 release](https://github.com/saphid/frame-control/releases/tag/kdeconnect-frame-24.02.2-1) |
|
||||
| psutil 7.2.2, unchanged stable-ABI Linux amd64/ARM64 wheels | Desktop and headset app bundles; copied only into exact managed DeckySpy 0.6.7 installs | BSD-3-Clause | [pins and provenance](ui/vendor/psutil-7.2.2/README.md), [license](ui/vendor/psutil-7.2.2/LICENSE.txt) |
|
||||
| Python 3.12 ([python-build-standalone](https://github.com/astral-sh/python-build-standalone)) | Desktop apps | PSF License and others | Included with it, in the app's `python` folder |
|
||||
| adb (Android SDK Platform-Tools) | Desktop apps (not Linux arm64) | Apache-2.0 and others | `NOTICE.txt` in the app's `tools` folder |
|
||||
| Mozilla's CA certificate list, as published by curl | Desktop apps | MPL-2.0 | https://curl.se/docs/caextract.html |
|
||||
|
||||
@@ -13,6 +13,9 @@ const path = require("path");
|
||||
|
||||
const REQUIRED = [
|
||||
"ui/server.py",
|
||||
"ui/vendor/psutil-7.2.2/aarch64.whl",
|
||||
"ui/vendor/psutil-7.2.2/x86_64.whl",
|
||||
"ui/vendor/psutil-7.2.2/LICENSE.txt",
|
||||
"ui/frame_power.py",
|
||||
"ui/frame_status.py",
|
||||
"scripts/frame_power_sample.py",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.47",
|
||||
"version": "0.4.49",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.47",
|
||||
"version": "0.4.49",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+6
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.47",
|
||||
"version": "0.4.49",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
@@ -60,6 +60,11 @@
|
||||
"framey-compatibility.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../ui/vendor/psutil-7.2.2",
|
||||
"to": "ui/vendor/psutil-7.2.2",
|
||||
"filter": ["*.whl", "LICENSE.txt", "README.md"]
|
||||
},
|
||||
{
|
||||
"from": "../ui/apk_sources",
|
||||
"to": "ui/apk_sources",
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"timeUtc": "2026-10-26T22:28:36.276360+00:00",
|
||||
"storeId": 89,
|
||||
"version": "0.6.7",
|
||||
"officialSha256": "2d431a89826ed5cf3fd37258373eb3b46c841c39795aa8b25f83e59f4eee87c7",
|
||||
"runtime": {
|
||||
"version": "v3.3.0",
|
||||
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
|
||||
},
|
||||
"architecture": "aarch64",
|
||||
"scope": "Read-only system monitoring, a silent local warning and private plugin settings; live ARM64 backend through local opaque Chromium. No worn VR.",
|
||||
"backend": {
|
||||
"successfulMethods": [
|
||||
"get_battery",
|
||||
"get_boottime",
|
||||
"get_cpu",
|
||||
"get_memory",
|
||||
"get_net_interface",
|
||||
"get_settings",
|
||||
"get_top_k_mem_procs",
|
||||
"get_version"
|
||||
],
|
||||
"memoryTotalMatchedProcMeminfo": true,
|
||||
"batteryPercent": 100,
|
||||
"noNetworkOrProcessStateChanged": true
|
||||
},
|
||||
"browser": {
|
||||
"scope": "local opaque Chromium with live actual Frame ARM64 Decky 3.3.0",
|
||||
"checks": {
|
||||
"cpuVisible": true,
|
||||
"memoryVisible": true,
|
||||
"battery100": true,
|
||||
"processVisible": true,
|
||||
"networkVisible": true,
|
||||
"version067": true,
|
||||
"silentWarning": true,
|
||||
"intervalChangedTo6": true,
|
||||
"reopenRetainedInterval6": true,
|
||||
"reopenedLiveArm64Metrics": true
|
||||
}
|
||||
},
|
||||
"cleanup": {
|
||||
"baseAbsent": true,
|
||||
"unitAbsent": true,
|
||||
"archiveAbsent": true
|
||||
},
|
||||
"independentCleanupAudit": {
|
||||
"baseAbsent": true,
|
||||
"unitAbsent": true,
|
||||
"archiveAbsent": true,
|
||||
"service": "MainPID=0\nLoadState=not-found\nActiveState=inactive\n",
|
||||
"lockAbsent": true
|
||||
},
|
||||
"batteryBefore": [
|
||||
100
|
||||
],
|
||||
"batteryAfter": [
|
||||
100
|
||||
],
|
||||
"originalServiceStatesPreserved": true,
|
||||
"lockReleased": true,
|
||||
"fakeFrame": "Pinned official install/remove transaction suite passed, including dependency receipt and files; backend execution stubbed.",
|
||||
"localRuntime": "Actual published Decky 3.3.0 amd64: all five cases passed, including historical bad-shim negative control.",
|
||||
"tests": {
|
||||
"python": {
|
||||
"run": 1649,
|
||||
"skipped": 16,
|
||||
"result": "passed"
|
||||
},
|
||||
"fakeFramePinnedTransactions": "passed",
|
||||
"node24": {
|
||||
"run": 46,
|
||||
"result": "passed"
|
||||
}
|
||||
},
|
||||
"unverified": [
|
||||
"Automatic low-battery/OOM alerts",
|
||||
"Resume callbacks and game-time warnings",
|
||||
"Sound",
|
||||
"Worn VR and physical-controller input"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
# DeckySpy system monitoring on Frame
|
||||
|
||||
Official ID 89, DeckySpy 0.6.7, SHA-256
|
||||
`2d431a89826ed5cf3fd37258373eb3b46c841c39795aa8b25f83e59f4eee87c7`.
|
||||
[Redacted results](framey-deckyspy-2026-10-26.json).
|
||||
|
||||
The official archive imports `psutil` but does not ship it. Published Decky
|
||||
3.3.0 reproduced `ModuleNotFoundError: No module named 'psutil'` during backend
|
||||
startup. The previous catalog's account/external-integration reason was wrong:
|
||||
this plugin monitors local system metrics; its token is a local reload guard.
|
||||
|
||||
Exact managed installs now include unmodified, SHA-256-pinned BSD-3-Clause
|
||||
psutil 7.2.2 stable-ABI wheels for amd64 and ARM64. The native architecture is
|
||||
selected remotely and its ELF header checked. Dependencies and their license
|
||||
stay in this plugin's `py_modules`; no pip, network download, system-library
|
||||
changes or other plugin imports are involved. [Provenance and license](../../ui/vendor/psutil-7.2.2/README.md).
|
||||
Existing copies need an explicit managed update or reinstall. Installed receipts
|
||||
without `deckyspy-psutil-and-battery` stay partial and display that requirement;
|
||||
they do not inherit the fixed catalog status.
|
||||
|
||||
On Frame OS 0.5.6, upstream psutil also misses `max1720x_bat_7-36`: its Linux
|
||||
battery discovery filters names for `BAT` or `battery`. The plugin-local
|
||||
fallback preserves native psutil results, then reads supplies whose `type` is
|
||||
`Battery`. It reads capacity/status and optional time-to-empty in Linux sysfs
|
||||
seconds, leaves unknown states unknown, and never writes sysfs.
|
||||
|
||||
Verification used the **published Decky 3.3.0 executable**, not a hand-built
|
||||
Plugin instance. Its actual legacy websocket route passed version, CPU,
|
||||
memory, battery, uptime, process and interface reads on the real ARM64 Frame.
|
||||
The memory total exactly matched `/proc/meminfo`. No process/network controls
|
||||
were run. Before starting, the test confirmed template migration sources were
|
||||
absent so upstream's template migrations could not move existing user data.
|
||||
|
||||
Local headless Chromium rendered the exact official frontend in Framey's
|
||||
opaque iframe, connected through a single trusted SSH RPC pipe to that live
|
||||
ARM64 backend. CPU/memory/swap/uptime, 100% battery, process rows and interfaces
|
||||
rendered. A test OOM warning produced a silent visible toast. Native Chromium
|
||||
keyboard input changed only the private plugin refresh interval from 5 to 6;
|
||||
backend setting/commit calls and a fresh panel reopening confirmed persistence.
|
||||
No real media, volume, power, network or process settings changed.
|
||||
|
||||
The shared `/tmp/frame-test.lock` protected all device execution. The private
|
||||
user service had a runtime limit and was stopped/removed, along with its base
|
||||
and upload. An independent SSH audit confirmed no unit/base/upload or running
|
||||
service remained. Battery stayed 100 → 100. Normal managed Decky was failed and
|
||||
the headset panel inactive as found; the lock was released. No headset wipe,
|
||||
normal service restart or installation outside the removable private user
|
||||
service occurred. Raw process names, addresses and local guard tokens were
|
||||
kept out of published evidence.
|
||||
|
||||
Local verification also passed all five actual-Decky amd64 regression cases,
|
||||
the focused fake-Frame pinned install/remove transaction (backend execution
|
||||
stubbed), 1,649 Python tests (16 skips), and 46 app tests with Node 24. The
|
||||
native ARM64 CI gate additionally runs discovery/public calls for this shim.
|
||||
|
||||
**Works** covers useful read-only system monitoring, a silent test warning and
|
||||
private settings while the panel is open. Automatic OOM/low-battery warnings,
|
||||
resume callbacks, game-time warnings, sound, worn VR and physical controllers
|
||||
remain unverified. No claim is made for persistent monitoring after closing
|
||||
Framey's isolated panel.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Emuchievements ARM64 hash helper feasibility, 26 October 2026
|
||||
|
||||
The exact official Emuchievements 2.0.5 archive (store ID 28, SHA-256
|
||||
`98dcb443e81b9f9011cfe77c69eb659869c8e2df2df8b0a443df66cbfc2f5a89`)
|
||||
contains an x86_64 `Emuchievements/bin/hash`. Frame Control still blocks
|
||||
that archive on ARM64; this experiment did not install the plugin.
|
||||
|
||||
The upstream source commit
|
||||
[`8567e94befffc943434b2adb842487274815f8bb`](https://github.com/EmuDeck/Emuchievements/commit/8567e94befffc943434b2adb842487274815f8bb)
|
||||
declares version 2.0.5. Its `src/py/main.py` matches the published ZIP's
|
||||
`Emuchievements/main.py` byte for byte (SHA-256
|
||||
`d4e72f136c94e48919575937ec4190f28864cc259eb111ef47f0df99f5912645`).
|
||||
The pinned build dependencies at that commit are libarchive
|
||||
`b3be94ba4f36943745e64a6a0a3c21c2d1281d3c`, libchdr
|
||||
`a20e04c12c0c20048bf771da6df7aa2e51f5e0cb`, and rcheevos
|
||||
`85a3209398e576d79170f569fcddf277640f3f06`.
|
||||
|
||||
On a separate Linux host, the version-matched source and submodules built an
|
||||
x86_64 `hash` with CMake. It and the official x86_64 binary both returned
|
||||
`ff78ca6c21ac11504dbddbd5d4e7524a` for one synthetic `.rom` file. An
|
||||
isolated Debian cross-build using `gcc-aarch64-linux-gnu` and
|
||||
`g++-aarch64-linux-gnu` produced an AArch64 binary with SHA-256
|
||||
`9c7aedb80bdd64941aa5dd9b41f9bb8471411c812e04c7ee7d60486779d99997`.
|
||||
It links to `liblzma.so.5`, `libstdc++.so.6`, `libgcc_s.so.1`, and `libc.so.6`.
|
||||
|
||||
A real Frame test acquired `/tmp/frame-test.lock` atomically with a unique
|
||||
owner before reading the device. SteamVR activity was **3** (standby),
|
||||
proximity raw **3.0625**, and battery **100%**. The ARM64 helper ran against
|
||||
the same synthetic ROM in a private `/tmp` directory, exited 0, and returned
|
||||
the same hash. The owned directory and lock were removed. No normal service,
|
||||
power setting, Steam library, real ROM, or installed plugin was changed.
|
||||
The separate host's source/build directory was also removed after the test.
|
||||
|
||||
This proves only a single-file helper path. The official ZIP remains blocked
|
||||
because it still bundles x86_64 bytes. A release-ready port needs a packaged
|
||||
ARM64 helper with GPL-3.0 source and license compliance, format-specific hash
|
||||
coverage (including archives and CHD), a managed plugin transaction, actual
|
||||
Decky backend and frontend checks, and cleanup. RetroAchievements login,
|
||||
real ROM scanning, wearer-visible VR, and physical controller use remain
|
||||
unverified.
|
||||
@@ -0,0 +1,807 @@
|
||||
{
|
||||
"timeUtc": "2026-10-26T21:15:13.918421+00:00",
|
||||
"plugins": [
|
||||
{
|
||||
"id": 12,
|
||||
"name": "MusicControl",
|
||||
"version": "1.1.6",
|
||||
"sha256": "246b89fd653c60a735b1cc401d1b0937d5cb969eea9024561661ff741f081d62",
|
||||
"backend": "Python",
|
||||
"frontendApi": "legacy"
|
||||
},
|
||||
{
|
||||
"id": 111,
|
||||
"name": "Music Control",
|
||||
"version": "1.1.7",
|
||||
"sha256": "3f56a3f56034b277ea74a2c37cc84133b69e382aa6bcb0b43876776aac737ed9",
|
||||
"backend": "Python",
|
||||
"frontendApi": "legacy"
|
||||
}
|
||||
],
|
||||
"regressions": {
|
||||
"removedBoundMethodShim": {
|
||||
"result": "Plugin._get_dbus_env() missing 1 required positional argument: 'self'",
|
||||
"success": false
|
||||
},
|
||||
"realFrameSubprocessDiagnostic": {
|
||||
"uid": 1000,
|
||||
"ldLibrary": "<Decky PyInstaller extraction directory>",
|
||||
"results": [
|
||||
{
|
||||
"shell": false,
|
||||
"code": 0,
|
||||
"hasArray": true,
|
||||
"stderr": ""
|
||||
},
|
||||
{
|
||||
"shell": true,
|
||||
"code": 1,
|
||||
"hasArray": false,
|
||||
"stderr": "/bin/sh: /usr/lib/libc.so.6: version `GLIBC_2.42' not found (required by <Decky PyInstaller extraction directory>/libtinfo.so.6)\n"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"realFrame": {
|
||||
"scope": "real Frame ARM64 Decky3.3; only discovery and selected-player read, no player controls",
|
||||
"runtime": {
|
||||
"version": "v3.3.0",
|
||||
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
|
||||
},
|
||||
"before": {
|
||||
"arch": "aarch64",
|
||||
"uid": 1000,
|
||||
"decky": "failed",
|
||||
"panel": "inactive",
|
||||
"artLinkPresent": false,
|
||||
"imagesParentPresent": true,
|
||||
"battery": [
|
||||
100
|
||||
]
|
||||
},
|
||||
"installs": [
|
||||
{
|
||||
"id": 12,
|
||||
"receipt": {
|
||||
"storeId": 12,
|
||||
"sha256": "246b89fd653c60a735b1cc401d1b0937d5cb969eea9024561661ff741f081d62",
|
||||
"version": "1.1.6",
|
||||
"compatShims": [
|
||||
"music-control-system-dbus-env"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 111,
|
||||
"receipt": {
|
||||
"storeId": 111,
|
||||
"sha256": "3f56a3f56034b277ea74a2c37cc84133b69e382aa6bcb0b43876776aac737ed9",
|
||||
"version": "1.1.7",
|
||||
"compatShims": [
|
||||
"music-control-system-dbus-env"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"calls": [
|
||||
{
|
||||
"id": 12,
|
||||
"method": "sp_list_media_players",
|
||||
"success": true,
|
||||
"playerCount": 0
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"method": "sp_get_media_player",
|
||||
"response": {
|
||||
"result": "org.mpris.MediaPlayer2.spotify",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 111,
|
||||
"method": "sp_list_media_players",
|
||||
"success": true,
|
||||
"playerCount": 0
|
||||
},
|
||||
{
|
||||
"id": 111,
|
||||
"method": "sp_get_media_player",
|
||||
"response": {
|
||||
"result": "org.mpris.MediaPlayer2.spotify",
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"cleanup": {
|
||||
"baseAbsent": true,
|
||||
"unitAbsent": true,
|
||||
"artLinkAbsent": true
|
||||
},
|
||||
"after": {
|
||||
"arch": "aarch64",
|
||||
"uid": 1000,
|
||||
"decky": "failed",
|
||||
"panel": "inactive",
|
||||
"artLinkPresent": false,
|
||||
"imagesParentPresent": true,
|
||||
"battery": [
|
||||
100
|
||||
]
|
||||
},
|
||||
"lockReleased": true
|
||||
},
|
||||
"isolatedRuntime": {
|
||||
"version": "v3.3.0",
|
||||
"architecture": "amd64",
|
||||
"sha256": "e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181",
|
||||
"isolation": "Docker network none; no host audio/D-Bus/home/device mounts; dropped capabilities; private dbus-daemon and silent synthetic MPRIS service; archives and scripts read-only"
|
||||
},
|
||||
"backendFixtureChecks": [
|
||||
{
|
||||
"id": 12,
|
||||
"version": "1.1.6",
|
||||
"calls": [
|
||||
{
|
||||
"method": "sp_list_media_players",
|
||||
"kwargs": {},
|
||||
"response": {
|
||||
"result": "org.mpris.MediaPlayer2.FrameySilent",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_set_media_player",
|
||||
"kwargs": {
|
||||
"player": "org.mpris.MediaPlayer2.FrameySilent"
|
||||
},
|
||||
"response": {
|
||||
"result": null,
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_get_media_player",
|
||||
"kwargs": {},
|
||||
"response": {
|
||||
"result": "org.mpris.MediaPlayer2.FrameySilent",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_identity",
|
||||
"kwargs": {
|
||||
"orgPath": "org.mpris.MediaPlayer2.FrameySilent"
|
||||
},
|
||||
"response": {
|
||||
"result": "Player",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "get_meta_data",
|
||||
"kwargs": {},
|
||||
"response": {
|
||||
"result": "trackid|/org/framey/TestTrack/1\nlength|180000000\ntitle|Silent Test Track\nartist|Fixture Artist\nalbum|Framey Test Album\n",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_track_status",
|
||||
"kwargs": {},
|
||||
"response": {
|
||||
"result": "Paused",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_track_progress",
|
||||
"kwargs": {},
|
||||
"response": {
|
||||
"result": "12000000",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_can_seek",
|
||||
"kwargs": {},
|
||||
"response": {
|
||||
"result": "true",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_get_volume",
|
||||
"kwargs": {},
|
||||
"response": {
|
||||
"result": "0.4",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_test_volume_control",
|
||||
"kwargs": {},
|
||||
"response": {
|
||||
"result": "true",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_play",
|
||||
"kwargs": {},
|
||||
"response": {
|
||||
"result": "",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_track_status",
|
||||
"kwargs": {},
|
||||
"response": {
|
||||
"result": "Playing",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_pause",
|
||||
"kwargs": {},
|
||||
"response": {
|
||||
"result": "",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_track_status",
|
||||
"kwargs": {},
|
||||
"response": {
|
||||
"result": "Paused",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_next",
|
||||
"kwargs": {},
|
||||
"response": {
|
||||
"result": "",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_previous",
|
||||
"kwargs": {},
|
||||
"response": {
|
||||
"result": "",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_seek",
|
||||
"kwargs": {
|
||||
"amount": 2000000
|
||||
},
|
||||
"response": {
|
||||
"result": "",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_track_progress",
|
||||
"kwargs": {},
|
||||
"response": {
|
||||
"result": "14000000",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_set_position",
|
||||
"kwargs": {
|
||||
"position": 45000000,
|
||||
"trackid": "/org/framey/TestTrack/1"
|
||||
},
|
||||
"response": {
|
||||
"result": "",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_track_progress",
|
||||
"kwargs": {},
|
||||
"response": {
|
||||
"result": "45000000",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_set_volume",
|
||||
"kwargs": {
|
||||
"volume": 0.25
|
||||
},
|
||||
"response": {
|
||||
"result": "method return time=1793049278.417118 sender=:1.0 -> destination=:1.19 serial=20 reply_serial=2\n",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_get_volume",
|
||||
"kwargs": {},
|
||||
"response": {
|
||||
"result": "0.25",
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"independentFixtureState": {
|
||||
"PlaybackStatus": "Paused",
|
||||
"Position": 45000000,
|
||||
"Volume": 0.25,
|
||||
"next": 1,
|
||||
"previous": 1,
|
||||
"writes": [
|
||||
[
|
||||
"PlayPause"
|
||||
],
|
||||
[
|
||||
"Pause"
|
||||
],
|
||||
[
|
||||
"Next"
|
||||
],
|
||||
[
|
||||
"Previous"
|
||||
],
|
||||
[
|
||||
"Seek",
|
||||
2000000
|
||||
],
|
||||
[
|
||||
"SetPosition",
|
||||
45000000
|
||||
],
|
||||
[
|
||||
"Volume",
|
||||
0.25
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 111,
|
||||
"version": "1.1.7",
|
||||
"calls": [
|
||||
{
|
||||
"method": "sp_list_media_players",
|
||||
"kwargs": {},
|
||||
"response": {
|
||||
"result": "org.mpris.MediaPlayer2.FrameySilent",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_set_media_player",
|
||||
"kwargs": {
|
||||
"player": "org.mpris.MediaPlayer2.FrameySilent"
|
||||
},
|
||||
"response": {
|
||||
"result": null,
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_get_media_player",
|
||||
"kwargs": {},
|
||||
"response": {
|
||||
"result": "org.mpris.MediaPlayer2.FrameySilent",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_identity",
|
||||
"kwargs": {
|
||||
"orgPath": "org.mpris.MediaPlayer2.FrameySilent"
|
||||
},
|
||||
"response": {
|
||||
"result": "Player",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "get_meta_data",
|
||||
"kwargs": {},
|
||||
"response": {
|
||||
"result": "trackid|/org/framey/TestTrack/1\nlength|180000000\ntitle|Silent Test Track\nartist|Fixture Artist\nalbum|Framey Test Album",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_track_status",
|
||||
"kwargs": {},
|
||||
"response": {
|
||||
"result": "Paused",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_track_progress",
|
||||
"kwargs": {},
|
||||
"response": {
|
||||
"result": "12000000",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_can_seek",
|
||||
"kwargs": {},
|
||||
"response": {
|
||||
"result": "true",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_get_volume",
|
||||
"kwargs": {},
|
||||
"response": {
|
||||
"result": "0.4",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_test_volume_control",
|
||||
"kwargs": {},
|
||||
"response": {
|
||||
"result": "true",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_play",
|
||||
"kwargs": {},
|
||||
"response": {
|
||||
"result": "method return time=1793049473.137015 sender=:1.0 -> destination=:1.12 serial=13 reply_serial=2\n",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_track_status",
|
||||
"kwargs": {},
|
||||
"response": {
|
||||
"result": "Playing",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_pause",
|
||||
"kwargs": {},
|
||||
"response": {
|
||||
"result": "method return time=1793049473.340963 sender=:1.0 -> destination=:1.14 serial=15 reply_serial=2\n",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_track_status",
|
||||
"kwargs": {},
|
||||
"response": {
|
||||
"result": "Paused",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_next",
|
||||
"kwargs": {},
|
||||
"response": {
|
||||
"result": "method return time=1793049473.547273 sender=:1.0 -> destination=:1.16 serial=17 reply_serial=2\n",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_previous",
|
||||
"kwargs": {},
|
||||
"response": {
|
||||
"result": "method return time=1793049473.649558 sender=:1.0 -> destination=:1.17 serial=18 reply_serial=2\n",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_seek",
|
||||
"kwargs": {
|
||||
"amount": 2000000
|
||||
},
|
||||
"response": {
|
||||
"result": "method return time=1793049473.752790 sender=:1.0 -> destination=:1.18 serial=19 reply_serial=2\n",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_track_progress",
|
||||
"kwargs": {},
|
||||
"response": {
|
||||
"result": "14000000",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_set_position",
|
||||
"kwargs": {
|
||||
"position": 45000000,
|
||||
"trackid": "/org/framey/TestTrack/1"
|
||||
},
|
||||
"response": {
|
||||
"result": "method return time=1793049473.965468 sender=:1.0 -> destination=:1.20 serial=21 reply_serial=2\n",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_track_progress",
|
||||
"kwargs": {},
|
||||
"response": {
|
||||
"result": "45000000",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_set_volume",
|
||||
"kwargs": {
|
||||
"volume": 0.25
|
||||
},
|
||||
"response": {
|
||||
"result": "method return time=1793049474.175137 sender=:1.0 -> destination=:1.22 serial=23 reply_serial=2\n",
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sp_get_volume",
|
||||
"kwargs": {},
|
||||
"response": {
|
||||
"result": "0.25",
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"independentFixtureState": {
|
||||
"PlaybackStatus": "Paused",
|
||||
"Position": 45000000,
|
||||
"Volume": 0.25,
|
||||
"next": 1,
|
||||
"previous": 1,
|
||||
"writes": [
|
||||
[
|
||||
"Volume",
|
||||
0.01
|
||||
],
|
||||
[
|
||||
"Volume",
|
||||
0.4
|
||||
],
|
||||
[
|
||||
"PlayPause"
|
||||
],
|
||||
[
|
||||
"Pause"
|
||||
],
|
||||
[
|
||||
"Next"
|
||||
],
|
||||
[
|
||||
"Previous"
|
||||
],
|
||||
[
|
||||
"Seek",
|
||||
2000000
|
||||
],
|
||||
[
|
||||
"SetPosition",
|
||||
45000000
|
||||
],
|
||||
[
|
||||
"Volume",
|
||||
0.25
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"browserChecks": [
|
||||
{
|
||||
"id": 12,
|
||||
"scope": "opaque local Chromium + real amd64 Decky3.3 + private silent MPRIS",
|
||||
"checks": [
|
||||
{
|
||||
"rendered": "Music Control\nCurrently Playing\nSilent Test Track\nFixture Artist\nPlayback Volume\nPlayer\nInfo"
|
||||
},
|
||||
{
|
||||
"clickedButton": 0,
|
||||
"independentReadback": {
|
||||
"PlaybackStatus": "Paused",
|
||||
"Position": 45000000,
|
||||
"Volume": 0.25,
|
||||
"next": 1,
|
||||
"previous": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"clickedButton": 2,
|
||||
"independentReadback": {
|
||||
"PlaybackStatus": "Paused",
|
||||
"Position": 45000000,
|
||||
"Volume": 0.25,
|
||||
"next": 2,
|
||||
"previous": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"clickedButton": 1,
|
||||
"independentReadback": {
|
||||
"PlaybackStatus": "Playing",
|
||||
"Position": 45000000,
|
||||
"Volume": 0.25,
|
||||
"next": 2,
|
||||
"previous": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"clickedButton": 1,
|
||||
"independentReadback": {
|
||||
"PlaybackStatus": "Paused",
|
||||
"Position": 45000000,
|
||||
"Volume": 0.25,
|
||||
"next": 2,
|
||||
"previous": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"slider": 0,
|
||||
"value": 0.5,
|
||||
"independentReadback": {
|
||||
"PlaybackStatus": "Paused",
|
||||
"Position": 90000000,
|
||||
"Volume": 0.25,
|
||||
"next": 2,
|
||||
"previous": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"slider": 1,
|
||||
"value": 60,
|
||||
"independentReadback": {
|
||||
"PlaybackStatus": "Paused",
|
||||
"Position": 90000000,
|
||||
"Volume": 0.6,
|
||||
"next": 2,
|
||||
"previous": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"infoRoute": "Music Control\nBack to plugin\nMusicControl\nInfoUsageTroubleshooting\nMusicControl allows you to control any media playback that is currently running that implements the MPRIS dbus interface.\nExamples of application that are supported:\nSpotify\nCider\nChrome / Firefox\nStrawberry\nOnly the flatpak versions of these applications have been tested, and not all of them might support the full feature set, so it is recommended to use the discover store to install the applications."
|
||||
},
|
||||
{
|
||||
"reopened": "Music Control\nCurrently Playing\nSilent Test Track\nFixture Artist\nPlayer\nInfo",
|
||||
"independentReadback": {
|
||||
"PlaybackStatus": "Paused",
|
||||
"Position": 90000000,
|
||||
"Volume": 0.6,
|
||||
"next": 2,
|
||||
"previous": 2
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 111,
|
||||
"scope": "opaque local Chromium + real amd64 Decky3.3 + private silent MPRIS",
|
||||
"checks": [
|
||||
{
|
||||
"rendered": "Music Control\nCurrently Playing\nSilent Test Track\nFixture Artist\nPlayback Volume\nPlayer\nInfo"
|
||||
},
|
||||
{
|
||||
"clickedButton": 0,
|
||||
"independentReadback": {
|
||||
"PlaybackStatus": "Paused",
|
||||
"Position": 45000000,
|
||||
"Volume": 0.25,
|
||||
"next": 1,
|
||||
"previous": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"clickedButton": 2,
|
||||
"independentReadback": {
|
||||
"PlaybackStatus": "Paused",
|
||||
"Position": 45000000,
|
||||
"Volume": 0.25,
|
||||
"next": 2,
|
||||
"previous": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"clickedButton": 1,
|
||||
"independentReadback": {
|
||||
"PlaybackStatus": "Playing",
|
||||
"Position": 45000000,
|
||||
"Volume": 0.25,
|
||||
"next": 2,
|
||||
"previous": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"clickedButton": 1,
|
||||
"independentReadback": {
|
||||
"PlaybackStatus": "Paused",
|
||||
"Position": 45000000,
|
||||
"Volume": 0.25,
|
||||
"next": 2,
|
||||
"previous": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"slider": 0,
|
||||
"value": 0.5,
|
||||
"independentReadback": {
|
||||
"PlaybackStatus": "Paused",
|
||||
"Position": 90000000,
|
||||
"Volume": 0.25,
|
||||
"next": 2,
|
||||
"previous": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"slider": 1,
|
||||
"value": 60,
|
||||
"independentReadback": {
|
||||
"PlaybackStatus": "Paused",
|
||||
"Position": 90000000,
|
||||
"Volume": 0.6,
|
||||
"next": 2,
|
||||
"previous": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"infoRoute": "Music Control\nBack to plugin\nMusicControl\nInfoUsageTroubleshooting\nMusicControl allows you to control any media playback that is currently running that implements the MPRIS dbus interface.\nExamples of application that are supported:\nSpotify\nCider\nChrome / Firefox\nStrawberry\nOnly the flatpak versions of these applications have been tested, and not all of them might support the full feature set, so it is recommended to use the discover store to install the applications."
|
||||
},
|
||||
{
|
||||
"reopened": "Music Control\nCurrently Playing\nSilent Test Track\nFixture Artist\nPlayer\nInfo",
|
||||
"independentReadback": {
|
||||
"PlaybackStatus": "Paused",
|
||||
"Position": 90000000,
|
||||
"Volume": 0.6,
|
||||
"next": 2,
|
||||
"previous": 2
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"limitations": [
|
||||
"Real Frame had no running MPRIS players. No real media transport or volume writes ran.",
|
||||
"The private fixture emits no audio and does not prove playback in a supported media app.",
|
||||
"Album art, physical controller, VR keyboard and worn VR were not tested.",
|
||||
"Both upstream backends truncate multiword Identity to its final word.",
|
||||
"Music Control 1.1.7 automatically probes player volume by writing 0.01 and restoring it; this ran only against the private fixture.",
|
||||
"Existing installed copies require remove/reinstall or an explicit managed update."
|
||||
],
|
||||
"validation": {
|
||||
"pythonSuite": {
|
||||
"run": 1639,
|
||||
"skipped": 16,
|
||||
"passed": true
|
||||
},
|
||||
"fakeFrame": {
|
||||
"total": 116,
|
||||
"results": {
|
||||
"blocked": 55,
|
||||
"installed-removed": 61
|
||||
},
|
||||
"backendExecution": false
|
||||
},
|
||||
"currentStore": {
|
||||
"checkedAtUtc": "2026-10-26T21:17:39.609721+00:00",
|
||||
"plugins": 116,
|
||||
"allPinnedDigestsUnchanged": true
|
||||
},
|
||||
"independentCleanupAudit": "MPRIS_CLEANUP_AUDIT_PASS: shared lock, test services/runtime directories and owned cache link absent",
|
||||
"pinnedHttpSshFlow": {
|
||||
"command": "scripts/e2e.sh test_framey",
|
||||
"passed": 2,
|
||||
"seconds": 32.59,
|
||||
"scope": "pinned official archive receipt assertions and sample install/update/remove safeguards"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
# MusicControl / Music Control runtime checks, 26 October 2026
|
||||
|
||||
Both official entries remain **partial**. Their pure-Python backends need a
|
||||
user-session MPRIS player and `dbus-send`, rather than Deck-specific hardware or
|
||||
an x86 binary. [Results](framey-mpris-runtime-2026-10-26.json) pin both archives
|
||||
and the actual Decky 3.3.0 executables used.
|
||||
|
||||
Two runtime failures were reproduced and repaired:
|
||||
|
||||
- The #335 `music-control-bound-methods` transformation was incorrect. Neither
|
||||
manifest declares `api_version`, so Decky uses `module.Plugin` and explicitly
|
||||
supplies the class as `self`. The earlier test used `module.Plugin()` and
|
||||
therefore diagnosed a fixture error as an upstream bug. In actual Decky,
|
||||
the transformation breaks discovery with `_get_dbus_env() missing ... self`
|
||||
and transport with `_sp_player_dbus() missing ... parameters`. Managed installs
|
||||
now retain the original class calls. This supersedes the earlier
|
||||
[bound-method evidence](framey-music-control-bound-methods-2026-10-26.md).
|
||||
- On Frame OS 0.5.6, MusicControl 1.1.6's `/bin/sh` subprocess inherits Decky's
|
||||
PyInstaller library path. Its bundled `libtinfo.so.6` needs `GLIBC_2.42`, which
|
||||
the Frame's libc lacks. A private diagnostic backend confirmed the same
|
||||
read-only D-Bus command fails with a shell and succeeds without one. Both
|
||||
exact-archive backends now restore `LD_LIBRARY_PATH_ORIG` (or an empty path)
|
||||
only in their copied D-Bus subprocess environment. The Decky process keeps
|
||||
its original environment. The receipt records `music-control-system-dbus-env`.
|
||||
|
||||
The patch is gated by store ID, SHA-256 and folder, and checks the expected
|
||||
source before editing. Existing installed copies need **remove/reinstall** or
|
||||
an explicit managed update; a desktop update alone does not rewrite them.
|
||||
The store still shows partial, with the same reason as the docs table.
|
||||
|
||||
Actual Decky 3.3.0 amd64 ran each repaired production-managed install separately
|
||||
in a container with no network, host audio, host D-Bus, home or device access.
|
||||
A private `dbus-daemon` hosted a silent synthetic MPRIS service. Discovery,
|
||||
selection, identity, metadata, status, progress, volume/CanSeek reads,
|
||||
play/pause, next/previous, seek, track position and volume writes passed through
|
||||
the real legacy websocket RPC envelope. The service's own state independently
|
||||
confirmed each write. The shared plugin folder prevents co-installation; each
|
||||
entry was tested in a fresh container.
|
||||
|
||||
Local headless Chromium loaded the shipped `headset.html` and exact official
|
||||
frontends inside their ordinary opaque `allow-scripts` iframe. The relay used
|
||||
actual Decky backends and allowed only the private fixture. Track/artist
|
||||
rendering, previous/next, play/pause, seek to 90 seconds, volume to 60%, Info
|
||||
navigation and close/reopen passed for both entries. Chromium retained its
|
||||
normal process isolation and sandboxed-origin restrictions. No external media
|
||||
or artwork request was permitted.
|
||||
|
||||
Under the shared `/tmp/frame-test.lock`, a removable private Decky 3.3.0 ARM64
|
||||
user service loaded each corrected managed install on the real Frame.
|
||||
Read-only `sp_list_media_players` returned an empty string successfully for
|
||||
both, and `sp_get_media_player` returned their default Spotify bus name.
|
||||
These final checks used Decky's direct loopback websocket through SSH; they
|
||||
do not claim the deployed Framey HTTP/CEF route. Earlier CEF attempts encountered
|
||||
transient reinjection failures. No player controls ran on the real Frame.
|
||||
The private units, runtimes and owned Steam image-cache link were removed;
|
||||
independent follow-up reads confirmed cleanup and lock release. Battery was
|
||||
100 → 100. Existing managed Decky remained failed and the headset panel inactive,
|
||||
as found. Power settings were not changed.
|
||||
|
||||
Remaining gaps: actual media-app playback/volume on ARM64, local-file album-art
|
||||
routing, physical controls and worn VR. Both backends truncate multiword player
|
||||
names to their last word. Music Control 1.1.7 automatically probes volume by
|
||||
writing 0.01 and restoring it; this was exercised only on the silent fixture.
|
||||
|
||||
Validation: full Python suite **1,639 tests, 16 skipped**; focused managed-store
|
||||
suite **23/23**; shared frontend action checks; all **116** store digests unchanged.
|
||||
The complete fake-Frame audit passed: **61 installed/removed, 55 rejected**
|
||||
for documented safeguards; official backends are stubbed in that harness. The
|
||||
regression test uses Decky's actual legacy class convention for both entries,
|
||||
checks original/absent library-path restoration without changing the parent
|
||||
environment, and rejects transferring the patch to a different archive digest.
|
||||
|
||||
The pinned HTTP/SSH flow also passed `scripts/e2e.sh test_framey`: **2/2**
|
||||
tests in 32.590 seconds. Its receipt assertion covers the replacement shim for
|
||||
both Music Control entries; the old assertion caused the first hosted e2e
|
||||
failure and left a plugin installed, cascading into the following test.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Superseded: Music Control bound-method fixture
|
||||
|
||||
The #335 result was incorrect. Its test instantiated `module.Plugin()`, while
|
||||
actual Decky 3.3.0 uses the class `module.Plugin` for this legacy manifest and
|
||||
supplies the class as `self`. The published backend's explicit-self calls are
|
||||
correct in that runtime. Removing those arguments breaks actual Decky discovery
|
||||
and transport.
|
||||
|
||||
The follow-up [real runtime checks](framey-mpris-runtime-2026-10-26.md) reproduce
|
||||
the regression, remove the incorrect transformation and replace the test with
|
||||
the actual legacy calling convention. They also identify and repair a separate
|
||||
Frame OS system-command library-path failure. Existing copies installed with
|
||||
`music-control-bound-methods` must be removed/reinstalled or explicitly updated.
|
||||
Both official Music Control entries remain partial; the follow-up describes
|
||||
exactly what passed and what remains unverified.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Native plugin packaging blockers, 26 October 2026
|
||||
|
||||
This is a static, digest-scoped follow-up. Neither plugin was installed or
|
||||
run on the Frame. Their compatibility statuses remain **broken**.
|
||||
|
||||
## Decky Cloud Save 1.4.2, store ID 49
|
||||
|
||||
The official ZIP SHA-256 is
|
||||
`b311d967ddf1b0f0046d36929645855891a40173e3a93f6f0cd8b1576b04b411`.
|
||||
It was downloaded and hash-checked without executing any member. Of its 27
|
||||
members, `decky-cloud-save/bin/rclone` is a regular executable of **60,776,600
|
||||
bytes** with ELF machine **62 (x86_64)**. The installer permits at most 30 MiB
|
||||
per member, so this exact ZIP fails before native architecture validation.
|
||||
Raising that limit alone would still leave an x86_64 executable on the ARM64
|
||||
Frame. No cloud credentials, saves, reads or writes were exercised.
|
||||
|
||||
## Emuchievements 2.0.5, store ID 28
|
||||
|
||||
The official ZIP SHA-256 is
|
||||
`98dcb443e81b9f9011cfe77c69eb659869c8e2df2df8b0a443df66cbfc2f5a89`.
|
||||
The [earlier helper check](framey-emuchievements-arm-2026-10-26.md) proved
|
||||
one synthetic ROM hash on ARM64, without installing the plugin. The upstream
|
||||
source commit `8567e94befffc943434b2adb842487274815f8bb` and its pinned
|
||||
libarchive, libchdr and rcheevos submodules were checked out separately for
|
||||
this audit. The source is GPL-3.0. An isolated Debian ARM64 cross-build made a
|
||||
2.6 MB helper (`c8794558a0d75294930f969a426e7d2ac13a27e3c1ff9bd8aae093e31596b9bd`),
|
||||
but it was **not shipped or run on ROM archives**. A complete source bundle
|
||||
without Git metadata compressed to about 8 MB; source and license distribution
|
||||
would be part of any release-ready port.
|
||||
|
||||
The proposed source has an additional blocker. `backend/src/util.cpp` builds
|
||||
an extraction destination by appending each raw archive member pathname to
|
||||
an owned `/tmp/tmpdir.*` prefix, then passes it to libarchive's disk writer.
|
||||
It neither rejects `..`/absolute paths nor enables libarchive's secure path
|
||||
flags. `backend/src/main.cpp` later removes the extracted file's parent path.
|
||||
With an untrusted archive, those paths may leave the intended temporary
|
||||
directory. The plugin's Python backend also invokes `bin/hash` through a
|
||||
shell with a path argument. A port needs bounded path validation, secure
|
||||
extraction/cleanup, safe subprocess arguments, format coverage and actual
|
||||
Decky/backend tests before its official ZIP can be transformed or installed.
|
||||
|
||||
The official archive still contains an x86_64 helper. RetroAchievements
|
||||
login, real ROM scanning, a packaged ARM64 backend, wearer-visible VR and
|
||||
physical controller use remain unverified.
|
||||
@@ -0,0 +1,119 @@
|
||||
{
|
||||
"timeUtc": "2026-10-26T23:23:16.239735+00:00",
|
||||
"plugin": {
|
||||
"id": 96,
|
||||
"version": "1.0.0",
|
||||
"sha256": "973d927e9d9a1feec04679c564af7e389a729b6735d2a988ffc2cb46ce4eb312"
|
||||
},
|
||||
"scope": "Silent private timer settings/events only; local Chromium with actual Frame ARM64 backend. No sound, physical power actions or worn VR.",
|
||||
"runtime": {
|
||||
"version": "v3.3.0",
|
||||
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
|
||||
},
|
||||
"realFrameLifecycle": {
|
||||
"lifecycle": {
|
||||
"freshCancel": true,
|
||||
"replacementSuppressesOldCompletion": true,
|
||||
"completionClearsEnd": true,
|
||||
"activeRestartPreservesDeadlineAndRecents": true,
|
||||
"canceledRestartIdle": true,
|
||||
"expiredRestartIdle": true
|
||||
},
|
||||
"actualDecky330": true
|
||||
},
|
||||
"browser": {
|
||||
"uiBegin300": true,
|
||||
"uiCancel": true,
|
||||
"liveCountdown": true,
|
||||
"completionToast": true,
|
||||
"subtleMode": true
|
||||
},
|
||||
"iframeSandbox": "allow-scripts allow-popups; no allow-same-origin",
|
||||
"isolatedNativeAmd64": [
|
||||
{
|
||||
"discovered": [
|
||||
{
|
||||
"name": "Simple Timer",
|
||||
"version": "1.0.0",
|
||||
"load_type": 1,
|
||||
"disabled": false
|
||||
}
|
||||
],
|
||||
"lifecycle": {
|
||||
"freshCancel": true,
|
||||
"replacementSuppressesOldCompletion": true,
|
||||
"completionClearsEnd": true,
|
||||
"lateExpiryEmitsWithoutSelfCancel": true,
|
||||
"activeRestartPreservesDeadlineAndRecents": true,
|
||||
"canceledRestartIdle": true,
|
||||
"expiredRestartIdle": true
|
||||
},
|
||||
"result": "discovery, cancel, replace, completion and persistent restart passed"
|
||||
},
|
||||
{
|
||||
"discovered": [
|
||||
{
|
||||
"name": "Simple Timer",
|
||||
"version": "1.0.0",
|
||||
"load_type": 1,
|
||||
"disabled": false
|
||||
}
|
||||
],
|
||||
"lifecycle": {
|
||||
"freshCancelError": {
|
||||
"type": -1,
|
||||
"id": 2,
|
||||
"error": {
|
||||
"name": "Exception",
|
||||
"message": "'Plugin' object has no attribute 'timer_task'",
|
||||
"traceback": "Traceback (most recent call last):\n File \"decky_loader/wsrouter.py\", line 70, in _call_route\n File \"decky_loader/loader.py\", line 232, in handle_plugin_method_call\n File \"decky_loader/loader.py\", line 229, in handle_plugin_method_call\n File \"decky_loader/plugin/plugin.py\", line 137, in execute_method\n File \"decky_loader/plugin/messages.py\", line 44, in wait_for_result\nException: 'Plugin' object has no attribute 'timer_task'\n"
|
||||
}
|
||||
},
|
||||
"cancelRetainsEnd": true,
|
||||
"replacedTimerStillFires": true,
|
||||
"expiredRestartError": true
|
||||
},
|
||||
"result": "upstream timer lifecycle regressions reproduced"
|
||||
}
|
||||
],
|
||||
"fakeFrame": "Pinned official archive installed and removed through real HTTP/SSH harness; backend execution stubbed.",
|
||||
"before": {
|
||||
"arch": "aarch64",
|
||||
"decky": "failed",
|
||||
"panel": "inactive",
|
||||
"battery": [
|
||||
99
|
||||
]
|
||||
},
|
||||
"after": {
|
||||
"arch": "aarch64",
|
||||
"decky": "failed",
|
||||
"panel": "inactive",
|
||||
"battery": [
|
||||
99
|
||||
]
|
||||
},
|
||||
"cleanup": {
|
||||
"baseAbsent": true,
|
||||
"unitAbsent": true,
|
||||
"sharedLockAbsent": true,
|
||||
"service": "MainPID=0\nLoadState=not-found\nActiveState=inactive",
|
||||
"testProcesses": []
|
||||
},
|
||||
"lockReleased": true,
|
||||
"limits": [
|
||||
"Sound and suspend unsupported",
|
||||
"No alert delivery while Framey panel is closed",
|
||||
"Worn VR, controller and keyboard unverified",
|
||||
"UI recents after asynchronous reopen not asserted; persisted recents verified through actual backend restart"
|
||||
],
|
||||
"localValidation": {
|
||||
"pythonTestsRun": 1648,
|
||||
"pythonSkipped": 16,
|
||||
"pythonResult": "OK",
|
||||
"actualDeckyNativeAmd64Cases": 6,
|
||||
"fakeFramePinnedInstallRemove": "PASS",
|
||||
"officialStoreEntriesRefreshed": 116,
|
||||
"officialStoreDigestsChanged": 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
# Simple Timer lifecycle on Frame
|
||||
|
||||
The exact official Simple Timer 1.0.0 archive (store ID 96, SHA-256
|
||||
`973d927e9d9a1feec04679c564af7e389a729b6735d2a988ffc2cb46ce4eb312`)
|
||||
now qualifies as **works for silent in-panel session timers** after a managed
|
||||
update/reinstall. The upstream BSD license and API1 manifest remain unchanged.
|
||||
[Redacted observations](framey-simple-timer-2026-10-26.json) accompany this report.
|
||||
|
||||
## Reproduced failures and repair
|
||||
|
||||
The unmodified archive, discovered and called by the actual published Decky
|
||||
3.3.0 loader, fails cancel before its first timer and expired-deadline startup
|
||||
with an annotation-only `timer_task` attribute. Cancel leaves `timer_end` saved,
|
||||
so canceled timers resume after restart. Starting a replacement leaves the first
|
||||
task alive; it still sends its own completion event.
|
||||
|
||||
The managed installer repairs only this exact ID/digest/folder. Lifecycle state
|
||||
is initialized before startup cancellation. A lock and cancellation/join give one
|
||||
task ownership of the timer. Explicit cancel and completion clear the persisted
|
||||
deadline. Backend unload stops the task while preserving an active deadline;
|
||||
startup resumes that exact deadline without duplicating recent timers. Late
|
||||
expiry emits its silent notice without canceling itself midway. Older installed
|
||||
copies remain partial until their receipt includes `simple-timer-lifecycle`.
|
||||
|
||||
## Verification
|
||||
|
||||
- **Actual published Decky 3.3.0, isolated native amd64:** production managed
|
||||
install, API1 discovery and public instance RPC/events; fresh cancel,
|
||||
replacement suppression, one completion, clearing persisted end, active
|
||||
restart preserving deadline/recents, canceled restart and expired restart.
|
||||
A separate unmodified-archive case must reproduce the original failures.
|
||||
The same gate runs on native ARM64 CI; this report does not preclaim its result.
|
||||
- **Real Frame ARM64, Frame OS 0.5.6:** the same lifecycle assertions through
|
||||
Steam CEF's existing `DeckyBackend`, with a bounded private Decky 3.3.0 user
|
||||
service and private settings. No plugin class was manually instantiated.
|
||||
- **Local Chromium:** actual headset host and official frontend in a sandbox
|
||||
without `allow-same-origin`; method/event bridge relayed to that real ARM64
|
||||
backend. Begin created a 300-second timer; Cancel returned to idle. A safe
|
||||
five-second backend timer updated the panel and produced the visible silent
|
||||
completion toast. Backend tests verify persisted recents across restart;
|
||||
asynchronous UI recents after reopen were not asserted.
|
||||
- **Fake Frame:** exact pinned archive installed and removed through the real
|
||||
HTTP/SSH store harness; execution is stubbed, so this verifies packaging only.
|
||||
|
||||
Only private settings and silent timers changed. The shared `/tmp/frame-test.lock`
|
||||
was held for execution and then released. Successful run battery was 99 → 99;
|
||||
normal Decky remained failed and the headset panel remained inactive as found.
|
||||
An independent audit confirmed the private base/unit/archive absent, MainPID 0,
|
||||
unit not found and no test client processes. Job-owned CEF event listeners and
|
||||
private Chromium were removed. Two earlier test-client attempts were cleaned up:
|
||||
one competed with Steam's single Decky websocket; the other lacked a bounded
|
||||
quiet-event wait. Battery across those attempts was 100 → 99. These were test
|
||||
adapter failures; the final run used CEF and passed.
|
||||
|
||||
Sound and suspend are unsupported. Alerts require the plugin panel to remain
|
||||
open. Worn VR, physical controller/keyboard and sleep/wake notification delivery
|
||||
remain unverified. A resumed timer is persisted backend behavior, not a promise
|
||||
of notification delivery while the panel is closed.
|
||||
@@ -0,0 +1,50 @@
|
||||
# v0.4.48 candidate gate, 26 October 2026
|
||||
|
||||
This candidate contains the exact-archive Music Control 1.1.7 Python call
|
||||
repair from [#335](https://github.com/saphid/frame-control/pull/335). All
|
||||
**11** hosted checks passed on its head
|
||||
`ff465accc508e40158c590eebffd4aec924503ac`, including fake-Frame e2e,
|
||||
Windows, macOS, Linux, ARM64 host libraries and iOS. The catalog remains
|
||||
**4 works / 52 partial / 49 broken / 11 N/A** out of 116. Music Control is
|
||||
partial: an isolated D-Bus command fixture proves dispatch after the repair;
|
||||
a live player and actual Decky ARM64 backend were not tested.
|
||||
|
||||
## Locked real Frame smoke
|
||||
|
||||
At 15:36 UTC, source head `ff465accc508e40158c590eebffd4aec924503ac`
|
||||
ran `scripts/frame-smoke.sh` on SteamOS 0.5.6 build `20261022.6153909`.
|
||||
The runner acquired its own unique `/tmp/frame-test.lock` owner before
|
||||
reading device status. Preflight found SteamVR activity **3** (standby),
|
||||
proximity raw **3.0625**, and battery **99%**. Owned ARM64 and Windows/Proton
|
||||
titles installed, launched and were removed. The optional x86_64 title
|
||||
installed and was removed; its launch was **skipped** because Steam Linux
|
||||
Runtime 4 was absent. The result was **12 passed / 1 skipped / 0 failed**.
|
||||
Owned cleanup passed and the command exited 0, including lock release.
|
||||
A separate fresh locked read found battery **100%**, activity **3** and no
|
||||
`fc_smoke_*` title directories; it released only its own lock. The private
|
||||
runner result is `2026-10-26T153655Z-20261022.6153909.json`.
|
||||
|
||||
At 15:42 UTC, exact version-bumped commit
|
||||
`a31b4a426cf4204e45fe218e204a26328aa0b1c9` ran the same token-locked
|
||||
smoke. Preflight found activity **3**, proximity raw **3.0625** and battery
|
||||
**100%**. It passed **12 / 1 optional x86 launch skipped / 0 failed**.
|
||||
Cleanup passed and the command exited 0 after releasing its lock. The
|
||||
private runner result is `2026-10-26T154258Z-20261022.6153909.json`.
|
||||
A fresh separately locked read found battery **100%**, activity **3** and
|
||||
no test-title directories, then released that lock. Merging current main
|
||||
into the candidate created commit
|
||||
`a8bf01d660cd9abb8d2a724626a06a0e7e6c2a6a`; its tree before this
|
||||
evidence document was byte-identical to the tested version-bumped commit
|
||||
(`d45c2465ddfcf877fe2c8bc30420d4719da5035e`).
|
||||
|
||||
No wearer was confirmed. Neither smoke woke the headset or ran the
|
||||
interactive worn-check script. Official-plugin wearer visibility, physical
|
||||
controllers, VR keyboard and optical comfort remain unverified. The hourly
|
||||
shared-lock worn check remains enabled.
|
||||
|
||||
## Remaining publication gates
|
||||
|
||||
The release PR's exact-head CI and installer previews, post-merge main CI,
|
||||
tagged builds with all ten installers and `SHA256SUMS`, independent package
|
||||
checks, publisher dry run and public `update.json` readback are required
|
||||
before publication.
|
||||
@@ -0,0 +1,85 @@
|
||||
# v0.4.48 release verification and withdrawal, 26 October 2026
|
||||
|
||||
The annotated [`v0.4.48` tag](https://github.com/saphid/frame-control/releases/tag/v0.4.48)
|
||||
dereferences to main commit `5438e1d9d05ca472f200dcde50799c8ae882fafb`,
|
||||
which contains the version bump and [candidate evidence](v0448-candidate-2026-10-26.md).
|
||||
The exact-commit [main checks run](https://github.com/saphid/frame-control/actions/runs/40356645911)
|
||||
finished successfully: all seven jobs passed, including e2e, iOS, Windows,
|
||||
macOS and Linux tests. Its first two attempts had an iOS job stuck in the
|
||||
runner queue and were cancelled; attempt 3 ran that job and ended with all
|
||||
seven required checks successful. The later documentation-only main commit
|
||||
`1ddb6278b8ae0a98ecc79a181bba4d3cc67a52b6` also passed all seven
|
||||
[main checks](https://github.com/saphid/frame-control/actions/runs/40406851705).
|
||||
|
||||
The exact-tag [release workflow](https://github.com/saphid/frame-control/actions/runs/40406781383)
|
||||
finished successfully. Its draft, Linux, Windows, Apple Silicon Mac, Intel
|
||||
Mac and checksum jobs all passed; no tagged installer build failed or was
|
||||
cancelled. Tagged CI launched the Linux x86_64 AppImage on Xvfb, checked both
|
||||
Mac bundles against their runner architecture, installed the Windows Setup
|
||||
and compared its installed resources with the unpacked build, and tested its
|
||||
packaged Python and native PC libraries.
|
||||
|
||||
An earlier [release-PR preview run](https://github.com/saphid/frame-control/actions/runs/40352797572/attempts/1)
|
||||
was not a passing gate: its Apple Silicon Mac build was cancelled and its
|
||||
checksum job was skipped. [Attempt 2](https://github.com/saphid/frame-control/actions/runs/40352797572/attempts/2)
|
||||
completed all four preview installer builds successfully. Publication used the
|
||||
separate exact-tag workflow above, whose six jobs all succeeded.
|
||||
|
||||
The draft had exactly ten installers and `SHA256SUMS`, each with a GitHub
|
||||
SHA-256 digest. An independently downloaded `SHA256SUMS` matched a fresh
|
||||
ten-line list generated from GitHub's asset metadata byte for byte. Both
|
||||
its downloaded SHA-256 and GitHub's own asset digest were
|
||||
`8cf7d8808f0b0b9da08e2590461e83ef557412a0ce2cc780c8e1d02e34ce1a60`.
|
||||
Independent downloads on lxso1 matched `SHA256SUMS` for these packages:
|
||||
|
||||
| Package | SHA-256 and independent check |
|
||||
| --- | --- |
|
||||
| Linux x86_64 AppImage | `7c0cbef7c0330c48ecb69f6fcee027a8b45fe84b0458985db31d16b09fc45cbe`; isolated Xvfb launch served HTTP 200 with `FrameControl/1`, then its server stopped. |
|
||||
| Linux arm64 DEB | `d3c2cdc4310fc3fed11460ad970a3ff6da8b80a6ec566acf1bcaa5a9570efc3e`; package declares version 0.4.48 and arm64 architecture. |
|
||||
| Mac arm64 ZIP | `3c5b0ad0877e4c1e1adbac17a9eb4f1114294a25576330050ac9ce338e0b1c00`; ZIP CRC, bundle version 0.4.48 and main Mach-O arm64 CPU passed. |
|
||||
| Mac x64 ZIP | `3bb90246794f0082fd4f34d86113e292ae543671ff8bea4096d888a9273a529c`; ZIP CRC, bundle version 0.4.48 and main Mach-O x86_64 CPU passed. |
|
||||
| Windows x64 Setup | `def654c238575778120c6f2a4df1af319b0cd169f64c2fceebe26ddc5e3c68ff`; downloaded file is a PE/Nullsoft installer; tagged Windows CI installed and tested its contents. |
|
||||
|
||||
After attaching the final release notes,
|
||||
`scripts/publish-release.sh --dry-run v0.4.48` passed the exact tag, ten
|
||||
installer digests, checksum asset and update-manifest checks.
|
||||
`scripts/publish-release.sh v0.4.48` published the release at
|
||||
**21:53:29 UTC**. GitHub's latest-release API read back tag `v0.4.48`,
|
||||
`draft=false`, `prerelease=false`, ten installers, `SHA256SUMS` and
|
||||
`update.json`. Immediately after publication, a fresh public latest-download
|
||||
of `update.json` reported version 0.4.48, the release page, ten hashed
|
||||
installers and the worn VR limit in its notes. This is historical evidence,
|
||||
not the current update-channel state.
|
||||
|
||||
## Withdrawn after a real Decky regression was confirmed
|
||||
|
||||
The all-green release gate did not include Music Control's actual Decky 3.3.0
|
||||
legacy class calling convention. [PR #338](https://github.com/saphid/frame-control/pull/338)
|
||||
reproduced a regression in the #335 digest-scoped Music Control 1.1.7 install
|
||||
shim: it removes explicit `self` from calls that the real legacy runtime needs,
|
||||
so player discovery fails with `_get_dbus_env() missing 1 required positional
|
||||
argument: 'self'`. The earlier fixture had instantiated `Plugin()` and missed
|
||||
this behavior. A passing CI run therefore did not prove this plugin path worked.
|
||||
|
||||
At **22:01:20 UTC**, v0.4.48 was returned to draft and the previously green
|
||||
v0.4.47 was marked latest. The v0.4.48 tag and build artifacts were preserved;
|
||||
no tag or installer was deleted. The preserved draft's notes now begin with a
|
||||
withdrawal notice. GitHub's latest-release API and a fresh public
|
||||
`releases/latest/download/update.json` now both point to **v0.4.47** with ten
|
||||
hashed installers. Existing v0.4.48 installations do not auto-downgrade.
|
||||
The repair in #338 merged at `04cde721c862c80a18dad17c2aab0c86ca8d3082`.
|
||||
It requires green post-merge main checks, a new release candidate and all
|
||||
release gates before any replacement version is offered. An app update alone
|
||||
does not rewrite the bad installed plugin shim: affected users need an explicit
|
||||
managed plugin update or remove/reinstall after the repair ships.
|
||||
|
||||
The [candidate smoke](v0448-candidate-2026-10-26.md) on the real Frame passed
|
||||
**12 / one optional x86_64 launch skipped / zero failed**. The optional launch
|
||||
needed Steam Linux Runtime 4, which was absent; no x86_64 headset launch was
|
||||
claimed. Owned test titles and processes were removed, the lock was released,
|
||||
and a fresh locked check found standby and no test-title leftovers. A later
|
||||
read-only locked check found SteamVR activity 3 (standby), proximity raw
|
||||
3.0625 and battery 100%, then released its own lock. No wearer was confirmed,
|
||||
so neither the worn-check script nor physical controller input was run.
|
||||
Official-plugin wearer visibility, controller use, VR keyboard, optical comfort
|
||||
and physical launch of these exact Mac/Windows installers remain unverified.
|
||||
@@ -0,0 +1,37 @@
|
||||
# v0.4.49 candidate gate, 26 October 2026
|
||||
|
||||
The version bump at `6247dfb` follows the merged Music Control repair
|
||||
([#338](https://github.com/saphid/frame-control/pull/338)), the real Decky
|
||||
runtime gate ([#343](https://github.com/saphid/frame-control/pull/343)), and
|
||||
the fail-closed publisher ([#344](https://github.com/saphid/frame-control/pull/344)).
|
||||
The published v0.4.48 draft remains withdrawn. This candidate is not a
|
||||
published update.
|
||||
|
||||
## Locked real Frame smoke
|
||||
|
||||
At 22:48 UTC, `scripts/frame-smoke.sh` ran from exact version-bumped source
|
||||
commit `6247dfb` on SteamOS 0.5.6, build `20261022.6153909`. It acquired a
|
||||
unique `/tmp/frame-test.lock` owner before reading status. SteamVR activity was
|
||||
**3 (standby)**, proximity raw **3.0625**, and battery **99%**. The result was
|
||||
**12 passed / one optional x86_64 launch skipped / zero failed**. The x86_64
|
||||
title could not launch because Steam Linux Runtime 4 was not installed; no
|
||||
x86_64 launch is claimed. ARM64 and Windows/Proton test titles installed,
|
||||
launched and were removed. The script's owned cleanup passed and exited 0.
|
||||
The private result is
|
||||
`tests/smoke/results/2026-10-26T224816Z-20261022.6153909.json`.
|
||||
|
||||
A separate read-only audit acquired a fresh unique lock after the smoke. It
|
||||
found activity **3**, proximity raw **3.0**, battery **99%**, no `fc_smoke_*`
|
||||
or `fc-smoke-*` title folders and no matching test processes. The audit
|
||||
released only its own lock. Neither run woke the headset or ran the worn
|
||||
interaction script. No wearer was confirmed; official-plugin wearer
|
||||
visibility, physical controller input, VR keyboard and optical comfort remain
|
||||
unverified.
|
||||
|
||||
## Remaining publication gates
|
||||
|
||||
The final candidate head needs green PR CI, native amd64 and ARM64 Decky
|
||||
runtime checks, all four installer previews, post-merge exact-commit main CI,
|
||||
tagged builds of all ten installers, checksums, independent package
|
||||
verification, publisher dry run and public update-channel readback. A gate
|
||||
that fails, is cancelled or remains pending holds publication.
|
||||
@@ -0,0 +1,59 @@
|
||||
# v0.4.49 tagged release verification, 26 October 2026
|
||||
|
||||
The annotated `v0.4.49` tag dereferences to merged main commit
|
||||
`38db6a284696c3bb86bf97f6b853af7e97681701`. This is the same tree as
|
||||
the reviewed [candidate PR #348](https://github.com/saphid/frame-control/pull/348).
|
||||
Its [main checks run](https://github.com/saphid/frame-control/actions/runs/40417275314)
|
||||
passed all seven jobs, including e2e, iOS, macOS, Windows and Linux.
|
||||
The [native Decky 3.3.0 run](https://github.com/saphid/frame-control/actions/runs/40417275256)
|
||||
passed on AMD64 and ARM64. The regression gate includes an expected failure
|
||||
for v0.4.48's bad legacy calling convention.
|
||||
|
||||
The exact-tag [release workflow](https://github.com/saphid/frame-control/actions/runs/40418177330)
|
||||
passed all six jobs on its first attempt: draft, Linux, Windows, Apple Silicon
|
||||
Mac, Intel Mac, and checksums. No tagged build failed, was cancelled, or was
|
||||
skipped. The draft contained exactly ten installers and `SHA256SUMS` before
|
||||
publication. Every asset had a GitHub SHA-256 digest. A fresh download of
|
||||
`SHA256SUMS` had SHA-256
|
||||
`8a04f81f3b1efea9903e9acbba2c61b67f275869c160e0c6fa1d388a4803b42a`;
|
||||
it matched a list regenerated from the ten GitHub installer digests byte for
|
||||
byte. All ten installers were independently downloaded and passed
|
||||
`sha256sum -c SHA256SUMS`.
|
||||
|
||||
## Independent package checks
|
||||
|
||||
| Package | Check |
|
||||
| --- | --- |
|
||||
| Linux x86_64 AppImage | SHA-256 `0ef192e6f13aed39469392c22b971b311d911a0bebae16cbbabbfa729b95ced0`; isolated Xvfb launch returned HTTP 200 with `FrameControl/1`, then its server stopped. The first local launch attempt hit the workspace sandbox's `Operation not permitted` restriction; the same downloaded bytes passed outside that sandbox. Tagged Linux CI also launched the AppImage. |
|
||||
| Linux arm64 DEB | SHA-256 `a8e448b8b8f10b2813546cb9d8fea6bbe424e14dc3a739df86daa69bfd4023c1`; declares version 0.4.49 and arm64. |
|
||||
| Linux amd64 DEB | SHA-256 `10bb554cc761c61c99d059ae3601f4889187d7f18bdc8f9eb3cf768822b5765d`; declares version 0.4.49 and amd64. |
|
||||
| Mac arm64 ZIP | SHA-256 `c43c1017b3d244fcf59ab8f5bd35dbad17af4562b2aca3baf65908814f2ae4c3`; ZIP CRC, bundle version 0.4.49 and main Mach-O arm64 CPU passed. |
|
||||
| Mac x64 ZIP | SHA-256 `0dd838a1322107928893ff5efce817719867a5db1af2fa09124b6bb03018a19d`; ZIP CRC, bundle version 0.4.49 and main Mach-O x86_64 CPU passed. |
|
||||
| Windows x64 Setup | SHA-256 `386d99fd15314e1caef577f7cdf21e9e9f572d1c0dbaa65c369e5a5b0c047d31`; downloaded file is a PE/Nullsoft installer. Tagged Windows CI installed it and checked its packaged resources and Python libraries. |
|
||||
| Windows x64 ZIP | SHA-256 `2c201a8d1115dd1a07ba7b9293f7e05c319bd802f574a4b2f9565268a7f5cf8b`; ZIP CRC passed. |
|
||||
|
||||
The remaining three packages (both Mac DMGs and the ARM64 AppImage) also
|
||||
matched `SHA256SUMS`; tagged CI built and checked their platform bundles.
|
||||
Physical launch of these exact Mac and Windows installers was not performed.
|
||||
|
||||
## Publication and real Frame scope
|
||||
|
||||
The [candidate evidence](v0449-candidate-2026-10-26.md) records a unique-lock
|
||||
real Frame smoke on the version-bumped source: **12 passed, one optional
|
||||
x86_64 launch skipped, zero failed**, followed by an independent locked audit
|
||||
with no test-title folders or processes. The optional runtime was absent; no
|
||||
x86_64 launch is claimed. SteamVR stayed in standby, no wearer was confirmed,
|
||||
and neither a worn test nor physical controller check was run.
|
||||
|
||||
`scripts/publish-release.sh --dry-run v0.4.49` passed the exact main, native
|
||||
Decky and tagged release job gates, ten installer hashes, regenerated
|
||||
`SHA256SUMS`, draft notes and update manifest. The real publisher repeated
|
||||
those gates, attached `update.json`, and published
|
||||
[v0.4.49](https://github.com/saphid/frame-control/releases/tag/v0.4.49) at
|
||||
**23:25:45 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.49, the correct
|
||||
page, ten hashed assets, the v0.4.48 repair note and the worn-VR limit.
|
||||
The v0.4.48 release remains a withdrawn draft; its notice points to v0.4.49.
|
||||
Existing v0.4.48 installations do not auto-downgrade, and affected installed
|
||||
Music Control plugins need managed Update or Remove and reinstall.
|
||||
@@ -63,7 +63,7 @@ none of these frontend controls has passed a worn-headset check yet.
|
||||
| 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; 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. Plugins may still use ordinary CSS overflow containers. | Parent/iframe message tests; physical controller scroll remains 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 |
|
||||
| `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. |
|
||||
@@ -74,10 +74,13 @@ none of these frontend controls has passed a worn-headset check yet.
|
||||
| API v1/v2 `addEventListener` / `removeEventListener` | Plugin-scoped real backend events while panel is open; bounded queue, about one-second polling, subscription expiry | Real ARM64 Simple Timer countdown, start/cancel and completion toast via local Chromium; scope/bounds/reconnect tests. No closed-panel notifications. |
|
||||
| `toaster.toast` | Visible toast inside the plugin panel; plain text or React nodes for title, body, icon, logo and subtext; `dismiss` and optional `onClick` | Host unit test; no Steam system notification or worn VR check |
|
||||
| `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.addPatch`, global components | No-op; cannot patch Steam routes | Not applicable |
|
||||
| `findModuleChild`, `Navigation.CloseSideMenus` | Steam module lookup returns `null`; close returns to this plugin's main panel | Pinned HLTB for Deck 2.0.12 entry point and shallow settings view in local host test. Steam library patch and live HLTB search remain unverified. |
|
||||
| `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. |
|
||||
| `Focusable.onActivate`, `showModal`, `ConfirmModal` | Pointer and Enter/Space activation, disabled state, and local confirmation dialogs with cancel/close and nested-dialog focus | Host tests and official AlarMe 1.6.3 timer/preset controls with a real ARM64 backend in local Chromium. Sound, suspend and worn VR remain unverified. |
|
||||
| Steam library/store patches, CEF tab injection | Unavailable; no matching target in the isolated panel | Frame CEF inspection |
|
||||
| `fetchNoCors`, file picker, root calls | Unavailable | Not supported by Framey |
|
||||
|
||||
@@ -88,6 +91,17 @@ 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.
|
||||
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
|
||||
lookup that works in the Steam Deck QAM can still return `undefined` here.
|
||||
Use a local control or a visible unsupported message when it does. For a
|
||||
confirmation, call `showModal` with a `ConfirmModal` and keep any irreversible
|
||||
operation behind its OK action. `Focusable.onActivate` accepts pointer and
|
||||
Enter/Space input in Framey, but only a worn-headset test can establish how a
|
||||
physical controller reaches it. See the
|
||||
[module lookup evidence](evidence/framey-module-lookup-2026-10-26.md) and
|
||||
[dialog control evidence](evidence/framey-dialog-controls-2026-10-26.md) for
|
||||
the exact tested official versions.
|
||||
An API v1 manifest does not guarantee a working Framey panel. Test the exact
|
||||
store archive and do not assume SteamClient app details, library patches, or
|
||||
destructive actions work in VR. The
|
||||
|
||||
@@ -4,7 +4,7 @@ Snapshot: 2026-10-26T06:10:58.537029+00:00; 116 current visible official plugins
|
||||
|
||||
This table and both store views use [`ui/framey-compatibility.json`](../ui/framey-compatibility.json). Results apply only to the exact store ID, version and SHA-256. A changed digest/version is **unverified**. Regenerate this document with `python3 scripts/framey-compatibility-doc.py`; use `--check` in review.
|
||||
|
||||
**Works: 4 · Partial: 52 · Broken: 49 · N/A: 11.**
|
||||
**Works: 6 · Partial: 50 · Broken: 49 · N/A: 11.**
|
||||
|
||||
- **Works** requires verified useful plugin behavior with its real backend. Read the reason for the verified scope and remaining limits; it does not imply worn-VR verification.
|
||||
- **Partial** means only a subset or a local fixture scope was verified, often just frontend/settings initialization. The remaining backend/device functionality is unverified; read the reason for the exact scope.
|
||||
@@ -29,7 +29,7 @@ A [Free Loader duplicate-entry follow-up](evidence/framey-free-loader-duplicates
|
||||
|
||||
The current published digests are pinned in the JSON; if the store changed, retain/download those exact archives for reproduction. Audit downloads permit larger ZIPs for inspection; production installer limits remain 50 MiB ZIP / 150 MiB unpacked / 30 MiB per member.
|
||||
|
||||
After the VR job released the shared lock, this audit acquired it and used a private temporary Decky 3.3.0 ARM64 user service. Read-only ProtonDB `get_setting` and Steam Status `get_settings` calls succeeded through the live Steam CEF websocket. The service and private base were removed and the lock released; normal managed services stayed inactive, battery was 100 → 99, and no power setting was changed. [Redacted results](evidence/framey-official-backend-2026-10-25.json) record exactly what ran. A [2026-10-26 follow-up](evidence/framey-events-backend-2026-10-26.json) verified default Notebook drawing/save/reopen across a real backend restart and Simple Timer start/cancel, countdown/completion events, a visible toast and persisted recents/subtle mode. Only private test settings were written. Simple Timer remains partial because its own expired-timer startup raises a missing `timer_task` error; sound/suspend are unsupported. A [local audio follow-up](evidence/framey-audio-backends-2026-10-26.md) exercised Volume Mixer, Volume Boost and Mixer through real Decky 3.3.0 amd64 backends on private silent virtual audio targets. Sliders, numeric volume entry, independent server readbacks and Mixer filter persistence across a runtime restart passed. An [ARM64 audio follow-up](evidence/framey-audio-arm64-2026-10-26.md) verified the unmodified official backends in actual Decky 3.3.0 on the Frame user audio session. Volume Mixer and Mixer now qualify as works for per-app volume: Chromium controls changed only a named silent stream, with independent Pulse readbacks. That run left Volume Boost partial because sink writes were denied. A [Volume Boost follow-up](evidence/framey-volume-boost-arm64-2026-10-26.md) now verifies linked/separate channel controls, re-link and reopen through the actual ARM64 backend. Independent Pulse reads confirmed 125%/150% gain writes while the default sink stayed muted; exact original volume/mute was restored. Volume Boost qualifies as works for sink controls. Audible output, Bluetooth playback and worn VR remain unverified. A [running-context follow-up](evidence/framey-running-context-2026-10-26.md) supplies detached running-app/main-app reads and relays actual Steam lifetime callbacks while a panel is open. Real Frame checks verified an empty list, registration/cleanup and the exact Discord Status ARM64 backend reporting no IPC connection. Nonempty games and transitions were synthetic fixtures; real game events, Discord activity and worn VR remain unverified. Discord Status moves to partial; no new works claim is made. A [library context follow-up](evidence/framey-library-context-2026-10-26.md) read 1,106 app overviews, 895 game IDs and the current public Steam ID from real Frame CEF under the shared lock. Local Chromium replayed this detached snapshot to render Deck Settings game selection, Steamback's empty save panel and PlayTime statistics/settings. Steamback and PlayTime used actual Decky 3.3.0 amd64 backends in an isolated container; their ARM64 backends and automatic game hooks remain unverified. Shotty's frontend now starts, but its published backend fails on missing `click`; it stays broken. A [chunked frontend follow-up](evidence/framey-deck-shelves-browser-2026-10-26.md) loaded Deck Shelves 2.0.0 through a managed JavaScript module route and rendered its settings in an opaque local Chromium panel. Its backend was denied; Steam Home shelves and worn VR remain unverified. A [local module lookup follow-up](evidence/framey-module-lookup-2026-10-26.md) supplies a finite registry of Framey-local controls and CSS. TabMaster QAM/docs now render; Steam library-tab editing remains unavailable. That pass exposed Junk-Store executable-script extraction failures; the follow-up below repairs them. SteamGridDB still requires Steam context-menu internals and DeckSP still fails its published MobX fallback. That pass tested TabMaster/Junk-Store in actual isolated amd64 Decky, without ARM64 or worn-VR coverage. A [script entrypoint follow-up](evidence/framey-script-entrypoints-2026-10-26.md) grants owner execute to shebang scripts and ZIP-declared executables without copying archive privilege/write bits. Junk-Store now passes static menu/backend calls in actual Decky 3.3.0 ARM64 and local Chromium Games/Epic navigation, back and reopen. It moves to partial: About still crashes on missing installed-app metadata, Legendary is absent, and login/game operations remain unverified. Existing installations require update/reinstall to repair permissions. A [read-only installed-metadata follow-up](evidence/framey-installed-context-2026-10-26.md) now repairs the About crash. The library snapshot copies only Steam-supplied installed booleans into detached metadata, preserving unknown values as undefined. Junk-Store About/Dependencies, Back and reopen render in local Chromium with actual Frame library data and the official ARM64 backend. It remains partial: dependency/login/game operations, RSS, the optional Line truncated
|
||||
After the VR job released the shared lock, this audit acquired it and used a private temporary Decky 3.3.0 ARM64 user service. Read-only ProtonDB `get_setting` and Steam Status `get_settings` calls succeeded through the live Steam CEF websocket. The service and private base were removed and the lock released; normal managed services stayed inactive, battery was 100 → 99, and no power setting was changed. [Redacted results](evidence/framey-official-backend-2026-10-25.json) record exactly what ran. A [2026-10-26 follow-up](evidence/framey-events-backend-2026-10-26.json) verified default Notebook drawing/save/reopen across a real backend restart and Simple Timer start/cancel, countdown/completion events, a visible toast and persisted recents/subtle mode. Only private test settings were written. That initial pass left Simple Timer partial because its own expired-timer startup raises a missing `timer_task` error; sound/suspend are unsupported. A [local audio follow-up](evidence/framey-audio-backends-2026-10-26.md) exercised Volume Mixer, Volume Boost and Mixer through real Decky 3.3.0 amd64 backends on private silent virtual audio targets. Sliders, numeric volume entry, independent server readbacks and Mixer filter persistence across a runtime restart passed. An [ARM64 audio follow-up](evidence/framey-audio-arm64-2026-10-26.md) verified the unmodified official backends in actual Decky 3.3.0 on the Frame user audio session. Volume Mixer and Mixer now qualify as works for per-app volume: Chromium controls changed only a named silent stream, with independent Pulse readbacks. That run left Volume Boost partial because sink writes were denied. A [Volume Boost follow-up](evidence/framey-volume-boost-arm64-2026-10-26.md) now verifies linked/separate channel controls, re-link and reopen through the actual ARM64 backend. Independent Pulse reads confirmed 125%/150% gain writes while the default sink stayed muted; exact original volume/mute was restored. Volume Boost qualifies as works for sink controls. Audible output, Bluetooth playback and worn VR remain unverified. A [running-context follow-up](evidence/framey-running-context-2026-10-26.md) supplies detached running-app/main-app reads and relays actual Steam lifetime callbacks while a panel is open. Real Frame checks verified an empty list, registration/cleanup and the exact Discord Status ARM64 backend reporting no IPC connection. Nonempty games and transitions were synthetic fixtures; real game events, Discord activity and worn VR remain unverified. Discord Status moves to partial; no new works claim is made. A [library context follow-up](evidence/framey-library-context-2026-10-26.md) read 1,106 app overviews, 895 game IDs and the current public Steam ID from real Frame CEF under the shared lock. Local Chromium replayed this detached snapshot to render Deck Settings game selection, Steamback's empty save panel and PlayTime statistics/settings. Steamback and PlayTime used actual Decky 3.3.0 amd64 backends in an isolated container; their ARM64 backends and automatic game hooks remain unverified. Shotty's frontend now starts, but its published backend fails on missing `click`; it stays broken. A [chunked frontend follow-up](evidence/framey-deck-shelves-browser-2026-10-26.md) loaded Deck Shelves 2.0.0 through a managed JavaScript module route and rendered its settings in an opaque local Chromium panel. Its backend was denied; Steam Home shelves and worn VR remain unverified. A [local module lookup follow-up](evidence/framey-module-lookup-2026-10-26.md) supplies a finite registry of Framey-local controls and CSS. TabMaster QAM/docs now render; Steam library-tab editing remains unavailable. That pass exposed Junk-Store executable-script extraction failures; the follow-up below repairs them. SteamGridDB still requires Steam context-menu internals and DeckSP still fails its published MobX fallback. That pass tested TabMaster/Junk-Store in actual isolated amd64 Decky, without ARM64 or worn-VR coverage. A [script entrypoint follow-up](evidence/framey-script-entrypoints-2026-10-26.md) grants owner execute to shebang scripts and ZIP-declared executables without copying archive privilege/write bits. Junk-Store now passes static menu/backend calls in actual Decky 3.3.0 ARM64 and local Chromium Games/Epic navigation, back and reopen. It moves to partial: About still crashes on missing installed-app metadata, Legendary is absent, and login/game operations remain unverified. Existing installations require update/reinstall to repair permissions. A [read-only installed-metadata follow-up](evidence/framey-installed-context-2026-10-26.md) now repairs the About crash. The library snapshot copies only Steam-supplied installed booleans into detached metadata, preserving unknown values as undefined. Junk-Store About/Dependencies, Back and reopen render in local Chromium with actual Frame library data and the official ARM64 backend. It remains partial: dependency/login/game operations, RSSLine truncated
|
||||
|
||||
## Every current plugin
|
||||
|
||||
@@ -41,7 +41,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
|
||||
| 7: CSS Loader 2.1.3 | legacy / legacy | partial | Panel initialization succeeds; CSS themes patch Steam UI and cannot theme the isolated VR shell. | static archive, local browser, fake-Frame |
|
||||
| 10: vibrantDeck 2.0.1 | legacy / legacy | N/A | Not supported on Frame: Changes X11 gamescope color properties, not the Frame SteamVR compositor. | static archive, local browser, fake-Frame |
|
||||
| 11: Fantastic 0.5.1 | legacy / legacy | N/A | Not supported on Frame: Targets Steam Deck fan hardware; no Frame fan-control mapping is provided. | static archive, local browser, fake-Frame |
|
||||
| 12: MusicControl 1.1.6 | legacy / legacy | partial | Legacy media controls initialize; player discovery and playback are unverified. | static archive, local browser, fake-Frame |
|
||||
| 12: MusicControl 1.1.6 | legacy / legacy | partial | Managed reinstall preserves Decky’s legacy calling convention and repairs the Frame OS shell library-path failure. Chromium transport/seek/volume against a silent MPRIS fixture and real ARM64 empty-player discovery pass; actual Frame media playback, album art and worn VR remain unverified. Reinstall existing copies to apply. | static archive, local browser, fake-Frame, isolated Decky 3.3.0 + MPRIS fixture, real Frame SSH |
|
||||
| 13: Pause Games 1.0.3 | 1 / 2 | partial | Settings and running-game list initialize in local Chromium with an explicit synthetic game; live Frame empty-list reads and lifetime registration/cleanup passed. Process pause/resume, automatic pausing and suspend integration remain unverified/unsupported; backend writes were denied. | static archive, local browser, fake-Frame |
|
||||
| 14: ProtonDB Badges 1.3.4 | 2 / 2 | partial | Settings render locally and read-only get_setting RPC returned the default through real Decky 3.3.0 on ARM64 Frame. Steam game-page badges have no patch target; live writes and VR controls unverified. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 16: Audio Loader 1.6.1 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. Steam UI sound lookup is present as explicitly unsupported; the frontend still requires Steam m_GamepadUIAudioStore.m_AudioPlaybackManager, which is unavailable in the isolated panel. | static archive, local browser, fake-Frame |
|
||||
@@ -65,7 +65,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
|
||||
| 44: DeckMTP 1.0.5 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: DeckMTP/bin/umtprd. | static archive, local browser, fake-Frame |
|
||||
| 46: Steamback 1.0.0-1 | legacy / legacy | partial | Panel opens using the real Frame current-user/library snapshot. Account setup and empty save-list reads pass through actual Decky 3.3.0 amd64 in isolation. ARM64 backend, supported-save discovery, automatic backups and restore remain unverified; no backup/restore ran. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 48: Quick Launch 1.2.1 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 49: Decky Cloud Save 1.4.2 | legacy / legacy | broken | Unsafe plugin archive member. | static archive, local browser, fake-Frame |
|
||||
| 49: Decky Cloud Save 1.4.2 | legacy / legacy | broken | The exact official ZIP contains a 60,776,600-byte x86_64 rclone member, above the 30 MiB member limit and unusable on ARM64. Installation remains blocked. | static archive, local browser, fake-Frame |
|
||||
| 50: Storage Cleaner 1.4.0 | 1 / 2 | partial | Empty-data panel rendered; cleanup was deliberately not run because it can remove local data. | static archive, local browser, fake-Frame |
|
||||
| 52: Game Theme Music 1.7.1-1 | 1 / 2 | partial | An inert local component lets the exact frontend initialize and render its Settings view in opaque Chromium. Backend get_setting and plugin network were denied in the audit; Steam game-page theme injection has no verified target in Framey. Music search, playback, downloads, settings persistence and worn VR remain unverified. | static archive, local browser, fake-Frame |
|
||||
| 53: Shotty 0.1.0 | legacy / legacy | partial | The frontend starts with the read-only Frame library snapshot. Frame Control adds a digest-scoped click.get_app_dir compatibility module to the managed install; the exact backend then imports and copies a synthetic Steam screenshot into a temporary Pictures folder in isolated Python. Actual Decky 3.3.0 ARM64 loading, live screenshots/rescue, in-VR panel and physical controller use remain unverified. | static archive, local browser, fake-Frame, real Frame SSH, isolated Python backend |
|
||||
@@ -97,14 +97,14 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
|
||||
| 85: Decky-Undervolt 1.0.8 | 1 / 2 | N/A | Not supported on Frame: RyzenAdj Curve Optimizer requires an AMD CPU; Frame is ARM64. | static archive, local browser, fake-Frame |
|
||||
| 87: ScreenSaver 1.1.2 | legacy / legacy | partial | Initializer succeeds; global screen overlay and idle/suspend integration have no verified VR target. | static archive, local browser, fake-Frame |
|
||||
| 88: steamdeck-input-disabler 1.0.2 | legacy / legacy | N/A | Not supported on Frame: Targets the built-in Steam Deck USB gamepad; Frame has different controllers. | static archive, local browser, fake-Frame |
|
||||
| 89: DeckySpy 0.6.7 | legacy / legacy | partial | Initializer succeeds; account tokens and external service integration were not exercised. | static archive, local browser, fake-Frame |
|
||||
| 89: DeckySpy 0.6.7 | legacy / legacy | works | Works for read-only system monitoring: live CPU, memory, uptime, processes, interfaces and Frame battery render in local Chromium with actual Decky 3.3.0 ARM64 on the Frame; silent warning and private settings save/reopen pass. Exact managed install adds pinned BSD psutil wheels and a battery-name fallback. Existing copies need update/reinstall. Automatic alerts, resume/game-time warnings, sound and worn VR remain unverified; monitoring requires an open panel. | static archive, fake-Frame, local browser, real Frame SSH |
|
||||
| 90: Picture in Picture 1.0.0 | 1 / 2 | partial | Initializer succeeds; global picture-in-picture injection cannot escape the isolated panel. | static archive, local browser, fake-Frame |
|
||||
| 91: PlayCount 1.7 | 1 / 2 | partial | Initializer succeeds; Steam game-page play-count patch has no supported VR target. | static archive, local browser, fake-Frame |
|
||||
| 92: Crosshair 1.1.0 | 1 / 2 | partial | Initializer succeeds; global crosshair injection has no supported compositor target. | static archive, local browser, fake-Frame |
|
||||
| 93: Deck Settings 1.2.6 | 1 / 2 | partial | Game picker renders with detached real Frame library/install-folder reads; search modal opens in local Chromium. Snapshot refreshes on panel reopen. Remote reports/proxy fetch, settings changes and ARM64 backend remain unverified. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 94: Decky DoT DNS 0.0.2 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 95: Decky-SpoofDPI 0.12.0 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-spoof-dpi/bin/spoofdpi. | static archive, local browser, fake-Frame |
|
||||
| 96: Simple Timer 1.0.0 | 1 / 2 | partial | Live start/cancel, countdown events, completion toast, persisted recents/subtle mode verified in Chromium with real ARM64 Decky. Upstream expired-timer restart raises missing timer_task. Audio/suspend unsupported; alerts require an open panel; worn VR unverified. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 96: Simple Timer 1.0.0 | 1 / 2 | works | Verified for silent in-panel session timers: Chromium Begin/Cancel/countdown/toast with real ARM64 Decky 3.3.0; repaired cancel, replace, completion and active/canceled/expired restarts pass actual loader RPC/events. Update/reinstall required. Sound and suspend unsupported; alerts require an open panel; worn VR unverified. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 97: Decky-Framegen 0.11.15 | 1 / 2 | broken | Plugin archive size or SHA-256 did not match the store. | static archive, local browser, fake-Frame |
|
||||
| 98: Decky-Lookup 0.1.0 | 1 / 2 | partial | Initializer succeeds; game-page context actions and live lookups remain unverified. | static archive, local browser, fake-Frame |
|
||||
| 99: Decky Zerotier 0.3.1 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
@@ -119,7 +119,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
|
||||
| 108: HueSync 3.8.0 | 1 / 2 | N/A | Not supported on Frame: Targets handheld RGB/LED controllers (AYANEO/Ally/MSI), not Frame hardware. | static archive, local browser, fake-Frame |
|
||||
| 109: MuraDeck 2.1.4 | 1 / 2 | N/A | Not supported on Frame: Calibration is specifically for defective Samsung Steam Deck OLED panels, not Frame displays. | static archive, local browser, fake-Frame |
|
||||
| 110: Dimmer Deck 0.0.4 | 1 / 2 | N/A | Not supported on Frame: Changes gamescope/X11 dimming properties; no mapping to Frame VR display brightness exists. | static archive, local browser, fake-Frame |
|
||||
| 111: Music Control 1.1.7 | legacy / legacy | partial | Panel initializer succeeds with backend/network blocked; actual controls and backend remain unverified. | static archive, local browser, fake-Frame |
|
||||
| 111: Music Control 1.1.7 | legacy / legacy | partial | Managed reinstall preserves Decky’s legacy calling convention and repairs the Frame OS shell library-path failure. Chromium transport/seek/volume against a silent MPRIS fixture and real ARM64 empty-player discovery pass; actual Frame media playback, album art and worn VR remain unverified. Reinstall existing copies to apply. | static archive, local browser, fake-Frame, isolated Python backend, isolated Decky 3.3.0 + MPRIS fixture, real Frame SSH |
|
||||
| 112: Better Keyboard 0.3.4 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. Frontend audit failed: window.SteamClient.UI.GetUIMode is not a function. | static archive, local browser, fake-Frame |
|
||||
| 113: Decky LSFG-VK 0.12.2 | 1 / 2 | partial | Initializer succeeds; runtime LSFG/Windows DLL downloads and Vulkan integration are not ARM64-validated. | static archive, local browser, fake-Frame |
|
||||
| 114: Network Sentinel 1.0.4 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
@@ -164,7 +164,7 @@ DFL includes exports imported through decky-frontend-lib / @decky/ui. Steam stor
|
||||
| CSS Loader | none detected | ButtonItem, ColorPickerModal, ConfirmModal, DialogButton, DialogCheckbox, Dropdown, DropdownItem, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, ScrollPanelGroup, SidebarNavigation, SliderField, Tabs, TextField, Toggle, ToggleField, classMap, definePlugin, findModuleChild, gamepadDialogClasses, gamepadSliderClasses, getGamepadNavigationTrees, replacePatch, showModal, staticClasses | routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| vibrantDeck | none detected | PanelSection, PanelSectionRow, Router, SliderField, ToggleField, definePlugin, staticClasses | Router, window.SP_REACT | Python | Changes X11 gamescope color properties, not the Frame SteamVR compositor. |
|
||||
| Fantastic | none detected | Field, Navigation, PanelSection, PanelSectionRow, ToggleField, definePlugin, staticClasses | window.SP_REACT, QAM/global tabs | Python + native payload; x86_64 | Targets Steam Deck fan hardware; no Frame fan-control mapping is provided. |
|
||||
| MusicControl | none detected | ButtonItem, DialogButton, Focusable, Menu, MenuItem, PanelSection, PanelSectionRow, Router, SidebarNavigation, SliderField, SteamSpinner, definePlugin, showContextMenu, staticClasses | Router, routerHook, window.SP_REACT, local/Steam routes | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| MusicControl | none detected | ButtonItem, DialogButton, Focusable, Menu, MenuItem, PanelSection, PanelSectionRow, Router, SidebarNavigation, SliderField, SteamSpinner, definePlugin, showContextMenu, staticClasses | Router, routerHook, window.SP_REACT, local/Steam routes | Python | User-session MPRIS player and dbus-send required; no Deck-specific hardware. |
|
||||
| Pause Games | Apps, Apps.RegisterForGameActionTaskChange, GameSessions.RegisterForAppLifetimeNotifications, System.RegisterForOnResumeFromSuspend, System.RegisterForOnSuspendRequest, System.UI.RegisterForFocusChangeEvents, System.UI.RegisterForSystemKeyEvents | Button, Field, Focusable, Marquee, PanelSection, PanelSectionRow, Router, Toggle, ToggleField, beforePatch, sleep | Router, window.SP_REACT, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| ProtonDB Badges | none detected | ButtonItem, ConfirmModal, DialogButton, DropdownItem, Field, Focusable, Navigation, PanelSection, PanelSectionRow, ScrollPanel, ScrollPanelGroup, ToggleField, afterPatch, appDetailsClasses, appDetailsHeaderClasses, createReactTreePatcher, definePlugin, findInReactTree, findModuleExport, showModal, staticClasses, useParams | SteamUIStore, appStore, routerHook, toaster, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Audio Loader | GameSessions.RegisterForAppLifetimeNotifications, d.ts | ButtonItem, DialogButton, Dropdown, DropdownItem, Focusable, Navigation, PanelSection, PanelSectionRow, Router, SliderField, Tabs, TextField, ToggleField, afterPatch, beforePatch, definePlugin, findModuleChild, gamepadDialogClasses, staticClasses | Router, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
@@ -220,7 +220,7 @@ DFL includes exports imported through decky-frontend-lib / @decky/ui. Steam stor
|
||||
| Decky-Undervolt | GameSessions.RegisterForAppLifetimeNotifications, System.RegisterForOnResumeFromSuspend | ButtonItem, DialogButton, DropdownItem, Navigation, PanelSection, PanelSectionRow, Router, SidebarNavigation, SliderField, TextField, ToggleField, definePlugin | Router, appStore, routerHook, window.SP_REACT, local/Steam routes, Steam stores | Python + native payload; x86_64 | RyzenAdj Curve Optimizer requires an AMD CPU; Frame is ARM64. |
|
||||
| ScreenSaver | Input.RegisterForControllerInputMessages, Input.RegisterForControllerStateChanges, Settings.SetSetting, System.RegisterForOnSuspendRequest, System.UpdateSettings, User.RegisterForPrepareForSystemSuspendProgress | PanelSection, PanelSectionRow, ToggleField, definePlugin, findModuleChild, staticClasses | toaster, window.SP_REACT, toasts, Steam page patches/module lookup | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| steamdeck-input-disabler | none detected | PanelSection, PanelSectionRow, ToggleField, definePlugin, staticClasses | window.SP_REACT | Python | Targets the built-in Steam Deck USB gamepad; Frame has different controllers. |
|
||||
| DeckySpy | System.RegisterForOnResumeFromSuspend | ButtonItem, Field, PanelSection, PanelSectionRow, Router, SliderField, ToggleField, definePlugin, staticClasses | Router, toaster, window.SP_REACT, toasts | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| DeckySpy | System.RegisterForOnResumeFromSuspend | ButtonItem, Field, PanelSection, PanelSectionRow, Router, SliderField, ToggleField, definePlugin, staticClasses | Router, toaster, window.SP_REACT, toasts | Python; managed psutil 7.2.2 stable-ABI native amd64/ARM64 dependency | Read-only Linux /proc and network-interface metrics; Frame battery read by power_supply type=Battery. No Deck-specific hardware writes. |
|
||||
| Picture in Picture | none detected | ButtonItem, ConfirmModal, DropdownItem, PanelSection, PanelSectionRow, Router, SliderField, TextField, ToggleField, findModuleChild, getGamepadNavigationTrees, quickAccessMenuClasses, showModal | Router, routerHook, window.SP_REACT, local/Steam routes, QAM/global tabs, Steam page patches/module lookup, Steam stores | no Python/native backend found | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| PlayCount | none detected | Button, ButtonItem, DialogBody, DialogButton, DialogControlsSection, DialogHeader, DropdownItem, ModalRoot, Navigation, PanelSection, PanelSectionRow, SliderField, Tabs, ToggleField, afterPatch, appDetailsClasses, createReactTreePatcher, definePlugin, findInReactTree, findModuleChild, showModal, staticClasses | routerHook, window.SP_REACT, local/Steam routes, QAM/global tabs, Steam page patches/module lookup, Steam stores | no Python/native backend found | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Crosshair | none detected | ButtonItem, PanelSection, PanelSectionRow, staticClasses | window.SP_REACT, QAM/global tabs | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
@@ -242,7 +242,7 @@ DFL includes exports imported through decky-frontend-lib / @decky/ui. Steam stor
|
||||
| HueSync | Settings.GetCurrentLanguage, System.GetSystemInfo, System.RegisterForBatteryStateChanges, System.RegisterForOnResumeFromSuspend, System.RegisterForOnSuspendRequest, User.RegisterForPrepareForSystemSuspendProgress, User.RegisterForResumeSuspendedGamesProgress | DialogButton, DropdownItem, Field, Focusable, Marquee, ModalRoot, PanelSection, PanelSectionRow, Router, SliderField, TextField, ToggleField, definePlugin, findModuleChild, gamepadSliderClasses, showModal, staticClasses | Router, toaster, window.SP_REACT, toasts, Steam page patches/module lookup, Steam stores | Python + native payload; x86_64 | Targets handheld RGB/LED controllers (AYANEO/Ally/MSI), not Frame hardware. |
|
||||
| MuraDeck | System.Display.RegisterForBrightnessChanges | ButtonItem, ConfirmModal, Field, Focusable, Navigation, PanelSection, PanelSectionRow, SideMenu, SidebarNavigation, SliderField, ToggleField, quickAccessControlsClasses, showModal | SteamUIStore, routerHook, window.SP_REACT, local/Steam routes, Steam stores | Python | Calibration is specifically for defective Samsung Steam Deck OLED panels, not Frame displays. |
|
||||
| Dimmer Deck | Settings.GetCurrentLanguage, System.Display.RegisterForBrightnessChanges, System.Display.SetBrightness | PanelSection, PanelSectionRow, SliderField, ToggleField, staticClasses | toaster, window.SP_REACT, toasts, Steam stores | Python | Changes gamescope/X11 dimming properties; no mapping to Frame VR display brightness exists. |
|
||||
| Music Control | none detected | ButtonItem, DialogButton, Focusable, PanelSection, PanelSectionRow, Router, SidebarNavigation, SliderField, SteamSpinner, definePlugin, staticClasses | Router, routerHook, window.SP_REACT, local/Steam routes | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Music Control | none detected | ButtonItem, DialogButton, Focusable, PanelSection, PanelSectionRow, Router, SidebarNavigation, SliderField, SteamSpinner, definePlugin, staticClasses | Router, routerHook, window.SP_REACT, local/Steam routes | Python | User-session MPRIS player and dbus-send required; no Deck-specific hardware. |
|
||||
| Better Keyboard | Settings.GetCurrentLanguage, UI.GetUIMode, UI.RegisterForUIModeChanged | ButtonItem, EUIMode, PanelSection, PanelSectionRow, ToggleField, staticClasses | SteamUIStore, window.SP_REACT, Steam stores | Python | /dev/input |
|
||||
| Decky LSFG-VK | none detected | ButtonItem, ConfirmModal, DialogBody, DialogButton, DialogControlsSection, DialogControlsSectionHeader, DialogHeader, Dropdown, Field, Focusable, ModalRoot, PanelSection, PanelSectionRow, Router, SliderField, Spinner, TextField, Toggle, ToggleField, showModal, staticClasses | Router, toaster, window.SP_REACT, toasts | Python | flatpak; x86_64 |
|
||||
| Network Sentinel | none detected | ButtonItem, Navigation, PanelSection, PanelSectionRow, SliderField, ToggleField, staticClasses | toaster, window.SP_REACT, toasts | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
@@ -288,11 +288,11 @@ All published Linux native payloads in this snapshot are x86_64; no published aa
|
||||
| Fantastic | bin/backend | Fan backend/driver is Deck-specific; an ARM64 rebuild would not provide compatible Frame hardware. |
|
||||
| PowerTools | bin/libryzenadj.so, bin/ryzenadj, bin/backend | RyzenAdj and AMD power backend cannot control an ARM64 Frame SoC. |
|
||||
| Memory Deck | bin/libscanmem.so.1.0.0 | libscanmem must be rebuilt for aarch64 and validated against target process architecture; memory writes were not tested. |
|
||||
| Emuchievements | bin/hash | The bin/hash helper needs an ARM64 build and cross-platform hashing tests; no ARM64 artifact is published. |
|
||||
| Emuchievements | bin/hash | A source-matched ARM64 helper matched one synthetic ROM on the real Frame (docs/evidence/framey-emuchievements-arm-2026-10-26.md). The pinned upstream source proposed for packaging also has unsafe archive extraction and cleanup paths (docs/evidence/framey-native-port-blockers-2026-10-26.md); fix and test those before shipping an ARM64 binary. The official x86_64 ZIP remains blocked; format coverage, plugin backend/UI, login and worn VR are unverified. |
|
||||
| MoonDeck | python/externals/yarl/_quoting_c.cpython-313-x86_64-linux-gnu.so, python/externals/aiohttp/_http_parser.cpython-313-x86_64-linux-gnu.so, python/externals/aiohttp/_websocket/reader_c.cpython-313-x86_64-linux-gnu.so, python/externals/aiohttp/_websocket/mask.cpython-313-x86_64-linux-gnu.so, python/externals/aiohttp/_http_writer.cpython-313-x86_64-linux-gnu.so, … 26 total; full list in JSON | Rebuild vendored CPython 3.13 extension wheels for the headset Python ABI and ARM64; buddy/helper dependencies also need ports. |
|
||||
| Controller Tools | bin/backend | Native backend needs an ARM64 build and controller/HID dependencies; its direct websocket also bypasses the Framey bridge. |
|
||||
| DeckMTP | bin/umtprd | umtprd needs ARM64 libusb/MTP dependencies and USB gadget permissions; root user-service blocker remains. |
|
||||
| Decky Cloud Save | bin/rclone | rclone needs an ARM64 artifact; this ZIP also fails safe member validation. Cloud writes were not exercised. |
|
||||
| Decky Cloud Save | bin/rclone | An ARM64 rclone artifact and safe package policy are needed; the official 60,776,600-byte x86_64 member fails archive validation. No cloud writes were run (docs/evidence/framey-native-port-blockers-2026-10-26.md). |
|
||||
| DeckyFileServer | bin/backend | Native file-server backend needs ARM64 dependencies; root requirement also blocks the user service. |
|
||||
| TS3 QuickAccess | bin/ts3-qs4sd.so | TeamSpeak client plugin must match an ARM64 TeamSpeak client and SDK ABI, not merely rebuild the .so. |
|
||||
| Syncthing | bin/decky-syncthing-watchdog | The watchdog needs an ARM64 build, plus a supported Syncthing runtime/package and safe path validation. |
|
||||
|
||||
+14
-3
@@ -70,9 +70,13 @@ The [26 October live checks](evidence/framey-events-backend-2026-10-26.json)
|
||||
used the actual official Notebook and Simple Timer Python backends in a private
|
||||
removable ARM64 Decky service. Notebook's default notebook saved a pointer stroke
|
||||
and loaded it after a backend restart. Simple Timer's start/cancel controls,
|
||||
countdown, completion toast and persisted recents/subtle mode worked. Simple
|
||||
Timer remains partial: its own expired-timer startup raises a missing
|
||||
`timer_task` error, sound/suspend are unsupported, and worn VR was not checked.
|
||||
countdown, completion toast and persisted recents/subtle mode worked. The
|
||||
[Simple Timer lifecycle repair](evidence/framey-simple-timer-2026-10-26.md) now
|
||||
fixes cancellation, replacement and active/canceled/expired backend restarts,
|
||||
verified through actual Decky 3.3.0 on amd64 and the ARM64 Frame. Managed
|
||||
update/reinstall is required. Silent in-panel session timers qualify as works;
|
||||
sound/suspend are unsupported, alerts require an open panel, and worn VR remains
|
||||
unverified.
|
||||
|
||||
DFL module and class lookup searches a finite registry of Framey's own controls,
|
||||
CSS maps and an explicitly unsupported optional UI-audio player. It follows DFL's
|
||||
@@ -204,6 +208,10 @@ browser session, so **Check result** can resume polling after a navigation or
|
||||
temporary connection error. After a polling error, Install, Update, and Remove
|
||||
stay disabled while the saved job's result is unknown; use **Check result**
|
||||
before starting another change.
|
||||
If Chromium denies session storage, the open panel keeps the accepted job 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.
|
||||
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
|
||||
@@ -224,6 +232,9 @@ chunks from two installed versions.
|
||||
The headset also includes the panel's store SHA-256 receipt with each backend
|
||||
call, Steam read, and event poll. It rejects a request if the installed receipt
|
||||
changed before the next status refresh; reopen the panel to use the new version.
|
||||
Panel-ready, error, and trusted-pointer reports also carry the current panel
|
||||
session. A delayed report from a closed panel cannot change the new panel's
|
||||
status or count as input to that panel.
|
||||
|
||||
## For plugin authors
|
||||
|
||||
|
||||
@@ -61,8 +61,10 @@ No wheel event or offset change means this synthetic injection path remains
|
||||
unverified; it does not establish whether a physical controller can scroll.
|
||||
The exercise then clicks the visible **Scroll plugin down/up** buttons through
|
||||
the same native OpenVR pointer transport and requires the test region to move
|
||||
down and return to the top. This proves the button path only; it does not
|
||||
identify a physical controller or repair native wheel delivery.
|
||||
down and return to the top. It also holds each button through that transport,
|
||||
requires Down to move farther than a single click, and requires Up to return
|
||||
to the top. These prove the synthetic button paths only; they do not identify
|
||||
a physical controller or repair native wheel delivery.
|
||||
Temporary controls exercise the shipped
|
||||
`DFL.SliderField` and `DFL.TextField`; their values remain UI-only. Text uses
|
||||
CDP `Input.insertText` after OpenVR pointer focus. This is synthetic input,
|
||||
@@ -119,7 +121,9 @@ the same-plugin navigation regression without a headset.
|
||||
3. **1:15–2:15:** Use the controller to open Settings, return to Panel, invoke
|
||||
**Read saved setting**, close/reopen the plugin, and confirm the value
|
||||
remains. Try controller scroll, a slider and a harmless text field in an
|
||||
installed compatible plugin; check SteamVR's actual keyboard and Back.
|
||||
installed compatible plugin. Hold **Scroll plugin down** and then **Scroll
|
||||
plugin up** with the controller; record continuous movement separately from
|
||||
native thumbstick or wheel scrolling. Check SteamVR's actual keyboard and Back.
|
||||
4. **2:15–3:00:** Restore the original sample setting. Switch away and back,
|
||||
close/reopen the Frame Control panel, and confirm controller input still
|
||||
works. Record comfort/legibility, controller model, any haptic behavior,
|
||||
|
||||
+15
-1
@@ -34,13 +34,27 @@ count. So a build reaches people only when you publish it, after testing it.
|
||||
|
||||
4. Publish:
|
||||
|
||||
Confirm the exact tag commit's main CI and e2e, the locked real Frame smoke,
|
||||
release verification, independent package checks, and all four tagged
|
||||
installer builds have passed. Hold the release if any gate is failed,
|
||||
cancelled, skipped, still running, or unverified. The publisher also reads
|
||||
the exact commit's GitHub CI, native Decky runtime gate, and tagged build
|
||||
job records and refuses to
|
||||
publish unless every required job succeeded; it cannot prove physical
|
||||
headset or installer behavior on its own.
|
||||
|
||||
```sh
|
||||
scripts/publish-release.sh --dry-run v0.4.0 # checks and shows update.json, changes nothing
|
||||
scripts/publish-release.sh v0.4.0
|
||||
```
|
||||
|
||||
The script checks that the tag is on GitHub and that all ten installers
|
||||
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
|
||||
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
|
||||
that the uploaded `SHA256SUMS` digest matches a fresh list of those ten
|
||||
installer digests. It attaches
|
||||
`update.json` (the version, the notes, the release page and each
|
||||
|
||||
@@ -549,3 +549,43 @@ Fixtures cover duplicate instance identity, unknown states, GUID matching,
|
||||
localised/denied channel data and 6 GHz ambiguity. No tests contact a real Frame.
|
||||
A Windows CI runner/VM cannot prove physical Steam dongle behaviour or audio RF
|
||||
interference. See [sources and limits](windows-wireless-doctor.md).
|
||||
|
||||
### Actual Decky backend shim gate
|
||||
|
||||
Fake-Frame tests stub plugin execution, so they cannot prove Decky's Python
|
||||
calling convention. `Decky backend runtime` CI separately runs the published,
|
||||
SHA-256-pinned **Decky 3.3.0** executable on native amd64 and ARM64 Linux.
|
||||
The loader discovers production-managed official archives and calls their public
|
||||
methods over its actual websocket API. No test imports or instantiates `Plugin`.
|
||||
|
||||
```sh
|
||||
python3 scripts/framey-test-decky-runtime.py --output /tmp/decky-runtime.json
|
||||
# Offline loader cache (still checked against its pinned native digest):
|
||||
python3 scripts/framey-test-decky-runtime.py --loader /path/to/PluginLoader --output /tmp/decky-runtime.json
|
||||
```
|
||||
|
||||
Run on Linux with Docker. Image build and the pinned loader download need network;
|
||||
plugin execution has no network, published ports, host home/audio/D-Bus/device
|
||||
mounts, capabilities or writable repository. Each case has its own unprivileged
|
||||
container, private tmpfs home and temporary files. Nothing contacts the headset
|
||||
or its shared lock. Failed/timed-out containers and the private image tag are
|
||||
removed by the runner.
|
||||
|
||||
Every `compat_shims.append` in the managed installer must have a case in
|
||||
`tests/decky-runtime/cases.json`. Add each affected pinned official archive and
|
||||
a safe public-method probe when introducing a shim. Ordinary unit tests reject
|
||||
uncovered shim names. The runtime job verifies the managed receipt, loader
|
||||
plugin discovery, and a successful backend call; a discovery listing alone
|
||||
could conceal a crashed backend process.
|
||||
|
||||
Current cases cover both Music Control entries, Shotty and DeckySpy. DeckySpy
|
||||
verifies native psutil import plus read-only CPU, memory, uptime, battery and
|
||||
network metrics through actual legacy RPC calls. Music Control uses
|
||||
the private silent MPRIS fixture to check discovery, metadata, play/pause,
|
||||
next/previous, seek and volume, with independent service-state readbacks.
|
||||
Shotty checks backend import, rescuer state and empty screenshot aggregation
|
||||
inside its disposable home. A fourth negative-control case deliberately
|
||||
reintroduces #335's incorrect bound-method transformation and must reproduce
|
||||
the real loader's legacy backend-call failure. The gate fails if that regression goes
|
||||
undetected. These checks verify backend behavior in a container; they do not
|
||||
upgrade any plugin's Frame, actual media playback or worn-VR compatibility claim.
|
||||
@@ -14,6 +14,12 @@ action calls the backend, so it reads the persisted setting rather than a
|
||||
browser cache. A missing, damaged, or old non-boolean value reads as the
|
||||
caller's default;
|
||||
the next save atomically replaces the sample's settings file.
|
||||
The current file is `{"schema": 1, "enabled": true|false}`. An unversioned
|
||||
`{"enabled": true|false}` file from the first sample release still reads, and
|
||||
the next save writes schema 1. A file with another explicit schema returns a
|
||||
visible read or save error and remains untouched, so downgrading the sample
|
||||
cannot erase settings written by a newer version. Add a deliberate migration
|
||||
for each future schema instead of changing this guard silently.
|
||||
The panel also guards its own read and write calls synchronously. If two
|
||||
controller events arrive before React redraws a disabled control, the second
|
||||
event is ignored; after the request settles, a new change can be saved.
|
||||
|
||||
@@ -5,26 +5,41 @@ from pathlib import Path
|
||||
import tempfile
|
||||
import decky
|
||||
|
||||
SETTINGS_SCHEMA = 1
|
||||
|
||||
|
||||
def settings_file():
|
||||
# Decky provides this per-plugin path through its official Python module.
|
||||
return Path(decky.DECKY_PLUGIN_SETTINGS_DIR) / 'settings.json'
|
||||
|
||||
|
||||
def read_settings():
|
||||
try:
|
||||
stored = json.loads(settings_file().read_text(encoding='utf-8'))
|
||||
except (FileNotFoundError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
return {}
|
||||
if not isinstance(stored, dict):
|
||||
return {}
|
||||
# The first sample release wrote {"enabled": bool} without a schema.
|
||||
# Keep reading it; the next save writes the current format.
|
||||
schema = stored.get('schema', SETTINGS_SCHEMA)
|
||||
if type(schema) is not int or schema != SETTINGS_SCHEMA:
|
||||
raise ValueError('Settings were written by an unsupported sample version')
|
||||
return stored
|
||||
|
||||
|
||||
class Plugin:
|
||||
async def get_setting(self, key: str, default=False):
|
||||
if key != 'enabled':
|
||||
raise ValueError('Unknown setting')
|
||||
try:
|
||||
stored = json.loads(settings_file().read_text(encoding='utf-8'))
|
||||
except (FileNotFoundError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
return default
|
||||
value = stored.get(key) if isinstance(stored, dict) else None
|
||||
value = read_settings().get(key)
|
||||
return value if type(value) is bool else default
|
||||
|
||||
async def set_setting(self, key: str, value: bool):
|
||||
if key != 'enabled' or type(value) is not bool:
|
||||
raise ValueError('Expected an enabled boolean')
|
||||
# Refuse to erase settings from a newer plugin after a downgrade.
|
||||
read_settings()
|
||||
target = settings_file()
|
||||
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
temporary = None
|
||||
@@ -32,7 +47,7 @@ class Plugin:
|
||||
with tempfile.NamedTemporaryFile('w', encoding='utf-8', dir=target.parent,
|
||||
prefix='.settings-', delete=False) as output:
|
||||
temporary = Path(output.name)
|
||||
json.dump({'enabled': value}, output)
|
||||
json.dump({'schema': SETTINGS_SCHEMA, 'enabled': value}, output)
|
||||
output.write('\n')
|
||||
temporary.chmod(0o600)
|
||||
os.replace(temporary, target)
|
||||
|
||||
@@ -50,7 +50,7 @@ def render(data):
|
||||
'record exactly what ran. A [2026-10-26 follow-up](evidence/framey-events-backend-2026-10-26.json) '
|
||||
'verified default Notebook drawing/save/reopen across a real backend restart and Simple Timer start/cancel, '
|
||||
'countdown/completion events, a visible toast and persisted recents/subtle mode. '
|
||||
'Only private test settings were written. Simple Timer remains partial because its own expired-timer startup '
|
||||
'Only private test settings were written. That initial pass left Simple Timer partial because its own expired-timer startup '
|
||||
'raises a missing `timer_task` error; sound/suspend are unsupported. '
|
||||
'A [local audio follow-up](evidence/framey-audio-backends-2026-10-26.md) exercised Volume Mixer, Volume Boost and Mixer '
|
||||
'through real Decky 3.3.0 amd64 backends on private silent virtual audio targets. Sliders, numeric volume entry, '
|
||||
@@ -80,7 +80,24 @@ def render(data):
|
||||
'Earlier focused checks cover [ProtonDB](evidence/framey-browser-2026-10-25.md), '
|
||||
'[Steam Status/cache](evidence/framey-cache-browser-2026-10-25.md), '
|
||||
'[Storage Cleaner](evidence/framey-api-v1-browser-2026-10-25.md), and '
|
||||
'[legacy MusicControl](evidence/framey-legacy-2026-10-25.md).', '',
|
||||
'[legacy MusicControl](evidence/framey-legacy-2026-10-25.md). '
|
||||
'A [shared Music Control runtime follow-up](evidence/framey-mpris-runtime-2026-10-26.md) corrects the earlier bound-method fixture error: actual Decky uses the legacy class calling convention, so explicit-self calls must stay. '
|
||||
'Exact managed installs also restore the system library path for D-Bus subprocesses, repairing a real Frame OS libtinfo/glibc failure in MusicControl 1.1.6. '
|
||||
'Both official entries pass transport/seek/volume, Info and reopen in local Chromium with actual Decky 3.3.0 amd64 and a private silent MPRIS service. '
|
||||
'Actual ARM64 Decky on the Frame passes read-only empty-player discovery for both. Existing copies need remove/reinstall or an explicit managed update. '
|
||||
'Actual media playback on ARM64, artwork and worn VR remain unverified; both stay partial. '
|
||||
'A [DeckySpy follow-up](evidence/framey-deckyspy-2026-10-26.md) repairs its omitted psutil dependency with pinned BSD stable-ABI amd64/ARM64 wheels and a plugin-local Frame battery-name fallback. '
|
||||
'Its actual Decky 3.3.0 ARM64 backend passed read-only metrics under the shared lock, with memory independently matched to /proc/meminfo. '
|
||||
'Local opaque Chromium used that live ARM64 backend to render CPU, memory, battery, processes and interfaces, display a silent warning toast and save/reopen a private refresh setting. '
|
||||
'DeckySpy qualifies as works for system monitoring while its panel is open. Automatic alerts, resume/game-time warnings, sound and worn VR remain unverified. '
|
||||
'Cleanup and an independent removal audit passed; battery stayed 100 → 100 and original service states were preserved. Existing copies need update/reinstall. '
|
||||
'A [Simple Timer lifecycle follow-up](evidence/framey-simple-timer-2026-10-26.md) repairs fresh/expired startup cancellation, '
|
||||
'canceled deadlines that resumed and replaced tasks that still notified. Exact managed installs retain the API1 instance convention. '
|
||||
'Published Decky 3.3.0 discovery/RPC/events verify cancel, replacement, completion and active/canceled/expired restarts in isolation; '
|
||||
'the real ARM64 Frame repeats those lifecycle checks under the shared lock. Local Chromium with that live backend verifies '
|
||||
'Begin/Cancel, countdown and a silent completion toast. Simple Timer qualifies as works for silent in-panel session timers. '
|
||||
'Update/reinstall is required; old receipts stay partial. Sound/suspend are unsupported, closed-panel alerts and worn VR unverified. '
|
||||
'The private service, files, test listeners and processes were removed; independent cleanup audit passed and battery stayed 99 → 99.', '',
|
||||
'## Every current plugin', '',
|
||||
'The API column shows manifest / actual bundled frontend API. Many manifests say v1 while their bundle connects API2. '
|
||||
'Missing manifest API is labelled legacy; an unrecognized bundle is unknown. Globals and hardware are static signals, not exhaustive dependencies.', '',
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run every managed backend shim in native, isolated, published Decky 3.3.0.
|
||||
|
||||
Downloads a digest-pinned loader, or accepts --loader for an offline cached copy.
|
||||
Only image build/download use network. All official plugin execution is in
|
||||
network/device/session-isolated disposable Docker containers. Never uses Frame.
|
||||
"""
|
||||
import argparse
|
||||
import ast
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
RELEASE = 'v3.3.0'
|
||||
LOADERS = {
|
||||
'x86_64': ('PluginLoader', 'e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181', 62),
|
||||
'aarch64': ('PluginLoader-arm64', '8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6', 183),
|
||||
}
|
||||
|
||||
|
||||
def check_coverage():
|
||||
"""A new production shim must declare an actual-runtime probe before CI runs."""
|
||||
sys.path.insert(0, str(ROOT/'ui'))
|
||||
import frame_decky_store
|
||||
tree = ast.parse(frame_decky_store.APPLY)
|
||||
shims = set()
|
||||
for node in ast.walk(tree):
|
||||
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
|
||||
and isinstance(node.func.value, ast.Name)
|
||||
and node.func.value.id == 'compat_shims' and node.func.attr == 'append'):
|
||||
if len(node.args) != 1 or not isinstance(node.args[0], ast.Constant) or not isinstance(node.args[0].value, str):
|
||||
raise ValueError('Managed shims must declare a literal name for runtime test coverage')
|
||||
shims.add(node.args[0].value)
|
||||
cases = json.loads((ROOT/'tests/decky-runtime/cases.json').read_text())
|
||||
if shims != {case['shim'] for case in cases}:
|
||||
raise ValueError('Every managed shim needs an actual Decky runtime case')
|
||||
return cases
|
||||
|
||||
|
||||
def loader_bytes(path):
|
||||
arch = platform.machine()
|
||||
if arch not in LOADERS:
|
||||
raise ValueError('Run on native Linux x86_64 or aarch64')
|
||||
asset, digest, machine = LOADERS[arch]
|
||||
if path:
|
||||
data = path.read_bytes()
|
||||
else:
|
||||
url = f'https://github.com/SteamDeckHomebrew/decky-loader/releases/download/{RELEASE}/{asset}'
|
||||
request = urllib.request.Request(url, headers={'User-Agent': 'Frame-Control-runtime-tests'})
|
||||
with urllib.request.urlopen(request, timeout=60) as response:
|
||||
data = response.read(80*1024*1024+1)
|
||||
if hashlib.sha256(data).hexdigest() != digest:
|
||||
raise ValueError('Decky 3.3.0 loader digest mismatch')
|
||||
if data[:4] != b'\x7fELF' or int.from_bytes(data[18:20], 'little') != machine:
|
||||
raise ValueError('Loader architecture does not match the native runner')
|
||||
return data, digest
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--loader', type=Path, help='Optional cached, digest-verified native Decky 3.3.0 binary')
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
if sys.platform != 'linux' or not shutil.which('docker'):
|
||||
parser.error('Linux with Docker is required')
|
||||
cases = check_coverage()
|
||||
data, digest = loader_bytes(args.loader)
|
||||
tag = 'framey-decky-runtime:'+uuid.uuid4().hex[:12]
|
||||
report = {'decky': RELEASE, 'architecture': platform.machine(), 'loaderSha256': digest, 'cases': []}
|
||||
try:
|
||||
subprocess.run(['docker', 'build', '-q', '-t', tag, '-f', str(ROOT/'tests/decky-runtime/Containerfile'),
|
||||
str(ROOT/'tests/decky-runtime')], check=True, timeout=300)
|
||||
with tempfile.TemporaryDirectory(prefix='framey-decky-runtime-') as temp:
|
||||
loader = Path(temp)/'PluginLoader'
|
||||
loader.write_bytes(data)
|
||||
loader.chmod(0o644) # Private container user copies/chmods its own executable.
|
||||
probes = [(case['id'], None) for case in cases] + [
|
||||
(111, '--reproduce-bound-method-regression'), (96, '--reproduce-timer-regression')]
|
||||
for plugin_id, negative in probes:
|
||||
name = 'framey-decky-'+uuid.uuid4().hex[:12]
|
||||
command = ['docker', 'run', '--rm', '--name', name, '--network', 'none', '--read-only',
|
||||
'--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--pids-limit', '128',
|
||||
'--tmpfs', '/tmp:rw,exec,mode=1777',
|
||||
'--tmpfs', '/home/tester:rw,exec,uid=1100,gid=1100,mode=700',
|
||||
'--tmpfs', '/run/user/1100:rw,uid=1100,gid=1100,mode=700',
|
||||
'--mount', f'type=bind,source={ROOT},target=/repo,readonly',
|
||||
'--mount', f'type=bind,source={loader},target=/input/PluginLoader,readonly',
|
||||
tag, str(plugin_id)]
|
||||
if negative:
|
||||
command.append(negative)
|
||||
try:
|
||||
result = subprocess.run(command, capture_output=True, text=True, timeout=120)
|
||||
if result.returncode:
|
||||
raise RuntimeError(f'Decky runtime case {plugin_id} failed:\n{result.stdout}\n{result.stderr}')
|
||||
observation = json.loads(result.stdout)
|
||||
report['cases'].append(observation)
|
||||
print(f"PASS {plugin_id}: {observation['result']}", flush=True)
|
||||
finally:
|
||||
subprocess.run(['docker', 'rm', '-f', name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
finally:
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(report, indent=2)+'\n')
|
||||
subprocess.run(['docker', 'image', 'rm', tag], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -71,13 +71,19 @@ def exercise(profile, plugin_id, folder):
|
||||
if not hit:
|
||||
raise RuntimeError('Native click hit another element: ' + str(panel.eval('({tag:__vrLastTarget?.tagName,text:__vrLastTarget?.textContent?.slice(0,100)})')))
|
||||
|
||||
def click_parent(selector):
|
||||
def parent_point(selector):
|
||||
parent.eval(f'document.querySelector({json.dumps(selector)}).scrollIntoView({{block:"center"}})')
|
||||
time.sleep(.3)
|
||||
pos = parent.eval(f'''(()=>{{let r=document.querySelector({json.dumps(selector)}).getBoundingClientRect();
|
||||
return {{x:r.x+r.width/2,y:r.y+r.height/2,w:innerWidth,h:innerHeight}}}})()''')
|
||||
sx, sy = vr.snapshot()['input']['scale']
|
||||
x, y = pos['x'] * sx / pos['w'], sy - pos['y'] * sy / pos['h']
|
||||
if not 0 <= x <= sx or not 0 <= y <= sy:
|
||||
raise RuntimeError('Parent control is outside the visible viewport')
|
||||
return x, y
|
||||
|
||||
def click_parent(selector):
|
||||
x, y = parent_point(selector)
|
||||
count = parent.eval('window.__vrParentClickCount')
|
||||
vr.post_mouse(300, x, y)
|
||||
vr.post_mouse(301, x, y, 1)
|
||||
@@ -86,6 +92,21 @@ def exercise(profile, plugin_id, folder):
|
||||
if not parent.eval(f'!!window.__vrParentLastTarget?.closest({json.dumps(selector)})'):
|
||||
raise RuntimeError('Native click missed parent control ' + selector)
|
||||
|
||||
def hold_parent(selector, seconds):
|
||||
x, y = parent_point(selector)
|
||||
count = parent.eval('window.__vrParentClickCount')
|
||||
vr.post_mouse(300, x, y)
|
||||
try:
|
||||
vr.post_mouse(301, x, y, 1)
|
||||
time.sleep(seconds)
|
||||
offset = panel.eval('document.getElementById("vr-scroll-probe").scrollTop')
|
||||
finally:
|
||||
vr.post_mouse(302, x, y, 1)
|
||||
wait(lambda:parent.eval('window.__vrParentClickCount'), lambda n:n > count, seconds=3)
|
||||
if not parent.eval(f'!!window.__vrParentLastTarget?.closest({json.dumps(selector)})'):
|
||||
raise RuntimeError('Native hold missed parent control ' + selector)
|
||||
return offset
|
||||
|
||||
try:
|
||||
parent.eval(f'(async()=>{{await refreshDecky();openFrameyPanel({plugin_id},"Framey VR Check");return true}})()')
|
||||
wait(lambda:parent.eval('byId("frameyPanelStatus").innerText'), lambda s:s == 'Plugin panel ready')
|
||||
@@ -172,6 +193,10 @@ def exercise(profile, plugin_id, folder):
|
||||
result['buttonScrollAfterUp'] = wait(
|
||||
lambda:panel.eval('document.getElementById("vr-scroll-probe").scrollTop'), lambda n:n == 0)
|
||||
result['buttonScrollMoved'] = result['buttonScrollAfterDown'] > 0 and result['buttonScrollAfterUp'] == 0
|
||||
result['holdScrollAfterDown'] = hold_parent('#frameyScrollDown', 1.2)
|
||||
result['holdScrollAfterUp'] = hold_parent('#frameyScrollUp', 2.0)
|
||||
result['holdScrollMoved'] = (result['holdScrollAfterDown'] > result['buttonScrollAfterDown']
|
||||
and result['holdScrollAfterUp'] == 0)
|
||||
panel.eval('document.getElementById("vr-scroll-probe").remove()')
|
||||
panel.eval('document.getElementById("vr-controls").remove()')
|
||||
# Scroll saved status into the visible iframe for compositor evidence.
|
||||
@@ -179,7 +204,8 @@ def exercise(profile, plugin_id, folder):
|
||||
result['finalOverlay'] = vr.snapshot()
|
||||
result['passed'] = (result['trustedOverlayPointer'] and result['diskSetting'] == {'enabled':True}
|
||||
and result['reopenedChecked'] and result['sliderValue'] != '10'
|
||||
and result['textValue'] == 'framey-ci' and result['buttonScrollMoved'])
|
||||
and result['textValue'] == 'framey-ci' and result['buttonScrollMoved']
|
||||
and result['holdScrollMoved'])
|
||||
return result
|
||||
except Exception as exc:
|
||||
result['error'] = str(exc)
|
||||
|
||||
@@ -31,6 +31,11 @@ trap 'rm -rf "$tmp"' EXIT
|
||||
gh api "repos/$repo/git/ref/tags/$tag" >/dev/null 2>&1 \
|
||||
|| { echo "tag $tag isn't on GitHub; push it first (git push origin $tag)" >&2; exit 1; }
|
||||
|
||||
# A complete asset list is insufficient: a cancelled installer build can leave
|
||||
# old uploaded bytes behind. Check the exact tagged commit's main/e2e jobs and
|
||||
# the latest exact-tag release attempt before any release mutation.
|
||||
python3 "$(dirname "$0")/verify-release-gates.py" "$tag"
|
||||
|
||||
# Every release, drafts included, one JSON object per line.
|
||||
gh api --paginate "repos/$repo/releases?per_page=100" --jq '.[]' > "$tmp/releases"
|
||||
# The release whose tag_name is the tag; failing that, the one untagged-... draft
|
||||
@@ -56,6 +61,12 @@ json.dump(r, sys.stdout)
|
||||
EOF
|
||||
id=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$tmp/release.json")
|
||||
echo "release $id ($(python3 -c 'import json,sys; r=json.load(open(sys.argv[1])); print(("draft" if r["draft"] else "published") + ", tag_name " + str(r["tag_name"]))' "$tmp/release.json"))"
|
||||
python3 - "$tmp/release.json" <<'EOF'
|
||||
import json, sys
|
||||
release = json.load(open(sys.argv[1]))
|
||||
if (release.get("body") or "").lstrip().startswith("> **WITHDRAWN"):
|
||||
sys.exit("not publishing: this release is marked WITHDRAWN")
|
||||
EOF
|
||||
|
||||
missing=""
|
||||
for name in $expected; do
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Refuse publication unless the exact tag commit and tagged installers passed CI.
|
||||
|
||||
This checks GitHub's current run and job records, not a prior PR's check marks.
|
||||
The real Frame smoke and independent package checks remain release-procedure
|
||||
gates documented in docs/releasing.md.
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
REPO = "saphid/frame-control"
|
||||
MAIN_JOBS = {
|
||||
"checks", "e2e", "ios", "wireless-windows",
|
||||
"server-tests (ubuntu-latest, 3.13)",
|
||||
"server-tests (macos-latest, 3.12)",
|
||||
"server-tests (windows-latest, 3.12)",
|
||||
}
|
||||
RELEASE_JOBS = {
|
||||
"draft", "checksums",
|
||||
"build (windows-latest, dist:win, app/dist/*.exe app/dist/*.zip)",
|
||||
"build (ubuntu-latest, dist:linux, app/dist/*.AppImage app/dist/*.deb)",
|
||||
"build (macos-15-intel, dist:mac:x64, app/dist/*.dmg app/dist/*.zip)",
|
||||
"build (macos-latest, dist, app/dist/*.dmg app/dist/*.zip)",
|
||||
}
|
||||
DECKY_JOBS = {"decky-runtime (ubuntu-latest)", "decky-runtime (ubuntu-24.04-arm)"}
|
||||
|
||||
|
||||
class GateError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def api(path):
|
||||
# publish-release.sh already requires sh. Resolve gh through that same
|
||||
# shell: on Git Bash, Python's Windows CreateProcess skips an extensionless
|
||||
# test gh shim in PATH and accidentally launches the real gh.exe instead.
|
||||
command = ["sh", "-c", 'gh api "$1"', "sh", f"repos/{REPO}/{path}"]
|
||||
result = subprocess.run(command, text=True, capture_output=True, check=False)
|
||||
if result.returncode:
|
||||
raise GateError(f"GitHub gate read failed for {path}: {result.stderr.strip()}")
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except json.JSONDecodeError as error:
|
||||
raise GateError(f"invalid GitHub gate response for {path}: {error}") from error
|
||||
|
||||
|
||||
def tag_commit(tag):
|
||||
target = api(f"git/ref/tags/{quote(tag, safe='')}").get("object") or {}
|
||||
for _ in range(5):
|
||||
kind, sha = target.get("type"), target.get("sha")
|
||||
if not isinstance(sha, str) or len(sha) != 40:
|
||||
raise GateError(f"{tag} has no valid tag target")
|
||||
if kind == "commit":
|
||||
return sha
|
||||
if kind != "tag":
|
||||
raise GateError(f"{tag} points to unsupported Git object {kind!r}")
|
||||
target = api(f"git/tags/{sha}").get("object") or {}
|
||||
raise GateError(f"{tag} has too many nested tag objects")
|
||||
|
||||
|
||||
def exact_run(workflow, branch, sha, event):
|
||||
query = f"actions/workflows/{workflow}/runs?branch={quote(branch, safe='')}&head_sha={sha}&per_page=100"
|
||||
runs = api(query).get("workflow_runs") or []
|
||||
matching = [run for run in runs if run.get("head_sha") == sha
|
||||
and run.get("head_branch") == branch and run.get("event") == event]
|
||||
if not matching:
|
||||
raise GateError(f"no exact {workflow} {event} run for {branch} at {sha}")
|
||||
run = max(matching, key=lambda item: item["id"])
|
||||
if run.get("status") != "completed" or run.get("conclusion") != "success":
|
||||
raise GateError(f"{workflow} run {run['id']} is {run.get('status')}/{run.get('conclusion')}")
|
||||
return run
|
||||
|
||||
|
||||
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):
|
||||
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)")
|
||||
|
||||
|
||||
def main(tag):
|
||||
if not tag.startswith("v") or not tag[1:]:
|
||||
raise GateError("expected a version tag beginning with v")
|
||||
sha = tag_commit(tag)
|
||||
successful_jobs(exact_run("checks.yml", "main", sha, "push"), MAIN_JOBS)
|
||||
successful_jobs(exact_run("decky-runtime.yml", "main", sha, "push"), DECKY_JOBS)
|
||||
successful_jobs(exact_run("release.yml", tag, sha, "push"), RELEASE_JOBS)
|
||||
print(f"exact release commit: {sha}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main(sys.argv[1])
|
||||
except (IndexError, GateError) as error:
|
||||
sys.exit(f"release gate failed: {error}")
|
||||
@@ -0,0 +1,12 @@
|
||||
# Native amd64 or ARM64; no emulation, host session bus, audio, devices or home.
|
||||
FROM debian:trixie-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 python3-dbus python3-gi python3-websockets dbus-bin dbus-daemon \
|
||||
ca-certificates zlib1g \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd -m -u 1100 -s /bin/sh tester \
|
||||
&& mkdir -p /run/user/1100 && chown tester:tester /run/user/1100
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 XDG_RUNTIME_DIR=/run/user/1100
|
||||
USER tester
|
||||
WORKDIR /repo
|
||||
ENTRYPOINT ["python3", "/repo/tests/decky-runtime/check.py"]
|
||||
@@ -0,0 +1,27 @@
|
||||
[
|
||||
{
|
||||
"id": 12,
|
||||
"shim": "music-control-system-dbus-env",
|
||||
"probe": "mpris"
|
||||
},
|
||||
{
|
||||
"id": 111,
|
||||
"shim": "music-control-system-dbus-env",
|
||||
"probe": "mpris"
|
||||
},
|
||||
{
|
||||
"id": 53,
|
||||
"shim": "shotty-click-app-dir",
|
||||
"probe": "screenshots"
|
||||
},
|
||||
{
|
||||
"id": 89,
|
||||
"shim": "deckyspy-psutil-and-battery",
|
||||
"probe": "system-monitor"
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"shim": "simple-timer-lifecycle",
|
||||
"probe": "timer-lifecycle"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Container-only real Decky backend discovery and smoke tests; no host services."""
|
||||
import argparse
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import zipfile
|
||||
import io
|
||||
|
||||
import websockets
|
||||
from websockets.exceptions import WebSocketException
|
||||
|
||||
ROOT = Path('/repo')
|
||||
sys.path[:0] = [str(ROOT/'ui'), str(ROOT/'tests')]
|
||||
import frame_decky_store
|
||||
from framey_official import PLUGINS, archive
|
||||
|
||||
|
||||
async def request(route, args):
|
||||
token = urllib.request.urlopen('http://127.0.0.1:1337/auth/token', timeout=2).read().decode()
|
||||
async with websockets.connect('ws://127.0.0.1:1337/ws?auth='+token,
|
||||
open_timeout=3, close_timeout=2) as ws:
|
||||
await ws.send(json.dumps({'type': 0, 'id': 1, 'route': route, 'args': args}))
|
||||
while True:
|
||||
response = json.loads(await asyncio.wait_for(ws.recv(), timeout=8))
|
||||
if response.get('id') != 1:
|
||||
continue
|
||||
await ws.send(json.dumps({'type': 3, 'id': 1}))
|
||||
assert response['type'] == 1, response
|
||||
return response['result']
|
||||
|
||||
|
||||
def rpc(route, *args):
|
||||
return asyncio.run(request(route, list(args)))
|
||||
|
||||
|
||||
def until_ready(read, description):
|
||||
deadline = time.monotonic()+20
|
||||
last = None
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
value = read()
|
||||
if value:
|
||||
return value
|
||||
except (OSError, ValueError, KeyError, TimeoutError, WebSocketException) as error:
|
||||
last = str(error)
|
||||
time.sleep(.1)
|
||||
raise RuntimeError(f'{description} not ready: {last}')
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('id', type=int)
|
||||
parser.add_argument('--reproduce-bound-method-regression', action='store_true')
|
||||
parser.add_argument('--reproduce-timer-regression', action='store_true')
|
||||
args = parser.parse_args()
|
||||
assert os.getuid() == 1100 and ROOT.is_dir(), 'Run through framey-test-decky-runtime.py'
|
||||
record = next(p for p in PLUGINS if p['id'] == args.id)
|
||||
case = next(p for p in json.loads((ROOT/'tests/decky-runtime/cases.json').read_text()) if p['id'] == args.id)
|
||||
data = archive(record)
|
||||
assert hashlib.sha256(data).hexdigest() == record['sha256']
|
||||
assert frame_decky_store.archive_info(data, record['sha256'], record['name']) == record['folder']
|
||||
home = Path.home()
|
||||
base = home/'.local/share/frame-control/decky'
|
||||
(base/'plugins').mkdir(parents=True)
|
||||
unit = home/'.config/systemd/user/frame-control-decky.service'
|
||||
unit.parent.mkdir(parents=True)
|
||||
unit.write_text('# Private fixture marker; no real service manager is contacted.\n')
|
||||
tools = home/'bin'
|
||||
tools.mkdir()
|
||||
systemctl = tools/'systemctl'
|
||||
systemctl.write_text('#!/bin/sh\ncase "$*" in *is-active*) exit 3;; *) exit 0;; esac\n')
|
||||
systemctl.chmod(0o700)
|
||||
env = dict(os.environ, PATH=str(tools)+':'+os.environ['PATH'])
|
||||
Path('/tmp/frame-control-decky-plugin-runtime-test').write_bytes(data)
|
||||
install = subprocess.run([sys.executable, '-', 'install', 'runtime-test', str(record['id']),
|
||||
record['sha256'], record['folder'], record['version'], '-'], input=frame_decky_store.apply_script(record['id'], record['sha256']),
|
||||
capture_output=True, text=True, env=env, timeout=15)
|
||||
assert install.returncode == 0, install.stderr
|
||||
plugin = base/'plugins'/record['folder']
|
||||
receipt = json.loads((plugin/'.frame-control-store.json').read_text())
|
||||
if not args.reproduce_timer_regression:
|
||||
assert receipt['compatShims'] == [case['shim']], receipt
|
||||
else:
|
||||
assert record['id'] == 96
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as original:
|
||||
(plugin/'main.py').write_bytes(original.read(record['folder']+'/main.py'))
|
||||
# Exercise the production manifest's API convention, never import Plugin ourselves.
|
||||
manifest = json.loads((plugin/'plugin.json').read_text())
|
||||
assert manifest.get('api_version', 'legacy') == record['api_version'], manifest
|
||||
if args.reproduce_bound_method_regression:
|
||||
assert record['id'] == 111
|
||||
source = (plugin/'main.py').read_text()
|
||||
for old, new in [('self._get_dbus_env(self)', 'self._get_dbus_env()'),
|
||||
('self._sp_player_dbus(self,', 'self._sp_player_dbus(')]:
|
||||
assert old in source
|
||||
source = source.replace(old, new)
|
||||
(plugin/'main.py').write_text(source)
|
||||
|
||||
(home/'.local/share/Steam/steamui/images').mkdir(parents=True)
|
||||
loader = base/'PluginLoader'
|
||||
shutil.copy('/input/PluginLoader', loader)
|
||||
loader.chmod(0o700)
|
||||
env.update(UNPRIVILEGED_PATH=str(base), PRIVILEGED_PATH=str(base), CHOWN_PLUGIN_PATH='0',
|
||||
LIVE_RELOAD='0', USE_CEF_CLOSE_WORKAROUND='0', KEEP_SYSTEMD_SERVICE='1',
|
||||
DBUS_SESSION_BUS_ADDRESS='unix:path=/run/user/1100/bus')
|
||||
processes = []
|
||||
report = {'id': record['id'], 'name': record['name'], 'version': record['version'],
|
||||
'sha256': record['sha256'], 'shim': case['shim'], 'calls': []}
|
||||
with (home/'runtime.log').open('w+') as log:
|
||||
try:
|
||||
if case['probe'] == 'mpris':
|
||||
bus = subprocess.Popen(['dbus-daemon', '--session', '--nofork',
|
||||
'--address='+env['DBUS_SESSION_BUS_ADDRESS']], env=env, stdout=log, stderr=log,
|
||||
start_new_session=True)
|
||||
processes.append(bus)
|
||||
until_ready(lambda: Path('/run/user/1100/bus').is_socket(), 'private session bus')
|
||||
player = subprocess.Popen([sys.executable, str(ROOT/'tests/fixtures/framey-mpris/player.py')],
|
||||
env=env, stdout=log, stderr=log, start_new_session=True)
|
||||
processes.append(player)
|
||||
until_ready(lambda: Path('/tmp/mpris-state.json').is_file(), 'silent MPRIS player')
|
||||
backend = subprocess.Popen([str(loader)], env=env, stdout=log, stderr=log, start_new_session=True)
|
||||
processes.append(backend)
|
||||
discovered = until_ready(lambda: rpc('loader/get_plugins'), 'published Decky plugin discovery')
|
||||
assert len(discovered) == 1 and discovered[0]['name'] == record['name'], discovered
|
||||
assert discovered[0]['version'] == record['version'] and not discovered[0]['disabled'], discovered
|
||||
report['discovered'] = discovered
|
||||
|
||||
def legacy(method, **kwargs):
|
||||
response = rpc('loader/call_legacy_plugin_method', record['name'], method, kwargs)
|
||||
assert response['success'] is True, (method, response)
|
||||
report['calls'].append({'method': method, 'result': response['result']})
|
||||
return response['result']
|
||||
|
||||
if case['probe'] == 'timer-lifecycle':
|
||||
from timer_lifecycle import probe
|
||||
|
||||
def restart(settings=None):
|
||||
nonlocal backend
|
||||
os.killpg(backend.pid, signal.SIGTERM)
|
||||
backend.wait(timeout=8)
|
||||
if settings is not None:
|
||||
path = base/'settings'/record['folder']/'settings.json'
|
||||
assert path.exists(), path
|
||||
path.write_text(json.dumps(settings))
|
||||
backend = subprocess.Popen([str(loader)], env=env, stdout=log, stderr=log,
|
||||
start_new_session=True)
|
||||
processes.append(backend)
|
||||
until_ready(lambda: rpc('loader/get_plugins'), 'restarted Decky discovery')
|
||||
until_ready(lambda: rpc('loader/call_plugin_method', record['name'],
|
||||
'settings_getSetting', 'subtle_mode', False), 'restarted instance RPC')
|
||||
|
||||
report['lifecycle'] = probe(record['name'], restart, home/'runtime.log',
|
||||
args.reproduce_timer_regression)
|
||||
report['result'] = ('upstream timer lifecycle regressions reproduced' if args.reproduce_timer_regression
|
||||
else 'discovery, cancel, replace, completion and persistent restart passed')
|
||||
elif args.reproduce_bound_method_regression:
|
||||
response = rpc('loader/call_legacy_plugin_method', record['name'], 'sp_list_media_players', {})
|
||||
assert response['success'] is False and 'self' in response['result'], response
|
||||
report['expectedRegression'] = response
|
||||
report['result'] = 'historical bad shim rejected by actual Decky legacy dispatch'
|
||||
elif case['probe'] == 'mpris':
|
||||
player_name = 'org.mpris.MediaPlayer2.FrameySilent'
|
||||
assert legacy('sp_list_media_players') == player_name
|
||||
legacy('sp_set_media_player', player=player_name)
|
||||
assert legacy('sp_get_media_player') == player_name
|
||||
assert legacy('sp_identity', orgPath=player_name) == 'Player' # Upstream truncation.
|
||||
metadata = legacy('get_meta_data')
|
||||
assert 'title|Silent Test Track' in metadata and 'length|180000000' in metadata, metadata
|
||||
assert legacy('sp_track_status') == 'Paused'
|
||||
assert legacy('sp_track_progress') == '12000000'
|
||||
assert legacy('sp_can_seek') == 'true'
|
||||
assert float(legacy('sp_get_volume')) == .4
|
||||
assert legacy('sp_test_volume_control') == 'true'
|
||||
assert json.loads(Path('/tmp/mpris-state.json').read_text())['Volume'] == .4
|
||||
legacy('sp_play')
|
||||
assert legacy('sp_track_status') == 'Playing'
|
||||
legacy('sp_pause')
|
||||
legacy('sp_next')
|
||||
legacy('sp_previous')
|
||||
legacy('sp_seek', amount=2000000)
|
||||
assert legacy('sp_track_progress') == '14000000'
|
||||
legacy('sp_set_position', position=45000000, trackid='/org/framey/TestTrack/1')
|
||||
legacy('sp_set_volume', volume=.25)
|
||||
state = json.loads(Path('/tmp/mpris-state.json').read_text())
|
||||
assert state['PlaybackStatus'] == 'Paused' and state['Volume'] == .25, state
|
||||
assert state['next'] == state['previous'] == 1 and state['Position'] == 45000000, state
|
||||
report['independentState'] = state
|
||||
report['result'] = 'discovery, metadata, transport, seek and volume passed'
|
||||
elif case['probe'] == 'system-monitor':
|
||||
def metric(method):
|
||||
response = legacy(method)
|
||||
if response['code'] != 0:
|
||||
raise ValueError(response['data'])
|
||||
return json.loads(response['data'])
|
||||
assert legacy('get_version') == {'code': 0, 'data': record['version']}
|
||||
memory = until_ready(lambda: metric('get_memory'), 'DeckySpy stats thread')
|
||||
assert memory['vmem']['total'] > 0 and 0 <= memory['vmem']['percent'] <= 100, memory
|
||||
cpu = metric('get_cpu')
|
||||
assert 0 <= cpu <= 100, cpu
|
||||
assert metric('get_boottime') > 0
|
||||
assert isinstance(metric('get_battery')['battery'], bool)
|
||||
assert isinstance(metric('get_net_interface'), list)
|
||||
assert isinstance(legacy('get_settings', key='framey.runtime-test', default=True)['data'], bool)
|
||||
report['result'] = 'discovery, native psutil import and read-only system metrics passed'
|
||||
else:
|
||||
assert case['probe'] == 'screenshots'
|
||||
assert legacy('was_rescued') is False
|
||||
assert legacy('aggregate_all', allapps=[]) == 0
|
||||
report['result'] = 'discovery, backend import and empty screenshot aggregation passed'
|
||||
log.flush()
|
||||
log.seek(0)
|
||||
assert 'Starting Decky version v3.3.0' in log.read()
|
||||
print(json.dumps(report))
|
||||
except Exception:
|
||||
log.flush()
|
||||
log.seek(0)
|
||||
print(log.read()[-12000:], file=sys.stderr)
|
||||
raise
|
||||
finally:
|
||||
for process in reversed(processes):
|
||||
if process.poll() is None:
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
continue
|
||||
try:
|
||||
process.wait(timeout=4)
|
||||
except subprocess.TimeoutExpired:
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
process.wait(timeout=4)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Simple Timer lifecycle through actual Decky instance dispatch, never Plugin()."""
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
import websockets
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
self.sequence = 0
|
||||
self.events = []
|
||||
|
||||
async def __aenter__(self):
|
||||
token = urllib.request.urlopen('http://127.0.0.1:1337/auth/token', timeout=2).read().decode()
|
||||
self.ws = await websockets.connect('ws://127.0.0.1:1337/ws?auth='+token)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
await self.ws.close()
|
||||
|
||||
async def receive(self, timeout=8):
|
||||
response = json.loads(await asyncio.wait_for(self.ws.recv(), timeout))
|
||||
if response.get('type') == 5 and response.get('event') == 'loader/plugin_event':
|
||||
event = response['args'][0]
|
||||
if event['plugin'] == self.name:
|
||||
self.events.append(event)
|
||||
return response
|
||||
|
||||
async def call(self, method, *args, success=True):
|
||||
self.sequence += 1
|
||||
await self.ws.send(json.dumps({'type': 0, 'id': self.sequence,
|
||||
'route': 'loader/call_plugin_method', 'args': [self.name, method, *args]}))
|
||||
while True:
|
||||
response = await self.receive()
|
||||
if response.get('id') != self.sequence:
|
||||
continue
|
||||
await self.ws.send(json.dumps({'type': 3, 'id': self.sequence}))
|
||||
if success:
|
||||
assert response['type'] == 1, response
|
||||
return response['result']
|
||||
return response
|
||||
|
||||
async def seconds(self):
|
||||
offset = len(self.events)
|
||||
await self.call('load_remaining_seconds')
|
||||
while not any(e['event'] == 'simple_timer_seconds_updated' for e in self.events[offset:]):
|
||||
await self.receive()
|
||||
return next(e['args'][0] for e in reversed(self.events)
|
||||
if e['event'] == 'simple_timer_seconds_updated')
|
||||
|
||||
async def drain(self, seconds):
|
||||
deadline = time.monotonic()+seconds
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
await self.receive(max(.01, deadline-time.monotonic()))
|
||||
except TimeoutError:
|
||||
break
|
||||
|
||||
|
||||
async def initial(name, negative):
|
||||
async with Client(name) as client:
|
||||
await client.call('set_subtle_mode', True) # No sound or physical actions.
|
||||
response = await client.call('cancel_timer', success=False)
|
||||
if negative:
|
||||
assert response['type'] == -1 and 'timer_task' in str(response), response
|
||||
else:
|
||||
assert response['type'] == 1, response
|
||||
await client.call('start_timer', 60)
|
||||
end = await client.call('settings_getSetting', 'timer_end', None)
|
||||
assert end > time.time()
|
||||
await client.call('cancel_timer')
|
||||
saved = await client.call('settings_getSetting', 'timer_end', None)
|
||||
assert (saved is not None) if negative else (saved is None), saved
|
||||
await client.call('start_timer', 1)
|
||||
await client.call('start_timer', 60)
|
||||
await client.drain(6.5) # Past upstream's 5-second tick and first timer's expiry.
|
||||
completed = [e for e in client.events if e['event'] == 'simple_timer_event']
|
||||
if negative:
|
||||
assert completed, client.events
|
||||
else:
|
||||
assert not completed, completed
|
||||
assert await client.seconds() > 45
|
||||
await client.call('cancel_timer')
|
||||
if negative:
|
||||
return {'freshCancelError': response, 'cancelRetainsEnd': True,
|
||||
'replacedTimerStillFires': True}
|
||||
await client.call('start_timer', 1)
|
||||
client.events.clear()
|
||||
await client.drain(5.5)
|
||||
completed = [e for e in client.events if e['event'] == 'simple_timer_event']
|
||||
assert len(completed) == 1 and completed[0]['args'] == ['Your session has ended!', True], completed
|
||||
assert await client.seconds() == 0
|
||||
assert await client.call('settings_getSetting', 'timer_end', None) is None
|
||||
client.events.clear()
|
||||
await client.call('start_timer', -20) # An already expired, private silent deadline.
|
||||
await client.drain(.3)
|
||||
expired = [e for e in client.events if e['event'] == 'simple_timer_event']
|
||||
assert len(expired) == 1 and expired[0]['args'] == ['Your timer has expired!', True], expired
|
||||
assert await client.call('settings_getSetting', 'timer_end', None) is None
|
||||
await client.call('start_timer', 60)
|
||||
return {'end': await client.call('settings_getSetting', 'timer_end', None),
|
||||
'recents': await client.call('settings_getSetting', 'recent_timers_seconds', [])}
|
||||
|
||||
|
||||
async def resumed(name, saved):
|
||||
async with Client(name) as client:
|
||||
assert 0 < await client.seconds() < 60
|
||||
assert await client.call('settings_getSetting', 'timer_end', None) == saved['end']
|
||||
assert await client.call('settings_getSetting', 'recent_timers_seconds', []) == saved['recents']
|
||||
await client.call('cancel_timer')
|
||||
assert await client.call('settings_getSetting', 'timer_end', None) is None
|
||||
|
||||
|
||||
async def idle(name):
|
||||
async with Client(name) as client:
|
||||
assert await client.seconds() == 0
|
||||
assert await client.call('settings_getSetting', 'timer_end', None) is None
|
||||
await client.call('cancel_timer') # Idempotent before any new timer.
|
||||
assert await client.call('settings_getSetting', 'subtle_mode', False) is True
|
||||
|
||||
|
||||
def probe(name, restart, log, negative=False):
|
||||
saved = asyncio.run(initial(name, negative))
|
||||
if negative:
|
||||
restart({'timer_end': time.time()-60, 'subtle_mode': True})
|
||||
asyncio.run(read_after_startup(name))
|
||||
assert "has no attribute 'timer_task'" in log.read_text(), log.read_text()[-6000:]
|
||||
return saved | {'expiredRestartError': True}
|
||||
restart()
|
||||
asyncio.run(resumed(name, saved))
|
||||
restart()
|
||||
asyncio.run(idle(name))
|
||||
restart({'timer_end': time.time()-60, 'subtle_mode': True})
|
||||
asyncio.run(idle(name))
|
||||
return {'freshCancel': True, 'replacementSuppressesOldCompletion': True,
|
||||
'completionClearsEnd': True, 'lateExpiryEmitsWithoutSelfCancel': True,
|
||||
'activeRestartPreservesDeadlineAndRecents': True,
|
||||
'canceledRestartIdle': True, 'expiredRestartIdle': True}
|
||||
|
||||
|
||||
async def read_after_startup(name):
|
||||
async with Client(name) as client:
|
||||
# _main is scheduled independently; allow it to finish before reading its journal.
|
||||
await client.call('settings_getSetting', 'subtle_mode', False)
|
||||
await client.drain(.3)
|
||||
@@ -185,6 +185,9 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
|
||||
expected = hashlib.sha256(bundle.read(plugin['folder']+'/dist/index.js')).hexdigest()
|
||||
self.assertEqual(ssh(f'sha256sum {installed}/dist/index.js').split()[0], expected)
|
||||
self.assertEqual(json.loads(ssh(f'cat {installed}/plugin.json')).get('api_version', 'legacy'), plugin['api_version'])
|
||||
if plugin['id'] == 96:
|
||||
self.assertEqual(found[0]['compatShims'], ['simple-timer-lifecycle'])
|
||||
self.assertEqual(found[0]['compatibility']['status'], 'works')
|
||||
if plugin['id'] == 135:
|
||||
chunk = 'chunks/pluginApi-B8sV7970.js'
|
||||
with zipfile.ZipFile(io.BytesIO(official_archive(plugin))) as bundle:
|
||||
@@ -196,6 +199,13 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
|
||||
self.assertEqual(ssh(f'test -f {installed}/py_modules/click.py && echo yes').strip(), 'yes')
|
||||
receipt = json.loads(ssh(f'cat {installed}/.frame-control-store.json'))
|
||||
self.assertEqual(receipt['compatShims'], ['shotty-click-app-dir'])
|
||||
if plugin['id'] == 89:
|
||||
receipt = json.loads(ssh(f'cat {installed}/.frame-control-store.json'))
|
||||
self.assertEqual(receipt['compatShims'], ['deckyspy-psutil-and-battery'])
|
||||
self.assertEqual(ssh(f'test -f {installed}/py_modules/psutil/_psutil_linux.abi3.so && test -f {installed}/py_modules/framey_battery.py && echo yes').strip(), 'yes')
|
||||
if plugin['id'] in (12, 111):
|
||||
receipt = json.loads(ssh(f'cat {installed}/.frame-control-store.json'))
|
||||
self.assertEqual(receipt['compatShims'], ['music-control-system-dbus-env'])
|
||||
job = finished(ok('POST', '/api/decky/plugin',
|
||||
{'action':'remove','id':plugin['id'],'acknowledged':True}))
|
||||
self.assertFalse(job.get('error'), job)
|
||||
|
||||
+37
-1
@@ -13,6 +13,7 @@ import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
args = sys.argv[1:]
|
||||
with open(os.environ["FAKEGH_LOG"], "a") as f:
|
||||
@@ -49,6 +50,18 @@ while i < len(args):
|
||||
|
||||
releases = json.load(open(os.environ["FAKEGH_RELEASES"]))
|
||||
repo = "repos/saphid/frame-control/"
|
||||
gate = json.load(open(os.environ["FAKEGH_GATES"])) if os.environ.get("FAKEGH_GATES") else {}
|
||||
commit = gate.get("tag_commit", "a" * 40)
|
||||
tag_object = "b" * 40
|
||||
main_jobs = ("checks", "e2e", "ios", "wireless-windows",
|
||||
"server-tests (ubuntu-latest, 3.13)", "server-tests (macos-latest, 3.12)",
|
||||
"server-tests (windows-latest, 3.12)")
|
||||
release_jobs = ("draft", "checksums",
|
||||
"build (windows-latest, dist:win, app/dist/*.exe app/dist/*.zip)",
|
||||
"build (ubuntu-latest, dist:linux, app/dist/*.AppImage app/dist/*.deb)",
|
||||
"build (macos-15-intel, dist:mac:x64, app/dist/*.dmg app/dist/*.zip)",
|
||||
"build (macos-latest, dist, app/dist/*.dmg app/dist/*.zip)")
|
||||
decky_jobs = ("decky-runtime (ubuntu-latest)", "decky-runtime (ubuntu-24.04-arm)")
|
||||
|
||||
|
||||
def answer(response, fallback):
|
||||
@@ -67,7 +80,30 @@ if method == "GET" and endpoint.startswith(repo + "git/ref/tags/"):
|
||||
tag = endpoint.rsplit("/", 1)[1]
|
||||
if tag not in os.environ.get("FAKEGH_TAGS", "").split():
|
||||
sys.exit("gh: Not Found (HTTP 404)")
|
||||
print(json.dumps({"ref": "refs/tags/" + tag}))
|
||||
print(json.dumps({"ref": "refs/tags/" + tag,
|
||||
"object": {"type": "tag", "sha": tag_object}}))
|
||||
elif method == "GET" and endpoint == repo + "git/tags/" + tag_object:
|
||||
print(json.dumps({"object": {"type": "commit", "sha": commit}}))
|
||||
elif method == "GET" and endpoint.startswith(repo + "actions/workflows/"):
|
||||
path, _, query = endpoint.partition("?")
|
||||
workflow = path.split("/")[5]
|
||||
branch = parse_qs(query).get("branch", [""])[0]
|
||||
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,
|
||||
"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]
|
||||
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"}
|
||||
for name in names]
|
||||
print(json.dumps({"total_count": len(jobs), "jobs": jobs}))
|
||||
elif method == "GET" and endpoint.startswith(repo + "releases?"):
|
||||
# The fallback is what --jq '.[]' prints.
|
||||
answer(releases, "\n".join(json.dumps(r) for r in releases))
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
# Silent MPRIS runtime fixture
|
||||
|
||||
`player.py` implements the synthetic target used by the
|
||||
[Music Control runtime evidence](../../../docs/evidence/framey-mpris-runtime-2026-10-26.md).
|
||||
It produces no audio. It requires system Python `dbus`, `gi` and GLib, and writes
|
||||
its independent control state to `/tmp/mpris-state.json`.
|
||||
|
||||
Run it only in a disposable container with **no network, devices, host home,
|
||||
audio or D-Bus mounts**. Create a private session bus listening at
|
||||
`/run/user/<container UID>/bus`: both official backends hard-code that path.
|
||||
Set `DBUS_SESSION_BUS_ADDRESS` to that private socket for the fixture. Use a
|
||||
private home including `.local/share/Steam/steamui/images`, because the upstream
|
||||
plugins create an album-art symlink on startup. Never run these plugins against
|
||||
a real user's player during this test: ID111 probes volume automatically.
|
||||
|
||||
Install each pinned archive separately through the managed transaction, then
|
||||
start the published Decky 3.3.0 loader with private `UNPRIVILEGED_PATH` and
|
||||
`PRIVILEGED_PATH`. Do not instantiate the legacy Plugin class: invoke methods
|
||||
through Decky's `loader/call_legacy_plugin_method` route with its keyword object.
|
||||
The two official entries share the `MusicControl` folder, so cannot coexist.
|
||||
This fixture is opt-in and is not imported by ordinary unit tests.
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
"""Silent MPRIS target. Run only inside the private network/device-isolated container."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import dbus, dbus.service, dbus.mainloop.glib
|
||||
from gi.repository import GLib
|
||||
P='org.mpris.MediaPlayer2.Player'
|
||||
R='org.mpris.MediaPlayer2'
|
||||
D='org.freedesktop.DBus.Properties'
|
||||
TRACK='/org/framey/TestTrack/1'
|
||||
class Player(dbus.service.Object):
|
||||
def __init__(self,bus):
|
||||
self.name=dbus.service.BusName('org.mpris.MediaPlayer2.FrameySilent',bus=bus)
|
||||
super().__init__(bus,'/org/mpris/MediaPlayer2')
|
||||
self.state={'PlaybackStatus':'Paused','Position':12000000,'Volume':0.4,'next':0,'previous':0,'writes':[]}
|
||||
self.save()
|
||||
def save(self):Path('/tmp/mpris-state.json').write_text(json.dumps(self.state))
|
||||
@dbus.service.method(D,in_signature='ss',out_signature='v')
|
||||
def Get(self,interface,prop):
|
||||
if interface==R and prop=='Identity':return dbus.String('Framey Silent Player')
|
||||
if interface==P:
|
||||
if prop=='Metadata':return dbus.Dictionary({'mpris:trackid':dbus.ObjectPath(TRACK),'mpris:length':dbus.Int64(180000000),'xesam:title':dbus.String('Silent Test Track'),'xesam:artist':dbus.Array(['Fixture Artist'],signature='s'),'xesam:album':dbus.String('Framey Test Album')},signature='sv')
|
||||
if prop=='PlaybackStatus':return dbus.String(self.state[prop])
|
||||
if prop=='Position':return dbus.Int64(self.state[prop])
|
||||
if prop=='Volume':return dbus.Double(self.state[prop])
|
||||
if prop=='CanSeek':return dbus.Boolean(True)
|
||||
raise dbus.exceptions.DBusException('Unknown property')
|
||||
@dbus.service.method(D,in_signature='ssv',out_signature='')
|
||||
def Set(self,interface,prop,value):
|
||||
assert interface==P and prop=='Volume' and 0<=value<=1
|
||||
self.state['Volume']=float(value);self.state['writes'].append(['Volume',float(value)]);self.save()
|
||||
@dbus.service.method(P,in_signature='',out_signature='')
|
||||
def PlayPause(self):
|
||||
self.state['PlaybackStatus']='Playing' if self.state['PlaybackStatus']=='Paused' else 'Paused';self.state['writes'].append(['PlayPause']);self.save()
|
||||
@dbus.service.method(P,in_signature='',out_signature='')
|
||||
def Pause(self):
|
||||
self.state['PlaybackStatus']='Paused';self.state['writes'].append(['Pause']);self.save()
|
||||
@dbus.service.method(P,in_signature='',out_signature='')
|
||||
def Next(self):
|
||||
self.state['next']+=1;self.state['writes'].append(['Next']);self.save()
|
||||
@dbus.service.method(P,in_signature='',out_signature='')
|
||||
def Previous(self):
|
||||
self.state['previous']+=1;self.state['writes'].append(['Previous']);self.save()
|
||||
@dbus.service.method(P,in_signature='x',out_signature='')
|
||||
def Seek(self,offset):
|
||||
self.state['Position']+=int(offset);self.state['writes'].append(['Seek',int(offset)]);self.save()
|
||||
@dbus.service.method(P,in_signature='ox',out_signature='')
|
||||
def SetPosition(self,track,pos):
|
||||
assert str(track)==TRACK and 0<=pos<=180000000
|
||||
self.state['Position']=int(pos);self.state['writes'].append(['SetPosition',int(pos)]);self.save()
|
||||
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
|
||||
player=Player(dbus.SessionBus())
|
||||
print('READY silent MPRIS fixture',flush=True)
|
||||
GLib.MainLoop().run()
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+13
-3
@@ -1,14 +1,17 @@
|
||||
# Pinned Decky store test archives
|
||||
|
||||
These are unmodified ZIPs downloaded from the official Decky store CDN on
|
||||
2026-10-25. Each ZIP includes its upstream `LICENSE`. They are test data, not
|
||||
plugins shipped or installed by Frame Control. The fake Frame tests serve them
|
||||
2026-10-25, except `96.zip` and `111.zip` downloaded on 2026-10-26. Each ZIP includes its
|
||||
upstream `LICENSE`. They are test data, not plugins shipped or installed by
|
||||
Frame Control. The fake Frame tests serve them
|
||||
from a local fixture so CI does not depend on the store being online.
|
||||
|
||||
| File | Official plugin | Version | Store SHA-256 |
|
||||
| --- | --- | --- | --- |
|
||||
| `96.zip` | Simple Timer | 1.0.0 | `973d927e9d9a1feec04679c564af7e389a729b6735d2a988ffc2cb46ce4eb312` |
|
||||
| `7.zip` | CSS Loader | 2.1.3 | `99beaf1374af82408e6d056e0b0147e5fca544e1035fa7ab88419231697bc231` |
|
||||
| `12.zip` | MusicControl | 1.1.6 | `246b89fd653c60a735b1cc401d1b0937d5cb969eea9024561661ff741f081d62` |
|
||||
| `111.zip` | Music Control | 1.1.7 | `3f56a3f56034b277ea74a2c37cc84133b69e382aa6bcb0b43876776aac737ed9` |
|
||||
| `14.zip` | ProtonDB Badges | 1.3.4 | `90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358` |
|
||||
| `123.zip` | Steam Status | 1.2.3 | `9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e` |
|
||||
| `50.zip` | Storage Cleaner | 1.4.0 | `1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591` |
|
||||
@@ -17,9 +20,10 @@ from a local fixture so CI does not depend on the store being online.
|
||||
| `36.zip` | SteamGridDB | 1.8.1 | `e84f5b1f111af8862b06218479491fbf61036e1bfd9f16f93f9901e9b9ebc5e0` |
|
||||
| `135.zip` | Deck Shelves | 2.0.0 | `b566280ad38fd3ecd584b5b46a855f301539eb9a86063834c64b7045f7063f91` |
|
||||
|
||||
The matching source projects are
|
||||
The matching source projects include [Simple Timer](https://github.com/decktools-xyz/simple-timer) and
|
||||
[CSS Loader](https://github.com/suchmememanyskill/SDH-CssLoader),
|
||||
[MusicControl](https://github.com/nikurasu/MusicControl),
|
||||
[Music Control](https://github.com/martinpl/music-control),
|
||||
[ProtonDB Badges](https://github.com/bschelst/protondb-decky),
|
||||
[Steam Status](https://github.com/bschelst/steamstatus-decky),
|
||||
[Storage Cleaner](https://github.com/mcarlucci/decky-storage-cleaner),
|
||||
@@ -38,3 +42,9 @@ Bluetooth result and limits are in
|
||||
Deck Shelves' chunked frontend renders its settings in local Chromium with
|
||||
backend/network calls denied. Its Steam Home shelves cannot be verified in the
|
||||
isolated VR panel; no worn-headset test is claimed.
|
||||
|
||||
Simple Timer also runs through actual published Decky 3.3.0 discovery, instance
|
||||
RPC and events in the native isolated runtime gate. Cancel/replace/completion and
|
||||
active/canceled/expired backend restarts are regression checks; the unmodified
|
||||
archive must reproduce its historical lifecycle errors. Only private silent
|
||||
timers/settings are used. The upstream BSD license stays unchanged.
|
||||
@@ -4,7 +4,13 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).parent / 'fixtures/framey-official'
|
||||
PLUGINS = (
|
||||
{'id': 89, 'name': 'DeckySpy', 'version': '0.6.7', 'api_version': 'legacy',
|
||||
'folder': 'decky-spy', 'sha256': '2d431a89826ed5cf3fd37258373eb3b46c841c39795aa8b25f83e59f4eee87c7'},
|
||||
{'id': 96, 'name': 'Simple Timer', 'version': '1.0.0', 'api_version': 1,
|
||||
'folder': 'simple-timer', 'sha256': '973d927e9d9a1feec04679c564af7e389a729b6735d2a988ffc2cb46ce4eb312'},
|
||||
{'id': 12, 'name': 'MusicControl', 'version': '1.1.6', 'api_version': 'legacy', 'folder': 'MusicControl', 'sha256': '246b89fd653c60a735b1cc401d1b0937d5cb969eea9024561661ff741f081d62'},
|
||||
{'id': 111, 'name': 'Music Control', 'version': '1.1.7', 'api_version': 'legacy',
|
||||
'folder': 'MusicControl', 'sha256': '3f56a3f56034b277ea74a2c37cc84133b69e382aa6bcb0b43876776aac737ed9'},
|
||||
{'id': 14, 'name': 'ProtonDB Badges', 'version': '1.3.4', 'api_version': 2,
|
||||
'folder': 'protondb-decky', 'sha256': '90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358'},
|
||||
{'id': 123, 'name': 'Steam Status', 'version': '1.2.3', 'api_version': 2,
|
||||
|
||||
@@ -5,6 +5,7 @@ import vm from 'node:vm';
|
||||
const html=readFileSync(new URL('../../ui/headset.html',import.meta.url),'utf8');
|
||||
const source=html.match(/<script>([\s\S]*?)<\/script>/)[1].split('refresh();refreshGames();')[0];
|
||||
const elements=new Map(),listeners=[],requests=[],replies=[];
|
||||
const timeouts=new Map(),intervals=new Map();let nextTimer=0;
|
||||
const el=id=>{if(!elements.has(id))elements.set(id,{removeAttribute(){},scrollIntoView(){}});return elements.get(id);};
|
||||
const frame={postMessage:data=>replies.push(data)};
|
||||
el('frameyFrame').contentWindow=frame;
|
||||
@@ -12,7 +13,10 @@ const context=vm.createContext({document:{getElementById:el,createElement:()=>({
|
||||
location:{hash:'',pathname:'/'},history:{replaceState(){}},
|
||||
sessionStorage:{getItem:()=>null,setItem(){}},localStorage:{getItem:()=>null,setItem(){}},
|
||||
window:{addEventListener:(type,fn)=>listeners.push({type,fn})},
|
||||
setTimeout(){},setInterval(){},console,
|
||||
setTimeout(fn){const id=++nextTimer;timeouts.set(id,fn);return id;},
|
||||
clearTimeout(id){timeouts.delete(id);},
|
||||
setInterval(fn){const id=++nextTimer;intervals.set(id,fn);return id;},
|
||||
clearInterval(id){intervals.delete(id);},console,
|
||||
fetch:(path,options)=>new Promise(resolve=>requests.push({path,body:JSON.parse(options.body),resolve}))});
|
||||
vm.runInContext(source,context);
|
||||
vm.runInContext('deckyState={running:true,plugins:[{storeId:96,sha256:"'+'a'.repeat(64)+'",version:"1"}]};openFrameyPanel(96,"Timer")',context);
|
||||
@@ -101,13 +105,58 @@ el('frameyScrollDown').onclick();
|
||||
assert.equal(replies.at(-1).type,'framey:scroll');
|
||||
assert.equal(replies.at(-1).session,scrollSession);
|
||||
assert.equal(replies.at(-1).direction,'down');
|
||||
const down=el('frameyScrollDown');
|
||||
const beforeHold=replies.length;
|
||||
down.onpointerdown({button:0,isPrimary:true,pointerId:7});
|
||||
assert.equal(replies.length,beforeHold+1,'pointer press scrolls once');
|
||||
down.onclick({detail:1});
|
||||
assert.equal(replies.length,beforeHold+1,'pointer click must not scroll twice');
|
||||
timeouts.get(nextTimer)();
|
||||
assert.equal(intervals.size,1);
|
||||
intervals.get(nextTimer)();
|
||||
assert.equal(replies.length,beforeHold+2,'held pointer scrolls again');
|
||||
down.onpointerup({pointerId:7});
|
||||
assert.equal(intervals.size,0,'release stops scrolling');
|
||||
down.onclick({detail:0});
|
||||
assert.equal(replies.length,beforeHold+3,'keyboard click scrolls once');
|
||||
await send({type:'framey:scroll:result',session:'old',direction:'down',moved:true});
|
||||
assert.equal(el('frameyPanelStatus').textContent,'Loading plugin panel…');
|
||||
await send({type:'framey:scroll:result',session:scrollSession,direction:'down',moved:true});
|
||||
assert.equal(el('frameyPanelStatus').textContent,'Scrolled plugin down');
|
||||
await send({type:'framey:scroll:result',session:scrollSession,direction:'down',moved:false});
|
||||
assert.equal(el('frameyPanelStatus').textContent,'No more plugin content in that direction');
|
||||
down.onpointerdown({button:0,isPrimary:true,pointerId:8});
|
||||
timeouts.get(nextTimer)();
|
||||
assert.equal(intervals.size,1);
|
||||
await send({type:'framey:scroll:result',session:scrollSession,direction:'down',moved:false});
|
||||
assert.equal(intervals.size,0,'end of content stops held scrolling');
|
||||
down.onpointerdown({button:0,isPrimary:true,pointerId:9});
|
||||
const lastHoldTimer=nextTimer;
|
||||
assert.equal(timeouts.has(lastHoldTimer),true);
|
||||
listeners.find(({type})=>type==='blur').fn();
|
||||
assert.equal(timeouts.has(lastHoldTimer),false,'loss of panel focus cancels a held scroll');
|
||||
// WindowProxy also survives panel navigation for lifecycle and input messages.
|
||||
// An old iframe must not mark a replacement ready, hide its error, or record
|
||||
// controller evidence for the new panel.
|
||||
const beforeStatusRequests=requests.length;
|
||||
for(const type of ['framey:ready','framey:input','framey:error']) {
|
||||
await send({type,session:'old',message:'old panel failed'});
|
||||
await send({type,message:'missing session'});
|
||||
}
|
||||
assert.equal(requests.length,beforeStatusRequests);
|
||||
assert.equal(el('frameyPanelStatus').textContent,'No more plugin content in that direction');
|
||||
await send({type:'framey:ready',session:scrollSession});
|
||||
assert.equal(el('frameyPanelStatus').textContent,'Plugin panel ready');
|
||||
assert.equal(requests.at(-1).path,'/api/framey/check');
|
||||
assert.equal(requests.at(-1).body.kind,'panel');
|
||||
await send({type:'framey:input',session:scrollSession});
|
||||
assert.equal(requests.at(-1).body.kind,'input');
|
||||
await send({type:'framey:error',session:scrollSession,message:'current panel failed'});
|
||||
assert.equal(el('frameyPanelStatus').textContent,'Plugin panel unavailable: current panel failed');
|
||||
down.onpointerdown({button:0,isPrimary:true,pointerId:10});
|
||||
const closeTimer=nextTimer;
|
||||
el('frameyClose').onclick();
|
||||
assert.equal(timeouts.has(closeTimer),false,'closing the panel cancels a held scroll');
|
||||
const replyCount=replies.length;
|
||||
el('frameyScrollUp').onclick();
|
||||
assert.equal(replies.length,replyCount);
|
||||
@@ -20,13 +20,15 @@ const elements=new Map(), roots=new Map();
|
||||
const document={getElementById:id=>{
|
||||
if(!elements.has(id))elements.set(id,new Element());return elements.get(id);
|
||||
},createElement:()=>new Element()};
|
||||
const window={React,ReactDOM:{createRoot:node=>{
|
||||
const window={React,location:{hash:'#sample-session'},ReactDOM:{createRoot:node=>{
|
||||
const root={render(value){this.value=value;},unmount(){this.unmounted=true;}};
|
||||
roots.set(node,root);return root;
|
||||
}},addEventListener(){}};
|
||||
const listeners=[];
|
||||
const calls=[];
|
||||
const messages=[];
|
||||
const parent={postMessage(message){
|
||||
messages.push(message);
|
||||
if(message.type!=='framey:call')return;
|
||||
calls.push(message);
|
||||
listeners.find(x=>x.type==='message').handler({source:parent,
|
||||
@@ -36,6 +38,12 @@ const context=vm.createContext({window,document,
|
||||
navigator:{language:'en'},parent,addEventListener:(type,handler)=>listeners.push({type,handler}),
|
||||
setTimeout:()=>1,clearTimeout(){}});
|
||||
vm.runInContext(host,context);
|
||||
const pointerup=listeners.find(listener=>listener.type==='pointerup').handler;
|
||||
pointerup({isTrusted:false});
|
||||
assert.equal(messages.some(message=>message.type==='framey:input'),false);
|
||||
pointerup({isTrusted:true});
|
||||
assert.equal(messages.at(-1).type,'framey:input');
|
||||
assert.equal(messages.at(-1).session,'sample-session');
|
||||
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();
|
||||
|
||||
@@ -244,3 +244,46 @@ vm.runInContext('api=mockApi',ctx);
|
||||
await vm.runInContext('refreshDecky()',ctx);
|
||||
assert.equal(el('frameyPanel').hidden,true);
|
||||
assert.match(el('deckyState').textContent,/panel was closed until status can be refreshed/);
|
||||
|
||||
// A denied sessionStorage must not lose an accepted job in the current panel.
|
||||
// Keep Update/Remove disabled after a polling outage and allow Check result.
|
||||
for(const method of ['getItem','setItem','removeItem'])
|
||||
ctx.sessionStorage[method]=()=>{throw Error('storage denied');};
|
||||
let accepted=0;
|
||||
ctx.mockApi=async path => {
|
||||
if(path==='/api/decky/plugin'){accepted++;return {job:'memory-job'};}
|
||||
if(path.startsWith('/api/job'))throw Error('connection lost');
|
||||
if(path==='/api/decky')return {managed:true,running:true,
|
||||
plugins:[{storeId:2,name:'Plugin 2',version:'1.0'}]};
|
||||
throw Error(`unexpected ${path}`);
|
||||
};
|
||||
vm.runInContext('api=mockApi;deckyState={managed:true,running:true,plugins:[]};' +
|
||||
'deckyStore=[{id:2,name:"Plugin 2",version:"1.0",description:"A panel",root:false}];showDeckyStore()',ctx);
|
||||
await vm.runInContext('deckyChange("install",{id:2,name:"Plugin 2",sha256:"a".repeat(64)})',ctx);
|
||||
assert.equal(accepted,1);
|
||||
assert.equal(vm.runInContext('deckyJobPending()',ctx),true);
|
||||
assert.equal(el('deckyJobCheck').hidden,false);
|
||||
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Remove').disabled,true);
|
||||
await vm.runInContext('deckyChange("remove",{id:2,name:"Plugin 2"})',ctx);
|
||||
assert.equal(accepted,1);
|
||||
ctx.mockApi=async path => {
|
||||
if(path.startsWith('/api/job'))return {done:true,message:'Installed Plugin 2'};
|
||||
if(path==='/api/decky')return {managed:true,running:true,
|
||||
plugins:[{storeId:2,name:'Plugin 2',version:'1.0'}]};
|
||||
throw Error(`unexpected ${path}`);
|
||||
};
|
||||
vm.runInContext('api=mockApi',ctx);
|
||||
await vm.runInContext('checkDeckyJob()',ctx);
|
||||
assert.equal(vm.runInContext('deckyJobPending()',ctx),false);
|
||||
assert.equal(el('deckyJobCheck').hidden,true);
|
||||
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Remove').disabled,false);
|
||||
|
||||
// Opening the headset panel with a key in its URL must survive denied storage.
|
||||
const deniedDocument={getElementById:id=>new Element(id),createElement:()=>new Element()};
|
||||
const deniedBoot=vm.createContext({document:deniedDocument,
|
||||
location:{hash:'#private-key',pathname:'/'},history:{replaceState(){}},
|
||||
sessionStorage:{getItem(){throw Error('storage denied');},setItem(){throw Error('storage denied');}},
|
||||
window:{addEventListener(){}},setInterval(){},setTimeout(){},confirm:()=>true,console,
|
||||
fetch(){throw Error('No network expected');}});
|
||||
vm.runInContext(script,deniedBoot);
|
||||
assert.equal(vm.runInContext('key',deniedBoot),'private-key');
|
||||
@@ -276,12 +276,29 @@ class ArtworkTests(unittest.TestCase):
|
||||
import threading
|
||||
from apk_sources import _images
|
||||
gate = threading.Event()
|
||||
with patch.object(_images.socket, 'getaddrinfo', side_effect=lambda *a, **k: gate.wait(5) and []):
|
||||
start = time.monotonic()
|
||||
with self.assertRaisesRegex(_images.SourceError, 'too long'):
|
||||
_images.get('https://example.org/a.png', deadline=start + 0.1)
|
||||
self.assertLess(time.monotonic() - start, 0.4)
|
||||
gate.set()
|
||||
real_join = threading.Thread.join
|
||||
waits = []
|
||||
|
||||
def bounded_join(worker, timeout=None):
|
||||
waits.append(timeout)
|
||||
return real_join(worker, timeout)
|
||||
|
||||
try:
|
||||
with patch.object(_images.socket, 'getaddrinfo', side_effect=lambda *a, **k: gate.wait(5) and []), \
|
||||
patch.object(_images.threading.Thread, 'join', bounded_join):
|
||||
start = time.monotonic()
|
||||
deadline = start + 0.1
|
||||
with self.assertRaisesRegex(_images.SourceError, 'too long'):
|
||||
_images.get('https://example.org/a.png', deadline=deadline)
|
||||
# Check the requested wait, not elapsed runner time: a loaded CI
|
||||
# host can deschedule this test after the bounded join returns.
|
||||
self.assertEqual(len(waits), 1)
|
||||
self.assertIsNotNone(waits[0])
|
||||
self.assertGreaterEqual(waits[0], 0)
|
||||
self.assertLessEqual(waits[0], deadline - start)
|
||||
finally:
|
||||
gate.set()
|
||||
wait_for_idle_resolvers()
|
||||
with self.assertRaisesRegex(_images.SourceError, 'too long'):
|
||||
_images.get('https://example.org/a.png', deadline=time.monotonic() - 1)
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ class StoreTransaction(unittest.TestCase):
|
||||
digest = hashlib.sha256(data or b'').hexdigest()
|
||||
try:
|
||||
return subprocess.run([sys.executable, '-', action, token, str(plugin_id), digest, folder, version, expected],
|
||||
input=frame_decky_store.APPLY, text=True, capture_output=True,
|
||||
input=frame_decky_store.apply_script(plugin_id, digest), text=True, capture_output=True,
|
||||
env=self.env, timeout=15)
|
||||
finally:
|
||||
source.unlink(missing_ok=True)
|
||||
@@ -161,6 +161,71 @@ class StoreTransaction(unittest.TestCase):
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertTrue(external.exists())
|
||||
|
||||
@unittest.skipUnless(__import__('platform').machine() in ('x86_64', 'aarch64'), 'native Linux wheel architecture')
|
||||
def test_exact_deckyspy_gets_native_dependency_and_license_only_in_its_folder(self):
|
||||
from framey_official import PLUGINS, archive as official_archive
|
||||
import platform
|
||||
plugin = next(p for p in PLUGINS if p['id'] == 89)
|
||||
result = self.apply('install', official_archive(plugin), plugin_id=89,
|
||||
folder=plugin['folder'], version=plugin['version'])
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
folder = self.folder.parent / plugin['folder']
|
||||
receipt = json.loads((folder / '.frame-control-store.json').read_text())
|
||||
self.assertEqual(receipt['compatShims'], ['deckyspy-psutil-and-battery'])
|
||||
modules = folder / 'py_modules'
|
||||
native = (modules / 'psutil/_psutil_linux.abi3.so').read_bytes()
|
||||
self.assertEqual(native[:4], b'\x7fELF')
|
||||
self.assertEqual(int.from_bytes(native[18:20], 'little'),
|
||||
{'x86_64': 62, 'aarch64': 183}[platform.machine()])
|
||||
self.assertIn('BSD 3-Clause', (modules / 'psutil-7.2.2.dist-info/LICENSE').read_text())
|
||||
result = self.apply('remove', plugin_id=89, folder=plugin['folder'])
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse(folder.exists())
|
||||
|
||||
@unittest.skipUnless(__import__('platform').machine() in ('x86_64', 'aarch64'), 'native Linux wheel architecture')
|
||||
def test_deckyspy_missing_or_corrupt_payload_leaves_no_installed_copy(self):
|
||||
from framey_official import PLUGINS, archive as official_archive
|
||||
plugin = next(p for p in PLUGINS if p['id'] == 89)
|
||||
for prefix, error in [('', 'Missing native psutil'),
|
||||
("FRAMEY_BACKEND_WHEELS = {'x86_64':'AAAA','aarch64':'AAAA'}\n",
|
||||
'SHA-256 mismatch')]:
|
||||
with patch.object(frame_decky_store, 'apply_script', return_value=prefix+frame_decky_store.APPLY):
|
||||
result = self.apply('install', official_archive(plugin), plugin_id=89,
|
||||
folder=plugin['folder'], version=plugin['version'])
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn(error, result.stderr)
|
||||
self.assertFalse((self.folder.parent / plugin['folder']).exists())
|
||||
def test_exact_simple_timer_gets_lifecycle_repair_and_keeps_api1_and_license(self):
|
||||
from framey_official import PLUGINS, archive as official_archive
|
||||
plugin = next(p for p in PLUGINS if p['id'] == 96)
|
||||
result = self.apply('install', official_archive(plugin), plugin_id=96,
|
||||
folder=plugin['folder'], version=plugin['version'])
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
folder = self.folder.parent / plugin['folder']
|
||||
self.assertEqual(json.loads((folder / '.frame-control-store.json').read_text())['compatShims'],
|
||||
['simple-timer-lifecycle'])
|
||||
self.assertEqual(json.loads((folder / 'plugin.json').read_text())['api_version'], 1)
|
||||
with zipfile.ZipFile(io.BytesIO(official_archive(plugin))) as original:
|
||||
self.assertEqual((folder / 'LICENSE').read_bytes(), original.read('simple-timer/LICENSE'))
|
||||
# Backend behavior is verified separately by the real Decky lifecycle gate.
|
||||
result = self.apply('remove', plugin_id=96, folder=plugin['folder'])
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse(folder.exists())
|
||||
|
||||
def test_changed_simple_timer_digest_does_not_get_pinned_repair(self):
|
||||
from framey_official import PLUGINS, archive as official_archive
|
||||
plugin = next(p for p in PLUGINS if p['id'] == 96)
|
||||
data = io.BytesIO(official_archive(plugin))
|
||||
with zipfile.ZipFile(data, 'a') as changed:
|
||||
original_main = changed.read('simple-timer/main.py')
|
||||
changed.writestr('simple-timer/new-version.txt', 'unrecognized new payload')
|
||||
result = self.apply('install', data.getvalue(), plugin_id=96,
|
||||
folder=plugin['folder'], version=plugin['version'])
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
folder = self.folder.parent / plugin['folder']
|
||||
self.assertEqual((folder / 'main.py').read_bytes(), original_main)
|
||||
self.assertEqual(json.loads((folder / '.frame-control-store.json').read_text()).get('compatShims', []), [])
|
||||
|
||||
def test_exact_shotty_archive_gets_isolated_click_import(self):
|
||||
from framey_official import PLUGINS, archive as official_archive
|
||||
plugin = next(p for p in PLUGINS if p['id'] == 53)
|
||||
@@ -214,6 +279,115 @@ assert copied.read_bytes() == b'local screenshot fixture'
|
||||
self.assertFalse((shotty / 'py_modules/click.py').exists())
|
||||
self.assertNotIn('compatShims', json.loads((shotty / '.frame-control-store.json').read_text()))
|
||||
|
||||
def test_legacy_music_control_keeps_decky_class_calling_convention(self):
|
||||
from framey_official import PLUGINS, archive as official_archive
|
||||
plugin = next(p for p in PLUGINS if p['id'] == 111)
|
||||
data = official_archive(plugin)
|
||||
self.assertEqual(hashlib.sha256(data).hexdigest(), plugin['sha256'])
|
||||
folder = self.folder.parent / plugin['folder']
|
||||
args = {'plugin_id': plugin['id'], 'folder': plugin['folder'], 'version': plugin['version']}
|
||||
result = self.apply('install', data, **args)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
receipt = json.loads((folder / '.frame-control-store.json').read_text())
|
||||
self.assertEqual(receipt['sha256'], plugin['sha256'])
|
||||
self.assertEqual(receipt['compatShims'], ['music-control-system-dbus-env'])
|
||||
self.assertNotIn('api_version', json.loads((folder / 'plugin.json').read_text()))
|
||||
self.assertIn('self._get_dbus_env(self)', (folder / 'main.py').read_text())
|
||||
|
||||
dbus = Path(self.tmp.name) / 'dbus-send'
|
||||
dbus.write_text('''#!/usr/bin/env python3
|
||||
import os, sys
|
||||
from pathlib import Path
|
||||
args = ' '.join(sys.argv[1:])
|
||||
assert os.environ['LD_LIBRARY_PATH'] == os.environ.get('LD_LIBRARY_PATH_ORIG', '')
|
||||
with Path(os.environ['MPRIS_CALLS']).open('a') as log: log.write(args + '\\n')
|
||||
if 'ListNames' in args:
|
||||
print('method return\\n array [\\n string "org.freedesktop.DBus"\\n string "org.mpris.MediaPlayer2.fixture"\\n ]')
|
||||
elif 'string:PlaybackStatus' in args:
|
||||
print('method return\\n variant string "Paused"')
|
||||
else:
|
||||
print('method return')
|
||||
''')
|
||||
dbus.chmod(0o700)
|
||||
calls = Path(self.tmp.name) / 'mpris-calls'
|
||||
source = '''import asyncio, importlib.util, os, sys, types
|
||||
from pathlib import Path
|
||||
decky = types.ModuleType('decky_plugin')
|
||||
sys.modules['decky_plugin'] = decky
|
||||
spec = importlib.util.spec_from_file_location('music_control', Path(sys.argv[1])/'main.py')
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
# Decky 3.3 uses the class for manifests with no api_version.
|
||||
plugin = module.Plugin
|
||||
async def check():
|
||||
assert await plugin.sp_list_media_players(plugin) == 'org.mpris.MediaPlayer2.fixture'
|
||||
await plugin.sp_set_media_player(plugin, 'org.mpris.MediaPlayer2.fixture')
|
||||
assert await plugin.sp_get_media_player(plugin) == 'org.mpris.MediaPlayer2.fixture'
|
||||
await plugin.sp_play(plugin)
|
||||
await plugin.sp_pause(plugin)
|
||||
await plugin.sp_next(plugin)
|
||||
await plugin.sp_previous(plugin)
|
||||
await plugin.sp_set_volume(plugin, 0.25)
|
||||
assert await plugin.sp_track_status(plugin) == 'Paused'
|
||||
asyncio.run(check())
|
||||
assert os.environ['LD_LIBRARY_PATH'] == 'FrameyBundledLibraries'
|
||||
'''
|
||||
called = subprocess.run([sys.executable, '-c', source, str(folder)],
|
||||
capture_output=True, text=True,
|
||||
env={**self.env, 'MPRIS_CALLS': str(calls), 'LD_LIBRARY_PATH': 'FrameyBundledLibraries',
|
||||
'LD_LIBRARY_PATH_ORIG': 'FrameySystemLibraries'}, timeout=10)
|
||||
self.assertEqual(called.returncode, 0, called.stderr)
|
||||
logged = calls.read_text()
|
||||
self.assertIn('org.freedesktop.DBus.ListNames', logged)
|
||||
self.assertIn('org.mpris.MediaPlayer2.Player.PlayPause', logged)
|
||||
self.assertIn('org.mpris.MediaPlayer2.Player.Pause', logged)
|
||||
self.assertIn('string:Volume variant:double:0.25', logged)
|
||||
self.assertIn('org.mpris.MediaPlayer2.fixture', logged)
|
||||
# An explicit same-version update replaces the previously shipped bad shim.
|
||||
main = folder / 'main.py'
|
||||
main.write_text(main.read_text().replace('self._get_dbus_env(self)', 'self._get_dbus_env()'))
|
||||
receipt['compatShims'] = ['music-control-bound-methods']
|
||||
(folder / '.frame-control-store.json').write_text(json.dumps(receipt))
|
||||
result = self.apply('update', data, expected=plugin['sha256'], **args)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
called = subprocess.run([sys.executable, '-c', source, str(folder)],
|
||||
capture_output=True, text=True,
|
||||
env={**self.env, 'MPRIS_CALLS': str(calls),
|
||||
'LD_LIBRARY_PATH': 'FrameyBundledLibraries'}, timeout=10)
|
||||
self.assertEqual(called.returncode, 0, called.stderr)
|
||||
result = self.apply('remove', expected=plugin['sha256'], **args)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse(folder.exists())
|
||||
|
||||
older = next(p for p in PLUGINS if p['id'] == 12)
|
||||
old_data = official_archive(older)
|
||||
old_args = {'plugin_id': older['id'], 'folder': older['folder'], 'version': older['version']}
|
||||
result = self.apply('install', old_data, **old_args)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(json.loads((folder / '.frame-control-store.json').read_text())['compatShims'],
|
||||
['music-control-system-dbus-env'])
|
||||
self.assertNotIn('api_version', json.loads((folder / 'plugin.json').read_text()))
|
||||
self.assertIn('self._get_dbus_env(self)', (folder / 'main.py').read_text())
|
||||
old_env = {**self.env, 'MPRIS_CALLS': str(calls), 'LD_LIBRARY_PATH': 'FrameyBundledLibraries'}
|
||||
old_env.pop('LD_LIBRARY_PATH_ORIG', None)
|
||||
called = subprocess.run([sys.executable, '-c', source, str(folder)],
|
||||
capture_output=True, text=True, env=old_env, timeout=10)
|
||||
self.assertEqual(called.returncode, 0, called.stderr)
|
||||
result = self.apply('remove', expected=older['sha256'], **old_args)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
# A future archive at the same store ID must retain its own source.
|
||||
other = io.BytesIO()
|
||||
untouched = 'class Plugin: pass\n'
|
||||
with zipfile.ZipFile(other, 'w') as bundle:
|
||||
bundle.writestr('MusicControl/plugin.json', json.dumps({'name': 'Music Control', 'flags': []}))
|
||||
bundle.writestr('MusicControl/dist/index.js', 'export default {};' + ' ' * 1200)
|
||||
bundle.writestr('MusicControl/main.py', untouched)
|
||||
result = self.apply('install', other.getvalue(), **args)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual((folder / 'main.py').read_text(), untouched)
|
||||
self.assertNotIn('compatShims', json.loads((folder / '.frame-control-store.json').read_text()))
|
||||
|
||||
def test_installed_script_entrypoints_execute_without_zip_exec_bits(self):
|
||||
data, _ = archive(extra=('Example/scripts/start.py',
|
||||
b'#!/bin/sh\nprintf "Framey script: %s\\n" "$1"\n'))
|
||||
|
||||
@@ -26,6 +26,8 @@ class HeadsetBundle(unittest.TestCase):
|
||||
self.assertIn('ui/headset.html', names)
|
||||
self.assertIn('ui/frame_headset_runtime.py', names)
|
||||
self.assertIn('ui/server.py', names)
|
||||
for name in ('aarch64.whl', 'x86_64.whl', 'LICENSE.txt'):
|
||||
self.assertIn('ui/vendor/psutil-7.2.2/'+name, names)
|
||||
self.assertFalse(any('\\' in name for name in names))
|
||||
self.assertFalse(any('__pycache__' in name for name in names))
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Frame battery names are not the BAT/battery names psutil searches."""
|
||||
import sandbox # noqa: F401
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'ui'))
|
||||
import framey_battery
|
||||
|
||||
|
||||
class FrameBattery(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.root = Path(self.tmp.name)
|
||||
self.battery = self.root/'max1720x_bat_7-36'
|
||||
self.battery.mkdir()
|
||||
self.psutil = SimpleNamespace(sensors_battery=lambda: None,
|
||||
POWER_TIME_UNLIMITED=-2, POWER_TIME_UNKNOWN=-1)
|
||||
self.patch = patch.dict(sys.modules, {'psutil': self.psutil})
|
||||
self.patch.start()
|
||||
self.addCleanup(self.patch.stop)
|
||||
|
||||
def files(self, **values):
|
||||
for name, value in values.items():
|
||||
(self.battery/name).write_text(str(value))
|
||||
|
||||
def test_frame_name_uses_type_capacity_and_status_without_writes(self):
|
||||
self.files(type='Battery', capacity=99, status='Full')
|
||||
result = framey_battery.sensors_battery(self.root)
|
||||
self.assertEqual((result.percent, result.power_plugged, result.secsleft), (99, True, -2))
|
||||
self.assertEqual((self.battery/'capacity').read_text(), '99')
|
||||
|
||||
def test_time_to_empty_is_seconds_and_missing_time_stays_unknown(self):
|
||||
self.files(type='Battery', capacity=42, status='Discharging', time_to_empty_now=1234)
|
||||
self.assertEqual(framey_battery.sensors_battery(self.root).secsleft, 1234)
|
||||
(self.battery/'time_to_empty_now').unlink()
|
||||
self.assertEqual(framey_battery.sensors_battery(self.root).secsleft, -1)
|
||||
|
||||
def test_unknown_charge_state_is_not_claimed_as_plugged(self):
|
||||
self.files(type='Battery', capacity=42, status='Unknown')
|
||||
result = framey_battery.sensors_battery(self.root)
|
||||
self.assertIsNone(result.power_plugged)
|
||||
self.assertEqual(result.secsleft, -1)
|
||||
|
||||
def test_native_psutil_result_is_preserved_and_invalid_supplies_are_ignored(self):
|
||||
native = object()
|
||||
self.psutil.sensors_battery = lambda: native
|
||||
self.assertIs(framey_battery.sensors_battery(self.root), native)
|
||||
self.psutil.sensors_battery = lambda: None
|
||||
for values in [dict(type='USB', capacity=99, status='Charging'),
|
||||
dict(type='Battery', capacity=101, status='Full'),
|
||||
dict(type='Battery', capacity=50, status='Full', present=0)]:
|
||||
self.files(**values)
|
||||
self.assertIsNone(framey_battery.sensors_battery(self.root))
|
||||
self.assertIsNone(framey_battery.sensors_battery(self.root/'missing'))
|
||||
@@ -59,6 +59,27 @@ class Compatibility(unittest.TestCase):
|
||||
frame_decky_store.change(no_ssh, 'install', 42)
|
||||
download.assert_not_called()
|
||||
|
||||
def test_old_deckyspy_copy_does_not_inherit_fixed_install_status(self):
|
||||
plugin = next(p for p in json.loads(compatibility.DATA.read_text())['plugins'] if p['id'] == 89)
|
||||
installed = {'plugins': [dict(storeId=89, version=plugin['version'], sha256=plugin['sha256']),
|
||||
dict(storeId=89, version=plugin['version'], sha256=plugin['sha256'],
|
||||
compatShims=['deckyspy-psutil-and-battery'])]}
|
||||
result = frame_decky.status(lambda *a, **k: json.dumps(installed))['plugins']
|
||||
self.assertEqual(result[0]['compatibility']['status'], 'partial')
|
||||
self.assertIn('Update or reinstall', result[0]['compatibility']['reason'])
|
||||
self.assertEqual(result[1]['compatibility']['status'], 'works')
|
||||
self.assertEqual(compatibility.lookup(89, plugin['sha256'], plugin['version'])['status'], 'works')
|
||||
|
||||
def test_old_simple_timer_copy_requires_lifecycle_reinstall(self):
|
||||
plugin = next(p for p in json.loads(compatibility.DATA.read_text())['plugins'] if p['id'] == 96)
|
||||
installed = {'plugins': [dict(storeId=96, version=plugin['version'], sha256=plugin['sha256']),
|
||||
dict(storeId=96, version=plugin['version'], sha256=plugin['sha256'],
|
||||
compatShims=['simple-timer-lifecycle'])]}
|
||||
result = frame_decky.status(lambda *a, **k: json.dumps(installed))['plugins']
|
||||
self.assertEqual(result[0]['compatibility']['status'], 'partial')
|
||||
self.assertIn('Update or reinstall', result[0]['compatibility']['reason'])
|
||||
self.assertEqual(result[1]['compatibility']['status'], 'works')
|
||||
|
||||
def test_missing_audit_data_is_unverified(self):
|
||||
with patch.object(compatibility, 'DATA', Path('/nonexistent/framey-audit.json')):
|
||||
self.assertEqual(compatibility.lookup(12, 'a'*64, '1.0')['status'], 'unverified')
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Managed backend shims must stay covered by the actual-Decky CI gate."""
|
||||
import sandbox # noqa: F401
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
spec = importlib.util.spec_from_file_location('decky_runtime_gate', ROOT/'scripts/framey-test-decky-runtime.py')
|
||||
gate = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(gate)
|
||||
|
||||
|
||||
class RuntimeCoverage(unittest.TestCase):
|
||||
def test_current_shims_have_unique_official_archive_probes(self):
|
||||
from framey_official import PLUGINS
|
||||
cases = gate.check_coverage()
|
||||
official = {record['id'] for record in PLUGINS}
|
||||
self.assertEqual(len(cases), len({case['id'] for case in cases}))
|
||||
for case in cases:
|
||||
self.assertIn(case['id'], official)
|
||||
self.assertIn(case['probe'], ('mpris', 'screenshots', 'system-monitor', 'timer-lifecycle'))
|
||||
|
||||
def test_new_shim_without_runtime_case_fails_coverage(self):
|
||||
import frame_decky_store
|
||||
with patch.object(frame_decky_store, 'APPLY', frame_decky_store.APPLY+
|
||||
'\ncompat_shims.append("uncovered-new-shim")\n'):
|
||||
with self.assertRaisesRegex(ValueError, 'Every managed shim'):
|
||||
gate.check_coverage()
|
||||
|
||||
def test_dynamic_shim_names_cannot_evade_inventory(self):
|
||||
import frame_decky_store
|
||||
with patch.object(frame_decky_store, 'APPLY', frame_decky_store.APPLY+
|
||||
'\ncompat_shims.append(variable_name)\n'):
|
||||
with self.assertRaisesRegex(ValueError, 'literal name'):
|
||||
gate.check_coverage()
|
||||
@@ -1,6 +1,7 @@
|
||||
"""The author example stores settings as a normal Decky user plugin."""
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -36,7 +37,8 @@ class SamplePlugin(unittest.TestCase):
|
||||
self.assertTrue(asyncio.run(first.set_setting('enabled', True)))
|
||||
second = backend(folder)
|
||||
self.assertTrue(asyncio.run(second.get_setting('enabled', False)))
|
||||
self.assertTrue((Path(folder) / 'settings.json').is_file())
|
||||
self.assertEqual(json.loads((Path(folder) / 'settings.json').read_text()),
|
||||
{'schema': 1, 'enabled': True})
|
||||
|
||||
def test_backend_rejects_unknown_keys_and_non_boolean_values(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
@@ -57,6 +59,43 @@ class SamplePlugin(unittest.TestCase):
|
||||
self.assertTrue(asyncio.run(plugin.set_setting('enabled', True)))
|
||||
self.assertTrue(asyncio.run(backend(folder).get_setting('enabled', False)))
|
||||
|
||||
def test_legacy_boolean_survives_restart_and_migrates_on_save(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
path = Path(folder) / 'settings.json'
|
||||
path.write_text('{"enabled": true}\n')
|
||||
self.assertTrue(asyncio.run(backend(folder).get_setting('enabled', False)))
|
||||
self.assertEqual(json.loads(path.read_text()), {'enabled': True},
|
||||
'Reading must not rewrite a user setting')
|
||||
self.assertTrue(asyncio.run(backend(folder).set_setting('enabled', False)))
|
||||
self.assertEqual(json.loads(path.read_text()), {'schema': 1, 'enabled': False})
|
||||
self.assertFalse(asyncio.run(backend(folder).get_setting('enabled', True)))
|
||||
|
||||
def test_newer_schema_is_preserved_on_read_and_save(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
path = Path(folder) / 'settings.json'
|
||||
original = '{"schema": 2, "enabled": true, "future": "keep"}\n'
|
||||
path.write_text(original)
|
||||
plugin = backend(folder)
|
||||
with self.assertRaisesRegex(ValueError, 'unsupported sample version'):
|
||||
asyncio.run(plugin.get_setting('enabled', False))
|
||||
with self.assertRaisesRegex(ValueError, 'unsupported sample version'):
|
||||
asyncio.run(plugin.set_setting('enabled', False))
|
||||
self.assertEqual(path.read_text(), original)
|
||||
self.assertEqual(list(Path(folder).glob('.settings-*')), [])
|
||||
|
||||
def test_failed_atomic_replace_keeps_previous_setting(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
path = Path(folder) / 'settings.json'
|
||||
plugin = backend(folder)
|
||||
self.assertTrue(asyncio.run(plugin.set_setting('enabled', True)))
|
||||
previous = path.read_bytes()
|
||||
with patch('os.replace', side_effect=OSError('storage unavailable')):
|
||||
with self.assertRaisesRegex(OSError, 'storage unavailable'):
|
||||
asyncio.run(plugin.set_setting('enabled', False))
|
||||
self.assertEqual(path.read_bytes(), previous)
|
||||
self.assertEqual(list(Path(folder).glob('.settings-*')), [])
|
||||
self.assertTrue(asyncio.run(backend(folder).get_setting('enabled', False)))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -37,12 +37,14 @@ def draft(tag_name="untagged-c6ddfed7f75d67db2e99", name="Frame Control 9.8.7",
|
||||
|
||||
|
||||
class PublishRelease(unittest.TestCase):
|
||||
def run_script(self, releases, *args, tags="v9.8.7"):
|
||||
def run_script(self, releases, *args, tags="v9.8.7", gates=None):
|
||||
d = Path(tempfile.mkdtemp())
|
||||
(d / "releases.json").write_text(json.dumps(releases))
|
||||
(d / "gates.json").write_text(json.dumps(gates or {}))
|
||||
env = dict(os.environ, PATH="%s:%s" % (FAKEGH, os.environ["PATH"]),
|
||||
FAKEGH_RELEASES=str(d / "releases.json"), FAKEGH_TAGS=tags,
|
||||
FAKEGH_LOG=str(d / "log"), FAKEGH_UPLOAD=str(d / "upload.json"))
|
||||
FAKEGH_LOG=str(d / "log"), FAKEGH_UPLOAD=str(d / "upload.json"),
|
||||
FAKEGH_GATES=str(d / "gates.json"))
|
||||
p = subprocess.run(["sh", str(SCRIPT), *args], env=env, capture_output=True, text=True, timeout=30)
|
||||
log = [json.loads(line) for line in (d / "log").read_text().splitlines()] if (d / "log").exists() else []
|
||||
upload = json.loads((d / "upload.json").read_text()) if (d / "upload.json").exists() else None
|
||||
@@ -120,6 +122,36 @@ class PublishRelease(unittest.TestCase):
|
||||
self.assertIsNone(upload)
|
||||
self.assertTrue(all("-X" not in c for c in log))
|
||||
|
||||
def test_refuses_failed_e2e_or_cancelled_installer_before_mutation(self):
|
||||
for gates in ({"bad_job": "e2e", "bad_job_conclusion": "failure"},
|
||||
{"bad_job": "decky-runtime (ubuntu-24.04-arm)",
|
||||
"bad_job_conclusion": "failure"},
|
||||
{"bad_job": "build (macos-latest, dist, app/dist/*.dmg app/dist/*.zip)",
|
||||
"bad_job_conclusion": "cancelled"}):
|
||||
p, log, upload = self.run_script([draft()], "v9.8.7", gates=gates)
|
||||
self.assertNotEqual(p.returncode, 0)
|
||||
self.assertIn("release gate failed", 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"},
|
||||
{"release_status": "in_progress"}):
|
||||
p, log, upload = self.run_script([draft()], "v9.8.7", gates=gates)
|
||||
self.assertNotEqual(p.returncode, 0)
|
||||
self.assertIn("release gate failed", p.stderr)
|
||||
self.assertIsNone(upload)
|
||||
self.assertFalse(any("-X" in call for call in log))
|
||||
|
||||
def test_refuses_a_withdrawn_release_even_when_ci_is_green(self):
|
||||
rel = dict(draft(), body="> **WITHDRAWN 26 October 2026.** Do not install.\n\n# Notes")
|
||||
p, log, upload = self.run_script([rel], "v9.8.7")
|
||||
self.assertNotEqual(p.returncode, 0)
|
||||
self.assertIn("marked WITHDRAWN", p.stderr)
|
||||
self.assertIsNone(upload)
|
||||
self.assertFalse(any("-X" in call for call in log))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+5
-2
@@ -40,7 +40,9 @@ def plugins(folder):
|
||||
'author': str(data.get('author', ''))[:80],
|
||||
'storeId': owned.get('storeId') if isinstance(owned.get('storeId'), int) else None,
|
||||
'version': str(owned.get('version', ''))[:40],
|
||||
'sha256': str(owned.get('sha256', ''))[:64]})
|
||||
'sha256': str(owned.get('sha256', ''))[:64],
|
||||
'compatShims': [s[:100] for s in owned.get('compatShims', []) if isinstance(s, str)][:16]
|
||||
if isinstance(owned.get('compatShims'), list) else []})
|
||||
except (ValueError, OSError, UnicodeError): pass
|
||||
return found
|
||||
os_release = {}
|
||||
@@ -166,7 +168,8 @@ def status(ssh):
|
||||
result = json.loads(ssh('python3 -', stdin=PROBE, timeout=15))
|
||||
for plugin in result.get('plugins', []):
|
||||
plugin['compatibility'] = framey_compatibility.lookup(
|
||||
plugin.get('storeId'), plugin.get('sha256'), plugin.get('version'))
|
||||
plugin.get('storeId'), plugin.get('sha256'), plugin.get('version'),
|
||||
installed_shims=plugin.get('compatShims', []))
|
||||
return result
|
||||
|
||||
|
||||
|
||||
+183
-2
@@ -3,10 +3,12 @@
|
||||
Only Frame Control owned plugin folders can be updated or removed. The store
|
||||
digest validates transport, not plugin trust; store plugins run as the user.
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import urllib.request
|
||||
@@ -131,8 +133,31 @@ def archive_info(data, expected_hash, expected_name):
|
||||
return folder
|
||||
|
||||
|
||||
DECKYSPY_DIGEST = '2d431a89826ed5cf3fd37258373eb3b46c841c39795aa8b25f83e59f4eee87c7'
|
||||
PSUTIL_DIGESTS = {
|
||||
'x86_64': '076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9',
|
||||
'aarch64': 'b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e',
|
||||
}
|
||||
|
||||
|
||||
def apply_script(plugin_id, digest):
|
||||
"""Send verified offline dependencies only for the exact managed plugin."""
|
||||
if plugin_id != 89 or digest != DECKYSPY_DIGEST:
|
||||
return APPLY
|
||||
wheels = {}
|
||||
for arch, expected in PSUTIL_DIGESTS.items():
|
||||
data = (Path(__file__).parent/'vendor/psutil-7.2.2'/f'{arch}.whl').read_bytes()
|
||||
if hashlib.sha256(data).hexdigest() != expected:
|
||||
raise ValueError('Bundled psutil dependency SHA-256 mismatch')
|
||||
wheels[arch] = base64.b64encode(data).decode('ascii')
|
||||
# stdin is Python source, never shell arguments or a shared remote temp file.
|
||||
battery = (Path(__file__).parent/'framey_battery.py').read_text(encoding='utf-8')
|
||||
return ('FRAMEY_BACKEND_WHEELS = '+repr(wheels)+'\n'
|
||||
+'FRAMEY_BACKEND_BATTERY_SOURCE = '+repr(battery)+'\n'+APPLY)
|
||||
|
||||
|
||||
APPLY = r'''
|
||||
import fcntl,hashlib,json,os,pathlib,shutil,subprocess,sys,zipfile
|
||||
import base64,fcntl,hashlib,io,json,os,pathlib,platform,shutil,subprocess,sys,zipfile
|
||||
action,token,plugin_id,digest,folder,version,expected_installed = sys.argv[1:]
|
||||
base=pathlib.Path.home()/'.local/share/frame-control/decky/plugins'
|
||||
unit=pathlib.Path.home()/'.config/systemd/user/frame-control-decky.service'
|
||||
@@ -209,6 +234,100 @@ try:
|
||||
unpacked=stage/folder
|
||||
if not unpacked.is_dir(): raise RuntimeError('Missing plugin folder')
|
||||
compat_shims=[]
|
||||
# Simple Timer 1.0.0 has annotation-only task state, orphaned
|
||||
# replacement tasks and canceled deadlines that resume on restart.
|
||||
# Preserve its API1 instance convention and upstream license.
|
||||
if int(plugin_id)==96 and digest=='973d927e9d9a1feec04679c564af7e389a729b6735d2a988ffc2cb46ce4eb312' and folder=='simple-timer':
|
||||
main=unpacked/'main.py'
|
||||
source=main.read_text(encoding='utf-8')
|
||||
start=' async def start_timer('; end=' async def load_recents('
|
||||
if source.count(start)!=1 or source.count(end)!=1: raise RuntimeError('Simple Timer compatibility source changed')
|
||||
a=source.index(start); b=source.index(end,a)
|
||||
source=source[:a]+""" async def _stop_timer(self):
|
||||
# Call only under timer_lock. Joining prevents a replaced task from
|
||||
# updating the new timer or sending a stale completion notification.
|
||||
task, self.timer_task = self.timer_task, None
|
||||
if task is not None and task is not asyncio.current_task():
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def start_timer(self, seconds: int):
|
||||
async with self.timer_lock:
|
||||
await self._stop_timer()
|
||||
recent = await self.settings_getSetting(settings_key_recent_timers, [])
|
||||
await self.settings_setSetting(settings_key_recent_timers, [seconds] + recent[:4])
|
||||
deadline = time.time() + seconds
|
||||
await self.settings_setSetting(settings_key_timer_end, deadline)
|
||||
await self.settings_commit()
|
||||
self.seconds_remaining = seconds
|
||||
self.timer_task = self.loop.create_task(self.timer_handler(datetime.fromtimestamp(deadline)))
|
||||
await self.load_recents()
|
||||
await self.load_remaining_seconds()
|
||||
|
||||
async def timer_handler(self, timer_end: datetime):
|
||||
while True:
|
||||
remaining = timer_end.timestamp() - time.time()
|
||||
if remaining <= 0:
|
||||
break
|
||||
await asyncio.sleep(min(5, remaining))
|
||||
self.seconds_remaining = max(0, timer_end.timestamp() - time.time())
|
||||
await self.load_remaining_seconds()
|
||||
async with self.timer_lock:
|
||||
if self.timer_task is not asyncio.current_task():
|
||||
return
|
||||
self.timer_task = None
|
||||
self.seconds_remaining = 0
|
||||
await self.settings_setSetting(settings_key_timer_end, None)
|
||||
await self.settings_commit()
|
||||
await self.load_remaining_seconds()
|
||||
subtle = await self.settings_getSetting(settings_key_subtle_mode, False)
|
||||
if remaining <= -10:
|
||||
await decky.emit("simple_timer_event", "Your timer has expired!", True)
|
||||
else:
|
||||
await decky.emit("simple_timer_event", "Your session has ended!", subtle)
|
||||
|
||||
async def cancel_timer(self):
|
||||
async with self.timer_lock:
|
||||
await self._stop_timer()
|
||||
self.seconds_remaining = 0
|
||||
await self.settings_setSetting(settings_key_timer_end, None)
|
||||
await self.settings_commit()
|
||||
await self.load_remaining_seconds()
|
||||
|
||||
"""+source[b:]
|
||||
start=' async def _main('; end=' async def _uninstall('
|
||||
if source.count(start)!=1 or source.count(end)!=1: raise RuntimeError('Simple Timer compatibility source changed')
|
||||
a=source.index(start); b=source.index(end,a)
|
||||
source=source[:a]+""" async def _main(self):
|
||||
self.loop = asyncio.get_running_loop()
|
||||
self.timer_task = None
|
||||
self.timer_lock = asyncio.Lock()
|
||||
self.seconds_remaining = 0
|
||||
await self.settings_read()
|
||||
deadline = await self.settings_getSetting(settings_key_timer_end, None)
|
||||
if deadline is not None:
|
||||
self.seconds_remaining = max(0, deadline - time.time())
|
||||
if self.seconds_remaining > 0:
|
||||
# Resume the exact deadline without adding another recent timer.
|
||||
self.timer_task = self.loop.create_task(self.timer_handler(datetime.fromtimestamp(deadline)))
|
||||
else:
|
||||
await self.cancel_timer()
|
||||
await self.load_recents()
|
||||
await self.load_subtle_mode()
|
||||
decky.logger.info("Simple Timer has been initialised.")
|
||||
|
||||
async def _unload(self):
|
||||
async with self.timer_lock:
|
||||
# Backend unload is a pause; only explicit cancel clears the deadline.
|
||||
await self._stop_timer()
|
||||
decky.logger.info("Simple Timer has been unloaded.")
|
||||
|
||||
"""+source[b:]
|
||||
main.write_text(source,encoding='utf-8')
|
||||
compat_shims.append('simple-timer-lifecycle')
|
||||
# Shotty 0.1.0 imports click.get_app_dir but never calls it. Its
|
||||
# official ZIP omits click and Decky's bundled Python lacks it.
|
||||
# Supply the Linux function only inside this exact plugin's
|
||||
@@ -228,6 +347,68 @@ try:
|
||||
' base = os.path.join(os.path.expanduser("~"), ".config")\n'
|
||||
' return os.path.join(base, app_name)\n',encoding='utf-8')
|
||||
compat_shims.append('shotty-click-app-dir')
|
||||
# DeckySpy's official archive omits its required psutil dependency.
|
||||
# The BSD wheels are packaged offline by Frame Control and selected
|
||||
# for the native loader; no pip, network or system path changes.
|
||||
if int(plugin_id)==89 and digest=='2d431a89826ed5cf3fd37258373eb3b46c841c39795aa8b25f83e59f4eee87c7' and folder=='decky-spy':
|
||||
arch=platform.machine()
|
||||
pins={
|
||||
'x86_64':'076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9',
|
||||
'aarch64':'b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e',
|
||||
}
|
||||
encoded=globals().get('FRAMEY_BACKEND_WHEELS',{}).get(arch)
|
||||
if arch not in pins or not encoded:
|
||||
raise RuntimeError('Missing native psutil dependency for DeckySpy')
|
||||
wheel_data=base64.b64decode(encoded,validate=True)
|
||||
if hashlib.sha256(wheel_data).hexdigest()!=pins[arch]:
|
||||
raise RuntimeError('DeckySpy psutil dependency SHA-256 mismatch')
|
||||
modules=unpacked/'py_modules'
|
||||
for name in ('psutil','psutil-7.2.2.dist-info'):
|
||||
if (modules/name).exists(): raise RuntimeError('DeckySpy dependency collides with archive')
|
||||
with zipfile.ZipFile(io.BytesIO(wheel_data)) as wheel:
|
||||
for member in wheel.infolist():
|
||||
path=pathlib.PurePosixPath(member.filename)
|
||||
if (path.is_absolute() or '..' in path.parts or not path.parts or
|
||||
path.parts[0] not in ('psutil','psutil-7.2.2.dist-info')):
|
||||
raise RuntimeError('Unsafe psutil dependency member')
|
||||
if member.is_dir(): continue
|
||||
payload=wheel.read(member)
|
||||
if payload[:4]==b'\x7fELF' and int.from_bytes(payload[18:20],'little')!=({'x86_64':62,'aarch64':183}[arch]):
|
||||
raise RuntimeError('Incorrect psutil native architecture')
|
||||
dest=modules/member.filename
|
||||
dest.parent.mkdir(parents=True,exist_ok=True)
|
||||
dest.write_bytes(payload)
|
||||
dest.chmod(0o600)
|
||||
compat=modules/'framey_battery.py'
|
||||
if compat.exists(): raise RuntimeError('DeckySpy battery module collides with archive')
|
||||
battery_source=globals().get('FRAMEY_BACKEND_BATTERY_SOURCE')
|
||||
if not isinstance(battery_source,str) or not battery_source:
|
||||
raise RuntimeError('Missing DeckySpy battery compatibility source')
|
||||
compat.write_text(battery_source,encoding='utf-8')
|
||||
main=unpacked/'main.py'
|
||||
source=main.read_text(encoding='utf-8')
|
||||
old=' battery = psutil.sensors_battery()\n'
|
||||
if source.count(old)!=1: raise RuntimeError('DeckySpy battery source changed')
|
||||
source=source.replace(old,' from framey_battery import sensors_battery\n battery = sensors_battery()\n')
|
||||
main.write_text(source,encoding='utf-8')
|
||||
compat_shims.append('deckyspy-psutil-and-battery')
|
||||
# These legacy backends launch system dbus-send (1.1.6 via /bin/sh).
|
||||
# Decky's PyInstaller LD_LIBRARY_PATH selects bundled libtinfo built
|
||||
# against newer glibc than Frame OS. Restore its saved system path
|
||||
# only in the copied subprocess environment; retain legacy self calls.
|
||||
music_digests={
|
||||
12:'246b89fd653c60a735b1cc401d1b0937d5cb969eea9024561661ff741f081d62',
|
||||
111:'3f56a3f56034b277ea74a2c37cc84133b69e382aa6bcb0b43876776aac737ed9',
|
||||
}
|
||||
if digest==music_digests.get(int(plugin_id)) and folder=='MusicControl':
|
||||
main=unpacked/'main.py'
|
||||
source=main.read_text(encoding='utf-8')
|
||||
old=' env = os.environ.copy()\n'
|
||||
if source.count(old)!=1:
|
||||
raise RuntimeError('Music Control compatibility source changed')
|
||||
source=source.replace(old,old+' env["LD_LIBRARY_PATH"] = env.get("LD_LIBRARY_PATH_ORIG", "")\n')
|
||||
main.write_text(source,encoding='utf-8')
|
||||
compat_shims.append('music-control-system-dbus-env')
|
||||
# Recheck under the transaction lock. Another store operation may
|
||||
# have installed a colliding name while this archive downloaded.
|
||||
proposed_name=json.loads((unpacked/'plugin.json').read_text()).get('name')
|
||||
@@ -326,7 +507,7 @@ def change(ssh, action, plugin_id, report=lambda stage, percent=None: None, revi
|
||||
ssh('umask 077; cat > ' + remote, stdin=data, text=False, timeout=120)
|
||||
report({'install':'Installing', 'update':'Updating', 'remove':'Removing'}[action] + ' plugin on Frame', 80)
|
||||
return json.loads(ssh(f'python3 - {action} {token} {plugin_id} {plugin["sha256"]} {folder} {plugin["version"]} {expected_installed}',
|
||||
stdin=APPLY, timeout=90))
|
||||
stdin=apply_script(plugin_id, plugin["sha256"]), timeout=90))
|
||||
finally:
|
||||
if data is not None:
|
||||
try: ssh('rm -f ' + remote, timeout=10)
|
||||
|
||||
+4
-1
@@ -163,7 +163,10 @@ def bundle():
|
||||
for path in sorted(folder.rglob('*')):
|
||||
if not path.is_file() or path.is_symlink() or '__pycache__' in path.parts:
|
||||
continue
|
||||
if path.suffix not in ('.py', '.html', '.js', '.css', '.json', '.sh', '.gz', '.zst', '.so', '.txt', '.md') and path.name not in ('ssh',):
|
||||
native_dependency = path.relative_to(ROOT).as_posix() in (
|
||||
'ui/vendor/psutil-7.2.2/aarch64.whl', 'ui/vendor/psutil-7.2.2/x86_64.whl')
|
||||
if (path.suffix not in ('.py', '.html', '.js', '.css', '.json', '.sh', '.gz', '.zst', '.so', '.txt', '.md')
|
||||
and path.name != 'ssh' and not native_dependency):
|
||||
continue
|
||||
data = path.read_bytes()
|
||||
info = tarfile.TarInfo(path.relative_to(ROOT).as_posix())
|
||||
|
||||
+10
-1
@@ -137,7 +137,16 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return self.reply(403, {'error': 'Forbidden'})
|
||||
try:
|
||||
size = int(self.headers.get('Content-Length', '0'))
|
||||
if not 0 < size <= 1024:
|
||||
if size > 1024:
|
||||
# Windows can reset a connection closed with an unread POST
|
||||
# body before the client receives our 400 response. Drain only
|
||||
# a bounded rejected body; never buffer an unbounded payload.
|
||||
if size <= 64 * 1024:
|
||||
self.rfile.read(size)
|
||||
else:
|
||||
self.close_connection = True
|
||||
raise ValueError('Invalid request size')
|
||||
if size <= 0:
|
||||
raise ValueError('Invalid request size')
|
||||
body = json.loads(self.rfile.read(size))
|
||||
if not isinstance(body, dict):
|
||||
|
||||
@@ -474,12 +474,14 @@
|
||||
"https://github.com/mirobouma/MusicControl.git"
|
||||
],
|
||||
"status": "partial",
|
||||
"reason": "Legacy media controls initialize; player discovery and playback are unverified.",
|
||||
"reason": "Managed reinstall preserves Decky’s legacy calling convention and repairs the Frame OS shell library-path failure. Chromium transport/seek/volume against a silent MPRIS fixture and real ARM64 empty-player discovery pass; actual Frame media playback, album art and worn VR remain unverified. Reinstall existing copies to apply.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
"local browser",
|
||||
"fake-Frame"
|
||||
"fake-Frame",
|
||||
"isolated Decky 3.3.0 + MPRIS fixture",
|
||||
"real Frame SSH"
|
||||
],
|
||||
"browser": {
|
||||
"id": 12,
|
||||
@@ -506,7 +508,7 @@
|
||||
"folder": "MusicControl"
|
||||
},
|
||||
"backend": "Python",
|
||||
"hardware": "No specific hardware requirement detected statically; runtime requirements unverified.",
|
||||
"hardware": "User-session MPRIS player and dbus-send required; no Deck-specific hardware.",
|
||||
"uiSurfaces": [
|
||||
"local/Steam routes"
|
||||
],
|
||||
@@ -515,7 +517,9 @@
|
||||
"sha256": "246b89fd653c60a735b1cc401d1b0937d5cb969eea9024561661ff741f081d62",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
}
|
||||
},
|
||||
"compatShim": "music-control-system-dbus-env",
|
||||
"evidence": "docs/evidence/framey-mpris-runtime-2026-10-26.md"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
@@ -927,7 +931,7 @@
|
||||
"logs": [
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[Bluetooth] !! get_bluetooth_status \u2192 Error: All retries failed. Last error: Error: Read-only compatibility audit: backend method blocked"
|
||||
"message": "[Bluetooth] !! get_bluetooth_status → Error: All retries failed. Last error: Error: Read-only compatibility audit: backend method blocked"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
@@ -935,7 +939,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[Bluetooth] !! get_paired_devices_with_info \u2192 Error: All retries failed. Last error: Error: Read-only compatibility audit: backend method blocked"
|
||||
"message": "[Bluetooth] !! get_paired_devices_with_info → Error: All retries failed. Last error: Error: Read-only compatibility audit: backend method blocked"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
@@ -1790,7 +1794,7 @@
|
||||
},
|
||||
"backend": "Python + native payload",
|
||||
"hardware": "No specific hardware requirement detected statically; runtime requirements unverified.",
|
||||
"nativePort": "The bin/hash helper needs an ARM64 build and cross-platform hashing tests; no ARM64 artifact is published.",
|
||||
"nativePort": "A source-matched ARM64 helper matched one synthetic ROM on the real Frame (docs/evidence/framey-emuchievements-arm-2026-10-26.md). The pinned upstream source proposed for packaging also has unsafe archive extraction and cleanup paths (docs/evidence/framey-native-port-blockers-2026-10-26.md); fix and test those before shipping an ARM64 binary. The official x86_64 ZIP remains blocked; format coverage, plugin backend/UI, login and worn VR are unverified.",
|
||||
"uiSurfaces": [
|
||||
"local/Steam routes",
|
||||
"toasts",
|
||||
@@ -3593,7 +3597,7 @@
|
||||
"https://github.com/GedasFX/decky-cloud-save.git"
|
||||
],
|
||||
"status": "broken",
|
||||
"reason": "Unsafe plugin archive member.",
|
||||
"reason": "The exact official ZIP contains a 60,776,600-byte x86_64 rclone member, above the 30 MiB member limit and unusable on ARM64. Installation remains blocked.",
|
||||
"blockInstall": true,
|
||||
"verification": [
|
||||
"static archive",
|
||||
@@ -3628,7 +3632,7 @@
|
||||
},
|
||||
"backend": "Python + native payload",
|
||||
"hardware": "No specific hardware requirement detected statically; runtime requirements unverified.",
|
||||
"nativePort": "rclone needs an ARM64 artifact; this ZIP also fails safe member validation. Cloud writes were not exercised.",
|
||||
"nativePort": "An ARM64 rclone artifact and safe package policy are needed; the official 60,776,600-byte x86_64 member fails archive validation. No cloud writes were run (docs/evidence/framey-native-port-blockers-2026-10-26.md).",
|
||||
"uiSurfaces": [
|
||||
"local/Steam routes",
|
||||
"toasts",
|
||||
@@ -6981,13 +6985,14 @@
|
||||
"https://github.com/Seraphli/decky-spy",
|
||||
"https://github.com/Seraphli/decky-spy.git"
|
||||
],
|
||||
"status": "partial",
|
||||
"reason": "Initializer succeeds; account tokens and external service integration were not exercised.",
|
||||
"status": "works",
|
||||
"reason": "Works for read-only system monitoring: live CPU, memory, uptime, processes, interfaces and Frame battery render in local Chromium with actual Decky 3.3.0 ARM64 on the Frame; silent warning and private settings save/reopen pass. Exact managed install adds pinned BSD psutil wheels and a battery-name fallback. Existing copies need update/reinstall. Automatic alerts, resume/game-time warnings, sound and worn VR remain unverified; monitoring requires an open panel.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
"fake-Frame",
|
||||
"local browser",
|
||||
"fake-Frame"
|
||||
"real Frame SSH"
|
||||
],
|
||||
"browser": {
|
||||
"id": 89,
|
||||
@@ -7007,8 +7012,8 @@
|
||||
"accepted": true,
|
||||
"folder": "decky-spy"
|
||||
},
|
||||
"backend": "Python",
|
||||
"hardware": "No specific hardware requirement detected statically; runtime requirements unverified.",
|
||||
"backend": "Python; managed psutil 7.2.2 stable-ABI native amd64/ARM64 dependency",
|
||||
"hardware": "Read-only Linux /proc and network-interface metrics; Frame battery read by power_supply type=Battery. No Deck-specific hardware writes.",
|
||||
"uiSurfaces": [
|
||||
"toasts"
|
||||
],
|
||||
@@ -7022,7 +7027,18 @@
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
"backendFollowUp": {
|
||||
"evidence": "docs/evidence/framey-deckyspy-2026-10-26.json",
|
||||
"mode": "actual Decky 3.3.0 ARM64 with private removable service under shared lock; local opaque Chromium through trusted SSH RPC pipe",
|
||||
"compatShims": [
|
||||
"deckyspy-psutil-and-battery"
|
||||
],
|
||||
"existingInstallRequires": "explicit managed update or reinstall"
|
||||
},
|
||||
"requiredShims": [
|
||||
"deckyspy-psutil-and-battery"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 90,
|
||||
@@ -7608,8 +7624,8 @@
|
||||
"https://github.com/decktools-xyz/simple-timer",
|
||||
"https://github.com/decktools-xyz/simple-timer.git"
|
||||
],
|
||||
"status": "partial",
|
||||
"reason": "Live start/cancel, countdown events, completion toast, persisted recents/subtle mode verified in Chromium with real ARM64 Decky. Upstream expired-timer restart raises missing timer_task. Audio/suspend unsupported; alerts require an open panel; worn VR unverified.",
|
||||
"status": "works",
|
||||
"reason": "Verified for silent in-panel session timers: Chromium Begin/Cancel/countdown/toast with real ARM64 Decky 3.3.0; repaired cancel, replace, completion and active/canceled/expired restarts pass actual loader RPC/events. Update/reinstall required. Sound and suspend unsupported; alerts require an open panel; worn VR unverified.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
@@ -7657,6 +7673,22 @@
|
||||
"subscriptionsAfterPanelClose": 0
|
||||
},
|
||||
"method": "local Chromium + real Frame SSH; private actual Decky 3.3.0 ARM64 service"
|
||||
},
|
||||
"requiredShims": [
|
||||
"simple-timer-lifecycle"
|
||||
],
|
||||
"lifecycle": {
|
||||
"evidence": "docs/evidence/framey-simple-timer-2026-10-26.md",
|
||||
"actualDeckyVersion": "3.3.0",
|
||||
"instanceApi": 1,
|
||||
"backendRestarts": [
|
||||
"active",
|
||||
"canceled",
|
||||
"expired"
|
||||
],
|
||||
"realFrame": true,
|
||||
"localBrowser": true,
|
||||
"fakeFrame": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -9582,12 +9614,15 @@
|
||||
"https://github.com/martinpl/music-control"
|
||||
],
|
||||
"status": "partial",
|
||||
"reason": "Panel initializer succeeds with backend/network blocked; actual controls and backend remain unverified.",
|
||||
"reason": "Managed reinstall preserves Decky’s legacy calling convention and repairs the Frame OS shell library-path failure. Chromium transport/seek/volume against a silent MPRIS fixture and real ARM64 empty-player discovery pass; actual Frame media playback, album art and worn VR remain unverified. Reinstall existing copies to apply.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
"local browser",
|
||||
"fake-Frame"
|
||||
"fake-Frame",
|
||||
"isolated Python backend",
|
||||
"isolated Decky 3.3.0 + MPRIS fixture",
|
||||
"real Frame SSH"
|
||||
],
|
||||
"browser": {
|
||||
"id": 111,
|
||||
@@ -9614,7 +9649,7 @@
|
||||
"folder": "MusicControl"
|
||||
},
|
||||
"backend": "Python",
|
||||
"hardware": "No specific hardware requirement detected statically; runtime requirements unverified.",
|
||||
"hardware": "User-session MPRIS player and dbus-send required; no Deck-specific hardware.",
|
||||
"uiSurfaces": [
|
||||
"local/Steam routes"
|
||||
],
|
||||
@@ -9623,7 +9658,9 @@
|
||||
"sha256": "3f56a3f56034b277ea74a2c37cc84133b69e382aa6bcb0b43876776aac737ed9",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
}
|
||||
},
|
||||
"compatShim": "music-control-system-dbus-env",
|
||||
"evidence": "docs/evidence/framey-mpris-runtime-2026-10-26.md"
|
||||
},
|
||||
{
|
||||
"id": 112,
|
||||
|
||||
+4
-3
@@ -190,7 +190,7 @@
|
||||
parent.postMessage({type:'framey:events:close', session:cacheSession}, '*');
|
||||
});
|
||||
addEventListener('pointerup', event => {
|
||||
if (event.isTrusted) parent.postMessage({type:'framey:input'}, '*');
|
||||
if (event.isTrusted) parent.postMessage({type:'framey:input', session:cacheSession}, '*');
|
||||
}, {passive:true});
|
||||
addEventListener('pointermove', event => {
|
||||
if (event.isTrusted) lastScrollTarget = scrollTarget(event.target);
|
||||
@@ -829,10 +829,11 @@
|
||||
if (!plugin?.content && !plugin?.settings) throw Error('The Decky plugin has no panel to display');
|
||||
window.ReactDOM.createRoot(root).render(R.createElement(PanelBoundary, null,
|
||||
R.createElement(PluginSurface, {plugin})));
|
||||
parent.postMessage({type:'framey:ready'}, '*');
|
||||
parent.postMessage({type:'framey:ready', session:cacheSession}, '*');
|
||||
} catch (failure) {
|
||||
error.textContent = `This Decky panel is not compatible with Framey: ${failure.message}`;
|
||||
parent.postMessage({type:'framey:error', message:failure.message}, '*');
|
||||
parent.postMessage({type:'framey:error', session:cacheSession,
|
||||
message:failure.message}, '*');
|
||||
}
|
||||
}
|
||||
start();
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Read Linux batteries psutil misses because their names lack BAT/battery.
|
||||
|
||||
Copied only into exact managed DeckySpy installs. No sysfs writes or estimates.
|
||||
"""
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
def sensors_battery(power_supply=Path('/sys/class/power_supply')):
|
||||
import psutil
|
||||
battery = psutil.sensors_battery()
|
||||
if battery is not None:
|
||||
return battery
|
||||
try:
|
||||
supplies = sorted(power_supply.iterdir())
|
||||
except OSError:
|
||||
return None
|
||||
for supply in supplies:
|
||||
try:
|
||||
if (supply/'type').read_text().strip() != 'Battery':
|
||||
continue
|
||||
present = supply/'present'
|
||||
if present.exists() and present.read_text().strip() == '0':
|
||||
continue
|
||||
percent = float((supply/'capacity').read_text().strip())
|
||||
if not 0 <= percent <= 100:
|
||||
continue
|
||||
status = (supply/'status').read_text().strip().lower()
|
||||
plugged = True if status in ('charging', 'full') else False if status == 'discharging' else None
|
||||
secsleft = psutil.POWER_TIME_UNLIMITED if plugged else psutil.POWER_TIME_UNKNOWN
|
||||
if plugged is False:
|
||||
try:
|
||||
# Linux power_supply time_to_empty_now is already seconds.
|
||||
value = int((supply/'time_to_empty_now').read_text())
|
||||
if value >= 0:
|
||||
secsleft = value
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return SimpleNamespace(percent=percent, secsleft=secsleft, power_plugged=plugged)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
return None
|
||||
@@ -12,7 +12,7 @@ def _records(path):
|
||||
return json.loads(path.read_text())['plugins']
|
||||
|
||||
|
||||
def lookup(plugin_id, digest, version):
|
||||
def lookup(plugin_id, digest, version, installed_shims=None):
|
||||
unknown = {'status': 'unverified', 'reason': 'This exact store version has not been audited on Frame.',
|
||||
'verification': [], 'blockInstall': False}
|
||||
if type(plugin_id) is not int or not isinstance(digest, str) or not isinstance(version, str):
|
||||
@@ -23,6 +23,14 @@ def lookup(plugin_id, digest, version):
|
||||
if len(matches) != 1:
|
||||
return unknown
|
||||
record = matches[0]
|
||||
return {key: record[key] for key in ('status', 'reason', 'verification', 'blockInstall')}
|
||||
result = {key: record[key] for key in ('status', 'reason', 'verification', 'blockInstall')}
|
||||
required = record.get('requiredShims', [])
|
||||
if result['status'] in ('works', 'partial') and required and installed_shims is not None and (
|
||||
not isinstance(installed_shims, list) or
|
||||
any(shim not in installed_shims for shim in required)):
|
||||
result['status'] = 'partial'
|
||||
result['reason'] = ('Update or reinstall this managed plugin to add its missing Frame compatibility fix. '
|
||||
+ result['reason'])
|
||||
return result
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
return unknown
|
||||
+77
-19
@@ -55,6 +55,7 @@ iframe{display:block;width:100%;height:560px;background:#0d1925;border:1px solid
|
||||
<div class="row" style="margin-top:14px"><button id="deckyStorePrevious" class="secondary">Previous</button><span id="deckyStorePage"></span><button id="deckyStoreNext" class="secondary">Next</button></div></div>
|
||||
<div id="frameyPanel" hidden><div class="row" style="margin-top:16px"><strong id="frameyTitle"></strong><button id="frameyClose" class="secondary">Close plugin panel</button>
|
||||
<button id="frameyScrollUp" class="secondary">Scroll plugin up</button><button id="frameyScrollDown" class="secondary">Scroll plugin down</button></div>
|
||||
<p><small>Hold a scroll button to keep moving through plugin content.</small></p>
|
||||
<p id="frameyCompatibility"></p>
|
||||
<p id="frameyPanelStatus" role="status">Loading plugin panel…</p>
|
||||
<iframe id="frameyFrame" title="Framey Decky plugin panel" sandbox="allow-scripts allow-popups" referrerpolicy="no-referrer"></iframe></div>
|
||||
@@ -64,8 +65,8 @@ iframe{display:block;width:100%;height:560px;background:#0d1925;border:1px solid
|
||||
<div id="extensionPanel" hidden><div class="row" style="margin-top:16px"><strong id="extensionTitle"></strong><button id="extensionClose" class="secondary">Close extension</button></div>
|
||||
<iframe id="extensionFrame" title="Frame Control extension" sandbox="allow-scripts" referrerpolicy="no-referrer"></iframe></div>
|
||||
<script>
|
||||
const key=location.hash.slice(1)||sessionStorage.getItem('frameHeadsetKey')||'';
|
||||
if(key)sessionStorage.setItem('frameHeadsetKey',key);
|
||||
const key=location.hash.slice(1)||(()=>{try{return sessionStorage.getItem('frameHeadsetKey')||'';}catch(_){return '';}})();
|
||||
if(key)try{sessionStorage.setItem('frameHeadsetKey',key);}catch(_){}
|
||||
history.replaceState(null,'',location.pathname);
|
||||
const byId=id=>document.getElementById(id);
|
||||
let latestStatus=null, activeExtension=null, recordBusy=false;
|
||||
@@ -106,7 +107,23 @@ 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';}
|
||||
function deckyJobPending(){return !!sessionStorage.getItem('frameyPluginJob');}
|
||||
// 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.
|
||||
let deckyPendingJob=null;
|
||||
function savedDeckyJob(){
|
||||
if(deckyPendingJob)return deckyPendingJob;
|
||||
try{return JSON.parse(sessionStorage.getItem('frameyPluginJob')||'null');}catch(_){return null;}
|
||||
}
|
||||
function rememberDeckyJob(job){
|
||||
deckyPendingJob=job;
|
||||
try{sessionStorage.setItem('frameyPluginJob',JSON.stringify(job));}catch(_){}
|
||||
}
|
||||
function clearDeckyJob(){
|
||||
deckyPendingJob=null;
|
||||
try{sessionStorage.removeItem('frameyPluginJob');}catch(_){}
|
||||
}
|
||||
function deckyJobPending(){return !!savedDeckyJob();}
|
||||
let deckyState=null, deckyStore=[], deckyStoreIndex=0, deckyStoreRequest=0,
|
||||
deckyStatusRequest=0,
|
||||
activeFrameyId=null, activeFrameyReceipt=null, frameySession=null, deckyBusy=false;
|
||||
@@ -174,7 +191,7 @@ function deckyJobView(title,message,percent,failed=false){
|
||||
byId('deckyJobPanel').hidden=false;byId('deckyJobTitle').textContent=title;
|
||||
byId('deckyJobStatus').textContent=message;
|
||||
const bar=byId('deckyJobProgress');if(Number.isFinite(percent))bar.value=percent;else bar.removeAttribute('value');
|
||||
byId('deckyJobCheck').hidden=!failed||!sessionStorage.getItem('frameyPluginJob');
|
||||
byId('deckyJobCheck').hidden=!failed||!deckyJobPending();
|
||||
}
|
||||
async function followDeckyJob(job,action,name){
|
||||
for(let n=0;n<180;n++){
|
||||
@@ -182,12 +199,12 @@ async function followDeckyJob(job,action,name){
|
||||
try{progress=await api(`/api/job?id=${encodeURIComponent(job)}`);}
|
||||
catch(error){
|
||||
if(/no such job/i.test(error.message)){
|
||||
sessionStorage.removeItem('frameyPluginJob');
|
||||
clearDeckyJob();
|
||||
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);}
|
||||
if(progress.done){if(progress.error){clearDeckyJob();throw Error(progress.error);}
|
||||
deckyJobView(`${action} ${name}`,progress.message||`${action} complete`,100);return;}
|
||||
deckyJobView(`${action} ${name}`,progress.stage||'Waiting for Frame…',progress.percent);
|
||||
await new Promise(resolve=>setTimeout(resolve,1000));
|
||||
@@ -210,24 +227,25 @@ async function deckyChange(action,plugin){if(deckyBusy)return;
|
||||
try{const started=await api('/api/decky/plugin',{action,id:plugin.id,
|
||||
sha256:action==='remove'?undefined:plugin.sha256,acknowledged:true});
|
||||
if(!started.job)throw Error('The server did not return a job ID');
|
||||
sessionStorage.setItem('frameyPluginJob',JSON.stringify({job:started.job,action,name:plugin.name}));
|
||||
rememberDeckyJob({job:started.job,action,name:plugin.name});
|
||||
await followDeckyJob(started.job,action,plugin.name);
|
||||
sessionStorage.removeItem('frameyPluginJob');
|
||||
clearDeckyJob();
|
||||
if(panelNote)byId('deckyJobStatus').textContent+=panelNote;
|
||||
}catch(error){deckyJobView(`${action} ${plugin.name}`,`Error: ${error.message}${panelNote}`,null,true);}
|
||||
finally{deckyBusy=false;await refreshDecky();}
|
||||
}
|
||||
async function checkDeckyJob(){let saved;try{saved=JSON.parse(sessionStorage.getItem('frameyPluginJob')||'null');}catch(_){saved=null;}
|
||||
async function checkDeckyJob(){const saved=savedDeckyJob();
|
||||
if(deckyBusy)return;
|
||||
if(!saved||typeof saved.job!=='string'){sessionStorage.removeItem('frameyPluginJob');showDeckyStore();return;}
|
||||
if(!saved||typeof saved.job!=='string'){clearDeckyJob();showDeckyStore();return;}
|
||||
deckyBusy=true;byId('deckyJobCheck').hidden=true;
|
||||
try{await followDeckyJob(saved.job,saved.action,saved.name);sessionStorage.removeItem('frameyPluginJob');}
|
||||
try{await followDeckyJob(saved.job,saved.action,saved.name);clearDeckyJob();}
|
||||
catch(error){deckyJobView(`${saved.action} ${saved.name}`,`Error: ${error.message}`,null,true);}
|
||||
finally{deckyBusy=false;await refreshDecky();}}
|
||||
byId('deckyJobCheck').onclick=checkDeckyJob;
|
||||
function openFrameyPanel(id,name){
|
||||
if(deckyBusy||deckyJobPending()||!deckyState?.running)return;
|
||||
const installed=(deckyState?.plugins||[]).find(p=>p.storeId===id);if(!installed)return;
|
||||
stopFrameyScrollHold();
|
||||
byId('frameyCompatibility').textContent=compatibilityText(installed);
|
||||
closeFrameyEvents();
|
||||
activeFrameyId=id;activeFrameyReceipt={sha256:installed.sha256,version:installed.version};
|
||||
@@ -249,15 +267,52 @@ function closeFrameyEvents(){
|
||||
session:frameySession,events:[],cursor:0}).catch(()=>{});
|
||||
frameyEventsActive=false;
|
||||
}
|
||||
function closeFrameyPanel(){closeFrameyEvents();activeFrameyId=null;activeFrameyReceipt=null;frameySession=null;
|
||||
function closeFrameyPanel(){stopFrameyScrollHold();closeFrameyEvents();activeFrameyId=null;activeFrameyReceipt=null;frameySession=null;
|
||||
byId('frameyFrame').removeAttribute('src');byId('frameyPanel').hidden=true;}
|
||||
byId('frameyClose').onclick=closeFrameyPanel;
|
||||
function scrollFramey(direction){
|
||||
if(activeFrameyId===null||!frameySession||!['up','down'].includes(direction))return;
|
||||
byId('frameyFrame').contentWindow?.postMessage({type:'framey:scroll',session:frameySession,direction},'*');
|
||||
if(activeFrameyId===null||!frameySession||!['up','down'].includes(direction))return false;
|
||||
const target=byId('frameyFrame').contentWindow;if(!target)return false;
|
||||
target.postMessage({type:'framey:scroll',session:frameySession,direction},'*');
|
||||
return true;
|
||||
}
|
||||
byId('frameyScrollUp').onclick=()=>scrollFramey('up');
|
||||
byId('frameyScrollDown').onclick=()=>scrollFramey('down');
|
||||
let frameyScrollHold=null;
|
||||
function stopFrameyScrollHold(){
|
||||
if(!frameyScrollHold)return;
|
||||
clearTimeout(frameyScrollHold.delay);
|
||||
clearInterval(frameyScrollHold.repeat);
|
||||
frameyScrollHold=null;
|
||||
}
|
||||
function bindFrameyScroll(id,direction){
|
||||
const button=byId(id);
|
||||
button.onpointerdown=event=>{
|
||||
if(event.button!==0||event.isPrimary===false)return;
|
||||
stopFrameyScrollHold();
|
||||
if(!scrollFramey(direction))return;
|
||||
const hold={button,direction,pointerId:event.pointerId,delay:null,repeat:null};
|
||||
frameyScrollHold=hold;
|
||||
hold.delay=setTimeout(()=>{
|
||||
if(frameyScrollHold!==hold)return;
|
||||
hold.repeat=setInterval(()=>{
|
||||
if(frameyScrollHold!==hold)return;
|
||||
if(!scrollFramey(direction))stopFrameyScrollHold();
|
||||
},180);
|
||||
},400);
|
||||
};
|
||||
const stop=event=>{
|
||||
if(frameyScrollHold?.button===button&&frameyScrollHold.pointerId===event.pointerId)
|
||||
stopFrameyScrollHold();
|
||||
};
|
||||
button.onpointerup=stop;
|
||||
button.onpointercancel=stop;
|
||||
button.onpointerleave=stop;
|
||||
// Pointerdown already sent one step. Keyboard activation emits a click with
|
||||
// detail 0; direct onclick calls from older tests also mean one step.
|
||||
button.onclick=event=>{if(!event||event.detail===0)scrollFramey(direction);};
|
||||
}
|
||||
bindFrameyScroll('frameyScrollUp','up');
|
||||
bindFrameyScroll('frameyScrollDown','down');
|
||||
window.addEventListener('blur',stopFrameyScrollHold);
|
||||
function frameyCacheKey(id){return `framey-plugin-cache-v1:${id}`;}
|
||||
function frameyCacheRead(id){
|
||||
try{const data=JSON.parse(localStorage.getItem(frameyCacheKey(id))||'{}');
|
||||
@@ -282,6 +337,7 @@ window.addEventListener('message',async event=>{
|
||||
if(['up','down'].includes(req.direction)&&typeof req.moved==='boolean')
|
||||
byId('frameyPanelStatus').textContent=req.moved?'Scrolled plugin '+req.direction:
|
||||
'No more plugin content in that direction';
|
||||
if(req.moved===false&&frameyScrollHold?.direction===req.direction)stopFrameyScrollHold();
|
||||
return;
|
||||
}
|
||||
if(req.type==='framey:cache:init'&&req.session===frameySession){
|
||||
@@ -292,10 +348,12 @@ window.addEventListener('message',async event=>{
|
||||
if(req.session===frameySession)frameyCacheSave(activeFrameyId,req.entries);
|
||||
return;
|
||||
}
|
||||
if(req.type==='framey:ready'){byId('frameyPanelStatus').textContent='Plugin panel ready';
|
||||
if(req.type==='framey:ready'&&req.session===frameySession){byId('frameyPanelStatus').textContent='Plugin panel ready';
|
||||
api('/api/framey/check',{kind:'panel',pluginId:activeFrameyId}).catch(()=>{});return;}
|
||||
if(req.type==='framey:input'){api('/api/framey/check',{kind:'input'}).catch(()=>{});return;}
|
||||
if(req.type==='framey:error'){byId('frameyPanelStatus').textContent=`Plugin panel unavailable: ${String(req.message).slice(0,200)}`;return;}
|
||||
if(req.type==='framey:input'&&req.session===frameySession){
|
||||
api('/api/framey/check',{kind:'input'}).catch(()=>{});return;}
|
||||
if(req.type==='framey:error'&&req.session===frameySession){
|
||||
byId('frameyPanelStatus').textContent=`Plugin panel unavailable: ${String(req.message).slice(0,200)}`;return;}
|
||||
if(req.type==='framey:running:close'){
|
||||
if(req.session===frameySession&&frameyRunningActive){
|
||||
frameyRunningActive=false;
|
||||
|
||||
Vendored
+29
@@ -0,0 +1,29 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2009, Jay Loden, Dave Daeschler, Giampaolo Rodola
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification,
|
||||
are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
* Neither the name of the psutil authors nor the names of its contributors
|
||||
may be used to endorse or promote products derived from this software without
|
||||
specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
||||
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
# psutil 7.2.2
|
||||
|
||||
Unmodified upstream PyPI wheels, retrieved 26 October 2026. BSD-3-Clause;
|
||||
see LICENSE.txt and the license retained in each wheel.
|
||||
Source: https://github.com/giampaolo/psutil/tree/release-7.2.2
|
||||
Release metadata: https://pypi.org/pypi/psutil/7.2.2/json
|
||||
|
||||
| Local file | Upstream wheel | SHA-256 |
|
||||
| --- | --- | --- |
|
||||
| x86_64.whl | psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl | 076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9 |
|
||||
| aarch64.whl | psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl | b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e |
|
||||
|
||||
Only exact ID 89 / DeckySpy 0.6.7 receives this missing dependency, isolated
|
||||
inside its py_modules directory, alongside Framey’s battery-name fallback.
|
||||
The fallback reads only Linux power_supply files identified as type Battery;
|
||||
it leaves native psutil results intact and never writes sysfs. The host and remote installer both verify
|
||||
these digests, and the remote selects the native ELF architecture. No pip or
|
||||
runtime download runs on the Frame. The stable ABI is exercised with actual
|
||||
Decky 3.3.0 rather than assuming compatibility with its bundled Python.
|
||||
Existing plugin copies require an explicit managed update or reinstall.
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Reference in new issue
Block a user