mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-28 22:01:49 +01:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c33dc0ccb6 | ||
|
|
b5bbcec1a0 | ||
|
|
bd36929053 | ||
|
|
ac04407bcc | ||
|
|
432b29f961 | ||
|
|
c88df07c81 | ||
|
|
423a86fefc | ||
|
|
5847a81066 | ||
|
|
3a8fee1d45 | ||
|
|
082af24115 | ||
|
|
3d06824f4d | ||
|
|
cf89bf1313 | ||
|
|
e93e75d849 | ||
|
|
a6869bbf93 | ||
|
|
a31223a90b | ||
|
|
4a95aeb5d0 | ||
|
|
9ae78aef60 | ||
|
|
821839baae | ||
|
|
d4c523efd9 | ||
|
|
13ed576abd | ||
|
|
21c609b4a2 | ||
|
|
b39905f7cf | ||
|
|
f1473fd610 | ||
|
|
bf56d21eca |
No files matched your search
+2
-21
@@ -7,9 +7,9 @@
|
||||
// node build/fetch-deps.js mac arm64 x64 | win x64 | linux x64 arm64
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
const https = require("https");
|
||||
const path = require("path");
|
||||
const { execFileSync } = require("child_process");
|
||||
const { get, fetchWithRetry } = require("./fetch-retry");
|
||||
|
||||
const PY = "3.12.14+20260924";
|
||||
const PY_URL = (triple) => "https://github.com/astral-sh/python-build-standalone/releases/download/"
|
||||
@@ -46,27 +46,8 @@ const PRUNE = [
|
||||
.flatMap((d) => [`lib/python3.12/${d}`, `Lib/${d}`]),
|
||||
];
|
||||
|
||||
function get(url, redirects = 5) {
|
||||
return new Promise((resolve, reject) => {
|
||||
https.get(url, { timeout: 60000 }, (res) => {
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
res.resume();
|
||||
if (!redirects) return reject(new Error(`${url}: too many redirects`));
|
||||
let next;
|
||||
try { next = new URL(res.headers.location, url).href; }
|
||||
catch { return reject(new Error(`${url}: bad redirect ${res.headers.location}`)); }
|
||||
return resolve(get(next, redirects - 1));
|
||||
}
|
||||
if (res.statusCode !== 200) return reject(new Error(`${url}: HTTP ${res.statusCode}`));
|
||||
const chunks = [];
|
||||
res.on("data", (c) => chunks.push(c));
|
||||
res.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
}).on("timeout", function () { this.destroy(new Error(`${url}: timed out`)); }).on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function download(url, sha256, file) {
|
||||
const data = await get(url);
|
||||
const data = await fetchWithRetry(get, url);
|
||||
const sum = crypto.createHash("sha256").update(data).digest("hex");
|
||||
if (sum !== sha256) throw new Error(`checksum mismatch for ${url}: ${sum}`);
|
||||
fs.writeFileSync(file, data);
|
||||
|
||||
@@ -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",
|
||||
"version": "0.4.51",
|
||||
"version": "0.4.52",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.51",
|
||||
"version": "0.4.52",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.51",
|
||||
"version": "0.4.52",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"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);
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
# Battery Tracker 0.2.0 user-service adaptation
|
||||
|
||||
Official store ID 67, ZIP SHA-256 `91c388d3c1b52079314045b79671ad55c767510658b6193f6936ed6cad383b94`; the exact `main.py` SHA-256 is `2303ec70c262f1cbfe1c8c138770c6e7c3496827e748e679c94ca43ff9f28725`. The BSD-licensed archive requests `_root`, hard-codes Steam Deck `BAT1`, raises on empty history, and drops the final sample in per-app grouping. The backend records local battery readings in plugin runtime SQLite; it does not need privileged writes for the verified path.
|
||||
|
||||
The digest-scoped managed install removes `_root`, discovers a readable supply whose `type` is `Battery` (preferring `BAT1` on Deck), fixes empty and single-sample history, retains pending samples while the panel is open, and flushes them on unload. The original ZIP and license remain pinned in the test fixture. Changed archive bytes still fail the root gate; other root plugins remain blocked.
|
||||
|
||||
Under a unique owner token on `/tmp/frame-test.lock`, a **read-only** real Frame probe found SteamVR activity `3` (standby), proximity raw `2.25`, and one Battery-type supply named `max1720x_bat_7-36`. It exposed readable `capacity` (89%), `status` (Charging), `voltage_now`, and `current_now`; `power_now` was absent. The owned lock was released. No wake, input, normal service, power setting, or plugin install was touched.
|
||||
|
||||
In network/device-isolated, unprivileged **actual Decky 3.3.0 amd64**, the exact adapted archive was discovered. A private Frame-shaped supply fixture produced history and per-app power through public legacy RPC. The complete native regression runner, including existing negative controls, passed. Local managed install/remove tests and the exact-source/manifest guard passed. CI's native ARM64 and fake Frame e2e jobs remain required before merge.
|
||||
|
||||
This supports **partial** compatibility. The official frontend initialized in the earlier opaque Chromium audit with backend calls denied; it has not been tested with these live samples. Actual managed recording on Frame ARM64, long-term history, foreground-app attribution, panel controls, and worn VR remain unverified. Existing copies need update or reinstall.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Network Sentinel 1.0.4 managed user-service check
|
||||
|
||||
Official store ID 114, ZIP SHA-256 `d3e07f40a0c800e72d15594e03c8c61e9bf3092524a578a0260520e735b971a5`.
|
||||
The exact archive's `main.py` SHA-256 is `2baa390cb66dc267e8f04e61ba1edc43af75115ad5e453d308f6bf26ed70b6b9`.
|
||||
Its backend imports `psutil`, but the ZIP contains no wheel. The manifest requests `_root` although the inspected backend uses user-readable interface counters, subprocess ping and DNS, settings, and optional `nmcli` reads. Loading the backend does not start monitoring.
|
||||
|
||||
The managed install checks the complete archive digest, manifest and Python source before removing `_root`. It adds the existing pinned BSD psutil 7.2.2 wheel for the native architecture to this plugin's `py_modules`, uses Decky's plugin-scoped settings directory, leaves legacy path migration inactive in the user service, and initializes ping jitter for failed/offline calls. Other root-requesting archives still fail the root gate. The receipt records `network-sentinel-user-psutil`; existing copies need update or reinstall.
|
||||
|
||||
Local transaction tests installed and removed the exact archive, checked the native wheel and unmodified license, and rejected an altered archive. In network/device-isolated, unprivileged **actual Decky 3.3.0 amd64**, the managed plugin was discovered, imported native psutil, returned default and updated settings, reported empty history and bandwidth, started its monitoring loop, produced a history sample with quality and bandwidth fields, then stopped. The full native regression runner also passed its other cases, including FileServer and negative controls. The first run's existing large FileServer install timed out at 15 seconds under host load; its bounded timeout is now 30 seconds and the complete rerun passed. No external network call succeeded in the isolated container. Native ARM64 and fake Frame install/remove are CI gates for this change; neither is claimed by this local evidence.
|
||||
|
||||
The preexisting local Chromium audit initialized the exact frontend with backend calls denied. It did not prove a functioning panel. External latency/DNS, Wi-Fi scans, actual Frame ARM64 runtime, frontend controls, and worn VR remain unverified. This is a **partial** compatibility result, not a full monitoring or headset-usability claim. No real Frame, normal service, power value, or physical network setting was touched in this check.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Installer dependency fetch retry, 28 October 2026
|
||||
|
||||
The desktop installer build fetches pinned Python, platform-tools, CA and KDE
|
||||
Connect packages through `app/build/fetch-deps.js`. Before this change, one
|
||||
temporary connection drop or HTTP 503 ended the build. The iOS bundle's
|
||||
separate Python fetch hit a real remote disconnect in main CI run
|
||||
[40698195628](https://github.com/saphid/frame-control/actions/runs/40698195628);
|
||||
that path was repaired separately in PR #372.
|
||||
|
||||
The desktop fetch now retries transient transport errors and HTTP 408, 429 or
|
||||
5xx responses at most twice, with one- and two-second waits. HTTP 4xx errors
|
||||
other than 408/429 fail immediately. The existing pinned SHA-256 comparison
|
||||
still happens after download and before writing package bytes, so altered
|
||||
content fails without retry or installation.
|
||||
|
||||
Local Node 24 checks passed: retry recovery, exhausted retries, permanent HTTP
|
||||
error, HTTP 503 and a response aborted after partial bytes; all six app test
|
||||
files, script syntax and
|
||||
`git diff --check`. Hosted exact-head CI and installer preview remain required
|
||||
before merge/release. No installer was published, and this test makes no
|
||||
claim about a physical Mac or Windows install.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Main Windows test gate, 28 October 2026
|
||||
|
||||
Exact main commit `b5bbcec1a0cc3f8b3d25ef2686fd8315e209f167`
|
||||
([checks run 40723931638](https://github.com/saphid/frame-control/actions/runs/40723931638))
|
||||
failed in `server-tests (windows-latest, 3.12)`. Its server test step had one
|
||||
error: `test_settings_need_and_accept_the_ui_key` received Windows
|
||||
`ConnectionAbortedError [WinError 10053]` while reading an unauthorized
|
||||
POST response. The test sent a JSON body, but the missing-key guard rejects
|
||||
the request before reading it. Closing a connection with an unread body can
|
||||
reset it on Windows; that is the likely cause, rather than a settings write
|
||||
failure. The other Windows tests completed, and the exact-main native Decky
|
||||
runtime run passed.
|
||||
|
||||
The test now sends an empty POST when checking that a missing key is refused.
|
||||
Its subsequent authorized POST still sends a settings body and verifies the
|
||||
saved value. The local `tests/test_server.py` module passed 45 tests after
|
||||
this change. Hosted Windows CI on the repair commit and green exact-main CI
|
||||
remain required before further merges.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Published release manifest guard
|
||||
|
||||
The publisher previously accepted an already published release, deleted its existing `update.json`, then attempted to upload a replacement. If that upload failed, running copies would lose the live update manifest. The publisher now refuses a non-draft release before any asset deletion or upload.
|
||||
|
||||
The stand-in GitHub API regression provides a published release with an existing `update.json` and verifies that publication fails without any mutating API call. On 2026-10-28, `python3 -m unittest discover -s tests -p test_publish_release.py -q` passed all 12 tests; `git diff --check` passed. Hosted CI remains the merge gate. No GitHub release or update channel was changed by this test.
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"tag": "v0.4.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.
|
||||
@@ -4,7 +4,7 @@ Snapshot: 2026-10-26T06:10:58.537029+00:00; 116 current visible official plugins
|
||||
|
||||
This table and both store views use [`ui/framey-compatibility.json`](../ui/framey-compatibility.json). Results apply only to the exact store ID, version and SHA-256. A changed digest/version is **unverified**. Regenerate this document with `python3 scripts/framey-compatibility-doc.py`; use `--check` in review.
|
||||
|
||||
**Works: 6 · Partial: 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.
|
||||
- **Partial** means only a subset or a local fixture scope was verified, often just frontend/settings initialization. The remaining backend/device functionality is unverified; read the reason for the exact scope.
|
||||
@@ -29,6 +29,10 @@ A [Free Loader duplicate-entry follow-up](evidence/framey-free-loader-duplicates
|
||||
|
||||
A [2026-10-28 static action-limit review](evidence/framey-static-action-limits-2026-10-28.md) hash-checked the exact AutoSuspend and CheatDeck archives. AutoSuspend contains Steam suspend/shutdown paths, and CheatDeck's advertised trainer flow needs a Steam game context menu and launch-option writes. Neither plugin was executed; both retain their broken labels.
|
||||
|
||||
A [Network Sentinel user-service check](evidence/framey-network-sentinel-2026-10-28.md) adapts only the exact 1.0.4 digest. In isolated actual Decky 3.3.0 amd64, the pinned plugin-local psutil wheel, private settings RPC and one monitoring-loop sample pass. External ping/DNS, Frame ARM64, panel controls and worn VR remain unverified; this is partial compatibility.
|
||||
|
||||
A [Battery Tracker user-service check](evidence/framey-battery-tracker-2026-10-28.md) adapts only the exact 0.2.0 digest. A locked read-only Frame probe confirmed a Battery-type supply with the required fields; actual Decky 3.3.0 amd64 recorded private Frame-shaped samples and returned history/per-app power. The managed plugin has not run on the real Frame, and panel controls and worn VR remain unverified; this is partial compatibility.
|
||||
|
||||
The current published digests are pinned in the JSON; if the store changed, retain/download those exact archives for reproduction. Audit downloads permit larger ZIPs for inspection; production installer limits remain 50 MiB ZIP / 150 MiB unpacked / 30 MiB per member.
|
||||
|
||||
After the VR job released the shared lock, this audit acquired it and used a private temporary Decky 3.3.0 ARM64 user service. Read-only ProtonDB `get_setting` and Steam Status `get_settings` calls succeeded through the live Steam CEF websocket. The service and private base were removed and the lock released; normal managed services stayed inactive, battery was 100 → 99, and no power setting was changed. [Redacted results](evidence/framey-official-backend-2026-10-25.json) record exactly what ran. A [2026-10-26 follow-up](evidence/framey-events-backend-2026-10-26.json) verified default Notebook drawing/save/reopen across a real backend restart and Simple Timer start/cancel, countdown/completion events, a visible toast and persisted recents/subtle mode. Only private test settings were written. That initial pass left Simple Timer partial because its own expired-timer startup raises a missing `timer_task` error; sound/suspend are unsupported. A [local audio follow-up](evidence/framey-audio-backends-2026-10-26.md) exercised Volume Mixer, Volume Boost and Mixer through real Decky 3.3.0 amd64 backends on private silent virtual audio targets. Sliders, numeric volume entry, independent server readbacks and Mixer filter persistence across a runtime restart passed. An [ARM64 audio follow-up](evidence/framey-audio-arm64-2026-10-26.md) verified the unmodified official backends in actual Decky 3.3.0 on the Frame user audio session. Volume Mixer and Mixer now qualify as works for per-app volume: Chromium controls changed only a named silent stream, with independent Pulse readbacks. That run left Volume Boost partial because sink writes were denied. A [Volume Boost follow-up](evidence/framey-volume-boost-arm64-2026-10-26.md) now verifies linked/separate channel controls, re-link and reopen through the actual ARM64 backend. Independent Pulse reads confirmed 125%/150% gain writes while the default sink stayed muted; exact original volume/mute was restored. Volume Boost qualifies as works for sink controls. Audible output, Bluetooth playback and worn VR remain unverified. A [running-context follow-up](evidence/framey-running-context-2026-10-26.md) supplies detached running-app/main-app reads and relays actual Steam lifetime callbacks while a panel is open. Real Frame checks verified an empty list, registration/cleanup and the exact Discord Status ARM64 backend reporting no IPC connection. Nonempty games and transitions were synthetic fixtures; real game events, Discord activity and worn VR remain unverified. Discord Status moves to partial; no new works claim is made. A [library context follow-up](evidence/framey-library-context-2026-10-26.md) read 1,106 app overviews, 895 game IDs and the current public Steam ID from real Frame CEF under the shared lock. Local Chromium replayed this detached snapshot to render Deck Settings game selection, Steamback's empty save panel and PlayTime statistics/settings. Steamback and PlayTime used actual Decky 3.3.0 amd64 backends in an isolated container; their ARM64 backends and automatic game hooks remain unverified. Shotty's frontend now starts, but its published backend fails on missing `click`; it stays broken. A [chunked frontend follow-up](evidence/framey-deck-shelves-browser-2026-10-26.md) loaded Deck Shelves 2.0.0 through a managed JavaScript module route and rendered its settings in an opaque local Chromium panel. Its backend was denied; Steam Home shelves and worn VR remain unverified. A [local module lookup follow-up](evidence/framey-module-lookup-2026-10-26.md) supplies a finite registry of Framey-local controls and CSS. TabMaster QAM/docs now render; Steam library-tab editing remains unavailable. That pass exposed Junk-Store executable-script extraction failures; the follow-up below repairs them. SteamGridDB still requires Steam context-menu internals and DeckSP still fails its published MobX fallback. That pass tested TabMaster/Junk-Store in actual isolated amd64 Decky, without ARM64 or worn-VR coverage. A [script entrypoint follow-up](evidence/framey-script-entrypoints-2026-10-26.md) grants owner execute to shebang scripts and ZIP-declared executables without copying archive privilege/write bits. Junk-Store now passes static menu/backend calls in actual Decky 3.3.0 ARM64 and local Chromium Games/Epic navigation, back and reopen. It moves to partial: About still crashes on missing installed-app metadata, Legendary is absent, and login/game operations remain unverified. Existing installations require update/reinstall to repair permissions. A [read-only installed-metadata follow-up](evidence/framey-installed-context-2026-10-26.md) now repairs the About crash. The library snapshot copies only Steam-supplied installed booleans into detached metadata, preserving unknown values as undefined. Junk-Store About/Dependencies, Back and reopen render in local Chromium with actual Frame library data and the official ARM64 backend. It remains partial: dependency/login/game operations, RSSLine truncated
|
||||
@@ -81,7 +85,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
|
||||
| 64: Syncthing 0.2.7 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: decky-syncthing/bin/decky-syncthing-watchdog. Shared Toggle value rendering is repaired; other listed blockers remain. | static archive, local browser, fake-Frame |
|
||||
| 65: Decky Terminal 0.5.3 | 1 / 2 | partial | Local routes initialize after parameter-route support; no shell commands were run. | static archive, local browser, fake-Frame |
|
||||
| 66: PlayTime 3.2.5 | 1 / 2 | partial | Statistics and routed settings render with a detached real Frame library/user snapshot and actual Decky 3.3.0 amd64 backend in isolation. User selection and empty statistics reads pass. Running snapshots are now exposed, but its private MobX observation is unsupported; automatic recording and ARM64 backend remain unverified. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 67: Battery Tracker 0.2.0 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 67: Battery Tracker 0.2.0 | legacy / legacy | partial | Exact managed install removes the unnecessary root flag, selects a readable Battery-type power supply, and repairs empty history and per-app sample grouping. Actual Decky 3.3.0 amd64 recorded Frame-shaped private battery samples and returned history/power data. A locked real Frame read verified the required sysfs fields, not the managed plugin. Live ARM64 recording, long-term battery history, foreground-app labeling, panel controls and worn VR remain unverified; existing copies need update or reinstall. | static archive, local browser, fake-Frame, isolated Decky amd64 backend, real Frame read-only sysfs |
|
||||
| 68: Wine Cellar 0.2.1 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-wine-cellar/bin/backend. Frontend audit failed: Audit blocks WebSocket. | static archive, local browser, fake-Frame |
|
||||
| 70: MicroSDeck 0.11.2 | legacy / 2 | broken | Plugin contains a non-ARM64 Linux binary: MicroSDeck/bin/backend. | static archive, local browser, fake-Frame |
|
||||
| 72: ScreenshotUploader 0.0.3 | legacy / legacy | partial | Initializer succeeds; screenshot upload requires external credentials/network and was not run. | static archive, local browser, fake-Frame |
|
||||
@@ -124,7 +128,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
|
||||
| 111: Music Control 1.1.7 | legacy / legacy | partial | Managed reinstall preserves Decky’s legacy calling convention and repairs the Frame OS shell library-path failure. Chromium transport/seek/volume against a silent MPRIS fixture and real ARM64 empty-player discovery pass; actual Frame media playback, album art and worn VR remain unverified. Reinstall existing copies to apply. | static archive, local browser, fake-Frame, isolated Python backend, isolated Decky 3.3.0 + MPRIS fixture, real Frame SSH |
|
||||
| 112: Better Keyboard 0.3.4 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. Frontend audit failed: window.SteamClient.UI.GetUIMode is not a function. | static archive, local browser, fake-Frame |
|
||||
| 113: Decky LSFG-VK 0.12.2 | 1 / 2 | partial | Initializer succeeds; runtime LSFG/Windows DLL downloads and Vulkan integration are not ARM64-validated. Shared Toggle value rendering is repaired; other listed blockers remain. | static archive, local browser, fake-Frame |
|
||||
| 114: Network Sentinel 1.0.4 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 114: Network Sentinel 1.0.4 | 1 / 2 | partial | Exact managed install removes the unnecessary root flag and adds pinned plugin-local psutil wheels. Actual Decky 3.3.0 amd64 in network isolation loads the backend and records a network sample; private settings and read-only RPCs pass. External ping/DNS, real Frame ARM64, Wi-Fi scan behavior, panel controls and worn VR remain unverified. Existing copies need update or reinstall. | static archive, local browser, fake-Frame, isolated Decky amd64 backend |
|
||||
| 115: Decky Clash 1.3.0 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 116: Muon 0.2.1 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. Shared Toggle value rendering is repaired; other listed blockers remain. | static archive, local browser, fake-Frame |
|
||||
| 117: DeckyParty File Manager 1.0.0 | legacy / legacy | partial | Initializer succeeds; privileged file operations and backend are unverified. No file changes were run. | static archive, local browser, fake-Frame |
|
||||
|
||||
@@ -59,6 +59,8 @@ count. So a build reaches people only when you publish it, after testing it.
|
||||
installer digests. It attaches
|
||||
`update.json` (the version, the notes, the release page and each
|
||||
installer's digest), then publishes the release and marks it latest. It
|
||||
refuses an already published release before replacing its live update
|
||||
manifest; use a new tested release for changes after publication. It
|
||||
uses only the REST API: `gh release view` can't find a draft whose
|
||||
`tag_name` still reads `untagged-…`, and GraphQL is often rate-limited.
|
||||
Such a draft is found by its exact title (`Frame Control 0.4.0`, or that
|
||||
|
||||
@@ -7,8 +7,11 @@ ios/scripts/make_frame_bundle.py runs this; app/build/fetch-deps.js does the sam
|
||||
in Node. Usage: python3 frame/kdeconnect/fetch.py
|
||||
"""
|
||||
import hashlib
|
||||
import http.client
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
@@ -24,6 +27,25 @@ def sha256(data):
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def download_verified(url, expected_sha256):
|
||||
for attempt in range(3):
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=120) as response:
|
||||
data = response.read()
|
||||
except (OSError, http.client.HTTPException) as error:
|
||||
# GitHub release downloads occasionally close during connection or
|
||||
# transfer. Retry those failures, but fail fast on permanent HTTP errors.
|
||||
if isinstance(error, urllib.error.HTTPError) and error.code not in (408, 429) and error.code < 500:
|
||||
raise
|
||||
if attempt == 2:
|
||||
raise
|
||||
time.sleep(attempt + 1)
|
||||
continue
|
||||
if sha256(data) != expected_sha256:
|
||||
raise SystemExit(f"checksum mismatch for {url}: {sha256(data)}")
|
||||
return data
|
||||
|
||||
|
||||
def fetch():
|
||||
m = manifest()
|
||||
PACKAGES.mkdir(exist_ok=True)
|
||||
@@ -32,10 +54,7 @@ def fetch():
|
||||
if path.is_file() and sha256(path.read_bytes()) == p["sha256"]:
|
||||
continue
|
||||
url = m["release"] + p["file"]
|
||||
with urllib.request.urlopen(url, timeout=120) as r:
|
||||
data = r.read()
|
||||
if sha256(data) != p["sha256"]:
|
||||
raise SystemExit(f"checksum mismatch for {url}: {sha256(data)}")
|
||||
data = download_verified(url, p["sha256"])
|
||||
tmp = path.with_suffix(".part")
|
||||
tmp.write_bytes(data)
|
||||
tmp.replace(path)
|
||||
|
||||
@@ -42,6 +42,8 @@ def render(data):
|
||||
'Open the browser audit URL in local Chromium, run `runAudit()` and inspect/download `results`. All backend/network actions remain denied.', '',
|
||||
'A [Free Loader duplicate-entry follow-up](evidence/framey-free-loader-duplicates-2026-10-26.md) accepts only byte-identical duplicate ZIP members whose modes differ at most in execute bits. The exact archive passed private local and focused fake-Frame install/remove; its frontend rendered in opaque Chromium and isolated Python settings/empty-deals reads passed with a Decky stub. The earlier full 116-plugin fake-Frame audit blocked this archive before the fix. Actual Decky, feed updates and worn VR remain unverified.', '',
|
||||
'A [2026-10-28 static action-limit review](evidence/framey-static-action-limits-2026-10-28.md) hash-checked the exact AutoSuspend and CheatDeck archives. AutoSuspend contains Steam suspend/shutdown paths, and CheatDeck\'s advertised trainer flow needs a Steam game context menu and launch-option writes. Neither plugin was executed; both retain their broken labels.', '',
|
||||
'A [Network Sentinel user-service check](evidence/framey-network-sentinel-2026-10-28.md) adapts only the exact 1.0.4 digest. In isolated actual Decky 3.3.0 amd64, the pinned plugin-local psutil wheel, private settings RPC and one monitoring-loop sample pass. External ping/DNS, Frame ARM64, panel controls and worn VR remain unverified; this is partial compatibility.', '',
|
||||
'A [Battery Tracker user-service check](evidence/framey-battery-tracker-2026-10-28.md) adapts only the exact 0.2.0 digest. A locked read-only Frame probe confirmed a Battery-type supply with the required fields; actual Decky 3.3.0 amd64 recorded private Frame-shaped samples and returned history/per-app power. The managed plugin has not run on the real Frame, and panel controls and worn VR remain unverified; this is partial compatibility.', '',
|
||||
'The current published digests are pinned in the JSON; if the store changed, retain/download those exact archives for reproduction. '
|
||||
'Audit downloads permit larger ZIPs for inspection; production installer limits remain 50 MiB ZIP / 150 MiB unpacked / 30 MiB per member.', '',
|
||||
'After the VR job released the shared lock, this audit acquired it and used a private temporary Decky 3.3.0 ARM64 user service. '
|
||||
|
||||
@@ -56,7 +56,7 @@ if len(hits) != 1:
|
||||
sys.exit("found %s for %s; expected one draft" % (why, tag))
|
||||
r = hits[0]
|
||||
if not r.get("draft"):
|
||||
print("note: %s is already published; refreshing update.json and marking it latest" % tag, file=sys.stderr)
|
||||
sys.exit("not publishing: %s is already published; leave its live update manifest intact" % tag)
|
||||
json.dump(r, sys.stdout)
|
||||
EOF
|
||||
id=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$tmp/release.json")
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
[
|
||||
{
|
||||
"id": 67,
|
||||
"shim": "battery-tracker-user-supply",
|
||||
"probe": "battery-tracker"
|
||||
},
|
||||
{
|
||||
"id": 114,
|
||||
"shim": "network-sentinel-user-psutil",
|
||||
"probe": "network-sentinel"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"shim": null,
|
||||
|
||||
@@ -85,7 +85,7 @@ def main():
|
||||
Path('/tmp/frame-control-decky-plugin-runtime-test').write_bytes(data)
|
||||
install = subprocess.run([sys.executable, '-', 'install', 'runtime-test', str(record['id']),
|
||||
record['sha256'], record['folder'], record['version'], '-'], input=frame_decky_store.apply_script(record['id'], record['sha256']),
|
||||
capture_output=True, text=True, env=env, timeout=15)
|
||||
capture_output=True, text=True, env=env, timeout=30)
|
||||
assert install.returncode == 0, install.stderr
|
||||
plugin = base/'plugins'/record['folder']
|
||||
receipt = json.loads((plugin/'.frame-control-store.json').read_text())
|
||||
@@ -119,6 +119,13 @@ def main():
|
||||
env.update(UNPRIVILEGED_PATH=str(base), PRIVILEGED_PATH=str(base), CHOWN_PLUGIN_PATH='0',
|
||||
LIVE_RELOAD='0', USE_CEF_CLOSE_WORKAROUND='0', KEEP_SYSTEMD_SERVICE='1',
|
||||
DBUS_SESSION_BUS_ADDRESS='unix:path=/run/user/1100/bus')
|
||||
if case['probe'] == 'battery-tracker':
|
||||
supply = home/'battery-supply'/'max1720x_bat_7-36'
|
||||
supply.mkdir(parents=True)
|
||||
for name, value in {'type':'Battery', 'capacity':'89', 'status':'Charging',
|
||||
'voltage_now':'8746718', 'current_now':'1030625'}.items():
|
||||
(supply/name).write_text(value+'\n')
|
||||
env['FRAMEY_BATTERY_ROOT'] = str(supply.parent)
|
||||
processes = []
|
||||
report = {'id': record['id'], 'name': record['name'], 'version': record['version'],
|
||||
'sha256': record['sha256'], 'shim': case['shim'], 'calls': []}
|
||||
@@ -152,7 +159,38 @@ def main():
|
||||
report['calls'].append({'method': method, 'result': 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'
|
||||
expected = ('e20363237e606ee65eae857792176d21a0bb8ba863489952728eaf2b13277dae'
|
||||
if __import__('platform').machine() == 'aarch64'
|
||||
|
||||
@@ -225,6 +225,17 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
|
||||
receipt = json.loads(ssh(f'cat {installed}/.frame-control-store.json'))
|
||||
self.assertEqual(receipt['compatShims'], ['deckyspy-psutil-and-battery'])
|
||||
self.assertEqual(ssh(f'test -f {installed}/py_modules/psutil/_psutil_linux.abi3.so && test -f {installed}/py_modules/framey_battery.py && echo yes').strip(), 'yes')
|
||||
if plugin['id'] == 67:
|
||||
receipt = json.loads(ssh(f'cat {installed}/.frame-control-store.json'))
|
||||
self.assertEqual(receipt['compatShims'], ['battery-tracker-user-supply'])
|
||||
self.assertEqual(found[0]['compatibility']['status'], 'partial')
|
||||
self.assertEqual(json.loads(ssh(f'cat {installed}/plugin.json'))['flags'], [])
|
||||
if plugin['id'] == 114:
|
||||
receipt = json.loads(ssh(f'cat {installed}/.frame-control-store.json'))
|
||||
self.assertEqual(receipt['compatShims'], ['network-sentinel-user-psutil'])
|
||||
self.assertEqual(found[0]['compatibility']['status'], 'partial')
|
||||
self.assertEqual(json.loads(ssh(f'cat {installed}/plugin.json'))['flags'], ['debug'])
|
||||
self.assertEqual(ssh(f'test -f {installed}/py_modules/psutil/_psutil_linux.abi3.so && echo yes').strip(), 'yes')
|
||||
if plugin['id'] in (12, 111):
|
||||
receipt = json.loads(ssh(f'cat {installed}/.frame-control-store.json'))
|
||||
self.assertEqual(receipt['compatShims'], ['music-control-system-dbus-env'])
|
||||
|
||||
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
|
||||
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
|
||||
Frame Control. The fake Frame tests serve them
|
||||
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` |
|
||||
| `60.zip` | DeckyFileServer | 1.2.0 | `7dc2ecdcd05aa415d1ac90afaff28ecb17f77809a2bc7a77aee8e92505b976f0` |
|
||||
| `125.zip` | Decky Localsend | 0.4.5 | `e271ccb55eb23f918b99a69f79aec905df4c9b2ef7b23bbf4ec1c2d1a6f0c6b7` |
|
||||
| `114.zip` | Network Sentinel | 1.0.4 | `d3e07f40a0c800e72d15594e03c8c61e9bf3092524a578a0260520e735b971a5` |
|
||||
| `67.zip` | Battery Tracker | 0.2.0 | `91c388d3c1b52079314045b79671ad55c767510658b6193f6936ed6cad383b94` |
|
||||
| `96.zip` | Simple Timer | 1.0.0 | `973d927e9d9a1feec04679c564af7e389a729b6735d2a988ffc2cb46ce4eb312` |
|
||||
| `7.zip` | CSS Loader | 2.1.3 | `99beaf1374af82408e6d056e0b0147e5fca544e1035fa7ab88419231697bc231` |
|
||||
| `12.zip` | MusicControl | 1.1.6 | `246b89fd653c60a735b1cc401d1b0937d5cb969eea9024561661ff741f081d62` |
|
||||
@@ -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
|
||||
is called. The official frontend's standalone Toggle component separately
|
||||
checks saved true/false state and matching settings callbacks; effects are inert.
|
||||
Network Sentinel runs through the same isolated native Decky gate. Its exact
|
||||
archive gets plugin-local psutil and a user-service manifest; the probe checks
|
||||
discovery, private settings and one monitoring-loop sample without external
|
||||
network access. It does not verify real network latency or worn VR.
|
||||
Battery Tracker's exact archive also has a user-service adaptation. The native
|
||||
Decky gate checks discovery, empty history, Frame-shaped private battery
|
||||
samples and per-app power aggregation. The real Frame probe checked only the
|
||||
readable Battery-type sysfs fields; the plugin itself did not run there.
|
||||
DeckyFileServer also runs through actual Decky 3.3.0 discovery/API1 RPC with a
|
||||
native helper replacement. HTTPS listing/download use only public markers in a
|
||||
private directory; uploads remain disabled. The exact source build, GPL licenses
|
||||
|
||||
@@ -4,6 +4,10 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).parent / 'fixtures/framey-official'
|
||||
PLUGINS = (
|
||||
{'id': 67, 'name': 'Battery Tracker', 'version': '0.2.0', 'api_version': 'legacy',
|
||||
'folder': 'steam-deck-battery-tracker', 'sha256': '91c388d3c1b52079314045b79671ad55c767510658b6193f6936ed6cad383b94'},
|
||||
{'id': 114, 'name': 'Network Sentinel', 'version': '1.0.4', 'api_version': 1,
|
||||
'folder': 'network-sentinel', 'sha256': 'd3e07f40a0c800e72d15594e03c8c61e9bf3092524a578a0260520e735b971a5'},
|
||||
{'id': 13, 'name': 'Pause Games', 'version': '1.0.3', 'api_version': 1,
|
||||
'folder': 'SDH-PauseGames', 'sha256': '4aefdfb0ef25dea9c6d9fd037731c8b99ee8a1be966c2f7d5b0602112ef29f62'},
|
||||
{'id': 60, 'name': 'DeckyFileServer', 'version': '1.2.0', 'api_version': 1,
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
"""Official Decky store transport and owned plugin transaction."""
|
||||
import sandbox # noqa: F401
|
||||
import hashlib
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import warnings
|
||||
@@ -74,6 +78,24 @@ class ArchiveTests(unittest.TestCase):
|
||||
with patch.object(frame_decky_store.urllib.request, 'urlopen', return_value=io.BytesIO(json.dumps(records).encode())):
|
||||
self.assertTrue(frame_decky_store.catalog()[0]['root'])
|
||||
|
||||
def test_only_exact_network_sentinel_adaptation_clears_store_root(self):
|
||||
records = [{'id': 114, 'name': 'Network Sentinel', 'tags': ['_root'],
|
||||
'versions': [{'name': '1.0.4', 'hash': frame_decky_store.NETWORK_SENTINEL_DIGEST}]}]
|
||||
with patch.object(frame_decky_store.urllib.request, 'urlopen', return_value=io.BytesIO(json.dumps(records).encode())):
|
||||
self.assertFalse(frame_decky_store.catalog()[0]['root'])
|
||||
records[0]['versions'][0]['hash'] = 'a'*64
|
||||
with patch.object(frame_decky_store.urllib.request, 'urlopen', return_value=io.BytesIO(json.dumps(records).encode())):
|
||||
self.assertTrue(frame_decky_store.catalog()[0]['root'])
|
||||
|
||||
def test_only_exact_battery_tracker_adaptation_clears_store_root(self):
|
||||
records = [{'id': 67, 'name': 'Battery Tracker', 'tags': ['_root'],
|
||||
'versions': [{'name': '0.2.0', 'hash': frame_decky_store.BATTERY_TRACKER_DIGEST}]}]
|
||||
with patch.object(frame_decky_store.urllib.request, 'urlopen', return_value=io.BytesIO(json.dumps(records).encode())):
|
||||
self.assertFalse(frame_decky_store.catalog()[0]['root'])
|
||||
records[0]['versions'][0]['hash'] = 'a'*64
|
||||
with patch.object(frame_decky_store.urllib.request, 'urlopen', return_value=io.BytesIO(json.dumps(records).encode())):
|
||||
self.assertTrue(frame_decky_store.catalog()[0]['root'])
|
||||
|
||||
def test_accepts_hashed_non_root_archive(self):
|
||||
data, digest = archive()
|
||||
self.assertEqual(frame_decky_store.archive_info(data, digest, 'Example'), 'Example')
|
||||
@@ -269,6 +291,114 @@ class StoreTransaction(unittest.TestCase):
|
||||
frame_decky_store.LOCALSEND_AARCH64)
|
||||
self.assertEqual(int.from_bytes(helper.read_bytes()[18:20], 'little'), 183)
|
||||
@unittest.skipUnless(__import__('platform').machine() in ('x86_64', 'aarch64'), 'native Linux wheel architecture')
|
||||
def test_exact_battery_tracker_uses_user_battery_supply_and_preserves_license(self):
|
||||
from framey_official import PLUGINS, archive as official_archive
|
||||
record = next(p for p in PLUGINS if p['id'] == 67)
|
||||
data = official_archive(record)
|
||||
self.assertEqual(frame_decky_store.archive_info(data, record['sha256'], record['name']), record['folder'])
|
||||
result = self.apply('install', data, plugin_id=67,
|
||||
folder=record['folder'], version=record['version'])
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
folder = self.folder.parent / record['folder']
|
||||
receipt = json.loads((folder / '.frame-control-store.json').read_text())
|
||||
self.assertEqual(receipt['compatShims'], ['battery-tracker-user-supply'])
|
||||
self.assertEqual(json.loads((folder / 'plugin.json').read_text())['flags'], [])
|
||||
source = (folder / 'main.py').read_text()
|
||||
self.assertNotIn('/sys/class/power_supply/BAT1/', source)
|
||||
self.assertIn("root / 'BAT1'", source)
|
||||
compile(source, str(folder / 'main.py'), 'exec')
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as original:
|
||||
self.assertEqual((folder / 'LICENSE').read_bytes(), original.read(record['folder']+'/LICENSE'))
|
||||
self.assertEqual(self.apply('remove', plugin_id=67, folder=record['folder']).returncode, 0)
|
||||
self.assertFalse(folder.exists())
|
||||
changed = io.BytesIO(data)
|
||||
with zipfile.ZipFile(changed, 'a') as archive:
|
||||
archive.writestr(record['folder']+'/changed.txt', 'changed')
|
||||
altered = changed.getvalue()
|
||||
with self.assertRaisesRegex(ValueError, 'root'):
|
||||
frame_decky_store.archive_info(altered, hashlib.sha256(altered).hexdigest(), record['name'])
|
||||
result = self.apply('install', altered, plugin_id=67,
|
||||
folder=record['folder'], version=record['version'])
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse(folder.exists())
|
||||
|
||||
def test_adapted_battery_tracker_empty_history_and_unload_flush(self):
|
||||
from framey_official import PLUGINS, archive as official_archive
|
||||
record = next(p for p in PLUGINS if p['id'] == 67)
|
||||
result = self.apply('install', official_archive(record), plugin_id=67,
|
||||
folder=record['folder'], version=record['version'])
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
folder = self.folder.parent / record['folder']
|
||||
runtime = Path(self.tmp.name) / 'battery-runtime'
|
||||
runtime.mkdir()
|
||||
supply = Path(self.tmp.name) / 'battery-supply' / 'frame-battery'
|
||||
supply.mkdir(parents=True)
|
||||
for name, value in {'type': 'Battery', 'capacity': '89', 'status': 'Charging',
|
||||
'voltage_now': '8746718', 'current_now': '1030625'}.items():
|
||||
(supply / name).write_text(value + '\n')
|
||||
decky = types.ModuleType('decky_plugin')
|
||||
decky.logger = logging.getLogger('battery-tracker-test')
|
||||
decky.DECKY_PLUGIN_RUNTIME_DIR = str(runtime)
|
||||
with patch.dict(sys.modules, {'decky_plugin': decky}), patch.dict(os.environ, {'FRAMEY_BATTERY_ROOT': str(supply.parent)}):
|
||||
spec = importlib.util.spec_from_file_location('framey_battery_tracker_test', folder / 'main.py')
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
async def exercise():
|
||||
plugin = module.Plugin()
|
||||
await plugin._main()
|
||||
self.assertEqual(await plugin.get_recent_data(), {'x': [], 'cap': [], 'power_data': []})
|
||||
# The first sample is still pending when Decky unloads the plugin.
|
||||
await asyncio.sleep(0)
|
||||
self.assertEqual(len(plugin._pending_samples), 1)
|
||||
self.assertEqual((await plugin.get_recent_data())['cap'], [.89])
|
||||
await plugin._unload()
|
||||
self.assertEqual(plugin._pending_samples, [])
|
||||
|
||||
asyncio.run(exercise())
|
||||
import sqlite3
|
||||
with sqlite3.connect(runtime / 'battery.db') as db:
|
||||
self.assertEqual(db.execute('select capacity from battery').fetchall(), [(89,)])
|
||||
|
||||
@unittest.skipUnless(__import__('platform').machine() in ('x86_64', 'aarch64'), 'native Linux wheel architecture')
|
||||
def test_exact_network_sentinel_is_user_scoped_and_digest_guarded(self):
|
||||
from framey_official import PLUGINS, archive as official_archive
|
||||
import platform
|
||||
record = next(p for p in PLUGINS if p['id'] == 114)
|
||||
data = official_archive(record)
|
||||
self.assertEqual(frame_decky_store.archive_info(data, record['sha256'], record['name']), record['folder'])
|
||||
result = self.apply('install', data, plugin_id=114,
|
||||
folder=record['folder'], version=record['version'])
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
folder = self.folder.parent / record['folder']
|
||||
receipt = json.loads((folder / '.frame-control-store.json').read_text())
|
||||
self.assertEqual(receipt['compatShims'], ['network-sentinel-user-psutil'])
|
||||
self.assertEqual(json.loads((folder / 'plugin.json').read_text())['flags'], ['debug'])
|
||||
source = (folder / 'main.py').read_text()
|
||||
self.assertNotIn('decky.DECKY_SETTINGS_DIR', source)
|
||||
self.assertIn('decky.DECKY_PLUGIN_SETTINGS_DIR', source)
|
||||
self.assertIn(" jitter = 0\n cmd = ['ping'", source)
|
||||
compile(source, str(folder / 'main.py'), 'exec')
|
||||
native = (folder / 'py_modules/psutil/_psutil_linux.abi3.so').read_bytes()
|
||||
self.assertEqual(int.from_bytes(native[18:20], 'little'),
|
||||
{'x86_64': 62, 'aarch64': 183}[platform.machine()])
|
||||
self.assertIn('BSD 3-Clause', (folder / 'py_modules/psutil-7.2.2.dist-info/LICENSE').read_text())
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as original:
|
||||
self.assertEqual((folder / 'LICENSE').read_bytes(), original.read(record['folder']+'/LICENSE'))
|
||||
self.assertEqual(self.apply('remove', plugin_id=114, folder=record['folder']).returncode, 0)
|
||||
self.assertFalse(folder.exists())
|
||||
changed = io.BytesIO(data)
|
||||
with zipfile.ZipFile(changed, 'a') as archive:
|
||||
archive.writestr(record['folder']+'/changed.txt', 'changed')
|
||||
altered = changed.getvalue()
|
||||
with self.assertRaisesRegex(ValueError, 'root'):
|
||||
frame_decky_store.archive_info(altered, hashlib.sha256(altered).hexdigest(), record['name'])
|
||||
result = self.apply('install', altered, plugin_id=114,
|
||||
folder=record['folder'], version=record['version'])
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse(folder.exists())
|
||||
|
||||
@unittest.skipUnless(__import__('platform').machine() in ('x86_64', 'aarch64'), 'native Linux wheel architecture')
|
||||
def test_exact_deckyspy_gets_native_dependency_and_license_only_in_its_folder(self):
|
||||
from framey_official import PLUGINS, archive as official_archive
|
||||
import platform
|
||||
|
||||
@@ -20,7 +20,8 @@ class RuntimeCoverage(unittest.TestCase):
|
||||
for case in cases:
|
||||
self.assertIn(case['id'], official)
|
||||
self.assertIn(case['probe'], ('mpris', 'screenshots', 'system-monitor', 'timer-lifecycle',
|
||||
'toggle-settings', 'fileserver', 'localsend'))
|
||||
'toggle-settings', 'fileserver', 'localsend', 'network-sentinel',
|
||||
'battery-tracker'))
|
||||
|
||||
def test_new_shim_without_runtime_case_fails_coverage(self):
|
||||
import frame_decky_store
|
||||
|
||||
@@ -84,10 +84,10 @@ class OfficialBundles(unittest.TestCase):
|
||||
folder = plugins / plugin['folder']
|
||||
(folder / '.frame-control-store.json').write_text(json.dumps({
|
||||
'storeId': plugin['id'], 'sha256': plugin['sha256']}))
|
||||
if plugin['id'] == 60:
|
||||
# This renderer fixture extracts the original ZIP. The
|
||||
# real managed adaptation is covered by transaction and
|
||||
# published Decky tests; an unadapted root copy must fail.
|
||||
if plugin['id'] in (60, 67, 114):
|
||||
# This renderer fixture extracts the original root ZIP.
|
||||
# Transaction and Decky tests cover managed adaptation;
|
||||
# an unadapted root copy must fail here.
|
||||
with patch.object(framey_panel, 'PLUGINS', plugins), self.assertRaises(FileNotFoundError):
|
||||
framey_panel.panel(plugin['id'])
|
||||
manifest['flags'] = []
|
||||
|
||||
@@ -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()
|
||||
@@ -76,6 +76,16 @@ class PublishRelease(unittest.TestCase):
|
||||
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
|
||||
self.assertEqual(next(c for c in log if "PATCH" in c)[3], "repos/saphid/frame-control/releases/2")
|
||||
|
||||
def test_already_published_release_keeps_its_live_update_manifest(self):
|
||||
old = {"id": 7, "name": "update.json", "size": 100,
|
||||
"digest": "sha256:" + "a" * 64}
|
||||
rel = dict(draft(tag_name="v9.8.7", assets=draft()["assets"] + [old]), draft=False)
|
||||
p, log, upload = self.run_script([rel], "v9.8.7")
|
||||
self.assertNotEqual(p.returncode, 0)
|
||||
self.assertIn("already published", p.stderr)
|
||||
self.assertIsNone(upload)
|
||||
self.assertFalse(any("-X" in call for call in log))
|
||||
|
||||
def test_refuses_missing_or_unhashed_installers(self):
|
||||
assets = draft()["assets"][1:]
|
||||
assets[0] = dict(assets[0], digest=None)
|
||||
|
||||
@@ -497,7 +497,10 @@ class ArtworkSettings(unittest.TestCase):
|
||||
time.sleep(0.05)
|
||||
key = {"X-Frame-UI": "1", "Content-Type": "application/json"}
|
||||
self.assertEqual(request("GET", "/api/settings/artwork")[0], 403)
|
||||
self.assertEqual(request("POST", "/api/settings/artwork", {"steamgriddb_api_key": "abc"})[0], 403)
|
||||
# The missing-key guard runs before reading a body. Leaving an
|
||||
# unread body on Windows can reset this test connection before
|
||||
# http.client receives the 403 response.
|
||||
self.assertEqual(request("POST", "/api/settings/artwork")[0], 403)
|
||||
status, payload = request("GET", "/api/settings/artwork", headers=key)
|
||||
self.assertEqual((status, json.loads(payload)["steamgriddb_configured"]), (200, False))
|
||||
status, payload = request("POST", "/api/settings/artwork", {"steamgriddb_api_key": "abc_1"}, key)
|
||||
|
||||
+153
-14
@@ -37,6 +37,10 @@ LOCALSEND_DIGEST = 'e271ccb55eb23f918b99a69f79aec905df4c9b2ef7b23bbf4ec1c2d1a6f0
|
||||
LOCALSEND_ORIGINAL = 'd448d03861d578685e6c70ea3ace25eb34fca99cb9d4f98bf51ba23aa5cae843'
|
||||
LOCALSEND_MAIN = 'db03f9201fe50b75dea99a7a05b54ec3f352d1bdfe174115b0e569154198d8ad'
|
||||
LOCALSEND_AARCH64 = 'e20363237e606ee65eae857792176d21a0bb8ba863489952728eaf2b13277dae'
|
||||
NETWORK_SENTINEL_DIGEST = 'd3e07f40a0c800e72d15594e03c8c61e9bf3092524a578a0260520e735b971a5'
|
||||
NETWORK_SENTINEL_MAIN = '2baa390cb66dc267e8f04e61ba1edc43af75115ad5e453d308f6bf26ed70b6b9'
|
||||
BATTERY_TRACKER_DIGEST = '91c388d3c1b52079314045b79671ad55c767510658b6193f6936ed6cad383b94'
|
||||
BATTERY_TRACKER_MAIN = '2303ec70c262f1cbfe1c8c138770c6e7c3496827e748e679c94ca43ff9f28725'
|
||||
|
||||
VERSION = re.compile(r'[A-Za-z0-9][A-Za-z0-9._+\-]{0,39}\Z')
|
||||
|
||||
@@ -83,7 +87,9 @@ def catalog():
|
||||
counts[item['id']] = counts.get(item['id'], 0) + 1
|
||||
unique = [item for item in result if counts[item['id']] == 1]
|
||||
for item in unique:
|
||||
if item['id'] == 60 and item['name'] == 'DeckyFileServer' and item['sha256'] == FILESERVER_DIGEST:
|
||||
if ((item['id'] == 60 and item['name'] == 'DeckyFileServer' and item['sha256'] == FILESERVER_DIGEST)
|
||||
or (item['id'] == 114 and item['name'] == 'Network Sentinel' and item['sha256'] == NETWORK_SENTINEL_DIGEST)
|
||||
or (item['id'] == 67 and item['name'] == 'Battery Tracker' and item['sha256'] == BATTERY_TRACKER_DIGEST)):
|
||||
item['root'] = False # Exact managed adaptation runs as the Frame user.
|
||||
item['compatibility'] = framey_compatibility.lookup(item['id'], item['sha256'], item['version'])
|
||||
return unique
|
||||
@@ -107,6 +113,10 @@ def archive_info(data, expected_hash, expected_name):
|
||||
and folder == 'DeckyFileServer')
|
||||
localsend = (expected_hash == LOCALSEND_DIGEST and expected_name == 'Decky Localsend'
|
||||
and folder == 'decky-localsend')
|
||||
network_sentinel = (expected_hash == NETWORK_SENTINEL_DIGEST and expected_name == 'Network Sentinel'
|
||||
and folder == 'network-sentinel')
|
||||
battery_tracker = (expected_hash == BATTERY_TRACKER_DIGEST and expected_name == 'Battery Tracker'
|
||||
and folder == 'steam-deck-battery-tracker')
|
||||
seen = {}
|
||||
for entry in files:
|
||||
name = entry.filename
|
||||
@@ -151,10 +161,16 @@ def archive_info(data, expected_hash, expected_name):
|
||||
flags = manifest.get('flags', [])
|
||||
if not isinstance(flags, list) or not all(isinstance(flag, str) for flag in flags):
|
||||
raise ValueError('Invalid plugin flags')
|
||||
if ('root' in flags or '_root' in flags) and not fileserver:
|
||||
if ('root' in flags or '_root' in flags) and not (fileserver or network_sentinel or battery_tracker):
|
||||
raise ValueError('This plugin needs root and is not supported by the Frame user service')
|
||||
if localsend and hashlib.sha256(archive.read(folder+'/main.py')).hexdigest() != LOCALSEND_MAIN:
|
||||
raise ValueError('LocalSend Python source differs from the pinned archive')
|
||||
if network_sentinel and (flags != ['debug', '_root'] or manifest.get('api_version') != 1
|
||||
or hashlib.sha256(archive.read(folder+'/main.py')).hexdigest() != NETWORK_SENTINEL_MAIN):
|
||||
raise ValueError('Network Sentinel manifest or source differs from the pinned archive')
|
||||
if battery_tracker and (flags != ['_root'] or 'api_version' in manifest
|
||||
or hashlib.sha256(archive.read(folder+'/main.py')).hexdigest() != BATTERY_TRACKER_MAIN):
|
||||
raise ValueError('Battery Tracker manifest or source differs from the pinned archive')
|
||||
return folder
|
||||
|
||||
|
||||
@@ -185,7 +201,8 @@ def apply_script(plugin_id, digest):
|
||||
# Python stdin, never shell arguments. The Frame rechecks hash and ELF machine.
|
||||
return ('FRAMEY_FILESERVER_BINARIES = '+repr(payloads)+'\n'
|
||||
+'FRAMEY_FILESERVER_HASHES = '+repr(FILESERVER_BINARIES)+'\n'+APPLY)
|
||||
if plugin_id != 89 or digest != DECKYSPY_DIGEST:
|
||||
if not ((plugin_id == 89 and digest == DECKYSPY_DIGEST)
|
||||
or (plugin_id == 114 and digest == NETWORK_SENTINEL_DIGEST)):
|
||||
return APPLY
|
||||
wheels = {}
|
||||
for arch, expected in PSUTIL_DIGESTS.items():
|
||||
@@ -194,9 +211,11 @@ def apply_script(plugin_id, digest):
|
||||
raise ValueError('Bundled psutil dependency SHA-256 mismatch')
|
||||
wheels[arch] = base64.b64encode(data).decode('ascii')
|
||||
# stdin is Python source, never shell arguments or a shared remote temp file.
|
||||
battery = (Path(__file__).parent/'framey_battery.py').read_text(encoding='utf-8')
|
||||
return ('FRAMEY_BACKEND_WHEELS = '+repr(wheels)+'\n'
|
||||
+'FRAMEY_BACKEND_BATTERY_SOURCE = '+repr(battery)+'\n'+APPLY)
|
||||
source = 'FRAMEY_BACKEND_WHEELS = '+repr(wheels)+'\n'
|
||||
if plugin_id == 89:
|
||||
battery = (Path(__file__).parent/'framey_battery.py').read_text(encoding='utf-8')
|
||||
source += 'FRAMEY_BACKEND_BATTERY_SOURCE = '+repr(battery)+'\n'
|
||||
return source+APPLY
|
||||
|
||||
|
||||
APPLY = r'''
|
||||
@@ -244,12 +263,14 @@ try:
|
||||
raise RuntimeError('Plugin archive too large')
|
||||
fileserver=(int(plugin_id)==60 and digest=='7dc2ecdcd05aa415d1ac90afaff28ecb17f77809a2bc7a77aee8e92505b976f0' and folder=='DeckyFileServer')
|
||||
localsend=(int(plugin_id)==125 and digest=='e271ccb55eb23f918b99a69f79aec905df4c9b2ef7b23bbf4ec1c2d1a6f0c6b7' and folder=='decky-localsend')
|
||||
network_sentinel=(int(plugin_id)==114 and digest=='d3e07f40a0c800e72d15594e03c8c61e9bf3092524a578a0260520e735b971a5' and folder=='network-sentinel')
|
||||
battery_tracker=(int(plugin_id)==67 and digest=='91c388d3c1b52079314045b79671ad55c767510658b6193f6936ed6cad383b94' and folder=='steam-deck-battery-tracker')
|
||||
manifest=json.loads(z.read(folder+'/plugin.json'))
|
||||
if not isinstance(manifest,dict): raise RuntimeError('Invalid plugin manifest')
|
||||
flags=manifest.get('flags',[])
|
||||
if not isinstance(flags,list) or not all(isinstance(flag,str) for flag in flags):
|
||||
raise RuntimeError('Invalid plugin flags')
|
||||
if ('root' in flags or '_root' in flags) and not fileserver:
|
||||
if ('root' in flags or '_root' in flags) and not (fileserver or network_sentinel or battery_tracker):
|
||||
raise RuntimeError('Root plugins are not supported by the Frame user service')
|
||||
seen={}
|
||||
for f in files:
|
||||
@@ -478,10 +499,10 @@ try:
|
||||
' base = os.path.join(os.path.expanduser("~"), ".config")\n'
|
||||
' return os.path.join(base, app_name)\n',encoding='utf-8')
|
||||
compat_shims.append('shotty-click-app-dir')
|
||||
# DeckySpy's official archive omits its required psutil dependency.
|
||||
# The BSD wheels are packaged offline by Frame Control and selected
|
||||
# for the native loader; no pip, network or system path changes.
|
||||
if int(plugin_id)==89 and digest=='2d431a89826ed5cf3fd37258373eb3b46c841c39795aa8b25f83e59f4eee87c7' and folder=='decky-spy':
|
||||
# Both exact official archives omit psutil. Install the pinned BSD
|
||||
# native wheel in the plugin folder only; never use pip or system paths.
|
||||
deckyspy=(int(plugin_id)==89 and digest=='2d431a89826ed5cf3fd37258373eb3b46c841c39795aa8b25f83e59f4eee87c7' and folder=='decky-spy')
|
||||
if deckyspy or network_sentinel:
|
||||
arch=platform.machine()
|
||||
pins={
|
||||
'x86_64':'076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9',
|
||||
@@ -489,13 +510,13 @@ try:
|
||||
}
|
||||
encoded=globals().get('FRAMEY_BACKEND_WHEELS',{}).get(arch)
|
||||
if arch not in pins or not encoded:
|
||||
raise RuntimeError('Missing native psutil dependency for DeckySpy')
|
||||
raise RuntimeError('Missing native psutil dependency')
|
||||
wheel_data=base64.b64decode(encoded,validate=True)
|
||||
if hashlib.sha256(wheel_data).hexdigest()!=pins[arch]:
|
||||
raise RuntimeError('DeckySpy psutil dependency SHA-256 mismatch')
|
||||
raise RuntimeError('Native psutil dependency SHA-256 mismatch')
|
||||
modules=unpacked/'py_modules'
|
||||
for name in ('psutil','psutil-7.2.2.dist-info'):
|
||||
if (modules/name).exists(): raise RuntimeError('DeckySpy dependency collides with archive')
|
||||
if (modules/name).exists(): raise RuntimeError('Native psutil dependency collides with archive')
|
||||
with zipfile.ZipFile(io.BytesIO(wheel_data)) as wheel:
|
||||
for member in wheel.infolist():
|
||||
path=pathlib.PurePosixPath(member.filename)
|
||||
@@ -510,6 +531,124 @@ try:
|
||||
dest.parent.mkdir(parents=True,exist_ok=True)
|
||||
dest.write_bytes(payload)
|
||||
dest.chmod(0o600)
|
||||
if network_sentinel:
|
||||
manifest_path=unpacked/'plugin.json'
|
||||
manifest=json.loads(manifest_path.read_text())
|
||||
main=unpacked/'main.py'
|
||||
if (manifest.get('name')!='Network Sentinel' or manifest.get('api_version')!=1
|
||||
or manifest.get('flags')!=['debug','_root'] or
|
||||
hashlib.sha256(main.read_bytes()).hexdigest()!='2baa390cb66dc267e8f04e61ba1edc43af75115ad5e453d308f6bf26ed70b6b9'):
|
||||
raise RuntimeError('Network Sentinel manifest or source changed')
|
||||
manifest['flags']=['debug']
|
||||
manifest_path.write_text(json.dumps(manifest),encoding='utf-8')
|
||||
source=main.read_text(encoding='utf-8')
|
||||
old='decky.DECKY_SETTINGS_DIR'
|
||||
ping=' cmd = [\'ping\', \'-c\', str(count), \'-W\', \'2\', host]\n'
|
||||
if (source.count(old)!=2 or source.count(' async def _migration(self):')!=1
|
||||
or source.count(ping)!=1):
|
||||
raise RuntimeError('Network Sentinel settings or migration source changed')
|
||||
source=source.replace(old,'decky.DECKY_PLUGIN_SETTINGS_DIR')
|
||||
source=source.replace(ping,' jitter = 0\n'+ping)
|
||||
source=source.replace(' async def _migration(self):',
|
||||
' async def _migration(self):\n # Managed user service does not move legacy Decky paths.\n return\n')
|
||||
main.write_text(source,encoding='utf-8')
|
||||
compat_shims.append('network-sentinel-user-psutil')
|
||||
if battery_tracker:
|
||||
manifest_path=unpacked/'plugin.json'
|
||||
manifest=json.loads(manifest_path.read_text())
|
||||
main=unpacked/'main.py'
|
||||
if (manifest.get('name')!='Battery Tracker' or manifest.get('flags')!=['_root']
|
||||
or 'api_version' in manifest or
|
||||
hashlib.sha256(main.read_bytes()).hexdigest()!='2303ec70c262f1cbfe1c8c138770c6e7c3496827e748e679c94ca43ff9f28725'):
|
||||
raise RuntimeError('Battery Tracker manifest or source changed')
|
||||
manifest['flags']=[]
|
||||
manifest_path.write_text(json.dumps(manifest),encoding='utf-8')
|
||||
source=main.read_text(encoding='utf-8')
|
||||
split_start=source.index('def split_by_app(data):\n')
|
||||
split_end=source.index('\n\nclass Plugin:',split_start)
|
||||
source=source[:split_start]+"""def _framey_battery_path():
|
||||
# A normal user can read Frame's Battery-type supply; BAT1 remains first
|
||||
# on Steam Deck. The override is for a private, networkless runtime fixture.
|
||||
root = Path(os.environ.get('FRAMEY_BATTERY_ROOT', '/sys/class/power_supply'))
|
||||
if not root.is_absolute():
|
||||
raise ValueError('Battery supply root must be absolute')
|
||||
for candidate in [root / 'BAT1', *sorted(root.iterdir())]:
|
||||
try:
|
||||
if ((candidate / 'type').read_text().strip() == 'Battery' and
|
||||
all((candidate / field).is_file() for field in
|
||||
('capacity', 'status', 'voltage_now', 'current_now'))):
|
||||
return candidate
|
||||
except OSError:
|
||||
pass
|
||||
raise FileNotFoundError('No readable Battery-type power supply')
|
||||
|
||||
|
||||
def split_by_app(data):
|
||||
if not data:
|
||||
return []
|
||||
groups = []
|
||||
start = 0
|
||||
app = data[0][4]
|
||||
for index, row in enumerate(data[1:], 1):
|
||||
if row[4] != app:
|
||||
groups.append((start, index, app))
|
||||
start, app = index, row[4]
|
||||
groups.append((start, len(data), app))
|
||||
return groups
|
||||
"""+source[split_end:]
|
||||
old=' self.app = "Unknown"\n'
|
||||
if source.count(old)!=1: raise RuntimeError('Battery Tracker startup source changed')
|
||||
source=source.replace(old,old+' self._pending_samples = []\n')
|
||||
unload_start=source.index(' async def _unload(self):\n')
|
||||
unload_end=source.index(' async def set_app(',unload_start)
|
||||
source=source[:unload_start]+""" async def _unload(self):
|
||||
task = getattr(self, '_recorder_task', None)
|
||||
if task:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
pending = getattr(self, '_pending_samples', [])
|
||||
if pending:
|
||||
self.cursor.executemany('insert into battery values (?, ?, ?, ?, ?)', pending)
|
||||
self.con.commit()
|
||||
pending.clear()
|
||||
if hasattr(self, 'con'):
|
||||
self.con.close()
|
||||
|
||||
"""+source[unload_end:]
|
||||
old=' ).fetchall()\n'
|
||||
if source.count(old)!=2: raise RuntimeError('Battery Tracker query source changed')
|
||||
query=' data = self.cursor.execute(\n "select * from battery where time > " + str(int(start_time))\n ).fetchall()\n'
|
||||
if source.count(query)!=1: raise RuntimeError('Battery Tracker history source changed')
|
||||
source=source.replace(query,query+' data.extend(row for row in self._pending_samples if row[0] > start_time)\n')
|
||||
recorder_start=source.index(' async def recorder(self):\n')
|
||||
source=source[:recorder_start]+""" async def recorder(self):
|
||||
battery = _framey_battery_path()
|
||||
decky_plugin.logger.info('recorder started')
|
||||
while True:
|
||||
try:
|
||||
volt = int((battery / 'voltage_now').read_text().strip())
|
||||
curr = int((battery / 'current_now').read_text().strip())
|
||||
cap = int((battery / 'capacity').read_text().strip())
|
||||
status = (battery / 'status').read_text().strip()
|
||||
state = -1 if status == 'Discharging' else 1 if status == 'Charging' else 0
|
||||
power = int(volt * curr * 10.0**-11)
|
||||
self._pending_samples.append((int(time.time()), cap, state, power, self.app))
|
||||
if len(self._pending_samples) > 10:
|
||||
self.cursor.executemany('insert into battery values (?, ?, ?, ?, ?)', self._pending_samples)
|
||||
self.con.commit()
|
||||
self._pending_samples.clear()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
decky_plugin.logger.exception('recorder')
|
||||
await asyncio.sleep(5)
|
||||
"""
|
||||
main.write_text(source,encoding='utf-8')
|
||||
compat_shims.append('battery-tracker-user-supply')
|
||||
if deckyspy:
|
||||
compat=modules/'framey_battery.py'
|
||||
if compat.exists(): raise RuntimeError('DeckySpy battery module collides with archive')
|
||||
battery_source=globals().get('FRAMEY_BACKEND_BATTERY_SOURCE')
|
||||
|
||||
@@ -5176,13 +5176,18 @@
|
||||
"https://github.com/safijari/steam-deck-battery-tracker",
|
||||
"https://github.com/safijari/steam-deck-battery-tracker.git"
|
||||
],
|
||||
"status": "broken",
|
||||
"reason": "This plugin needs root and is not supported by the Frame user service.",
|
||||
"blockInstall": true,
|
||||
"status": "partial",
|
||||
"reason": "Exact managed install removes the unnecessary root flag, selects a readable Battery-type power supply, and repairs empty history and per-app sample grouping. Actual Decky 3.3.0 amd64 recorded Frame-shaped private battery samples and returned history/power data. A locked real Frame read verified the required sysfs fields, not the managed plugin. Live ARM64 recording, long-term battery history, foreground-app labeling, panel controls and worn VR remain unverified; existing copies need update or reinstall.",
|
||||
"blockInstall": false,
|
||||
"requiredShims": [
|
||||
"battery-tracker-user-supply"
|
||||
],
|
||||
"verification": [
|
||||
"static archive",
|
||||
"local browser",
|
||||
"fake-Frame"
|
||||
"fake-Frame",
|
||||
"isolated Decky amd64 backend",
|
||||
"real Frame read-only sysfs"
|
||||
],
|
||||
"browser": {
|
||||
"id": 67,
|
||||
@@ -5206,8 +5211,8 @@
|
||||
},
|
||||
"archiveValidation": {
|
||||
"id": 67,
|
||||
"accepted": false,
|
||||
"reason": "This plugin needs root and is not supported by the Frame user service"
|
||||
"accepted": true,
|
||||
"reason": "Exact digest-scoped user-service adaptation"
|
||||
},
|
||||
"backend": "Python",
|
||||
"hardware": "/sys/class/power_supply/BAT1/capacity; /sys/class/power_supply/BAT1/current_now; /sys/class/power_supply/BAT1/status; /sys/class/power_supply/BAT1/voltage_now",
|
||||
@@ -5217,9 +5222,8 @@
|
||||
"fakeFrame": {
|
||||
"id": 67,
|
||||
"sha256": "91c388d3c1b52079314045b79671ad55c767510658b6193f6936ed6cad383b94",
|
||||
"archiveValidation": "This plugin needs root and is not supported by the Frame user service",
|
||||
"result": "blocked",
|
||||
"reason": "ValueError: Root plugins are not supported by the Frame user service"
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -9969,13 +9973,17 @@
|
||||
"https://github.com/SteamDeckHomebrew/decky-frontend-lib",
|
||||
"https://github.com/SteamDeckHomebrew/decky-loader"
|
||||
],
|
||||
"status": "broken",
|
||||
"reason": "This plugin needs root and is not supported by the Frame user service.",
|
||||
"blockInstall": true,
|
||||
"status": "partial",
|
||||
"reason": "Exact managed install removes the unnecessary root flag and adds pinned plugin-local psutil wheels. Actual Decky 3.3.0 amd64 in network isolation loads the backend and records a network sample; private settings and read-only RPCs pass. External ping/DNS, real Frame ARM64, Wi-Fi scan behavior, panel controls and worn VR remain unverified. Existing copies need update or reinstall.",
|
||||
"blockInstall": false,
|
||||
"requiredShims": [
|
||||
"network-sentinel-user-psutil"
|
||||
],
|
||||
"verification": [
|
||||
"static archive",
|
||||
"local browser",
|
||||
"fake-Frame"
|
||||
"fake-Frame",
|
||||
"isolated Decky amd64 backend"
|
||||
],
|
||||
"browser": {
|
||||
"id": 114,
|
||||
@@ -10014,8 +10022,8 @@
|
||||
},
|
||||
"archiveValidation": {
|
||||
"id": 114,
|
||||
"accepted": false,
|
||||
"reason": "This plugin needs root and is not supported by the Frame user service"
|
||||
"accepted": true,
|
||||
"reason": "Exact digest-scoped user-service adaptation"
|
||||
},
|
||||
"backend": "Python",
|
||||
"hardware": "No specific hardware requirement detected statically; runtime requirements unverified.",
|
||||
@@ -10025,9 +10033,8 @@
|
||||
"fakeFrame": {
|
||||
"id": 114,
|
||||
"sha256": "d3e07f40a0c800e72d15594e03c8c61e9bf3092524a578a0260520e735b971a5",
|
||||
"archiveValidation": "This plugin needs root and is not supported by the Frame user service",
|
||||
"result": "blocked",
|
||||
"reason": "ValueError: This plugin needs root and is not supported by the Frame user service."
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user