= new Set(["stable", "beta", "dev"]);
const ALLOWED_TARGETS = new Set([
+ FIRMWARE_TARGET_WINDOWS,
FIRMWARE_TARGET_RPI5,
FIRMWARE_TARGET_PC_X86_64,
]);
@@ -135,6 +138,9 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void {
throw createError({ statusCode: 400, statusMessage: `invalid target '${target}'` });
}
+ if (body.content_b64.length > Math.ceil(512 * 1024 * 1024 / 3) * 4) {
+ throw createError({ statusCode: 413, statusMessage: "firmware artifact exceeds 512 MiB" });
+ }
const buf = Buffer.from(body.content_b64, "base64");
if (buf.length === 0) {
throw createError({ statusCode: 400, statusMessage: "empty artifact" });
@@ -189,6 +195,9 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void {
throw createError({ statusCode: 400, statusMessage: `unknown ioBOX model '${modelId}'` });
}
+ if (body.content_b64.length > Math.ceil(512 * 1024 * 1024 / 3) * 4) {
+ throw createError({ statusCode: 413, statusMessage: "firmware artifact exceeds 512 MiB" });
+ }
const buf = Buffer.from(body.content_b64, "base64");
if (buf.length === 0) throw createError({ statusCode: 400, statusMessage: "empty artifact" });
@@ -273,8 +282,17 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void {
// Push update now: server pings the kiosk via WS coordinator so it goes
// and pulls /api/kiosk/firmware/check immediately. The actual download
// happens kiosk-side over the existing kiosk_key channel.
- app.post("/admin/kiosks/:id/firmware/push", (event) => {
+ app.post("/admin/kiosks/:id/firmware/push", async (event) => {
const id = (getRouterParam(event, "id") ?? "");
+ const kiosk = await deps.repo.getKioskById(id);
+ if (!kiosk) throw createError({ statusCode: 404, statusMessage: "kiosk not found" });
+ if (kiosk.firmware_target === FIRMWARE_TARGET_WINDOWS) {
+ const release = await selectWindowsRelease(deps.repo, kiosk, currentTenantSchema(event));
+ if (release) {
+ const policy = await windowsUpdatePolicy(deps.repo, kiosk);
+ await deps.repo.updateKiosk(id, { windows_update_push: createWindowsPush(release.version, policy) });
+ }
+ }
const dispatched = getCoordinator().sendToKiosk(id, { type: "firmware_check", force: true });
return { ok: true, dispatched };
});
diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts
index 68aeb972..9dfb7679 100644
--- a/server/src/plugins/service-api-http/index.ts
+++ b/server/src/plugins/service-api-http/index.ts
@@ -1,3 +1,4 @@
+import { selectWindowsRelease, windowsPushRequest, windowsUpdatePolicy } from "../../shared/windows-updates.js";
import { selectPublicUpdate } from "../../shared/public-update-selection.js";
import { effectiveFirmwareChannel } from "../../shared/kiosk-channels.js";
import { parseKioskLogs } from "../../shared/kiosk-logs.js";
@@ -1586,6 +1587,24 @@ export function registerKioskRoutes(
}
const currentVersion = url.searchParams.get("current")?.trim() ?? kiosk.kiosk_app_version ?? "";
+ // Windows service polls independently of the desktop/control connection.
+ // It receives durable policy on up-to-date responses as well as upgrades.
+ if (target === "windows-x64") {
+ const policy = await windowsUpdatePolicy(repo, kiosk);
+ const release = await selectWindowsRelease(repo, kiosk, verified.schema_name);
+ const upgrade = release && isVersionUpgrade(release.version, currentVersion);
+ return {
+ up_to_date: !upgrade,
+ update_policy: policy,
+ push_request: upgrade ? windowsPushRequest(kiosk.windows_update_push, release.version, policy) : null,
+ ...(upgrade ? { update: {
+ release_id: release.id, version: release.version, channel: release.channel,
+ sha256: release.sha256, signature: release.signature, size_bytes: release.size_bytes,
+ download_url: `/api/kiosk/firmware/download/${release.id}`,
+ } } : {}),
+ };
+ }
+
let release = null;
// Explicit per-kiosk pin wins over all rollout / channel selection.
if (kiosk.firmware_target_version) {
diff --git a/server/src/shared/db/migrations-pg.ts b/server/src/shared/db/migrations-pg.ts
index 705245cb..6c0c6265 100644
--- a/server/src/shared/db/migrations-pg.ts
+++ b/server/src/shared/db/migrations-pg.ts
@@ -1050,4 +1050,5 @@ export const TENANT_MIGRATIONS: readonly string[] = [
`ALTER TABLE ${table} ALTER COLUMN local_short_key SET NOT NULL`,
`CREATE UNIQUE INDEX ${table}_local_short_key_unique ON ${table}(local_short_key)`,
]),
+ `ALTER TABLE kiosks ADD COLUMN IF NOT EXISTS windows_update_push TEXT`,
];
diff --git a/server/src/shared/firmware-targets.ts b/server/src/shared/firmware-targets.ts
index c782ae2b..123c37ab 100644
--- a/server/src/shared/firmware-targets.ts
+++ b/server/src/shared/firmware-targets.ts
@@ -1,3 +1,4 @@
+export const FIRMWARE_TARGET_WINDOWS = "windows-x64";
export const FIRMWARE_TARGET_RPI5 = "betterframe-rpi5-aarch64";
export const FIRMWARE_TARGET_PC_X86_64 = "betterframe-pc-x86_64";
@@ -22,6 +23,8 @@ export function firmwareTargetLabel(raw: string | null | undefined): string {
return "Raspberry Pi 5";
case FIRMWARE_TARGET_PC_X86_64:
return "PC x86_64";
+ case FIRMWARE_TARGET_WINDOWS:
+ return "Windows x64";
case "":
return "unknown";
default:
@@ -31,5 +34,5 @@ export function firmwareTargetLabel(raw: string | null | undefined): string {
export function isKnownFirmwareTarget(raw: string | null | undefined): boolean {
const target = normalizeFirmwareTarget(raw);
- return target === FIRMWARE_TARGET_RPI5 || target === FIRMWARE_TARGET_PC_X86_64;
+ return target === FIRMWARE_TARGET_WINDOWS || target === FIRMWARE_TARGET_RPI5 || target === FIRMWARE_TARGET_PC_X86_64;
}
diff --git a/server/src/shared/types.ts b/server/src/shared/types.ts
index cc76f344..ed277281 100644
--- a/server/src/shared/types.ts
+++ b/server/src/shared/types.ts
@@ -394,6 +394,7 @@ export interface Kiosk {
disk_total_mb: number | null;
disk_free_mb: number | null;
disk_used_percent: number | null;
+ windows_update_push?: string | null;
firmware_channel: FirmwareChannel;
firmware_target_version: string | null;
firmware_last_attempt_at: string | null;
diff --git a/server/src/shared/windows-updates.ts b/server/src/shared/windows-updates.ts
new file mode 100644
index 00000000..62d3f8d0
--- /dev/null
+++ b/server/src/shared/windows-updates.ts
@@ -0,0 +1,42 @@
+import { createHash, randomUUID } from "node:crypto";
+import type { Repository } from "./db/repository.js";
+import type { Kiosk } from "./types.js";
+import { withDefaultTenant } from "./default-tenant.js";
+import { normalizeUpdateSchedule } from "./update-schedule.js";
+
+export async function windowsUpdatePolicy(repo: Repository, kiosk: Kiosk) {
+ return {
+ server: "", // The updater binds this to its configured BF origin.
+ schedule: { ...normalizeUpdateSchedule(await repo.getSetupExtra("update_schedule")), timezone: Intl.DateTimeFormat().resolvedOptions().timeZone },
+ firmware_channel: kiosk.firmware_channel ?? "stable",
+ firmware_target_version: kiosk.firmware_target_version ?? null,
+ os_update_channel: "stable", os_update_target_version: null,
+ };
+}
+
+export async function selectWindowsRelease(repo: Repository, kiosk: Kiosk, schema: string | null) {
+ return withDefaultTenant(repo, schema, async () => {
+ if (kiosk.firmware_target_version) {
+ const release = await repo.getFirmwareReleaseByVersionArch(kiosk.firmware_target_version, "windows-x64");
+ return release && !release.yanked_at ? release : null;
+ }
+ for (const rollout of await repo.listActiveRolloutsForKiosk(kiosk.id)) {
+ const bucket = createHash("sha256").update(`${rollout.id}:${kiosk.id}`).digest().readUInt32BE(0) % 100;
+ if (bucket >= rollout.percentage) continue;
+ const release = await repo.getFirmwareRelease(rollout.release_id);
+ if (release && !release.yanked_at && release.arch === "windows-x64") return release;
+ }
+ return repo.getLatestFirmwareRelease(kiosk.firmware_channel ?? "stable", "windows-x64");
+ });
+}
+
+export function createWindowsPush(version: string, policy: unknown, now = Date.now()): string {
+ return JSON.stringify({ id: randomUUID(), version, policy: JSON.stringify(policy), expires: now + 30 * 60 * 1000 });
+}
+export function windowsPushRequest(raw: string | null | undefined, version: string | undefined, policy: unknown, now = Date.now()): string | null {
+ try {
+ const request = JSON.parse(raw ?? "null");
+ return request && typeof request.id === "string" && request.version === version
+ && request.expires > now && request.policy === JSON.stringify(policy) ? request.id : null;
+ } catch { return null; }
+}
diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx
index f8fdab2a..4e14265c 100644
--- a/server/src/web-templates/admin-pages.tsx
+++ b/server/src/web-templates/admin-pages.tsx
@@ -4233,6 +4233,7 @@ export function FirmwarePage(props: FirmwarePageProps) {
diff --git a/server/tests/firmware-import.test.ts b/server/tests/firmware-import.test.ts
index f837d4df..e98efafb 100644
--- a/server/tests/firmware-import.test.ts
+++ b/server/tests/firmware-import.test.ts
@@ -68,6 +68,12 @@ test("firmware HTTP imports safely retry, reject conflicts and serialize concurr
}
assert.ok((await readdir(firmware.firmwareDir())).every(name => name.endsWith(".bin")));
+ const windows = await request({...payload("signed MSI bytes"), target: "windows-x64"});
+ assert.equal(windows.status, 200);
+ const windowsRelease = await repo.getFirmwareRelease((await windows.json()).release_id);
+ assert.equal(windowsRelease?.arch, "windows-x64");
+ assert.deepEqual(await firmware.readBlob(windowsRelease!.artifact_path, windowsRelease!.sha256), Buffer.from("signed MSI bytes"));
+
await repo.yankFirmwareRelease(original.release_id);
assert.equal((await request(payload())).status, 409);
assert.ok((await repo.getFirmwareRelease(original.release_id))?.yanked_at);
diff --git a/server/tests/windows-updates.test.ts b/server/tests/windows-updates.test.ts
new file mode 100644
index 00000000..5ed7833a
--- /dev/null
+++ b/server/tests/windows-updates.test.ts
@@ -0,0 +1,31 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { createWindowsPush, windowsPushRequest, selectWindowsRelease } from "../src/shared/windows-updates.js";
+import { isKnownFirmwareTarget, firmwareTargetLabel } from "../src/shared/firmware-targets.js";
+import type { Repository } from "../src/shared/db/repository.js";
+import type { Kiosk } from "../src/shared/types.js";
+
+test("Windows is a distinct supported signed firmware target", () => {
+ assert.equal(isKnownFirmwareTarget("windows-x64"), true);
+ assert.equal(firmwareTargetLabel("windows-x64"), "Windows x64");
+});
+test("an admin push overrides only its exact version and unchanged policy until expiry", () => {
+ const policy = {channel:"stable",pin:null,schedule:{mode:"windows"}};
+ const push = createWindowsPush("1.2.0", policy, 1000);
+ assert.ok(windowsPushRequest(push,"1.2.0",policy,1001));
+ assert.equal(windowsPushRequest(push,"1.3.0",policy,1001),null);
+ assert.equal(windowsPushRequest(push,"1.2.0",{...policy,channel:"dev"},1001),null);
+ assert.equal(windowsPushRequest(push,"1.2.0",policy,1000+1800000),null);
+ assert.equal(windowsPushRequest("corrupt","1.2.0",policy,1001),null);
+});
+test("missing or withdrawn Windows pins never fall through to a different release", async () => {
+ let latestCalls=0;
+ const repo = {
+ adapter: { dialect: () => "sqlite" },
+ getFirmwareReleaseByVersionArch: async () => null,
+ getLatestFirmwareRelease: async () => {latestCalls++;return null;},
+ } as unknown as Repository;
+ const kiosk = {id:"kiosk",firmware_target_version:"1.0.0",firmware_channel:"stable"} as Kiosk;
+ assert.equal(await selectWindowsRelease(repo,kiosk,null),null);
+ assert.equal(latestCalls,0);
+});
From aaa0ef4a75cb8baf2a0109fa2f6ffd842af136c2 Mon Sep 17 00:00:00 2001
From: bcbetterninja <327058824+bcbetterninja@users.noreply.github.com>
Date: Sat, 26 Sep 2026 01:21:31 +0000
Subject: [PATCH 4/8] fix(windows): bound recovery probes and verify
interrupted transactions
---
client/windows-updater/src/platform.rs | 11 +++++++----
client/windows-updater/src/worker.rs | 12 ++----------
docs/windows-updates.md | 4 +++-
scripts/test-windows-update-recovery.ps1 | 19 +++++++++++++++++++
server/tests/local-shortlinks.test.ts | 4 +++-
5 files changed, 34 insertions(+), 16 deletions(-)
diff --git a/client/windows-updater/src/platform.rs b/client/windows-updater/src/platform.rs
index abf27ce1..78786eb5 100644
--- a/client/windows-updater/src/platform.rs
+++ b/client/windows-updater/src/platform.rs
@@ -432,8 +432,11 @@ pub fn launch_client(session: u32) -> Result<(), String> {
Ok(())
}
pub fn package_probe() -> Result<(), String> {
- let mut process = Command::new(client_exe())
- .arg("installation-test")
+ executable_probe(&client_exe(), "installation-test")
+}
+pub fn executable_probe(path: &Path, argument: &str) -> Result<(), String> {
+ let mut process = Command::new(path)
+ .arg(argument)
.creation_flags(CREATE_NO_WINDOW)
.spawn()
.map_err(|e| e.to_string())?;
@@ -442,12 +445,12 @@ pub fn package_probe() -> Result<(), String> {
return if status.success() {
Ok(())
} else {
- Err(format!("installed client probe failed: {status}"))
+ Err(format!("installed executable probe failed: {status}"))
};
}
std::thread::sleep(Duration::from_secs(1));
}
let _ = process.kill();
let _ = process.wait();
- Err("installed client probe timed out".into())
+ Err("installed executable probe timed out".into())
}
diff --git a/client/windows-updater/src/worker.rs b/client/windows-updater/src/worker.rs
index a40dac33..93459891 100644
--- a/client/windows-updater/src/worker.rs
+++ b/client/windows-updater/src/worker.rs
@@ -6,7 +6,7 @@ use std::{
fs, os::windows::process::CommandExt, path::Path, process::Command, sync::atomic::Ordering,
time::Duration,
};
-use windows_sys::Win32::System::Threading::{CREATE_NO_WINDOW, DETACHED_PROCESS};
+use windows_sys::Win32::System::Threading::DETACHED_PROCESS;
const INSTALL_MUTEX: &str = "Global\\BetterFrameUpdateInstall";
#[derive(Clone, Serialize, Deserialize)]
@@ -317,15 +317,7 @@ fn install(pending: &mut Pending, key: &str) -> Result<(), String> {
os::run_msi(&candidate)?;
os::package_probe()?;
let updater = os::install_dir().join("bin/betterframe-windows-updater.exe");
- if !Command::new(updater)
- .arg("probe")
- .creation_flags(CREATE_NO_WINDOW)
- .status()
- .map_err(|e| e.to_string())?
- .success()
- {
- return Err("installed updater cannot start".into());
- }
+ os::executable_probe(&updater, "probe")?;
os::start_service()?;
pending.stage = "awaiting-health".into();
write("pending.json", pending)?;
diff --git a/docs/windows-updates.md b/docs/windows-updates.md
index df109a5b..cf8c5824 100644
--- a/docs/windows-updates.md
+++ b/docs/windows-updates.md
@@ -94,4 +94,6 @@ Updater tests cover signature/hash/size rejection, origin restrictions, no redir
authentication recovery, saved pins, DST windows, rate-limit deferral and interrupted
downloads. Native Windows CI runs the real SYSTEM service against a local BF fixture:
it saves policy, loses enrollment and authentication, installs a signed upgrade, then
-rejects a broken candidate and restores the previously working MSI and client.
+rejects a broken candidate and restores the previously working MSI and client. It
+also restarts with an unfinished transaction journal and a stopped desktop to verify
+recovery before any further update checks.
diff --git a/scripts/test-windows-update-recovery.ps1 b/scripts/test-windows-update-recovery.ps1
index 88363f28..4c5b27a6 100644
--- a/scripts/test-windows-update-recovery.ps1
+++ b/scripts/test-windows-update-recovery.ps1
@@ -119,6 +119,25 @@ class Client {
if ($attempts.version -ne '1.0.2' -or $attempts.count -ne 1) { throw 'Failure history was lost during rollback' }
if ((Get-Service BetterFrameUpdater).Status -ne 'Running') { throw 'Recovery left the updater stopped' }
Write-Host 'Failed candidate rolled back, client restarted, updater survived, and retry history persisted.'
+
+ # A restart must recover an unfinished transaction before checking for
+ # another release, even when the app and enrollment are unavailable.
+ Stop-Service BetterFrameUpdater
+ Get-Process betterframe-windows-client -ErrorAction SilentlyContinue | Stop-Process -Force
+ $previous = Get-Content "$fixture/1.0.1.json" -Raw | ConvertFrom-Json
+ $candidate = Get-Content "$fixture/1.0.2.json" -Raw | ConvertFrom-Json
+ Write-Json "$updateDir/pending.json" @{
+ previous=$previous; candidate=$candidate; stage='installing'
+ started=[DateTimeOffset]::UtcNow.ToUnixTimeSeconds(); sessions=@([Diagnostics.Process]::GetCurrentProcess().SessionId)
+ }
+ Remove-Item "$stateDir/runtime-health.json" -Force -ErrorAction SilentlyContinue
+ Start-Service BetterFrameUpdater
+ Wait-For {
+ if ((Test-Path "$updateDir/pending.json") -or -not (Test-Path "$stateDir/runtime-health.json")) { return $false }
+ try { $health = Get-Content "$stateDir/runtime-health.json" -Raw | ConvertFrom-Json } catch { return $false }
+ return $health.version -eq '1.0.1'
+ } 'Service restart did not recover the interrupted transaction' 180
+ Write-Host 'Interrupted transaction recovered from its durable journal with the desktop stopped.'
} finally {
Stop-Service BetterFrameUpdater -ErrorAction SilentlyContinue
Get-Process betterframe-windows-client -ErrorAction SilentlyContinue | Stop-Process -Force
diff --git a/server/tests/local-shortlinks.test.ts b/server/tests/local-shortlinks.test.ts
index c8a6ee3b..efc3e408 100644
--- a/server/tests/local-shortlinks.test.ts
+++ b/server/tests/local-shortlinks.test.ts
@@ -46,6 +46,8 @@ test("PostgreSQL aliases backfill, persist, retry collisions and remain tenant s
const client = await pool.connect();
const start = TENANT_MIGRATIONS.findIndex(sql => sql.startsWith("CREATE TABLE local_short_keys"));
assert.ok(start > 0);
+ const end = TENANT_MIGRATIONS.findIndex(sql => sql.startsWith("CREATE UNIQUE INDEX cameras_local_short_key_unique"));
+ assert.ok(end > start);
try {
await client.query(`CREATE SCHEMA ${schema}`);
await client.query(`SET search_path TO ${schema}, pg_catalog`);
@@ -55,7 +57,7 @@ test("PostgreSQL aliases backfill, persist, retry collisions and remain tenant s
await client.query(`CREATE FUNCTION gen_random_uuid() RETURNS uuid LANGUAGE sql VOLATILE AS $$
SELECT (lpad(to_hex(nextval('short_test_sequence')), 6, '0') || '00-0000-4000-8000-000000000000')::uuid
$$`);
- for (const sql of TENANT_MIGRATIONS.slice(start)) await client.query(sql);
+ for (const sql of TENANT_MIGRATIONS.slice(start, end + 1)) await client.query(sql);
const original = (await client.query("SELECT * FROM layouts")).rows[0];
assert.match(original.local_short_key, /^[0-9a-f]{6}$/);
assert.equal(rowToLayout(original).local_short_key, original.local_short_key);
From 24bccb4eb49f004ab7bd976efd2b3ed28eb0e66b Mon Sep 17 00:00:00 2001
From: bcbetterninja <327058824+bcbetterninja@users.noreply.github.com>
Date: Sat, 26 Sep 2026 01:24:15 +0000
Subject: [PATCH 5/8] fix(windows): persist push requests and correct MSI
extension loading
---
.github/workflows/validate.yml | 5 +++++
scripts/build-windows-msi.ps1 | 3 ++-
server/src/shared/db/mappers.ts | 1 +
server/tests/firmware-import.test.ts | 9 +++++++++
4 files changed, 17 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml
index 0083073e..2da2ea62 100644
--- a/.github/workflows/validate.yml
+++ b/.github/workflows/validate.yml
@@ -138,6 +138,11 @@ jobs:
../scripts/build-windows-msi.ps1 -InstallVersion "0.1.${{ github.run_number }}"
$msi = Get-ChildItem target/wix/*.msi | Select-Object -First 1
../scripts/test-windows-msi.ps1 -MsiPath $msi.FullName
+ windows-update-recovery:
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v6
+ - uses: dtolnay/rust-toolchain@stable
- name: Recover Windows updates through BF after authentication and app failures
working-directory: client
run: ../scripts/test-windows-update-recovery.ps1
diff --git a/scripts/build-windows-msi.ps1 b/scripts/build-windows-msi.ps1
index 3e66a01f..517abf84 100644
--- a/scripts/build-windows-msi.ps1
+++ b/scripts/build-windows-msi.ps1
@@ -32,5 +32,6 @@ $moduleDir = New-Item -ItemType Directory -Force -Path "target\gstreamer-msm"
"gstreamer-1.0-system.msm",
"gstreamer-1.0-libav.msm"
) | ForEach-Object { Copy-Item (Join-Path $sourceDir $_) $moduleDir }
-cargo wix --package betterframe-client --nocapture --install-version $InstallVersion -L -sice:ICE30 -L -sice:ICE80 -L -ext -L WixUtilExtension
+# cargo-wix loads WixUtilExtension automatically when the source uses its namespace.
+cargo wix --package betterframe-client --nocapture --install-version $InstallVersion -L -sice:ICE30 -L -sice:ICE80
if ($LASTEXITCODE -ne 0) { throw "MSI build failed" }
diff --git a/server/src/shared/db/mappers.ts b/server/src/shared/db/mappers.ts
index 31411c9d..ed4681ae 100644
--- a/server/src/shared/db/mappers.ts
+++ b/server/src/shared/db/mappers.ts
@@ -417,6 +417,7 @@ export function rowToKiosk(r: Row): Kiosk {
disk_total_mb: nn(r["disk_total_mb"]),
disk_free_mb: nn(r["disk_free_mb"]),
disk_used_percent: nn(r["disk_used_percent"]),
+ windows_update_push: sn(r["windows_update_push"]),
firmware_channel: (s(r["firmware_channel"] ?? "stable")) as FirmwareChannel,
firmware_target_version: sn(r["firmware_target_version"]),
firmware_last_attempt_at: sn(r["firmware_last_attempt_at"]),
diff --git a/server/tests/firmware-import.test.ts b/server/tests/firmware-import.test.ts
index e98efafb..69a86070 100644
--- a/server/tests/firmware-import.test.ts
+++ b/server/tests/firmware-import.test.ts
@@ -11,6 +11,7 @@ import { PgAdapter } from "../src/shared/db/pg-adapter.js";
import { Repository } from "../src/shared/db/repository.js";
import { registerFirmwareRoutes } from "../src/plugins/service-admin-http/routes-firmware.js";
import type { AdminDeps } from "../src/plugins/service-admin-http/index.js";
+import { createWindowsPush, windowsPushRequest, windowsUpdatePolicy } from "../src/shared/windows-updates.js";
test("firmware HTTP imports safely retry, reject conflicts and serialize concurrent registration", { skip: !process.env["BF_TEST_PG_URL"] }, async (t) => {
const dataDir = await mkdtemp(join(tmpdir(), "bf-firmware-import-"));
@@ -74,6 +75,14 @@ test("firmware HTTP imports safely retry, reject conflicts and serialize concurr
assert.equal(windowsRelease?.arch, "windows-x64");
assert.deepEqual(await firmware.readBlob(windowsRelease!.artifact_path, windowsRelease!.sha256), Buffer.from("signed MSI bytes"));
+ const kiosk = await repo.createKiosk({name: "Windows updater", key_hash: "unused", key_prefix: "unused"});
+ const policy = await windowsUpdatePolicy(repo, kiosk);
+ const push = createWindowsPush(windowsRelease!.version, policy);
+ await repo.updateKiosk(kiosk.id, {windows_update_push: push});
+ const reloaded = await repo.getKioskById(kiosk.id);
+ assert.equal(reloaded?.windows_update_push, push);
+ assert.ok(windowsPushRequest(reloaded?.windows_update_push, windowsRelease!.version, policy));
+
await repo.yankFirmwareRelease(original.release_id);
assert.equal((await request(payload())).status, 409);
assert.ok((await repo.getFirmwareRelease(original.release_id))?.yanked_at);
From 04a8228a8de1b5447f441d01606c43f4a1df3b52 Mon Sep 17 00:00:00 2001
From: bcbetterninja <327058824+bcbetterninja@users.noreply.github.com>
Date: Sat, 26 Sep 2026 01:28:39 +0000
Subject: [PATCH 6/8] test(windows): use resolved fixture paths for native
compiler
---
scripts/test-windows-update-recovery.ps1 | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/scripts/test-windows-update-recovery.ps1 b/scripts/test-windows-update-recovery.ps1
index 4c5b27a6..3fd013ed 100644
--- a/scripts/test-windows-update-recovery.ps1
+++ b/scripts/test-windows-update-recovery.ps1
@@ -43,7 +43,7 @@ try {
$env:BF_FIRMWARE_SIGNING_PUBLIC_KEY = Get-Content "$fixture/pub.pem" -Raw
$env:RUSTFLAGS = '-C target-feature=+crt-static'
foreach ($version in @('1.0.0','1.0.1','1.0.2')) {
- $dir = New-Item -ItemType Directory -Force "$fixture/$version"
+ $dir = (New-Item -ItemType Directory -Force (Join-Path $fixture "release-$version")).FullName
$env:BF_BUILD_VERSION = $version
cargo build --release --locked -p betterframe-windows-updater --target-dir target/updater
if ($LASTEXITCODE -ne 0) { throw 'Fixture updater build failed' }
@@ -67,8 +67,10 @@ class Client {
}
}
"@
- [IO.File]::WriteAllText("$dir/client.cs", $source)
- & $csc /nologo /target:winexe "/out:$dir/client.exe" "$dir/client.cs"
+ $sourcePath = Join-Path $dir "client.cs"
+ $clientPath = Join-Path $dir "client.exe"
+ [IO.File]::WriteAllText($sourcePath, $source)
+ & $csc /nologo /target:winexe "/out:$clientPath" $sourcePath
if ($LASTEXITCODE -ne 0) { throw 'Fixture client build failed' }
& $candle -nologo -arch x64 "-dReleaseVersion=$version" "-dFixtureDir=$dir" -out "$dir/package.wixobj" ../scripts/windows-update-tests/fixture.wxs
if ($LASTEXITCODE -ne 0) { throw 'Fixture WiX compile failed' }
From 6ff70215d33c6645af1abddf0ee91d66ee8572a3 Mon Sep 17 00:00:00 2001
From: bcbetterninja <327058824+bcbetterninja@users.noreply.github.com>
Date: Sat, 26 Sep 2026 01:33:20 +0000
Subject: [PATCH 7/8] test(windows): pass a native path to Windows Installer
---
scripts/test-windows-update-recovery.ps1 | 11 ++++++++---
1 file changed, 8 insertions(+), 3 deletions(-)
diff --git a/scripts/test-windows-update-recovery.ps1 b/scripts/test-windows-update-recovery.ps1
index 3fd013ed..5864727d 100644
--- a/scripts/test-windows-update-recovery.ps1
+++ b/scripts/test-windows-update-recovery.ps1
@@ -30,8 +30,12 @@ function Wait-For([scriptblock]$Condition, [string]$Message, [int]$Seconds = 180
throw $Message
}
function Invoke-Msi([string]$Arguments) {
- $process = Start-Process msiexec.exe -ArgumentList $Arguments -Wait -PassThru
- if ($process.ExitCode -notin 0,3010) { throw "Fixture MSI failed: $($process.ExitCode)" }
+ $log = Join-Path $fixture 'fixture-install.log'
+ $process = Start-Process msiexec.exe -ArgumentList "$Arguments /L*v `"$log`"" -Wait -PassThru
+ if ($process.ExitCode -notin 0,3010) {
+ if (Test-Path $log) { Get-Content $log -Tail 60 }
+ throw "Fixture MSI failed: $($process.ExitCode)"
+ }
}
try {
if (Get-Service BetterFrameUpdater -ErrorAction SilentlyContinue) { throw 'Test requires no existing BetterFrame installation' }
@@ -93,7 +97,8 @@ class Client {
$origin = 'http://127.0.0.1:' + (Get-Content "$fixture/port" -Raw)
New-Item -ItemType Directory -Force $stateDir | Out-Null
Write-Json "$stateDir/state.json" @{server_url=$origin;kiosk_key='disposable-test-key';demo=$false}
- Invoke-Msi "/i `"$fixture/1.0.0.msi`" /qn /norestart"
+ $initialMsi = (Resolve-Path (Join-Path $fixture '1.0.0.msi')).Path
+ Invoke-Msi "/i `"$initialMsi`" /qn /norestart"
Start-Process "$installDir/bin/betterframe-windows-client.exe" -ArgumentList desktop | Out-Null
Wait-For { Test-Path "$updateDir/policy.json" } 'Updater did not persist server policy'
Stop-Service BetterFrameUpdater
From c08773a784c7f29a28e22ae2b1611ce4558d72be Mon Sep 17 00:00:00 2001
From: bcbetterninja <327058824+bcbetterninja@users.noreply.github.com>
Date: Sat, 26 Sep 2026 01:39:10 +0000
Subject: [PATCH 8/8] fix(release): require BF storage acknowledgment before
Windows publication
---
.github/workflows/build.yml | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 51a3726a..019fa3e1 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -469,10 +469,15 @@ jobs:
jq -nc --arg v "${{ inputs.version }}" --arg c "${{ inputs.channel }}" \
--rawfile b "$bin.b64" --rawfile s "$bin.sig" \
'{version:$v,channel:$c,target:"windows-x64",content_b64:$b,signature:$s}' > "$bin.import.json"
- curl --fail-with-body --retry 3 --retry-all-errors --retry-delay 10 \
+ status=$(curl --fail-with-body --retry 3 --retry-all-errors --retry-delay 10 \
--connect-timeout 15 --max-time 600 \
-H "Authorization: Bearer $BF_AUTOIMPORT_API_KEY" -H 'Content-Type: application/json' \
- --data-binary @"$bin.import.json" "$BF_AUTOIMPORT_URL/api/admin/firmware/import"
+ --output "$bin.import-response.json" --write-out '%{http_code}' \
+ --data-binary @"$bin.import.json" "$BF_AUTOIMPORT_URL/api/admin/firmware/import")
+ [[ "$status" =~ ^2[0-9][0-9]$ ]] || { echo "BF publication did not return success: $status"; exit 1; }
+ jq -e --rawfile hash "$bin.sha256" \
+ '.ok == true and (.release_id | type == "string" and length > 0) and .sha256 == $hash' \
+ "$bin.import-response.json" > /dev/null
- name: Upload Windows installer to GitHub Release
uses: softprops/action-gh-release@v3
with: