Compare commits

..
7 changed files with 208 additions and 24 deletions

No files matched your search

+2 -21
View File
@@ -7,9 +7,9 @@
// node build/fetch-deps.js mac arm64 x64 | win x64 | linux x64 arm64
const crypto = require("crypto");
const fs = require("fs");
const https = require("https");
const path = require("path");
const { execFileSync } = require("child_process");
const { get, fetchWithRetry } = require("./fetch-retry");
const PY = "3.12.14+20260924";
const PY_URL = (triple) => "https://github.com/astral-sh/python-build-standalone/releases/download/"
@@ -46,27 +46,8 @@ const PRUNE = [
.flatMap((d) => [`lib/python3.12/${d}`, `Lib/${d}`]),
];
function get(url, redirects = 5) {
return new Promise((resolve, reject) => {
https.get(url, { timeout: 60000 }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
if (!redirects) return reject(new Error(`${url}: too many redirects`));
let next;
try { next = new URL(res.headers.location, url).href; }
catch { return reject(new Error(`${url}: bad redirect ${res.headers.location}`)); }
return resolve(get(next, redirects - 1));
}
if (res.statusCode !== 200) return reject(new Error(`${url}: HTTP ${res.statusCode}`));
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => resolve(Buffer.concat(chunks)));
}).on("timeout", function () { this.destroy(new Error(`${url}: timed out`)); }).on("error", reject);
});
}
async function download(url, sha256, file) {
const data = await get(url);
const data = await fetchWithRetry(get, url);
const sum = crypto.createHash("sha256").update(data).digest("hex");
if (sum !== sha256) throw new Error(`checksum mismatch for ${url}: ${sum}`);
fs.writeFileSync(file, data);
+43
View File
@@ -0,0 +1,43 @@
const https = require("https");
function get(url, redirects = 5, transport = https) {
return new Promise((resolve, reject) => {
transport.get(url, { timeout: 60000 }, (res) => {
res.on("error", reject);
res.on("aborted", () => reject(new Error(`${url}: response aborted`)));
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
if (!redirects) return reject(new Error(`${url}: too many redirects`));
let next;
try { next = new URL(res.headers.location, url).href; }
catch { return reject(new Error(`${url}: bad redirect ${res.headers.location}`)); }
return resolve(get(next, redirects - 1, transport));
}
if (res.statusCode !== 200) {
res.resume();
const error = new Error(`${url}: HTTP ${res.statusCode}`);
error.statusCode = res.statusCode;
return reject(error);
}
const chunks = [];
res.on("data", (chunk) => chunks.push(chunk));
res.on("end", () => resolve(Buffer.concat(chunks)));
}).on("timeout", function () { this.destroy(new Error(`${url}: timed out`)); }).on("error", reject);
});
}
// Release assets are pinned by SHA-256 at the call site. Retry only transport
// failures and transient HTTP responses; the caller still rejects bad bytes.
async function fetchWithRetry(fetcher, url, pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
for (let attempt = 0; attempt < 3; attempt++) {
try { return await fetcher(url); }
catch (error) {
const code = error.statusCode;
if (code != null && code !== 408 && code !== 429 && code < 500) throw error;
if (attempt === 2) throw error;
await pause((attempt + 1) * 1000);
}
}
}
module.exports = { get, fetchWithRetry };
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.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
View File
@@ -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",
+76
View File
@@ -0,0 +1,76 @@
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const test = require('node:test');
const { get, fetchWithRetry } = require('../build/fetch-retry');
test('installer fetch recovers a dropped connection without hiding final failure', async () => {
let calls = 0;
const delays = [];
const bytes = await fetchWithRetry(async () => {
if (++calls < 3) throw new Error('socket closed');
return Buffer.from('pinned bytes');
}, 'https://example.invalid/package', async (ms) => delays.push(ms));
assert.equal(bytes.toString(), 'pinned bytes');
assert.equal(calls, 3);
assert.deepEqual(delays, [1000, 2000]);
calls = 0;
await assert.rejects(fetchWithRetry(async () => {
calls++;
throw new Error('socket closed');
}, 'https://example.invalid/package', async () => {}), /socket closed/);
assert.equal(calls, 3);
});
test('installer fetch rejects permanent HTTP errors on the first attempt', async () => {
let calls = 0;
const missing = new Error('HTTP 404');
missing.statusCode = 404;
await assert.rejects(fetchWithRetry(async () => {
calls++;
throw missing;
}, 'https://example.invalid/missing', async () => {}), /HTTP 404/);
assert.equal(calls, 1);
});
test('installer fetch retries a transient HTTP response', async () => {
let calls = 0;
const busy = new Error('HTTP 503');
busy.statusCode = 503;
const bytes = await fetchWithRetry(async () => {
if (++calls === 1) throw busy;
return Buffer.from('pinned bytes');
}, 'https://example.invalid/package', async () => {});
assert.equal(bytes.toString(), 'pinned bytes');
assert.equal(calls, 2);
});
test('installer fetch retries a response aborted after headers and partial bytes', async () => {
let calls = 0;
const transport = {
get(_url, _options, callback) {
const request = new EventEmitter();
queueMicrotask(() => {
const response = new EventEmitter();
response.statusCode = 200;
response.headers = {};
callback(response);
if (++calls === 1) {
response.emit('data', Buffer.from('partial'));
response.emit('aborted');
} else {
response.emit('data', Buffer.from('complete'));
response.emit('end');
}
});
return request;
},
};
const bytes = await fetchWithRetry(
(url) => get(url, 5, transport),
'https://example.invalid/package',
async () => {},
);
assert.equal(bytes.toString(), 'complete');
assert.equal(calls, 2);
});
@@ -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,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.