mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-26 19:03:05 +01:00
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e711945c5a | ||
|
|
035c59ee11 | ||
|
|
c687bcc71f | ||
|
|
b6f45b287f | ||
|
|
a77434d91b | ||
|
|
e07fa15592 | ||
|
|
1f5b37a755 | ||
|
|
77c54c0b01 | ||
|
|
d38af757ec | ||
|
|
211f4fb148 | ||
|
|
72fd779170 | ||
|
|
286e692147 | ||
|
|
fe51d29eb4 | ||
|
|
4b47c384e6 | ||
|
|
7baafc9a18 | ||
|
|
b0d8bf3968 | ||
|
|
b05a666e50 | ||
|
|
8feb19a6d3 | ||
|
|
f84a4da9b1 | ||
|
|
fc978a2afd | ||
|
|
9aff6adcec | ||
|
|
2d8b70d607 | ||
|
|
80657b8a8e | ||
|
|
21b85ba99d | ||
|
|
a38d598f14 | ||
|
|
08567ca571 | ||
|
|
4fc5b83873 | ||
|
|
d96330554a | ||
|
|
385ae8290b |
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.45",
|
||||
"version": "0.4.47",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.45",
|
||||
"version": "0.4.47",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.45",
|
||||
"version": "0.4.47",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Chunked Framey frontend audit route, 26 October 2026
|
||||
|
||||
The local 116-plugin Chromium audit served chunked modules at a route that
|
||||
omitted the store receipt, while the production panel requested
|
||||
`/framey/module/<id>/<receipt>/<file>`. The audit parsed that receipt as part
|
||||
of the file path and returned 404. This made its result for chunked frontends
|
||||
weaker than the separate targeted panel checks.
|
||||
|
||||
The audit now accepts the production URL shape only when the plugin ID,
|
||||
receipt and JavaScript path are valid, the receipt equals the catalog SHA-256,
|
||||
and the cached archive bytes match that SHA-256. Invalid IDs, receipts, paths
|
||||
and changed archive bytes fail before the module is served. The production
|
||||
Framey panel and installer are unchanged.
|
||||
|
||||
## Exact archived browser check
|
||||
|
||||
Pinned official Deck Shelves 2.0.0 (store ID 135, SHA-256
|
||||
`b566280ad38fd3ecd584b5b46a855f301539eb9a86063834c64b7045f7063f91`)
|
||||
ran through the repaired audit in an opaque Chromium iframe. Its entry module
|
||||
and all eight JavaScript chunks returned HTTP 200 at receipt-bound URLs. The
|
||||
frontend reported ready and called `get_settings`; the audit denied that
|
||||
backend call and a Steam library read. Plugin network access remained blocked
|
||||
by the audit policy. The previous
|
||||
catalog browser observation had failed to resolve `./chunks/...` from a blob
|
||||
URL. This check verifies the current audit route and frontend initializer,
|
||||
not the official backend or a live shelf.
|
||||
|
||||
The focused route regression covers entry and chunk reads plus wrong receipt,
|
||||
path traversal, malformed ID and changed archive bytes. The catalog's
|
||||
**4 works / 50 partial / 51 broken / 11 N/A** statuses are unchanged. Deck
|
||||
Shelves remains partial; installed settings, home-shelf behavior, worn VR and
|
||||
physical controller input remain unverified.
|
||||
@@ -4,7 +4,17 @@ The exact official Deck Shelves 2.0.0 archive (store ID 135, SHA-256 `b566280ad3
|
||||
|
||||
Framey now serves JavaScript files from the managed plugin's `dist` directory using hierarchical module URLs. Its opaque `allow-scripts` iframe still has no Frame UI key. Local Chromium received HTTP 200 for the entry and eight chunks, reported the initializer ready, and rendered the settings panel with “Enable shelves on Home,” “Create default shelves,” “Behavior,” and “Shelves.” The browser audit denied the attempted `get_settings` backend call, Steam library read and plugin network access. No backend method executed.
|
||||
|
||||
The catalog moves this exact version from broken to **partial** for visible local settings only. The plugin's main feature injects shelves into Steam Home; Framey has no verified Steam Home patch target. Settings writes, official backend behavior, real Frame module serving, wearer-visible VR, and physical controller input remain unverified. The [compatibility table](../framey-compatibility.md) and store views retain these limits.
|
||||
The catalog moves this exact version from broken to **partial** for visible local settings. The plugin's main feature injects shelves into Steam Home; Framey has no verified Steam Home patch target. Actual Decky RPC, real Frame settings, wearer-visible VR, and physical controller input remain unverified. The [compatibility table](../framey-compatibility.md) and store views retain these limits.
|
||||
|
||||
## Isolated official backend settings check
|
||||
|
||||
I verified the same archive digest before loading its unchanged `Deck-Shelves/main.py` in local Python. A stub `decky` module supplied only a logger; `DECKY_PLUGIN_SETTINGS_DIR` and `DECKY_HOME` pointed inside a temporary directory. No Decky service, headset, Steam API, or network call was involved. Calling `_main`, `get_settings`, and `set_settings` returned the default disabled state, saved one shelf named `Local Test`, and returned that shelf from a fresh `Plugin` instance. The temporary directory was removed after the check.
|
||||
|
||||
```json
|
||||
{"archiveSha256":"b566280ad38fd3ecd584b5b46a855f301539eb9a86063834c64b7045f7063f91","defaultEnabled":false,"defaultShelves":0,"saveResult":true,"reopenedEnabled":true,"reopenedShelfTitle":"Local Test","settingsFileExists":true}
|
||||
```
|
||||
|
||||
This proves only the archive's Python settings logic under those local conditions. It does not prove that Decky 3.3.0 can dispatch these methods on the ARM64 Frame, that the opaque panel can save settings through the production bridge, or that a shelf appears on Steam Home or in VR.
|
||||
|
||||
## Exact source real Frame gates
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Free Loader duplicate archive entries, 26 October 2026
|
||||
|
||||
The exact official Free Loader 1.6.0 archive is store ID 63, SHA-256
|
||||
`05208aee6f6ba824391d8ddc81b1168b2599a9711e7528cdaa789f6cc710b42a`.
|
||||
It has 23 ZIP entries. Seven names under `free-loader/py_modules/` occur
|
||||
twice. Each duplicate pair has identical uncompressed bytes and file type;
|
||||
five Python files and `.keep` differ only between mode `0644` and `0755`.
|
||||
The directory entry is identical. No path escape, symlink or alternate content
|
||||
was accepted.
|
||||
|
||||
The installer now accepts an exact duplicate name only when its bytes match
|
||||
and its mode differs at most in execute bits. Both desktop validation and the
|
||||
headset transaction enforce that rule before extraction. The existing member
|
||||
count, unpacked-size, per-member-size, root, path and ARM64 checks still run.
|
||||
Synthetic tests reject changed duplicate payloads and non-execute mode changes.
|
||||
|
||||
## Scoped verification
|
||||
|
||||
- The exact archive passed the revised validator. A private local transaction
|
||||
installed and removed its `free-loader` folder and receipt; cleanup left no
|
||||
plugin folder. The focused store suite passed 21/21 tests, including a
|
||||
duplicate-member install/remove and permission readback.
|
||||
- After merging current main, the full local Python suite passed **1,637
|
||||
tests, 16 skipped**. The generated compatibility document check and
|
||||
whitespace check passed.
|
||||
- The pinned official fake-Frame HTTP/SSH install/remove test passed with this
|
||||
archive alongside the other pinned fixtures. It ran in a uniquely named
|
||||
disposable Compose project; its containers, network and volumes were removed.
|
||||
The fake does not execute the plugin's Python backend.
|
||||
- The current Framey opaque Chromium host on main `d963305` initialized the
|
||||
exact frontend. Its **Free Games** view rendered, then displayed “Failed to
|
||||
load games!” because the audit intentionally denied all backend/network
|
||||
calls. It attempted `settings_read`, `read_deals` and logger methods.
|
||||
The browser audit's former `local_bundle` stub had returned HTTP 404 after
|
||||
receipt-bound panel lookup was introduced; the audit now supplies the
|
||||
digest-checked bundle and receipt to the shipped panel renderer.
|
||||
- Exact official Python backend code ran in a disposable Python 3.12 container
|
||||
with network disabled, read-only root, no capabilities, a private data mount
|
||||
and a minimal `decky` API stub. `_main`, `settings_read` and `read_deals`
|
||||
succeeded: 14 default settings, a 12-hour update frequency, an empty deals
|
||||
map, and only private `settings.json`/`deal_db.json` files. The container
|
||||
and its private files were removed. No feed refresh or notification ran.
|
||||
|
||||
This makes **partial** the accurate digest-scoped status. The earlier full
|
||||
116-plugin fake-Frame catalog audit blocked this archive before the validator
|
||||
change; the focused pinned flow now passes. Actual Decky 3.3.0 backend load,
|
||||
live giveaway feeds, notification delivery, headset install, worn UI and
|
||||
physical controller input remain unverified. No real Frame session or service
|
||||
was touched for this change.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Game Theme Music local panel check, 26 October 2026
|
||||
|
||||
The exact official Game Theme Music 1.7.1-1 archive (store ID 52, SHA-256 `9ce293ab4db212c03c6b96dfbc5e4d92e874d33387463f53223773921f75a80e`) passed the existing fake Frame install/remove audit. Its prior local Chromium run stopped before initialization with `Cannot read properties of null (reading 'type')`: the frontend dereferenced the result of `DFL.fakeRenderComponent` while looking for Steam's Library Context Menu.
|
||||
|
||||
Framey's isolated host now returns an inert local component type from that lookup. The patch API remains a no-op and does not expose or modify a Steam React tree. In the repository's opaque Chromium audit, the unchanged archive reported initializer ready and rendered a Settings view containing Music Volume, Default Muted, Use yt-dlp, Invidious Instance, Download Music, Overrides and Extras. The audit observed a `get_setting` request, but denied the backend call. It also denied plugin network requests; a YouTube/Invidious fetch failed as expected. The bounded root-text observation came from the test iframe and contained only these public UI labels.
|
||||
|
||||
The catalog therefore moves this digest from **broken** to **partial** for the visible local settings panel. No theme was played or injected into a Steam game page. Music search, playback, downloads, settings persistence, live ARM64 backend behavior, wearer-visible VR and physical controller input remain unverified. The earlier failed browser observation remains in the canonical JSON alongside this follow-up so the change in evidence is explicit.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Shotty Python compatibility, 26 October 2026
|
||||
|
||||
The exact official Shotty 0.1.0 archive (store ID 53, SHA-256 `ab7f6618b264a0cb766525e788cef6c3e1478c0fef98a701225e5e8af441e2fe`) imports `click.get_app_dir` at startup but never calls it. The archive includes an empty `py_modules` directory and no `click` package. A prior actual Decky 3.3.0 amd64 backend check stopped at `ModuleNotFoundError: click`; its local frontend separately starts with Framey's read-only Steam library snapshot.
|
||||
|
||||
Frame Control now writes a small Linux `click.get_app_dir` compatibility module into **only this exact digest's** managed `Shotty/py_modules` folder during staging. Decky Loader adds each plugin's `py_modules` folder to that plugin's Python search path. The staging transaction records `shotty-click-app-dir` in the receipt and removes the whole staged folder on failure. A different Shotty digest receives no module. The official ZIP bytes and their verified hash remain unchanged.
|
||||
|
||||
An isolated transaction test installed the pinned archive, imported its unchanged `main.py` using the staged module, and called its `aggregate_all` method with a synthetic Steam screenshot under a temporary `HOME`. It copied one fixture image into that temporary home's `Pictures/Screenshots/Local Test Game` folder; reopening the copied bytes matched the source. Removal deleted the managed folder. A second synthetic Shotty archive with another digest installed without the module. The test neither started the plugin's continuous screenshot rescuer nor touched a person's Steam library or screenshots.
|
||||
|
||||
This supports **partial** compatibility for the local frontend and a narrow backend copy path. Actual Decky 3.3.0 ARM64 plugin startup, gamescope screenshot rescue, real screenshot aggregation, VR panel visibility, and physical controller use remain unverified. No headset lock was acquired or headset state changed for this check.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Interactive Framey worn-check proximity gate, 26 October 2026
|
||||
|
||||
The interactive `scripts/framey-worn-check.py` already required an explicit
|
||||
operator wear confirmation and a unique `/tmp/frame-test.lock` owner before
|
||||
reading SteamVR activity. It did not read proximity. This change makes its
|
||||
locked preflight read the raw proximity sensor and stop before its interaction
|
||||
window when the reading is missing or invalid. It checks activity, proximity
|
||||
and battery again before input. The same owned-lock cleanup applies on failure.
|
||||
|
||||
The focused local gate suite passes 9/9 tests, including missing and nonfinite
|
||||
proximity, proximity disappearing between reads, metrics preceding the SteamVR status row,
|
||||
standby, low battery, no operator confirmation and a foreign lock. No headset
|
||||
input was sent for this change. A separate owner-locked read-only Frame check
|
||||
found SteamVR activity 3 (standby), proximity raw 3.0625 and battery 93%; it
|
||||
released its lock without a worn test.
|
||||
|
||||
The raw proximity value is not calibrated as proof of wear. The operator's
|
||||
in-person confirmation remains required; awake activity, proximity and trusted
|
||||
synthetic input alone do not prove wearer-visible pixels or physical controller
|
||||
usability. The hourly worn-check schedule remains enabled.
|
||||
@@ -0,0 +1,44 @@
|
||||
# v0.4.46 candidate gate, 26 October 2026
|
||||
|
||||
This candidate includes merged Free Loader PR #325, the Decky user-tool PATH
|
||||
fix (#323), and the interactive worn-check proximity gate (#324).
|
||||
The official 116-plugin catalog is **4 works / 50 partial / 51 broken /
|
||||
11 N/A**. Free Loader is partial for its verified package, local frontend
|
||||
and isolated settings/empty-deals scope. No live feed or worn-VR claim follows.
|
||||
|
||||
## Locked real Frame smoke before the version bump
|
||||
|
||||
At 12:12 UTC, source commit `21b85ba99dfd742a56e2c20259f56f35d83590fc`
|
||||
ran `scripts/frame-smoke.sh` on the real Frame under a unique owner of
|
||||
`/tmp/frame-test.lock`. Locked preflight found SteamVR activity **3**
|
||||
(standby), proximity raw **3.0** and battery **100%** on Frame OS 0.5.6 build
|
||||
`20261022.6153909`. Test-owned ARM64 and Windows/Proton titles installed,
|
||||
launched and were removed. The optional x86_64 title installed and was
|
||||
removed; launch was **skipped** because Steam Linux Runtime 4 is absent.
|
||||
The result was **12 passed / 1 skipped / 0 failed**. Cleanup passed and the
|
||||
command exited 0, which includes release of its owned lock. The detailed
|
||||
result remains private at
|
||||
`tests/smoke/results/2026-10-26T121228Z-20261022.6153909.json` in the
|
||||
candidate checkout.
|
||||
|
||||
This smoke checks release plumbing and temporary titles, not an installed
|
||||
official Decky plugin. No wearer was confirmed. The interactive worn-check
|
||||
script was not run, and no headset wake or controller input was sent. Free
|
||||
Loader's actual ARM64 Decky backend, live feeds, panel visibility to a wearer,
|
||||
physical controllers, keyboard and optical comfort remain unverified. The
|
||||
hourly worn-headset check stays enabled.
|
||||
|
||||
## Publication boundary
|
||||
|
||||
At 12:17 UTC, exact version-bumped candidate commit
|
||||
`9aff6adcec71852ce9bedf0ae37364bd32bb6627` ran the same locked
|
||||
`scripts/frame-smoke.sh`. Preflight again found activity **3** standby,
|
||||
proximity raw **3.0** and battery **100%**. The result was **12 passed /
|
||||
1 optional x86 launch skipped / 0 failed**. Owned title/process cleanup
|
||||
passed and the command exited 0 after releasing its lock. Its private result
|
||||
is `tests/smoke/results/2026-10-26T121721Z-20261022.6153909.json` in the
|
||||
candidate checkout. This paragraph is the only change after that smoke.
|
||||
|
||||
Green release-PR and post-merge CI, tagged installer build, all ten installer
|
||||
digests with `SHA256SUMS`, independent package checks, publisher dry run and
|
||||
public `update.json` readback remain required before publication.
|
||||
@@ -0,0 +1,58 @@
|
||||
# v0.4.46 tagged release, 26 October 2026
|
||||
|
||||
[PR #326](https://github.com/saphid/frame-control/pull/326) passed every required
|
||||
check on exact head `fc978a2afdfa47e60477fcb0bb573e7b6181fa89` and merged
|
||||
at 12:31:06 UTC. Main became `f84a4da9b1ae938d15b658af17903c0e10a56e94`.
|
||||
All seven jobs in the [post-merge main run](https://github.com/saphid/frame-control/actions/runs/40323149126)
|
||||
passed, including iOS and fake-Frame e2e. The app and lockfile both say
|
||||
`0.4.46`. Annotated tag `v0.4.46` dereferences to that main commit. The
|
||||
[tagged release workflow](https://github.com/saphid/frame-control/actions/runs/40324176903)
|
||||
passed its draft, Linux, Windows, Apple Silicon Mac, Intel Mac and checksum jobs.
|
||||
|
||||
## Frame and package gates
|
||||
|
||||
The [candidate evidence](v0446-candidate-2026-10-26.md) records a locked
|
||||
exact-version real Frame smoke: **12 passed / 1 optional x86 launch skipped /
|
||||
0 failed**, with owned cleanup and lock release. The x86 launch needs Steam
|
||||
Linux Runtime 4, which was absent. A separate 12:25 UTC read-only check acquired
|
||||
and released its own `/tmp/frame-test.lock`; SteamVR activity was **3**
|
||||
(standby), proximity raw **3.0**, and battery **100%**. No wearer was confirmed,
|
||||
so no wake, controller input or interactive worn-check script ran.
|
||||
|
||||
The draft contained exactly ten installers plus `SHA256SUMS`, each with a
|
||||
GitHub SHA-256 digest. A fresh downloaded checksum file matched the ten digest
|
||||
lines regenerated from GitHub metadata byte for byte; its digest was
|
||||
`365e7f6d9549ff993162964920a6560480069ed04ce1eca5551696ce7bbc4ae8`.
|
||||
`scripts/publish-release.sh --dry-run v0.4.46` passed the exact tag, installer,
|
||||
checksum and update-manifest gates after release notes were attached.
|
||||
|
||||
Independent downloads passed these checks:
|
||||
|
||||
| Package | Result |
|
||||
| --- | --- |
|
||||
| Linux x86_64 AppImage | SHA-256 `65f66f1bbb5908d49c179307950f93b643c2b8da8ac4da48a778c5c9bac5d41b`; launched under isolated Xvfb, bundled server returned HTTP 200 with `FrameControl/1`, then stopped. |
|
||||
| Mac arm64 ZIP | SHA-256 `9af88f75db7936472590022ca03a03da9de4f91de7b35c5d4d130b6dc78ab4b5`; ZIP CRC valid, bundle version 0.4.46, main Mach-O CPU arm64. |
|
||||
| Mac x64 ZIP | SHA-256 `28d9ac00c9c918a7cb9f2caac54505faa8a953285bb40f82e02d9233b775d37a`; ZIP CRC valid, bundle version 0.4.46, main Mach-O CPU x86_64. |
|
||||
| Windows x64 Setup | SHA-256 `68e303dcabb2c82ba5b9a5a58bace2ca8154f9645f1da2214ca4c6dbb05f157d`; PE/Nullsoft installer format. Tagged Windows CI checked packaged files and Python libraries. |
|
||||
| Linux arm64 DEB | SHA-256 `da9b00bd44ca7a91c542df26d8c5711ad564d618add34f89aef946785c0bd538`; package version 0.4.46 and architecture arm64. |
|
||||
|
||||
Tagged Mac and Windows packages were not launched on physical machines during
|
||||
this release gate. Earlier physical Intel Mac acceptance covers older bytes.
|
||||
|
||||
## Publication and limits
|
||||
|
||||
`scripts/publish-release.sh v0.4.46` published
|
||||
[v0.4.46](https://github.com/saphid/frame-control/releases/tag/v0.4.46) at
|
||||
12:48:03 UTC. GitHub's latest-release API returned the expected tag,
|
||||
`draft=false`, `prerelease=false`, ten installers, `SHA256SUMS` and
|
||||
`update.json`. A fresh public latest-download of `update.json` reported
|
||||
version 0.4.46, the stable release page, ten hashed installers and the
|
||||
worn-VR limit in its notes.
|
||||
|
||||
The exact-version official catalog is **4 works / 50 partial / 51 broken /
|
||||
11 N/A** out of 116. Free Loader is partial for package, local frontend and
|
||||
isolated settings/empty-deals evidence. Its actual ARM64 Decky load, live
|
||||
feeds and notifications remain unverified. The real Frame was in standby;
|
||||
official-plugin wearer visibility, physical controller usability, keyboard
|
||||
and optical comfort remain unverified. The hourly shared-lock worn check stays
|
||||
enabled.
|
||||
@@ -0,0 +1,46 @@
|
||||
# v0.4.47 candidate gate, 26 October 2026
|
||||
|
||||
This candidate contains Game Theme Music's locally rendered Settings view
|
||||
(#330) and a digest-scoped Python import module for the official Shotty 0.1.0
|
||||
archive (#331). The 116-plugin table is **4 works / 52 partial / 49 broken /
|
||||
11 N/A**. Game Theme Music is partial for its local Settings view; its
|
||||
backend, network and playback were denied in that audit. Shotty is partial
|
||||
for its local frontend and synthetic screenshot copy in isolated Python;
|
||||
actual ARM64 Decky startup and live screenshot rescue are unverified.
|
||||
|
||||
## Locked Frame candidate smoke
|
||||
|
||||
At 14:18 UTC, source commit `1f5b37a7552880b40868617ef22398fcd39b3f5d`
|
||||
ran `scripts/frame-smoke.sh` on the real Frame. The script acquired its unique
|
||||
owner in `/tmp/frame-test.lock` before reading status. Locked preflight found
|
||||
SteamVR activity **3** (standby), proximity raw **3.0625**, and battery
|
||||
**100%** on Frame OS 0.5.6 build `20261022.6153909`. Owned ARM64 and
|
||||
Windows/Proton test titles installed, launched, and were removed. The
|
||||
optional x86_64 title installed and was removed; launch was **skipped**
|
||||
because Steam Linux Runtime 4 was absent. The result was **12 passed /
|
||||
1 skipped / 0 failed**. Cleanup passed and the command exited 0, including
|
||||
release of its owned lock. Detailed results stay private in
|
||||
`tests/smoke/results/2026-10-26T141812Z-20261022.6153909.json`.
|
||||
|
||||
No wearer was confirmed. The interactive worn-check script was not run;
|
||||
the smoke did not wake the headset or test the Framey panel. Game Theme
|
||||
Music playback, Shotty live rescue, wearer-visible official-plugin UI,
|
||||
physical controllers, keyboard and optical comfort remain unverified.
|
||||
The hourly shared-lock worn check remains enabled.
|
||||
|
||||
## Publication boundary
|
||||
|
||||
At 14:23 UTC, the exact version-bumped candidate commit
|
||||
`e07fa155928d1609304e317d15e0c4ef4a977579` ran the same token-locked
|
||||
`scripts/frame-smoke.sh`.
|
||||
Preflight again found activity **3** (standby), proximity raw **3.0625** and
|
||||
battery **100%**. The result was **12 passed / 1 optional x86 launch skipped /
|
||||
0 failed**. Owned cleanup passed and the command exited 0 after releasing its
|
||||
lock. Its private result is
|
||||
`tests/smoke/results/2026-10-26T142321Z-20261022.6153909.json`.
|
||||
This paragraph is the only change after that smoke.
|
||||
|
||||
PR #331's combined-head CI, green release-PR and post-merge CI, tagged
|
||||
installer builds, all ten installer digests with `SHA256SUMS`, independent
|
||||
package checks, publisher dry run, and public `update.json` readback remain
|
||||
required.
|
||||
@@ -0,0 +1,62 @@
|
||||
# v0.4.47 tagged release, 26 October 2026
|
||||
|
||||
The release PR [#332](https://github.com/saphid/frame-control/pull/332)
|
||||
passed its exact-head checks and all four installer preview jobs, including
|
||||
Intel Mac. It merged at main commit
|
||||
`035c59ee1134aa158c3cd818e8dd35f822cbfc4b`. The seven-job post-merge
|
||||
main [checks run](https://github.com/saphid/frame-control/actions/runs/40342375912)
|
||||
passed, including iOS and fake-Frame e2e. Annotated tag `v0.4.47`
|
||||
dereferences to that main commit. The tagged
|
||||
[release workflow](https://github.com/saphid/frame-control/actions/runs/40344549980)
|
||||
passed its draft, Linux, Windows, Apple Silicon Mac, Intel Mac, and checksum
|
||||
jobs.
|
||||
|
||||
## Frame and package gates
|
||||
|
||||
The [candidate evidence](v0447-candidate-2026-10-26.md) records a locked
|
||||
exact-version real Frame smoke: **12 passed / one optional x86 launch skipped /
|
||||
zero failed**, with owned cleanup and lock release. The x86 launch needs Steam
|
||||
Linux Runtime 4, which was absent. The headset was in standby and no wearer was
|
||||
confirmed, so the interactive worn-check script was not run. No panel or
|
||||
physical-controller claim follows from the smoke.
|
||||
|
||||
The draft contained exactly ten installers and `SHA256SUMS`, each with a
|
||||
GitHub SHA-256 digest. An independently downloaded `SHA256SUMS` matched a
|
||||
fresh ten-line list regenerated from GitHub asset metadata byte for byte;
|
||||
its SHA-256 was
|
||||
`ab1a95e5d51333dfc962723a21b044da935e07c24cedbd430222dc482d725508`.
|
||||
`scripts/publish-release.sh --dry-run v0.4.47` passed the tag, installer,
|
||||
checksum, and update-manifest gates after release notes were attached.
|
||||
|
||||
Independent downloads passed these checks:
|
||||
|
||||
| Package | Result |
|
||||
| --- | --- |
|
||||
| Linux x86_64 AppImage | SHA-256 `e7c83cf9177d11de724caf40a829b5c97ac3d2f0a04df24bcb1e4107310f1f49`; launched under isolated Xvfb on lxso1, bundled server returned HTTP 200 with `FrameControl/1`, then stopped. |
|
||||
| Mac arm64 ZIP | SHA-256 `10092131b7fd43950e0dc436a51da1e41b42803d532a89363e2b71880b92b212`; ZIP CRC valid, bundle version 0.4.47, main Mach-O CPU arm64. |
|
||||
| Mac x64 ZIP | SHA-256 `24531b088e85d2e7e9174289b0b9532c78fb85e7a29775c24369c00bc61e5408`; ZIP CRC valid, bundle version 0.4.47, main Mach-O CPU x86_64. |
|
||||
| Windows x64 Setup | SHA-256 `a697a7e9042eb0d9545d99639e39ff53fbf993491c0728f59f038f21175b77cc`; PE/Nullsoft installer format. Tagged Windows CI checked packaged files and Python libraries. |
|
||||
| Linux arm64 DEB | SHA-256 `e0be95ffc714e71487889a7da4886676b75c993f86bb674d180dc3ea4cd4b3b6`; package version 0.4.47 and architecture arm64. |
|
||||
|
||||
The tagged Mac and Windows packages were not launched on physical machines
|
||||
in this release gate. Earlier physical Intel Mac acceptance covers older
|
||||
bytes.
|
||||
|
||||
## Publication and limits
|
||||
|
||||
`scripts/publish-release.sh v0.4.47` published
|
||||
[v0.4.47](https://github.com/saphid/frame-control/releases/tag/v0.4.47) at
|
||||
15:07:28 UTC. GitHub's latest-release API returned that tag with
|
||||
`draft=false`, `prerelease=false`, ten installers, `SHA256SUMS`, and
|
||||
`update.json`. A fresh public latest-download of `update.json` reported
|
||||
version 0.4.47, the stable release page, ten hashed installers, and the
|
||||
worn-VR limit in its notes.
|
||||
|
||||
The exact-version official catalog is **4 works / 52 partial / 49 broken /
|
||||
11 N/A** out of 116. Game Theme Music has a local Settings view; its music
|
||||
playback and live backend are unverified. Shotty's backend imported and
|
||||
copied a synthetic screenshot in isolated Python; live ARM64 Decky loading
|
||||
and screenshot rescue are unverified. The real Frame was in standby;
|
||||
official-plugin wearer visibility, physical controller usability, VR
|
||||
keyboard, and optical comfort remain unverified. The hourly shared-lock
|
||||
worn check remains enabled.
|
||||
@@ -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: 49 · Broken: 52 · N/A: 11.**
|
||||
**Works: 4 · Partial: 52 · 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.
|
||||
@@ -25,6 +25,8 @@ python3 scripts/framey-audit-browser.py --cache /tmp/framey-archives
|
||||
|
||||
Open the browser audit URL in local Chromium, run `runAudit()` and inspect/download `results`. All backend/network actions remain denied.
|
||||
|
||||
A [Free Loader duplicate-entry follow-up](evidence/framey-free-loader-duplicates-2026-10-26.md) accepts only byte-identical duplicate ZIP members whose modes differ at most in execute bits. The exact archive passed private local and focused fake-Frame install/remove; its frontend rendered in opaque Chromium and isolated Python settings/empty-deals reads passed with a Decky stub. The earlier full 116-plugin fake-Frame audit blocked this archive before the fix. Actual Decky, feed updates and worn VR remain unverified.
|
||||
|
||||
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
|
||||
@@ -65,15 +67,15 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
|
||||
| 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 |
|
||||
| 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 | broken | Frontend audit failed: Cannot read properties of null (reading 'type'). | static archive, local browser, fake-Frame |
|
||||
| 53: Shotty 0.1.0 | legacy / legacy | broken | Library snapshot fixes the frontend allApps crash. Actual published Decky 3.3.0 amd64 backend fails importing click.get_app_dir (ModuleNotFoundError); the archive does not bundle click. Screenshot aggregation/rescue was not run. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 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 |
|
||||
| 56: MangoPeel 0.0.7 | 1 / 2 | partial | Initializer succeeds; gamescope/MangoHud actions and backend are unverified. | static archive, local browser, fake-Frame |
|
||||
| 58: Volume Boost 0.0.7 | legacy / legacy | works | Default-sink controls pass in local Chromium through real Decky 3.3.0 ARM64: linked slider/keyboard and numeric writes, separate channels, re-link and reopen, with independent Pulse readback including 125%/150% while muted. Exact original volume/mute restored. Audible output, Bluetooth sinks and worn VR unverified. | static archive, local browser, fake-Frame, isolated Linux backend, real Frame SSH |
|
||||
| 59: Web Browser 1.4.3 | legacy / legacy | broken | The exact frontend now initializes with a null Steam React-root lookup. The browser itself still requires Steam GamepadUI CDP injection and a backend WebSocket unavailable in Framey; no working browser tab was verified. | static archive, local browser, fake-Frame, focused local browser |
|
||||
| 60: DeckyFileServer 1.2.0 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: DeckyFileServer/bin/backend. | static archive, local browser, fake-Frame |
|
||||
| 61: TabMaster 2.15.1 | 1 / 2 | partial | Local Chromium rendered the QAM and navigable Overview/Filters documentation with an actual Decky 3.3.0 amd64 backend. Library-tab editing remains unavailable: Steam login/library patches and MobX reactions have no target. ARM64 and worn VR unverified. | static archive, local browser, fake-Frame |
|
||||
| 62: TS3 QuickAccess 1.2.0 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: ts3-qs4sd/bin/ts3-qs4sd.so. | static archive, local browser, fake-Frame |
|
||||
| 63: Free Loader 1.6.0 | 1 / 2 | broken | Unsafe plugin archive member. | static archive, local browser, fake-Frame |
|
||||
| 63: Free Loader 1.6.0 | 1 / 2 | partial | The exact archive's duplicate names contain identical bytes and differ only in execute bits; the guarded installer now accepts them, and a private local install/remove transaction passed. The current opaque Chromium host renders Free Games but cannot load deals with backend/network denied. Exact official Python settings and empty local-deals methods passed in a no-network container with a Decky stub. Real Decky, feed refresh, notifications and worn VR remain unverified. | static archive, local browser, fake-Frame pinned install/remove, local exact-archive transaction, isolated Python backend |
|
||||
| 64: Syncthing 0.2.7 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: decky-syncthing/bin/decky-syncthing-watchdog. | static archive, local browser, fake-Frame |
|
||||
| 65: Decky Terminal 0.5.3 | 1 / 2 | partial | Local routes initialize after parameter-route support; no shell commands were run. | static archive, local browser, fake-Frame |
|
||||
| 66: PlayTime 3.2.5 | 1 / 2 | partial | Statistics and routed settings render with a detached real Frame library/user snapshot and actual Decky 3.3.0 amd64 backend in isolation. User selection and empty statistics reads pass. Running snapshots are now exposed, but its private MobX observation is unsupported; automatic recording and ARM64 backend remain unverified. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
@@ -140,7 +142,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
|
||||
| 132: SuggestMe 1.5.3 | 1 / 2 | partial | Initializer succeeds; recommendation backend and Steam library data unverified. | static archive, local browser, fake-Frame |
|
||||
| 133: Non-Steam Badges 0.1.0 | 1 / 2 | partial | Initializer succeeds; Non-Steam game badges have no supported Steam page injection target. | static archive, local browser, fake-Frame |
|
||||
| 134: Deck Progress Tracker 1.3.6 | 1 / 2 | partial | Initializer succeeds; Steam game/library statistics and page patches unverified. | static archive, local browser, fake-Frame |
|
||||
| 135: Deck Shelves 2.0.0 | 1 / 1 | partial | The exact chunked frontend now initializes and renders Deck Shelves settings in an opaque local Chromium panel. Backend calls were denied; Home-screen shelf injection has no verified Steam UI target in Framey. Settings writes, real backend behavior and worn VR are unverified. | static archive, local browser, fake-Frame |
|
||||
| 135: Deck Shelves 2.0.0 | 1 / 1 | partial | The exact chunked frontend renders settings in opaque local Chromium. The official Python backend saved a shelf and returned it after reopen with a stub Decky module and temporary settings directory. Actual Decky RPC, real Frame settings, Steam Home shelf injection and worn VR remain unverified. | static archive, local browser, fake-Frame, isolated Python backend |
|
||||
| 136: Decky-Clipper 1.0.2 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: Decky-Clipper/bin/gstreamer-1.0/libgstproxy.so. | static archive, local browser, fake-Frame |
|
||||
| 137: Decky Proton Launch 0.9.0 | 1 / 2 | partial | Initializer succeeds; Proton wrapper installation and launch changes were not run. | static archive, local browser, fake-Frame |
|
||||
| 138: Docky 1.4.13 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
|
||||
@@ -112,8 +112,10 @@ the same-plugin navigation regression without a headset.
|
||||
interaction window, use the physical controller to open the sample and
|
||||
toggle **Example setting**. Confirm the laser aims where expected and the
|
||||
script records a fresh input/panel event and visible stereo output. The flag
|
||||
is the operator’s statement; sensor activity and trusted pointers can also
|
||||
be produced synthetically and cannot identify controller hardware.
|
||||
is the operator’s statement. The script requires valid locked SteamVR activity
|
||||
and proximity readings before input; proximity alone cannot establish wear.
|
||||
Sensor activity and trusted pointers can also be produced synthetically and
|
||||
cannot identify controller hardware.
|
||||
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
|
||||
|
||||
@@ -11,6 +11,7 @@ import io
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
import zipfile
|
||||
@@ -27,8 +28,45 @@ for(const key of ['error','warn']){const original=console[key];console[key]=(...
|
||||
report(key,args.map(String).join(' '));original(...args);};}
|
||||
window.fetch=()=>Promise.reject(Error('Audit blocks plugin network'));
|
||||
window.WebSocket=class{constructor(){throw Error('Audit blocks WebSocket');}};
|
||||
addEventListener('message', event=>{
|
||||
if(event.source!==parent||event.data?.type!=='audit:inspect')return;
|
||||
parent.postMessage({type:'audit:snapshot',text:(document.getElementById('framey-root')?.innerText||'').slice(0,2000)},'*');
|
||||
});
|
||||
</script>'''
|
||||
|
||||
|
||||
def render_panel(record, data):
|
||||
if hashlib.sha256(data).hexdigest() != record['sha256']:
|
||||
raise ValueError('Archive hash mismatch')
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||
bundle = archive.read(record['folder']+'/dist/index.js')
|
||||
# The audit supplies only digest-checked archive bytes, not an installed
|
||||
# receipt. Stub the receipt-bound lookup while using the shipped renderer.
|
||||
with patch.object(framey_panel, '_owned_dist', return_value=(None, bundle, record['sha256'])):
|
||||
return framey_panel.panel(record['id']).replace(
|
||||
b'<script id="framey-bundle"', PRELUDE.encode()+b'<script id="framey-bundle"')
|
||||
|
||||
|
||||
def read_module_asset(path, records, cache):
|
||||
"""Serve the same receipt-bound module URL shape as the installed panel."""
|
||||
prefix = '/framey/module/'
|
||||
if not path.startswith(prefix):
|
||||
raise ValueError('Invalid module path')
|
||||
parts = path[len(prefix):].split('/', 2)
|
||||
if len(parts) != 3 or not re.fullmatch(r'[1-9][0-9]*', parts[0]):
|
||||
raise ValueError('Invalid plugin ID')
|
||||
plugin_id, receipt, name = parts
|
||||
if not framey_panel.RECEIPT.fullmatch(receipt) or not framey_panel.MODULE_PATH.fullmatch(name):
|
||||
raise ValueError('Invalid module receipt or path')
|
||||
record = records[int(plugin_id)]
|
||||
if receipt != record['sha256']:
|
||||
raise ValueError('Plugin receipt changed')
|
||||
data = (cache/(receipt+'.zip')).read_bytes()
|
||||
if hashlib.sha256(data).hexdigest() != receipt:
|
||||
raise ValueError('Archive hash mismatch')
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||
return archive.read(record['folder']+'/dist/'+name)
|
||||
|
||||
PAGE = '''<!doctype html><meta charset=utf-8><title>Framey catalog frontend audit</title>
|
||||
<h1>Framey catalog frontend audit</h1>
|
||||
<p>Exact archived frontend code runs in an opaque iframe. All backend calls and network requests are denied.
|
||||
@@ -42,6 +80,7 @@ addEventListener('message',event=>{
|
||||
if(event.source!==panel.contentWindow||!active)return;
|
||||
const data=event.data;
|
||||
if(data.type==='audit:log'&&active.logs.length<8)active.logs.push({kind:data.kind,message:data.message.split('\\n')[0].slice(0,400)});
|
||||
if(data.type==='audit:snapshot')active.rootText=String(data.text).slice(0,2000);
|
||||
if(data.type==='framey:error')active.error=String(data.message).slice(0,500);
|
||||
if(data.type==='framey:ready')active.ready=true;
|
||||
if(data.type==='framey:events')event.source.postMessage({type:'framey:reply',id:data.id,result:{events:[],cursor:0}},'*');
|
||||
@@ -59,10 +98,15 @@ window.runAudit=async(ids,settleMs=1200)=>{
|
||||
if(!Number.isInteger(settleMs)||settleMs<1200||settleMs>10000)throw Error('Invalid audit settle time');
|
||||
if(running)throw Error('Audit already running');running=true;run.disabled=true;
|
||||
try{for(const plugin of plugins.filter(plugin=>!ids||ids.includes(plugin.id))){
|
||||
active={id:plugin.id,name:plugin.name,ready:false,error:null,logs:[],methods:[],
|
||||
active={id:plugin.id,name:plugin.name,ready:false,error:null,logs:[],methods:[],rootText:'',
|
||||
library:librarySnapshot?'snapshot replay':'unavailable',running:runningSnapshot?'snapshot replay':'unavailable'};
|
||||
progress.textContent=plugin.id+' '+plugin.name;panel.src='/panel/'+plugin.id;
|
||||
await new Promise(resolve=>setTimeout(resolve,settleMs));results.push(active);active=null;
|
||||
await new Promise(resolve=>setTimeout(resolve,settleMs));
|
||||
// Detached Steam snapshots may contain personal library data. Keep text
|
||||
// observations out of reports that replay either snapshot.
|
||||
if(!librarySnapshot&&!runningSnapshot)panel.contentWindow.postMessage({type:'audit:inspect'},'*');
|
||||
await new Promise(resolve=>setTimeout(resolve,80));
|
||||
results.push(active);active=null;
|
||||
panel.src='about:blank';await new Promise(resolve=>setTimeout(resolve,30));
|
||||
}}finally{running=false;run.disabled=false;progress.textContent='Done: '+results.length;}
|
||||
return results;
|
||||
@@ -116,27 +160,13 @@ def main():
|
||||
try:
|
||||
record = records[int(path.split('/')[-1])]
|
||||
data = (args.cache/(record['sha256']+'.zip')).read_bytes()
|
||||
if hashlib.sha256(data).hexdigest() != record['sha256']:
|
||||
raise ValueError('Archive hash mismatch')
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||
bundle = archive.read(record['folder']+'/dist/index.js')
|
||||
with patch.object(framey_panel, 'local_bundle', return_value=bundle):
|
||||
body = framey_panel.panel(record['id']).replace(
|
||||
b'<script id="framey-bundle"', PRELUDE.encode()+b'<script id="framey-bundle"')
|
||||
body = render_panel(record, data)
|
||||
except (KeyError, ValueError, OSError, zipfile.BadZipFile):
|
||||
self.send_error(404, 'Verified frontend archive unavailable')
|
||||
return
|
||||
elif path.startswith('/framey/module/'):
|
||||
try:
|
||||
plugin_id, name = path.removeprefix('/framey/module/').split('/', 1)
|
||||
if not framey_panel.MODULE_PATH.fullmatch(name):
|
||||
raise ValueError('Invalid module path')
|
||||
record = records[int(plugin_id)]
|
||||
data = (args.cache/(record['sha256']+'.zip')).read_bytes()
|
||||
if hashlib.sha256(data).hexdigest() != record['sha256']:
|
||||
raise ValueError('Archive hash mismatch')
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||
body = archive.read(record['folder']+'/dist/'+name)
|
||||
body = read_module_asset(path, records, args.cache)
|
||||
content_type = 'text/javascript; charset=utf-8'
|
||||
module_asset = True
|
||||
except (KeyError, ValueError, OSError, zipfile.BadZipFile):
|
||||
|
||||
@@ -40,6 +40,7 @@ def render(data):
|
||||
'python3 scripts/framey-audit-browser.py --cache /tmp/framey-archives',
|
||||
'```', '',
|
||||
'Open the browser audit URL in local Chromium, run `runAudit()` and inspect/download `results`. All backend/network actions remain denied.', '',
|
||||
'A [Free Loader duplicate-entry follow-up](evidence/framey-free-loader-duplicates-2026-10-26.md) accepts only byte-identical duplicate ZIP members whose modes differ at most in execute bits. The exact archive passed private local and focused fake-Frame install/remove; its frontend rendered in opaque Chromium and isolated Python settings/empty-deals reads passed with a Decky stub. The earlier full 116-plugin fake-Frame audit blocked this archive before the fix. Actual Decky, feed updates and worn VR remain unverified.', '',
|
||||
'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. '
|
||||
|
||||
@@ -36,13 +36,19 @@ def remote(command, stdin=None, timeout=15):
|
||||
|
||||
|
||||
PROBE = r'''
|
||||
import json, pathlib, subprocess
|
||||
import json, math, pathlib, subprocess
|
||||
power=[]
|
||||
for item in pathlib.Path('/sys/class/power_supply').glob('*/capacity'):
|
||||
try:
|
||||
if (item.parent/'type').read_text().strip() == 'Battery':
|
||||
power.append({'name':item.parent.name,'percent':int(item.read_text().strip())})
|
||||
except (OSError,ValueError): pass
|
||||
proximity=[]
|
||||
for item in pathlib.Path('/sys/bus/iio/devices').glob('iio:device*/in_proximity_raw'):
|
||||
try:
|
||||
value=float(item.read_text().strip())
|
||||
if math.isfinite(value) and value >= 0: proximity.append(value)
|
||||
except (OSError,ValueError): pass
|
||||
stats=subprocess.run(['/opt/steamvr/bin/linuxarm64/vrcmd','--stats'],capture_output=True,text=True,timeout=5)
|
||||
try:
|
||||
rows=json.loads(stats.stdout) if stats.returncode == 0 else None
|
||||
@@ -62,7 +68,7 @@ if base.is_dir() and not base.is_symlink():
|
||||
if isinstance(p.get('storeId'),int): plugins.append({'id':p['storeId'],'name':manifest['name']})
|
||||
except (OSError,ValueError,KeyError): pass
|
||||
service=subprocess.run(['systemctl','--user','is-active','frame-control-headset.service'],capture_output=True,text=True)
|
||||
print(json.dumps({'battery':power,'activity':activity,'panel':panel,
|
||||
print(json.dumps({'battery':power,'proximity':proximity,'activity':activity,'panel':panel,
|
||||
'panelService':service.returncode==0,'plugins':plugins}))
|
||||
'''
|
||||
|
||||
@@ -120,11 +126,15 @@ def main(worn_confirmed=False):
|
||||
raise RuntimeError('Battery capacity unavailable or below 20%; no device session started')
|
||||
if before['activity'] is None or before['activity'] >= 3:
|
||||
raise RuntimeError('SteamVR is in standby; no worn-headset check started')
|
||||
if not before.get('proximity'):
|
||||
raise RuntimeError('Proximity reading unavailable; no worn-headset check started')
|
||||
if not before['panelService'] or not before['plugins']:
|
||||
raise RuntimeError('Install and open the Frame Control panel and a Framey plugin before this check')
|
||||
after_lock = probe()
|
||||
if after_lock['activity'] >= 3 or min(x['percent'] for x in after_lock['battery']) < 20:
|
||||
raise RuntimeError('Activity or battery changed after acquiring the lock')
|
||||
if (not isinstance(after_lock.get('activity'), int) or after_lock['activity'] >= 3 or
|
||||
not after_lock.get('proximity') or not after_lock.get('battery') or
|
||||
min(x['percent'] for x in after_lock['battery']) < 20):
|
||||
raise RuntimeError('Activity, proximity or battery changed after acquiring the lock')
|
||||
state = json.dumps({'nonce':token,'started':time.time()})
|
||||
code = "import pathlib; p=pathlib.Path.home()/'.local/share/frame-control/headset/worn-check.json'; p.write_text(" + repr(state) + ")"
|
||||
remote('python3 -c ' + shlex.quote(code))
|
||||
|
||||
@@ -192,6 +192,10 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
|
||||
self.assertEqual(
|
||||
ssh(f'sha256sum {installed}/dist/{chunk}').split()[0],
|
||||
hashlib.sha256(expected_chunk).hexdigest())
|
||||
if plugin['id'] == 53:
|
||||
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'])
|
||||
job = finished(ok('POST', '/api/decky/plugin',
|
||||
{'action':'remove','id':plugin['id'],'acknowledged':True}))
|
||||
self.assertFalse(job.get('error'), job)
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -17,6 +17,10 @@ PLUGINS = (
|
||||
'folder': 'hltb-for-deck', 'sha256': 'bd54a23b0b8ec0798aa590e5be236522a593529d7680e77fb888e2971bf3d558'},
|
||||
{'id': 36, 'name': 'SteamGridDB', 'version': '1.8.1', 'api_version': 1,
|
||||
'folder': 'decky-steamgriddb', 'sha256': 'e84f5b1f111af8862b06218479491fbf61036e1bfd9f16f93f9901e9b9ebc5e0'},
|
||||
{'id': 53, 'name': 'Shotty', 'version': '0.1.0', 'api_version': 'legacy',
|
||||
'folder': 'Shotty', 'sha256': 'ab7f6618b264a0cb766525e788cef6c3e1478c0fef98a701225e5e8af441e2fe'},
|
||||
{'id': 63, 'name': 'Free Loader', 'version': '1.6.0', 'api_version': 1,
|
||||
'folder': 'free-loader', 'sha256': '05208aee6f6ba824391d8ddc81b1168b2599a9711e7528cdaa789f6cc710b42a'},
|
||||
{'id': 135, 'name': 'Deck Shelves', 'version': '2.0.0', 'api_version': 1,
|
||||
'folder': 'Deck-Shelves', 'sha256': 'b566280ad38fd3ecd584b5b46a855f301539eb9a86063834c64b7045f7063f91'},
|
||||
)
|
||||
|
||||
@@ -29,6 +29,10 @@ vm.runInContext(testSource,context);
|
||||
// No Steam React tree exists inside the isolated Framey iframe.
|
||||
assert.equal(window.DFL.getReactRoot({}),null);
|
||||
assert.equal(window.DFL.findInReactTree(window.DFL.getReactRoot(null),()=>true),null);
|
||||
const inertSteamComponent=window.DFL.fakeRenderComponent(undefined).type;
|
||||
assert.equal(inertSteamComponent.name,'MissingSteamComponent');
|
||||
assert.equal(window.DFL.fakeRenderComponent(undefined).type,inertSteamComponent);
|
||||
assert.equal(typeof window.DFL.afterPatch(inertSteamComponent.prototype,'render',()=>{}).unpatch,'function');
|
||||
// Official @decky/rollup bundles use the automatic JSX runtime, including keyed lists.
|
||||
const jsx=window.SP_JSX.jsx('p',{id:'message',children:'Saved'},'message-key');
|
||||
assert.equal(jsx.type,'p');
|
||||
|
||||
@@ -30,6 +30,21 @@ def archive(flags=(), extra=None):
|
||||
return data, hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def duplicate_archive(second=b'#!/bin/sh\nexit 0\n', second_mode=0o100755):
|
||||
stream = io.BytesIO()
|
||||
with warnings.catch_warnings(), zipfile.ZipFile(stream, 'w') as bundle:
|
||||
warnings.simplefilter('ignore', UserWarning)
|
||||
bundle.writestr('Example/plugin.json', json.dumps({'name': 'Example', 'flags': []}))
|
||||
bundle.writestr('Example/dist/index.js', 'export default {};' + ' ' * 1200)
|
||||
for mode, payload in ((0o100644, b'#!/bin/sh\nexit 0\n'), (second_mode, second)):
|
||||
info = zipfile.ZipInfo('Example/bin/helper')
|
||||
info.create_system = 3
|
||||
info.external_attr = mode << 16
|
||||
bundle.writestr(info, payload)
|
||||
data = stream.getvalue()
|
||||
return data, hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
class ArchiveTests(unittest.TestCase):
|
||||
def test_catalog_selects_valid_visible_latest_versions(self):
|
||||
records = [{'id': 42, 'name': 'Example', 'author': 'Panel Author', 'visible': True, 'versions':
|
||||
@@ -80,6 +95,14 @@ class ArchiveTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ValueError, 'manifest'):
|
||||
frame_decky_store.archive_info(data, hashlib.sha256(data).hexdigest(), 'Example')
|
||||
|
||||
def test_only_identical_duplicate_payloads_with_execute_bit_difference_are_accepted(self):
|
||||
data, digest = duplicate_archive()
|
||||
self.assertEqual(frame_decky_store.archive_info(data, digest, 'Example'), 'Example')
|
||||
for data, digest in (duplicate_archive(second=b'#!/bin/sh\nexit 1\n'),
|
||||
duplicate_archive(second_mode=0o100666)):
|
||||
with self.subTest(digest=digest), self.assertRaisesRegex(ValueError, 'Unsafe'):
|
||||
frame_decky_store.archive_info(data, digest, 'Example')
|
||||
|
||||
|
||||
@unittest.skipIf(os.name == 'nt', 'remote plugin transaction runs on Linux')
|
||||
class StoreTransaction(unittest.TestCase):
|
||||
@@ -98,14 +121,14 @@ class StoreTransaction(unittest.TestCase):
|
||||
self.folder = home / '.local/share/frame-control/decky/plugins/Example'
|
||||
self.folder.parent.mkdir(parents=True)
|
||||
|
||||
def apply(self, action, data=None, expected='-'):
|
||||
def apply(self, action, data=None, expected='-', plugin_id=42, folder='Example', version='1.0'):
|
||||
token = secrets.token_hex(12)
|
||||
source = Path('/tmp/frame-control-decky-plugin-' + token)
|
||||
if data is not None:
|
||||
source.write_bytes(data)
|
||||
digest = hashlib.sha256(data or b'').hexdigest()
|
||||
try:
|
||||
return subprocess.run([sys.executable, '-', action, token, '42', digest, 'Example', '1.0', expected],
|
||||
return subprocess.run([sys.executable, '-', action, token, str(plugin_id), digest, folder, version, expected],
|
||||
input=frame_decky_store.APPLY, text=True, capture_output=True,
|
||||
env=self.env, timeout=15)
|
||||
finally:
|
||||
@@ -138,6 +161,59 @@ class StoreTransaction(unittest.TestCase):
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertTrue(external.exists())
|
||||
|
||||
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)
|
||||
data = official_archive(plugin)
|
||||
self.assertEqual(hashlib.sha256(data).hexdigest(), plugin['sha256'])
|
||||
shotty = 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((shotty / '.frame-control-store.json').read_text())
|
||||
self.assertEqual(receipt['sha256'], plugin['sha256'])
|
||||
self.assertEqual(receipt['compatShims'], ['shotty-click-app-dir'])
|
||||
compat = shotty / 'py_modules/click.py'
|
||||
self.assertTrue(compat.is_file())
|
||||
source = '''import asyncio, importlib.util, logging, sys, types
|
||||
from pathlib import Path
|
||||
folder = Path(sys.argv[1])
|
||||
sys.path.insert(0, str(folder/'py_modules'))
|
||||
decky = types.ModuleType('decky_plugin')
|
||||
decky.logger = logging.getLogger('shotty-test')
|
||||
decky.DECKY_PLUGIN_DIR = str(folder)
|
||||
sys.modules['decky_plugin'] = decky
|
||||
spec = importlib.util.spec_from_file_location('shotty', folder/'main.py')
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
assert module.Plugin.__name__ == 'Plugin'
|
||||
from click import get_app_dir
|
||||
assert get_app_dir('Shotty') == str(Path.home()/'.config/Shotty')
|
||||
photo = Path.home()/'.local/share/Steam/userdata/100/760/remote/42/screenshots/test.jpg'
|
||||
photo.parent.mkdir(parents=True)
|
||||
photo.write_bytes(b'local screenshot fixture')
|
||||
plugin = module.Plugin()
|
||||
assert asyncio.run(plugin.aggregate_all([[42, 'Local Test Game']])) == 1
|
||||
copied = Path.home()/'Pictures/Screenshots/Local Test Game/test.jpg'
|
||||
assert copied.read_bytes() == b'local screenshot fixture'
|
||||
'''
|
||||
imported = subprocess.run([sys.executable, '-c', source, str(shotty)],
|
||||
capture_output=True, text=True,
|
||||
env={**self.env, 'XDG_CONFIG_HOME': str(Path(self.tmp.name)/'.config')}, timeout=10)
|
||||
self.assertEqual(imported.returncode, 0, imported.stderr)
|
||||
result = self.apply('remove', expected=plugin['sha256'], **args)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse(shotty.exists())
|
||||
stream = io.BytesIO()
|
||||
with zipfile.ZipFile(stream, 'w') as bundle:
|
||||
bundle.writestr('Shotty/plugin.json', json.dumps({'name': 'Shotty', 'flags': []}))
|
||||
bundle.writestr('Shotty/dist/index.js', 'export default {};' + ' ' * 1200)
|
||||
other = stream.getvalue()
|
||||
result = self.apply('install', other, **args)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse((shotty / 'py_modules/click.py').exists())
|
||||
self.assertNotIn('compatShims', json.loads((shotty / '.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'))
|
||||
@@ -152,6 +228,16 @@ class StoreTransaction(unittest.TestCase):
|
||||
self.assertFalse((self.folder / 'plugin.json').stat().st_mode & 0o111)
|
||||
self.assertFalse((self.folder / 'dist/index.js').stat().st_mode & 0o111)
|
||||
|
||||
def test_identical_duplicate_archive_installs_without_widening_permissions(self):
|
||||
data, _ = duplicate_archive()
|
||||
result = self.apply('install', data)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
helper = self.folder / 'bin/helper'
|
||||
self.assertEqual(helper.read_bytes(), b'#!/bin/sh\nexit 0\n')
|
||||
self.assertTrue(helper.stat().st_mode & 0o100)
|
||||
self.assertFalse(helper.stat().st_mode & 0o6000)
|
||||
self.assertEqual(self.apply('remove').returncode, 0)
|
||||
|
||||
def test_declared_executable_is_owner_only_without_privilege_bits(self):
|
||||
info = zipfile.ZipInfo('Example/bin/entrypoint')
|
||||
info.create_system = 3
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""The local frontend audit still renders receipt-bound Framey panels."""
|
||||
import sandbox # noqa: F401
|
||||
import base64
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
|
||||
|
||||
MODULE = Path(__file__).resolve().parents[1]/'scripts/framey-audit-browser.py'
|
||||
spec = importlib.util.spec_from_file_location('framey_audit_browser', MODULE)
|
||||
audit = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(audit)
|
||||
|
||||
|
||||
class ReceiptBoundAudit(unittest.TestCase):
|
||||
def test_digest_checked_archive_renders_without_installed_plugin(self):
|
||||
bundle = b"import './chunks/frontend.js';"
|
||||
stream = io.BytesIO()
|
||||
with zipfile.ZipFile(stream, 'w') as archive:
|
||||
archive.writestr('Example/dist/index.js', bundle)
|
||||
archive.writestr('Example/dist/chunks/frontend.js', b'export const ready = true;')
|
||||
data = stream.getvalue()
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
record = {'id':42, 'folder':'Example', 'sha256':digest}
|
||||
page = audit.render_panel(record, data)
|
||||
self.assertIn(base64.b64encode(bundle), page)
|
||||
self.assertIn(f'/framey/module/42/{digest}/index.js'.encode(), page)
|
||||
self.assertIn(b'const report=(kind,message)', page)
|
||||
with self.assertRaisesRegex(ValueError, 'hash mismatch'):
|
||||
audit.render_panel({**record, 'sha256':'0'*64}, data)
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
cache = Path(directory)
|
||||
(cache/(digest+'.zip')).write_bytes(data)
|
||||
records = {42:record}
|
||||
route = f'/framey/module/42/{digest}/'
|
||||
self.assertEqual(audit.read_module_asset(route+'index.js', records, cache), bundle)
|
||||
self.assertEqual(audit.read_module_asset(route+'chunks/frontend.js', records, cache),
|
||||
b'export const ready = true;')
|
||||
for bad in (f'/framey/module/42/{"0"*64}/index.js',
|
||||
route+'chunks/../frontend.js', route+'other.json',
|
||||
f'/framey/module/042/{digest}/index.js'):
|
||||
with self.subTest(bad=bad), self.assertRaises((ValueError, KeyError)):
|
||||
audit.read_module_asset(bad, records, cache)
|
||||
(cache/(digest+'.zip')).write_bytes(data+b'changed')
|
||||
with self.assertRaisesRegex(ValueError, 'hash mismatch'):
|
||||
audit.read_module_asset(route+'index.js', records, cache)
|
||||
@@ -32,7 +32,7 @@ class WornGate(unittest.TestCase):
|
||||
self.assertIn('trusted pointer events alone do not prove',out.getvalue())
|
||||
|
||||
def test_standby_is_read_only_under_owned_lock_and_releases_it(self):
|
||||
state = {'battery':[{'percent':99}], 'activity':3, 'panelService':True,
|
||||
state = {'battery':[{'percent':99}], 'activity':3, 'proximity':[6.25], 'panelService':True,
|
||||
'plugins':[{'id':14,'name':'ProtonDB Badges'}], 'panel':None}
|
||||
code, report, remote, probe = self.run_gate(state)
|
||||
self.assertEqual(code, 1)
|
||||
@@ -43,7 +43,7 @@ class WornGate(unittest.TestCase):
|
||||
self.assertIn('rmdir /tmp/frame-test.lock', remote.call_args_list[1].args[0])
|
||||
|
||||
def test_low_battery_releases_owned_lock(self):
|
||||
state = {'battery':[{'percent':19}], 'activity':0, 'panelService':True,
|
||||
state = {'battery':[{'percent':19}], 'activity':0, 'proximity':[6.25], 'panelService':True,
|
||||
'plugins':[{'id':14,'name':'ProtonDB Badges'}], 'panel':None}
|
||||
code, report, remote, probe = self.run_gate(state)
|
||||
self.assertEqual(code, 1)
|
||||
@@ -52,7 +52,7 @@ class WornGate(unittest.TestCase):
|
||||
self.assertEqual(remote.call_count, 2)
|
||||
|
||||
def test_foreign_lock_prevents_all_sensor_reads_and_is_never_removed(self):
|
||||
state = {'battery':[{'percent':99}], 'activity':0, 'panelService':True,
|
||||
state = {'battery':[{'percent':99}], 'activity':0, 'proximity':[6.25], 'panelService':True,
|
||||
'plugins':[{'id':14,'name':'ProtonDB Badges'}], 'panel':None}
|
||||
code, report, remote, probe = self.run_gate(state, RuntimeError('File exists'))
|
||||
self.assertEqual(code, 1)
|
||||
@@ -64,6 +64,29 @@ class WornGate(unittest.TestCase):
|
||||
def test_screenshot_palette_counts_distinct_colors(self):
|
||||
self.assertEqual(worn.color_count('0 #ff00aa 1 #ff00aa 2 #00ff00'), 2)
|
||||
|
||||
def test_missing_proximity_stops_before_panel_or_input_and_releases_lock(self):
|
||||
state = {'battery':[{'percent':99}], 'activity':0, 'proximity':[],
|
||||
'panelService':True, 'plugins':[{'id':14,'name':'ProtonDB Badges'}], 'panel':None}
|
||||
code, report, remote, probe = self.run_gate(state)
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn('Proximity reading unavailable', report)
|
||||
probe.assert_called_once()
|
||||
self.assertEqual(remote.call_count, 2)
|
||||
self.assertIn('rmdir /tmp/frame-test.lock', remote.call_args_list[-1].args[0])
|
||||
|
||||
def test_proximity_disappearing_on_second_read_stops_before_input(self):
|
||||
first = {'battery':[{'percent':99}], 'activity':0, 'proximity':[200.0],
|
||||
'panelService':True, 'plugins':[{'id':14,'name':'ProtonDB Badges'}], 'panel':None}
|
||||
second = {**first, 'proximity':[]}
|
||||
out = io.StringIO()
|
||||
with patch.object(worn, 'probe', side_effect=[first, second]) as probe, \
|
||||
patch.object(worn, 'remote') as remote, contextlib.redirect_stdout(out):
|
||||
code = worn.main(worn_confirmed=True)
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn('Activity, proximity or battery changed', out.getvalue())
|
||||
self.assertEqual(probe.call_count, 2)
|
||||
self.assertEqual(remote.call_count, 2)
|
||||
|
||||
def test_probe_reads_activity_from_status_after_metrics(self):
|
||||
rows = [{'operation':'performance','frame_presents':12},
|
||||
{'operation':'status','activity_level':1}]
|
||||
@@ -81,4 +104,15 @@ class WornGate(unittest.TestCase):
|
||||
out = io.StringIO()
|
||||
with patch('subprocess.run', side_effect=run), contextlib.redirect_stdout(out):
|
||||
exec(worn.PROBE, {})
|
||||
self.assertEqual(json.loads(out.getvalue())['activity'], expected)
|
||||
result = json.loads(out.getvalue())
|
||||
self.assertEqual(result['activity'], expected)
|
||||
self.assertIsInstance(result['proximity'], list)
|
||||
|
||||
def test_probe_discards_nonfinite_proximity(self):
|
||||
def glob(_path, pattern):
|
||||
return [SimpleNamespace(read_text=lambda: 'nan')] if pattern == 'iio:device*/in_proximity_raw' else []
|
||||
with patch('pathlib.Path.glob', autospec=True, side_effect=glob), \
|
||||
patch('subprocess.run', return_value=SimpleNamespace(returncode=1, stdout='')), \
|
||||
contextlib.redirect_stdout(io.StringIO()) as out:
|
||||
exec(worn.PROBE, {})
|
||||
self.assertEqual(json.loads(out.getvalue())['proximity'], [])
|
||||
+48
-7
@@ -86,7 +86,7 @@ def archive_info(data, expected_hash, expected_name):
|
||||
folder = manifests[0].split('/')[0]
|
||||
if not SAFE_FOLDER.fullmatch(folder):
|
||||
raise ValueError('Invalid plugin folder')
|
||||
seen = set()
|
||||
seen = {}
|
||||
for entry in files:
|
||||
name = entry.filename
|
||||
mode = entry.external_attr >> 16
|
||||
@@ -95,11 +95,21 @@ def archive_info(data, expected_hash, expected_name):
|
||||
if entry.is_dir() and parts[-1] == '':
|
||||
parts = parts[:-1]
|
||||
if ('\\' in name or name.startswith('/') or
|
||||
any(part in ('', '.', '..') for part in parts) or name in seen or
|
||||
any(part in ('', '.', '..') for part in parts) or
|
||||
parts[0] != folder or entry.file_size > 30 * 1024 * 1024 or
|
||||
kind not in (0, 0o040000, 0o100000)):
|
||||
raise ValueError('Unsafe plugin archive member')
|
||||
seen.add(name)
|
||||
prior = seen.get(name)
|
||||
if prior is not None:
|
||||
# Some official ZIPs repeat unchanged files with execute-bit
|
||||
# differences. Reject any content or other mode ambiguity.
|
||||
old_mode = prior.external_attr >> 16
|
||||
if (prior.file_size != entry.file_size or
|
||||
((old_mode ^ mode) & ~0o111) or
|
||||
archive.read(prior) != archive.read(entry)):
|
||||
raise ValueError('Unsafe plugin archive member')
|
||||
else:
|
||||
seen[name] = entry
|
||||
if entry.is_dir():
|
||||
continue
|
||||
with archive.open(entry) as source:
|
||||
@@ -164,17 +174,26 @@ try:
|
||||
files=z.infolist()
|
||||
if not files or len(files)>3000 or sum(f.file_size for f in files)>150*1024*1024:
|
||||
raise RuntimeError('Plugin archive too large')
|
||||
seen=set()
|
||||
seen={}
|
||||
for f in files:
|
||||
parts=f.filename.split('/')
|
||||
if f.is_dir() and parts[-1]=='': parts=parts[:-1]
|
||||
kind=f.external_attr>>16&0o170000
|
||||
if (not parts or parts[0]!=folder or f.filename.startswith('/') or
|
||||
any(part in ('','.','..') for part in parts) or '\\' in f.filename or
|
||||
f.filename in seen or f.file_size>30*1024*1024 or
|
||||
f.file_size>30*1024*1024 or
|
||||
kind not in (0,0o040000,0o100000)):
|
||||
raise RuntimeError('Unsafe plugin archive')
|
||||
seen.add(f.filename)
|
||||
prior=seen.get(f.filename)
|
||||
if prior is not None:
|
||||
# Match desktop validation before zipfile overwrites a
|
||||
# repeated member during extraction.
|
||||
old_mode=prior.external_attr>>16
|
||||
if (prior.file_size!=f.file_size or
|
||||
((old_mode^ (f.external_attr>>16)) & ~0o111) or
|
||||
z.read(prior)!=z.read(f)):
|
||||
raise RuntimeError('Unsafe plugin archive')
|
||||
else: seen[f.filename]=f
|
||||
stage.mkdir(mode=0o700)
|
||||
z.extractall(stage)
|
||||
# zipfile drops execute bits. Legacy plugins also publish shebang
|
||||
@@ -189,6 +208,26 @@ try:
|
||||
entry.chmod(entry.stat().st_mode|0o100)
|
||||
unpacked=stage/folder
|
||||
if not unpacked.is_dir(): raise RuntimeError('Missing plugin folder')
|
||||
compat_shims=[]
|
||||
# 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
|
||||
# py_modules path; other plugins keep their original imports.
|
||||
if int(plugin_id)==53 and digest=='ab7f6618b264a0cb766525e788cef6c3e1478c0fef98a701225e5e8af441e2fe' and folder=='Shotty':
|
||||
compat=unpacked/'py_modules/click.py'
|
||||
if compat.exists(): raise RuntimeError('Shotty compatibility module collides with archive')
|
||||
compat.parent.mkdir(parents=True,exist_ok=True)
|
||||
compat.write_text(
|
||||
'"""Frame Control Linux compatibility for Shotty 0.1.0."""\n'
|
||||
'import os\n'
|
||||
'def get_app_dir(app_name, roaming=True, force_posix=False):\n'
|
||||
' if not isinstance(app_name, str) or not app_name:\n'
|
||||
' raise ValueError("app_name must be a nonempty string")\n'
|
||||
' base = os.environ.get("XDG_CONFIG_HOME")\n'
|
||||
' if not base or not os.path.isabs(base):\n'
|
||||
' 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')
|
||||
# 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')
|
||||
@@ -202,7 +241,9 @@ try:
|
||||
if isinstance(peer_data,dict) and peer_data.get('name')==proposed_name:
|
||||
raise RuntimeError('A different plugin with this name is already installed')
|
||||
except (OSError,ValueError,UnicodeError): continue
|
||||
(unpacked/marker).write_text(json.dumps({'storeId':int(plugin_id),'sha256':digest,'version':version})+'\n')
|
||||
record={'storeId':int(plugin_id),'sha256':digest,'version':version}
|
||||
if compat_shims: record['compatShims']=compat_shims
|
||||
(unpacked/marker).write_text(json.dumps(record)+'\n')
|
||||
# Stop even a failed unit before removal to cancel any pending auto-restart.
|
||||
if running or action=='remove': service('stop','frame-control-decky.service')
|
||||
try:
|
||||
|
||||
@@ -3795,8 +3795,8 @@
|
||||
"https://github.com/SteamDeckHomebrew/decky-loader",
|
||||
"https://github.com/yt-dlp/yt-dlp"
|
||||
],
|
||||
"status": "broken",
|
||||
"reason": "Frontend audit failed: Cannot read properties of null (reading 'type').",
|
||||
"status": "partial",
|
||||
"reason": "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.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
@@ -3829,6 +3829,13 @@
|
||||
"sha256": "9ce293ab4db212c03c6b96dfbc5e4d92e874d33387463f53223773921f75a80e",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"renderFollowUp": {
|
||||
"evidence": "docs/evidence/framey-game-theme-music-2026-10-26.md",
|
||||
"mode": "exact archive in opaque local Chromium; backend and network denied",
|
||||
"frontendReady": true,
|
||||
"settingsRendered": true,
|
||||
"steamGamePagePatched": false
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -3875,14 +3882,15 @@
|
||||
"sourceURLs": [
|
||||
"https://github.com/safijari/Shotty"
|
||||
],
|
||||
"status": "broken",
|
||||
"reason": "Library snapshot fixes the frontend allApps crash. Actual published Decky 3.3.0 amd64 backend fails importing click.get_app_dir (ModuleNotFoundError); the archive does not bundle click. Screenshot aggregation/rescue was not run.",
|
||||
"status": "partial",
|
||||
"reason": "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.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
"local browser",
|
||||
"fake-Frame",
|
||||
"real Frame SSH"
|
||||
"real Frame SSH",
|
||||
"isolated Python backend"
|
||||
],
|
||||
"browser": {
|
||||
"id": 53,
|
||||
@@ -3913,6 +3921,13 @@
|
||||
"evidence": "docs/evidence/framey-library-context-2026-10-26.json",
|
||||
"mode": "real Frame read-only CEF projection; local Chromium snapshot replay; scoped actual amd64 backends",
|
||||
"frontendReady": true
|
||||
},
|
||||
"backendFollowUp": {
|
||||
"evidence": "docs/evidence/framey-shotty-python-2026-10-26.md",
|
||||
"mode": "exact archive managed transaction and isolated Python import/copy with temporary HOME",
|
||||
"compatShim": "shotty-click-app-dir",
|
||||
"syntheticScreenshotCopied": true,
|
||||
"actualDeckyArm64": false
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -4651,13 +4666,15 @@
|
||||
"https://github.com/jwhitlow45/free-loader",
|
||||
"https://github.com/jwhitlow45/free-loader.git"
|
||||
],
|
||||
"status": "broken",
|
||||
"reason": "Unsafe plugin archive member.",
|
||||
"blockInstall": true,
|
||||
"status": "partial",
|
||||
"reason": "The exact archive's duplicate names contain identical bytes and differ only in execute bits; the guarded installer now accepts them, and a private local install/remove transaction passed. The current opaque Chromium host renders Free Games but cannot load deals with backend/network denied. Exact official Python settings and empty local-deals methods passed in a no-network container with a Decky stub. Real Decky, feed refresh, notifications and worn VR remain unverified.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
"local browser",
|
||||
"fake-Frame"
|
||||
"fake-Frame pinned install/remove",
|
||||
"local exact-archive transaction",
|
||||
"isolated Python backend"
|
||||
],
|
||||
"browser": {
|
||||
"id": 63,
|
||||
@@ -4674,8 +4691,8 @@
|
||||
},
|
||||
"archiveValidation": {
|
||||
"id": 63,
|
||||
"accepted": false,
|
||||
"reason": "Unsafe plugin archive member"
|
||||
"accepted": true,
|
||||
"folder": "free-loader"
|
||||
},
|
||||
"backend": "Python",
|
||||
"hardware": "No specific hardware requirement detected statically; runtime requirements unverified.",
|
||||
@@ -4686,9 +4703,15 @@
|
||||
"fakeFrame": {
|
||||
"id": 63,
|
||||
"sha256": "05208aee6f6ba824391d8ddc81b1168b2599a9711e7528cdaa789f6cc710b42a",
|
||||
"archiveValidation": "Unsafe plugin archive member",
|
||||
"result": "blocked",
|
||||
"reason": "ValueError: Unsafe plugin archive member."
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed",
|
||||
"reason": "Targeted pinned official fake-Frame install/remove passed after the validator change; the earlier full 116-plugin audit blocked this digest."
|
||||
},
|
||||
"localFollowUp": {
|
||||
"evidence": "docs/evidence/framey-free-loader-duplicates-2026-10-26.md",
|
||||
"hostCommit": "d963305",
|
||||
"frontendReady": true,
|
||||
"backendScope": "isolated official Python settings/defaults and empty local-deals read with a Decky stub"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -12053,12 +12076,13 @@
|
||||
"https://github.com/sponsors/santojon"
|
||||
],
|
||||
"status": "partial",
|
||||
"reason": "The exact chunked frontend now initializes and renders Deck Shelves settings in an opaque local Chromium panel. Backend calls were denied; Home-screen shelf injection has no verified Steam UI target in Framey. Settings writes, real backend behavior and worn VR are unverified.",
|
||||
"reason": "The exact chunked frontend renders settings in opaque local Chromium. The official Python backend saved a shelf and returned it after reopen with a stub Decky module and temporary settings directory. Actual Decky RPC, real Frame settings, Steam Home shelf injection and worn VR remain unverified.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
"local browser",
|
||||
"fake-Frame"
|
||||
"fake-Frame",
|
||||
"isolated Python backend"
|
||||
],
|
||||
"browser": {
|
||||
"id": 135,
|
||||
@@ -12093,6 +12117,13 @@
|
||||
"mode": "exact archive in opaque local Chromium; backend and network denied",
|
||||
"frontendReady": true,
|
||||
"settingsRendered": true
|
||||
},
|
||||
"backendFollowUp": {
|
||||
"evidence": "docs/evidence/framey-deck-shelves-browser-2026-10-26.md",
|
||||
"mode": "exact archive Python with stub Decky module and temporary settings directory",
|
||||
"defaultSettingsRead": true,
|
||||
"shelfSaveReopen": true,
|
||||
"actualDeckyRpc": false
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+6
-1
@@ -390,6 +390,7 @@
|
||||
addPatch:()=>()=>{}, removePatch:()=>{}, addGlobalComponent:()=>{}, removeGlobalComponent:()=>{},
|
||||
};
|
||||
const noPatch = () => {const undo=()=>{};undo.unpatch=undo;return undo;};
|
||||
class MissingSteamComponent {}
|
||||
const showModal = element => {
|
||||
let resolveClosed;
|
||||
const ClosedPromise = new Promise(resolve=>{resolveClosed=resolve;});
|
||||
@@ -509,7 +510,11 @@
|
||||
for(const classes of classModules.values())
|
||||
for(const name of Object.keys(classes)) if(classes[name]===token)return name;
|
||||
},
|
||||
findSP:()=>null, fakeRenderComponent:()=>null,
|
||||
findSP:()=>null,
|
||||
// Some legacy panels dereference the type of a Steam-only component before
|
||||
// installing a patch. Give them an inert local type; no Steam tree is
|
||||
// rendered or patched in this isolated iframe.
|
||||
fakeRenderComponent:()=>({type:MissingSteamComponent}),
|
||||
appDetailsClasses: {}, appDetailsHeaderClasses: {},
|
||||
useParams: () => currentParams,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user