Compare commits

..
Author SHA1 Message Date
SAPHID a1bc084400 Merge remote-tracking branch 'origin/main' into docs/support-matrix-v0452 2026-10-28 16:55:13 +00:00
Alex Southwell 978af2f287 Merge pull request #377 from saphid/fix/direct-local-transfer
fix: copy large desktop files without HTTP staging
2026-10-28 16:54:36 +00:00
SAPHID fbeb24d754 docs: align support matrix with v0.4.52 evidence 2026-10-28 16:40:32 +00:00
SAPHID dc139633a9 Merge remote-tracking branch 'origin/main' into fix/direct-local-transfer 2026-10-28 16:35:27 +00:00
SAPHID 85c0bf542c docs: record final direct-transfer test suite 2026-10-28 16:35:14 +00:00
Alex Southwell 0f421bfd2d Merge pull request #378 from saphid/fix/windows-denied-post-test
test: avoid unread body in Windows auth guard probe
2026-10-28 16:35:02 +00:00
SAPHID 27b5bf27f3 test: exercise selected-headset direct copy over HTTP 2026-10-28 16:29:40 +00:00
SAPHID c33dc0ccb6 test: avoid unread body in Windows auth guard probe 2026-10-28 16:22:47 +00:00
SAPHID 78634c09a9 Merge remote-tracking branch 'origin/main' into fix/direct-local-transfer 2026-10-28 16:17:10 +00:00
SAPHID 5a83989138 fix: copy large desktop files without HTTP staging 2026-10-28 16:16:48 +00:00
Alex Southwell b5bbcec1a0 Merge pull request #376 from saphid/docs/v0452-release-verification
docs: record v0.4.52 release verification
2026-10-28 16:14:01 +00:00
SAPHID bd36929053 docs: record v0.4.52 release verification 2026-10-28 16:00:46 +00:00
Alex Southwell ac04407bcc Merge pull request #375 from saphid/release/v0.4.52
Prepare Frame Control v0.4.52 candidate
2026-10-28 15:20:04 +00:00
SAPHID 432b29f961 Record locked v0.4.52 source smoke and cleanup 2026-10-28 14:47:33 +00:00
SAPHID c88df07c81 Record merged installer fix in v0.4.52 candidate 2026-10-28 14:40:51 +00:00
SAPHID 423a86fefc Merge remote-tracking branch 'origin/main' into release/v0.4.52 2026-10-28 14:40:13 +00:00
Alex Southwell 5847a81066 Merge pull request #374 from saphid/fix/installer-fetch-transient-retry
Retry transient installer dependency downloads
2026-10-28 14:39:50 +00:00
SAPHID 3a8fee1d45 Prepare Frame Control v0.4.52 candidate 2026-10-28 14:38:10 +00:00
SAPHID 082af24115 Retry installer fetches after interrupted responses 2026-10-28 14:30:23 +00:00
SAPHID 3d06824f4d Merge remote-tracking branch 'origin/main' into fix/installer-fetch-transient-retry 2026-10-28 14:21:01 +00:00
Alex Southwell cf89bf1313 Merge pull request #371 from saphid/feat/framey-battery-tracker-user
Adapt exact Battery Tracker archive for Frame user service
2026-10-28 14:19:54 +00:00
SAPHID e93e75d849 Retry transient installer dependency downloads 2026-10-28 14:13:27 +00:00
SAPHID a6869bbf93 Merge remote-tracking branch 'origin/main' into feat/framey-battery-tracker-user 2026-10-28 14:07:07 +00:00
Alex Southwell a31223a90b Merge pull request #372 from saphid/fix/ios-kdeconnect-fetch-retry
Retry transient iOS bundle package downloads
2026-10-28 14:06:15 +00:00
SAPHID 4a95aeb5d0 Scope native psutil transaction test to Linux wheel architectures 2026-10-28 13:57:55 +00:00
SAPHID 9ae78aef60 Retry transient iOS bundle package downloads 2026-10-28 13:51:47 +00:00
SAPHID 821839baae Merge remote-tracking branch 'origin/main' into feat/framey-battery-tracker-user 2026-10-28 13:45:25 +00:00
SAPHID d4c523efd9 Adapt exact Battery Tracker archive for Frame user service 2026-10-28 13:44:59 +00:00
Alex Southwell 13ed576abd Merge pull request #370 from saphid/fix/release-publisher-published-guard
Preserve live update manifest after publication
2026-10-28 13:37:48 +00:00
Alex Southwell 21c609b4a2 Merge pull request #369 from saphid/feat/framey-network-sentinel-user
Framey: adapt exact Network Sentinel archive for user service
2026-10-28 13:20:27 +00:00
SAPHID b39905f7cf fix(release): preserve published update manifests 2026-10-28 13:17:48 +00:00
SAPHID f1473fd610 feat(framey): adapt exact Network Sentinel archive for user service 2026-10-28 13:10:02 +00:00
Alex Southwell bf56d21eca Merge pull request #368 from saphid/ci/release-preview-assets-20261028
CI: require every expected installer preview output
2026-10-28 12:35:29 +00:00
SAPHID addd37467d ci: fail installer previews when expected outputs are missing 2026-10-28 12:26:24 +00:00
Alex Southwell 9776cca4d3 Merge pull request #367 from saphid/ci/actions-node24-runtime-20261028
CI: pin Node 24 GitHub Actions releases
2026-10-28 12:18:05 +00:00
Alex Southwell fb8d939a28 Merge pull request #366 from saphid/docs/framey-static-action-limits-20261028
Clarify AutoSuspend and CheatDeck compatibility limits
2026-10-28 12:07:07 +00:00
SAPHID 59ad05b323 ci: pin Node 24 GitHub Actions for workflow compatibility 2026-10-28 12:01:18 +00:00
SAPHID 155a3199f8 Clarify AutoSuspend and CheatDeck compatibility limits 2026-10-28 11:51:16 +00:00
Alex Southwell 41e5f0048b Merge pull request #341 from saphid/fleet/a83rq2046k
Document standing stability goal and coordinator takeover gates
2026-10-28 11:36:14 +00:00
SAPHID 25d0cb41ab Keep stability handoff links and snapshot scope current 2026-10-27 05:20:24 +00:00
SAPHID 9e77a35e40 Merge remote-tracking branch 'origin/main' into coord/stability-goal-reconcile 2026-10-27 05:20:07 +00:00
Alex Southwell bc02974b49 Merge pull request #365 from saphid/docs/v0451-release-verification
Record v0.4.51 release verification
2026-10-27 05:19:50 +00:00
SAPHID 5b448bc922 Update stability handoff for current release authority and v0.4.51 2026-10-27 05:17:24 +00:00
SAPHID b345921385 Merge remote-tracking branch 'origin/main' into coord/stability-goal-reconcile 2026-10-27 05:16:40 +00:00
SAPHID 1c60985c9f Record v0.4.51 release verification 2026-10-27 05:11:26 +00:00
Alex Southwell 4da7220660 Merge pull request #364 from saphid/release/v0.4.51
Prepare Frame Control v0.4.51 candidate
2026-10-27 04:48:22 +00:00
SAPHID 728a4dbe9b Record v0.4.51 candidate smoke and remaining gates 2026-10-27 04:39:00 +00:00
SAPHID 91cf774b21 Prepare v0.4.51 with safe smoke proximity gate 2026-10-27 04:32:11 +00:00
Alex Southwell 560f3de563 Merge pull request #363 from saphid/feat/framey-localsend-managed-arm64
Adapt pinned LocalSend helper for ARM64 Framey installs
2026-10-27 04:30:20 +00:00
SAPHID 022d407ff9 Record bounded ARM64 LocalSend transfer in catalog 2026-10-27 04:21:35 +00:00
SAPHID 014f554012 Include LocalSend helper in desktop installers 2026-10-27 04:19:09 +00:00
SAPHID c847b7a3ca Align LocalSend evidence with native runtime checks 2026-10-27 04:17:33 +00:00
SAPHID ee13edabb8 Record isolated ARM64 LocalSend transfer on Frame 2026-10-27 04:15:40 +00:00
SAPHID fc030c6191 Merge remote-tracking branch 'origin/main' into feat/framey-localsend-managed-arm64 2026-10-27 04:08:43 +00:00
SAPHID 8990ddfb91 Adapt official LocalSend helper for managed ARM64 Framey 2026-10-27 04:08:34 +00:00
Alex Southwell af8fc63f3b Merge pull request #362 from saphid/feat/framey-localsend-arm64
Document official LocalSend source match for ARM64
2026-10-27 03:55:55 +00:00
SAPHID 9c39300185 Record isolated official LocalSend backend transfer 2026-10-27 03:48:09 +00:00
SAPHID 592988c212 Clarify LocalSend earlier build revision 2026-10-27 03:41:07 +00:00
SAPHID 89f5dc164e Merge remote-tracking branch 'origin/main' into feat/framey-localsend-arm64 2026-10-27 03:40:49 +00:00
SAPHID 13c689926d Document exact LocalSend source and ARM64 build provenance 2026-10-27 03:40:18 +00:00
Alex Southwell 1b143094d1 Merge pull request #361 from saphid/docs/v0450-release-verification
Record v0.4.50 release verification
2026-10-27 03:37:03 +00:00
SAPHID b2cc34576c Record v0.4.50 release verification 2026-10-27 03:21:19 +00:00
SAPHID de3d7d4fbc Document standing stability goal and coordinator takeover gates 2026-10-26 22:01:06 +00:00
66 changed files with 2226 additions and 126 deletions

No files matched your search

+11 -11
View File
@@ -14,11 +14,11 @@ jobs:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: "3.9" # the oldest python3 the Mac app may pick up (Xcode CLT)
- uses: actions/setup-node@v4
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: "24"
- name: Install zsh
@@ -67,11 +67,11 @@ jobs:
python: "3.13"
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: ${{ matrix.python }}
- uses: actions/setup-node@v4
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: "24"
- name: OBS lifecycle tests
@@ -84,11 +84,11 @@ jobs:
runs-on: windows-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: "3.12"
- uses: actions/setup-node@v4
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: "24"
- name: Wireless doctor fixtures, UI and live read-only Windows inventory
@@ -98,7 +98,7 @@ jobs:
ios:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Generate the project
run: brew install xcodegen && cd ios && xcodegen generate
- name: Build and test
@@ -113,7 +113,7 @@ jobs:
runs-on: ubuntu-24.04-arm
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install zsh
# The runner image's third-party apt sources (packages.microsoft.com) sometimes
# fail to refresh; Ubuntu's own lists still update, and the install fails if not.
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
runs-on: ${{ matrix.os }}
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- 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
+2 -2
View File
@@ -17,7 +17,7 @@ jobs:
os: [ubuntu-latest, ubuntu-24.04-arm]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Native libraries
run: |
sudo apt-get update -qq
@@ -31,7 +31,7 @@ jobs:
windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: msys2/setup-msys2@v2
with:
msystem: UCRT64
+16 -4
View File
@@ -81,8 +81,8 @@ jobs:
files: app/dist/*.AppImage app/dist/*.deb
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: "24"
# PC in the headset (Experimental, Windows only): our capture adapter and
@@ -113,6 +113,18 @@ jobs:
run: npm ci && npm run ${{ matrix.script }}
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
- name: Check installer outputs before upload
shell: bash
run: |
# Every platform must produce each advertised file type. An artifact
# upload can otherwise succeed with just one file from the path list.
read -r -a patterns <<< '${{ matrix.files }}'
for pattern in "${patterns[@]}"; do
if ! compgen -G "$pattern" >/dev/null; then
echo "missing installer output matching $pattern" >&2
exit 1
fi
done
- name: Check the Mac bundle matches its runner
if: runner.os == 'macOS'
shell: bash
@@ -189,14 +201,14 @@ jobs:
app/dist/*.zip
app/dist/*.AppImage
app/dist/*.deb
if-no-files-found: ignore
if-no-files-found: error
checksums:
needs: build
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Attach SHA256SUMS after all installers upload
env:
GH_TOKEN: ${{ github.token }}
+6
View File
@@ -20,6 +20,12 @@ const REQUIRED = [
"ui/vendor/deckyfileserver-1.2.0/GO-LICENSE.txt",
"ui/vendor/deckyfileserver-1.2.0/README.md",
"scripts/build-framey-fileserver.py",
"ui/vendor/localsend-0.4.5/aarch64.gz",
"ui/vendor/localsend-0.4.5/source.tar.gz",
"ui/vendor/localsend-0.4.5/LICENSE.txt",
"ui/vendor/localsend-0.4.5/GO-LICENSE.txt",
"ui/vendor/localsend-0.4.5/README.md",
"scripts/build-framey-localsend.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",
+2 -21
View File
@@ -7,9 +7,9 @@
// node build/fetch-deps.js mac arm64 x64 | win x64 | linux x64 arm64
const crypto = require("crypto");
const fs = require("fs");
const https = require("https");
const path = require("path");
const { execFileSync } = require("child_process");
const { get, fetchWithRetry } = require("./fetch-retry");
const PY = "3.12.14+20260924";
const PY_URL = (triple) => "https://github.com/astral-sh/python-build-standalone/releases/download/"
@@ -46,27 +46,8 @@ const PRUNE = [
.flatMap((d) => [`lib/python3.12/${d}`, `Lib/${d}`]),
];
function get(url, redirects = 5) {
return new Promise((resolve, reject) => {
https.get(url, { timeout: 60000 }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
if (!redirects) return reject(new Error(`${url}: too many redirects`));
let next;
try { next = new URL(res.headers.location, url).href; }
catch { return reject(new Error(`${url}: bad redirect ${res.headers.location}`)); }
return resolve(get(next, redirects - 1));
}
if (res.statusCode !== 200) return reject(new Error(`${url}: HTTP ${res.statusCode}`));
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => resolve(Buffer.concat(chunks)));
}).on("timeout", function () { this.destroy(new Error(`${url}: timed out`)); }).on("error", reject);
});
}
async function download(url, sha256, file) {
const data = await get(url);
const data = await fetchWithRetry(get, url);
const sum = crypto.createHash("sha256").update(data).digest("hex");
if (sum !== sha256) throw new Error(`checksum mismatch for ${url}: ${sum}`);
fs.writeFileSync(file, data);
+43
View File
@@ -0,0 +1,43 @@
const https = require("https");
function get(url, redirects = 5, transport = https) {
return new Promise((resolve, reject) => {
transport.get(url, { timeout: 60000 }, (res) => {
res.on("error", reject);
res.on("aborted", () => reject(new Error(`${url}: response aborted`)));
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
if (!redirects) return reject(new Error(`${url}: too many redirects`));
let next;
try { next = new URL(res.headers.location, url).href; }
catch { return reject(new Error(`${url}: bad redirect ${res.headers.location}`)); }
return resolve(get(next, redirects - 1, transport));
}
if (res.statusCode !== 200) {
res.resume();
const error = new Error(`${url}: HTTP ${res.statusCode}`);
error.statusCode = res.statusCode;
return reject(error);
}
const chunks = [];
res.on("data", (chunk) => chunks.push(chunk));
res.on("end", () => resolve(Buffer.concat(chunks)));
}).on("timeout", function () { this.destroy(new Error(`${url}: timed out`)); }).on("error", reject);
});
}
// Release assets are pinned by SHA-256 at the call site. Retry only transport
// failures and transient HTTP responses; the caller still rejects bad bytes.
async function fetchWithRetry(fetcher, url, pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
for (let attempt = 0; attempt < 3; attempt++) {
try { return await fetcher(url); }
catch (error) {
const code = error.statusCode;
if (code != null && code !== 408 && code !== 429 && code < 500) throw error;
if (attempt === 2) throw error;
await pause((attempt + 1) * 1000);
}
}
}
module.exports = { get, fetchWithRetry };
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.50",
"version": "0.4.52",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.50",
"version": "0.4.52",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+8 -2
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.50",
"version": "0.4.52",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
@@ -65,6 +65,11 @@
"to": "ui/vendor/deckyfileserver-1.2.0",
"filter": ["*.gz", "*.txt", "README.md"]
},
{
"from": "../ui/vendor/localsend-0.4.5",
"to": "ui/vendor/localsend-0.4.5",
"filter": ["*.gz", "*.txt", "README.md"]
},
{
"from": "../ui/vendor/psutil-7.2.2",
"to": "ui/vendor/psutil-7.2.2",
@@ -86,7 +91,8 @@
"obs-headset-source.py",
"frame_power_sample.py",
"frame_power_report.py",
"build-framey-fileserver.py"
"build-framey-fileserver.py",
"build-framey-localsend.py"
]
},
{
+76
View File
@@ -0,0 +1,76 @@
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const test = require('node:test');
const { get, fetchWithRetry } = require('../build/fetch-retry');
test('installer fetch recovers a dropped connection without hiding final failure', async () => {
let calls = 0;
const delays = [];
const bytes = await fetchWithRetry(async () => {
if (++calls < 3) throw new Error('socket closed');
return Buffer.from('pinned bytes');
}, 'https://example.invalid/package', async (ms) => delays.push(ms));
assert.equal(bytes.toString(), 'pinned bytes');
assert.equal(calls, 3);
assert.deepEqual(delays, [1000, 2000]);
calls = 0;
await assert.rejects(fetchWithRetry(async () => {
calls++;
throw new Error('socket closed');
}, 'https://example.invalid/package', async () => {}), /socket closed/);
assert.equal(calls, 3);
});
test('installer fetch rejects permanent HTTP errors on the first attempt', async () => {
let calls = 0;
const missing = new Error('HTTP 404');
missing.statusCode = 404;
await assert.rejects(fetchWithRetry(async () => {
calls++;
throw missing;
}, 'https://example.invalid/missing', async () => {}), /HTTP 404/);
assert.equal(calls, 1);
});
test('installer fetch retries a transient HTTP response', async () => {
let calls = 0;
const busy = new Error('HTTP 503');
busy.statusCode = 503;
const bytes = await fetchWithRetry(async () => {
if (++calls === 1) throw busy;
return Buffer.from('pinned bytes');
}, 'https://example.invalid/package', async () => {});
assert.equal(bytes.toString(), 'pinned bytes');
assert.equal(calls, 2);
});
test('installer fetch retries a response aborted after headers and partial bytes', async () => {
let calls = 0;
const transport = {
get(_url, _options, callback) {
const request = new EventEmitter();
queueMicrotask(() => {
const response = new EventEmitter();
response.statusCode = 200;
response.headers = {};
callback(response);
if (++calls === 1) {
response.emit('data', Buffer.from('partial'));
response.emit('aborted');
} else {
response.emit('data', Buffer.from('complete'));
response.emit('end');
}
});
return request;
},
};
const bytes = await fetchWithRetry(
(url) => get(url, 5, transport),
'https://example.invalid/package',
async () => {},
);
assert.equal(bytes.toString(), 'complete');
assert.equal(calls, 2);
});
+4 -4
View File
@@ -1,11 +1,11 @@
# What we have tested
Release baseline: Frame Control **v0.4.41**, 26 October 2026. Rows also identify verified post-release main changes explicitly. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
Current published release: Frame Control **v0.4.52**, 28 October 2026. Rows identify the last direct check of each feature; an older row does not mean the latest installer was retested on that hardware. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. See each link for the exact procedure and limits.
| Feature | Real Frame + physical PC tested | VM only | Untested or limited | Last check and proof |
|---|---|---|---|---|
| Mac desktop release — tested on a real Mac | Physical MacBook Air (MacBookAir9,1), Intel Core i3, macOS 15.7.7 (24G720): official v0.4.39 x64 DMG and ZIP hashes/architectures verified; clean DMG app installed and launched. Native UI, preferences across restart, update discovery of 0.4.40, offline OBS Start/Stop and 116-entry Decky store checked. Actual Open at Login menu toggled on, persisted across Quit/relaunch, then restored off. A 54-byte relay upload/readback/delete passed under coordinator/headset locks | No | Ordinary browser quarantine, right-click Open and Open Anyway remain unverified; the synthetic quarantine probe was inconclusive. Actual login startup untested. Direct frame.local had no route and app SSH trust failed; relay transfer is not native Mac transfer acceptance. Real OBS/Framey VR unverified. Earlier shutdown race fix #284 shipped in v0.4.40, whose Intel installer was not tested here | 26 Oct, official v0.4.39 tag `fd0b0a9`: [repeat Intel acceptance evidence](evidence/intel-mac-v0.4.39-2026-10-26.md); [earlier release test](evidence/mac-v0439-2026-10-26.md) |
| Core title smoke test | Linux lxso2 ran the exact v0.4.41 tagged source against the real Frame; 13/13 reserved title steps passed and cleanup completed | No | The headset was unworn; visible rendering and controller input were not checked. Optional Steam Linux Runtime 4 was absent, so the x86_64 test executable did not actually launch. Other feature paths retain their separate limits below | 26 Oct, exact v0.4.41 tag `b5d50a8`, Frame OS 0.5.6 build `20261022.6153909`: [tagged release evidence](evidence/v0441-release-2026-10-26.md); [earlier candidate smoke](evidence/v0441-candidate-frame-2026-10-26.md) |
| Core title smoke test | A Linux host ran the v0.4.52 versioned source candidate, whose tree matches the tagged release commit, against the real Frame under a unique lock: 12 steps passed, one optional x86_64 launch was skipped, zero failed; a separate locked cleanup audit passed | No | The headset was in standby and unworn. Steam Linux Runtime 4 was absent, so the x86_64 test executable did not launch. Worn rendering and physical-controller input were not checked | 28 Oct, v0.4.52 tag `v0.4.52`, Frame OS 0.5.6 build `20261022.6153909`: [release verification](evidence/v0452-release-2026-10-28.md), [candidate smoke](evidence/v0452-candidate-2026-10-28.md) |
| Windows desktop in the headset | No physical Windows PC test | Windows 11 VM sent a whole desktop to a real Frame; this used an intermediate fix, not the final native DLL | Physical GPU, capture and controller input; individual windows unavailable in bundled DXGI build | 24 Oct, v0.4.10 installer + candidate fix: [VM log](evidence/windows-vm-2026-10-24.md), [PC guide](pc-in-headset.md) |
| Linux desktop in the headset | No | No | Native Linux PC capture is disabled; lxso2 lacked a desktop portal and PipeWire session. The separate Steam `-pipewire` helper is not Frame Control's PC capture | 24 Oct, v0.4.17: [host audit](evidence/linux-pc-host-lxso2-2026-10-24.md), [Steam helper PR #163](https://github.com/saphid/frame-control/pull/163) |
| Mac desktop in the headset | A physical Mac sent its screen/window to a real Frame | No | Current v0.4.26 Mac installer and worn-headset controller input have not been repeated | 28 Sep, pre-v0.4 / Frame OS 0.4.1: [Mac guide and measurement](streaming.md#b-show-the-macs-desktop-inside-the-frame) |
@@ -24,7 +24,7 @@ Release baseline: Frame Control **v0.4.41**, 26 October 2026. Rows also identify
| Open at login | No Frame needed; official v0.4.39 on the physical Intel Air toggled the actual native menu on, retained its checkmark after Quit/relaunch and restored it off. Linux autostart file previously toggled on a physical Linux host with an isolated config directory | No | A full OS login session and physical Windows host login remain untested | 26 Oct, v0.4.39: [native Intel toggle evidence](evidence/intel-mac-v0.4.39-2026-10-26.md); Linux v0.4.26: [PR #189](https://github.com/saphid/frame-control/pull/189) |
| Local Frame Control extensions | Example panel displayed live Frame status in a browser preview; the real Frame served a read-only example in the experimental headset panel | No | No wearer-visible extension view; third-party extensions and physical Windows/macOS extension folders untested | 25 Oct, v0.4.29: [PR #194](https://github.com/saphid/frame-control/pull/194), [PR #201](https://github.com/saphid/frame-control/pull/201), [extension guide](extensions.md) |
| Experimental Frame Control SteamVR panel | Frame OS 0.5.6 registered and served the opt-in overlay. A later unattended check safely woke SteamVR and found the panel in stereo compositor and mirror captures; it removed its temporary service and restored power settings | No | No person wore the headset for that check. Physical controller input, scrolling, VR keyboard, comfort and legibility remain unverified. The large status view has browser and packaging checks, not a wearer review | 26 Oct, v0.4.29–0.4.40: [panel PR #201](https://github.com/saphid/frame-control/pull/201), [unattended evidence](evidence/framey-vr-2026-10-25/README.md), [post-#292 unworn sample exercise](evidence/v0440-release-2026-10-26.md#post-tag-292-panel-regression), [earlier standby-gated exercise](evidence/framey-vr-standby-gate-2026-10-26.md), [worn checklist](framey-vr-check.md) |
| Experimental Decky Loader manager | Frame OS 0.5.6 ARM64 user-service install/status/update/remove and managed ProtonDB install/update/remove passed earlier. A private Decky 3.3.0 service returned read-only ProtonDB and Steam Status setting RPCs; a later locked check verified the official Notebook default drawing/save/reopen flow and Simple Timer backend events in local Chromium | No | The loader remains opt-in. Installs/updates reject an archive whose Decky name collides with another installed plugin before download and under the headset transaction lock; this safety path has host/fake Frame tests, not a new real-headset collision test. v0.4.41 classifies the 116 exact-version plugins as 3 works, 48 partial, 54 broken and 11 not applicable. Notebook works for its default flow; Volume Mixer and Mixer work for tested per-app silent-stream volume control only. TabMaster is partial. Deck Shelves moved to partial after its exact chunked frontend rendered settings in local Chromium; its backend and Steam Home shelves remain unverified. Steamback, PlayTime and Deck Settings became partial after detached read-only library/user snapshots and isolated amd64 backend checks; their official ARM64 backends, automatic behavior and VR panels remain unverified. A read-only Steam bridge returned the Frame country, install folders and system information; IsThereAnyDeal selected that country in local Chromium, but its deployed HTTP endpoint and deal lookup remain unverified. An isolated ARM64 LocalSend core answered its info endpoint, but the official x86-only plugin archive and file transfers remain blocked. Volume Mixer and Mixer controlled a named silent stream through real ARM64 Decky with independent Pulse readbacks; Volume Boost default-sink reads matched, but real writes were denied. Audible output, Bluetooth playback, internal-node controls and worn VR remain unverified. Most official backends and VR controls remain unverified. Physical controller use and wearer-visible official panels remain unverified | 26 Oct, v0.4.31–0.4.41: [managed store PR #208](https://github.com/saphid/frame-control/pull/208), [name-collision PR #269](https://github.com/saphid/frame-control/pull/269), [catalog PR #266](https://github.com/saphid/frame-control/pull/266), [backend event evidence](evidence/framey-events-backend-2026-10-26.json), [Steam read PR #274](https://github.com/saphid/frame-control/pull/274), [LocalSend evidence PR #275](https://github.com/saphid/frame-control/pull/275), [local audio evidence](evidence/framey-audio-backends-2026-10-26.md), [ARM64 audio evidence](evidence/framey-audio-arm64-2026-10-26.md), [post-release library evidence](evidence/framey-library-context-2026-10-26.md), [compatibility table](framey-compatibility.md) |
| Experimental Framey VR plugin panels and store | Unattended Frame OS 0.5.6 runs showed the Framey sample panel in stereo compositor and mirror captures. The latest standby-gated exercise restored the original power settings and released its shared lock. Synthetic OpenVR clicks changed a live sample setting, read its Decky backend file and confirmed it after a fresh iframe reopen | No | This was the repository sample, not an official store plugin. Physical controllers, native scrolling, VR keyboard, wearer comfort and legibility remain unverified. CSS Loader and SteamGridDB exact archives passed fake Frame install/remove and panel packing; this does not establish theme or artwork controls in VR. Panel-local backend events and React toasts have local/real-backend checks, but no wearer review. Pause Games and Tailscale Control settings render in local Chromium; their live actions are unverified. The 116-plugin catalog had 60 fake installs/removals and 56 expected blocks; v0.4.40 recorded 101 local Chromium initializers ready and 15 failed, up from 98/18 in v0.4.39. Notebook is marked works only for its default drawing/save/reopen flow | 26 Oct, v0.4.38–0.4.40: [post-#294 unworn sample and scroll evidence](evidence/framey-scroll-probe-2026-10-26.md#post-294-exact-main-sample-check), [backend event PR #270](https://github.com/saphid/frame-control/pull/270), [settings/cache PR #271](https://github.com/saphid/frame-control/pull/271), [post-release sample exercise](evidence/v0439-release-2026-10-26.md#post-release-framey-library-change-282), [worn checklist](framey-vr-check.md) |
| Experimental Decky Loader manager | Earlier real Frame checks passed opt-in ARM64 user-service install/status/update/remove and managed ProtonDB install/update/remove. The current exact-version catalog records six scoped works: Notebook default drawing, Volume Mixer and Mixer silent per-app volume, Volume Boost muted sink controls, DeckySpy metrics, and Simple Timer silent in-panel timers | No | v0.4.52 classifies 116 exact-version plugins as **6 works / 54 partial / 45 broken / 11 N/A**. “Works” applies only to each documented behavior, never worn VR. Network Sentinel and Battery Tracker are partial; their managed real-Frame ARM64 behavior is unverified. Most other backends, audible output, physical-controller use and wearer-visible official panels remain unverified | 28 Oct, v0.4.52: [exact-version table](framey-compatibility.md), [release verification](evidence/v0452-release-2026-10-28.md), [DeckySpy evidence](evidence/framey-deckyspy-2026-10-26.md), [Simple Timer evidence](evidence/framey-simple-timer-2026-10-26.md), [audio evidence](evidence/framey-audio-arm64-2026-10-26.md) |
| Experimental Framey VR plugin panels and store | An unattended Frame OS 0.5.6 exercise showed the repository sample panel in stereo compositor and mirror captures. Synthetic OpenVR clicks changed a live sample setting, read its Decky backend file, and confirmed it after a fresh iframe reopen; temporary services were removed and original power settings restored | No | This was the repository sample, not an official store plugin. Physical controllers, native scrolling, VR keyboard, wearer comfort and legibility remain unverified. Fake-Frame install/remove and local Chromium plugin initialization do not prove an official plugin is visible or usable in worn VR | 28 Oct, v0.4.52 catalog with earlier 25–26 Oct VR evidence: [unattended sample evidence](evidence/framey-vr-2026-10-25/README.md), [compatibility table](framey-compatibility.md), [worn checklist](framey-vr-check.md) |
Release notes must preserve these limits until a newer recorded check changes them. When a feature is retested, add its date, exact app/Frame version, host type and proof here. The older [v0.3.1 matrix](support-matrix.html) is a historical snapshot and does not describe current coverage.
@@ -0,0 +1,31 @@
# Large Send to Frame files: source and offline verification
[Issue #373](https://github.com/saphid/frame-control/issues/373) reports that
a 26.5 GB file selected from external USB media failed with “empty or
too-large upload” in Frame Control 0.4.8. Current main still imposed an
8 GiB `POST /api/upload` body cap for **Send to Frame**, even in the desktop
app. The desktop already knows the selected local file path, but previously
sent the whole file through HTTP into another temporary copy before SSH.
The desktop page now passes that path to `POST /api/transfer`, which is
enabled in the app-started server. It checks for an absolute, nonempty
regular file and copies it directly by the
existing rsync/scp route to `~/Downloads`. This avoids both the 8 GiB HTTP
cap and a duplicate temporary file on the computer. The endpoint preserves
the selected-headset work guard. Large copies have a finite timeout that
scales with source size. Plain browser uploads still have an 8 GiB cap and
now state it before sending bytes; the page says which route applies.
Offline checks use a mocked 27 GiB file size without allocating that space.
They verify the direct server copy path, a timeout longer than one hour, the
page's desktop-versus-browser choice, and early rejection of an oversized
browser upload. Loopback tests confirm the browser cannot call the direct
route, reject a mismatched selected headset with HTTP 409, and accept the
matching desktop request with HTTP 200. Unit tests reject missing, relative,
directory, hidden and empty source paths before any SSH. The full Python
suite on the final branch passed **1,690 tests, 16 skipped**. Hosted CI is a
separate merge gate.
An actual 26.5 GB transfer from USB media to a Frame, end-to-end throughput,
resume after interruption and free-space behavior remain unverified. No real
headset or user file was touched for this change.
@@ -0,0 +1,11 @@
# Battery Tracker 0.2.0 user-service adaptation
Official store ID 67, ZIP SHA-256 `91c388d3c1b52079314045b79671ad55c767510658b6193f6936ed6cad383b94`; the exact `main.py` SHA-256 is `2303ec70c262f1cbfe1c8c138770c6e7c3496827e748e679c94ca43ff9f28725`. The BSD-licensed archive requests `_root`, hard-codes Steam Deck `BAT1`, raises on empty history, and drops the final sample in per-app grouping. The backend records local battery readings in plugin runtime SQLite; it does not need privileged writes for the verified path.
The digest-scoped managed install removes `_root`, discovers a readable supply whose `type` is `Battery` (preferring `BAT1` on Deck), fixes empty and single-sample history, retains pending samples while the panel is open, and flushes them on unload. The original ZIP and license remain pinned in the test fixture. Changed archive bytes still fail the root gate; other root plugins remain blocked.
Under a unique owner token on `/tmp/frame-test.lock`, a **read-only** real Frame probe found SteamVR activity `3` (standby), proximity raw `2.25`, and one Battery-type supply named `max1720x_bat_7-36`. It exposed readable `capacity` (89%), `status` (Charging), `voltage_now`, and `current_now`; `power_now` was absent. The owned lock was released. No wake, input, normal service, power setting, or plugin install was touched.
In network/device-isolated, unprivileged **actual Decky 3.3.0 amd64**, the exact adapted archive was discovered. A private Frame-shaped supply fixture produced history and per-app power through public legacy RPC. The complete native regression runner, including existing negative controls, passed. Local managed install/remove tests and the exact-source/manifest guard passed. CI's native ARM64 and fake Frame e2e jobs remain required before merge.
This supports **partial** compatibility. The official frontend initialized in the earlier opaque Chromium audit with backend calls denied; it has not been tested with these live samples. Actual managed recording on Frame ARM64, long-term history, foreground-app attribution, panel controls, and worn VR remain unverified. Existing copies need update or reinstall.
@@ -0,0 +1,42 @@
{
"helperSha256": "e20363237e606ee65eae857792176d21a0bb8ba863489952728eaf2b13277dae",
"lockAcquired": true,
"action": "private_network_marker_only",
"before": {
"activity": 3,
"proximity": [
4.6875
],
"battery": [
72
],
"services": {
"frame-control-decky.service": "inactive",
"frame-control-headset.service": "inactive"
}
},
"transfer": {
"result": "private_arm64_transfer_passed",
"protocolVersion": "2.0",
"markerBytes": 38,
"markerSha256": "3e724efac20a4a600a93f7bdddd469b8287a6aaa2b7b433d838d8ef23e1a9c2c",
"network": "private-veth-no-default-route",
"helperSha256": "e20363237e606ee65eae857792176d21a0bb8ba863489952728eaf2b13277dae"
},
"after": {
"activity": 3,
"proximity": [
4.6875
],
"battery": [
72
],
"services": {
"frame-control-decky.service": "inactive",
"frame-control-headset.service": "inactive"
}
},
"result": "private_arm64_transfer_passed",
"ownedDirectoryAbsent": true,
"ownLockReleased": true
}
@@ -0,0 +1,62 @@
# Decky Localsend managed ARM64 candidate, 27 October 2026
Official Decky Localsend **0.4.5**, store ID **125**, archive SHA-256
`e271ccb55eb23f918b99a69f79aec905df4c9b2ef7b23bbf4ec1c2d1a6f0c6b7`,
contains an x86_64 helper. Frame Control's proposed managed adaptation accepts
only that exact archive. The original x86 helper and Python backend hashes are
checked before a pinned static ARM64 helper replaces the binary. The headset
transaction checks the ARM64 SHA-256 and ELF machine again. It leaves the
official Python, frontend, manifest and BSD license unchanged, retains
managed update/remove receipts, and rejects changed archive digests.
[Source correspondence and local upstream transfer](framey-localsend-source-match-2026-10-27.md)
records the exact upstream plugin tag and Go commit. The native asset and
build recipe are [bundled](../../ui/vendor/localsend-0.4.5/README.md).
Go dependencies are checksum-pinned but not vendored. The Frame installer
does not download modules or run a compiler.
## Evidence so far
- The host transaction tests installed and removed the exact archive,
preserved its Python/LICENSE bytes, checked the managed receipt and
rejected a changed archive. The ARM64 branch independently wrote and
hashed the AArch64 ELF on x86 without executing it.
- The fake Frame managed install/remove flow passed with the exact official
archive and checked the installed helper digest. Its project containers,
volumes and network were absent after the run.
- The full local Python suite passed **1,669 tests with 16 skipped** after
rerunning with loopback socket access. The first attempt ran inside a
workspace sandbox that denied local test sockets across unrelated server
tests; it did not identify a LocalSend regression.
- A fresh local Chromium opaque iframe initialized the exact official frontend
with backend and network effects denied. Its visible text included Backend is
stopped, Device Name, Available Devices, File Upload, Receive History and
Configuration. The initializer reported ready and called only
`get_receive_history`, `get_backend_status` and `get_backend_config`.
This proves a visible initial panel with safe fallbacks, not working send,
receive or settings controls.
- The original x86 backend and helper completed one private no-route marker
transfer outside Decky. This does not prove the managed ARM64 binary or
actual Decky service.
- The published Decky 3.3.0 AMD64 and ARM64 runtime gate discovered the
adapted plugin and returned its stopped status and safe configuration through
public RPCs. Its helper remained stopped during that gate.
- A locked real Frame probe ran the rebuilt ARM64 helper with SHA-256
`e20363237e606ee65eae857792176d21a0bb8ba863489952728eaf2b13277dae`
in a private network namespace with no default route. Its LocalSend 2.0
endpoint accepted and saved one 38-byte marker, and the saved bytes matched
the sent marker. The headset was in standby (SteamVR activity 3, proximity
raw 4.6875) with 72% battery before and after. Both normal Frame Control
services stayed inactive. The owned helper/process/files and shared lock
were removed. [Machine-readable result](framey-localsend-arm64-transfer-2026-10-27.json).
A useful frontend workflow still needs verification. The ARM64 marker test
exercised the helper in isolation, not the actual Decky plugin service, local
discovery, user-facing transfer, or installation on the headset. No worn
visibility or physical-controller claim follows from this candidate.
The proposed catalog status is **partial** for exact managed archive
installation and settings/status. Steam routes, local discovery, user-facing
transfer, clipboard/screenshot sharing and worn VR remain unverified. The
historical raw-archive audit fields still describe the earlier unadapted x86
ZIP; `managedNative` records the current digest-scoped path separately.
@@ -0,0 +1,55 @@
# Decky Localsend 0.4.5 source match and ARM64 build, 27 October 2026
The exact official store archive (ID 125, SHA-256
`e271ccb55eb23f918b99a69f79aec905df4c9b2ef7b23bbf4ec1c2d1a6f0c6b7`)
remains **blocked** on Frame: it carries an x86_64 `bin/localsend-core`.
No managed plugin install, transfer, or status promotion was performed.
The archive's `main.py` has SHA-256
`db03f9201fe50b75dea99a7a05b54ec3f352d1bdfe174115b0e569154198d8ad`,
identical byte for byte to `main.py` at the upstream Decky plugin tag `v0.45`
(`7c166bdc33e2a667527ffd511eb5b90d3ce0bd67`). That tag pins its Go
submodule to `ad28b8e9440e17e44b4735a6c4aa0512009cf333`. All 42 dependency
versions embedded in the official helper match that source's `go.mod`, which
declares Go 1.25.7.
Building that pinned Go commit with `CGO_ENABLED=0`, `GOOS=linux`,
`GOARCH=amd64`, and the flags `-trimpath -buildvcs=false -ldflags='-s -w'`
produced **byte-identical** official helper bytes: 15,241,400 bytes,
SHA-256 `d448d03861d578685e6c70ea3ace25eb34fca99cb9d4f98bf51ba23aa5cae843`.
This resolves the source correspondence gap left by the earlier build from
the later `66edc509` commit. The same pinned source and flags with
`GOARCH=arm64` produced a static AArch64 ELF of 11,731,128 bytes, SHA-256
`e20363237e606ee65eae857792176d21a0bb8ba863489952728eaf2b13277dae`.
Two ARM64 builds matched byte for byte. The Go core is MIT licensed; the
separate Decky Python plugin is BSD-3-Clause.
A unique-owner atomic `/tmp/frame-test.lock` preflight on the real Frame read
SteamVR activity **1**, proximity raw **88.0625**, and battery **35%**.
Because the headset was active or wearer status was unknown, the probe did
not upload or execute the ARM64 helper, start a LocalSend service, or send
input. The task-owned directory was absent and only the owned lock was
released. This is **not** a worn visual or controller result.
The unmodified plugin backend's `_main` starts a fixed-path Unix notification
socket but does not start the Go network service. Its explicit `start_backend`
method starts the helper with a default all-interface setting, HTTPS and
auto-save disabled. A local follow-up ran that exact Python backend with the
official x86_64 helper in a disposable user/network namespace. A veth pair
provided only documentation-range private addresses and there was no default
route. A private Decky module stub supplied task-owned settings, runtime,
plugin and log directories; its notification socket was moved into that
directory. The backend's `start_backend`, `proxy_get` info request and
`proxy_post` prepare/upload methods passed. One 42-byte public marker
arrived in the private upload directory with independent SHA-256
`346373ca56ec52c3f47b8d41de2a6a8479d8ad10d2891ff3b0dc7ea9c3892a39`.
The backend stopped, its process and private directory were absent, and the
global notification socket was untouched. This verifies the upstream x86
backend/proxy/transfer contract locally, **not** Decky 3.3.0 discovery, ARM64
transfer, an installed Frame plugin or real-network transfer.
Before a managed ARM64 adaptation, verify actual Decky backend discovery/RPC,
the frontend and a private transfer with the exact-source ARM64 helper;
package the source, license and exact helper digest. The official x86_64
archive remains blocked until that work passes. Worn VR and physical
controller use remain unverified.
@@ -0,0 +1,11 @@
# Network Sentinel 1.0.4 managed user-service check
Official store ID 114, ZIP SHA-256 `d3e07f40a0c800e72d15594e03c8c61e9bf3092524a578a0260520e735b971a5`.
The exact archive's `main.py` SHA-256 is `2baa390cb66dc267e8f04e61ba1edc43af75115ad5e453d308f6bf26ed70b6b9`.
Its backend imports `psutil`, but the ZIP contains no wheel. The manifest requests `_root` although the inspected backend uses user-readable interface counters, subprocess ping and DNS, settings, and optional `nmcli` reads. Loading the backend does not start monitoring.
The managed install checks the complete archive digest, manifest and Python source before removing `_root`. It adds the existing pinned BSD psutil 7.2.2 wheel for the native architecture to this plugin's `py_modules`, uses Decky's plugin-scoped settings directory, leaves legacy path migration inactive in the user service, and initializes ping jitter for failed/offline calls. Other root-requesting archives still fail the root gate. The receipt records `network-sentinel-user-psutil`; existing copies need update or reinstall.
Local transaction tests installed and removed the exact archive, checked the native wheel and unmodified license, and rejected an altered archive. In network/device-isolated, unprivileged **actual Decky 3.3.0 amd64**, the managed plugin was discovered, imported native psutil, returned default and updated settings, reported empty history and bandwidth, started its monitoring loop, produced a history sample with quality and bandwidth fields, then stopped. The full native regression runner also passed its other cases, including FileServer and negative controls. The first run's existing large FileServer install timed out at 15 seconds under host load; its bounded timeout is now 30 seconds and the complete rerun passed. No external network call succeeded in the isolated container. Native ARM64 and fake Frame install/remove are CI gates for this change; neither is claimed by this local evidence.
The preexisting local Chromium audit initialized the exact frontend with backend calls denied. It did not prove a functioning panel. External latency/DNS, Wi-Fi scans, actual Frame ARM64 runtime, frontend controls, and worn VR remain unverified. This is a **partial** compatibility result, not a full monitoring or headset-usability claim. No real Frame, normal service, power value, or physical network setting was touched in this check.
@@ -0,0 +1,35 @@
# AutoSuspend and CheatDeck static action limits, 28 October 2026
This is a read-only follow-up to the exact official Decky compatibility
catalog. The two archives were downloaded from the official version CDN and
SHA-256 checked before inspection. Neither plugin was imported, installed,
opened in a live panel, or run on the Frame.
## AutoSuspend 2.2.0, store ID 27
Archive SHA-256:
`20ceb9aa5327226326531d947e4a7a5318ba78b73f503266996a81877cffa536`.
The Python backend exposes settings reads and writes. The frontend contains a
`SteamClient.System.ShutdownPC()` call and Steam suspend callbacks. Its existing
opaque Chromium audit fails during initialization with `Cannot read properties
of null (reading '6')`. Static presence of those actions does not prove that
opening the panel would call them, but a settings-only initialization check
would not establish safe threshold or suspend behavior on Frame. The plugin
remains **broken**; no suspend, shutdown, notification, or live setting was
exercised.
## CheatDeck 1.1.6, store ID 76
Archive SHA-256:
`6dfe1f821b0cfe2a61c95e49efd6cb2587d79ff3d1e101e14fa80c12c7f6d89f`.
The published README directs users to a Steam game context-menu item to choose
a trainer and save launch options. The frontend references
`SteamClient.Apps.SetAppLaunchOptions`; the Python backend provides settings
RPCs. The existing opaque Chromium audit fails with `Cannot convert undefined
or null to object`. The isolated Framey panel has no Steam game context-menu
target, so repairing that initializer alone would not verify the advertised
trainer workflow. The plugin remains **broken**; no launch-option write, game,
trainer, real headset, or worn-VR action was exercised.
These observations only refine the reasons for the two existing catalog rows.
The exact-version totals stay **6 works / 52 partial / 47 broken / 11 N/A**.
@@ -0,0 +1,21 @@
# Installer dependency fetch retry, 28 October 2026
The desktop installer build fetches pinned Python, platform-tools, CA and KDE
Connect packages through `app/build/fetch-deps.js`. Before this change, one
temporary connection drop or HTTP 503 ended the build. The iOS bundle's
separate Python fetch hit a real remote disconnect in main CI run
[40698195628](https://github.com/saphid/frame-control/actions/runs/40698195628);
that path was repaired separately in PR #372.
The desktop fetch now retries transient transport errors and HTTP 408, 429 or
5xx responses at most twice, with one- and two-second waits. HTTP 4xx errors
other than 408/429 fail immediately. The existing pinned SHA-256 comparison
still happens after download and before writing package bytes, so altered
content fails without retry or installation.
Local Node 24 checks passed: retry recovery, exhausted retries, permanent HTTP
error, HTTP 503 and a response aborted after partial bytes; all six app test
files, script syntax and
`git diff --check`. Hosted exact-head CI and installer preview remain required
before merge/release. No installer was published, and this test makes no
claim about a physical Mac or Windows install.
@@ -0,0 +1,18 @@
# Main Windows test gate, 28 October 2026
Exact main commit `b5bbcec1a0cc3f8b3d25ef2686fd8315e209f167`
([checks run 40723931638](https://github.com/saphid/frame-control/actions/runs/40723931638))
failed in `server-tests (windows-latest, 3.12)`. Its server test step had one
error: `test_settings_need_and_accept_the_ui_key` received Windows
`ConnectionAbortedError [WinError 10053]` while reading an unauthorized
POST response. The test sent a JSON body, but the missing-key guard rejects
the request before reading it. Closing a connection with an unread body can
reset it on Windows; that is the likely cause, rather than a settings write
failure. The other Windows tests completed, and the exact-main native Decky
runtime run passed.
The test now sends an empty POST when checking that a missing key is refused.
Its subsequent authorized POST still sends a settings body and verifies the
saved value. The local `tests/test_server.py` module passed 45 tests after
this change. Hosted Windows CI on the repair commit and green exact-main CI
remain required before further merges.
@@ -0,0 +1,5 @@
# Published release manifest guard
The publisher previously accepted an already published release, deleted its existing `update.json`, then attempted to upload a replacement. If that upload failed, running copies would lose the live update manifest. The publisher now refuses a non-draft release before any asset deletion or upload.
The stand-in GitHub API regression provides a published release with an existing `update.json` and verifies that publication fails without any mutating API call. On 2026-10-28, `python3 -m unittest discover -s tests -p test_publish_release.py -q` passed all 12 tests; `git diff --check` passed. Hosted CI remains the merge gate. No GitHub release or update channel was changed by this test.
@@ -0,0 +1,76 @@
{
"tag": "v0.4.50",
"releaseId": 424993367,
"tagNameAtAudit": "v0.4.50",
"draftAtAudit": true,
"sha256sumsSha256": "85383104a8f08f49ffde24ac867f9c2ead41951e602600a9d22d58bc19bf06f5",
"assets": [
{
"name": "Frame-Control-mac-arm64.dmg",
"size": 173090558,
"sha256": "d402924394725bac2b28bef17c7945e2f7e4c3093227eace78a42ed80e864be9",
"udifTrailer": "ok"
},
{
"name": "Frame-Control-mac-arm64.zip",
"size": 172911785,
"sha256": "2583937fefdc315706018c1bf794b916726ee55b461e321c3334df21245a110d",
"zipCrc": "ok",
"members": 1666,
"bundleVersion": "0.4.50"
},
{
"name": "Frame-Control-mac-x64.dmg",
"size": 180139223,
"sha256": "2128c14b15dd1a2a59790c43d42640a25185562056796a3437910d6f88f996c8",
"udifTrailer": "ok"
},
{
"name": "Frame-Control-mac-x64.zip",
"size": 179998207,
"sha256": "ed684023ff087d6741e88d4ca4f8a47e67bec07255fc73fbd18a6bcaa42d14fe",
"zipCrc": "ok",
"members": 1662,
"bundleVersion": "0.4.50"
},
{
"name": "Frame-Control-Setup-x64.exe",
"size": 147348168,
"sha256": "7c46fcc759cefb9060e1dc2fce39682f6dad306f3666805f77ce707e16ef3e2c",
"peMachine": 332
},
{
"name": "Frame-Control-win-x64.zip",
"size": 194302082,
"sha256": "57e71304bae96dc8aab6b845a64c27ac428931d02bb41ae60b33b19fcd11193f",
"zipCrc": "ok",
"members": 1510
},
{
"name": "Frame-Control-linux-x86_64.AppImage",
"size": 175453095,
"sha256": "d2196b9a50d0777203e60721dd19c6edbb5c18a7639148f0a496d420e231b053",
"elfMachine": 62
},
{
"name": "Frame-Control-linux-arm64.AppImage",
"size": 167036625,
"sha256": "49370f9c27931bf6c28676d627dc34553550b544ab38b7f4be8e8f6dbc9b7c4b",
"elfMachine": 183
},
{
"name": "Frame-Control-linux-amd64.deb",
"size": 142209572,
"sha256": "430d057f234557f5f9dbb3e2cff3469829d041d0d7be191e70e6ef7de6663256",
"debVersion": "0.4.50",
"architecture": "amd64"
},
{
"name": "Frame-Control-linux-arm64.deb",
"size": 129291236,
"sha256": "6403f8caafcdd5832415547e146ce3886cf7358e0c3a8898f4ee6e9a0a7b05ea",
"debVersion": "0.4.50",
"architecture": "arm64"
}
]
}
+75
View File
@@ -0,0 +1,75 @@
# v0.4.50 tagged release verification, 27 October 2026
The annotated `v0.4.50` tag dereferences to merged main commit
`2797212d8c95311deb8ba73043d0f90ba10bc962`. Its tree exactly matched
the reviewed [candidate PR #360](https://github.com/saphid/frame-control/pull/360)
head `dab13fa8763b4f836cd49d398d152124655ea8dc`. The candidate includes
the Framey and Windows changes listed in the public release notes and the
publisher gate that checks every workflow run attempt.
## Exact commit and tagged build gates
- [Main checks](https://github.com/saphid/frame-control/actions/runs/40440862876)
completed successfully on the tag commit: seven of seven jobs, including
e2e, iOS, Windows, macOS, Linux and the aggregate gate.
- [Native Decky runtime](https://github.com/saphid/frame-control/actions/runs/40440862867)
completed successfully on that commit: AMD64 and ARM64 actual Decky 3.3.0
discovery and safe probes.
- An exact-main [installer preview](https://github.com/saphid/frame-control/actions/runs/40441532929)
built Windows, Linux, Apple Silicon Mac and Intel Mac installers successfully
before tagging. Its draft and checksum jobs were inapplicable to the manual
preview and skipped by their tag-only conditions; they were not release gates.
- The [exact-tag release workflow](https://github.com/saphid/frame-control/actions/runs/40442228066)
then passed all six jobs on its first attempt: draft, four platform builds,
and checksums. No tagged job failed, was cancelled, skipped or overridden.
The draft contained exactly ten installer assets and `SHA256SUMS` before
publication. Every installer was independently downloaded by GitHub asset ID,
one file at a time. Its downloaded size and SHA-256 matched GitHub's asset
metadata and the downloaded `SHA256SUMS`; the latter's SHA-256 was
`85383104a8f08f49ffde24ac867f9c2ead41951e602600a9d22d58bc19bf06f5`.
The [sanitized audit result](v0450-assets-2026-10-27.json) records all ten
names, sizes, hashes and package checks. The audit also checked both Mac ZIP
CRCs and bundle version `0.4.50`, both DMG UDIF trailers, the Windows ZIP
CRC and PE installer header, both AppImage ELF architectures, and both DEB
version and architecture fields. The Windows NSIS installer itself has a
32-bit PE launcher; tagged Windows CI installed its x64 payload and checked
packaged resources and Python libraries.
The independently downloaded Linux x86_64 AppImage had SHA-256
`d2196b9a50d0777203e60721dd19c6edbb5c18a7639148f0a496d420e231b053`.
Its first local launch exited inside the workspace sandbox before the server
answered. The same bytes then passed outside that sandbox under isolated Xvfb:
HTTP 200 with `FrameControl/1`, followed by a stopped server. The launcher
used a temporary profile and disabled telemetry and update checks. Tagged
Linux CI also launched its own built AppImage. No physical Mac or Windows
launch of these exact installers was performed.
## Real Frame and publication
The [candidate evidence](v0450-candidate-2026-10-27.md) records a unique-lock
real Frame smoke on the version-bumped source: **12 passed, one optional
x86_64 launch skipped, zero failed**. Steam Linux Runtime 4 was absent, so no
x86_64 launch is claimed. A separate fresh-lock cleanup audit found no owned
test title directories, metadata or processes; both locks were released.
SteamVR activity was 3 (standby), proximity raw 3.25 and battery 100%.
There was no wearer confirmation, wake command or worn interaction test.
The private smoke result's SHA-256 is
`7b8d88aafb4459bf882edeb10f65c021c5aa25ad9ed2755faf04e3b4a95c1632`.
`scripts/publish-release.sh --dry-run v0.4.50` passed the exact main,
native runtime and tagged release jobs, all ten installer hashes, regenerated
`SHA256SUMS`, draft notes and update-manifest checks. The real publisher
repeated those gates, attached `update.json`, and published
[v0.4.50](https://github.com/saphid/frame-control/releases/tag/v0.4.50) at
**03:19:21 UTC**. GitHub's latest-release API returned `draft=false`,
`prerelease=false`, ten installers, `SHA256SUMS` and `update.json`. A fresh
public latest-download of `update.json` reported version `0.4.50`, the
correct release page, ten hashes matching the independent downloads, and the
worn-headset limit in its notes.
The exact-version official Decky catalog is **6 works / 51 partial /
48 broken / 11 N/A**. These statuses have the scoped evidence in
[the compatibility table](../framey-compatibility.md). Worn headset
visibility, physical controller input, VR keyboard and optical comfort remain
unverified. The hourly shared-lock worn check remains enabled.
@@ -0,0 +1,76 @@
{
"tag": "v0.4.51",
"releaseId": 425049939,
"tagNameAtAudit": "v0.4.51",
"draftAtAudit": true,
"sha256sumsSha256": "9d28eae8610356cba56310c892b6dd16dfc55c0507c9089600ab86e9697a04f8",
"assets": [
{
"name": "Frame-Control-mac-arm64.dmg",
"size": 177621496,
"sha256": "8c84ef490402c202ad694f9d363d792c5dbc9b046e40d8d834bcb6d7752ab6e8",
"udifTrailer": "ok"
},
{
"name": "Frame-Control-mac-arm64.zip",
"size": 177449210,
"sha256": "3541b67985e3931b9469af9249df3f71133589d726ffbd7b0c2b9c1c3dd3f67c",
"zipCrc": "ok",
"members": 1673,
"bundleVersion": "0.4.51"
},
{
"name": "Frame-Control-mac-x64.dmg",
"size": 184691853,
"sha256": "04fecaf012e6ab4ca139823d2220bf61b788568cdffbfdad22b5d0b2c2b63763",
"udifTrailer": "ok"
},
{
"name": "Frame-Control-mac-x64.zip",
"size": 184535648,
"sha256": "7e9836d62f882fbaac3d9b35831410c5e6138d9b68fb93815cbd2bb9200bd46b",
"zipCrc": "ok",
"members": 1669,
"bundleVersion": "0.4.51"
},
{
"name": "Frame-Control-Setup-x64.exe",
"size": 151882885,
"sha256": "a45996f621901c2df8fc41d9eca2ae3fcd6b9ff40de66ed8acfca9415a3a81b0",
"peMachine": 332
},
{
"name": "Frame-Control-win-x64.zip",
"size": 198837858,
"sha256": "41791b6ca2c40164eb44cdc29e1281886556c257b88bcd5f86941daddd15d66a",
"zipCrc": "ok",
"members": 1517
},
{
"name": "Frame-Control-linux-x86_64.AppImage",
"size": 179993078,
"sha256": "5ebbb931276cd3126cb3ecab88c6cde05f490cbd0e0205b418c32d00f765ea42",
"elfMachine": 62
},
{
"name": "Frame-Control-linux-arm64.AppImage",
"size": 171579329,
"sha256": "4205422b02b9207bf2cf59ce3eb847b601306cef326df9fb005a7df540d82bb5",
"elfMachine": 183
},
{
"name": "Frame-Control-linux-amd64.deb",
"size": 146747460,
"sha256": "7c3a4eae7fb2098c759cbd9a8194c326b70f2fa078030062c047e4c8418b4bef",
"debVersion": "0.4.51",
"architecture": "amd64"
},
{
"name": "Frame-Control-linux-arm64.deb",
"size": 133829576,
"sha256": "793433d21e457d5422ea982678f50d5f3ac0c1c46d40c5dcebf42d16651d4995",
"debVersion": "0.4.51",
"architecture": "arm64"
}
]
}
@@ -0,0 +1,53 @@
# v0.4.51 candidate gate, 27 October 2026
The source candidate `91cf774` follows green main merge commit
`560f3de5631ce2dca641c68a5cba7b2dd1d2660f`. The main
[checks](https://github.com/saphid/frame-control/actions/runs/40450100545)
and [native Decky runtime](https://github.com/saphid/frame-control/actions/runs/40450100462)
passed on that exact merge commit. This candidate bumps the app and lockfile
to 0.4.51 and requires standby-range proximity before the release smoke can
change test titles.
Since v0.4.50, [#362](https://github.com/saphid/frame-control/pull/362)
recorded the pinned official LocalSend source match, and
[#363](https://github.com/saphid/frame-control/pull/363) added an exact-digest
managed ARM64 helper, desktop/headset packaging, and a scoped **partial**
compatibility entry. Its PR head passed e2e, native Decky AMD64/ARM64, and all
four installer previews. The catalog is **6 works / 52 partial / 47 broken /
11 N/A**. A private no-route ARM64 helper marker transfer passed on the real
Frame, but the managed Decky service on Frame, user-facing transfer, local
discovery, Steam routes, worn VR and physical controllers remain unverified.
## Locked real Frame smoke
At 04:33:21 UTC, `scripts/frame-smoke.sh` ran from source candidate `91cf774`
with a unique-owner atomic `/tmp/frame-test.lock`. Its read-only preflight
found Frame OS 0.5.6 build `20261022.6153909`, SteamVR activity **3
(standby)**, proximity raw **4.6875**, and battery **88%**. It passed **12
steps**, skipped one optional x86_64 launch, and failed **zero**. ARM64 and
Windows/Proton test titles installed, launched and were removed. The x86_64
title installed and was removed; Steam Linux Runtime 4 was absent, so no
x86_64 launch is claimed. The runner's owned cleanup passed and it released
only its lock. The private result is
`/tmp/frame-control-v0451-smoke-results/2026-10-27T043321Z-20261022.6153909.json`
(SHA-256 `c2b9611e072fd08109c31f4aa05a4ba6b93de82f532a7d87a4a1c6dcd52024a3`).
A separate read-only audit acquired a fresh unique lock after the smoke. It
found activity **3**, proximity raw **4.6875**, battery **91%** while charging,
no exact reserved smoke title files or metadata, and no processes owned by
those title paths. It released its own lock. The private audit result is
`/tmp/frame-control-v0451-cleanup-audit.json` (SHA-256
`3953072bb5457f4fef2cb766d83f65f844896fc7893d2479a166cb9325f72b48`).
No power setting or security state was changed. No wearer was confirmed and no
worn panel or controller interaction was tested; the hourly worn check remains
enabled.
## Remaining release gates
The candidate needs green exact-head PR CI, e2e, native Decky runtime and all
four installer previews. After the candidate merges, its exact main commit
needs green CI and e2e. The tagged release then needs every installer build,
ten assets, matching checksums, release verification, independent package
checks and publisher dry run. A failed, cancelled, skipped, pending or
unverified release gate holds publication. The current published release
remains v0.4.50.
+75
View File
@@ -0,0 +1,75 @@
# v0.4.51 tagged release verification, 27 October 2026
The annotated `v0.4.51` tag dereferences to merged main commit
`4da7220660ba345bc46cdb525b6c512bc6a9d8bd`. Its tree
`16aa888207e8661280aa659c96a08fee05f17689` exactly matches reviewed
[candidate PR #364](https://github.com/saphid/frame-control/pull/364) head
`728a4dbe9b049e5f5cfcd6de02d9ca858d3e9aa4`. The candidate source
passed the locked smoke described in
[its evidence](v0451-candidate-2026-10-27.md).
## Exact commit and tagged build gates
- [Main checks](https://github.com/saphid/frame-control/actions/runs/40451752952)
passed on the tag commit: all seven jobs on the first attempt, including
e2e, iOS, Windows, macOS, Linux and the aggregate check.
- [Native Decky runtime](https://github.com/saphid/frame-control/actions/runs/40451752953)
passed on the tag commit: both published Decky 3.3.0 AMD64 and ARM64 jobs.
- An exact-main [installer preview](https://github.com/saphid/frame-control/actions/runs/40451826490)
built all four platform installers successfully before tagging. Its draft
and checksum jobs were inapplicable to manual preview runs.
- The [exact-tag release workflow](https://github.com/saphid/frame-control/actions/runs/40452627862)
passed all six jobs on its first attempt: draft, Linux, Windows, Intel Mac,
Apple Silicon Mac and checksums. No tagged job failed, was cancelled,
skipped or overridden.
The unpublished draft held exactly ten installers and `SHA256SUMS` before
publication. Every installer was independently downloaded by GitHub asset ID,
one at a time. Each downloaded size and SHA-256 matched GitHub's asset
metadata; the downloaded `SHA256SUMS` matched all ten entries byte for byte.
Its SHA-256 was
`9d28eae8610356cba56310c892b6dd16dfc55c0507c9089600ab86e9697a04f8`.
The [sanitized asset audit](v0451-assets-2026-10-27.json) records all ten
names, sizes, hashes and package checks. It checked both Mac ZIP CRCs and
bundle version `0.4.51`, both DMG UDIF trailers, Windows ZIP CRC and PE
installer header, both AppImage ELF architectures, and both DEB version and
architecture fields.
The independently downloaded Linux x86_64 AppImage had SHA-256
`5ebbb931276cd3126cb3ecab88c6cde05f490cbd0e0205b418c32d00f765ea42`.
It launched under isolated Xvfb with a temporary profile, telemetry and
update checks disabled. Its bundled server returned HTTP 200 with
`FrameControl/1` and stopped after app exit. No physical Mac or Windows
launch of these exact installers was performed.
## Real Frame and publication
The [candidate evidence](v0451-candidate-2026-10-27.md) records a unique-lock
real Frame smoke on the version-bumped source: **12 passed, one optional
x86_64 launch skipped, zero failed**. Steam Linux Runtime 4 was absent, so no
x86_64 launch is claimed. A separate fresh-lock audit found no reserved smoke
title files, metadata or owned processes; both locks were released. SteamVR
activity was 3 (standby), proximity raw 4.6875, battery 88% before and 91%
while charging at audit. There was no wearer confirmation, wake command or
worn interaction test. The private smoke result's SHA-256 is
`c2b9611e072fd08109c31f4aa05a4ba6b93de82f532a7d87a4a1c6dcd52024a3`.
`scripts/publish-release.sh --dry-run v0.4.51` passed exact-main, native
runtime and tagged release jobs, all ten installer hashes, regenerated
`SHA256SUMS`, draft notes and update-manifest checks. The real publisher
repeated those gates, attached `update.json`, and published
[v0.4.51](https://github.com/saphid/frame-control/releases/tag/v0.4.51) at
**05:10:05 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.51`, the correct
release page, ten hashes matching the independent downloads, and the worn
limit in its notes. The public manifest's SHA-256 was
`27bc0e45acf6b9a5677639e66ab501f2acc3eb7f5c0f4f867ced8a5d11825c52`.
The exact-version Decky catalog is **6 works / 52 partial / 47 broken /
11 N/A**. Managed Decky Localsend is **partial**: real Frame testing proved
only its rebuilt helper's private marker transfer, while the published
Decky 3.3.0 runtime gate proved discovery and stopped status/configuration
RPCs. Real managed-service operation, user-facing transfer, network
discovery, Steam routes, worn visibility and physical-controller input remain
unverified. The hourly shared-lock worn check remains enabled.
@@ -0,0 +1,76 @@
{
"tag": "v0.4.52",
"releaseId": 426854020,
"tagNameAtAudit": "v0.4.52",
"draftAtAudit": true,
"sha256sumsSha256": "71306c21818a8842f60633540a16457e962e9ffc7fac01a547a5a2fd17f32904",
"assets": [
{
"name": "Frame-Control-mac-arm64.dmg",
"size": 177628410,
"sha256": "2dad765390965eaa01ab9a1737bffddcb6c8d481e4d255322e663bcdb7ad1e30",
"udifTrailer": "ok"
},
{
"name": "Frame-Control-mac-arm64.zip",
"size": 177451653,
"sha256": "abb17c3e3de2ada967d10b7e277c9f122fb731108efc6d70fed9b87e92e66106",
"zipCrc": "ok",
"members": 1673,
"bundleVersion": "0.4.52"
},
{
"name": "Frame-Control-mac-x64.dmg",
"size": 184701118,
"sha256": "1317ee7b30de46bc69a19d42d68245dbf55ca02fbc6ceee5761534ddb8706bab",
"udifTrailer": "ok"
},
{
"name": "Frame-Control-mac-x64.zip",
"size": 184538083,
"sha256": "4d0fbf1904073a7b28d0e968013bd7ca34600536538db32d00b03cf6aaf5b3b4",
"zipCrc": "ok",
"members": 1669,
"bundleVersion": "0.4.52"
},
{
"name": "Frame-Control-Setup-x64.exe",
"size": 151885028,
"sha256": "bb190c20abb2fae1878b732819ae64c214c0cfd627c3a6279b25e9d9c0517740",
"peMachine": 332
},
{
"name": "Frame-Control-win-x64.zip",
"size": 198840260,
"sha256": "ef7d248967bda011951bd703972f3a1cb00a43e484af0daf58a5f754e2f2486c",
"zipCrc": "ok",
"members": 1517
},
{
"name": "Frame-Control-linux-x86_64.AppImage",
"size": 179997222,
"sha256": "6241e068222a2c05f41946e94dedb1456dce25ae94d4857bcb7a4d1891feac7d",
"elfMachine": 62
},
{
"name": "Frame-Control-linux-arm64.AppImage",
"size": 171579364,
"sha256": "27da455144f8c88ba6a56d6eed90e1806c098d5f6806790089513ba77ec79f52",
"elfMachine": 183
},
{
"name": "Frame-Control-linux-amd64.deb",
"size": 146749772,
"sha256": "97425cbd4d460460b9f0574519bbf11afa1b8cc8003e1b784d6c002244da4075",
"debVersion": "0.4.52",
"architecture": "amd64"
},
{
"name": "Frame-Control-linux-arm64.deb",
"size": 133832104,
"sha256": "58c98ed203caca60ce022c90232c13591d11c72ff5b28c5860aa7b221ec3e918",
"debVersion": "0.4.52",
"architecture": "arm64"
}
]
}
@@ -0,0 +1,63 @@
# v0.4.52 candidate gate, 28 October 2026
This candidate bumps the desktop app and lockfile to 0.4.52. It contains
installer-fetch [PR #374](https://github.com/saphid/frame-control/pull/374),
merged to main at `5847a810667724ef423f278a83650f9f1c6d18b5`. Its exact
reviewed head `082af24115a87b3bf11a77c2cf93b5bcdd3b4409` passed
[CI and e2e](https://github.com/saphid/frame-control/actions/runs/40706672771),
[native Decky](https://github.com/saphid/frame-control/actions/runs/40706672854)
and [all four installer previews](https://github.com/saphid/frame-control/actions/runs/40706672774).
The merge commit's own CI is pending. No release has been published from this
branch. The versioned source candidate tested below was
`c88df07c816f1475268760add93a5ab072463688`.
Since v0.4.51, main added the exact-archive Network Sentinel and Battery
Tracker Framey adaptations and CI/release safety fixes. PR #374 retries
transient installer dependency failures, including responses aborted after
partial bytes; pinned SHA-256 verification remains required before writing a
download. The exact-version official Decky catalog is **6 works / 54 partial /
45 broken / 11 N/A**. Network Sentinel and Battery Tracker are partial;
managed real Frame operation, worn visibility and physical-controller input
remain unverified.
## Locked real Frame smoke
At 14:42:13 UTC, `scripts/frame-smoke.sh` ran from the versioned source
candidate under its unique-owner atomic `/tmp/frame-test.lock`. Its read-only
preflight found Frame OS 0.5.6 build `20261022.6153909`, SteamVR activity
**3 (standby)**, proximity raw **2.3125**, and battery **100%**. ARM64 and
Windows/Proton test titles installed, launched and were removed. The x86_64
title installed and was removed; its optional launch was skipped because
Steam Linux Runtime 4 was absent. The result was **12 passed, one optional
skip, zero failed**. Owned cleanup passed and the runner released only its
lock. The private result is
`/tmp/frame-control-v0452-smoke-results/2026-10-28T144213Z-20261022.6153909.json`
(SHA-256 `42ea66740d001cc097ce9e871be6b01be1a10f184ab4480019b0a306851cfc57`).
A separate read-only audit acquired a fresh unique lock after smoke. It
found activity **3**, proximity raw **2.3125**, battery **100%**, no exact
reserved smoke title files or metadata, and no processes owned by those
title paths. It released its own lock. The private audit result is
`/tmp/frame-v0452-cleanup-audit.json` (SHA-256
`daebfb5c02d6cf59e5cee092e3fa90a07b3dc9f105e167d46a4f4327c541c7ce`).
No power setting or security state was changed. No wearer was confirmed; no
worn panel, physical-controller or official plugin interaction was tested.
The hourly worn check remains enabled.
## Remaining release gates
- Main's #374 merge commit needs green exact-commit CI, e2e and native Decky
runtime before the candidate can merge.
- This candidate needs its own exact-head CI, e2e, native Decky runtime and
all four installer previews after its sync with the final main merge.
- The locked source smoke and independent cleanup audit above passed. Their
optional x86_64 runtime skip does not prove that launch; no worn display or
physical-controller claim follows from standby smoke.
- After candidate merge, the exact main commit needs green CI, e2e and native
Decky runtime. The annotated tag then needs all four installer builds and
checksums on that same commit, independent downloads and package checks,
release verification and a publisher dry run. Any failed, cancelled,
skipped, pending or unverified required gate holds publication.
The published release and update channel remain at v0.4.51 until all gates
pass and the draft is explicitly published.
+70
View File
@@ -0,0 +1,70 @@
# v0.4.52 tagged release verification, 28 October 2026
The annotated `v0.4.52` tag dereferences to merged main commit
`ac04407bcc799e9e88ce31c3f602020e9f3b1cee`. Its tree
`864fde2ec006afe00eb886279af9361a6e3b657e` exactly matches reviewed
[candidate PR #375](https://github.com/saphid/frame-control/pull/375) head
`432b29f9618d1e300abdf8437a2bd8360d54cffb`. The candidate's locked
source smoke and cleanup are recorded in [its evidence](v0452-candidate-2026-10-28.md).
## Exact commit and tagged build gates
- [Main checks](https://github.com/saphid/frame-control/actions/runs/40714882563)
passed on the tag commit: all seven jobs on the first attempt, including
e2e, iOS, Windows, macOS, Linux and the aggregate check.
- [Native Decky runtime](https://github.com/saphid/frame-control/actions/runs/40714882617)
passed on the tag commit: both published Decky 3.3.0 AMD64 and ARM64 jobs
on the first attempt.
- The [exact-tag release workflow](https://github.com/saphid/frame-control/actions/runs/40717151326)
passed all six jobs on the first attempt: draft, Linux, Windows, Intel Mac,
Apple Silicon Mac and checksums. No tagged job failed, was cancelled,
skipped or overridden.
Before publication, the unpublished draft held exactly ten installers and
`SHA256SUMS`. Every installer was independently downloaded by GitHub asset
ID. Each downloaded size and SHA-256 matched the asset metadata; the
downloaded `SHA256SUMS` matched all ten entries byte for byte. Its SHA-256 was
`71306c21818a8842f60633540a16457e962e9ffc7fac01a547a5a2fd17f32904`.
The [sanitized asset audit](v0452-assets-2026-10-28.json) records all ten
names, sizes, hashes and package checks. It checked both Mac ZIP CRCs and
bundle version `0.4.52`, both DMG UDIF trailers, Windows ZIP CRC and PE
installer header, both AppImage ELF architectures, and both DEB version and
architecture fields.
The independently downloaded Linux x86_64 AppImage had SHA-256
`6241e068222a2c05f41946e94dedb1456dce25ae94d4857bcb7a4d1891feac7d`.
It launched under isolated Xvfb with a temporary profile, telemetry and
update checks disabled. Its bundled server returned HTTP 200 with
`FrameControl/1` and stopped after app exit. No physical Mac or Windows
launch of these exact installers was performed.
## Real Frame and publication
The [candidate evidence](v0452-candidate-2026-10-28.md) records a unique-lock
real Frame smoke on the version-bumped source: **12 passed, one optional
x86_64 launch skipped, zero failed**. Steam Linux Runtime 4 was absent, so
no x86_64 launch is claimed. A separate fresh-lock audit found no reserved
smoke title files, metadata or owned processes; both locks were released.
SteamVR activity was 3 (standby), proximity raw 2.3125 and battery 100%.
There was no wearer confirmation, wake command or worn interaction test.
The private smoke result's SHA-256 is
`42ea66740d001cc097ce9e871be6b01be1a10f184ab4480019b0a306851cfc57`.
`scripts/publish-release.sh --dry-run v0.4.52` passed exact-main, native
runtime and tagged release jobs, all ten installer hashes, regenerated
`SHA256SUMS`, draft notes and update-manifest checks. Its local log had
SHA-256 `d4264b5ad3106387b63d3fc800f558c912ff36b546ea83d792a5a0e8a4949cc6`.
The real publisher repeated those gates, attached `update.json`, and published
[v0.4.52](https://github.com/saphid/frame-control/releases/tag/v0.4.52) at
**15:52:29 UTC**. GitHub's latest-release API returned `draft=false`, ten
installers, `SHA256SUMS` and `update.json`. A fresh public latest-download of
`update.json` reported version `0.4.52`, the correct release page, ten
names, sizes and hashes matching the independent downloads, and the explicit
limit that no person wore the headset. The public manifest's SHA-256 was
`486bafc8ae4f1feed0f3a1db28b5034498eed78af2a11083be48175489e6d461`.
The exact-version Decky catalog is **6 works / 54 partial / 45 broken /
11 N/A**. Network Sentinel and Battery Tracker remain **partial**. Their
real managed ARM64 operation and wearer-visible behavior are unverified.
Worn VR visibility and physical-controller interaction also remain
unverified. The separate hourly worn check remains enabled.
+15 -9
View File
@@ -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: 6 · Partial: 51 · Broken: 48 · N/A: 11.**
**Works: 6 · Partial: 54 · Broken: 45 · 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.
@@ -27,6 +27,12 @@ Open the browser audit URL in local Chromium, run `runAudit()` and inspect/downl
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.
A [2026-10-28 static action-limit review](evidence/framey-static-action-limits-2026-10-28.md) hash-checked the exact AutoSuspend and CheatDeck archives. AutoSuspend contains Steam suspend/shutdown paths, and CheatDeck's advertised trainer flow needs a Steam game context menu and launch-option writes. Neither plugin was executed; both retain their broken labels.
A [Network Sentinel user-service check](evidence/framey-network-sentinel-2026-10-28.md) adapts only the exact 1.0.4 digest. In isolated actual Decky 3.3.0 amd64, the pinned plugin-local psutil wheel, private settings RPC and one monitoring-loop sample pass. External ping/DNS, Frame ARM64, panel controls and worn VR remain unverified; this is partial compatibility.
A [Battery Tracker user-service check](evidence/framey-battery-tracker-2026-10-28.md) adapts only the exact 0.2.0 digest. A locked read-only Frame probe confirmed a Battery-type supply with the required fields; actual Decky 3.3.0 amd64 recorded private Frame-shaped samples and returned history/per-app power. The managed plugin has not run on the real Frame, and panel controls and worn VR remain unverified; this is partial compatibility.
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. 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
@@ -52,7 +58,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
| 23: Animation Changer 1.3.2 | legacy / legacy | partial | Initializer succeeds; forced backend failure prevents loading animations. Boot-video changes untested. | static archive, local browser, fake-Frame |
| 24: RadiYo! 1.3 | legacy / legacy | partial | Initializer succeeds; radio discovery and playback remain unverified. | static archive, local browser, fake-Frame |
| 26: HLTB for Deck 2.0.12 | 1 / 2 | partial | Settings render without the prior IndexedDB SecurityError; Steam library badges have no patch target, and live lookup is unverified. | static archive, local browser, fake-Frame |
| 27: AutoSuspend 2.2.0 | legacy / legacy | broken | Frontend audit failed: Cannot read properties of null (reading '6'). | static archive, local browser, fake-Frame |
| 27: AutoSuspend 2.2.0 | legacy / legacy | broken | Frontend audit failed: Cannot read properties of null (reading '6'). The exact frontend also contains SteamOS suspend and shutdown paths; threshold behavior and safe Frame operation are unverified. | static archive, local browser, fake-Frame |
| 28: Emuchievements 2.0.5 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: Emuchievements/bin/hash. | static archive, local browser, fake-Frame |
| 30: TunnelDeck 1.0.4 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 32: AutoFlatpaks 1.6.8 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. Shared Toggle value rendering is repaired; other listed blockers remain. | static archive, local browser, fake-Frame |
@@ -79,14 +85,14 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
| 64: Syncthing 0.2.7 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: decky-syncthing/bin/decky-syncthing-watchdog. Shared Toggle value rendering is repaired; other listed blockers remain. | 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 |
| 67: Battery Tracker 0.2.0 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 67: Battery Tracker 0.2.0 | legacy / legacy | partial | Exact managed install removes the unnecessary root flag, selects a readable Battery-type power supply, and repairs empty history and per-app sample grouping. Actual Decky 3.3.0 amd64 recorded Frame-shaped private battery samples and returned history/power data. A locked real Frame read verified the required sysfs fields, not the managed plugin. Live ARM64 recording, long-term battery history, foreground-app labeling, panel controls and worn VR remain unverified; existing copies need update or reinstall. | static archive, local browser, fake-Frame, isolated Decky amd64 backend, real Frame read-only sysfs |
| 68: Wine Cellar 0.2.1 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-wine-cellar/bin/backend. Frontend audit failed: Audit blocks WebSocket. | static archive, local browser, fake-Frame |
| 70: MicroSDeck 0.11.2 | legacy / 2 | broken | Plugin contains a non-ARM64 Linux binary: MicroSDeck/bin/backend. | static archive, local browser, fake-Frame |
| 72: ScreenshotUploader 0.0.3 | legacy / legacy | partial | Initializer succeeds; screenshot upload requires external credentials/network and was not run. | static archive, local browser, fake-Frame |
| 73: EmuDecky 1.0.2-1 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: EmuDecky/bin/hello. | static archive, local browser, fake-Frame |
| 74: KDE Connect 0.1.0-1 | legacy / legacy | partial | Initializer succeeds; KDE Connect installation/pairing/file transfers unverified. | static archive, local browser, fake-Frame |
| 75: Reshadeck 0.2.0 | legacy / legacy | partial | Initializer succeeds; ReShade deployment into Proton games is unverified. | static archive, local browser, fake-Frame |
| 76: CheatDeck 1.1.6 | 1 / 2 | broken | Frontend audit failed: Cannot convert undefined or null to object. | static archive, local browser, fake-Frame |
| 76: CheatDeck 1.1.6 | 1 / 2 | broken | Frontend audit failed: Cannot convert undefined or null to object. Its primary trainer workflow uses a Steam game context menu and launch-option writes, with no target in the isolated Framey panel; no trainer launch was verified. | static archive, local browser, fake-Frame |
| 77: BT Wake Control 1.2.0 | 1 / 2 | partial | Initializer succeeds; backend uses DMI board paths and Bluetooth wake permissions, unverified on ARM64 Frame. | static archive, local browser, fake-Frame |
| 78: Brightness Bar 1.0.4 | legacy / legacy | N/A | Not supported on Frame: Targets Steam/QAM brightness shortcuts and a global Steam overlay; neither is attached to the Framey panel. | static archive, local browser, fake-Frame |
| 79: Decky Notifications 1.0.1 | legacy / legacy | partial | Initializer succeeds; device/account setup and notification delivery unverified. | static archive, local browser, fake-Frame |
@@ -122,7 +128,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
| 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. Shared Toggle value rendering is repaired; other listed blockers remain. | 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 |
| 114: Network Sentinel 1.0.4 | 1 / 2 | partial | Exact managed install removes the unnecessary root flag and adds pinned plugin-local psutil wheels. Actual Decky 3.3.0 amd64 in network isolation loads the backend and records a network sample; private settings and read-only RPCs pass. External ping/DNS, real Frame ARM64, Wi-Fi scan behavior, panel controls and worn VR remain unverified. Existing copies need update or reinstall. | static archive, local browser, fake-Frame, isolated Decky amd64 backend |
| 115: Decky Clash 1.3.0 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 116: Muon 0.2.1 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. Shared Toggle value rendering is repaired; other listed blockers remain. | static archive, local browser, fake-Frame |
| 117: DeckyParty File Manager 1.0.0 | legacy / legacy | partial | Initializer succeeds; privileged file operations and backend are unverified. No file changes were run. | static archive, local browser, fake-Frame |
@@ -132,7 +138,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
| 121: DeckPass 1.1.1 | 1 / 2 | partial | Initializer succeeds; KeePass Flatpak discovery and secrets integration were not exercised. | static archive, local browser, fake-Frame |
| 123: Steam Status 1.2.3 | 2 / 2 | partial | Settings/public status rendered locally; cache errors fixed. Read-only get_settings RPC returned actual Decky 3.3.0 defaults on ARM64 Frame. Persistence, live backend diagnostics and VR controls unverified. | static archive, local browser, fake-Frame, real Frame SSH |
| 124: AlarMe 1.6.3 | 1 / 2 | partial | Silent labeled timer create/ticks/pause/cancel, preset save/cancel-confirm/delete and stopwatch start/lap/pause/reset/reopen pass in local Chromium with the official Decky 3.3.0 ARM64 backend. Audio, alarms/reminders, suspend inhibition, Steam overlays and worn VR remain unverified; notifications require the panel to stay open. | static archive, local browser, fake-Frame, real Frame SSH |
| 125: Decky Localsend 0.4.5 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-localsend/bin/localsend-core. | static archive, local browser, fake-Frame |
| 125: Decky Localsend 0.4.5 | 1 / 2 | partial | Exact managed 0.4.5 archive replaces its x86 helper with a pinned ARM64 build. Published Decky 3.3.0 ARM64 stopped status/configuration RPCs and a private real-Frame ARM64 helper marker transfer passed. The managed service, user-facing discovery/transfer, Steam routes and worn VR remain unverified. | exact archive and source hashes, local Chromium initial panel, host managed transaction, fake Frame managed install/remove, private x86 backend transfer, published Decky 3.3.0 AMD64/ARM64 stopped status/configuration RPCs, private real Frame ARM64 helper marker transfer |
| 126: Launch Options 1.9.2 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-launch-options/bin/hello. Frontend audit failed: Cannot convert undefined or null to object. Shared Toggle value rendering is repaired; other listed blockers remain. | static archive, local browser, fake-Frame |
| 127: Decky Translator 0.9.0 | 1 / 2 | partial | Initializer succeeds; x86_64 download and hidraw/controller assumptions need an ARM64 audit. OCR/translation not exercised. | static archive, local browser, fake-Frame |
| 128: Quick User Switcher 1.0.0 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
@@ -279,11 +285,11 @@ DFL includes exports imported through decky-frontend-lib / @decky/ui. Steam stor
## Native payload portability
All published Linux native payloads in this snapshot are x86_64; no published aarch64 payload was found. The three Windows DLLs belong to Decky-Framegen. Except for the exact managed DeckyFileServer adaptation below, the installer still rejects these archives. Apart from LocalSend and DeckyFileServer, ARM64 builds were not attempted: the constraints below remain unresolved.
All published Linux native payloads in this snapshot are x86_64; no published aarch64 payload was found. The three Windows DLLs belong to Decky-Framegen. Except for the exact managed DeckyFileServer and Decky Localsend adaptations below, the installer still rejects these archives. Apart from LocalSend and DeckyFileServer, ARM64 builds were not attempted: the constraints below remain unresolved.
A [DeckyFileServer ARM64 follow-up](evidence/framey-fileserver-arm64-2026-10-27.md) ships an offline, digest-checked GPL helper build, corresponding vendored source and a build recipe. Only its exact managed archive can replace the x86 helper and remove its unnecessary root flag. Actual Decky 3.3.0 discovery/RPC and real Frame HTTPS private-fixture listing/download pass; uploads stay disabled. Hostname-DNS failures and empty optional arguments are repaired. Decky 3.3.0 still exposes `decky.HOME`; that call is unchanged. The plugin moves from broken to partial: its frontend advertises HTTP/steamdeck URLs and needs a Steam folder picker. Use HTTPS and a preconfigured directory. Uploads, generated thumbnails and worn VR remain unverified.
[LocalSend ARM64 build/runtime evidence](evidence/framey-localsend-arm64-2026-10-26.md) covers a reproducible MIT helper build and its isolated real-Frame protocol 2.0 info response. The official x86 archive remains blocked; full plugin integration and file transfers are unverified.
[LocalSend ARM64 build/runtime evidence](evidence/framey-localsend-arm64-2026-10-26.md) covers an isolated real-Frame protocol 2.0 info response from an earlier helper build. [Exact official source matching](evidence/framey-localsend-source-match-2026-10-27.md) now identifies the official helper source and a reproducible ARM64 build from that revision. The exact official x86 backend passed one local private-namespace transfer, and the rebuilt ARM64 helper passed one locked real-Frame private marker transfer. Published Decky 3.3.0 AMD64/ARM64 runtime checks discovered the adapted plugin and read its stopped status and configuration. Only the exact managed 0.4.5 archive can replace its x86 helper on Frame. The actual managed Decky service on Frame, user-facing transfer, local discovery and worn VR remain unverified ([managed adaptation evidence](evidence/framey-localsend-managed-2026-10-27.md)).
| Plugin | Native payloads | ARM64 result / remaining blocker |
| --- | --- | --- |
@@ -308,7 +314,7 @@ A [DeckyFileServer ARM64 follow-up](evidence/framey-fileserver-arm64-2026-10-27.
| HueSync | bin/hello, py_modules/portio.so | portio and handheld LED backends depend on x86 I/O/unsupported hardware; ARM64 compilation alone is insufficient. |
| Decky Clipboard | bin/xclip | xclip needs ARM64 X11 libraries/server; root and gamescope assumptions also block this archive. |
| Reroll | bin/memscan.so | memscan.so needs ARM64 process-memory support and ABI testing; root requirement remains. Frontend initializes but no memory access was tested. |
| Decky Localsend | bin/localsend-core | MIT LocalSend-Go 66edc509a28b9f877e7374504892dcbc653a4da4 reproducibly cross-built with Go 1.25.7/CGO_ENABLED=0 for ARM64; SHA-256 e79238f6051ce34f172f765ae918d3d902b819e27377989a55e5b1eba10e46f9. Help/official backend CLI flags and protocol 2.0 info endpoint passed on real Frame in an isolated network namespace. No file transfer or full plugin/CEF integration tested; exact source match to shipped helper unproven. Official ZIP stays x86_64 and blocked. |
| Decky Localsend | bin/localsend-core | Exact official x86 helper is reproducible from pinned MIT source; the managed installer replaces only the exact 0.4.5 digest with SHA-256 e20363237e606ee65eae857792176d21a0bb8ba863489952728eaf2b13277dae on ARM64. The Frame rechecks original Python/helper bytes, replacement digest and ELF machine; x86 keeps the original helper. Local official x86 backend/proxy and real Frame rebuilt ARM64 helper each passed one private marker transfer. Published Decky 3.3.0 AMD64/ARM64 discovery and stopped status/configuration RPCs passed. The actual managed Decky service on Frame, network discovery, user-facing transfer, Steam routes and worn VR remain unverified (docs/evidence/framey-localsend-managed-2026-10-27.md). |
| Launch Options | bin/hello | Remove the template x86 bin/hello or publish an ARM64 rebuild; missing Steam module mapping also prevents frontend initialization. |
| Decky-Clipper | bin/gstreamer-1.0/libgstproxy.so, bin/gstreamer-1.0/libgstmidi.so, bin/gstreamer-1.0/libgstmpegtsmux.so, bin/gstreamer-1.0/gst-plugin-scanner, bin/gstreamer-1.0/libgstivfparse.so, … 115 total; full list in JSON | Rebuild all bundled GStreamer libraries/plugins for ARM64 and verify capture/codec ABI against Frame SteamVR; gamescope capture differs. |
+153
View File
@@ -0,0 +1,153 @@
# Frame Control standing goal and historical recovery handoff
Owner: Alex. This records the 27 October 2026 AEDT recovery handoff; status
checks below use UTC. Alex subsequently authorized the existing coordinator to
continue the roadmap and publish releases when every gate passes. This document
does not authorize a second coordinator to run concurrently.
## Alex's top priority
> Make Frame Control's released version always stable and running cleanly. Never release something that isn't working correctly. I don't want to break things for people.
Keep the released app stable and main green. Investigate failures before
shipping; a version number or an uploaded artifact is not proof of readiness.
## Standing roadmap
Continue official Decky compatibility, test gaps, fixes and useful features.
The existing owner is T3 job `mppkd9qczt` on lxso2. Preserve its work and avoid
competing coordinator or release jobs. Verify its current state before any
takeover; this historical handoff is not a takeover instruction.
## Mandatory release gates
- CI must be green on the **exact release commit**, not merely an earlier
commit, another branch, or a previous successful workflow attempt.
- End-to-end tests and release verification must pass for that candidate.
- Every required installer build must succeed, including Linux, Windows,
macOS Intel and macOS Apple Silicon.
- Never publish a release with failed or cancelled required builds. A retry
must actually finish successfully; a cancelled build is not a pass.
- Keep main green. Resolve required failures before merging or releasing.
- Preserve evidence linking the release tag/commit, successful workflow run,
installer assets/checksums, e2e results and real-device verification.
- Use `scripts/publish-release.sh --dry-run TAG` before publishing. Alex's
standing full-auto instruction authorizes the existing coordinator to tag and
publish after every gate passes. Stop for secrets, money, destructive Git
actions or wiping the headset; never bypass a failed release gate.
## Current release state, checked 27 October 2026 at 05:15 UTC
- The latest public release is
[v0.4.51](https://github.com/saphid/frame-control/releases/tag/v0.4.51),
published at 05:10:05 UTC from main commit
`4da7220660ba345bc46cdb525b6c512bc6a9d8bd`.
- [Exact-commit checks](https://github.com/saphid/frame-control/actions/runs/40451752952),
[native Decky runtime](https://github.com/saphid/frame-control/actions/runs/40451752953),
and the [tagged installer release](https://github.com/saphid/frame-control/actions/runs/40452627862)
succeeded. The tag release built Linux, Windows, Intel Mac and Apple Silicon
Mac installers and checksums. The published update manifest reports 0.4.51.
- The locked candidate smoke passed 12 checks, skipped one optional x86_64
launch, and failed none. SteamVR was in standby; worn display and physical
controller use remain unverified. The hourly shared-lock worn check remains
enabled. See [candidate evidence](../evidence/v0451-candidate-2026-10-27.md)
and [release evidence](../evidence/v0451-release-2026-10-27.md) for the exact
asset audit and publication record.
- Main matched the release commit and its checks were green at this check.
Revalidate before another merge or release. Disk capacity on lxso2 remains
critical; do not delete pre-existing data to make space without Alex's OK.
## Incident snapshot supplied by Alex
At the time of the recovery request, latest published was **v0.4.47** with a
passed release run; **v0.4.48 RC was not published**, after a failed run with a
cancelled macOS build. PRs **#337 and #338 were open**. Preserve this as the
historical incident snapshot, not a claim that remote state stays unchanged.
## Verified update during recovery
Checked 26 October 2026 around 21:58 UTC:
- v0.4.47 was published at 15:07:28 UTC.
- A subsequent v0.4.48 release run
[40406781383](https://github.com/saphid/frame-control/actions/runs/40406781383)
completed successfully on commit
`5438e1d9d05ca472f200dcde50799c8ae882fafb`. Its Linux, Windows, Intel macOS
and Apple Silicon macOS installer jobs, draft and checksum jobs all succeeded.
- GitHub now reports **v0.4.48 published at 21:53:29 UTC**. This publication
happened outside the recovery task. Recheck the exact-commit checks and
release evidence before drawing conclusions about all release gates.
- [#337](https://github.com/saphid/frame-control/pull/337) is merged; lxso1's
clean main was fast-forwarded to
`1ddb6278b8ae0a98ecc79a181bba4d3cc67a52b6`.
- [#338](https://github.com/saphid/frame-control/pull/338) was open at that
time; it has since merged.
- lxso2 T3 recovered at **21:42:54 UTC**; port 3773/local HTTP and existing
HTTPS returned 200 by 21:43:58 UTC. Root disk capacity remains critical;
recovery is not a capacity fix.
- Fleet guards, probe units, safety tests and incident evidence are proposed
in [fleet #38](https://github.com/saphid/fleet/pull/38); units were installed
under the owner's incident authorization. Disk critical alerts use the
existing fleet Prometheus/watchdog pipeline.
## Historical lxso1 readiness
Main checkout: `/home/saphid/projects/steam-frame` (GitHub repository
`saphid/frame-control`). Shell default Node may be older; use
`~/.nvm/versions/node/v24.22.1/bin` first in PATH for Node 24 CI commands.
Python 3.9.25 and 3.13.14 were installed through uv for the CI matrix; system
Python 3.12.3 already existed. Workflows at that time required no Rust/Go
toolchain.
Readiness commands, per-gate logs and exact-commit JSON results live at
`/tmp/frame-control-readiness-20261027/` on lxso1. These are local readiness
checks, not a substitute for GitHub CI on a future release commit. The Linux
installer command uses `--publish never`; no release was published by these
checks. Native ARM64, Windows/macOS platform gates, Xcode/iOS and real-headset
checks still require their proper runners/hardware.
Completed on main commit `1ddb6278b8ae0a98ecc79a181bba4d3cc67a52b6`,
26 October 2026 at 22:00 UTC:
| Gate | Result on lxso1 |
| --- | --- |
| Shell syntax, Python compile | Passed |
| Python 3.9 / 3.12 / 3.13 server suites | 1,639 tests each; passed with platform/optional skips |
| Linux wireless doctor, app syntax/tests, website tests/syntax | Passed |
| `npm ci`, `npm run dist:linux` | Passed; AppImage and deb built without publishing |
| AppImage launch verification | Passed |
| `scripts/e2e.sh` on x86_64 Docker | Passed: 50 tests, one optional catalog audit skipped; not native ARM64 hardware proof |
| Publish dry runs for v0.4.47 and v0.4.48 | Passed, including all ten installer digests and SHA256SUMS; no mutation |
| Native PC host build and required-native tests | Blocked/failed: GStreamer/GLib development packages absent; required native adapter unavailable |
The workflows name system GStreamer packages but do not pin package versions.
They were left uninstalled under the owner's pinned-versions-only constraint.
Resolve that dependency policy before claiming full Linux native readiness.
Main was clean and matched origin/main on the final 21:59 UTC fetch.
## How a takeover should resume
1. Read this file, `docs/testing.md`, the current roadmap/evidence and the
existing coordinator's recorded work through supported read-only tooling.
Verify remote releases, PR states and workflow conclusions again.
2. Check lxso2 health and disk headroom. Disk-full recovery requires capacity
decisions; do not delete protected data or alter T3 state databases.
3. Confirm that the existing coordinator is no longer active and that the
takeover is authorized by the current instructions. Preserve uncommitted
changes and active worktrees; do not run competing coordinator/release jobs.
4. Fetch origin and inspect local status before updating main. Resume the
roadmap from recorded gaps and existing PRs, not from assumptions about
what an interrupted worker completed.
5. Work on an isolated branch and use PRs. Validate the actual candidate
commit with every release gate above; record hardware/platform blockers.
6. Publish only after all required gates pass under Alex's current full-auto
instruction or a newer explicit instruction.
The lxso1 five-minute `fleet-t3-lxso2-health.timer` only checks and alerts; it
does not start takeover threads. Automatic stand-in launch is not configured
or proven by this recovery deployment. It would need supported gateway
capability and authorized credentials; do not copy or mint gateway secrets.
The original handoff copy was placed at
`/home/saphid/frame-control-handoff/STANDING_GOAL.md` on lxso1. This repository
document may be newer.
+2
View File
@@ -59,6 +59,8 @@ count. So a build reaches people only when you publish it, after testing it.
installer digests. It attaches
`update.json` (the version, the notes, the release page and each
installer's digest), then publishes the release and marks it latest. It
refuses an already published release before replacing its live update
manifest; use a new tested release for changes after publication. It
uses only the REST API: `gh release view` can't find a draft whose
`tag_name` still reads `untagged-…`, and GraphQL is often rate-limited.
Such a draft is found by its exact title (`Frame Control 0.4.0`, or that
+23 -4
View File
@@ -7,8 +7,11 @@ ios/scripts/make_frame_bundle.py runs this; app/build/fetch-deps.js does the sam
in Node. Usage: python3 frame/kdeconnect/fetch.py
"""
import hashlib
import http.client
import json
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
@@ -24,6 +27,25 @@ def sha256(data):
return hashlib.sha256(data).hexdigest()
def download_verified(url, expected_sha256):
for attempt in range(3):
try:
with urllib.request.urlopen(url, timeout=120) as response:
data = response.read()
except (OSError, http.client.HTTPException) as error:
# GitHub release downloads occasionally close during connection or
# transfer. Retry those failures, but fail fast on permanent HTTP errors.
if isinstance(error, urllib.error.HTTPError) and error.code not in (408, 429) and error.code < 500:
raise
if attempt == 2:
raise
time.sleep(attempt + 1)
continue
if sha256(data) != expected_sha256:
raise SystemExit(f"checksum mismatch for {url}: {sha256(data)}")
return data
def fetch():
m = manifest()
PACKAGES.mkdir(exist_ok=True)
@@ -32,10 +54,7 @@ def fetch():
if path.is_file() and sha256(path.read_bytes()) == p["sha256"]:
continue
url = m["release"] + p["file"]
with urllib.request.urlopen(url, timeout=120) as r:
data = r.read()
if sha256(data) != p["sha256"]:
raise SystemExit(f"checksum mismatch for {url}: {sha256(data)}")
data = download_verified(url, p["sha256"])
tmp = path.with_suffix(".part")
tmp.write_bytes(data)
tmp.replace(path)
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""Rebuild the pinned LocalSend-Go helper (Go 1.25.7 and module access required)."""
import argparse
import gzip
import hashlib
import io
import json
import os
from pathlib import Path
import subprocess
import tarfile
import tempfile
ROOT = Path(__file__).resolve().parents[1]
ASSETS = ROOT / 'ui/vendor/localsend-0.4.5'
SOURCE_SHA256 = 'f1f86292624b989d540689dbfad503bf15f3937d156978157b8dd418d54415d1'
EXPECTED = {
'amd64': 'd448d03861d578685e6c70ea3ace25eb34fca99cb9d4f98bf51ba23aa5cae843',
'arm64': 'e20363237e606ee65eae857792176d21a0bb8ba863489952728eaf2b13277dae',
}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--go', default='go', help='Path to Go 1.25.7')
parser.add_argument('--output', type=Path, default=ASSETS)
args = parser.parse_args()
assert subprocess.check_output([args.go, 'version'], text=True).split()[2] == 'go1.25.7', 'Use Go 1.25.7'
source_data = (ASSETS/'source.tar.gz').read_bytes()
assert hashlib.sha256(source_data).hexdigest() == SOURCE_SHA256, 'Pinned source archive changed'
args.output.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix='framey-localsend-build-') as temporary:
base = Path(temporary)
with tarfile.open(fileobj=io.BytesIO(source_data), mode='r:gz') as archive:
members = archive.getmembers()
assert all((m.isfile() or m.isdir()) and
(m.name == 'source' or m.name.startswith('source/'))
and '..' not in Path(m.name).parts for m in members), 'Unsafe source archive'
archive.extractall(base, filter='data')
env = dict(os.environ, CGO_ENABLED='0', GOOS='linux', GOTOOLCHAIN='local',
GOCACHE=str(base/'cache'), GOMODCACHE=str(base/'modules'))
for arch in ('amd64', 'arm64'):
output = base/'localsend-core'
subprocess.run([args.go, 'build', '-mod=readonly', '-trimpath', '-buildvcs=false',
'-ldflags=-s -w', '-o', str(output), '.'], cwd=base/'source',
env=dict(env, GOARCH=arch), check=True)
data = output.read_bytes()
digest = hashlib.sha256(data).hexdigest()
assert digest == EXPECTED[arch], f'{arch} helper does not match pinned bytes'
if arch == 'arm64':
packed = gzip.compress(data, mtime=0)
(args.output/'aarch64.gz').write_bytes(packed)
packed_digest = hashlib.sha256(packed).hexdigest()
else:
packed_digest = None
print(json.dumps({'arch': arch, 'sha256': digest, 'gzipSha256': packed_digest,
'bytes': len(data)}), flush=True)
if __name__ == '__main__':
main()
+12 -3
View File
@@ -41,6 +41,9 @@ def render(data):
'```', '',
'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.', '',
'A [2026-10-28 static action-limit review](evidence/framey-static-action-limits-2026-10-28.md) hash-checked the exact AutoSuspend and CheatDeck archives. AutoSuspend contains Steam suspend/shutdown paths, and CheatDeck\'s advertised trainer flow needs a Steam game context menu and launch-option writes. Neither plugin was executed; both retain their broken labels.', '',
'A [Network Sentinel user-service check](evidence/framey-network-sentinel-2026-10-28.md) adapts only the exact 1.0.4 digest. In isolated actual Decky 3.3.0 amd64, the pinned plugin-local psutil wheel, private settings RPC and one monitoring-loop sample pass. External ping/DNS, Frame ARM64, panel controls and worn VR remain unverified; this is partial compatibility.', '',
'A [Battery Tracker user-service check](evidence/framey-battery-tracker-2026-10-28.md) adapts only the exact 0.2.0 digest. A locked read-only Frame probe confirmed a Battery-type supply with the required fields; actual Decky 3.3.0 amd64 recorded private Frame-shaped samples and returned history/per-app power. The managed plugin has not run on the real Frame, and panel controls and worn VR remain unverified; this is partial compatibility.', '',
'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. '
@@ -123,15 +126,21 @@ def render(data):
p['backend']+('; '+', '.join(native) if native else ''),p['hardware']]))+' |')
lines += ['', '## Native payload portability', '',
'All published Linux native payloads in this snapshot are x86_64; no published aarch64 payload was found. '
'The three Windows DLLs belong to Decky-Framegen. Except for the exact managed DeckyFileServer adaptation below, the installer still rejects these archives. '
'The three Windows DLLs belong to Decky-Framegen. Except for the exact managed DeckyFileServer and Decky Localsend adaptations below, the installer still rejects these archives. '
'Apart from LocalSend and DeckyFileServer, ARM64 builds were not attempted: the constraints below remain unresolved.', '',
'A [DeckyFileServer ARM64 follow-up](evidence/framey-fileserver-arm64-2026-10-27.md) ships an offline, digest-checked GPL helper build, corresponding vendored source and a build recipe. '
'Only its exact managed archive can replace the x86 helper and remove its unnecessary root flag. Actual Decky 3.3.0 discovery/RPC and real Frame HTTPS private-fixture listing/download pass; uploads stay disabled. '
'Hostname-DNS failures and empty optional arguments are repaired. Decky 3.3.0 still exposes `decky.HOME`; that call is unchanged. '
'The plugin moves from broken to partial: its frontend advertises HTTP/steamdeck URLs and needs a Steam folder picker. Use HTTPS and a preconfigured directory. Uploads, generated thumbnails and worn VR remain unverified.', '',
'[LocalSend ARM64 build/runtime evidence](evidence/framey-localsend-arm64-2026-10-26.md) '
'covers a reproducible MIT helper build and its isolated real-Frame protocol 2.0 info response. '
'The official x86 archive remains blocked; full plugin integration and file transfers are unverified.', '',
'covers an isolated real-Frame protocol 2.0 info response from an earlier helper build. '
'[Exact official source matching](evidence/framey-localsend-source-match-2026-10-27.md) '
'now identifies the official helper source and a reproducible ARM64 build from that revision. '
'The exact official x86 backend passed one local private-namespace transfer, and the rebuilt ARM64 helper passed one locked real-Frame private marker transfer. '
'Published Decky 3.3.0 AMD64/ARM64 runtime checks discovered the adapted plugin and read its stopped status and configuration. '
'Only the exact managed 0.4.5 archive can replace its x86 helper on Frame. '
'The actual managed Decky service on Frame, user-facing transfer, local discovery and worn VR remain unverified '
'([managed adaptation evidence](evidence/framey-localsend-managed-2026-10-27.md)).', '',
'| Plugin | Native payloads | ARM64 result / remaining blocker |', '| --- | --- | --- |']
for p in data['plugins']:
if p['native']:
+1 -1
View File
@@ -56,7 +56,7 @@ if len(hits) != 1:
sys.exit("found %s for %s; expected one draft" % (why, tag))
r = hits[0]
if not r.get("draft"):
print("note: %s is already published; refreshing update.json and marking it latest" % tag, file=sys.stderr)
sys.exit("not publishing: %s is already published; leave its live update manifest intact" % tag)
json.dump(r, sys.stdout)
EOF
id=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$tmp/release.json")
+15
View File
@@ -1,4 +1,14 @@
[
{
"id": 67,
"shim": "battery-tracker-user-supply",
"probe": "battery-tracker"
},
{
"id": 114,
"shim": "network-sentinel-user-psutil",
"probe": "network-sentinel"
},
{
"id": 13,
"shim": null,
@@ -33,5 +43,10 @@
"id": 60,
"shim": "deckyfileserver-user-native",
"probe": "fileserver"
},
{
"id": 125,
"shim": "localsend-digest-native",
"probe": "localsend"
}
]
+55 -2
View File
@@ -85,7 +85,7 @@ def main():
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)
capture_output=True, text=True, env=env, timeout=30)
assert install.returncode == 0, install.stderr
plugin = base/'plugins'/record['folder']
receipt = json.loads((plugin/'.frame-control-store.json').read_text())
@@ -119,6 +119,13 @@ def main():
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')
if case['probe'] == 'battery-tracker':
supply = home/'battery-supply'/'max1720x_bat_7-36'
supply.mkdir(parents=True)
for name, value in {'type':'Battery', 'capacity':'89', 'status':'Charging',
'voltage_now':'8746718', 'current_now':'1030625'}.items():
(supply/name).write_text(value+'\n')
env['FRAMEY_BATTERY_ROOT'] = str(supply.parent)
processes = []
report = {'id': record['id'], 'name': record['name'], 'version': record['version'],
'sha256': record['sha256'], 'shim': case['shim'], 'calls': []}
@@ -152,7 +159,53 @@ def main():
report['calls'].append({'method': method, 'result': response})
return response
if case['probe'] == 'fileserver':
if case['probe'] == 'battery-tracker':
empty_or_first = legacy('get_recent_data', lookback=2)
assert isinstance(empty_or_first['x'], list) and isinstance(empty_or_first['cap'], list)
assert isinstance(empty_or_first['power_data'], list)
initial = until_ready(lambda: legacy('get_recent_data', lookback=2)['cap'], 'private first battery sample')
assert initial[-1] == .89, initial
assert legacy('set_app', app='Framey private') is True
(supply/'status').write_text('Discharging\n')
sample = until_ready(lambda: next((row for row in legacy('get_recent_data', lookback=2)['power_data']
if row['name']=='Framey private'), None), 'private discharging battery sample')
assert sample['average_power'] > 0, sample
history = legacy('get_recent_data', lookback=2)
assert len(history['x']) == len(history['cap']) >= 2, history
report['result'] = 'discovery, Frame-shaped private battery samples, history and per-app power passed; real managed Frame backend unverified'
elif case['probe'] == 'network-sentinel':
assert current('get_settings')['auto_monitor'] is False
assert current('get_network_history') == []
assert current('get_live_ping') == 0
assert current('get_bandwidth_stats') == {'download_bps': 0, 'upload_bps': 0}
assert current('update_settings', {'show_bandwidth': False}) is True
assert current('get_settings')['show_bandwidth'] is False
connection = current('get_connection_info')
assert isinstance(connection.get('hostname', connection.get('error')), str), connection
status = current('get_network_status')
assert status['quality']['quality'] == 'disconnected', status
assert status['quality']['jitter'] == 0, status
assert current('start_monitoring') is True
history = until_ready(lambda: current('get_network_history'), 'isolated network sample')
assert 'bandwidth' in history[0] and 'quality' in history[0], history
assert current('stop_monitoring') is True
report['result'] = 'discovery, native psutil, private settings and isolated monitoring loop passed; external latency/DNS and live Frame unverified'
elif case['probe'] == 'localsend':
helper = plugin/'bin/localsend-core'
expected = ('e20363237e606ee65eae857792176d21a0bb8ba863489952728eaf2b13277dae'
if __import__('platform').machine() == 'aarch64'
else 'd448d03861d578685e6c70ea3ace25eb34fca99cb9d4f98bf51ba23aa5cae843')
assert hashlib.sha256(helper.read_bytes()).hexdigest() == expected
status = current('get_backend_status')
assert status == {'running': False, 'url': 'https://127.0.0.1:53317'}, status
settings = current('get_backend_config')
assert settings['use_https'] and not settings['auto_save'], settings
assert settings['network_interface'] == '*', settings
assert settings['download_folder'].startswith(str(base)), settings
stopped = current('proxy_get', '/api/localsend/v2/info')
assert stopped['status'] == 503 and stopped['data']['error'] == 'Backend not running', stopped
report['result'] = 'published Decky discovery and public read-only settings/status RPC passed; network helper remained stopped'
elif case['probe'] == 'fileserver':
assert until_ready(lambda: current('get_port') == 8000, 'default port')
assert current('get_directory') == str(home)
assert current('get_server_running') is False
+18
View File
@@ -210,6 +210,13 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
arch = ssh('uname -m').strip()
self.assertEqual(ssh(f'sha256sum {installed}/bin/backend').split()[0],
frame_decky_store.FILESERVER_BINARIES[arch])
if plugin['id'] == 125:
receipt = json.loads(ssh(f'cat {installed}/.frame-control-store.json'))
self.assertEqual(receipt['compatShims'], ['localsend-digest-native'])
arch = ssh('uname -m').strip()
self.assertEqual(ssh(f'sha256sum {installed}/bin/localsend-core').split()[0],
frame_decky_store.LOCALSEND_AARCH64 if arch == 'aarch64'
else frame_decky_store.LOCALSEND_ORIGINAL)
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'))
@@ -218,6 +225,17 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
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'] == 67:
receipt = json.loads(ssh(f'cat {installed}/.frame-control-store.json'))
self.assertEqual(receipt['compatShims'], ['battery-tracker-user-supply'])
self.assertEqual(found[0]['compatibility']['status'], 'partial')
self.assertEqual(json.loads(ssh(f'cat {installed}/plugin.json'))['flags'], [])
if plugin['id'] == 114:
receipt = json.loads(ssh(f'cat {installed}/.frame-control-store.json'))
self.assertEqual(receipt['compatShims'], ['network-sentinel-user-psutil'])
self.assertEqual(found[0]['compatibility']['status'], 'partial')
self.assertEqual(json.loads(ssh(f'cat {installed}/plugin.json'))['flags'], ['debug'])
self.assertEqual(ssh(f'test -f {installed}/py_modules/psutil/_psutil_linux.abi3.so && 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'])
Binary file not shown.
Binary file not shown.
Binary file not shown.
+18 -1
View File
@@ -2,7 +2,8 @@
These are unmodified ZIPs downloaded from the official Decky store CDN on
2026-10-25, except `96.zip` and `111.zip` downloaded on 2026-10-26 and `60.zip` on
2026-10-27; `13.zip` was added from the digest-verified audit cache on 2026-10-27.
2026-10-27; `13.zip` and `125.zip` were added from the digest-verified audit cache on 2026-10-27,
and `67.zip` and `114.zip` on 2026-10-28.
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.
@@ -11,6 +12,9 @@ from a local fixture so CI does not depend on the store being online.
| --- | --- | --- | --- |
| `13.zip` | Pause Games | 1.0.3 | `4aefdfb0ef25dea9c6d9fd037731c8b99ee8a1be966c2f7d5b0602112ef29f62` |
| `60.zip` | DeckyFileServer | 1.2.0 | `7dc2ecdcd05aa415d1ac90afaff28ecb17f77809a2bc7a77aee8e92505b976f0` |
| `125.zip` | Decky Localsend | 0.4.5 | `e271ccb55eb23f918b99a69f79aec905df4c9b2ef7b23bbf4ec1c2d1a6f0c6b7` |
| `114.zip` | Network Sentinel | 1.0.4 | `d3e07f40a0c800e72d15594e03c8c61e9bf3092524a578a0260520e735b971a5` |
| `67.zip` | Battery Tracker | 0.2.0 | `91c388d3c1b52079314045b79671ad55c767510658b6193f6936ed6cad383b94` |
| `96.zip` | Simple Timer | 1.0.0 | `973d927e9d9a1feec04679c564af7e389a729b6735d2a988ffc2cb46ce4eb312` |
| `7.zip` | CSS Loader | 2.1.3 | `99beaf1374af82408e6d056e0b0147e5fca544e1035fa7ab88419231697bc231` |
| `12.zip` | MusicControl | 1.1.6 | `246b89fd653c60a735b1cc401d1b0937d5cb969eea9024561661ff741f081d62` |
@@ -56,8 +60,21 @@ Pause Games runs through actual published Decky 3.3.0 discovery and instance RPC
for private settings and a synthetic exclusion list. No process-control method
is called. The official frontend's standalone Toggle component separately
checks saved true/false state and matching settings callbacks; effects are inert.
Network Sentinel runs through the same isolated native Decky gate. Its exact
archive gets plugin-local psutil and a user-service manifest; the probe checks
discovery, private settings and one monitoring-loop sample without external
network access. It does not verify real network latency or worn VR.
Battery Tracker's exact archive also has a user-service adaptation. The native
Decky gate checks discovery, empty history, Frame-shaped private battery
samples and per-app power aggregation. The real Frame probe checked only the
readable Battery-type sysfs fields; the plugin itself did not run there.
DeckyFileServer also runs through actual Decky 3.3.0 discovery/API1 RPC with a
native helper replacement. HTTPS listing/download use only public markers in a
private directory; uploads remain disabled. The exact source build, GPL licenses
and reproducible recipe are in [the native assets](../../../ui/vendor/deckyfileserver-1.2.0/README.md).
The original empty optional-argument bug is reproduced by a negative runtime case.
Decky Localsend's exact original x86 backend/proxy passed an isolated private
marker transfer outside Decky. The native Decky gate checks discovery and
read-only backend status/configuration with its network helper stopped. The
managed ARM64 helper and transfer remain separately gated; the fixture does
not prove worn VR or a transfer on Frame.
+6
View File
@@ -4,10 +4,16 @@ from pathlib import Path
ROOT = Path(__file__).parent / 'fixtures/framey-official'
PLUGINS = (
{'id': 67, 'name': 'Battery Tracker', 'version': '0.2.0', 'api_version': 'legacy',
'folder': 'steam-deck-battery-tracker', 'sha256': '91c388d3c1b52079314045b79671ad55c767510658b6193f6936ed6cad383b94'},
{'id': 114, 'name': 'Network Sentinel', 'version': '1.0.4', 'api_version': 1,
'folder': 'network-sentinel', 'sha256': 'd3e07f40a0c800e72d15594e03c8c61e9bf3092524a578a0260520e735b971a5'},
{'id': 13, 'name': 'Pause Games', 'version': '1.0.3', 'api_version': 1,
'folder': 'SDH-PauseGames', 'sha256': '4aefdfb0ef25dea9c6d9fd037731c8b99ee8a1be966c2f7d5b0602112ef29f62'},
{'id': 60, 'name': 'DeckyFileServer', 'version': '1.2.0', 'api_version': 1,
'folder': 'DeckyFileServer', 'sha256': '7dc2ecdcd05aa415d1ac90afaff28ecb17f77809a2bc7a77aee8e92505b976f0'},
{'id': 125, 'name': 'Decky Localsend', 'version': '0.4.5', 'api_version': 1,
'folder': 'decky-localsend', 'sha256': 'e271ccb55eb23f918b99a69f79aec905df4c9b2ef7b23bbf4ec1c2d1a6f0c6b7'},
{'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,
+33
View File
@@ -0,0 +1,33 @@
// Exercise the real page functions for desktop direct copy and browser size guard.
import { readFileSync } from "fs";
import assert from "node:assert/strict";
const src = readFileSync(new URL("../../ui/index.html", import.meta.url), "utf8");
const lines = src.split("\n");
function grab(name) {
const start = lines.findIndex(line => line.startsWith(`function ${name}(`));
assert.notEqual(start, -1, `${name} exists`);
let depth = 0;
for (let i = start; i < lines.length; i++) {
for (const c of lines[i]) depth += c === "{" ? 1 : c === "}" ? -1 : 0;
if (depth === 0) return lines.slice(start, i + 1).join("\n");
}
throw new Error(`${name} is unbalanced`);
}
const calls = [];
const copy = new Function("api", "upload", grab("copyToDownloads") + "\nreturn copyToDownloads;")(
async (...args) => { calls.push(["api", ...args]); return { message: "sent" }; },
async (...args) => { calls.push(["upload", ...args]); return { message: "uploaded" }; });
const large = { name: "usb-file.bin", size: 27 * 1024 ** 3 };
assert.deepEqual(await copy(large, "/media/usb/usb-file.bin", "frame-a"), { message: "sent" });
assert.deepEqual(calls.pop(), ["api", "/api/transfer", { action: "copy-local", path: "/media/usb/usb-file.bin" }, "frame-a"]);
assert.deepEqual(await copy({ name: "small.bin", size: 100 }, "", "frame-a"), { message: "uploaded" });
assert.deepEqual(calls.pop(), ["upload", { name: "small.bin", size: 100 }, "push", "frame-a"]);
let opened = false;
const browserUpload = new Function("XMLHttpRequest", grab("upload") + "\nreturn upload;")(
class { constructor() { opened = true; } });
await assert.rejects(browserUpload(large, "push", "frame-a"), /limited to 8 GiB/);
assert.equal(opened, false, "oversized browser file is refused before creating a request");
console.log("ok");
+8 -2
View File
@@ -6,8 +6,9 @@ BUILD_ID in tests/smoke/results/<time>-<BUILD_ID>.json (git-ignored):
Before any status read or title change, it atomically owns /tmp/frame-test.lock
on the Frame and checks SteamVR standby, proximity and battery read-only. A
busy lock, awake headset or battery below 20% stops the run. Only this run's
lock is released, including after test failures.
busy lock, awake headset, missing or active-range proximity reading, or battery
below 20% stops the run. Only this run's lock is released, including after
test failures.
- properties.json from Valve's devkit service names the login user;
- status (ui/frame_status.py) reads the build, battery and storage;
@@ -107,6 +108,11 @@ def preflight():
state = json.loads(result.stdout)
if state.get('activity') != 3:
raise RuntimeError('Frame is awake or SteamVR activity is unknown; active session untouched')
proximity = state.get('proximity')
if (not isinstance(proximity, list) or not proximity or
any(not isinstance(value, (int, float)) or isinstance(value, bool) or
value < 0 or value > 10 for value in proximity)):
raise RuntimeError('Frame proximity is active or unknown; active session untouched')
battery = state.get('battery')
if not battery or min(battery) < 20:
raise RuntimeError('Frame battery is unavailable or below the smoke threshold')
+186
View File
@@ -1,14 +1,18 @@
"""Official Decky store transport and owned plugin transaction."""
import sandbox # noqa: F401
import hashlib
import asyncio
import importlib.util
import io
import json
import logging
import os
from pathlib import Path
import secrets
import subprocess
import sys
import tempfile
import types
import unittest
from unittest.mock import patch
import warnings
@@ -74,6 +78,24 @@ class ArchiveTests(unittest.TestCase):
with patch.object(frame_decky_store.urllib.request, 'urlopen', return_value=io.BytesIO(json.dumps(records).encode())):
self.assertTrue(frame_decky_store.catalog()[0]['root'])
def test_only_exact_network_sentinel_adaptation_clears_store_root(self):
records = [{'id': 114, 'name': 'Network Sentinel', 'tags': ['_root'],
'versions': [{'name': '1.0.4', 'hash': frame_decky_store.NETWORK_SENTINEL_DIGEST}]}]
with patch.object(frame_decky_store.urllib.request, 'urlopen', return_value=io.BytesIO(json.dumps(records).encode())):
self.assertFalse(frame_decky_store.catalog()[0]['root'])
records[0]['versions'][0]['hash'] = 'a'*64
with patch.object(frame_decky_store.urllib.request, 'urlopen', return_value=io.BytesIO(json.dumps(records).encode())):
self.assertTrue(frame_decky_store.catalog()[0]['root'])
def test_only_exact_battery_tracker_adaptation_clears_store_root(self):
records = [{'id': 67, 'name': 'Battery Tracker', 'tags': ['_root'],
'versions': [{'name': '0.2.0', 'hash': frame_decky_store.BATTERY_TRACKER_DIGEST}]}]
with patch.object(frame_decky_store.urllib.request, 'urlopen', return_value=io.BytesIO(json.dumps(records).encode())):
self.assertFalse(frame_decky_store.catalog()[0]['root'])
records[0]['versions'][0]['hash'] = 'a'*64
with patch.object(frame_decky_store.urllib.request, 'urlopen', return_value=io.BytesIO(json.dumps(records).encode())):
self.assertTrue(frame_decky_store.catalog()[0]['root'])
def test_accepts_hashed_non_root_archive(self):
data, digest = archive()
self.assertEqual(frame_decky_store.archive_info(data, digest, 'Example'), 'Example')
@@ -212,6 +234,170 @@ class StoreTransaction(unittest.TestCase):
self.assertEqual(helper.stat().st_mode & 0o777, 0o700)
compile((plugin/'main.py').read_text(), str(plugin/'main.py'), 'exec')
self.assertIn('if arg]', (plugin/'main.py').read_text())
@unittest.skipUnless(__import__('platform').machine() in ('x86_64', 'aarch64'), 'native Linux helper architecture')
def test_exact_localsend_gets_verified_native_helper_and_preserves_source(self):
from framey_official import PLUGINS, archive as official_archive
import platform
plugin = next(p for p in PLUGINS if p['id'] == 125)
data = official_archive(plugin)
self.assertEqual(frame_decky_store.archive_info(data, plugin['sha256'], plugin['name']), plugin['folder'])
result = self.apply('install', data, plugin_id=125, 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'], ['localsend-digest-native'])
self.assertEqual(json.loads((folder/'plugin.json').read_text())['flags'], [])
expected = (frame_decky_store.LOCALSEND_AARCH64 if platform.machine() == 'aarch64'
else frame_decky_store.LOCALSEND_ORIGINAL)
self.assertEqual(hashlib.sha256((folder/'bin/localsend-core').read_bytes()).hexdigest(), expected)
self.assertEqual((folder/'bin/localsend-core').stat().st_mode & 0o777, 0o700)
with zipfile.ZipFile(io.BytesIO(data)) as source:
for name in ('main.py', 'LICENSE'):
self.assertEqual((folder/name).read_bytes(), source.read(plugin['folder']+'/'+name))
result = self.apply('remove', plugin_id=125, folder=plugin['folder'])
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse(folder.exists())
def test_localsend_exception_does_not_accept_another_archive_or_corrupt_helper(self):
from framey_official import PLUGINS, archive as official_archive
plugin = next(p for p in PLUGINS if p['id'] == 125)
with zipfile.ZipFile(io.BytesIO(official_archive(plugin))) as source:
stream = io.BytesIO()
with zipfile.ZipFile(stream, 'w', zipfile.ZIP_DEFLATED) as target:
for entry in source.infolist():
target.writestr(entry, source.read(entry))
target.writestr(plugin['folder']+'/changed.txt', b'new version')
changed = stream.getvalue()
with self.assertRaisesRegex(ValueError, 'non-ARM64'):
frame_decky_store.archive_info(changed, hashlib.sha256(changed).hexdigest(), plugin['name'])
self.assertEqual(frame_decky_store.apply_script(125, '0'*64), frame_decky_store.APPLY)
with patch.object(frame_decky_store, 'LOCALSEND_AARCH64', '0'*64):
with self.assertRaisesRegex(ValueError, 'SHA-256 mismatch'):
frame_decky_store.apply_script(125, frame_decky_store.LOCALSEND_DIGEST)
def test_localsend_arm64_transaction_checks_written_helper(self):
from framey_official import PLUGINS, archive as official_archive
plugin = next(p for p in PLUGINS if p['id'] == 125)
# The local x86 host only writes the ELF; native ARM execution is gated
# separately by the published Decky runtime runner.
with patch.object(frame_decky_store, 'APPLY',
"import platform\nplatform.machine=lambda:'aarch64'\n"+frame_decky_store.APPLY):
result = self.apply('install', official_archive(plugin), plugin_id=125,
folder=plugin['folder'], version=plugin['version'])
self.assertEqual(result.returncode, 0, result.stderr)
helper = self.folder.parent/plugin['folder']/'bin/localsend-core'
self.assertEqual(hashlib.sha256(helper.read_bytes()).hexdigest(),
frame_decky_store.LOCALSEND_AARCH64)
self.assertEqual(int.from_bytes(helper.read_bytes()[18:20], 'little'), 183)
@unittest.skipUnless(__import__('platform').machine() in ('x86_64', 'aarch64'), 'native Linux wheel architecture')
def test_exact_battery_tracker_uses_user_battery_supply_and_preserves_license(self):
from framey_official import PLUGINS, archive as official_archive
record = next(p for p in PLUGINS if p['id'] == 67)
data = official_archive(record)
self.assertEqual(frame_decky_store.archive_info(data, record['sha256'], record['name']), record['folder'])
result = self.apply('install', data, plugin_id=67,
folder=record['folder'], version=record['version'])
self.assertEqual(result.returncode, 0, result.stderr)
folder = self.folder.parent / record['folder']
receipt = json.loads((folder / '.frame-control-store.json').read_text())
self.assertEqual(receipt['compatShims'], ['battery-tracker-user-supply'])
self.assertEqual(json.loads((folder / 'plugin.json').read_text())['flags'], [])
source = (folder / 'main.py').read_text()
self.assertNotIn('/sys/class/power_supply/BAT1/', source)
self.assertIn("root / 'BAT1'", source)
compile(source, str(folder / 'main.py'), 'exec')
with zipfile.ZipFile(io.BytesIO(data)) as original:
self.assertEqual((folder / 'LICENSE').read_bytes(), original.read(record['folder']+'/LICENSE'))
self.assertEqual(self.apply('remove', plugin_id=67, folder=record['folder']).returncode, 0)
self.assertFalse(folder.exists())
changed = io.BytesIO(data)
with zipfile.ZipFile(changed, 'a') as archive:
archive.writestr(record['folder']+'/changed.txt', 'changed')
altered = changed.getvalue()
with self.assertRaisesRegex(ValueError, 'root'):
frame_decky_store.archive_info(altered, hashlib.sha256(altered).hexdigest(), record['name'])
result = self.apply('install', altered, plugin_id=67,
folder=record['folder'], version=record['version'])
self.assertNotEqual(result.returncode, 0)
self.assertFalse(folder.exists())
def test_adapted_battery_tracker_empty_history_and_unload_flush(self):
from framey_official import PLUGINS, archive as official_archive
record = next(p for p in PLUGINS if p['id'] == 67)
result = self.apply('install', official_archive(record), plugin_id=67,
folder=record['folder'], version=record['version'])
self.assertEqual(result.returncode, 0, result.stderr)
folder = self.folder.parent / record['folder']
runtime = Path(self.tmp.name) / 'battery-runtime'
runtime.mkdir()
supply = Path(self.tmp.name) / 'battery-supply' / 'frame-battery'
supply.mkdir(parents=True)
for name, value in {'type': 'Battery', 'capacity': '89', 'status': 'Charging',
'voltage_now': '8746718', 'current_now': '1030625'}.items():
(supply / name).write_text(value + '\n')
decky = types.ModuleType('decky_plugin')
decky.logger = logging.getLogger('battery-tracker-test')
decky.DECKY_PLUGIN_RUNTIME_DIR = str(runtime)
with patch.dict(sys.modules, {'decky_plugin': decky}), patch.dict(os.environ, {'FRAMEY_BATTERY_ROOT': str(supply.parent)}):
spec = importlib.util.spec_from_file_location('framey_battery_tracker_test', folder / 'main.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
async def exercise():
plugin = module.Plugin()
await plugin._main()
self.assertEqual(await plugin.get_recent_data(), {'x': [], 'cap': [], 'power_data': []})
# The first sample is still pending when Decky unloads the plugin.
await asyncio.sleep(0)
self.assertEqual(len(plugin._pending_samples), 1)
self.assertEqual((await plugin.get_recent_data())['cap'], [.89])
await plugin._unload()
self.assertEqual(plugin._pending_samples, [])
asyncio.run(exercise())
import sqlite3
with sqlite3.connect(runtime / 'battery.db') as db:
self.assertEqual(db.execute('select capacity from battery').fetchall(), [(89,)])
@unittest.skipUnless(__import__('platform').machine() in ('x86_64', 'aarch64'), 'native Linux wheel architecture')
def test_exact_network_sentinel_is_user_scoped_and_digest_guarded(self):
from framey_official import PLUGINS, archive as official_archive
import platform
record = next(p for p in PLUGINS if p['id'] == 114)
data = official_archive(record)
self.assertEqual(frame_decky_store.archive_info(data, record['sha256'], record['name']), record['folder'])
result = self.apply('install', data, plugin_id=114,
folder=record['folder'], version=record['version'])
self.assertEqual(result.returncode, 0, result.stderr)
folder = self.folder.parent / record['folder']
receipt = json.loads((folder / '.frame-control-store.json').read_text())
self.assertEqual(receipt['compatShims'], ['network-sentinel-user-psutil'])
self.assertEqual(json.loads((folder / 'plugin.json').read_text())['flags'], ['debug'])
source = (folder / 'main.py').read_text()
self.assertNotIn('decky.DECKY_SETTINGS_DIR', source)
self.assertIn('decky.DECKY_PLUGIN_SETTINGS_DIR', source)
self.assertIn(" jitter = 0\n cmd = ['ping'", source)
compile(source, str(folder / 'main.py'), 'exec')
native = (folder / 'py_modules/psutil/_psutil_linux.abi3.so').read_bytes()
self.assertEqual(int.from_bytes(native[18:20], 'little'),
{'x86_64': 62, 'aarch64': 183}[platform.machine()])
self.assertIn('BSD 3-Clause', (folder / 'py_modules/psutil-7.2.2.dist-info/LICENSE').read_text())
with zipfile.ZipFile(io.BytesIO(data)) as original:
self.assertEqual((folder / 'LICENSE').read_bytes(), original.read(record['folder']+'/LICENSE'))
self.assertEqual(self.apply('remove', plugin_id=114, folder=record['folder']).returncode, 0)
self.assertFalse(folder.exists())
changed = io.BytesIO(data)
with zipfile.ZipFile(changed, 'a') as archive:
archive.writestr(record['folder']+'/changed.txt', 'changed')
altered = changed.getvalue()
with self.assertRaisesRegex(ValueError, 'root'):
frame_decky_store.archive_info(altered, hashlib.sha256(altered).hexdigest(), record['name'])
result = self.apply('install', altered, plugin_id=114,
folder=record['folder'], version=record['version'])
self.assertNotEqual(result.returncode, 0)
self.assertFalse(folder.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
+2
View File
@@ -28,6 +28,8 @@ class HeadsetBundle(unittest.TestCase):
self.assertIn('ui/server.py', names)
for filename in ('aarch64.gz', 'x86_64.gz', 'source.tar.gz', 'LICENSE.txt', 'GO-LICENSE.txt', 'README.md'):
self.assertIn('ui/vendor/deckyfileserver-1.2.0/'+filename, names)
for filename in ('aarch64.gz', 'source.tar.gz', 'LICENSE.txt', 'GO-LICENSE.txt', 'README.md'):
self.assertIn('ui/vendor/localsend-0.4.5/'+filename, names)
self.assertIn('scripts/build-framey-fileserver.py', names)
for name in ('aarch64.whl', 'x86_64.whl', 'LICENSE.txt'):
self.assertIn('ui/vendor/psutil-7.2.2/'+name, names)
+2 -1
View File
@@ -20,7 +20,8 @@ class RuntimeCoverage(unittest.TestCase):
for case in cases:
self.assertIn(case['id'], official)
self.assertIn(case['probe'], ('mpris', 'screenshots', 'system-monitor', 'timer-lifecycle',
'toggle-settings', 'fileserver'))
'toggle-settings', 'fileserver', 'localsend', 'network-sentinel',
'battery-tracker'))
def test_new_shim_without_runtime_case_fails_coverage(self):
import frame_decky_store
+4 -4
View File
@@ -84,10 +84,10 @@ class OfficialBundles(unittest.TestCase):
folder = plugins / plugin['folder']
(folder / '.frame-control-store.json').write_text(json.dumps({
'storeId': plugin['id'], 'sha256': plugin['sha256']}))
if plugin['id'] == 60:
# This renderer fixture extracts the original ZIP. The
# real managed adaptation is covered by transaction and
# published Decky tests; an unadapted root copy must fail.
if plugin['id'] in (60, 67, 114):
# This renderer fixture extracts the original root ZIP.
# Transaction and Decky tests cover managed adaptation;
# an unadapted root copy must fail here.
with patch.object(framey_panel, 'PLUGINS', plugins), self.assertRaises(FileNotFoundError):
framey_panel.panel(plugin['id'])
manifest['flags'] = []
+53
View File
@@ -0,0 +1,53 @@
"""The iOS Frame bundle must recover transient pinned package downloads."""
import hashlib
import http.client
import importlib.util
from pathlib import Path
import unittest
from unittest.mock import patch
import urllib.error
import io
ROOT = Path(__file__).resolve().parent.parent
SPEC = importlib.util.spec_from_file_location('kdeconnect_fetch', ROOT / 'frame/kdeconnect/fetch.py')
fetch = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(fetch)
class VerifiedDownload(unittest.TestCase):
def test_remote_disconnect_retries_then_checks_hash(self):
data = b'pinned package'
digest = hashlib.sha256(data).hexdigest()
with patch.object(fetch.urllib.request, 'urlopen', side_effect=[
http.client.RemoteDisconnected('closed'), io.BytesIO(data)]) as open_url, \
patch.object(fetch.time, 'sleep') as sleep:
self.assertEqual(fetch.download_verified('https://example.invalid/package', digest), data)
self.assertEqual(open_url.call_count, 2)
sleep.assert_called_once_with(1)
def test_permanent_http_error_and_wrong_hash_fail_closed(self):
url = 'https://example.invalid/package'
missing = urllib.error.HTTPError(url, 404, 'missing', {}, None)
with patch.object(fetch.urllib.request, 'urlopen', side_effect=missing) as open_url, \
patch.object(fetch.time, 'sleep') as sleep:
with self.assertRaises(urllib.error.HTTPError):
fetch.download_verified(url, '0' * 64)
open_url.assert_called_once()
sleep.assert_not_called()
with patch.object(fetch.urllib.request, 'urlopen', return_value=io.BytesIO(b'changed')) as open_url:
with self.assertRaisesRegex(SystemExit, 'checksum mismatch'):
fetch.download_verified(url, '0' * 64)
open_url.assert_called_once()
def test_exhausted_transient_error_is_not_silenced(self):
with patch.object(fetch.urllib.request, 'urlopen', side_effect=http.client.RemoteDisconnected('closed')) as open_url, \
patch.object(fetch.time, 'sleep') as sleep:
with self.assertRaises(http.client.RemoteDisconnected):
fetch.download_verified('https://example.invalid/package', '0' * 64)
self.assertEqual(open_url.call_count, 3)
self.assertEqual(sleep.call_count, 2)
if __name__ == '__main__':
unittest.main()
+86
View File
@@ -0,0 +1,86 @@
"""Large desktop file copies use the selected local path, not an 8 GiB HTTP upload."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import http.client
import json
import os
import stat
import subprocess
import sys
import tempfile
import threading
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "ui"))
import server
class LargeTransfer(unittest.TestCase):
def test_desktop_http_copy_stays_on_selected_headset(self):
with tempfile.TemporaryDirectory() as tmp, server.LoopbackServer(("127.0.0.1", 0), server.Handler) as httpd:
source = Path(tmp) / "usb-file.bin"
source.write_bytes(b"a small stand-in for a large file")
worker = threading.Thread(target=httpd.serve_forever, daemon=True)
worker.start()
link = mock.Mock()
link.active_device.return_value = {"id": "frame-a"}
body = json.dumps({"action": "copy-local", "path": str(source)})
def copy(device):
conn = http.client.HTTPConnection("127.0.0.1", httpd.server_port, timeout=5)
try:
conn.request("POST", "/api/transfer", body, {"X-Frame-UI": server.UI_KEY,
"X-Frame-Device": device, "Content-Type": "application/json"})
response = conn.getresponse()
return response.status, json.loads(response.read())
finally:
conn.close()
try:
with mock.patch.dict(os.environ, {"FRAME_CONTROL_APP": "1"}), \
mock.patch.object(server, "LINK", link), \
mock.patch.object(server, "push_file", return_value="Sent usb-file.bin to ~/Downloads/") as push:
self.assertEqual(copy("frame-b")[0], 409)
push.assert_not_called()
self.assertEqual(copy("frame-a"), (200, {"message": "Sent usb-file.bin to ~/Downloads/"}))
push.assert_called_once_with(source)
finally:
httpd.shutdown()
worker.join(timeout=5)
def test_27_gib_local_file_reaches_direct_copy(self):
source = Path(os.path.abspath("large-file.bin"))
size = 27 * 1024 ** 3
info = SimpleNamespace(st_mode=stat.S_IFREG | 0o644, st_size=size)
with mock.patch.dict(os.environ, {"FRAME_CONTROL_APP": "1"}), \
mock.patch.object(Path, "stat", return_value=info), \
mock.patch.object(server, "push_file", return_value="Sent large-file.bin to ~/Downloads/") as push:
self.assertEqual(server.transfer_local({"action": "copy-local", "path": str(source)}),
{"message": "Sent large-file.bin to ~/Downloads/"})
push.assert_called_once_with(source)
def test_direct_copy_validates_desktop_file_before_ssh(self):
with tempfile.TemporaryDirectory() as tmp, \
mock.patch.dict(os.environ, {"FRAME_CONTROL_APP": "1"}), \
mock.patch.object(server, "push_file") as push:
empty = Path(tmp) / "empty.bin"
empty.touch()
hidden = Path(tmp) / ".hidden"
hidden.write_bytes(b"x")
for path in ("relative.bin", str(Path(tmp) / "missing.bin"), tmp,
str(empty), str(hidden)):
with self.subTest(path=path), self.assertRaises(server.Failure):
server.transfer_local({"action": "copy-local", "path": path})
push.assert_not_called()
def test_large_copy_gets_more_than_one_hour_without_loading_file(self):
info = SimpleNamespace(st_size=27 * 1024 ** 3)
result = subprocess.CompletedProcess([], 0, "", "")
with mock.patch.object(Path, "stat", return_value=info), \
mock.patch.object(server.shutil, "which", return_value=None), \
mock.patch.object(server.frame_host, "run_ssh", return_value=result) as run:
self.assertEqual(server.push_file("/tmp/large-file.bin"),
"Sent large-file.bin to ~/Downloads/")
self.assertGreater(run.call_args.kwargs["timeout"], 3600)
+6
View File
@@ -309,6 +309,12 @@ class Renderer(unittest.TestCase):
resources = {r['from']: r.get('filter', ['**/*']) for r in config['build']['extraResources']}
self.assertIn('*.js', resources['../ui'])
self.assertIn('*.js', resources['../frame/android'])
self.assertIn('*.gz', resources['../ui/vendor/localsend-0.4.5'])
self.assertIn('*.txt', resources['../ui/vendor/localsend-0.4.5'])
self.assertIn('build-framey-localsend.py', resources['../scripts'])
checker = (ROOT / 'app/build/check-resources.js').read_text()
for name in ('aarch64.gz', 'source.tar.gz', 'LICENSE.txt', 'GO-LICENSE.txt', 'README.md'):
self.assertIn(f'ui/vendor/localsend-0.4.5/{name}', checker)
class SteamGridDB(unittest.TestCase):
+10
View File
@@ -76,6 +76,16 @@ class PublishRelease(unittest.TestCase):
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
self.assertEqual(next(c for c in log if "PATCH" in c)[3], "repos/saphid/frame-control/releases/2")
def test_already_published_release_keeps_its_live_update_manifest(self):
old = {"id": 7, "name": "update.json", "size": 100,
"digest": "sha256:" + "a" * 64}
rel = dict(draft(tag_name="v9.8.7", assets=draft()["assets"] + [old]), draft=False)
p, log, upload = self.run_script([rel], "v9.8.7")
self.assertNotEqual(p.returncode, 0)
self.assertIn("already published", p.stderr)
self.assertIsNone(upload)
self.assertFalse(any("-X" in call for call in log))
def test_refuses_missing_or_unhashed_installers(self):
assets = draft()["assets"][1:]
assets[0] = dict(assets[0], digest=None)
+23
View File
@@ -22,6 +22,7 @@ WORKFLOW = ROOT / ".github" / "workflows" / "release.yml"
FAKEGH = ROOT / "tests" / "fakegh"
STEP = "- name: Create the draft release"
UPLOAD_STEP = "- name: Upload to the release"
OUTPUTS_STEP = "- name: Check installer outputs before upload"
def step_script(step=STEP):
@@ -116,6 +117,28 @@ class DraftStep(unittest.TestCase):
self.assertEqual(self.patches(log), [])
@unittest.skipUnless(os.name != "nt" and shutil.which("bash"),
"needs a POSIX bash; Actions uses Git Bash on Windows")
class InstallerOutputsStep(unittest.TestCase):
def run_step(self, files):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
for name in files:
(root / name).write_bytes(b"installer")
script = step_script(OUTPUTS_STEP).replace("${{ matrix.files }}", "*.dmg *.zip")
return subprocess.run(["bash", "--noprofile", "--norc", "-eo", "pipefail", "-c", script],
cwd=root, capture_output=True, text=True, timeout=10)
def test_requires_each_advertised_installer_type(self):
result = self.run_step(["Frame-Control-mac.dmg"])
self.assertNotEqual(result.returncode, 0)
self.assertIn("missing installer output matching *.zip", result.stderr)
def test_accepts_all_advertised_installer_types(self):
result = self.run_step(["Frame-Control-mac.dmg", "Frame-Control-mac.zip"])
self.assertEqual(result.returncode, 0, result.stderr)
@unittest.skipUnless(os.name != "nt" and shutil.which("bash") and shutil.which("jq"),
"needs a POSIX bash and jq; Actions runs this step under bash")
class UploadStep(unittest.TestCase):
+19 -1
View File
@@ -267,6 +267,17 @@ class ServerGuards(unittest.TestCase):
self.assertEqual(self.request("GET", "/api/titles/job?token=nope", headers={"X-Frame-UI": "1"})[0], 404)
self.assertEqual(self.request("POST", "/api/titles", {"action": "list"})[0], 403)
def test_direct_local_transfer_is_desktop_only(self):
status, payload = self.post("/api/transfer", {"action": "copy-local", "path": "/tmp/a.bin"})
self.assertEqual(status, 403, payload)
def test_oversized_browser_upload_rejected_without_reading_body(self):
status, _, payload = self.request("POST", "/api/upload", b"",
{"X-Frame-UI": "1", "X-Filename": "large.bin",
"Content-Length": str(27 * 1024 ** 3)})
self.assertEqual(status, 413, payload)
self.assertIn(b"limited to 8 GiB", payload)
def test_web_install_needs_the_app_page(self):
# A website can only open frame-control:// links; it can't call these itself.
link = {"url": "https://cdn.example.com/game.apk"}
@@ -497,7 +508,10 @@ class ArtworkSettings(unittest.TestCase):
time.sleep(0.05)
key = {"X-Frame-UI": "1", "Content-Type": "application/json"}
self.assertEqual(request("GET", "/api/settings/artwork")[0], 403)
self.assertEqual(request("POST", "/api/settings/artwork", {"steamgriddb_api_key": "abc"})[0], 403)
# The missing-key guard runs before reading a body. Leaving an
# unread body on Windows can reset this test connection before
# http.client receives the 403 response.
self.assertEqual(request("POST", "/api/settings/artwork")[0], 403)
status, payload = request("GET", "/api/settings/artwork", headers=key)
self.assertEqual((status, json.loads(payload)["steamgriddb_configured"]), (200, False))
status, payload = request("POST", "/api/settings/artwork", {"steamgriddb_api_key": "abc_1"}, key)
@@ -575,6 +589,10 @@ class PageScripts(unittest.TestCase):
r = subprocess.run(["node", str(ROOT / "tests" / "page" / "meta_rows.mjs")], capture_output=True, text=True)
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
def test_large_local_file_uses_direct_copy(self):
r = subprocess.run(["node", str(ROOT / "tests" / "page" / "large_transfer.mjs")], capture_output=True, text=True)
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
@unittest.skipIf(os.name == "nt", "runs on the Frame (Linux); local-bin/ssh is a POSIX shell script")
class LocalMode(unittest.TestCase):
+13
View File
@@ -95,6 +95,19 @@ class RealProcesses(unittest.TestCase):
class Safety(unittest.TestCase):
def test_locked_preflight_rejects_active_or_unknown_proximity_before_titles(self):
for proximity in ([], [88.0625], [4.6875, 88.0625], [None]):
with self.subTest(proximity=proximity), mock.patch.object(
frame_smoke, 'remote', return_value=mock.Mock(
returncode=0, stdout=json.dumps({
'activity': 3, 'proximity': proximity, 'battery': [72]}))):
with self.assertRaisesRegex(RuntimeError, 'proximity is active or unknown'):
frame_smoke.preflight()
with mock.patch.object(frame_smoke, 'remote', return_value=mock.Mock(
returncode=0, stdout=json.dumps({
'activity': 3, 'proximity': [4.6875], 'battery': [72]}))):
self.assertEqual(frame_smoke.preflight()['proximity'], [4.6875])
def test_missing_x86_runtime_skips_launch_but_still_removes_title(self):
smoke = frame_smoke.Smoke()
gid = 'fc_smoke_x86_64'
+198 -18
View File
@@ -33,6 +33,14 @@ FILESERVER_BINARIES = {
'aarch64': '86b80021ebd62ed619eba065757c1122defa5a74b2c045aa44a0c6b4403a508a',
}
FILESERVER_ORIGINAL = 'd9eab8559c87c01736188bc676f3b4b44dee47cf952b6e571c770d1ea61733e9'
LOCALSEND_DIGEST = 'e271ccb55eb23f918b99a69f79aec905df4c9b2ef7b23bbf4ec1c2d1a6f0c6b7'
LOCALSEND_ORIGINAL = 'd448d03861d578685e6c70ea3ace25eb34fca99cb9d4f98bf51ba23aa5cae843'
LOCALSEND_MAIN = 'db03f9201fe50b75dea99a7a05b54ec3f352d1bdfe174115b0e569154198d8ad'
LOCALSEND_AARCH64 = 'e20363237e606ee65eae857792176d21a0bb8ba863489952728eaf2b13277dae'
NETWORK_SENTINEL_DIGEST = 'd3e07f40a0c800e72d15594e03c8c61e9bf3092524a578a0260520e735b971a5'
NETWORK_SENTINEL_MAIN = '2baa390cb66dc267e8f04e61ba1edc43af75115ad5e453d308f6bf26ed70b6b9'
BATTERY_TRACKER_DIGEST = '91c388d3c1b52079314045b79671ad55c767510658b6193f6936ed6cad383b94'
BATTERY_TRACKER_MAIN = '2303ec70c262f1cbfe1c8c138770c6e7c3496827e748e679c94ca43ff9f28725'
VERSION = re.compile(r'[A-Za-z0-9][A-Za-z0-9._+\-]{0,39}\Z')
@@ -79,7 +87,9 @@ def catalog():
counts[item['id']] = counts.get(item['id'], 0) + 1
unique = [item for item in result if counts[item['id']] == 1]
for item in unique:
if item['id'] == 60 and item['name'] == 'DeckyFileServer' and item['sha256'] == FILESERVER_DIGEST:
if ((item['id'] == 60 and item['name'] == 'DeckyFileServer' and item['sha256'] == FILESERVER_DIGEST)
or (item['id'] == 114 and item['name'] == 'Network Sentinel' and item['sha256'] == NETWORK_SENTINEL_DIGEST)
or (item['id'] == 67 and item['name'] == 'Battery Tracker' and item['sha256'] == BATTERY_TRACKER_DIGEST)):
item['root'] = False # Exact managed adaptation runs as the Frame user.
item['compatibility'] = framey_compatibility.lookup(item['id'], item['sha256'], item['version'])
return unique
@@ -101,6 +111,12 @@ def archive_info(data, expected_hash, expected_name):
raise ValueError('Invalid plugin folder')
fileserver = (expected_hash == FILESERVER_DIGEST and expected_name == 'DeckyFileServer'
and folder == 'DeckyFileServer')
localsend = (expected_hash == LOCALSEND_DIGEST and expected_name == 'Decky Localsend'
and folder == 'decky-localsend')
network_sentinel = (expected_hash == NETWORK_SENTINEL_DIGEST and expected_name == 'Network Sentinel'
and folder == 'network-sentinel')
battery_tracker = (expected_hash == BATTERY_TRACKER_DIGEST and expected_name == 'Battery Tracker'
and folder == 'steam-deck-battery-tracker')
seen = {}
for entry in files:
name = entry.filename
@@ -130,8 +146,10 @@ def archive_info(data, expected_hash, expected_name):
with archive.open(entry) as source:
header = source.read(20)
if header[:4] == b'\x7fELF' and int.from_bytes(header[18:20], 'little') != 183:
if not (fileserver and name == 'DeckyFileServer/bin/backend'
and hashlib.sha256(archive.read(entry)).hexdigest() == FILESERVER_ORIGINAL):
if not ((fileserver and name == 'DeckyFileServer/bin/backend'
and hashlib.sha256(archive.read(entry)).hexdigest() == FILESERVER_ORIGINAL)
or (localsend and name == 'decky-localsend/bin/localsend-core'
and hashlib.sha256(archive.read(entry)).hexdigest() == LOCALSEND_ORIGINAL)):
raise ValueError('Plugin contains a non-ARM64 Linux binary: ' + name)
if header[:2] == b'MZ':
raise ValueError('Plugin contains a Windows binary: ' + name)
@@ -143,8 +161,16 @@ def archive_info(data, expected_hash, expected_name):
flags = manifest.get('flags', [])
if not isinstance(flags, list) or not all(isinstance(flag, str) for flag in flags):
raise ValueError('Invalid plugin flags')
if ('root' in flags or '_root' in flags) and not fileserver:
if ('root' in flags or '_root' in flags) and not (fileserver or network_sentinel or battery_tracker):
raise ValueError('This plugin needs root and is not supported by the Frame user service')
if localsend and hashlib.sha256(archive.read(folder+'/main.py')).hexdigest() != LOCALSEND_MAIN:
raise ValueError('LocalSend Python source differs from the pinned archive')
if network_sentinel and (flags != ['debug', '_root'] or manifest.get('api_version') != 1
or hashlib.sha256(archive.read(folder+'/main.py')).hexdigest() != NETWORK_SENTINEL_MAIN):
raise ValueError('Network Sentinel manifest or source differs from the pinned archive')
if battery_tracker and (flags != ['_root'] or 'api_version' in manifest
or hashlib.sha256(archive.read(folder+'/main.py')).hexdigest() != BATTERY_TRACKER_MAIN):
raise ValueError('Battery Tracker manifest or source differs from the pinned archive')
return folder
@@ -157,6 +183,12 @@ PSUTIL_DIGESTS = {
def apply_script(plugin_id, digest):
"""Deliver verified offline dependencies only to exact managed archives."""
if plugin_id == 125 and digest == LOCALSEND_DIGEST:
packed = (Path(__file__).parent/'vendor/localsend-0.4.5/aarch64.gz').read_bytes()
data = gzip.decompress(packed)
if hashlib.sha256(data).hexdigest() != LOCALSEND_AARCH64:
raise ValueError('Bundled LocalSend ARM64 helper SHA-256 mismatch')
return ('FRAMEY_LOCALSEND_AARCH64 = '+repr(base64.b64encode(packed).decode('ascii'))+'\n'+APPLY)
if plugin_id == 60 and digest == FILESERVER_DIGEST:
assets = Path(__file__).parent/'vendor/deckyfileserver-1.2.0'
payloads = {}
@@ -169,7 +201,8 @@ def apply_script(plugin_id, digest):
# Python stdin, never shell arguments. The Frame rechecks hash and ELF machine.
return ('FRAMEY_FILESERVER_BINARIES = '+repr(payloads)+'\n'
+'FRAMEY_FILESERVER_HASHES = '+repr(FILESERVER_BINARIES)+'\n'+APPLY)
if plugin_id != 89 or digest != DECKYSPY_DIGEST:
if not ((plugin_id == 89 and digest == DECKYSPY_DIGEST)
or (plugin_id == 114 and digest == NETWORK_SENTINEL_DIGEST)):
return APPLY
wheels = {}
for arch, expected in PSUTIL_DIGESTS.items():
@@ -178,9 +211,11 @@ def apply_script(plugin_id, digest):
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)
source = 'FRAMEY_BACKEND_WHEELS = '+repr(wheels)+'\n'
if plugin_id == 89:
battery = (Path(__file__).parent/'framey_battery.py').read_text(encoding='utf-8')
source += 'FRAMEY_BACKEND_BATTERY_SOURCE = '+repr(battery)+'\n'
return source+APPLY
APPLY = r'''
@@ -227,12 +262,15 @@ try:
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')
fileserver=(int(plugin_id)==60 and digest=='7dc2ecdcd05aa415d1ac90afaff28ecb17f77809a2bc7a77aee8e92505b976f0' and folder=='DeckyFileServer')
localsend=(int(plugin_id)==125 and digest=='e271ccb55eb23f918b99a69f79aec905df4c9b2ef7b23bbf4ec1c2d1a6f0c6b7' and folder=='decky-localsend')
network_sentinel=(int(plugin_id)==114 and digest=='d3e07f40a0c800e72d15594e03c8c61e9bf3092524a578a0260520e735b971a5' and folder=='network-sentinel')
battery_tracker=(int(plugin_id)==67 and digest=='91c388d3c1b52079314045b79671ad55c767510658b6193f6936ed6cad383b94' and folder=='steam-deck-battery-tracker')
manifest=json.loads(z.read(folder+'/plugin.json'))
if not isinstance(manifest,dict): raise RuntimeError('Invalid plugin manifest')
flags=manifest.get('flags',[])
if not isinstance(flags,list) or not all(isinstance(flag,str) for flag in flags):
raise RuntimeError('Invalid plugin flags')
if ('root' in flags or '_root' in flags) and not fileserver:
if ('root' in flags or '_root' in flags) and not (fileserver or network_sentinel or battery_tracker):
raise RuntimeError('Root plugins are not supported by the Frame user service')
seen={}
for f in files:
@@ -257,8 +295,10 @@ try:
if not f.is_dir():
with z.open(f) as source: header=source.read(20)
if header[:4]==b'\x7fELF' and int.from_bytes(header[18:20],'little')!=183:
if not (fileserver and f.filename==folder+'/bin/backend' and
hashlib.sha256(z.read(f)).hexdigest()=='d9eab8559c87c01736188bc676f3b4b44dee47cf952b6e571c770d1ea61733e9'):
if not ((fileserver and f.filename==folder+'/bin/backend' and
hashlib.sha256(z.read(f)).hexdigest()=='d9eab8559c87c01736188bc676f3b4b44dee47cf952b6e571c770d1ea61733e9')
or (localsend and f.filename==folder+'/bin/localsend-core' and
hashlib.sha256(z.read(f)).hexdigest()=='d448d03861d578685e6c70ea3ace25eb34fca99cb9d4f98bf51ba23aa5cae843')):
raise RuntimeError('Plugin contains a non-ARM64 Linux binary')
if header[:2]==b'MZ': raise RuntimeError('Plugin contains a Windows binary')
stage.mkdir(mode=0o700)
@@ -276,6 +316,28 @@ try:
unpacked=stage/folder
if not unpacked.is_dir(): raise RuntimeError('Missing plugin folder')
compat_shims=[]
if localsend:
helper=unpacked/'bin/localsend-core'
source=unpacked/'main.py'
if hashlib.sha256(helper.read_bytes()).hexdigest()!='d448d03861d578685e6c70ea3ace25eb34fca99cb9d4f98bf51ba23aa5cae843':
raise RuntimeError('LocalSend original helper mismatch')
if hashlib.sha256(source.read_bytes()).hexdigest()!='db03f9201fe50b75dea99a7a05b54ec3f352d1bdfe174115b0e569154198d8ad':
raise RuntimeError('LocalSend Python source mismatch')
if manifest.get('name')!='Decky Localsend' or manifest.get('api_version')!=1 or manifest.get('flags')!=[]:
raise RuntimeError('LocalSend manifest mismatch')
arch=platform.machine()
if arch=='aarch64':
packed=globals().get('FRAMEY_LOCALSEND_AARCH64')
if not isinstance(packed,str): raise RuntimeError('Missing LocalSend ARM64 helper')
native=gzip.decompress(base64.b64decode(packed,validate=True))
if (hashlib.sha256(native).hexdigest()!='e20363237e606ee65eae857792176d21a0bb8ba863489952728eaf2b13277dae'
or native[:4]!=b'\x7fELF' or int.from_bytes(native[18:20],'little')!=183):
raise RuntimeError('LocalSend ARM64 helper mismatch')
helper.write_bytes(native)
elif arch!='x86_64':
raise RuntimeError('Unsupported LocalSend helper architecture')
helper.chmod(0o700)
compat_shims.append('localsend-digest-native')
# GPL source and reproducible build recipe ship with Frame Control.
# The exact official helper is x86_64; this plugin needs no root.
if int(plugin_id)==60 and digest=='7dc2ecdcd05aa415d1ac90afaff28ecb17f77809a2bc7a77aee8e92505b976f0' and folder=='DeckyFileServer':
@@ -437,10 +499,10 @@ 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':
# Both exact official archives omit psutil. Install the pinned BSD
# native wheel in the plugin folder only; never use pip or system paths.
deckyspy=(int(plugin_id)==89 and digest=='2d431a89826ed5cf3fd37258373eb3b46c841c39795aa8b25f83e59f4eee87c7' and folder=='decky-spy')
if deckyspy or network_sentinel:
arch=platform.machine()
pins={
'x86_64':'076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9',
@@ -448,13 +510,13 @@ try:
}
encoded=globals().get('FRAMEY_BACKEND_WHEELS',{}).get(arch)
if arch not in pins or not encoded:
raise RuntimeError('Missing native psutil dependency for DeckySpy')
raise RuntimeError('Missing native psutil dependency')
wheel_data=base64.b64decode(encoded,validate=True)
if hashlib.sha256(wheel_data).hexdigest()!=pins[arch]:
raise RuntimeError('DeckySpy psutil dependency SHA-256 mismatch')
raise RuntimeError('Native 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')
if (modules/name).exists(): raise RuntimeError('Native psutil dependency collides with archive')
with zipfile.ZipFile(io.BytesIO(wheel_data)) as wheel:
for member in wheel.infolist():
path=pathlib.PurePosixPath(member.filename)
@@ -469,6 +531,124 @@ try:
dest.parent.mkdir(parents=True,exist_ok=True)
dest.write_bytes(payload)
dest.chmod(0o600)
if network_sentinel:
manifest_path=unpacked/'plugin.json'
manifest=json.loads(manifest_path.read_text())
main=unpacked/'main.py'
if (manifest.get('name')!='Network Sentinel' or manifest.get('api_version')!=1
or manifest.get('flags')!=['debug','_root'] or
hashlib.sha256(main.read_bytes()).hexdigest()!='2baa390cb66dc267e8f04e61ba1edc43af75115ad5e453d308f6bf26ed70b6b9'):
raise RuntimeError('Network Sentinel manifest or source changed')
manifest['flags']=['debug']
manifest_path.write_text(json.dumps(manifest),encoding='utf-8')
source=main.read_text(encoding='utf-8')
old='decky.DECKY_SETTINGS_DIR'
ping=' cmd = [\'ping\', \'-c\', str(count), \'-W\', \'2\', host]\n'
if (source.count(old)!=2 or source.count(' async def _migration(self):')!=1
or source.count(ping)!=1):
raise RuntimeError('Network Sentinel settings or migration source changed')
source=source.replace(old,'decky.DECKY_PLUGIN_SETTINGS_DIR')
source=source.replace(ping,' jitter = 0\n'+ping)
source=source.replace(' async def _migration(self):',
' async def _migration(self):\n # Managed user service does not move legacy Decky paths.\n return\n')
main.write_text(source,encoding='utf-8')
compat_shims.append('network-sentinel-user-psutil')
if battery_tracker:
manifest_path=unpacked/'plugin.json'
manifest=json.loads(manifest_path.read_text())
main=unpacked/'main.py'
if (manifest.get('name')!='Battery Tracker' or manifest.get('flags')!=['_root']
or 'api_version' in manifest or
hashlib.sha256(main.read_bytes()).hexdigest()!='2303ec70c262f1cbfe1c8c138770c6e7c3496827e748e679c94ca43ff9f28725'):
raise RuntimeError('Battery Tracker manifest or source changed')
manifest['flags']=[]
manifest_path.write_text(json.dumps(manifest),encoding='utf-8')
source=main.read_text(encoding='utf-8')
split_start=source.index('def split_by_app(data):\n')
split_end=source.index('\n\nclass Plugin:',split_start)
source=source[:split_start]+"""def _framey_battery_path():
# A normal user can read Frame's Battery-type supply; BAT1 remains first
# on Steam Deck. The override is for a private, networkless runtime fixture.
root = Path(os.environ.get('FRAMEY_BATTERY_ROOT', '/sys/class/power_supply'))
if not root.is_absolute():
raise ValueError('Battery supply root must be absolute')
for candidate in [root / 'BAT1', *sorted(root.iterdir())]:
try:
if ((candidate / 'type').read_text().strip() == 'Battery' and
all((candidate / field).is_file() for field in
('capacity', 'status', 'voltage_now', 'current_now'))):
return candidate
except OSError:
pass
raise FileNotFoundError('No readable Battery-type power supply')
def split_by_app(data):
if not data:
return []
groups = []
start = 0
app = data[0][4]
for index, row in enumerate(data[1:], 1):
if row[4] != app:
groups.append((start, index, app))
start, app = index, row[4]
groups.append((start, len(data), app))
return groups
"""+source[split_end:]
old=' self.app = "Unknown"\n'
if source.count(old)!=1: raise RuntimeError('Battery Tracker startup source changed')
source=source.replace(old,old+' self._pending_samples = []\n')
unload_start=source.index(' async def _unload(self):\n')
unload_end=source.index(' async def set_app(',unload_start)
source=source[:unload_start]+""" async def _unload(self):
task = getattr(self, '_recorder_task', None)
if task:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
pending = getattr(self, '_pending_samples', [])
if pending:
self.cursor.executemany('insert into battery values (?, ?, ?, ?, ?)', pending)
self.con.commit()
pending.clear()
if hasattr(self, 'con'):
self.con.close()
"""+source[unload_end:]
old=' ).fetchall()\n'
if source.count(old)!=2: raise RuntimeError('Battery Tracker query source changed')
query=' data = self.cursor.execute(\n "select * from battery where time > " + str(int(start_time))\n ).fetchall()\n'
if source.count(query)!=1: raise RuntimeError('Battery Tracker history source changed')
source=source.replace(query,query+' data.extend(row for row in self._pending_samples if row[0] > start_time)\n')
recorder_start=source.index(' async def recorder(self):\n')
source=source[:recorder_start]+""" async def recorder(self):
battery = _framey_battery_path()
decky_plugin.logger.info('recorder started')
while True:
try:
volt = int((battery / 'voltage_now').read_text().strip())
curr = int((battery / 'current_now').read_text().strip())
cap = int((battery / 'capacity').read_text().strip())
status = (battery / 'status').read_text().strip()
state = -1 if status == 'Discharging' else 1 if status == 'Charging' else 0
power = int(volt * curr * 10.0**-11)
self._pending_samples.append((int(time.time()), cap, state, power, self.app))
if len(self._pending_samples) > 10:
self.cursor.executemany('insert into battery values (?, ?, ?, ?, ?)', self._pending_samples)
self.con.commit()
self._pending_samples.clear()
except asyncio.CancelledError:
raise
except Exception:
decky_plugin.logger.exception('recorder')
await asyncio.sleep(5)
"""
main.write_text(source,encoding='utf-8')
compat_shims.append('battery-tracker-user-supply')
if deckyspy:
compat=modules/'framey_battery.py'
if compat.exists(): raise RuntimeError('DeckySpy battery module collides with archive')
battery_source=globals().get('FRAMEY_BACKEND_BATTERY_SOURCE')
+47 -27
View File
@@ -1672,7 +1672,7 @@
"https://github.com/jurassicplayer/decky-autosuspend.git"
],
"status": "broken",
"reason": "Frontend audit failed: Cannot read properties of null (reading '6').",
"reason": "Frontend audit failed: Cannot read properties of null (reading '6'). The exact frontend also contains SteamOS suspend and shutdown paths; threshold behavior and safe Frame operation are unverified.",
"blockInstall": false,
"verification": [
"static archive",
@@ -5176,13 +5176,18 @@
"https://github.com/safijari/steam-deck-battery-tracker",
"https://github.com/safijari/steam-deck-battery-tracker.git"
],
"status": "broken",
"reason": "This plugin needs root and is not supported by the Frame user service.",
"blockInstall": true,
"status": "partial",
"reason": "Exact managed install removes the unnecessary root flag, selects a readable Battery-type power supply, and repairs empty history and per-app sample grouping. Actual Decky 3.3.0 amd64 recorded Frame-shaped private battery samples and returned history/power data. A locked real Frame read verified the required sysfs fields, not the managed plugin. Live ARM64 recording, long-term battery history, foreground-app labeling, panel controls and worn VR remain unverified; existing copies need update or reinstall.",
"blockInstall": false,
"requiredShims": [
"battery-tracker-user-supply"
],
"verification": [
"static archive",
"local browser",
"fake-Frame"
"fake-Frame",
"isolated Decky amd64 backend",
"real Frame read-only sysfs"
],
"browser": {
"id": 67,
@@ -5206,8 +5211,8 @@
},
"archiveValidation": {
"id": 67,
"accepted": false,
"reason": "This plugin needs root and is not supported by the Frame user service"
"accepted": true,
"reason": "Exact digest-scoped user-service adaptation"
},
"backend": "Python",
"hardware": "/sys/class/power_supply/BAT1/capacity; /sys/class/power_supply/BAT1/current_now; /sys/class/power_supply/BAT1/status; /sys/class/power_supply/BAT1/voltage_now",
@@ -5217,9 +5222,8 @@
"fakeFrame": {
"id": 67,
"sha256": "91c388d3c1b52079314045b79671ad55c767510658b6193f6936ed6cad383b94",
"archiveValidation": "This plugin needs root and is not supported by the Frame user service",
"result": "blocked",
"reason": "ValueError: Root plugins are not supported by the Frame user service"
"archiveValidation": "accepted",
"result": "installed-removed"
}
},
{
@@ -5854,7 +5858,7 @@
"https://github.com/jurassicplayer/decky-autosuspend"
],
"status": "broken",
"reason": "Frontend audit failed: Cannot convert undefined or null to object.",
"reason": "Frontend audit failed: Cannot convert undefined or null to object. Its primary trainer workflow uses a Steam game context menu and launch-option writes, with no target in the isolated Framey panel; no trainer launch was verified.",
"blockInstall": false,
"verification": [
"static archive",
@@ -9969,13 +9973,17 @@
"https://github.com/SteamDeckHomebrew/decky-frontend-lib",
"https://github.com/SteamDeckHomebrew/decky-loader"
],
"status": "broken",
"reason": "This plugin needs root and is not supported by the Frame user service.",
"blockInstall": true,
"status": "partial",
"reason": "Exact managed install removes the unnecessary root flag and adds pinned plugin-local psutil wheels. Actual Decky 3.3.0 amd64 in network isolation loads the backend and records a network sample; private settings and read-only RPCs pass. External ping/DNS, real Frame ARM64, Wi-Fi scan behavior, panel controls and worn VR remain unverified. Existing copies need update or reinstall.",
"blockInstall": false,
"requiredShims": [
"network-sentinel-user-psutil"
],
"verification": [
"static archive",
"local browser",
"fake-Frame"
"fake-Frame",
"isolated Decky amd64 backend"
],
"browser": {
"id": 114,
@@ -10014,8 +10022,8 @@
},
"archiveValidation": {
"id": 114,
"accepted": false,
"reason": "This plugin needs root and is not supported by the Frame user service"
"accepted": true,
"reason": "Exact digest-scoped user-service adaptation"
},
"backend": "Python",
"hardware": "No specific hardware requirement detected statically; runtime requirements unverified.",
@@ -10025,9 +10033,8 @@
"fakeFrame": {
"id": 114,
"sha256": "d3e07f40a0c800e72d15594e03c8c61e9bf3092524a578a0260520e735b971a5",
"archiveValidation": "This plugin needs root and is not supported by the Frame user service",
"result": "blocked",
"reason": "ValueError: This plugin needs root and is not supported by the Frame user service."
"archiveValidation": "accepted",
"result": "installed-removed"
}
},
{
@@ -11142,13 +11149,17 @@
"https://github.com/moyoez/decky-localsend",
"https://github.com/moyoez/decky-localsend.git"
],
"status": "broken",
"reason": "Plugin contains a non-ARM64 Linux binary: decky-localsend/bin/localsend-core.",
"blockInstall": true,
"status": "partial",
"reason": "Exact managed 0.4.5 archive replaces its x86 helper with a pinned ARM64 build. Published Decky 3.3.0 ARM64 stopped status/configuration RPCs and a private real-Frame ARM64 helper marker transfer passed. The managed service, user-facing discovery/transfer, Steam routes and worn VR remain unverified.",
"blockInstall": false,
"verification": [
"static archive",
"local browser",
"fake-Frame"
"exact archive and source hashes",
"local Chromium initial panel",
"host managed transaction",
"fake Frame managed install/remove",
"private x86 backend transfer",
"published Decky 3.3.0 AMD64/ARM64 stopped status/configuration RPCs",
"private real Frame ARM64 helper marker transfer"
],
"browser": {
"id": 125,
@@ -11174,7 +11185,7 @@
},
"backend": "Python + native payload",
"hardware": "No specific hardware requirement detected statically; runtime requirements unverified.",
"nativePort": "MIT LocalSend-Go 66edc509a28b9f877e7374504892dcbc653a4da4 reproducibly cross-built with Go 1.25.7/CGO_ENABLED=0 for ARM64; SHA-256 e79238f6051ce34f172f765ae918d3d902b819e27377989a55e5b1eba10e46f9. Help/official backend CLI flags and protocol 2.0 info endpoint passed on real Frame in an isolated network namespace. No file transfer or full plugin/CEF integration tested; exact source match to shipped helper unproven. Official ZIP stays x86_64 and blocked.",
"nativePort": "Exact official x86 helper is reproducible from pinned MIT source; the managed installer replaces only the exact 0.4.5 digest with SHA-256 e20363237e606ee65eae857792176d21a0bb8ba863489952728eaf2b13277dae on ARM64. The Frame rechecks original Python/helper bytes, replacement digest and ELF machine; x86 keeps the original helper. Local official x86 backend/proxy and real Frame rebuilt ARM64 helper each passed one private marker transfer. Published Decky 3.3.0 AMD64/ARM64 discovery and stopped status/configuration RPCs passed. The actual managed Decky service on Frame, network discovery, user-facing transfer, Steam routes and worn VR remain unverified (docs/evidence/framey-localsend-managed-2026-10-27.md).",
"uiSurfaces": [
"local/Steam routes",
"toasts",
@@ -11186,6 +11197,15 @@
"archiveValidation": "Plugin contains a non-ARM64 Linux binary: decky-localsend/bin/localsend-core",
"result": "blocked",
"reason": "ValueError: Plugin contains a non-ARM64 Linux binary: decky-localsend/bin/localsend-core."
},
"requiredShims": [
"localsend-digest-native"
],
"managedNative": {
"shim": "localsend-digest-native",
"helperSha256": "e20363237e606ee65eae857792176d21a0bb8ba863489952728eaf2b13277dae",
"sourceCommit": "ad28b8e9440e17e44b4735a6c4aa0512009cf333",
"verification": "Host transaction and fake Frame managed install/remove; local original x86 backend private marker transfer. Published Decky ARM64 runtime and real Frame transfer pending (docs/evidence/framey-localsend-managed-2026-10-27.md)."
}
},
{
+8 -3
View File
@@ -1686,6 +1686,7 @@
<option value="vr">VR app</option>
</select>
<div class="progress" id="prog"><i></i></div>
<div class="hint">The desktop app copies files from this computer directly, including files larger than 8 GiB. Browser uploads are limited to 8 GiB.</div>
<div class="hint">Clipboard text, both ways, is in <a href="#pad">Type on the headset</a>.</div>
</section>
@@ -3843,6 +3844,7 @@ window.addEventListener("drop", e => {
function upload(file, mode, dev = window.connDevice, backupToken = null, progress = () => {}, signal = null) {
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(new Error("Import cancelled"));
if (file.size > 8 * 1024 ** 3) return reject(new Error("Browser uploads are limited to 8 GiB; use the desktop app to copy larger files"));
const xhr = new XMLHttpRequest();
const abort = () => xhr.abort();
const cleanup = () => signal?.removeEventListener("abort", abort);
@@ -3875,6 +3877,9 @@ function upload(file, mode, dev = window.connDevice, backupToken = null, progres
xhr.send(file);
});
}
function copyToDownloads(file, path, dev) {
return path ? api("/api/transfer", { action: "copy-local", path }, dev) : upload(file, "push", dev);
}
async function refreshMedia(selectId) {
const data = await api("/api/media", { action: "list" });
const selected = selectId || $("mediaFile").value;
@@ -4001,7 +4006,7 @@ async function sendFilesOne(files, dirs, dev) {
if (TITLE_EXT.test(f.name)) { await sideload(f, path); continue; }
const apk = f.name.toLowerCase().endsWith(".apk");
if (apk) await importAndroidFiles([f], dev);
else await act(`Copy ${f.name} to ~/Downloads`, () => upload(f, "push", dev));
else await act(`Copy ${f.name} to ~/Downloads`, () => copyToDownloads(f, path, dev));
}
$("fileInput").value = "";
refresh();
@@ -4137,7 +4142,7 @@ async function sideloadOne(file, path, isDir = false, dev = window.connDevice) {
catch (e) {
log(`${file.name}: ${e.message}`, "e");
if (canPush && confirm(`${file.name}: ${e.message}\n\nCopy it to ~/Downloads instead?`))
await act(`Copy ${file.name} to ~/Downloads`, () => upload(file, "push", dev));
await act(`Copy ${file.name} to ~/Downloads`, () => copyToDownloads(file, path, dev));
return;
}
// Its own fresh list, so the dialog can say whether this replaces an installed title.
@@ -4146,7 +4151,7 @@ async function sideloadOne(file, path, isDir = false, dev = window.connDevice) {
const choice = await confirmTitle(r.plan, canPush, installed);
if (choice === "push") {
api("/api/titles", { action: "discard", token: r.token }).catch(() => {});
await act(`Copy ${file.name} to ~/Downloads`, () => upload(file, "push", dev));
await act(`Copy ${file.name} to ~/Downloads`, () => copyToDownloads(file, path, dev));
return;
}
if (!choice) { api("/api/titles", { action: "discard", token: r.token }).catch(() => {}); return; }
+31 -3
View File
@@ -28,6 +28,7 @@ import shutil
import signal
import socket
import socketserver
import stat
import subprocess
import sys
import tempfile
@@ -3494,8 +3495,27 @@ def power_action(body):
raise Failure(str(exc), 409) from exc
def transfer_local(body):
"""Copy a desktop-selected file without a second, size-limited HTTP upload to /tmp."""
if os.environ.get("FRAME_CONTROL_APP") != "1":
raise Failure("Direct file copy needs the Frame Control desktop app", 403)
if body.get("action") != "copy-local" or not isinstance(body.get("path"), str):
raise Failure("Choose a file on this computer", 400)
path = Path(body["path"])
if not path.is_absolute() or not path.name or path.name.startswith("."):
raise Failure("Choose a regular file with a visible name", 400)
try:
info = path.stat()
except OSError:
raise Failure("That file is no longer available on this computer", 400) from None
if not stat.S_ISREG(info.st_mode) or info.st_size <= 0:
raise Failure("Choose a nonempty regular file", 400)
return {"message": push_file(path)}
POST = {
"/api/steam-pipewire": steam_pipewire_action,
"/api/transfer": transfer_local,
"/api/power": power_action,
"/api/android/backup": android_backup, "/api/backup": backup_action,
"/api/decky": decky,
@@ -3611,6 +3631,12 @@ def _next_chunk(chunks, timeout):
def push_file(path, dest="Downloads/"):
"""Copy a file to the Frame (as scripts/push.sh): rsync where both ends have it, else scp."""
name = Path(path).name
# A large file can legitimately take more than an hour over Wi-Fi. Keep a
# finite deadline, but allow roughly one MiB/s plus startup time.
try:
timeout = max(3600, min(24 * 3600, int(Path(path).stat().st_size / (1 << 20)) + 600))
except OSError:
raise Failure(f"{name} is no longer available on this computer", 400) from None
try:
# Not on Windows: a Windows rsync (cwRsync, MSYS2) wouldn't take our POSIX -e quoting.
if not frame_host.WINDOWS and shutil.which("rsync") and ssh("command -v rsync >/dev/null && echo yes || true").strip() == "yes":
@@ -3618,7 +3644,7 @@ def push_file(path, dest="Downloads/"):
else:
# Modern scp uses SFTP, so the remote path isn't parsed by a shell.
cmd = ["scp", *SSH[1:], "-r", str(path), f"{FRAME}:{dest}"]
r = frame_host.run_ssh(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=3600)
r = frame_host.run_ssh(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=timeout)
except subprocess.TimeoutExpired:
raise Failure(f"Copying {name} timed out")
if r.returncode != 0:
@@ -4070,8 +4096,10 @@ class Handler(BaseHTTPRequestHandler):
length = int(self.headers.get("Content-Length") or 0)
if not name or name.startswith("."):
raise Failure("missing filename", 400)
if length <= 0 or length > MAX_UPLOAD:
raise Failure("empty or too-large upload", 400)
if length <= 0:
raise Failure("empty upload", 400)
if length > MAX_UPLOAD:
raise Failure("Browser uploads are limited to 8 GiB; use the desktop app to copy larger files", 413)
if mode in ("apk", "apkinfo") and not name.lower().endswith(".apk"):
raise Failure("APK install needs a .apk file", 400)
backup = None
+27
View File
@@ -0,0 +1,27 @@
Copyright 2009 The Go Authors.
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 Google LLC 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.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 MoeMagicMango
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+43
View File
@@ -0,0 +1,43 @@
# Decky Localsend 0.4.5 native helper
The official Decky store ZIP SHA-256 is
`e271ccb55eb23f918b99a69f79aec905df4c9b2ef7b23bbf4ec1c2d1a6f0c6b7`.
Its Python backend matches upstream plugin tag `v0.45` byte for byte. That
tag pins LocalSend-Go commit `ad28b8e9440e17e44b4735a6c4aa0512009cf333`.
Building that source with Go 1.25.7, CGO disabled, `-trimpath`,
`-buildvcs=false` and `-ldflags='-s -w'` reproduces the published x86_64
helper **byte for byte**. The same source produced the ARM64 helper twice
with identical hashes.
| Artifact | SHA-256 |
| --- | --- |
| Official x86_64 helper | `d448d03861d578685e6c70ea3ace25eb34fca99cb9d4f98bf51ba23aa5cae843` |
| Rebuilt ARM64 helper | `e20363237e606ee65eae857792176d21a0bb8ba863489952728eaf2b13277dae` |
| `aarch64.gz` | `2c4e24dd00d67c6d4f2125b42f1eedc0fe8bcedcbf804be0c7ae91e7b391d5f9` |
| `source.tar.gz` | `f1f86292624b989d540689dbfad503bf15f3937d156978157b8dd418d54415d1` |
`source.tar.gz` is a Git archive of the exact LocalSend-Go commit, including
`go.mod` and `go.sum`. Go modules are **not vendored**: reproducing the
build requires access to checksum-verified dependencies or a populated module
cache. Run the following to check both expected helper hashes:
```sh
python3 scripts/build-framey-localsend.py \
--go /path/to/go1.25.7/bin/go \
--output /tmp/framey-localsend-build
```
The helper shipped to Frame needs no compiler, modules, root service or
runtime download. `LICENSE.txt` is the upstream MIT license; `GO-LICENSE.txt`
is the Go runtime BSD license. The official plugin ZIP retains its own
BSD-3-Clause license unchanged.
Only the exact official ZIP can use this managed adaptation. The Frame
transaction checks its original x86 helper and Python backend hashes,
checks the replacement ARM64 SHA-256 and ELF machine locally and again on the
headset, then records a managed receipt. Other native archives remain blocked.
The official x86 backend and the rebuilt ARM64 helper each passed a single
private no-route marker transfer. Published Decky 3.3.0 AMD64/ARM64 runtime
checks discovered the plugin and read its stopped status and configuration.
The actual managed Decky service on Frame, user-facing transfer, headset UI
and worn use remain unverified. See
`docs/evidence/framey-localsend-managed-2026-10-27.md` and its linked result.
Binary file not shown.
Binary file not shown.