mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-29 00:02:03 +01:00
Compare commits
77
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f544474ce | ||
|
|
b40bf89565 | ||
|
|
760c89fec8 | ||
|
|
584970b82f | ||
|
|
d319c5ef4d | ||
|
|
dad5f78a02 | ||
|
|
36da680e14 | ||
|
|
841c0b84be | ||
|
|
68f46bdd60 | ||
|
|
7eaa878827 | ||
|
|
c8577804df | ||
|
|
eda0a563c0 | ||
|
|
4f1072cb4d | ||
|
|
38dbd4cc0b | ||
|
|
326078cc24 | ||
|
|
0c0df7a243 | ||
|
|
04ccdda050 | ||
|
|
6c37fbbd37 | ||
|
|
e7b037ce88 | ||
|
|
f1c8017873 | ||
|
|
defa6f49f0 | ||
|
|
89e5ee610d | ||
|
|
2b8836929e | ||
|
|
248e5b3382 | ||
|
|
3dcba52e91 | ||
|
|
efda14776d | ||
|
|
6bcce9cbf7 | ||
|
|
8f1a811e1d | ||
|
|
9658f38382 | ||
|
|
eb77fe1d2f | ||
|
|
c733fab6ae | ||
|
|
2fcf50e59a | ||
|
|
e4ce91d686 | ||
|
|
a1a376b4ee | ||
|
|
fe8d94b00b | ||
|
|
05be1e2c13 | ||
|
|
e0bdaebe4f | ||
|
|
d977bc3d54 | ||
|
|
d12071a1e7 | ||
|
|
2aa77b30d6 | ||
|
|
a1bc084400 | ||
|
|
978af2f287 | ||
|
|
fbeb24d754 | ||
|
|
dc139633a9 | ||
|
|
85c0bf542c | ||
|
|
0f421bfd2d | ||
|
|
27b5bf27f3 | ||
|
|
c33dc0ccb6 | ||
|
|
78634c09a9 | ||
|
|
5a83989138 | ||
|
|
b5bbcec1a0 | ||
|
|
bd36929053 | ||
|
|
ac04407bcc | ||
|
|
432b29f961 | ||
|
|
c88df07c81 | ||
|
|
423a86fefc | ||
|
|
5847a81066 | ||
|
|
3a8fee1d45 | ||
|
|
082af24115 | ||
|
|
3d06824f4d | ||
|
|
cf89bf1313 | ||
|
|
e93e75d849 | ||
|
|
a6869bbf93 | ||
|
|
a31223a90b | ||
|
|
4a95aeb5d0 | ||
|
|
9ae78aef60 | ||
|
|
821839baae | ||
|
|
d4c523efd9 | ||
|
|
13ed576abd | ||
|
|
21c609b4a2 | ||
|
|
b39905f7cf | ||
|
|
f1473fd610 | ||
|
|
bf56d21eca | ||
|
|
addd37467d | ||
|
|
9776cca4d3 | ||
|
|
fb8d939a28 | ||
|
|
155a3199f8 |
No files matched your search
@@ -113,6 +113,18 @@ jobs:
|
|||||||
run: npm ci && npm run ${{ matrix.script }}
|
run: npm ci && npm run ${{ matrix.script }}
|
||||||
env:
|
env:
|
||||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
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
|
- name: Check the Mac bundle matches its runner
|
||||||
if: runner.os == 'macOS'
|
if: runner.os == 'macOS'
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -189,7 +201,7 @@ jobs:
|
|||||||
app/dist/*.zip
|
app/dist/*.zip
|
||||||
app/dist/*.AppImage
|
app/dist/*.AppImage
|
||||||
app/dist/*.deb
|
app/dist/*.deb
|
||||||
if-no-files-found: ignore
|
if-no-files-found: error
|
||||||
|
|
||||||
checksums:
|
checksums:
|
||||||
needs: build
|
needs: build
|
||||||
|
|||||||
+2
-21
@@ -7,9 +7,9 @@
|
|||||||
// node build/fetch-deps.js mac arm64 x64 | win x64 | linux x64 arm64
|
// node build/fetch-deps.js mac arm64 x64 | win x64 | linux x64 arm64
|
||||||
const crypto = require("crypto");
|
const crypto = require("crypto");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const https = require("https");
|
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const { execFileSync } = require("child_process");
|
const { execFileSync } = require("child_process");
|
||||||
|
const { get, fetchWithRetry } = require("./fetch-retry");
|
||||||
|
|
||||||
const PY = "3.12.14+20260924";
|
const PY = "3.12.14+20260924";
|
||||||
const PY_URL = (triple) => "https://github.com/astral-sh/python-build-standalone/releases/download/"
|
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}`]),
|
.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) {
|
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");
|
const sum = crypto.createHash("sha256").update(data).digest("hex");
|
||||||
if (sum !== sha256) throw new Error(`checksum mismatch for ${url}: ${sum}`);
|
if (sum !== sha256) throw new Error(`checksum mismatch for ${url}: ${sum}`);
|
||||||
fs.writeFileSync(file, data);
|
fs.writeFileSync(file, data);
|
||||||
|
|||||||
@@ -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 };
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "frame-control",
|
"name": "frame-control",
|
||||||
"version": "0.4.51",
|
"version": "0.4.53",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frame-control",
|
"name": "frame-control",
|
||||||
"version": "0.4.51",
|
"version": "0.4.53",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^44.4.5",
|
"electron": "^44.4.5",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frame-control",
|
"name": "frame-control",
|
||||||
"productName": "Frame Control",
|
"productName": "Frame Control",
|
||||||
"version": "0.4.51",
|
"version": "0.4.53",
|
||||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -181,7 +181,6 @@ old="$stage/old.app"
|
|||||||
if mv "$app" "$old"; then
|
if mv "$app" "$old"; then
|
||||||
if mv "$new" "$app"; then rm -rf "$old"; else mv "$old" "$app"; fi
|
if mv "$new" "$app"; then rm -rf "$old"; else mv "$old" "$app"; fi
|
||||||
fi
|
fi
|
||||||
xattr -dr com.apple.quarantine "$app" 2>/dev/null
|
|
||||||
rm -rf "$stage"
|
rm -rf "$stage"
|
||||||
open "$app"
|
open "$app"
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# What we have tested
|
# 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.53**, 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 |
|
| 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) |
|
| 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.53 versioned source candidate against the real Frame under a unique lock; only documentation changed before the tagged release, leaving its executable source the same: 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.53 tag `v0.4.53`, Frame OS 0.5.6 build `20261022.6153909`: [release verification](evidence/v0453-release-2026-10-28.md), [candidate smoke](evidence/v0453-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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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 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 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.53 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.53: [exact-version table](framey-compatibility.md), [release verification](evidence/v0453-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 | 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 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.53 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.
|
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,21 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## Hosted ARM64 follow-up
|
||||||
|
|
||||||
|
The [main native Decky run](https://github.com/saphid/frame-control/actions/runs/40776292569)
|
||||||
|
on `dad5f78a02dd477a689689865cd4ef4205b87e22` passed the exact Battery
|
||||||
|
Tracker case on native amd64 **and ARM64** runners. Both logs report `PASS 67`
|
||||||
|
for discovery, Frame-shaped private battery samples, history and per-app power
|
||||||
|
through actual Decky 3.3.0 RPC. These were disposable fixture supplies inside
|
||||||
|
isolated containers. The managed plugin still has not recorded a real Frame
|
||||||
|
battery sample; the remaining limits above still apply.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# DeckyFileServer 1.2.0 address correction, 28 October 2026
|
||||||
|
|
||||||
|
Official store ID 60 and ZIP SHA-256
|
||||||
|
`7dc2ecdcd05aa415d1ac90afaff28ecb17f77809a2bc7a77aee8e92505b976f0`.
|
||||||
|
The exact bundled frontend `dist/index.js` has SHA-256
|
||||||
|
`791015d8b3f234fad89ce422b09fb32bbad05e7c867d501706cfbcf9f7c6187f`.
|
||||||
|
Only that pinned managed archive receives this change. A changed archive keeps
|
||||||
|
the normal native/root rejection.
|
||||||
|
|
||||||
|
The [pinned corresponding Go source](../../ui/vendor/deckyfileserver-1.2.0/README.md)
|
||||||
|
calls `ListenAndServeTLS` for every server start. The official frontend instead
|
||||||
|
constructs `http://steamdeck:<port>` when uploads are off, and toggles the
|
||||||
|
second address between HTTP and HTTPS using the upload setting. Neither rule
|
||||||
|
matches the server. The `steamdeck` hostname also has no verified resolution
|
||||||
|
on Frame. Earlier [real Frame backend evidence](framey-fileserver-arm64-2026-10-27.md)
|
||||||
|
confirmed private HTTPS listing and download with uploads disabled.
|
||||||
|
|
||||||
|
The managed installer now checks the original frontend hash and replaces the
|
||||||
|
two displayed addresses with one `https://<backend IP>:<port>` address. A
|
||||||
|
rendered-element check executes the installed official bundle and verifies
|
||||||
|
that URL with uploads off and on; it also checks that no `steamdeck` address
|
||||||
|
remains. Receipts add `deckyfileserver-https-address`. Older managed copies
|
||||||
|
without it display an update or reinstall notice; the compatibility status
|
||||||
|
stays **partial**.
|
||||||
|
|
||||||
|
This check does not prove a live browser can reach every returned IP, or that
|
||||||
|
the folder picker works in Framey. Use a preconfigured directory. Uploads,
|
||||||
|
generated thumbnails, worn VR, physical controllers and wearer readability
|
||||||
|
remain unverified. This correction did not touch the real headset.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Free Loader 1.6.0: private Decky backend check
|
||||||
|
|
||||||
|
The official store archive for ID 63 was downloaded and matched SHA-256
|
||||||
|
`05208aee6f6ba824391d8ddc81b1168b2599a9711e7528cdaa789f6cc710b42a`.
|
||||||
|
The published Decky 3.3.0 amd64 loader matched SHA-256
|
||||||
|
`e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181`.
|
||||||
|
|
||||||
|
An unprivileged, disposable Debian container ran that loader and the exact
|
||||||
|
official plugin with no network, host devices, host session or writable host
|
||||||
|
mount. Decky discovered Free Loader as enabled. Its API 1 methods returned 14
|
||||||
|
default settings and an empty local deals map. A private `larger_text` setting
|
||||||
|
changed to `true` and read back; startup created only the container's private
|
||||||
|
settings file, and `read_deals` created an empty private deals cache. No feed,
|
||||||
|
notification, Steam store or external account method was called. The local
|
||||||
|
probe exited successfully; its container was removed. The committed native
|
||||||
|
Decky runtime case repeats these calls and also checks persistence after a
|
||||||
|
loader restart on the CI runner's native architecture. The local x86_64 run
|
||||||
|
passed all 14 runtime cases, including Free Loader and the existing negative
|
||||||
|
regressions. The runner removed its temporary containers and image.
|
||||||
|
|
||||||
|
This strengthens the **partial** label. It does not verify a live feed refresh,
|
||||||
|
notification delivery, real Frame ARM64 service, rendered deal cards, worn VR
|
||||||
|
or controller interaction. The earlier opaque Chromium check had denied backend
|
||||||
|
and network calls, so it could not establish that deal cards render with live
|
||||||
|
data. No headset was touched for this check.
|
||||||
|
|
||||||
|
## Hosted ARM64 follow-up
|
||||||
|
|
||||||
|
The [main native Decky run](https://github.com/saphid/frame-control/actions/runs/40776292569)
|
||||||
|
on `dad5f78a02dd477a689689865cd4ef4205b87e22` passed the exact Free Loader
|
||||||
|
case on native amd64 **and ARM64** runners. Both logs report `PASS 63` for
|
||||||
|
discovery, default settings, empty local deals, private setting write/read and
|
||||||
|
restart persistence. The runners used separate network and device isolated
|
||||||
|
containers; no feed was fetched and no real Frame service was started. The
|
||||||
|
remaining partial-compatibility limits above still apply.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## Hosted ARM64 follow-up
|
||||||
|
|
||||||
|
The [main native Decky run](https://github.com/saphid/frame-control/actions/runs/40776292569)
|
||||||
|
on `dad5f78a02dd477a689689865cd4ef4205b87e22` passed the exact Network
|
||||||
|
Sentinel case on native amd64 **and ARM64** runners. Both logs report `PASS 114`
|
||||||
|
for discovery, native psutil import, private settings and one isolated
|
||||||
|
monitoring-loop sample through actual Decky 3.3.0 RPC. The containers had no
|
||||||
|
external network or Frame device access. Real Frame monitoring, external
|
||||||
|
latency/DNS, panel controls and worn VR remain unverified.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Shotty native Decky backend check, 28 October 2026
|
||||||
|
|
||||||
|
The exact official Shotty 0.1.0 archive (store ID 53, SHA-256
|
||||||
|
`ab7f6618b264a0cb766525e788cef6c3e1478c0fef98a701225e5e8af441e2fe`)
|
||||||
|
receives Frame Control's digest-scoped `shotty-click-app-dir` module during a
|
||||||
|
managed install. The [earlier isolated Python check](framey-shotty-python-2026-10-26.md)
|
||||||
|
copied one synthetic screenshot into a temporary `Pictures` folder.
|
||||||
|
|
||||||
|
The [main native Decky run](https://github.com/saphid/frame-control/actions/runs/40776292569)
|
||||||
|
on commit `dad5f78a02dd477a689689865cd4ef4205b87e22` passed Shotty's
|
||||||
|
`tests/decky-runtime/cases.json` case on both native amd64 and ARM64 runners.
|
||||||
|
The test used the digest-verified published Decky 3.3.0 loader and exact archive
|
||||||
|
in separate disposable, network and device isolated containers. It installed
|
||||||
|
the managed archive, checked the shim receipt and plugin discovery, then called
|
||||||
|
the legacy backend methods through Decky's real websocket RPC:
|
||||||
|
|
||||||
|
| Runner | Observed result |
|
||||||
|
| --- | --- |
|
||||||
|
| Ubuntu amd64 | `was_rescued` returned `false`; `aggregate_all(allapps=[])` returned `0` |
|
||||||
|
| Ubuntu ARM64 | `was_rescued` returned `false`; `aggregate_all(allapps=[])` returned `0` |
|
||||||
|
|
||||||
|
Both job logs report `PASS 53: discovery, backend import and empty screenshot
|
||||||
|
aggregation passed`. The containers were removed by the test runner. This
|
||||||
|
corrects the prior catalog statement that actual Decky ARM64 backend loading
|
||||||
|
was unverified. The empty RPCs do not exercise a real screenshot or the
|
||||||
|
continuous rescuer. The managed backend on a real Frame, live screenshot
|
||||||
|
notifications and copies, wearer-visible panel and physical controllers remain
|
||||||
|
unverified, so Shotty stays **partial**.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Shotty screenshot copy through native Decky RPC, 28 October 2026
|
||||||
|
|
||||||
|
The exact official Shotty 0.1.0 archive (store ID 53, SHA-256
|
||||||
|
`ab7f6618b264a0cb766525e788cef6c3e1478c0fef98a701225e5e8af441e2fe`)
|
||||||
|
was installed with Frame Control's digest-scoped `shotty-click-app-dir` shim.
|
||||||
|
The local `scripts/framey-test-decky-runtime.py` run used the published,
|
||||||
|
digest-verified Decky 3.3.0 amd64 loader in a disposable unprivileged container
|
||||||
|
with no network, host devices or writable host mount. Its full 14-case native
|
||||||
|
suite, including three negative controls, exited successfully.
|
||||||
|
|
||||||
|
The Shotty case placed two small marker files under a private synthetic Steam
|
||||||
|
screenshots directory in the container's temporary home. Calls went through
|
||||||
|
Decky's real legacy websocket RPC. `set_id_map_fronend` supplied a private game
|
||||||
|
name; `copy_screenshot` returned `true` for the selected file. Independent
|
||||||
|
reads of `Pictures/Screenshots/Private Game/selected.jpg` and
|
||||||
|
`Pictures/Screenshots/most_recent.jpg` matched the marker bytes. A later
|
||||||
|
`aggregate_all` returned `1`, and the historical file in the named folder
|
||||||
|
also matched its source bytes. Empty aggregation and `was_rescued=false` still
|
||||||
|
passed. The runner removed its owned containers and image; no headset or user
|
||||||
|
screenshot was accessed.
|
||||||
|
|
||||||
|
This local run checks the real Decky RPC and same-filesystem copy path with
|
||||||
|
synthetic files on amd64. The PR's hosted native ARM64 gate must pass before
|
||||||
|
merge; this local result alone does not establish ARM64 behavior. Steam's live
|
||||||
|
screenshot notification, cross-filesystem copies, the continuous rescuer, real
|
||||||
|
Frame installation, wearer-visible VR and physical controller use remain
|
||||||
|
unverified. Shotty remains **partial**.
|
||||||
@@ -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,35 @@
|
|||||||
|
# Repeatable installer audit command, 28 October 2026
|
||||||
|
|
||||||
|
The independent installer check used for v0.4.53 was previously a local
|
||||||
|
one-off script. The new script at scripts/audit-release-assets.py makes that
|
||||||
|
check repeatable before future releases are published. It reads the release
|
||||||
|
by exact tag or the draft's exact version title, requires ten installers plus
|
||||||
|
SHA256SUMS, downloads each by asset ID, checks size and SHA-256 against GitHub
|
||||||
|
metadata, matches the downloaded checksum file byte for byte, and inspects
|
||||||
|
package formats and versions. On an x86_64 Linux host, its optional flag also
|
||||||
|
launches the tagged x86_64 AppImage under isolated Xvfb.
|
||||||
|
|
||||||
|
The command writes a JSON report only after all checks pass and refuses to
|
||||||
|
overwrite an existing report. It performs no headset operation and makes no
|
||||||
|
GitHub changes. It does not prove physical Mac or Windows installer launch.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Eight focused offline tests passed, including incomplete metadata, wrong
|
||||||
|
Mac bundle version, wrong AppImage architecture, a bad downloaded checksum
|
||||||
|
file, and refusal to overwrite a report. Three existing checksum-generator
|
||||||
|
tests passed.
|
||||||
|
- After v0.4.53 publication, the new command audited its public release with
|
||||||
|
the tag v0.4.53, report path /tmp/frame-v0453-reusable-audit.json, and the
|
||||||
|
--launch-x86-appimage option. It exited successfully after independently
|
||||||
|
downloading all ten installers. Their names, sizes, hashes and package
|
||||||
|
checks matched the [prepublication asset audit](v0453-assets-2026-10-28.json),
|
||||||
|
and the downloaded SHA256SUMS had the same SHA-256:
|
||||||
|
813441ac5c121e2c9effd274576aa6db1ff01187f811463a1014f86e0172608c.
|
||||||
|
The x86_64 AppImage's bundled server returned HTTP 200 with FrameControl/1
|
||||||
|
and stopped after app exit. The local JSON report had SHA-256
|
||||||
|
ad8d7feaf6c0398b216f43c00206dd01d6efc714e3b341048bece96c0e5be376.
|
||||||
|
|
||||||
|
This postpublication repeat verifies the new command; it does not replace
|
||||||
|
the prepublication v0.4.53 release gates recorded in
|
||||||
|
[release verification](v0453-release-2026-10-28.md).
|
||||||
@@ -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.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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
{
|
||||||
|
"tag": "v0.4.53",
|
||||||
|
"releaseId": 427006429,
|
||||||
|
"tagNameAtAudit": "v0.4.53",
|
||||||
|
"draftAtAudit": true,
|
||||||
|
"sha256sumsSha256": "813441ac5c121e2c9effd274576aa6db1ff01187f811463a1014f86e0172608c",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"name": "Frame-Control-mac-arm64.dmg",
|
||||||
|
"size": 177629060,
|
||||||
|
"sha256": "8ae712108aa3fdfb5f40a2f205db65ffd7694914bab2ac0af7a85dafb1c8c31e",
|
||||||
|
"udifTrailer": "ok"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Frame-Control-mac-arm64.zip",
|
||||||
|
"size": 177452293,
|
||||||
|
"sha256": "f6f683e43b397d51432a8651d0b54d5d96cfd3e9d75e0fe4a9769cdd10ccf33b",
|
||||||
|
"zipCrc": "ok",
|
||||||
|
"members": 1673,
|
||||||
|
"bundleVersion": "0.4.53"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Frame-Control-mac-x64.dmg",
|
||||||
|
"size": 184700330,
|
||||||
|
"sha256": "7c25a0d6633aa5312c5e7a1abf8d1abd656e109cc78093a2c5bd29bf790d8e03",
|
||||||
|
"udifTrailer": "ok"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Frame-Control-mac-x64.zip",
|
||||||
|
"size": 184538726,
|
||||||
|
"sha256": "fe7c5b46ea718a5e8f5d8cc511813d420fdbdfea4858e4c4c20f92b7aaaed529",
|
||||||
|
"zipCrc": "ok",
|
||||||
|
"members": 1669,
|
||||||
|
"bundleVersion": "0.4.53"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Frame-Control-Setup-x64.exe",
|
||||||
|
"size": 151885488,
|
||||||
|
"sha256": "9bdbafc00fd170a4e31dbaafc73ce89c952c85a1cbad9198822ea2c473489597",
|
||||||
|
"peMachine": 332
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Frame-Control-win-x64.zip",
|
||||||
|
"size": 198840858,
|
||||||
|
"sha256": "8360ea2427a84374a2106267d0440dbf5e4d9b22f9ef44e465d31f81c7ebfaff",
|
||||||
|
"zipCrc": "ok",
|
||||||
|
"members": 1517
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Frame-Control-linux-x86_64.AppImage",
|
||||||
|
"size": 179997230,
|
||||||
|
"sha256": "7755621360a1aafb6e1bdf2b41838d172a3f6d7f5662d2b5217f6aae08486c2b",
|
||||||
|
"elfMachine": 62
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Frame-Control-linux-arm64.AppImage",
|
||||||
|
"size": 171579370,
|
||||||
|
"sha256": "985b0a1c3e04af5cc1a41e2e7ad9e8481aac45762b369044bf314972e894dc64",
|
||||||
|
"elfMachine": 183
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Frame-Control-linux-amd64.deb",
|
||||||
|
"size": 146412012,
|
||||||
|
"sha256": "2adbef7c1bec3e9e40244984598a2e2c9816eb2c21fdb21d6996f9d30f2d0fec",
|
||||||
|
"debVersion": "0.4.53",
|
||||||
|
"architecture": "amd64"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Frame-Control-linux-arm64.deb",
|
||||||
|
"size": 133498848,
|
||||||
|
"sha256": "0625dfdb1959f8eab52ac3f6c4bf909b7de3966c9ca959c391e43630516ff9c7",
|
||||||
|
"debVersion": "0.4.53",
|
||||||
|
"architecture": "arm64"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# v0.4.53 candidate smoke, 28 October 2026
|
||||||
|
|
||||||
|
The versioned source candidate `2aa77b30d684ee8dc9a3384648bcd9bfd5c634e5`
|
||||||
|
bumps only `app/package.json` and its lockfile from 0.4.52 to 0.4.53 on
|
||||||
|
main commit `978af2f2879d80a22a72ea5a6992884424ed3480`. That main commit
|
||||||
|
contains [PR #377](https://github.com/saphid/frame-control/pull/377), which
|
||||||
|
copies desktop-selected files directly rather than staging them through the
|
||||||
|
8 GiB HTTP upload route. Browser uploads keep the 8 GiB limit. A physical
|
||||||
|
26.5 GB USB-to-Frame transfer has **not** been verified.
|
||||||
|
|
||||||
|
## Locked real Frame smoke
|
||||||
|
|
||||||
|
At 17:04:22 UTC, `scripts/frame-smoke.sh` ran from that candidate on lxso2.
|
||||||
|
Before any status read or title change, it acquired `/tmp/frame-test.lock`
|
||||||
|
atomically with its unique owner token. Its read-only preflight found
|
||||||
|
SteamVR activity **3 (standby)**, proximity raw **2.3125**, and battery
|
||||||
|
**100%**. It did not request pairing or VR input.
|
||||||
|
|
||||||
|
On Frame OS 0.5.6 build `20261022.6153909`, ARM64 and Windows/Proton tiny
|
||||||
|
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**. The runner's cleanup passed and it released only its own lock.
|
||||||
|
The private JSON result is
|
||||||
|
`/tmp/frame-control-v0453-smoke-results/2026-10-28T170422Z-20261022.6153909.json`
|
||||||
|
with SHA-256
|
||||||
|
`5a5bc8b1da1ef405235d660a2587c84204afd0816c80f8d9dfd9577dac72d547`.
|
||||||
|
|
||||||
|
A separate read-only audit acquired and released a fresh unique-owner lock.
|
||||||
|
It found activity **3**, proximity raw **2.3125**, battery **100%**, no
|
||||||
|
reserved smoke title files or metadata, and no processes owned by those
|
||||||
|
title paths. Its private JSON result is
|
||||||
|
`/tmp/frame-v0453-cleanup-audit.json` with SHA-256
|
||||||
|
`6ab884b6adffb14c4643e30ee2e5fe247b2ebba7a3ad5424515927cd40146e79`.
|
||||||
|
No power or security setting was changed. Nobody was confirmed wearing the
|
||||||
|
Frame; wearer-visible VR, physical-controller use and the optional x86_64
|
||||||
|
launch remain unverified. The separate hourly worn check stays enabled.
|
||||||
|
|
||||||
|
After this smoke, the local release branch merged the exact green head
|
||||||
|
`a1bc084400058908132e6937322bc97e71ed5d68` of support-matrix PR #379.
|
||||||
|
Compared with the smoked commit `2aa77b3`, only this evidence file and
|
||||||
|
`docs/current-support-matrix.md` changed. A diff over `app`, `ui`, `frame`,
|
||||||
|
`scripts`, `tests`, `desktop`, `mac` and `ios` was empty; the executable
|
||||||
|
source tested by smoke remains the same. The branch then merged PR #379's
|
||||||
|
main commit `05be1e2c1341ad9f36c6d5bbda27e7eaaf84ef59`; the executable
|
||||||
|
diff remained empty. This does not replace final-commit CI or installer
|
||||||
|
verification.
|
||||||
|
|
||||||
|
## Gates still required
|
||||||
|
|
||||||
|
This smoke is evidence for the versioned source candidate, not a release.
|
||||||
|
The final candidate must pass its own exact-head CI, e2e, native Decky
|
||||||
|
runtime and four installer
|
||||||
|
previews. Its final merged main commit must then pass exact-commit CI, e2e
|
||||||
|
and native runtime. The tag must build all four installer jobs and checksums
|
||||||
|
without failed, cancelled, skipped or overridden gates. Independent package
|
||||||
|
checks, release verification and a successful publisher dry run are required
|
||||||
|
before publication. Any unfinished or failed gate holds v0.4.53 as a draft.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# v0.4.53 release verification, 28 October 2026
|
||||||
|
|
||||||
|
The annotated `v0.4.53` tag dereferences to merged main commit
|
||||||
|
`e4ce91d6868665e6689a4aa17a27a46bf9abd089`. Its tree matches reviewed
|
||||||
|
[candidate PR #380](https://github.com/saphid/frame-control/pull/380) head
|
||||||
|
`a1a376b4ee9464ac9d97426cce21e662e3a9bf1e`; the diff is empty.
|
||||||
|
|
||||||
|
## Exact commit gates
|
||||||
|
|
||||||
|
- [Main checks](https://github.com/saphid/frame-control/actions/runs/40739079630)
|
||||||
|
passed all seven jobs on the first attempt, including e2e, iOS, Windows,
|
||||||
|
macOS, Linux and aggregate checks.
|
||||||
|
- [Native Decky runtime](https://github.com/saphid/frame-control/actions/runs/40739079822)
|
||||||
|
passed both published Decky 3.3.0 AMD64 and ARM64 jobs on the first attempt.
|
||||||
|
- Tagged installer and checksum workflow:
|
||||||
|
[run 40740373224](https://github.com/saphid/frame-control/actions/runs/40740373224).
|
||||||
|
All six jobs passed 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 draft held exactly ten installers and `SHA256SUMS`.
|
||||||
|
Every installer was independently downloaded by GitHub asset ID; each size
|
||||||
|
and SHA-256 matched the asset metadata. The downloaded `SHA256SUMS` matched
|
||||||
|
all ten entries byte for byte and had SHA-256
|
||||||
|
`813441ac5c121e2c9effd274576aa6db1ff01187f811463a1014f86e0172608c`.
|
||||||
|
The [sanitized asset audit](v0453-assets-2026-10-28.json) records names,
|
||||||
|
sizes, hashes and package checks. It verified both Mac ZIP CRCs and bundle
|
||||||
|
version `0.4.53`, both DMG UDIF trailers, Windows ZIP CRC and PE header,
|
||||||
|
both AppImage ELF architectures, and both DEB version and architecture fields.
|
||||||
|
|
||||||
|
## Real Frame and package checks
|
||||||
|
|
||||||
|
The [candidate smoke evidence](v0453-candidate-2026-10-28.md)
|
||||||
|
records a unique-lock source smoke: 12 passed, one optional x86_64 launch
|
||||||
|
skipped because Steam Linux Runtime 4 was absent, zero failed. Independent
|
||||||
|
fresh-lock cleanup found no reserved test titles or owned processes. The
|
||||||
|
source tree changed only in documentation after the smoke, and the merged
|
||||||
|
release tree matches the PR head. SteamVR was in standby; nobody was confirmed
|
||||||
|
wearing the Frame, and no VR input was sent.
|
||||||
|
The private smoke result had SHA-256
|
||||||
|
`5a5bc8b1da1ef405235d660a2587c84204afd0816c80f8d9dfd9577dac72d547`;
|
||||||
|
the fresh-lock cleanup audit had SHA-256
|
||||||
|
`6ab884b6adffb14c4643e30ee2e5fe247b2ebba7a3ad5424515927cd40146e79`.
|
||||||
|
|
||||||
|
The tagged x86_64 AppImage downloaded independently by asset ID had SHA-256
|
||||||
|
`7755621360a1aafb6e1bdf2b41838d172a3f6d7f5662d2b5217f6aae08486c2b`.
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Publication
|
||||||
|
|
||||||
|
`scripts/publish-release.sh --dry-run v0.4.53` passed exact-main, native
|
||||||
|
runtime and tagged release jobs, all ten installer hashes, regenerated
|
||||||
|
`SHA256SUMS`, draft notes and update-manifest checks. Its log had SHA-256
|
||||||
|
`ddad938f25d4321a8d24b29ff895a20431ec6d1c2c6faf8fd42f939100c97bff`.
|
||||||
|
The publisher repeated those gates, attached `update.json`, and published
|
||||||
|
[v0.4.53](https://github.com/saphid/frame-control/releases/tag/v0.4.53) at
|
||||||
|
**18:14:22 UTC**. Its log had SHA-256
|
||||||
|
`b54cd7f87c0ba858cc61086fc6293287bdb7555c6a49d3f1c9b8216b39d0dcf3`.
|
||||||
|
GitHub's latest-release API returned `draft=false`, `prerelease=false`, the
|
||||||
|
correct tag, ten installers, `SHA256SUMS` and `update.json`. A fresh download
|
||||||
|
from the public `releases/latest/download/update.json` route reported version
|
||||||
|
`0.4.53`, the correct release page, all ten names, sizes and hashes matching
|
||||||
|
the independent downloads, and the explicit 26.5 GB transfer limit. The
|
||||||
|
public manifest had SHA-256
|
||||||
|
`b873a3422e60e2fec2ff4a08f0a1fe610b9df18eb94a1a72e259beefb763bb38`.
|
||||||
|
|
||||||
|
The exact-version Decky catalog remains **6 works / 54 partial / 45 broken /
|
||||||
|
11 N/A**. A physical 26.5 GB transfer, wearer-visible VR, physical-controller
|
||||||
|
use and the optional x86_64 title launch are unverified. The separate hourly
|
||||||
|
worn check remains enabled.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# v0.4.54 candidate hold, 28 October 2026
|
||||||
|
|
||||||
|
The local, unpushed `release/v0.4.54` candidate is commit
|
||||||
|
`3eefedd8d4dd2ce8db7d62d0dd2d7cff1fe2089b`, based on main
|
||||||
|
`e7b037ce883e45990bf239aee29b05434a39a399`. It changes only the
|
||||||
|
version in `app/package.json` and `app/package-lock.json`. It has not been
|
||||||
|
tagged or published. The latest public release remains
|
||||||
|
[v0.4.53](https://github.com/saphid/frame-control/releases/tag/v0.4.53).
|
||||||
|
|
||||||
|
The locked real Frame smoke at 20:28 UTC read SteamVR activity 3 (standby),
|
||||||
|
proximity 2.3125 and battery 100% on Frame OS build `20261022.6153909`.
|
||||||
|
ARM64 and Windows/Proton test titles launched. The x86_64 test title did
|
||||||
|
**not** launch: Steam reported that Steam Linux Runtime 4.0 was found but
|
||||||
|
not installed. The result has **12 passed, 1 skipped, 0 failed**. Its final
|
||||||
|
cleanup step passed and its unique lock was released. The private JSON result
|
||||||
|
is `/tmp/frame-v0454-smoke-results/2026-10-28T202826Z-20261022.6153909.json`
|
||||||
|
with SHA-256 `d86edaec0b86faf130c21aa3d7113cc1ac5b2ae0869dbd15f811bf3bb5b76b88`.
|
||||||
|
|
||||||
|
A separate read-only audit acquired and released a fresh unique headset lock.
|
||||||
|
It read activity 3, proximity 2.3125 and battery 100%, and found no reserved
|
||||||
|
smoke title directories or owned test processes. It did not wake the Frame or
|
||||||
|
send input. Nobody confirmed wearing the Frame, so no wearer-visible or
|
||||||
|
physical-controller result follows.
|
||||||
|
|
||||||
|
Follow-up probes at about 22:40–22:45 UTC each acquired a new unique owner in
|
||||||
|
the shared headset lock before reading status. SteamVR stayed at activity 3
|
||||||
|
(standby), proximity was 2.3125–2.375 and battery was 100%. A bounded,
|
||||||
|
read-only `FEXBash --help` probe reported that `SteamLinuxRuntime_4` did not
|
||||||
|
exist in Steam's common directory. A separate read of `/usr/bin/FEXBash` found
|
||||||
|
that its wrapper exits when that directory is absent. The probes did not
|
||||||
|
install a runtime, launch a title, wake the headset or send input. Each owner
|
||||||
|
released its lock. The available FEX wrapper therefore did not provide an
|
||||||
|
immediate x86 Linux launch path; this does not establish that every possible
|
||||||
|
runtime source is absent.
|
||||||
|
|
||||||
|
**Release decision: hold v0.4.54.** The smoke script at the candidate commit
|
||||||
|
returned exit 0 even though it recorded a skipped launch. Under the current
|
||||||
|
release rule, a skip cannot satisfy the smoke gate. The accompanying change
|
||||||
|
makes a skipped smoke return nonzero while preserving the explicit skipped
|
||||||
|
step and cleanup record. Exact release-commit CI, release verification, tagged
|
||||||
|
installer builds, checksums and publisher dry run have not been performed for
|
||||||
|
this local candidate.
|
||||||
@@ -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.
|
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: 52 · Broken: 47 · 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.
|
- **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.
|
- **Partial** means only a subset or a local fixture scope was verified, often just frontend/settings initialization. The remaining backend/device functionality is unverified; read the reason for the exact scope.
|
||||||
@@ -25,7 +25,13 @@ python3 scripts/framey-audit-browser.py --cache /tmp/framey-archives
|
|||||||
|
|
||||||
Open the browser audit URL in local Chromium, run `runAudit()` and inspect/download `results`. All backend/network actions remain denied.
|
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 [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. The earlier full 116-plugin fake-Frame audit blocked this archive before the fix. A later [native Decky 3.3.0 check](evidence/framey-free-loader-native-2026-10-28.md) verified plugin discovery, default settings, empty local deals and private setting persistence after restart in network-isolated amd64 and ARM64 containers. Live feed updates, notifications, Steam store integration, real Frame ARM64 service 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 and ARM64, the pinned plugin-local psutil wheel, private settings RPC and one monitoring-loop sample pass. External ping/DNS, a real Frame backend, 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 and ARM64 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.
|
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.
|
||||||
|
|
||||||
@@ -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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
@@ -68,25 +74,25 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
|
|||||||
| 49: Decky Cloud Save 1.4.2 | legacy / legacy | broken | The exact official ZIP contains a 60,776,600-byte x86_64 rclone member, above the 30 MiB member limit and unusable on ARM64. Installation remains blocked. | static archive, local browser, fake-Frame |
|
| 49: Decky Cloud Save 1.4.2 | legacy / legacy | broken | The exact official ZIP contains a 60,776,600-byte x86_64 rclone member, above the 30 MiB member limit and unusable on ARM64. Installation remains blocked. | static archive, local browser, fake-Frame |
|
||||||
| 50: Storage Cleaner 1.4.0 | 1 / 2 | partial | Empty-data panel rendered; cleanup was deliberately not run because it can remove local data. | static archive, local browser, fake-Frame |
|
| 50: Storage Cleaner 1.4.0 | 1 / 2 | partial | Empty-data panel rendered; cleanup was deliberately not run because it can remove local data. | static archive, local browser, fake-Frame |
|
||||||
| 52: Game Theme Music 1.7.1-1 | 1 / 2 | partial | An inert local component lets the exact frontend initialize and render its Settings view in opaque Chromium. Backend get_setting and plugin network were denied in the audit; Steam game-page theme injection has no verified target in Framey. Music search, playback, downloads, settings persistence and worn VR remain unverified. | static archive, local browser, fake-Frame |
|
| 52: Game Theme Music 1.7.1-1 | 1 / 2 | partial | An inert local component lets the exact frontend initialize and render its Settings view in opaque Chromium. Backend get_setting and plugin network were denied in the audit; Steam game-page theme injection has no verified target in Framey. Music search, playback, downloads, settings persistence and worn VR remain unverified. | static archive, local browser, fake-Frame |
|
||||||
| 53: Shotty 0.1.0 | legacy / legacy | partial | The frontend starts with the read-only Frame library snapshot. Frame Control adds a digest-scoped click.get_app_dir compatibility module to the managed install; the exact backend then imports and copies a synthetic Steam screenshot into a temporary Pictures folder in isolated Python. Actual Decky 3.3.0 ARM64 loading, live screenshots/rescue, in-VR panel and physical controller use remain unverified. | static archive, local browser, fake-Frame, real Frame SSH, isolated Python backend |
|
| 53: Shotty 0.1.0 | legacy / legacy | partial | The frontend starts with the read-only Frame library snapshot. The exact-digest click.get_app_dir shim lets actual Decky 3.3.0 in isolated amd64 and ARM64 containers discover Shotty and complete empty screenshot RPCs; isolated Python also copied a synthetic screenshot into a temporary Pictures folder. The managed backend on the real Frame, live screenshots/rescue, in-VR panel and physical controller use remain unverified. | static archive, local browser, fake-Frame, real Frame SSH, isolated Python backend, isolated Decky 3.3.0 amd64/ARM64 empty backend RPCs |
|
||||||
| 56: MangoPeel 0.0.7 | 1 / 2 | partial | Initializer succeeds; gamescope/MangoHud actions and backend are unverified. | static archive, local browser, fake-Frame |
|
| 56: MangoPeel 0.0.7 | 1 / 2 | partial | Initializer succeeds; gamescope/MangoHud actions and backend are unverified. | static archive, local browser, fake-Frame |
|
||||||
| 58: Volume Boost 0.0.7 | legacy / legacy | works | Default-sink controls pass in local Chromium through real Decky 3.3.0 ARM64: linked slider/keyboard and numeric writes, separate channels, re-link and reopen, with independent Pulse readback including 125%/150% while muted. Exact original volume/mute restored. Audible output, Bluetooth sinks and worn VR unverified. | static archive, local browser, fake-Frame, isolated Linux backend, real Frame SSH |
|
| 58: Volume Boost 0.0.7 | legacy / legacy | works | Default-sink controls pass in local Chromium through real Decky 3.3.0 ARM64: linked slider/keyboard and numeric writes, separate channels, re-link and reopen, with independent Pulse readback including 125%/150% while muted. Exact original volume/mute restored. Audible output, Bluetooth sinks and worn VR unverified. | static archive, local browser, fake-Frame, isolated Linux backend, real Frame SSH |
|
||||||
| 59: Web Browser 1.4.3 | legacy / legacy | broken | The exact frontend now initializes with a null Steam React-root lookup. The browser itself still requires Steam GamepadUI CDP injection and a backend WebSocket unavailable in Framey; no working browser tab was verified. | static archive, local browser, fake-Frame, focused local browser |
|
| 59: Web Browser 1.4.3 | legacy / legacy | broken | The exact frontend now initializes with a null Steam React-root lookup. The browser itself still requires Steam GamepadUI CDP injection and a backend WebSocket unavailable in Framey; no working browser tab was verified. | static archive, local browser, fake-Frame, focused local browser |
|
||||||
| 60: DeckyFileServer 1.2.0 | 1 / 2 | partial | Managed install supplies a native ARM64 helper and removes the exact archive's unnecessary root request. Real Decky 3.3.0 on Frame passes private read-only HTTPS listing/download and upload denial. Frontend address/picker limitations remain; use HTTPS and a preconfigured directory. Uploads, generated thumbnails and worn VR are unverified. Shared Toggle value rendering is repaired; other listed blockers remain. | static archive, local browser, fake-Frame, real Decky 3.3.0 amd64, real Frame SSH |
|
| 60: DeckyFileServer 1.2.0 | 1 / 2 | partial | Managed install supplies a native ARM64 helper, removes the exact archive's unnecessary root request, and displays the backend-returned IP with HTTPS. Real Decky 3.3.0 on Frame passes private read-only HTTPS listing/download and upload denial. The Steam folder picker remains unavailable; use a preconfigured directory. Uploads, generated thumbnails and worn VR are unverified. Existing managed copies need update or reinstall for the address fix. | static archive, local browser, fake-Frame, real Decky 3.3.0 amd64, real Frame SSH |
|
||||||
| 61: TabMaster 2.15.1 | 1 / 2 | partial | Local Chromium rendered the QAM and navigable Overview/Filters documentation with an actual Decky 3.3.0 amd64 backend. Library-tab editing remains unavailable: Steam login/library patches and MobX reactions have no target. ARM64 and worn VR unverified. | static archive, local browser, fake-Frame |
|
| 61: TabMaster 2.15.1 | 1 / 2 | partial | Local Chromium rendered the QAM and navigable Overview/Filters documentation with an actual Decky 3.3.0 amd64 backend. Library-tab editing remains unavailable: Steam login/library patches and MobX reactions have no target. ARM64 and worn VR unverified. | static archive, local browser, fake-Frame |
|
||||||
| 62: TS3 QuickAccess 1.2.0 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: ts3-qs4sd/bin/ts3-qs4sd.so. | static archive, local browser, fake-Frame |
|
| 62: TS3 QuickAccess 1.2.0 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: ts3-qs4sd/bin/ts3-qs4sd.so. | static archive, local browser, fake-Frame |
|
||||||
| 63: Free Loader 1.6.0 | 1 / 2 | partial | The exact archive's duplicate names contain identical bytes and differ only in execute bits; the guarded installer now accepts them, and a private local install/remove transaction passed. The current opaque Chromium host renders Free Games but cannot load deals with backend/network denied. Exact official Python settings and empty local-deals methods passed in a no-network container with a Decky stub. Real Decky, feed refresh, notifications and worn VR remain unverified. | static archive, local browser, fake-Frame pinned install/remove, local exact-archive transaction, isolated Python backend |
|
| 63: Free Loader 1.6.0 | 1 / 2 | partial | The exact archive's byte-identical duplicate ZIP members pass guarded install/remove. Actual Decky 3.3.0 in private network-isolated amd64 and ARM64 containers discovers the plugin, reads default settings and empty local deals, and retains a settings change after restart. Live deal-feed refresh, notifications, Steam store integration, real Frame ARM64 service and worn VR remain unverified; no feed request was made. | static archive, local browser, fake-Frame pinned install/remove, local exact-archive transaction, isolated Python backend, isolated Decky 3.3.0 amd64/ARM64 backend |
|
||||||
| 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 |
|
| 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 |
|
| 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 |
|
| 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 in isolated amd64 and ARM64 containers recorded Frame-shaped private battery samples and returned history/per-app power. A locked real Frame read verified the required sysfs fields, not the managed plugin. Real Frame ARM64 recording, long-term 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 3.3.0 amd64/ARM64 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 in isolated amd64 and ARM64 containers loads the backend and records a private network sample; settings and read-only RPCs pass. External ping/DNS, real Frame ARM64 backend, 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 3.3.0 amd64/ARM64 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
@@ -281,7 +287,7 @@ DFL includes exports imported through decky-frontend-lib / @decky/ui. Steam stor
|
|||||||
|
|
||||||
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.
|
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.
|
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 remains partial. A [frontend URL follow-up](evidence/framey-fileserver-url-2026-10-28.md) changes only the exact managed bundle to display the backend-returned IP with HTTPS, matching the helper. Existing managed copies need update or reinstall. The Steam folder picker is still unavailable; use 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 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)).
|
[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)).
|
||||||
|
|
||||||
|
|||||||
+27
-3
@@ -27,8 +27,22 @@ count. So a build reaches people only when you publish it, after testing it.
|
|||||||
temporary profile. This CI test does not test a physical display, FUSE mount
|
temporary profile. This CI test does not test a physical display, FUSE mount
|
||||||
or a headset.
|
or a headset.
|
||||||
|
|
||||||
2. Download the draft's installers and test them. An installed copy of the
|
2. Independently download and inspect the draft's installers on an x86_64
|
||||||
previous version won't offer the draft, so install it directly.
|
Linux host with `gh`, `dpkg-deb` and `xvfb-run` installed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/audit-release-assets.py v0.4.0 \
|
||||||
|
--report /tmp/frame-v0.4.0-assets.json --launch-x86-appimage
|
||||||
|
```
|
||||||
|
|
||||||
|
The command downloads each of the ten installers by asset ID, verifies its
|
||||||
|
size and SHA-256 against GitHub metadata, matches the downloaded
|
||||||
|
`SHA256SUMS` byte for byte, checks package formats and versions, and starts
|
||||||
|
the x86_64 AppImage with a temporary profile and isolated Xvfb display. It
|
||||||
|
writes the report only after all checks pass. This does not prove that the
|
||||||
|
Mac or Windows installers launch on physical machines. Test those packages
|
||||||
|
separately when the required machines are available. An installed copy of
|
||||||
|
the previous version won't offer the draft, so install it directly.
|
||||||
|
|
||||||
3. Write the release notes on the draft. The update banner links to them.
|
3. Write the release notes on the draft. The update banner links to them.
|
||||||
|
|
||||||
@@ -58,7 +72,11 @@ count. So a build reaches people only when you publish it, after testing it.
|
|||||||
that the uploaded `SHA256SUMS` digest matches a fresh list of those ten
|
that the uploaded `SHA256SUMS` digest matches a fresh list of those ten
|
||||||
installer digests. It attaches
|
installer digests. It attaches
|
||||||
`update.json` (the version, the notes, the release page and each
|
`update.json` (the version, the notes, the release page and each
|
||||||
installer's digest), then publishes the release and marks it latest. It
|
installer's digest), verifies the uploaded asset's SHA-256 and downloads
|
||||||
|
its bytes for an exact readback before publishing the release and marking
|
||||||
|
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
|
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.
|
`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
|
Such a draft is found by its exact title (`Frame Control 0.4.0`, or that
|
||||||
@@ -68,6 +86,12 @@ count. So a build reaches people only when you publish it, after testing it.
|
|||||||
seconds after starting, then every 6 hours, and anyone can use **Check for
|
seconds after starting, then every 6 hours, and anyone can use **Check for
|
||||||
Updates…** (the app menu on macOS, the Help menu elsewhere).
|
Updates…** (the app menu on macOS, the Help menu elsewhere).
|
||||||
|
|
||||||
|
After publication, rerun `audit-release-assets.py` with a new report path.
|
||||||
|
For a published release it also downloads `update.json`, verifies its
|
||||||
|
GitHub SHA-256, and checks that the offered version, release page, notes,
|
||||||
|
installer names, sizes, and digests match that release. This checks the
|
||||||
|
updater channel's published bytes, which the draft audit cannot inspect.
|
||||||
|
|
||||||
To pull a bad release, mark the previous one as latest
|
To pull a bad release, mark the previous one as latest
|
||||||
(`gh release edit v0.3.9 --latest`) or turn the bad one back into a draft.
|
(`gh release edit v0.3.9 --latest`) or turn the bad one back into a draft.
|
||||||
Copies that already updated stay on it. Nothing downgrades them.
|
Copies that already updated stay on it. Nothing downgrades them.
|
||||||
|
|||||||
+3
-2
@@ -233,8 +233,9 @@ before (if it was, it stays, synced to this checkout as Frame Control always
|
|||||||
does). A cleanup that fails counts as a failed step. Results go to
|
does). A cleanup that fails counts as a failed step. Results go to
|
||||||
`tests/smoke/results/<time>-<BUILD_ID>.json` (not committed) with a summary on
|
`tests/smoke/results/<time>-<BUILD_ID>.json` (not committed) with a summary on
|
||||||
screen. The summary counts passed, skipped and failed steps separately. It
|
screen. The summary counts passed, skipped and failed steps separately. It
|
||||||
exits 0 when no step failed (including a skipped optional runtime), 1 if a step
|
exits 0 only when every step passed, 1 if a step failed or was skipped, or 2 if
|
||||||
failed, or 2 if the headset isn't reachable.
|
the headset isn't reachable. A skipped x86-64 launch blocks a release smoke
|
||||||
|
gate even though it remains separately labeled in the result.
|
||||||
|
|
||||||
Smoke process cleanup runs before and after title removal and during the
|
Smoke process cleanup runs before and after title removal and during the
|
||||||
initial/final leftover sweeps. It targets only the reserved `fc_smoke_arm64`,
|
initial/final leftover sweeps. It targets only the reserved `fc_smoke_arm64`,
|
||||||
|
|||||||
@@ -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
|
in Node. Usage: python3 frame/kdeconnect/fetch.py
|
||||||
"""
|
"""
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import http.client
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -24,6 +27,25 @@ def sha256(data):
|
|||||||
return hashlib.sha256(data).hexdigest()
|
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():
|
def fetch():
|
||||||
m = manifest()
|
m = manifest()
|
||||||
PACKAGES.mkdir(exist_ok=True)
|
PACKAGES.mkdir(exist_ok=True)
|
||||||
@@ -32,10 +54,7 @@ def fetch():
|
|||||||
if path.is_file() and sha256(path.read_bytes()) == p["sha256"]:
|
if path.is_file() and sha256(path.read_bytes()) == p["sha256"]:
|
||||||
continue
|
continue
|
||||||
url = m["release"] + p["file"]
|
url = m["release"] + p["file"]
|
||||||
with urllib.request.urlopen(url, timeout=120) as r:
|
data = download_verified(url, p["sha256"])
|
||||||
data = r.read()
|
|
||||||
if sha256(data) != p["sha256"]:
|
|
||||||
raise SystemExit(f"checksum mismatch for {url}: {sha256(data)}")
|
|
||||||
tmp = path.with_suffix(".part")
|
tmp = path.with_suffix(".part")
|
||||||
tmp.write_bytes(data)
|
tmp.write_bytes(data)
|
||||||
tmp.replace(path)
|
tmp.replace(path)
|
||||||
|
|||||||
Executable
+275
@@ -0,0 +1,275 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Independently download and inspect every installer in a GitHub release.
|
||||||
|
|
||||||
|
Run on Linux after the tagged workflow finishes and before publishing its
|
||||||
|
draft. The JSON report is written only after every check passes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import platform
|
||||||
|
import plistlib
|
||||||
|
import re
|
||||||
|
import runpy
|
||||||
|
import shutil
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
REPO = "saphid/frame-control"
|
||||||
|
SCRIPTS = Path(__file__).resolve().parent
|
||||||
|
CHECKSUMS = runpy.run_path(str(SCRIPTS / "release-checksums.py"))
|
||||||
|
INSTALLERS = CHECKSUMS["INSTALLERS"]
|
||||||
|
|
||||||
|
|
||||||
|
class AuditError(Exception):
|
||||||
|
"""Release asset metadata or downloaded bytes failed a check."""
|
||||||
|
|
||||||
|
|
||||||
|
def github_json(path):
|
||||||
|
result = subprocess.run(
|
||||||
|
["gh", "api", f"repos/{REPO}/{path}"],
|
||||||
|
capture_output=True, text=True, check=False, timeout=60,
|
||||||
|
)
|
||||||
|
if result.returncode:
|
||||||
|
raise AuditError(f"GitHub read failed for {path}: {result.stderr.strip()}")
|
||||||
|
try:
|
||||||
|
return json.loads(result.stdout)
|
||||||
|
except json.JSONDecodeError as error:
|
||||||
|
raise AuditError(f"invalid GitHub JSON for {path}: {error}") from error
|
||||||
|
|
||||||
|
|
||||||
|
def find_release(tag):
|
||||||
|
releases = []
|
||||||
|
for page in range(1, 21):
|
||||||
|
batch = github_json(f"releases?per_page=100&page={page}")
|
||||||
|
if not isinstance(batch, list):
|
||||||
|
raise AuditError("GitHub release list is not an array")
|
||||||
|
releases.extend(batch)
|
||||||
|
try:
|
||||||
|
return CHECKSUMS["find_release"](
|
||||||
|
(json.dumps(item) for item in releases), tag
|
||||||
|
)
|
||||||
|
except ValueError as error:
|
||||||
|
if "found 0" not in str(error):
|
||||||
|
raise AuditError(str(error)) from error
|
||||||
|
if len(batch) < 100:
|
||||||
|
break
|
||||||
|
raise AuditError(f"no unique release found for {tag}")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_assets(release):
|
||||||
|
assets = release.get("assets")
|
||||||
|
expected = set(INSTALLERS) | {"SHA256SUMS"}
|
||||||
|
if release.get("draft") is False:
|
||||||
|
expected.add("update.json")
|
||||||
|
if not isinstance(assets, list) or len(assets) != len(expected):
|
||||||
|
raise AuditError(f"expected exactly {len(expected)} release assets")
|
||||||
|
by_name = {asset.get("name"): asset for asset in assets if isinstance(asset, dict)}
|
||||||
|
if set(by_name) != expected or len(by_name) != len(assets):
|
||||||
|
raise AuditError("release has missing, duplicate, or unexpected assets")
|
||||||
|
for name, asset in by_name.items():
|
||||||
|
if (not isinstance(asset.get("id"), int) or asset["id"] <= 0
|
||||||
|
or not isinstance(asset.get("size"), int) or asset["size"] <= 0
|
||||||
|
or not re.fullmatch(r"sha256:[0-9a-fA-F]{64}", asset.get("digest") or "")):
|
||||||
|
raise AuditError(f"{name} has no valid ID, size, or SHA-256 digest")
|
||||||
|
try:
|
||||||
|
expected_sums = CHECKSUMS["checksums"](release).encode("ascii")
|
||||||
|
except ValueError as error:
|
||||||
|
raise AuditError(str(error)) from error
|
||||||
|
return by_name, expected_sums
|
||||||
|
|
||||||
|
|
||||||
|
def sha256(path):
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as source:
|
||||||
|
for block in iter(lambda: source.read(1024 * 1024), b""):
|
||||||
|
digest.update(block)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def download_asset(asset, path):
|
||||||
|
with path.open("wb") as target:
|
||||||
|
result = subprocess.run(
|
||||||
|
["gh", "api", "-H", "Accept: application/octet-stream",
|
||||||
|
f"repos/{REPO}/releases/assets/{asset['id']}"],
|
||||||
|
stdout=target, stderr=subprocess.PIPE, check=False, timeout=1800,
|
||||||
|
)
|
||||||
|
if result.returncode:
|
||||||
|
raise AuditError(f"download failed for {asset['name']}: "
|
||||||
|
f"{result.stderr.decode(errors='replace').strip()}")
|
||||||
|
if path.stat().st_size != asset["size"]:
|
||||||
|
raise AuditError(f"{asset['name']} size differs from GitHub metadata")
|
||||||
|
digest = sha256(path)
|
||||||
|
if digest != asset["digest"][7:].lower():
|
||||||
|
raise AuditError(f"{asset['name']} SHA-256 differs from GitHub metadata")
|
||||||
|
return digest
|
||||||
|
|
||||||
|
|
||||||
|
def validate_update_manifest(path, release):
|
||||||
|
"""Check the bytes running copies read against the published release."""
|
||||||
|
try:
|
||||||
|
manifest = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (UnicodeError, json.JSONDecodeError) as error:
|
||||||
|
raise AuditError(f"update.json is not valid UTF-8 JSON: {error}") from error
|
||||||
|
if not isinstance(manifest, dict):
|
||||||
|
raise AuditError("update.json is not an object")
|
||||||
|
tag = release.get("tag_name")
|
||||||
|
if not isinstance(tag, str) or not re.fullmatch(r"v\d+\.\d+\.\d+", tag):
|
||||||
|
raise AuditError("published release has an invalid tag")
|
||||||
|
if manifest.get("version") != tag[1:]:
|
||||||
|
raise AuditError("update.json version differs from the release tag")
|
||||||
|
page = f"https://github.com/{REPO}/releases/tag/{tag}"
|
||||||
|
if manifest.get("page") != page:
|
||||||
|
raise AuditError("update.json release page differs from the tag page")
|
||||||
|
if manifest.get("notes") != (release.get("body") or "")[:4000]:
|
||||||
|
raise AuditError("update.json notes differ from the release notes")
|
||||||
|
expected = {asset["name"]: {key: asset[key] for key in ("name", "size", "digest")}
|
||||||
|
for asset in release["assets"] if asset["name"] in INSTALLERS}
|
||||||
|
assets = manifest.get("assets")
|
||||||
|
if not isinstance(assets, list) or len(assets) != len(expected):
|
||||||
|
raise AuditError("update.json has an incomplete installer list")
|
||||||
|
names = [asset.get("name") for asset in assets if isinstance(asset, dict)]
|
||||||
|
if (len(names) != len(assets) or not all(isinstance(name, str) for name in names)
|
||||||
|
or len(names) != len(set(names))):
|
||||||
|
raise AuditError("update.json has invalid or duplicate installer entries")
|
||||||
|
if {asset["name"]: asset for asset in assets} != expected:
|
||||||
|
raise AuditError("update.json installers differ from the release assets")
|
||||||
|
return {"version": manifest["version"], "installers": len(assets)}
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_package(path, name, version):
|
||||||
|
if name.endswith(".zip"):
|
||||||
|
with zipfile.ZipFile(path) as archive:
|
||||||
|
bad = archive.testzip()
|
||||||
|
if bad is not None:
|
||||||
|
raise AuditError(f"{name} has a bad ZIP member: {bad}")
|
||||||
|
detail = {"zipCrc": "ok", "members": len(archive.infolist())}
|
||||||
|
if "-mac-" in name:
|
||||||
|
matches = [entry for entry in archive.namelist()
|
||||||
|
if entry.endswith("Frame Control.app/Contents/Info.plist")]
|
||||||
|
if len(matches) != 1:
|
||||||
|
raise AuditError(f"{name} has {len(matches)} app Info.plist files")
|
||||||
|
found = plistlib.loads(archive.read(matches[0])).get("CFBundleShortVersionString")
|
||||||
|
if found != version:
|
||||||
|
raise AuditError(f"{name} bundle version {found!r} is not {version}")
|
||||||
|
detail["bundleVersion"] = found
|
||||||
|
return detail
|
||||||
|
if name.endswith(".deb"):
|
||||||
|
result = subprocess.run(
|
||||||
|
["dpkg-deb", "-f", str(path), "Version", "Architecture"],
|
||||||
|
capture_output=True, text=True, check=False, timeout=60,
|
||||||
|
)
|
||||||
|
fields = [line.partition(":")[2].strip() for line in result.stdout.splitlines()]
|
||||||
|
architecture = "arm64" if "arm64" in name else "amd64"
|
||||||
|
if result.returncode or fields != [version, architecture]:
|
||||||
|
raise AuditError(f"{name} DEB version/architecture mismatch: {fields}")
|
||||||
|
return {"debVersion": fields[0], "architecture": fields[1]}
|
||||||
|
if name.endswith(".AppImage"):
|
||||||
|
with path.open("rb") as source:
|
||||||
|
head = source.read(20)
|
||||||
|
machine = struct.unpack("<H", head[18:20])[0] if len(head) >= 20 else None
|
||||||
|
expected = 183 if "arm64" in name else 62
|
||||||
|
if head[:4] != b"\x7fELF" or machine != expected:
|
||||||
|
raise AuditError(f"{name} ELF machine {machine!r} is not {expected}")
|
||||||
|
return {"elfMachine": machine}
|
||||||
|
if name.endswith(".dmg"):
|
||||||
|
if path.stat().st_size < 512:
|
||||||
|
raise AuditError(f"{name} is too small for a UDIF trailer")
|
||||||
|
with path.open("rb") as source:
|
||||||
|
source.seek(-512, 2)
|
||||||
|
trailer = source.read(512)
|
||||||
|
if trailer[:4] != b"koly":
|
||||||
|
raise AuditError(f"{name} has no UDIF trailer")
|
||||||
|
return {"udifTrailer": "ok"}
|
||||||
|
if name.endswith(".exe"):
|
||||||
|
with path.open("rb") as source:
|
||||||
|
head = source.read(64)
|
||||||
|
if len(head) < 64 or head[:2] != b"MZ":
|
||||||
|
raise AuditError(f"{name} has no MZ header")
|
||||||
|
source.seek(struct.unpack("<I", head[60:64])[0])
|
||||||
|
pe = source.read(6)
|
||||||
|
machine = struct.unpack("<H", pe[4:6])[0] if len(pe) == 6 else None
|
||||||
|
if pe[:4] != b"PE\x00\x00" or machine not in (0x14C, 0x8664):
|
||||||
|
raise AuditError(f"{name} has an invalid PE header or machine")
|
||||||
|
return {"peMachine": machine}
|
||||||
|
raise AuditError(f"no package inspection for {name}")
|
||||||
|
|
||||||
|
|
||||||
|
def audit(tag, report_path, launch_x86_appimage=False):
|
||||||
|
if not re.fullmatch(r"v\d+\.\d+\.\d+", tag):
|
||||||
|
raise AuditError("expected a version tag such as v0.4.54")
|
||||||
|
if report_path.exists():
|
||||||
|
raise AuditError(f"report already exists; choose a new path: {report_path}")
|
||||||
|
if not report_path.parent.is_dir():
|
||||||
|
raise AuditError(f"report directory does not exist: {report_path.parent}")
|
||||||
|
if not shutil.which("gh") or not shutil.which("dpkg-deb"):
|
||||||
|
raise AuditError("gh and dpkg-deb are required on the audit host")
|
||||||
|
if launch_x86_appimage and not shutil.which("xvfb-run"):
|
||||||
|
raise AuditError("xvfb-run is required for --launch-x86-appimage")
|
||||||
|
if launch_x86_appimage and platform.machine().lower() not in ("x86_64", "amd64"):
|
||||||
|
raise AuditError("--launch-x86-appimage requires an x86_64 audit host")
|
||||||
|
release = find_release(tag)
|
||||||
|
by_name, expected_sums = validate_assets(release)
|
||||||
|
with tempfile.TemporaryDirectory(prefix=f"frame-{tag}-assets-") as directory:
|
||||||
|
scratch = Path(directory)
|
||||||
|
sums_path = scratch / "SHA256SUMS"
|
||||||
|
sums_hash = download_asset(by_name["SHA256SUMS"], sums_path)
|
||||||
|
if sums_path.read_bytes() != expected_sums:
|
||||||
|
raise AuditError("SHA256SUMS bytes differ from the ten GitHub asset digests")
|
||||||
|
manifest_detail = None
|
||||||
|
if not release.get("draft"):
|
||||||
|
manifest_path = scratch / "update.json"
|
||||||
|
manifest_digest = download_asset(by_name["update.json"], manifest_path)
|
||||||
|
manifest_detail = validate_update_manifest(manifest_path, release)
|
||||||
|
manifest_detail["sha256"] = manifest_digest
|
||||||
|
rows = []
|
||||||
|
for name in INSTALLERS:
|
||||||
|
path = scratch / name
|
||||||
|
asset = by_name[name]
|
||||||
|
digest = download_asset(asset, path)
|
||||||
|
detail = inspect_package(path, name, tag[1:])
|
||||||
|
if launch_x86_appimage and name == "Frame-Control-linux-x86_64.AppImage":
|
||||||
|
path.chmod(0o755)
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, str(SCRIPTS / "check-appimage-launch.py"), str(path)],
|
||||||
|
capture_output=True, text=True, check=False, timeout=120,
|
||||||
|
)
|
||||||
|
if result.returncode or digest not in result.stdout:
|
||||||
|
raise AuditError(f"isolated AppImage launch failed: {result.stderr.strip()}")
|
||||||
|
detail["appimageLaunch"] = "HTTP 200; FrameControl/1; server stopped"
|
||||||
|
rows.append({"name": name, "size": asset["size"], "sha256": digest, **detail})
|
||||||
|
path.unlink()
|
||||||
|
print(f"verified {name}", flush=True)
|
||||||
|
report = {"tag": tag, "releaseId": release["id"],
|
||||||
|
"tagNameAtAudit": release.get("tag_name"), "draftAtAudit": release.get("draft"),
|
||||||
|
"sha256sumsSha256": sums_hash, "updateManifest": manifest_detail,
|
||||||
|
"assets": rows}
|
||||||
|
with report_path.open("x", encoding="utf-8") as output:
|
||||||
|
json.dump(report, output, indent=2)
|
||||||
|
output.write("\n")
|
||||||
|
print(f"verified {len(rows)} installer downloads; report {report_path}")
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
parser.add_argument("tag", help="release tag, for example v0.4.54")
|
||||||
|
parser.add_argument("--report", required=True, type=Path, help="new JSON report path")
|
||||||
|
parser.add_argument("--launch-x86-appimage", action="store_true",
|
||||||
|
help="also launch the tagged x86_64 AppImage in isolated Xvfb")
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
audit(args.tag, args.report, args.launch_x86_appimage)
|
||||||
|
except (AuditError, OSError, subprocess.TimeoutExpired,
|
||||||
|
zipfile.BadZipFile, plistlib.InvalidFileException) as error:
|
||||||
|
parser.exit(1, f"asset audit failed: {error}\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -40,7 +40,10 @@ def render(data):
|
|||||||
'python3 scripts/framey-audit-browser.py --cache /tmp/framey-archives',
|
'python3 scripts/framey-audit-browser.py --cache /tmp/framey-archives',
|
||||||
'```', '',
|
'```', '',
|
||||||
'Open the browser audit URL in local Chromium, run `runAudit()` and inspect/download `results`. All backend/network actions remain denied.', '',
|
'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 [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. The earlier full 116-plugin fake-Frame audit blocked this archive before the fix. A later [native Decky 3.3.0 check](evidence/framey-free-loader-native-2026-10-28.md) verified plugin discovery, default settings, empty local deals and private setting persistence after restart in network-isolated amd64 and ARM64 containers. Live feed updates, notifications, Steam store integration, real Frame ARM64 service 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 and ARM64, the pinned plugin-local psutil wheel, private settings RPC and one monitoring-loop sample pass. External ping/DNS, a real Frame backend, 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 and ARM64 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. '
|
'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.', '',
|
'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. '
|
'After the VR job released the shared lock, this audit acquired it and used a private temporary Decky 3.3.0 ARM64 user service. '
|
||||||
@@ -128,7 +131,7 @@ def render(data):
|
|||||||
'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. '
|
'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. '
|
'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. '
|
'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.', '',
|
'The plugin remains partial. A [frontend URL follow-up](evidence/framey-fileserver-url-2026-10-28.md) changes only the exact managed bundle to display the backend-returned IP with HTTPS, matching the helper. Existing managed copies need update or reinstall. The Steam folder picker is still unavailable; use a preconfigured directory. Uploads, generated thumbnails and worn VR remain unverified.', '',
|
||||||
'[LocalSend ARM64 build/runtime evidence](evidence/framey-localsend-arm64-2026-10-26.md) '
|
'[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. '
|
'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) '
|
'[Exact official source matching](evidence/framey-localsend-source-match-2026-10-27.md) '
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ def check_coverage():
|
|||||||
shims.add(node.args[0].value)
|
shims.add(node.args[0].value)
|
||||||
cases = json.loads((ROOT/'tests/decky-runtime/cases.json').read_text())
|
cases = json.loads((ROOT/'tests/decky-runtime/cases.json').read_text())
|
||||||
# Frontend-only compatibility probes need no archive/backend adaptation.
|
# Frontend-only compatibility probes need no archive/backend adaptation.
|
||||||
if shims != {case['shim'] for case in cases if case['shim'] is not None}:
|
covered = {shim for case in cases for shim in ([case['shim']] if case['shim'] else []) + case.get('additionalShims', [])}
|
||||||
|
if shims != covered:
|
||||||
raise ValueError('Every managed shim needs an actual Decky runtime case')
|
raise ValueError('Every managed shim needs an actual Decky runtime case')
|
||||||
return cases
|
return cases
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ if len(hits) != 1:
|
|||||||
sys.exit("found %s for %s; expected one draft" % (why, tag))
|
sys.exit("found %s for %s; expected one draft" % (why, tag))
|
||||||
r = hits[0]
|
r = hits[0]
|
||||||
if not r.get("draft"):
|
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)
|
json.dump(r, sys.stdout)
|
||||||
EOF
|
EOF
|
||||||
id=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$tmp/release.json")
|
id=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$tmp/release.json")
|
||||||
@@ -139,7 +139,23 @@ for asset in $old; do
|
|||||||
gh api -X DELETE "repos/$repo/releases/assets/$asset" >/dev/null
|
gh api -X DELETE "repos/$repo/releases/assets/$asset" >/dev/null
|
||||||
done
|
done
|
||||||
gh api -X POST "https://uploads.github.com/repos/$repo/releases/$id/assets?name=update.json" \
|
gh api -X POST "https://uploads.github.com/repos/$repo/releases/$id/assets?name=update.json" \
|
||||||
-H "Content-Type: application/json" --input "$tmp/update.json" >/dev/null
|
-H "Content-Type: application/json" --input "$tmp/update.json" > "$tmp/uploaded-manifest.json"
|
||||||
|
asset_id=$(python3 - "$tmp/uploaded-manifest.json" "$tmp/update.json" <<'EOF'
|
||||||
|
import hashlib, json, pathlib, sys
|
||||||
|
asset = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||||
|
body = pathlib.Path(sys.argv[2]).read_bytes()
|
||||||
|
digest = "sha256:" + hashlib.sha256(body).hexdigest()
|
||||||
|
if (asset.get("name") != "update.json" or not isinstance(asset.get("id"), int)
|
||||||
|
or asset["id"] <= 0 or asset.get("size") != len(body)
|
||||||
|
or asset.get("digest") != digest):
|
||||||
|
sys.exit("not publishing: uploaded update.json metadata differs from its source bytes")
|
||||||
|
print(asset["id"])
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
gh api -H "Accept: application/octet-stream" "repos/$repo/releases/assets/$asset_id" > "$tmp/update-readback.json"
|
||||||
|
cmp -s "$tmp/update.json" "$tmp/update-readback.json" || {
|
||||||
|
echo "not publishing: downloaded update.json differs from its source bytes" >&2; exit 1;
|
||||||
|
}
|
||||||
echo "ok update.json"
|
echo "ok update.json"
|
||||||
|
|
||||||
gh api -X PATCH "repos/$repo/releases/$id" -f tag_name="$tag" -f body="$(cat "$tmp/release-body.txt")" -F draft=false -F prerelease=false \
|
gh api -X PATCH "repos/$repo/releases/$id" -f tag_name="$tag" -f body="$(cat "$tmp/release-body.txt")" -F draft=false -F prerelease=false \
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"id": 67,
|
||||||
|
"shim": "battery-tracker-user-supply",
|
||||||
|
"probe": "battery-tracker"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 114,
|
||||||
|
"shim": "network-sentinel-user-psutil",
|
||||||
|
"probe": "network-sentinel"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": 13,
|
"id": 13,
|
||||||
"shim": null,
|
"shim": null,
|
||||||
"probe": "toggle-settings"
|
"probe": "toggle-settings"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": 63,
|
||||||
|
"shim": null,
|
||||||
|
"probe": "free-loader"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": 12,
|
"id": 12,
|
||||||
"shim": "music-control-system-dbus-env",
|
"shim": "music-control-system-dbus-env",
|
||||||
@@ -32,7 +47,10 @@
|
|||||||
{
|
{
|
||||||
"id": 60,
|
"id": 60,
|
||||||
"shim": "deckyfileserver-user-native",
|
"shim": "deckyfileserver-user-native",
|
||||||
"probe": "fileserver"
|
"probe": "fileserver",
|
||||||
|
"additionalShims": [
|
||||||
|
"deckyfileserver-https-address"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 125,
|
"id": 125,
|
||||||
|
|||||||
@@ -85,12 +85,13 @@ def main():
|
|||||||
Path('/tmp/frame-control-decky-plugin-runtime-test').write_bytes(data)
|
Path('/tmp/frame-control-decky-plugin-runtime-test').write_bytes(data)
|
||||||
install = subprocess.run([sys.executable, '-', 'install', 'runtime-test', str(record['id']),
|
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']),
|
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
|
assert install.returncode == 0, install.stderr
|
||||||
plugin = base/'plugins'/record['folder']
|
plugin = base/'plugins'/record['folder']
|
||||||
receipt = json.loads((plugin/'.frame-control-store.json').read_text())
|
receipt = json.loads((plugin/'.frame-control-store.json').read_text())
|
||||||
if not args.reproduce_timer_regression:
|
if not args.reproduce_timer_regression:
|
||||||
assert receipt.get('compatShims', []) == ([case['shim']] if case['shim'] else []), receipt
|
expected_shims = ([case['shim']] if case['shim'] else []) + case.get('additionalShims', [])
|
||||||
|
assert receipt.get('compatShims', []) == expected_shims, receipt
|
||||||
else:
|
else:
|
||||||
assert record['id'] == 96
|
assert record['id'] == 96
|
||||||
with zipfile.ZipFile(io.BytesIO(data)) as original:
|
with zipfile.ZipFile(io.BytesIO(data)) as original:
|
||||||
@@ -119,6 +120,13 @@ def main():
|
|||||||
env.update(UNPRIVILEGED_PATH=str(base), PRIVILEGED_PATH=str(base), CHOWN_PLUGIN_PATH='0',
|
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',
|
LIVE_RELOAD='0', USE_CEF_CLOSE_WORKAROUND='0', KEEP_SYSTEMD_SERVICE='1',
|
||||||
DBUS_SESSION_BUS_ADDRESS='unix:path=/run/user/1100/bus')
|
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 = []
|
processes = []
|
||||||
report = {'id': record['id'], 'name': record['name'], 'version': record['version'],
|
report = {'id': record['id'], 'name': record['name'], 'version': record['version'],
|
||||||
'sha256': record['sha256'], 'shim': case['shim'], 'calls': []}
|
'sha256': record['sha256'], 'shim': case['shim'], 'calls': []}
|
||||||
@@ -152,7 +160,38 @@ def main():
|
|||||||
report['calls'].append({'method': method, 'result': response})
|
report['calls'].append({'method': method, 'result': response})
|
||||||
return response
|
return response
|
||||||
|
|
||||||
if case['probe'] == 'localsend':
|
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'
|
helper = plugin/'bin/localsend-core'
|
||||||
expected = ('e20363237e606ee65eae857792176d21a0bb8ba863489952728eaf2b13277dae'
|
expected = ('e20363237e606ee65eae857792176d21a0bb8ba863489952728eaf2b13277dae'
|
||||||
if __import__('platform').machine() == 'aarch64'
|
if __import__('platform').machine() == 'aarch64'
|
||||||
@@ -202,6 +241,30 @@ def main():
|
|||||||
report['result'] = ('original empty argument bug reproduced: thumbnail policy ignored'
|
report['result'] = ('original empty argument bug reproduced: thumbnail policy ignored'
|
||||||
if args.reproduce_fileserver_empty_flag_regression else
|
if args.reproduce_fileserver_empty_flag_regression else
|
||||||
'discovery, API1 settings, native HTTPS listing/download, thumbnail policy and stop passed; uploads disabled')
|
'discovery, API1 settings, native HTTPS listing/download, thumbnail policy and stop passed; uploads disabled')
|
||||||
|
elif case['probe'] == 'free-loader':
|
||||||
|
settings = current('settings_read')
|
||||||
|
assert settings['notify_on_free_games'] is True, settings
|
||||||
|
assert settings['larger_text'] is False, settings
|
||||||
|
assert current('read_deals') == {}
|
||||||
|
# This plugin commits defaults on startup and writes an empty
|
||||||
|
# deals cache on first read. Both paths are private to this
|
||||||
|
# disposable container; no feed, notification or store call.
|
||||||
|
settings_dir = base/'settings'/record['folder']
|
||||||
|
assert (settings_dir/'settings.json').is_file()
|
||||||
|
assert (settings_dir/'deal_db.json').is_file()
|
||||||
|
current('settings_setSetting', 'larger_text', True)
|
||||||
|
assert current('settings_read')['larger_text'] is True
|
||||||
|
os.killpg(backend.pid, signal.SIGTERM)
|
||||||
|
backend.wait(timeout=8)
|
||||||
|
backend = subprocess.Popen([str(loader)], env=env, stdout=log, stderr=log,
|
||||||
|
start_new_session=True)
|
||||||
|
processes.append(backend)
|
||||||
|
until_ready(lambda: rpc('loader/get_plugins'), 'restarted Decky discovery')
|
||||||
|
assert until_ready(lambda: current('settings_read')['larger_text'],
|
||||||
|
'private Free Loader setting after restart') is True
|
||||||
|
assert current('read_deals') == {}
|
||||||
|
report['result'] = ('discovery, default settings, empty local deals, '
|
||||||
|
'private setting write/read and restart persistence passed; feed, notifications and VR unverified')
|
||||||
elif case['probe'] == 'toggle-settings':
|
elif case['probe'] == 'toggle-settings':
|
||||||
def call(method, *values):
|
def call(method, *values):
|
||||||
result = rpc('loader/call_plugin_method', record['name'], method, *values)
|
result = rpc('loader/call_plugin_method', record['name'], method, *values)
|
||||||
@@ -295,7 +358,22 @@ def main():
|
|||||||
assert case['probe'] == 'screenshots'
|
assert case['probe'] == 'screenshots'
|
||||||
assert legacy('was_rescued') is False
|
assert legacy('was_rescued') is False
|
||||||
assert legacy('aggregate_all', allapps=[]) == 0
|
assert legacy('aggregate_all', allapps=[]) == 0
|
||||||
report['result'] = 'discovery, backend import and empty screenshot aggregation passed'
|
screenshots = home/'.local/share/Steam/userdata/9001/760/remote/4242/screenshots'
|
||||||
|
screenshots.mkdir(parents=True)
|
||||||
|
marker = b'Framey private screenshot fixture\n'
|
||||||
|
(screenshots/'selected.jpg').write_bytes(marker)
|
||||||
|
legacy('set_id_map_fronend', allapps=[[4242, 'Private Game']])
|
||||||
|
assert legacy('copy_screenshot', app_id=4242,
|
||||||
|
url='https://localhost/selected.jpg') is True
|
||||||
|
copied = home/'Pictures/Screenshots/Private Game/selected.jpg'
|
||||||
|
assert copied.read_bytes() == marker
|
||||||
|
assert (home/'Pictures/Screenshots/most_recent.jpg').read_bytes() == marker
|
||||||
|
(screenshots/'historical.jpg').write_bytes(b'Framey historical fixture\n')
|
||||||
|
assert legacy('aggregate_all', allapps=[[4242, 'Private Game']]) == 1
|
||||||
|
assert (copied.parent/'historical.jpg').read_bytes() == b'Framey historical fixture\n'
|
||||||
|
assert legacy('was_rescued') is False
|
||||||
|
report['result'] = ('discovery, backend import, empty aggregation, private screenshot '
|
||||||
|
'copy and historical aggregation passed')
|
||||||
log.flush()
|
log.flush()
|
||||||
log.seek(0)
|
log.seek(0)
|
||||||
assert 'Starting Decky version v3.3.0' in log.read()
|
assert 'Starting Decky version v3.3.0' in log.read()
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import subprocess
|
|||||||
import threading
|
import threading
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
from harness import FrameTestCase, ROOT, Server, api, finished, ok, require, ssh
|
from harness import FrameTestCase, ROOT, Server, api, finished, ok, require, reset, ssh
|
||||||
|
import frame_decky_store
|
||||||
from framey_official import LEGACY_PLUGINS, PLUGINS as PANEL_PLUGINS, archive as official_archive
|
from framey_official import LEGACY_PLUGINS, PLUGINS as PANEL_PLUGINS, archive as official_archive
|
||||||
|
|
||||||
OFFICIAL_PLUGINS = PANEL_PLUGINS + LEGACY_PLUGINS
|
OFFICIAL_PLUGINS = PANEL_PLUGINS + LEGACY_PLUGINS
|
||||||
@@ -99,6 +100,13 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
|
|||||||
input=source, capture_output=True, timeout=20)
|
input=source, capture_output=True, timeout=20)
|
||||||
self.assertEqual(copy.returncode, 0, copy.stderr)
|
self.assertEqual(copy.returncode, 0, copy.stderr)
|
||||||
|
|
||||||
|
def test_fake_frame_reset_clears_managed_decky_state(self):
|
||||||
|
ssh('mkdir -p ~/.local/share/frame-control/decky/plugins/test-leftover ~/.config/systemd/user')
|
||||||
|
ssh('touch ~/.config/systemd/user/frame-control-decky.service')
|
||||||
|
reset()
|
||||||
|
self.assertEqual(ssh('test -e ~/.local/share/frame-control/decky && echo yes || echo no').strip(), 'no')
|
||||||
|
self.assertEqual(ssh('test -e ~/.config/systemd/user/frame-control-decky.service && echo yes || echo no').strip(), 'no')
|
||||||
|
|
||||||
def test_store_install_update_remove_and_guards(self):
|
def test_store_install_update_remove_and_guards(self):
|
||||||
self.backend()
|
self.backend()
|
||||||
status, _, _ = api('GET', '/api/decky/store', headers={'X-Frame-UI':''})
|
status, _, _ = api('GET', '/api/decky/store', headers={'X-Frame-UI':''})
|
||||||
@@ -190,6 +198,9 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
|
|||||||
installed = f"~/.local/share/frame-control/decky/plugins/{plugin['folder']}"
|
installed = f"~/.local/share/frame-control/decky/plugins/{plugin['folder']}"
|
||||||
with zipfile.ZipFile(io.BytesIO(official_archive(plugin))) as bundle:
|
with zipfile.ZipFile(io.BytesIO(official_archive(plugin))) as bundle:
|
||||||
expected = hashlib.sha256(bundle.read(plugin['folder']+'/dist/index.js')).hexdigest()
|
expected = hashlib.sha256(bundle.read(plugin['folder']+'/dist/index.js')).hexdigest()
|
||||||
|
if plugin['id'] == 60:
|
||||||
|
# The pinned managed bundle rewrites the two incorrect addresses.
|
||||||
|
expected = '05819ddd0ee9c3ce7eeb184977a2f7905d7c8f5cfaec8bdff837d7935985a0e7'
|
||||||
self.assertEqual(ssh(f'sha256sum {installed}/dist/index.js').split()[0], expected)
|
self.assertEqual(ssh(f'sha256sum {installed}/dist/index.js').split()[0], expected)
|
||||||
self.assertEqual(json.loads(ssh(f'cat {installed}/plugin.json')).get('api_version', 'legacy'), plugin['api_version'])
|
self.assertEqual(json.loads(ssh(f'cat {installed}/plugin.json')).get('api_version', 'legacy'), plugin['api_version'])
|
||||||
if plugin['id'] == 96:
|
if plugin['id'] == 96:
|
||||||
@@ -205,8 +216,8 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
|
|||||||
if plugin['id'] == 60:
|
if plugin['id'] == 60:
|
||||||
self.assertEqual(json.loads(ssh(f'cat {installed}/plugin.json'))['flags'], [])
|
self.assertEqual(json.loads(ssh(f'cat {installed}/plugin.json'))['flags'], [])
|
||||||
receipt = json.loads(ssh(f'cat {installed}/.frame-control-store.json'))
|
receipt = json.loads(ssh(f'cat {installed}/.frame-control-store.json'))
|
||||||
self.assertEqual(receipt['compatShims'], ['deckyfileserver-user-native'])
|
self.assertEqual(receipt['compatShims'],
|
||||||
import frame_decky_store
|
['deckyfileserver-user-native', 'deckyfileserver-https-address'])
|
||||||
arch = ssh('uname -m').strip()
|
arch = ssh('uname -m').strip()
|
||||||
self.assertEqual(ssh(f'sha256sum {installed}/bin/backend').split()[0],
|
self.assertEqual(ssh(f'sha256sum {installed}/bin/backend').split()[0],
|
||||||
frame_decky_store.FILESERVER_BINARIES[arch])
|
frame_decky_store.FILESERVER_BINARIES[arch])
|
||||||
@@ -225,6 +236,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'))
|
receipt = json.loads(ssh(f'cat {installed}/.frame-control-store.json'))
|
||||||
self.assertEqual(receipt['compatShims'], ['deckyspy-psutil-and-battery'])
|
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')
|
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):
|
if plugin['id'] in (12, 111):
|
||||||
receipt = json.loads(ssh(f'cat {installed}/.frame-control-store.json'))
|
receipt = json.loads(ssh(f'cat {installed}/.frame-control-store.json'))
|
||||||
self.assertEqual(receipt['compatShims'], ['music-control-system-dbus-env'])
|
self.assertEqual(receipt['compatShims'], ['music-control-system-dbus-env'])
|
||||||
|
|||||||
@@ -304,6 +304,8 @@ def clean_home():
|
|||||||
pass
|
pass
|
||||||
for pattern in (fs.DEVKIT_GAMES + '/*', fs.DEVKIT_GAMES + '/.[!.]*', HOME + '/devkit-utils',
|
for pattern in (fs.DEVKIT_GAMES + '/*', fs.DEVKIT_GAMES + '/.[!.]*', HOME + '/devkit-utils',
|
||||||
HOME + '/.devkit-utils.frame-control', HOME + '/Applications', HOME + '/Downloads/*',
|
HOME + '/.devkit-utils.frame-control', HOME + '/Applications', HOME + '/Downloads/*',
|
||||||
|
HOME + '/.local/share/frame-control',
|
||||||
|
HOME + '/.config/systemd/user/frame-control-decky.service',
|
||||||
fs.STEAM_ROOT + '/steamapps/compatdata', fs.STEAM_ROOT + '/steamapps/shadercache',
|
fs.STEAM_ROOT + '/steamapps/compatdata', fs.STEAM_ROOT + '/steamapps/shadercache',
|
||||||
fs.STEAM_ROOT + '/logs', '/var/log/fakeframe/devkit-*', '/var/log/fakeframe/shortcut-*'):
|
fs.STEAM_ROOT + '/logs', '/var/log/fakeframe/devkit-*', '/var/log/fakeframe/shortcut-*'):
|
||||||
for p in glob.glob(pattern):
|
for p in glob.glob(pattern):
|
||||||
|
|||||||
+11
-3
@@ -9,6 +9,7 @@ $FAKEGH_NEW_ID (default 9001) without adding it to the listing, like GitHub's li
|
|||||||
lagging a fresh draft. With jq installed, --jq filters a response the way gh does
|
lagging a fresh draft. With jq installed, --jq filters a response the way gh does
|
||||||
(raw strings, compact JSON)."""
|
(raw strings, compact JSON)."""
|
||||||
import json
|
import json
|
||||||
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -120,10 +121,17 @@ elif method == "POST" and endpoint == repo + "releases":
|
|||||||
answer(new, json.dumps(new))
|
answer(new, json.dumps(new))
|
||||||
elif method == "DELETE" and endpoint.startswith(repo + "releases/assets/"):
|
elif method == "DELETE" and endpoint.startswith(repo + "releases/assets/"):
|
||||||
pass
|
pass
|
||||||
|
elif method == "GET" and endpoint == repo + "releases/assets/901":
|
||||||
|
data = open(os.environ["FAKEGH_UPLOAD"], "rb").read()
|
||||||
|
if os.environ.get("FAKEGH_CORRUPT_MANIFEST_READBACK") == "1":
|
||||||
|
data += b" "
|
||||||
|
sys.stdout.buffer.write(data)
|
||||||
elif method == "POST" and endpoint.startswith("https://uploads.github.com/" + repo + "releases/"):
|
elif method == "POST" and endpoint.startswith("https://uploads.github.com/" + repo + "releases/"):
|
||||||
with open(inp) as src, open(os.environ["FAKEGH_UPLOAD"], "w") as dst:
|
data = open(inp, "rb").read()
|
||||||
dst.write(src.read())
|
with open(os.environ["FAKEGH_UPLOAD"], "wb") as dst:
|
||||||
print("{}")
|
dst.write(data)
|
||||||
|
print(json.dumps({"id": 901, "name": "update.json", "size": len(data),
|
||||||
|
"digest": "sha256:" + hashlib.sha256(data).hexdigest()}))
|
||||||
elif method == "PATCH" and endpoint.startswith(repo + "releases/"):
|
elif method == "PATCH" and endpoint.startswith(repo + "releases/"):
|
||||||
rid = endpoint[len(repo + "releases/"):]
|
rid = endpoint[len(repo + "releases/"):]
|
||||||
if not rid.isdigit(): # what GitHub said to v0.4.2's PATCH of releases/ (an empty id)
|
if not rid.isdigit(): # what GitHub said to v0.4.2's PATCH of releases/ (an empty id)
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+12
-1
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
These are unmodified ZIPs downloaded from the official Decky store CDN on
|
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-25, except `96.zip` and `111.zip` downloaded on 2026-10-26 and `60.zip` on
|
||||||
2026-10-27; `13.zip` and `125.zip` were 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
|
Each ZIP includes its upstream `LICENSE`. They are test data, not plugins shipped or installed by
|
||||||
Frame Control. The fake Frame tests serve them
|
Frame Control. The fake Frame tests serve them
|
||||||
from a local fixture so CI does not depend on the store being online.
|
from a local fixture so CI does not depend on the store being online.
|
||||||
@@ -12,6 +13,8 @@ from a local fixture so CI does not depend on the store being online.
|
|||||||
| `13.zip` | Pause Games | 1.0.3 | `4aefdfb0ef25dea9c6d9fd037731c8b99ee8a1be966c2f7d5b0602112ef29f62` |
|
| `13.zip` | Pause Games | 1.0.3 | `4aefdfb0ef25dea9c6d9fd037731c8b99ee8a1be966c2f7d5b0602112ef29f62` |
|
||||||
| `60.zip` | DeckyFileServer | 1.2.0 | `7dc2ecdcd05aa415d1ac90afaff28ecb17f77809a2bc7a77aee8e92505b976f0` |
|
| `60.zip` | DeckyFileServer | 1.2.0 | `7dc2ecdcd05aa415d1ac90afaff28ecb17f77809a2bc7a77aee8e92505b976f0` |
|
||||||
| `125.zip` | Decky Localsend | 0.4.5 | `e271ccb55eb23f918b99a69f79aec905df4c9b2ef7b23bbf4ec1c2d1a6f0c6b7` |
|
| `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` |
|
| `96.zip` | Simple Timer | 1.0.0 | `973d927e9d9a1feec04679c564af7e389a729b6735d2a988ffc2cb46ce4eb312` |
|
||||||
| `7.zip` | CSS Loader | 2.1.3 | `99beaf1374af82408e6d056e0b0147e5fca544e1035fa7ab88419231697bc231` |
|
| `7.zip` | CSS Loader | 2.1.3 | `99beaf1374af82408e6d056e0b0147e5fca544e1035fa7ab88419231697bc231` |
|
||||||
| `12.zip` | MusicControl | 1.1.6 | `246b89fd653c60a735b1cc401d1b0937d5cb969eea9024561661ff741f081d62` |
|
| `12.zip` | MusicControl | 1.1.6 | `246b89fd653c60a735b1cc401d1b0937d5cb969eea9024561661ff741f081d62` |
|
||||||
@@ -57,6 +60,14 @@ 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
|
for private settings and a synthetic exclusion list. No process-control method
|
||||||
is called. The official frontend's standalone Toggle component separately
|
is called. The official frontend's standalone Toggle component separately
|
||||||
checks saved true/false state and matching settings callbacks; effects are inert.
|
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
|
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
|
native helper replacement. HTTPS listing/download use only public markers in a
|
||||||
private directory; uploads remain disabled. The exact source build, GPL licenses
|
private directory; uploads remain disabled. The exact source build, GPL licenses
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ from pathlib import Path
|
|||||||
|
|
||||||
ROOT = Path(__file__).parent / 'fixtures/framey-official'
|
ROOT = Path(__file__).parent / 'fixtures/framey-official'
|
||||||
PLUGINS = (
|
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,
|
{'id': 13, 'name': 'Pause Games', 'version': '1.0.3', 'api_version': 1,
|
||||||
'folder': 'SDH-PauseGames', 'sha256': '4aefdfb0ef25dea9c6d9fd037731c8b99ee8a1be966c2f7d5b0602112ef29f62'},
|
'folder': 'SDH-PauseGames', 'sha256': '4aefdfb0ef25dea9c6d9fd037731c8b99ee8a1be966c2f7d5b0602112ef29f62'},
|
||||||
{'id': 60, 'name': 'DeckyFileServer', 'version': '1.2.0', 'api_version': 1,
|
{'id': 60, 'name': 'DeckyFileServer', 'version': '1.2.0', 'api_version': 1,
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {readFileSync} from 'node:fs';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
|
||||||
|
const source = readFileSync(process.argv[2], 'utf8');
|
||||||
|
const testSource = source.replace(/export \{ index as default \};/, 'globalThis.__Content = Content;');
|
||||||
|
assert.notEqual(testSource, source, 'official module export must be present');
|
||||||
|
|
||||||
|
const DFL = new Proxy({
|
||||||
|
definePlugin: factory => factory(),
|
||||||
|
staticClasses: {Title: 'title'},
|
||||||
|
}, {get(target, name) { return target[name] ?? name; }});
|
||||||
|
const createElement = (type, props, ...children) => ({type, props: props ?? {}, children});
|
||||||
|
|
||||||
|
for (const allow_uploads of [false, true]) {
|
||||||
|
const SP_REACT = {
|
||||||
|
createElement,
|
||||||
|
createContext: value => ({value}),
|
||||||
|
useState: initial => [{...initial, server_running: true,
|
||||||
|
ip_address: '192.0.2.5', port: 8421, allow_uploads}, () => {}],
|
||||||
|
useEffect() {},
|
||||||
|
};
|
||||||
|
const window = {SP_REACT,
|
||||||
|
__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit: {
|
||||||
|
connect: () => ({_version: 2, callable: () => async () => ({}), openFilePicker: async () => ({})}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const context = vm.createContext({window, SP_REACT, DFL, setInterval() {}, clearInterval() {}});
|
||||||
|
vm.runInContext(testSource, context);
|
||||||
|
const tree = context.__Content();
|
||||||
|
const fields = [];
|
||||||
|
function visit(value) {
|
||||||
|
if (!value || typeof value !== 'object') return;
|
||||||
|
if (value.type === DFL.Field) fields.push(value);
|
||||||
|
for (const child of value.children ?? []) visit(child);
|
||||||
|
}
|
||||||
|
visit(tree);
|
||||||
|
const addresses = fields.filter(field => field.props.label === 'Server Address');
|
||||||
|
assert.equal(addresses.length, 1);
|
||||||
|
assert.equal(addresses[0].children.join(''), 'https://192.0.2.5:8421');
|
||||||
|
assert.equal(fields.filter(field => field.children.join('').includes('steamdeck')).length, 0);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
@@ -28,8 +28,8 @@ rechecked around deletion; shared Proton services are never stopped by name.
|
|||||||
|
|
||||||
Usage: python3 tests/smoke/frame_smoke.py [--pair] (or scripts/frame-smoke.sh)
|
Usage: python3 tests/smoke/frame_smoke.py [--pair] (or scripts/frame-smoke.sh)
|
||||||
Env: FRAME_ALIAS (default frame), FRAME_SMOKE_RESULTS (default tests/smoke/results)
|
Env: FRAME_ALIAS (default frame), FRAME_SMOKE_RESULTS (default tests/smoke/results)
|
||||||
Exit status: 0 no step failed (optional runtime may skip a launch), 1 a step
|
Exit status: 0 every step passed, 1 a step failed or was skipped, 2 the
|
||||||
failed, 2 the headset isn't reachable.
|
headset isn't reachable. A skipped launch is not a release smoke pass.
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
@@ -425,7 +425,7 @@ def run_smoke(args, before):
|
|||||||
'passed': passed, 'skipped': skipped, 'failed': failed,
|
'passed': passed, 'skipped': skipped, 'failed': failed,
|
||||||
'devkit_utils_there_before': had_utils, 'steps': smoke.steps}, indent=1))
|
'devkit_utils_there_before': had_utils, 'steps': smoke.steps}, indent=1))
|
||||||
print(f'==> {passed} passed, {skipped} skipped, {failed} failed on build {build}; results in {out}')
|
print(f'==> {passed} passed, {skipped} skipped, {failed} failed on build {build}; results in {out}')
|
||||||
return 0 if failed == 0 else 1
|
return 0 if failed == 0 and skipped == 0 else 1
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"""Independent release audit rejects incomplete metadata and wrong package versions."""
|
||||||
|
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||||
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import plistlib
|
||||||
|
import struct
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "audit-release-assets.py"
|
||||||
|
spec = importlib.util.spec_from_file_location("audit_release_assets", SCRIPT)
|
||||||
|
audit = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(audit)
|
||||||
|
|
||||||
|
|
||||||
|
def release_assets():
|
||||||
|
assets = []
|
||||||
|
for number, name in enumerate(audit.INSTALLERS, 1):
|
||||||
|
assets.append({
|
||||||
|
"id": number, "name": name, "size": number,
|
||||||
|
"digest": "sha256:" + hashlib.sha256(str(number).encode()).hexdigest(),
|
||||||
|
})
|
||||||
|
sums = audit.CHECKSUMS["checksums"]({"assets": assets}).encode("ascii")
|
||||||
|
assets.append({
|
||||||
|
"id": 99, "name": "SHA256SUMS", "size": len(sums),
|
||||||
|
"digest": "sha256:" + hashlib.sha256(sums).hexdigest(),
|
||||||
|
})
|
||||||
|
return assets, sums
|
||||||
|
|
||||||
|
|
||||||
|
class AssetAudit(unittest.TestCase):
|
||||||
|
def test_published_manifest_matches_release_bytes(self):
|
||||||
|
assets, _ = release_assets()
|
||||||
|
release = {"tag_name": "v9.8.7", "body": "Release notes", "assets": assets}
|
||||||
|
manifest = {
|
||||||
|
"version": "9.8.7",
|
||||||
|
"page": "https://github.com/saphid/frame-control/releases/tag/v9.8.7",
|
||||||
|
"notes": "Release notes",
|
||||||
|
"assets": [{key: asset[key] for key in ("name", "size", "digest")}
|
||||||
|
for asset in assets if asset["name"] in audit.INSTALLERS],
|
||||||
|
}
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "update.json"
|
||||||
|
path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
self.assertEqual(audit.validate_update_manifest(path, release),
|
||||||
|
{"version": "9.8.7", "installers": 10})
|
||||||
|
for change in (
|
||||||
|
{"version": "9.8.6"},
|
||||||
|
{"page": "https://github.com/elsewhere/releases/tag/v9.8.7"},
|
||||||
|
{"notes": "stale notes"},
|
||||||
|
{"assets": manifest["assets"][:-1]},
|
||||||
|
{"assets": [dict(manifest["assets"][0], digest="sha256:" + "0" * 64),
|
||||||
|
*manifest["assets"][1:]]},
|
||||||
|
):
|
||||||
|
with self.subTest(change=change):
|
||||||
|
path.write_text(json.dumps({**manifest, **change}), encoding="utf-8")
|
||||||
|
with self.assertRaises(audit.AuditError):
|
||||||
|
audit.validate_update_manifest(path, release)
|
||||||
|
|
||||||
|
def test_complete_metadata_uses_release_checksum_order(self):
|
||||||
|
assets, sums = release_assets()
|
||||||
|
by_name, expected = audit.validate_assets({"assets": assets})
|
||||||
|
self.assertEqual(len(by_name), 11)
|
||||||
|
self.assertEqual(expected, sums)
|
||||||
|
|
||||||
|
def test_published_release_requires_update_manifest(self):
|
||||||
|
assets, sums = release_assets()
|
||||||
|
manifest = {"id": 100, "name": "update.json", "size": 20,
|
||||||
|
"digest": "sha256:" + "a" * 64}
|
||||||
|
with self.assertRaises(audit.AuditError):
|
||||||
|
audit.validate_assets({"draft": False, "assets": assets})
|
||||||
|
by_name, expected = audit.validate_assets({"draft": False, "assets": [*assets, manifest]})
|
||||||
|
self.assertEqual(len(by_name), 12)
|
||||||
|
self.assertEqual(expected, sums)
|
||||||
|
|
||||||
|
def test_missing_duplicate_and_unhashed_assets_fail(self):
|
||||||
|
assets, _ = release_assets()
|
||||||
|
variants = (
|
||||||
|
assets[:-1],
|
||||||
|
[*assets, assets[0]],
|
||||||
|
[dict(assets[0], digest="sha256:bad"), *assets[1:]],
|
||||||
|
[dict(assets[0], size=0), *assets[1:]],
|
||||||
|
)
|
||||||
|
for changed in variants:
|
||||||
|
with self.subTest(changed=changed[0]), self.assertRaises(audit.AuditError):
|
||||||
|
audit.validate_assets({"assets": changed})
|
||||||
|
|
||||||
|
def test_mac_zip_bundle_version_must_match_tag(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "mac.zip"
|
||||||
|
with zipfile.ZipFile(path, "w") as archive:
|
||||||
|
archive.writestr(
|
||||||
|
"Frame Control.app/Contents/Info.plist",
|
||||||
|
plistlib.dumps({"CFBundleShortVersionString": "9.8.7"}),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
audit.inspect_package(path, "Frame-Control-mac-arm64.zip", "9.8.7")["bundleVersion"],
|
||||||
|
"9.8.7",
|
||||||
|
)
|
||||||
|
with self.assertRaises(audit.AuditError):
|
||||||
|
audit.inspect_package(path, "Frame-Control-mac-arm64.zip", "9.8.8")
|
||||||
|
|
||||||
|
def test_appimage_machine_must_match_name(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "image"
|
||||||
|
path.write_bytes(b"\x7fELF" + bytes(14) + struct.pack("<H", 183))
|
||||||
|
self.assertEqual(
|
||||||
|
audit.inspect_package(path, "Frame-Control-linux-arm64.AppImage", "9.8.7"),
|
||||||
|
{"elfMachine": 183},
|
||||||
|
)
|
||||||
|
with self.assertRaises(audit.AuditError):
|
||||||
|
audit.inspect_package(path, "Frame-Control-linux-x86_64.AppImage", "9.8.7")
|
||||||
|
|
||||||
|
def test_existing_report_is_never_overwritten(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "report.json"
|
||||||
|
path.write_text("keep")
|
||||||
|
with self.assertRaisesRegex(audit.AuditError, "already exists"):
|
||||||
|
audit.audit("v9.8.7", path)
|
||||||
|
self.assertEqual(path.read_text(), "keep")
|
||||||
|
|
||||||
|
def test_missing_report_directory_fails_before_download(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "missing" / "report.json"
|
||||||
|
with self.assertRaisesRegex(audit.AuditError, "directory does not exist"):
|
||||||
|
audit.audit("v9.8.7", path)
|
||||||
|
|
||||||
|
def test_bad_downloaded_checksum_file_creates_no_report(self):
|
||||||
|
assets, _ = release_assets()
|
||||||
|
release = {"id": 42, "tag_name": "v9.8.7", "draft": True, "assets": assets}
|
||||||
|
|
||||||
|
def bad_download(asset, path):
|
||||||
|
path.write_bytes(b"wrong checksum file\n")
|
||||||
|
return asset["digest"][7:]
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
report = Path(directory) / "report.json"
|
||||||
|
with mock.patch.object(audit, "find_release", return_value=release), \
|
||||||
|
mock.patch.object(audit, "download_asset", side_effect=bad_download), \
|
||||||
|
mock.patch.object(audit.shutil, "which", return_value="/fake/tool"):
|
||||||
|
with self.assertRaisesRegex(audit.AuditError, "SHA256SUMS bytes differ"):
|
||||||
|
audit.audit("v9.8.7", report)
|
||||||
|
self.assertFalse(report.exists())
|
||||||
|
|
||||||
|
def test_bad_published_manifest_creates_no_report(self):
|
||||||
|
assets, sums = release_assets()
|
||||||
|
assets.append({"id": 100, "name": "update.json", "size": 2,
|
||||||
|
"digest": "sha256:" + hashlib.sha256(b"{}").hexdigest()})
|
||||||
|
release = {"id": 42, "tag_name": "v9.8.7", "draft": False,
|
||||||
|
"body": "Release notes", "assets": assets}
|
||||||
|
|
||||||
|
def download(asset, path):
|
||||||
|
path.write_bytes(sums if asset["name"] == "SHA256SUMS" else b"{}")
|
||||||
|
return asset["digest"][7:]
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
report = Path(directory) / "report.json"
|
||||||
|
with mock.patch.object(audit, "find_release", return_value=release), \
|
||||||
|
mock.patch.object(audit, "download_asset", side_effect=download), \
|
||||||
|
mock.patch.object(audit.shutil, "which", return_value="/fake/tool"):
|
||||||
|
with self.assertRaisesRegex(audit.AuditError, "version differs"):
|
||||||
|
audit.audit("v9.8.7", report)
|
||||||
|
self.assertFalse(report.exists())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
"""Official Decky store transport and owned plugin transaction."""
|
"""Official Decky store transport and owned plugin transaction."""
|
||||||
import sandbox # noqa: F401
|
import sandbox # noqa: F401
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import asyncio
|
||||||
|
import importlib.util
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import secrets
|
import secrets
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import types
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
import warnings
|
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())):
|
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'])
|
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):
|
def test_accepts_hashed_non_root_archive(self):
|
||||||
data, digest = archive()
|
data, digest = archive()
|
||||||
self.assertEqual(frame_decky_store.archive_info(data, digest, 'Example'), 'Example')
|
self.assertEqual(frame_decky_store.archive_info(data, digest, 'Example'), 'Example')
|
||||||
@@ -206,12 +228,16 @@ class StoreTransaction(unittest.TestCase):
|
|||||||
self.assertEqual(result.returncode, 0, result.stderr)
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
plugin = self.folder.parent/record['folder']
|
plugin = self.folder.parent/record['folder']
|
||||||
self.assertEqual(json.loads((plugin/'plugin.json').read_text())['flags'], [])
|
self.assertEqual(json.loads((plugin/'plugin.json').read_text())['flags'], [])
|
||||||
self.assertEqual(json.loads((plugin/'.frame-control-store.json').read_text())['compatShims'], ['deckyfileserver-user-native'])
|
self.assertEqual(json.loads((plugin/'.frame-control-store.json').read_text())['compatShims'],
|
||||||
|
['deckyfileserver-user-native', 'deckyfileserver-https-address'])
|
||||||
helper = plugin/'bin/backend'
|
helper = plugin/'bin/backend'
|
||||||
self.assertEqual(hashlib.sha256(helper.read_bytes()).hexdigest(), frame_decky_store.FILESERVER_BINARIES[platform.machine()])
|
self.assertEqual(hashlib.sha256(helper.read_bytes()).hexdigest(), frame_decky_store.FILESERVER_BINARIES[platform.machine()])
|
||||||
self.assertEqual(helper.stat().st_mode & 0o777, 0o700)
|
self.assertEqual(helper.stat().st_mode & 0o777, 0o700)
|
||||||
compile((plugin/'main.py').read_text(), str(plugin/'main.py'), 'exec')
|
compile((plugin/'main.py').read_text(), str(plugin/'main.py'), 'exec')
|
||||||
self.assertIn('if arg]', (plugin/'main.py').read_text())
|
self.assertIn('if arg]', (plugin/'main.py').read_text())
|
||||||
|
rendered = subprocess.run(['node', str(Path(__file__).parent/'page/framey_fileserver_url.mjs'),
|
||||||
|
str(plugin/'dist/index.js')], capture_output=True, text=True, timeout=20)
|
||||||
|
self.assertEqual(rendered.returncode, 0, rendered.stderr)
|
||||||
|
|
||||||
@unittest.skipUnless(__import__('platform').machine() in ('x86_64', 'aarch64'), 'native Linux helper architecture')
|
@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):
|
def test_exact_localsend_gets_verified_native_helper_and_preserves_source(self):
|
||||||
@@ -269,6 +295,114 @@ class StoreTransaction(unittest.TestCase):
|
|||||||
frame_decky_store.LOCALSEND_AARCH64)
|
frame_decky_store.LOCALSEND_AARCH64)
|
||||||
self.assertEqual(int.from_bytes(helper.read_bytes()[18:20], 'little'), 183)
|
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')
|
@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):
|
def test_exact_deckyspy_gets_native_dependency_and_license_only_in_its_folder(self):
|
||||||
from framey_official import PLUGINS, archive as official_archive
|
from framey_official import PLUGINS, archive as official_archive
|
||||||
import platform
|
import platform
|
||||||
|
|||||||
@@ -80,6 +80,16 @@ class Compatibility(unittest.TestCase):
|
|||||||
self.assertIn('Update or reinstall', result[0]['compatibility']['reason'])
|
self.assertIn('Update or reinstall', result[0]['compatibility']['reason'])
|
||||||
self.assertEqual(result[1]['compatibility']['status'], 'works')
|
self.assertEqual(result[1]['compatibility']['status'], 'works')
|
||||||
|
|
||||||
|
def test_fileserver_old_copy_requires_https_address_update(self):
|
||||||
|
plugin = next(p for p in json.loads(compatibility.DATA.read_text())['plugins'] if p['id'] == 60)
|
||||||
|
old = dict(storeId=60, version=plugin['version'], sha256=plugin['sha256'],
|
||||||
|
compatShims=['deckyfileserver-user-native'])
|
||||||
|
updated = dict(old, compatShims=['deckyfileserver-user-native', 'deckyfileserver-https-address'])
|
||||||
|
result = frame_decky.status(lambda *a, **k: json.dumps({'plugins': [old, updated]}))['plugins']
|
||||||
|
self.assertIn('Update or reinstall', result[0]['compatibility']['reason'])
|
||||||
|
self.assertNotIn('Update or reinstall', result[1]['compatibility']['reason'])
|
||||||
|
self.assertEqual([p['compatibility']['status'] for p in result], ['partial', 'partial'])
|
||||||
|
|
||||||
def test_missing_audit_data_is_unverified(self):
|
def test_missing_audit_data_is_unverified(self):
|
||||||
with patch.object(compatibility, 'DATA', Path('/nonexistent/framey-audit.json')):
|
with patch.object(compatibility, 'DATA', Path('/nonexistent/framey-audit.json')):
|
||||||
self.assertEqual(compatibility.lookup(12, 'a'*64, '1.0')['status'], 'unverified')
|
self.assertEqual(compatibility.lookup(12, 'a'*64, '1.0')['status'], 'unverified')
|
||||||
@@ -20,7 +20,8 @@ class RuntimeCoverage(unittest.TestCase):
|
|||||||
for case in cases:
|
for case in cases:
|
||||||
self.assertIn(case['id'], official)
|
self.assertIn(case['id'], official)
|
||||||
self.assertIn(case['probe'], ('mpris', 'screenshots', 'system-monitor', 'timer-lifecycle',
|
self.assertIn(case['probe'], ('mpris', 'screenshots', 'system-monitor', 'timer-lifecycle',
|
||||||
'toggle-settings', 'fileserver', 'localsend'))
|
'toggle-settings', 'fileserver', 'localsend', 'network-sentinel',
|
||||||
|
'battery-tracker', 'free-loader'))
|
||||||
|
|
||||||
def test_new_shim_without_runtime_case_fails_coverage(self):
|
def test_new_shim_without_runtime_case_fails_coverage(self):
|
||||||
import frame_decky_store
|
import frame_decky_store
|
||||||
|
|||||||
@@ -84,10 +84,10 @@ class OfficialBundles(unittest.TestCase):
|
|||||||
folder = plugins / plugin['folder']
|
folder = plugins / plugin['folder']
|
||||||
(folder / '.frame-control-store.json').write_text(json.dumps({
|
(folder / '.frame-control-store.json').write_text(json.dumps({
|
||||||
'storeId': plugin['id'], 'sha256': plugin['sha256']}))
|
'storeId': plugin['id'], 'sha256': plugin['sha256']}))
|
||||||
if plugin['id'] == 60:
|
if plugin['id'] in (60, 67, 114):
|
||||||
# This renderer fixture extracts the original ZIP. The
|
# This renderer fixture extracts the original root ZIP.
|
||||||
# real managed adaptation is covered by transaction and
|
# Transaction and Decky tests cover managed adaptation;
|
||||||
# published Decky tests; an unadapted root copy must fail.
|
# an unadapted root copy must fail here.
|
||||||
with patch.object(framey_panel, 'PLUGINS', plugins), self.assertRaises(FileNotFoundError):
|
with patch.object(framey_panel, 'PLUGINS', plugins), self.assertRaises(FileNotFoundError):
|
||||||
framey_panel.panel(plugin['id'])
|
framey_panel.panel(plugin['id'])
|
||||||
manifest['flags'] = []
|
manifest['flags'] = []
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -37,14 +37,16 @@ def draft(tag_name="untagged-c6ddfed7f75d67db2e99", name="Frame Control 9.8.7",
|
|||||||
|
|
||||||
|
|
||||||
class PublishRelease(unittest.TestCase):
|
class PublishRelease(unittest.TestCase):
|
||||||
def run_script(self, releases, *args, tags="v9.8.7", gates=None):
|
def run_script(self, releases, *args, tags="v9.8.7", gates=None,
|
||||||
|
corrupt_manifest_readback=False):
|
||||||
d = Path(tempfile.mkdtemp())
|
d = Path(tempfile.mkdtemp())
|
||||||
(d / "releases.json").write_text(json.dumps(releases))
|
(d / "releases.json").write_text(json.dumps(releases))
|
||||||
(d / "gates.json").write_text(json.dumps(gates or {}))
|
(d / "gates.json").write_text(json.dumps(gates or {}))
|
||||||
env = dict(os.environ, PATH="%s:%s" % (FAKEGH, os.environ["PATH"]),
|
env = dict(os.environ, PATH="%s:%s" % (FAKEGH, os.environ["PATH"]),
|
||||||
FAKEGH_RELEASES=str(d / "releases.json"), FAKEGH_TAGS=tags,
|
FAKEGH_RELEASES=str(d / "releases.json"), FAKEGH_TAGS=tags,
|
||||||
FAKEGH_LOG=str(d / "log"), FAKEGH_UPLOAD=str(d / "upload.json"),
|
FAKEGH_LOG=str(d / "log"), FAKEGH_UPLOAD=str(d / "upload.json"),
|
||||||
FAKEGH_GATES=str(d / "gates.json"))
|
FAKEGH_GATES=str(d / "gates.json"),
|
||||||
|
FAKEGH_CORRUPT_MANIFEST_READBACK="1" if corrupt_manifest_readback else "0")
|
||||||
p = subprocess.run(["sh", str(SCRIPT), *args], env=env, capture_output=True, text=True, timeout=30)
|
p = subprocess.run(["sh", str(SCRIPT), *args], env=env, capture_output=True, text=True, timeout=30)
|
||||||
log = [json.loads(line) for line in (d / "log").read_text().splitlines()] if (d / "log").exists() else []
|
log = [json.loads(line) for line in (d / "log").read_text().splitlines()] if (d / "log").exists() else []
|
||||||
upload = json.loads((d / "upload.json").read_text()) if (d / "upload.json").exists() else None
|
upload = json.loads((d / "upload.json").read_text()) if (d / "upload.json").exists() else None
|
||||||
@@ -60,11 +62,21 @@ class PublishRelease(unittest.TestCase):
|
|||||||
self.assertEqual(sorted(a["name"] for a in upload["assets"]), sorted(INSTALLERS))
|
self.assertEqual(sorted(a["name"] for a in upload["assets"]), sorted(INSTALLERS))
|
||||||
self.assertIn(["api", "-X", "DELETE", "repos/saphid/frame-control/releases/assets/7"], log)
|
self.assertIn(["api", "-X", "DELETE", "repos/saphid/frame-control/releases/assets/7"], log)
|
||||||
patch = next(c for c in log if "PATCH" in c)
|
patch = next(c for c in log if "PATCH" in c)
|
||||||
|
readback = next(c for c in log if any("releases/assets/901" in arg for arg in c))
|
||||||
|
self.assertLess(log.index(readback), log.index(patch))
|
||||||
self.assertEqual(patch[3], "repos/saphid/frame-control/releases/42")
|
self.assertEqual(patch[3], "repos/saphid/frame-control/releases/42")
|
||||||
for f in ("tag_name=v9.8.7", "draft=false", "prerelease=false", "make_latest=true"):
|
for f in ("tag_name=v9.8.7", "draft=false", "prerelease=false", "make_latest=true"):
|
||||||
self.assertIn(f, patch)
|
self.assertIn(f, patch)
|
||||||
self.assertTrue(all(c[0] == "api" for c in log)) # never `gh release ...` (GraphQL)
|
self.assertTrue(all(c[0] == "api" for c in log)) # never `gh release ...` (GraphQL)
|
||||||
|
|
||||||
|
def test_refuses_mismatched_manifest_readback_before_publication(self):
|
||||||
|
p, log, upload = self.run_script([draft()], "v9.8.7",
|
||||||
|
corrupt_manifest_readback=True)
|
||||||
|
self.assertNotEqual(p.returncode, 0)
|
||||||
|
self.assertIn("downloaded update.json differs", p.stderr)
|
||||||
|
self.assertIsNotNone(upload)
|
||||||
|
self.assertFalse(any("PATCH" in call for call in log))
|
||||||
|
|
||||||
def test_an_untagged_draft_may_carry_a_subtitle(self):
|
def test_an_untagged_draft_may_carry_a_subtitle(self):
|
||||||
p, log, upload = self.run_script([draft(name="Frame Control 9.8.7: faster")], "v9.8.7")
|
p, log, upload = self.run_script([draft(name="Frame Control 9.8.7: faster")], "v9.8.7")
|
||||||
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
|
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
|
||||||
@@ -76,6 +88,16 @@ class PublishRelease(unittest.TestCase):
|
|||||||
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
|
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")
|
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):
|
def test_refuses_missing_or_unhashed_installers(self):
|
||||||
assets = draft()["assets"][1:]
|
assets = draft()["assets"][1:]
|
||||||
assets[0] = dict(assets[0], digest=None)
|
assets[0] = dict(assets[0], digest=None)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ WORKFLOW = ROOT / ".github" / "workflows" / "release.yml"
|
|||||||
FAKEGH = ROOT / "tests" / "fakegh"
|
FAKEGH = ROOT / "tests" / "fakegh"
|
||||||
STEP = "- name: Create the draft release"
|
STEP = "- name: Create the draft release"
|
||||||
UPLOAD_STEP = "- name: Upload to the release"
|
UPLOAD_STEP = "- name: Upload to the release"
|
||||||
|
OUTPUTS_STEP = "- name: Check installer outputs before upload"
|
||||||
|
|
||||||
|
|
||||||
def step_script(step=STEP):
|
def step_script(step=STEP):
|
||||||
@@ -116,6 +117,28 @@ class DraftStep(unittest.TestCase):
|
|||||||
self.assertEqual(self.patches(log), [])
|
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"),
|
@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")
|
"needs a POSIX bash and jq; Actions runs this step under bash")
|
||||||
class UploadStep(unittest.TestCase):
|
class UploadStep(unittest.TestCase):
|
||||||
|
|||||||
+19
-1
@@ -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("GET", "/api/titles/job?token=nope", headers={"X-Frame-UI": "1"})[0], 404)
|
||||||
self.assertEqual(self.request("POST", "/api/titles", {"action": "list"})[0], 403)
|
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):
|
def test_web_install_needs_the_app_page(self):
|
||||||
# A website can only open frame-control:// links; it can't call these itself.
|
# A website can only open frame-control:// links; it can't call these itself.
|
||||||
link = {"url": "https://cdn.example.com/game.apk"}
|
link = {"url": "https://cdn.example.com/game.apk"}
|
||||||
@@ -497,7 +508,10 @@ class ArtworkSettings(unittest.TestCase):
|
|||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
key = {"X-Frame-UI": "1", "Content-Type": "application/json"}
|
key = {"X-Frame-UI": "1", "Content-Type": "application/json"}
|
||||||
self.assertEqual(request("GET", "/api/settings/artwork")[0], 403)
|
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)
|
status, payload = request("GET", "/api/settings/artwork", headers=key)
|
||||||
self.assertEqual((status, json.loads(payload)["steamgriddb_configured"]), (200, False))
|
self.assertEqual((status, json.loads(payload)["steamgriddb_configured"]), (200, False))
|
||||||
status, payload = request("POST", "/api/settings/artwork", {"steamgriddb_api_key": "abc_1"}, key)
|
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)
|
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)
|
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")
|
@unittest.skipIf(os.name == "nt", "runs on the Frame (Linux); local-bin/ssh is a POSIX shell script")
|
||||||
class LocalMode(unittest.TestCase):
|
class LocalMode(unittest.TestCase):
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ class Safety(unittest.TestCase):
|
|||||||
launch.assert_called_once_with(gid)
|
launch.assert_called_once_with(gid)
|
||||||
remove.assert_called_once_with(gid)
|
remove.assert_called_once_with(gid)
|
||||||
|
|
||||||
def test_optional_runtime_skip_is_counted_separately_in_saved_result(self):
|
def test_optional_runtime_skip_fails_gate_and_is_counted_in_saved_result(self):
|
||||||
with tempfile.TemporaryDirectory() as root:
|
with tempfile.TemporaryDirectory() as root:
|
||||||
output = Path(root) / 'results'
|
output = Path(root) / 'results'
|
||||||
folder = Path(root) / 'titles'
|
folder = Path(root) / 'titles'
|
||||||
@@ -156,7 +156,7 @@ class Safety(unittest.TestCase):
|
|||||||
mock.patch.object(frame_smoke, 'title_cycle', side_effect=cycle), \
|
mock.patch.object(frame_smoke, 'title_cycle', side_effect=cycle), \
|
||||||
mock.patch.object(frame_smoke.tempfile, 'mkdtemp', return_value=str(folder)):
|
mock.patch.object(frame_smoke.tempfile, 'mkdtemp', return_value=str(folder)):
|
||||||
self.assertEqual(frame_smoke.run_smoke(mock.Mock(pair=False),
|
self.assertEqual(frame_smoke.run_smoke(mock.Mock(pair=False),
|
||||||
{'activity': 3, 'proximity': [], 'battery': [100]}), 0)
|
{'activity': 3, 'proximity': [], 'battery': [100]}), 1)
|
||||||
result = json.loads(next(output.glob('*-fake.json')).read_text())
|
result = json.loads(next(output.glob('*-fake.json')).read_text())
|
||||||
self.assertEqual((result['passed'], result['skipped'], result['failed']), (6, 1, 0))
|
self.assertEqual((result['passed'], result['skipped'], result['failed']), (6, 1, 0))
|
||||||
self.assertEqual(next(s for s in result['steps'] if s['step'] == 'x86_64: launch')
|
self.assertEqual(next(s for s in result['steps'] if s['step'] == 'x86_64: launch')
|
||||||
|
|||||||
+184
-14
@@ -37,6 +37,10 @@ LOCALSEND_DIGEST = 'e271ccb55eb23f918b99a69f79aec905df4c9b2ef7b23bbf4ec1c2d1a6f0
|
|||||||
LOCALSEND_ORIGINAL = 'd448d03861d578685e6c70ea3ace25eb34fca99cb9d4f98bf51ba23aa5cae843'
|
LOCALSEND_ORIGINAL = 'd448d03861d578685e6c70ea3ace25eb34fca99cb9d4f98bf51ba23aa5cae843'
|
||||||
LOCALSEND_MAIN = 'db03f9201fe50b75dea99a7a05b54ec3f352d1bdfe174115b0e569154198d8ad'
|
LOCALSEND_MAIN = 'db03f9201fe50b75dea99a7a05b54ec3f352d1bdfe174115b0e569154198d8ad'
|
||||||
LOCALSEND_AARCH64 = 'e20363237e606ee65eae857792176d21a0bb8ba863489952728eaf2b13277dae'
|
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')
|
VERSION = re.compile(r'[A-Za-z0-9][A-Za-z0-9._+\-]{0,39}\Z')
|
||||||
|
|
||||||
@@ -83,7 +87,9 @@ def catalog():
|
|||||||
counts[item['id']] = counts.get(item['id'], 0) + 1
|
counts[item['id']] = counts.get(item['id'], 0) + 1
|
||||||
unique = [item for item in result if counts[item['id']] == 1]
|
unique = [item for item in result if counts[item['id']] == 1]
|
||||||
for item in unique:
|
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['root'] = False # Exact managed adaptation runs as the Frame user.
|
||||||
item['compatibility'] = framey_compatibility.lookup(item['id'], item['sha256'], item['version'])
|
item['compatibility'] = framey_compatibility.lookup(item['id'], item['sha256'], item['version'])
|
||||||
return unique
|
return unique
|
||||||
@@ -107,6 +113,10 @@ def archive_info(data, expected_hash, expected_name):
|
|||||||
and folder == 'DeckyFileServer')
|
and folder == 'DeckyFileServer')
|
||||||
localsend = (expected_hash == LOCALSEND_DIGEST and expected_name == 'Decky Localsend'
|
localsend = (expected_hash == LOCALSEND_DIGEST and expected_name == 'Decky Localsend'
|
||||||
and folder == '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 = {}
|
seen = {}
|
||||||
for entry in files:
|
for entry in files:
|
||||||
name = entry.filename
|
name = entry.filename
|
||||||
@@ -151,10 +161,16 @@ def archive_info(data, expected_hash, expected_name):
|
|||||||
flags = manifest.get('flags', [])
|
flags = manifest.get('flags', [])
|
||||||
if not isinstance(flags, list) or not all(isinstance(flag, str) for flag in flags):
|
if not isinstance(flags, list) or not all(isinstance(flag, str) for flag in flags):
|
||||||
raise ValueError('Invalid plugin 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')
|
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:
|
if localsend and hashlib.sha256(archive.read(folder+'/main.py')).hexdigest() != LOCALSEND_MAIN:
|
||||||
raise ValueError('LocalSend Python source differs from the pinned archive')
|
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
|
return folder
|
||||||
|
|
||||||
|
|
||||||
@@ -185,7 +201,8 @@ def apply_script(plugin_id, digest):
|
|||||||
# Python stdin, never shell arguments. The Frame rechecks hash and ELF machine.
|
# Python stdin, never shell arguments. The Frame rechecks hash and ELF machine.
|
||||||
return ('FRAMEY_FILESERVER_BINARIES = '+repr(payloads)+'\n'
|
return ('FRAMEY_FILESERVER_BINARIES = '+repr(payloads)+'\n'
|
||||||
+'FRAMEY_FILESERVER_HASHES = '+repr(FILESERVER_BINARIES)+'\n'+APPLY)
|
+'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
|
return APPLY
|
||||||
wheels = {}
|
wheels = {}
|
||||||
for arch, expected in PSUTIL_DIGESTS.items():
|
for arch, expected in PSUTIL_DIGESTS.items():
|
||||||
@@ -194,9 +211,11 @@ def apply_script(plugin_id, digest):
|
|||||||
raise ValueError('Bundled psutil dependency SHA-256 mismatch')
|
raise ValueError('Bundled psutil dependency SHA-256 mismatch')
|
||||||
wheels[arch] = base64.b64encode(data).decode('ascii')
|
wheels[arch] = base64.b64encode(data).decode('ascii')
|
||||||
# stdin is Python source, never shell arguments or a shared remote temp file.
|
# 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')
|
source = 'FRAMEY_BACKEND_WHEELS = '+repr(wheels)+'\n'
|
||||||
return ('FRAMEY_BACKEND_WHEELS = '+repr(wheels)+'\n'
|
if plugin_id == 89:
|
||||||
+'FRAMEY_BACKEND_BATTERY_SOURCE = '+repr(battery)+'\n'+APPLY)
|
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'''
|
APPLY = r'''
|
||||||
@@ -244,12 +263,14 @@ try:
|
|||||||
raise RuntimeError('Plugin archive too large')
|
raise RuntimeError('Plugin archive too large')
|
||||||
fileserver=(int(plugin_id)==60 and digest=='7dc2ecdcd05aa415d1ac90afaff28ecb17f77809a2bc7a77aee8e92505b976f0' and folder=='DeckyFileServer')
|
fileserver=(int(plugin_id)==60 and digest=='7dc2ecdcd05aa415d1ac90afaff28ecb17f77809a2bc7a77aee8e92505b976f0' and folder=='DeckyFileServer')
|
||||||
localsend=(int(plugin_id)==125 and digest=='e271ccb55eb23f918b99a69f79aec905df4c9b2ef7b23bbf4ec1c2d1a6f0c6b7' and folder=='decky-localsend')
|
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'))
|
manifest=json.loads(z.read(folder+'/plugin.json'))
|
||||||
if not isinstance(manifest,dict): raise RuntimeError('Invalid plugin manifest')
|
if not isinstance(manifest,dict): raise RuntimeError('Invalid plugin manifest')
|
||||||
flags=manifest.get('flags',[])
|
flags=manifest.get('flags',[])
|
||||||
if not isinstance(flags,list) or not all(isinstance(flag,str) for flag in flags):
|
if not isinstance(flags,list) or not all(isinstance(flag,str) for flag in flags):
|
||||||
raise RuntimeError('Invalid plugin 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')
|
raise RuntimeError('Root plugins are not supported by the Frame user service')
|
||||||
seen={}
|
seen={}
|
||||||
for f in files:
|
for f in files:
|
||||||
@@ -364,7 +385,38 @@ try:
|
|||||||
new=' if "disable_thumbnails" in status:\n await Plugin.set_disable_thumbnails(self, status["disable_thumbnails"])\n if "server_running" in status:\n await Plugin.set_server_running(self, status["server_running"])'
|
new=' if "disable_thumbnails" in status:\n await Plugin.set_disable_thumbnails(self, status["disable_thumbnails"])\n if "server_running" in status:\n await Plugin.set_server_running(self, status["server_running"])'
|
||||||
if old not in text: raise RuntimeError('DeckyFileServer start configuration changed')
|
if old not in text: raise RuntimeError('DeckyFileServer start configuration changed')
|
||||||
source.write_text(text.replace(old,new))
|
source.write_text(text.replace(old,new))
|
||||||
|
# The helper always calls ListenAndServeTLS. The published panel
|
||||||
|
# prints HTTP when uploads are disabled and hard-codes a Deck
|
||||||
|
# hostname that the Frame does not provide. Keep only the
|
||||||
|
# actual address returned by this backend.
|
||||||
|
frontend=unpacked/'dist/index.js'
|
||||||
|
original=frontend.read_bytes()
|
||||||
|
if hashlib.sha256(original).hexdigest()!='791015d8b3f234fad89ce422b09fb32bbad05e7c867d501706cfbcf9f7c6187f':
|
||||||
|
raise RuntimeError('DeckyFileServer frontend source changed')
|
||||||
|
bundle=original.decode('utf-8')
|
||||||
|
old_url=""" window.SP_REACT.createElement(DFL.Field, { inlineWrap: "shift-children-below", label: "Server Address", bottomSeparator: "none" },
|
||||||
|
"http",
|
||||||
|
state.allow_uploads ? "s" : "",
|
||||||
|
"://steamdeck:",
|
||||||
|
state.port),
|
||||||
|
window.SP_REACT.createElement(DFL.Field, { inlineWrap: "shift-children-below" },
|
||||||
|
"http",
|
||||||
|
state.allow_uploads ? "s" : "",
|
||||||
|
"://",
|
||||||
|
state.ip_address,
|
||||||
|
":",
|
||||||
|
state.port),
|
||||||
|
"""
|
||||||
|
new_url=""" window.SP_REACT.createElement(DFL.Field, { inlineWrap: "shift-children-below", label: "Server Address", bottomSeparator: "none" },
|
||||||
|
"https://",
|
||||||
|
state.ip_address,
|
||||||
|
":",
|
||||||
|
state.port),
|
||||||
|
"""
|
||||||
|
if bundle.count(old_url)!=1: raise RuntimeError('DeckyFileServer address UI changed')
|
||||||
|
frontend.write_text(bundle.replace(old_url,new_url),encoding='utf-8')
|
||||||
compat_shims.append('deckyfileserver-user-native')
|
compat_shims.append('deckyfileserver-user-native')
|
||||||
|
compat_shims.append('deckyfileserver-https-address')
|
||||||
# Simple Timer 1.0.0 has annotation-only task state, orphaned
|
# Simple Timer 1.0.0 has annotation-only task state, orphaned
|
||||||
# replacement tasks and canceled deadlines that resume on restart.
|
# replacement tasks and canceled deadlines that resume on restart.
|
||||||
# Preserve its API1 instance convention and upstream license.
|
# Preserve its API1 instance convention and upstream license.
|
||||||
@@ -478,10 +530,10 @@ try:
|
|||||||
' base = os.path.join(os.path.expanduser("~"), ".config")\n'
|
' base = os.path.join(os.path.expanduser("~"), ".config")\n'
|
||||||
' return os.path.join(base, app_name)\n',encoding='utf-8')
|
' return os.path.join(base, app_name)\n',encoding='utf-8')
|
||||||
compat_shims.append('shotty-click-app-dir')
|
compat_shims.append('shotty-click-app-dir')
|
||||||
# DeckySpy's official archive omits its required psutil dependency.
|
# Both exact official archives omit psutil. Install the pinned BSD
|
||||||
# The BSD wheels are packaged offline by Frame Control and selected
|
# native wheel in the plugin folder only; never use pip or system paths.
|
||||||
# for the native loader; no pip, network or system path changes.
|
deckyspy=(int(plugin_id)==89 and digest=='2d431a89826ed5cf3fd37258373eb3b46c841c39795aa8b25f83e59f4eee87c7' and folder=='decky-spy')
|
||||||
if int(plugin_id)==89 and digest=='2d431a89826ed5cf3fd37258373eb3b46c841c39795aa8b25f83e59f4eee87c7' and folder=='decky-spy':
|
if deckyspy or network_sentinel:
|
||||||
arch=platform.machine()
|
arch=platform.machine()
|
||||||
pins={
|
pins={
|
||||||
'x86_64':'076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9',
|
'x86_64':'076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9',
|
||||||
@@ -489,13 +541,13 @@ try:
|
|||||||
}
|
}
|
||||||
encoded=globals().get('FRAMEY_BACKEND_WHEELS',{}).get(arch)
|
encoded=globals().get('FRAMEY_BACKEND_WHEELS',{}).get(arch)
|
||||||
if arch not in pins or not encoded:
|
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)
|
wheel_data=base64.b64decode(encoded,validate=True)
|
||||||
if hashlib.sha256(wheel_data).hexdigest()!=pins[arch]:
|
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'
|
modules=unpacked/'py_modules'
|
||||||
for name in ('psutil','psutil-7.2.2.dist-info'):
|
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:
|
with zipfile.ZipFile(io.BytesIO(wheel_data)) as wheel:
|
||||||
for member in wheel.infolist():
|
for member in wheel.infolist():
|
||||||
path=pathlib.PurePosixPath(member.filename)
|
path=pathlib.PurePosixPath(member.filename)
|
||||||
@@ -510,6 +562,124 @@ try:
|
|||||||
dest.parent.mkdir(parents=True,exist_ok=True)
|
dest.parent.mkdir(parents=True,exist_ok=True)
|
||||||
dest.write_bytes(payload)
|
dest.write_bytes(payload)
|
||||||
dest.chmod(0o600)
|
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'
|
compat=modules/'framey_battery.py'
|
||||||
if compat.exists(): raise RuntimeError('DeckySpy battery module collides with archive')
|
if compat.exists(): raise RuntimeError('DeckySpy battery module collides with archive')
|
||||||
battery_source=globals().get('FRAMEY_BACKEND_BATTERY_SOURCE')
|
battery_source=globals().get('FRAMEY_BACKEND_BATTERY_SOURCE')
|
||||||
|
|||||||
@@ -1672,7 +1672,7 @@
|
|||||||
"https://github.com/jurassicplayer/decky-autosuspend.git"
|
"https://github.com/jurassicplayer/decky-autosuspend.git"
|
||||||
],
|
],
|
||||||
"status": "broken",
|
"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,
|
"blockInstall": false,
|
||||||
"verification": [
|
"verification": [
|
||||||
"static archive",
|
"static archive",
|
||||||
@@ -3903,14 +3903,15 @@
|
|||||||
"https://github.com/safijari/Shotty"
|
"https://github.com/safijari/Shotty"
|
||||||
],
|
],
|
||||||
"status": "partial",
|
"status": "partial",
|
||||||
"reason": "The frontend starts with the read-only Frame library snapshot. Frame Control adds a digest-scoped click.get_app_dir compatibility module to the managed install; the exact backend then imports and copies a synthetic Steam screenshot into a temporary Pictures folder in isolated Python. Actual Decky 3.3.0 ARM64 loading, live screenshots/rescue, in-VR panel and physical controller use remain unverified.",
|
"reason": "The frontend starts with the read-only Frame library snapshot. The exact-digest click.get_app_dir shim lets actual Decky 3.3.0 in isolated amd64 and ARM64 containers discover Shotty and complete empty screenshot RPCs; isolated Python also copied a synthetic screenshot into a temporary Pictures folder. The managed backend on the real Frame, live screenshots/rescue, in-VR panel and physical controller use remain unverified.",
|
||||||
"blockInstall": false,
|
"blockInstall": false,
|
||||||
"verification": [
|
"verification": [
|
||||||
"static archive",
|
"static archive",
|
||||||
"local browser",
|
"local browser",
|
||||||
"fake-Frame",
|
"fake-Frame",
|
||||||
"real Frame SSH",
|
"real Frame SSH",
|
||||||
"isolated Python backend"
|
"isolated Python backend",
|
||||||
|
"isolated Decky 3.3.0 amd64/ARM64 empty backend RPCs"
|
||||||
],
|
],
|
||||||
"browser": {
|
"browser": {
|
||||||
"id": 53,
|
"id": 53,
|
||||||
@@ -3943,11 +3944,12 @@
|
|||||||
"frontendReady": true
|
"frontendReady": true
|
||||||
},
|
},
|
||||||
"backendFollowUp": {
|
"backendFollowUp": {
|
||||||
"evidence": "docs/evidence/framey-shotty-python-2026-10-26.md",
|
"evidence": "docs/evidence/framey-shotty-native-2026-10-28.md",
|
||||||
"mode": "exact archive managed transaction and isolated Python import/copy with temporary HOME",
|
"mode": "exact archive managed transaction, isolated Python copy, and actual Decky 3.3.0 amd64/ARM64 discovery with empty backend RPCs",
|
||||||
"compatShim": "shotty-click-app-dir",
|
"compatShim": "shotty-click-app-dir",
|
||||||
"syntheticScreenshotCopied": true,
|
"syntheticScreenshotCopied": true,
|
||||||
"actualDeckyArm64": false
|
"actualDeckyArm64": true,
|
||||||
|
"actualFrameBackend": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4349,7 +4351,7 @@
|
|||||||
"https://github.com/grimkor/DeckyFileServer"
|
"https://github.com/grimkor/DeckyFileServer"
|
||||||
],
|
],
|
||||||
"status": "partial",
|
"status": "partial",
|
||||||
"reason": "Managed install supplies a native ARM64 helper and removes the exact archive's unnecessary root request. Real Decky 3.3.0 on Frame passes private read-only HTTPS listing/download and upload denial. Frontend address/picker limitations remain; use HTTPS and a preconfigured directory. Uploads, generated thumbnails and worn VR are unverified. Shared Toggle value rendering is repaired; other listed blockers remain.",
|
"reason": "Managed install supplies a native ARM64 helper, removes the exact archive's unnecessary root request, and displays the backend-returned IP with HTTPS. Real Decky 3.3.0 on Frame passes private read-only HTTPS listing/download and upload denial. The Steam folder picker remains unavailable; use a preconfigured directory. Uploads, generated thumbnails and worn VR are unverified. Existing managed copies need update or reinstall for the address fix.",
|
||||||
"blockInstall": false,
|
"blockInstall": false,
|
||||||
"verification": [
|
"verification": [
|
||||||
"static archive",
|
"static archive",
|
||||||
@@ -4385,7 +4387,8 @@
|
|||||||
"reason": "ValueError: Root plugins are not supported by the Frame user service"
|
"reason": "ValueError: Root plugins are not supported by the Frame user service"
|
||||||
},
|
},
|
||||||
"requiredShims": [
|
"requiredShims": [
|
||||||
"deckyfileserver-user-native"
|
"deckyfileserver-user-native",
|
||||||
|
"deckyfileserver-https-address"
|
||||||
],
|
],
|
||||||
"managedNative": {
|
"managedNative": {
|
||||||
"shim": "deckyfileserver-user-native",
|
"shim": "deckyfileserver-user-native",
|
||||||
@@ -4703,14 +4706,15 @@
|
|||||||
"https://github.com/jwhitlow45/free-loader.git"
|
"https://github.com/jwhitlow45/free-loader.git"
|
||||||
],
|
],
|
||||||
"status": "partial",
|
"status": "partial",
|
||||||
"reason": "The exact archive's duplicate names contain identical bytes and differ only in execute bits; the guarded installer now accepts them, and a private local install/remove transaction passed. The current opaque Chromium host renders Free Games but cannot load deals with backend/network denied. Exact official Python settings and empty local-deals methods passed in a no-network container with a Decky stub. Real Decky, feed refresh, notifications and worn VR remain unverified.",
|
"reason": "The exact archive's byte-identical duplicate ZIP members pass guarded install/remove. Actual Decky 3.3.0 in private network-isolated amd64 and ARM64 containers discovers the plugin, reads default settings and empty local deals, and retains a settings change after restart. Live deal-feed refresh, notifications, Steam store integration, real Frame ARM64 service and worn VR remain unverified; no feed request was made.",
|
||||||
"blockInstall": false,
|
"blockInstall": false,
|
||||||
"verification": [
|
"verification": [
|
||||||
"static archive",
|
"static archive",
|
||||||
"local browser",
|
"local browser",
|
||||||
"fake-Frame pinned install/remove",
|
"fake-Frame pinned install/remove",
|
||||||
"local exact-archive transaction",
|
"local exact-archive transaction",
|
||||||
"isolated Python backend"
|
"isolated Python backend",
|
||||||
|
"isolated Decky 3.3.0 amd64/ARM64 backend"
|
||||||
],
|
],
|
||||||
"browser": {
|
"browser": {
|
||||||
"id": 63,
|
"id": 63,
|
||||||
@@ -5176,13 +5180,18 @@
|
|||||||
"https://github.com/safijari/steam-deck-battery-tracker",
|
"https://github.com/safijari/steam-deck-battery-tracker",
|
||||||
"https://github.com/safijari/steam-deck-battery-tracker.git"
|
"https://github.com/safijari/steam-deck-battery-tracker.git"
|
||||||
],
|
],
|
||||||
"status": "broken",
|
"status": "partial",
|
||||||
"reason": "This plugin needs root and is not supported by the Frame user service.",
|
"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 in isolated amd64 and ARM64 containers recorded Frame-shaped private battery samples and returned history/per-app power. A locked real Frame read verified the required sysfs fields, not the managed plugin. Real Frame ARM64 recording, long-term history, foreground-app labeling, panel controls and worn VR remain unverified; existing copies need update or reinstall.",
|
||||||
"blockInstall": true,
|
"blockInstall": false,
|
||||||
|
"requiredShims": [
|
||||||
|
"battery-tracker-user-supply"
|
||||||
|
],
|
||||||
"verification": [
|
"verification": [
|
||||||
"static archive",
|
"static archive",
|
||||||
"local browser",
|
"local browser",
|
||||||
"fake-Frame"
|
"fake-Frame",
|
||||||
|
"isolated Decky 3.3.0 amd64/ARM64 backend",
|
||||||
|
"real Frame read-only sysfs"
|
||||||
],
|
],
|
||||||
"browser": {
|
"browser": {
|
||||||
"id": 67,
|
"id": 67,
|
||||||
@@ -5206,8 +5215,8 @@
|
|||||||
},
|
},
|
||||||
"archiveValidation": {
|
"archiveValidation": {
|
||||||
"id": 67,
|
"id": 67,
|
||||||
"accepted": false,
|
"accepted": true,
|
||||||
"reason": "This plugin needs root and is not supported by the Frame user service"
|
"reason": "Exact digest-scoped user-service adaptation"
|
||||||
},
|
},
|
||||||
"backend": "Python",
|
"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",
|
"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 +5226,8 @@
|
|||||||
"fakeFrame": {
|
"fakeFrame": {
|
||||||
"id": 67,
|
"id": 67,
|
||||||
"sha256": "91c388d3c1b52079314045b79671ad55c767510658b6193f6936ed6cad383b94",
|
"sha256": "91c388d3c1b52079314045b79671ad55c767510658b6193f6936ed6cad383b94",
|
||||||
"archiveValidation": "This plugin needs root and is not supported by the Frame user service",
|
"archiveValidation": "accepted",
|
||||||
"result": "blocked",
|
"result": "installed-removed"
|
||||||
"reason": "ValueError: Root plugins are not supported by the Frame user service"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -5854,7 +5862,7 @@
|
|||||||
"https://github.com/jurassicplayer/decky-autosuspend"
|
"https://github.com/jurassicplayer/decky-autosuspend"
|
||||||
],
|
],
|
||||||
"status": "broken",
|
"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,
|
"blockInstall": false,
|
||||||
"verification": [
|
"verification": [
|
||||||
"static archive",
|
"static archive",
|
||||||
@@ -9969,13 +9977,17 @@
|
|||||||
"https://github.com/SteamDeckHomebrew/decky-frontend-lib",
|
"https://github.com/SteamDeckHomebrew/decky-frontend-lib",
|
||||||
"https://github.com/SteamDeckHomebrew/decky-loader"
|
"https://github.com/SteamDeckHomebrew/decky-loader"
|
||||||
],
|
],
|
||||||
"status": "broken",
|
"status": "partial",
|
||||||
"reason": "This plugin needs root and is not supported by the Frame user service.",
|
"reason": "Exact managed install removes the unnecessary root flag and adds pinned plugin-local psutil wheels. Actual Decky 3.3.0 in isolated amd64 and ARM64 containers loads the backend and records a private network sample; settings and read-only RPCs pass. External ping/DNS, real Frame ARM64 backend, Wi-Fi scan behavior, panel controls and worn VR remain unverified. Existing copies need update or reinstall.",
|
||||||
"blockInstall": true,
|
"blockInstall": false,
|
||||||
|
"requiredShims": [
|
||||||
|
"network-sentinel-user-psutil"
|
||||||
|
],
|
||||||
"verification": [
|
"verification": [
|
||||||
"static archive",
|
"static archive",
|
||||||
"local browser",
|
"local browser",
|
||||||
"fake-Frame"
|
"fake-Frame",
|
||||||
|
"isolated Decky 3.3.0 amd64/ARM64 backend"
|
||||||
],
|
],
|
||||||
"browser": {
|
"browser": {
|
||||||
"id": 114,
|
"id": 114,
|
||||||
@@ -10014,8 +10026,8 @@
|
|||||||
},
|
},
|
||||||
"archiveValidation": {
|
"archiveValidation": {
|
||||||
"id": 114,
|
"id": 114,
|
||||||
"accepted": false,
|
"accepted": true,
|
||||||
"reason": "This plugin needs root and is not supported by the Frame user service"
|
"reason": "Exact digest-scoped user-service adaptation"
|
||||||
},
|
},
|
||||||
"backend": "Python",
|
"backend": "Python",
|
||||||
"hardware": "No specific hardware requirement detected statically; runtime requirements unverified.",
|
"hardware": "No specific hardware requirement detected statically; runtime requirements unverified.",
|
||||||
@@ -10025,9 +10037,8 @@
|
|||||||
"fakeFrame": {
|
"fakeFrame": {
|
||||||
"id": 114,
|
"id": 114,
|
||||||
"sha256": "d3e07f40a0c800e72d15594e03c8c61e9bf3092524a578a0260520e735b971a5",
|
"sha256": "d3e07f40a0c800e72d15594e03c8c61e9bf3092524a578a0260520e735b971a5",
|
||||||
"archiveValidation": "This plugin needs root and is not supported by the Frame user service",
|
"archiveValidation": "accepted",
|
||||||
"result": "blocked",
|
"result": "installed-removed"
|
||||||
"reason": "ValueError: This plugin needs root and is not supported by the Frame user service."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+8
-3
@@ -1686,6 +1686,7 @@
|
|||||||
<option value="vr">VR app</option>
|
<option value="vr">VR app</option>
|
||||||
</select>
|
</select>
|
||||||
<div class="progress" id="prog"><i></i></div>
|
<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>
|
<div class="hint">Clipboard text, both ways, is in <a href="#pad">Type on the headset</a>.</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -3843,6 +3844,7 @@ window.addEventListener("drop", e => {
|
|||||||
function upload(file, mode, dev = window.connDevice, backupToken = null, progress = () => {}, signal = null) {
|
function upload(file, mode, dev = window.connDevice, backupToken = null, progress = () => {}, signal = null) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (signal?.aborted) return reject(new Error("Import cancelled"));
|
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 xhr = new XMLHttpRequest();
|
||||||
const abort = () => xhr.abort();
|
const abort = () => xhr.abort();
|
||||||
const cleanup = () => signal?.removeEventListener("abort", abort);
|
const cleanup = () => signal?.removeEventListener("abort", abort);
|
||||||
@@ -3875,6 +3877,9 @@ function upload(file, mode, dev = window.connDevice, backupToken = null, progres
|
|||||||
xhr.send(file);
|
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) {
|
async function refreshMedia(selectId) {
|
||||||
const data = await api("/api/media", { action: "list" });
|
const data = await api("/api/media", { action: "list" });
|
||||||
const selected = selectId || $("mediaFile").value;
|
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; }
|
if (TITLE_EXT.test(f.name)) { await sideload(f, path); continue; }
|
||||||
const apk = f.name.toLowerCase().endsWith(".apk");
|
const apk = f.name.toLowerCase().endsWith(".apk");
|
||||||
if (apk) await importAndroidFiles([f], dev);
|
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 = "";
|
$("fileInput").value = "";
|
||||||
refresh();
|
refresh();
|
||||||
@@ -4137,7 +4142,7 @@ async function sideloadOne(file, path, isDir = false, dev = window.connDevice) {
|
|||||||
catch (e) {
|
catch (e) {
|
||||||
log(`${file.name}: ${e.message}`, "e");
|
log(`${file.name}: ${e.message}`, "e");
|
||||||
if (canPush && confirm(`${file.name}: ${e.message}\n\nCopy it to ~/Downloads instead?`))
|
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;
|
return;
|
||||||
}
|
}
|
||||||
// Its own fresh list, so the dialog can say whether this replaces an installed title.
|
// 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);
|
const choice = await confirmTitle(r.plan, canPush, installed);
|
||||||
if (choice === "push") {
|
if (choice === "push") {
|
||||||
api("/api/titles", { action: "discard", token: r.token }).catch(() => {});
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (!choice) { api("/api/titles", { action: "discard", token: r.token }).catch(() => {}); return; }
|
if (!choice) { api("/api/titles", { action: "discard", token: r.token }).catch(() => {}); return; }
|
||||||
|
|||||||
+31
-3
@@ -28,6 +28,7 @@ import shutil
|
|||||||
import signal
|
import signal
|
||||||
import socket
|
import socket
|
||||||
import socketserver
|
import socketserver
|
||||||
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -3494,8 +3495,27 @@ def power_action(body):
|
|||||||
raise Failure(str(exc), 409) from exc
|
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 = {
|
POST = {
|
||||||
"/api/steam-pipewire": steam_pipewire_action,
|
"/api/steam-pipewire": steam_pipewire_action,
|
||||||
|
"/api/transfer": transfer_local,
|
||||||
"/api/power": power_action,
|
"/api/power": power_action,
|
||||||
"/api/android/backup": android_backup, "/api/backup": backup_action,
|
"/api/android/backup": android_backup, "/api/backup": backup_action,
|
||||||
"/api/decky": decky,
|
"/api/decky": decky,
|
||||||
@@ -3611,6 +3631,12 @@ def _next_chunk(chunks, timeout):
|
|||||||
def push_file(path, dest="Downloads/"):
|
def push_file(path, dest="Downloads/"):
|
||||||
"""Copy a file to the Frame (as scripts/push.sh): rsync where both ends have it, else scp."""
|
"""Copy a file to the Frame (as scripts/push.sh): rsync where both ends have it, else scp."""
|
||||||
name = Path(path).name
|
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:
|
try:
|
||||||
# Not on Windows: a Windows rsync (cwRsync, MSYS2) wouldn't take our POSIX -e quoting.
|
# 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":
|
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:
|
else:
|
||||||
# Modern scp uses SFTP, so the remote path isn't parsed by a shell.
|
# Modern scp uses SFTP, so the remote path isn't parsed by a shell.
|
||||||
cmd = ["scp", *SSH[1:], "-r", str(path), f"{FRAME}:{dest}"]
|
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:
|
except subprocess.TimeoutExpired:
|
||||||
raise Failure(f"Copying {name} timed out")
|
raise Failure(f"Copying {name} timed out")
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
@@ -4070,8 +4096,10 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
length = int(self.headers.get("Content-Length") or 0)
|
length = int(self.headers.get("Content-Length") or 0)
|
||||||
if not name or name.startswith("."):
|
if not name or name.startswith("."):
|
||||||
raise Failure("missing filename", 400)
|
raise Failure("missing filename", 400)
|
||||||
if length <= 0 or length > MAX_UPLOAD:
|
if length <= 0:
|
||||||
raise Failure("empty or too-large upload", 400)
|
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"):
|
if mode in ("apk", "apkinfo") and not name.lower().endswith(".apk"):
|
||||||
raise Failure("APK install needs a .apk file", 400)
|
raise Failure("APK install needs a .apk file", 400)
|
||||||
backup = None
|
backup = None
|
||||||
|
|||||||
Reference in new issue
Block a user