From b559106ea675cd24ccf1107b71f32329e77182b6 Mon Sep 17 00:00:00 2001 From: bcbetterninja <327058824+bcbetterninja@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:20:34 +0000 Subject: [PATCH 1/8] fix(windows): enable desktop startup from MSI installation --- .github/workflows/build.yml | 33 ++---------- .github/workflows/validate.yml | 8 +++ client/Cargo.toml | 1 + client/README.md | 27 ++++++++++ client/src/main.rs | 2 + client/src/platform/windows/host.rs | 44 ++++++++++++++-- client/src/platform/windows/mod.rs | 36 +++++++++++-- client/wix/main.wxs | 14 ++++- scripts/build-windows-msi.ps1 | 29 +++++++++++ scripts/test-windows-msi.ps1 | 80 +++++++++++++++++++++++++++++ 10 files changed, 236 insertions(+), 38 deletions(-) create mode 100644 scripts/build-windows-msi.ps1 create mode 100644 scripts/test-windows-msi.ps1 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 76a68696..b7fc73e1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -395,29 +395,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - cargo install cargo-wix --version 0.3.9 --locked - $archive = Join-Path $env:RUNNER_TEMP "gstreamer-1.0-msvc-x86_64-$env:GSTREAMER_VERSION-merge-modules.zip" - gh release download $env:GSTREAMER_RELEASE --repo $env:GITHUB_REPOSITORY --dir $env:RUNNER_TEMP --pattern (Split-Path $archive -Leaf) - $actualHash = (Get-FileHash $archive -Algorithm SHA256).Hash.ToLowerInvariant() - if ($actualHash -ne $env:GSTREAMER_MSM_SHA256) { - throw "GStreamer merge-module checksum mismatch: $actualHash" - } - $extractDir = Join-Path $env:RUNNER_TEMP "gstreamer-msm" - Expand-Archive -LiteralPath $archive -DestinationPath $extractDir - $sourceDir = Get-ChildItem $extractDir -Directory | Select-Object -First 1 - $moduleDir = New-Item -ItemType Directory -Force -Path "target\gstreamer-msm" - @( - "base-system-1.0.msm", - "base-crypto.msm", - "gstreamer-1.0-core.msm", - "gstreamer-1.0-net.msm", - "gstreamer-1.0-playback.msm", - "gstreamer-1.0-codecs.msm", - "gstreamer-1.0-system.msm", - "gstreamer-1.0-libav.msm" - ) | ForEach-Object { Copy-Item (Join-Path $sourceDir $_) $moduleDir } - $msiVersion = "0.1.${{ github.run_number }}" - cargo wix --package betterframe-client --nocapture --install-version $msiVersion -L -sice:ICE30 -L -sice:ICE80 + ../scripts/build-windows-msi.ps1 -InstallVersion "0.1.${{ github.run_number }}" $msi = Get-ChildItem target\wix\*.msi | Select-Object -First 1 Copy-Item $msi.FullName "betterframe-windows-client-${{ inputs.version }}-x86_64.msi" - uses: actions/upload-artifact@v7 @@ -430,6 +408,9 @@ jobs: needs: windows-client runs-on: windows-latest steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} - uses: actions/download-artifact@v8 with: name: betterframe-windows-client-x86_64 @@ -440,11 +421,7 @@ jobs: if (Test-Path "$env:ProgramFiles\gstreamer") { throw "clean-host test runner unexpectedly contains a global GStreamer installation" } - $msi = Resolve-Path "betterframe-windows-client-${{ inputs.version }}-x86_64.msi" - $installer = Start-Process msiexec.exe -ArgumentList "/i `"$msi`" /qn /norestart" -Wait -PassThru - if ($installer.ExitCode -notin 0, 3010) { throw "client install failed with exit code $($installer.ExitCode)" } - & "$env:ProgramFiles\betterframe-windows-client\bin\betterframe-windows-client.exe" self-test - if ($LASTEXITCODE -ne 0) { throw "installed client self-test failed with exit code $LASTEXITCODE" } + ../scripts/test-windows-msi.ps1 -MsiPath "betterframe-windows-client-${{ inputs.version }}-x86_64.msi" - name: Upload Windows installer to GitHub Release uses: softprops/action-gh-release@v3 with: diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 15776e19..56d0c062 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -130,6 +130,14 @@ jobs: - name: Build and test Windows client working-directory: client run: cargo test --workspace --locked + - name: Build and verify MSI startup lifecycle + working-directory: client + env: + GH_TOKEN: ${{ github.token }} + run: | + ../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 iobox: runs-on: ubuntu-24.04 diff --git a/client/Cargo.toml b/client/Cargo.toml index 0cef51fe..8432f776 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -59,6 +59,7 @@ windows-sys = { version = "0.52", features = [ "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_LibraryLoader", + "Win32_System_Console", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse", diff --git a/client/README.md b/client/README.md index 9d9f7af2..29bf1f63 100644 --- a/client/README.md +++ b/client/README.md @@ -62,3 +62,30 @@ path. Camera and webpage navigation retain their own existing behavior. BetterFrame uses `cloud.betterportal.frame` as its canonical application ID for Android/Android TV and Linux GTK. See [application identity and domain conventions](../docs/application-identity.md). + +### Windows installation and startup + +Install the Windows MSI, then open **BetterFrame** from the Start menu to begin +pairing. The display shows the pairing code; no terminal or separate `install` +command is needed. Double-clicking the installed executable also starts the app. + +The MSI enables BetterFrame at Windows sign-in through the machine-wide +`HKLM\Software\Microsoft\Windows\CurrentVersion\Run\BetterFrame` entry. +The agent and display run without a console window, in the signed-in user's +session. Repeated launches in that session reuse the running agent. MSI repair +restores the startup entry, upgrades update its executable path, and uninstall +removes it. Windows Settings / Task Manager **Startup apps** can disable startup. + +Use one dedicated Windows account for the kiosk: enrollment is machine-wide, +with protected state restricted to the account that created it, administrators, +and SYSTEM. Sign in with that same account after reboot. The installer does not +configure automatic Windows sign-in, and the display cannot run before sign-in. +A Windows service runs in a noninteractive session and cannot display this UI. + +The older CLI `install` / `uninstall` commands manage a separate, optional +scheduled task; they are not needed for MSI installations. If you previously +created that task, run the CLI `uninstall` command once as administrator to remove +it (this does not uninstall the MSI). Normal application removal uses Windows +**Installed apps**. Explicit CLI commands such as `agent` and `self-test` still +attach to an existing terminal; for scripts use PowerShell `Start-Process -Wait +-PassThru` to wait and inspect the exit code of the GUI executable. diff --git a/client/src/main.rs b/client/src/main.rs index 4616ff60..d958d8d1 100644 --- a/client/src/main.rs +++ b/client/src/main.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_os = "windows", windows_subsystem = "windows")] + pub use betterframe_client_core as core; pub use core::bundle; mod network; diff --git a/client/src/platform/windows/host.rs b/client/src/platform/windows/host.rs index 575a5e26..ec31cdb6 100644 --- a/client/src/platform/windows/host.rs +++ b/client/src/platform/windows/host.rs @@ -130,12 +130,34 @@ pub(super) fn run_command(program: &str, args: &[&str]) -> Result<(), String> { } } -pub(super) fn acquire_app_instance() -> Result, String> { - let name = wide("Local\\BetterFrameWindowsRenderer"); +pub(super) fn report_startup_error(error: &str, desktop: bool) { + eprintln!("{error}"); + if desktop { + use windows_sys::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_ICONERROR, MB_OK}; + let text = wide(&format!("BetterFrame could not start.\n\n{error}")); + let title = wide("BetterFrame"); + unsafe { MessageBoxW(0, text.as_ptr(), title.as_ptr(), MB_OK | MB_ICONERROR); } + } +} + +pub(super) struct InstanceGuard(HANDLE); + +impl Drop for InstanceGuard { + fn drop(&mut self) { + unsafe { CloseHandle(self.0); } + } +} + +pub(super) fn acquire_app_instance() -> Result, String> { + acquire_instance("Local\\BetterFrameWindowsRenderer") +} + +pub(super) fn acquire_instance(name: &str) -> Result, String> { + let name = wide(name); let handle = unsafe { CreateMutexW(null(), 0, name.as_ptr()) }; if handle == 0 { return Err(format!( - "create renderer mutex: {}", + "create instance mutex: {}", std::io::Error::last_os_error() )); } @@ -143,5 +165,19 @@ pub(super) fn acquire_app_instance() -> Result, String> { unsafe { CloseHandle(handle) }; return Ok(None); } - Ok(Some(handle)) + Ok(Some(InstanceGuard(handle))) +} + +#[cfg(test)] +mod instance_tests { + use super::*; + + #[test] + fn duplicate_launch_is_ignored_and_exit_releases_instance() { + let name = format!("Local\\BetterFrameInstanceTest-{}", std::process::id()); + let first = acquire_instance(&name).unwrap().unwrap(); + assert!(acquire_instance(&name).unwrap().is_none()); + drop(first); + assert!(acquire_instance(&name).unwrap().is_some()); + } } diff --git a/client/src/platform/windows/mod.rs b/client/src/platform/windows/mod.rs index bd000154..db766c17 100644 --- a/client/src/platform/windows/mod.rs +++ b/client/src/platform/windows/mod.rs @@ -199,8 +199,33 @@ fn unpaired_state(server_url: &str) -> ClientState { pub fn run() { use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + let args: Vec = std::env::args().collect(); + let command = args.get(1).map(String::as_str); + let desktop = matches!(command, None | Some("desktop")); + if !desktop && command != Some("app") { + // Keep explicit diagnostic/administration commands usable from a terminal. + // Explorer and logon launches never allocate a console. + unsafe { + windows_sys::Win32::System::Console::AttachConsole( + windows_sys::Win32::System::Console::ATTACH_PARENT_PROCESS, + ); + } + } + // Guard before starting diagnostic workers or touching shared state. + let _instance = if desktop || command == Some("agent") { + match acquire_instance("Local\\BetterFrameWindowsAgent") { + Ok(Some(instance)) => Some(instance), + Ok(None) => return, + Err(error) => { + report_startup_error(&error, desktop); + std::process::exit(1); + } + } + } else { + None + }; // Agent and renderer are separate processes: use separate protected spools. - let mode = if std::env::args().nth(1).as_deref() == Some("agent") { "agent" } else { "app" }; + let mode = if desktop || command == Some("agent") { "agent" } else { "app" }; let read_path = state_dir().join(format!("logs-{mode}.json")); let write_path = read_path.clone(); let app_logs = crate::diagnostic_logs::AppLogLayer::start( @@ -218,21 +243,22 @@ pub fn run() { .with(tracing_subscriber::fmt::layer()) .with(app_logs).init(); - let args: Vec = std::env::args().collect(); info!( "BetterFrame Windows client {} starting (mode={}, arch={})", kiosk_app_version(), - args.get(1).map(String::as_str).unwrap_or("help"), + args.get(1).map(String::as_str).unwrap_or("desktop"), std::env::consts::ARCH ); let result = match args.get(1).map(|s| s.as_str()) { - Some("agent") => run_agent_cli(&args[2..]), + None => run_agent_cli(&[]), + Some("desktop" | "agent") => run_agent_cli(&args[2..]), Some("app") => run_app(), Some("self-test") => self_test(), Some("install") => install_tasks(&args[2..]), Some("uninstall") => uninstall_tasks(), _ => { eprintln!("Usage:"); + eprintln!(" betterframe-windows-client [desktop] [--server URL]"); eprintln!(" betterframe-windows-client agent [--server URL]"); eprintln!(" betterframe-windows-client app"); eprintln!(" betterframe-windows-client self-test"); @@ -243,7 +269,7 @@ pub fn run() { }; if let Err(err) = result { - eprintln!("{err}"); + report_startup_error(&err, desktop); std::process::exit(1); } } diff --git a/client/wix/main.wxs b/client/wix/main.wxs index dc1b4376..e23e7694 100644 --- a/client/wix/main.wxs +++ b/client/wix/main.wxs @@ -45,12 +45,23 @@ Id="StartMenuShortcut" Directory="ProgramMenuFolder" Name="BetterFrame" - Arguments="agent" + Arguments="desktop" WorkingDirectory="APPLICATIONFOLDER" Icon="BetterFrameIcon.exe" Advertise="yes" /> + + + + @@ -83,6 +94,7 @@ ConfigurableDirectory="APPLICATIONFOLDER" Absent="disallow"> + diff --git a/scripts/build-windows-msi.ps1 b/scripts/build-windows-msi.ps1 new file mode 100644 index 00000000..52137c54 --- /dev/null +++ b/scripts/build-windows-msi.ps1 @@ -0,0 +1,29 @@ +# Run from client/ with the GStreamer SDK and WiX available (Windows CI). +param([Parameter(Mandatory = $true)][string]$InstallVersion) +$ErrorActionPreference = 'Stop' + +cargo install cargo-wix --version 0.3.9 --locked +if ($LASTEXITCODE -ne 0) { throw "cargo-wix install failed" } +$archive = Join-Path $env:RUNNER_TEMP "gstreamer-1.0-msvc-x86_64-$env:GSTREAMER_VERSION-merge-modules.zip" +gh release download $env:GSTREAMER_RELEASE --repo $env:GITHUB_REPOSITORY --dir $env:RUNNER_TEMP --pattern (Split-Path $archive -Leaf) +if ($LASTEXITCODE -ne 0) { throw "GStreamer merge-module download failed" } +$actualHash = (Get-FileHash $archive -Algorithm SHA256).Hash.ToLowerInvariant() +if ($actualHash -ne $env:GSTREAMER_MSM_SHA256) { + throw "GStreamer merge-module checksum mismatch: $actualHash" +} +$extractDir = Join-Path $env:RUNNER_TEMP "gstreamer-msm" +Expand-Archive -LiteralPath $archive -DestinationPath $extractDir +$sourceDir = Get-ChildItem $extractDir -Directory | Select-Object -First 1 +$moduleDir = New-Item -ItemType Directory -Force -Path "target\gstreamer-msm" +@( + "base-system-1.0.msm", + "base-crypto.msm", + "gstreamer-1.0-core.msm", + "gstreamer-1.0-net.msm", + "gstreamer-1.0-playback.msm", + "gstreamer-1.0-codecs.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 +if ($LASTEXITCODE -ne 0) { throw "MSI build failed" } diff --git a/scripts/test-windows-msi.ps1 b/scripts/test-windows-msi.ps1 new file mode 100644 index 00000000..181a002a --- /dev/null +++ b/scripts/test-windows-msi.ps1 @@ -0,0 +1,80 @@ +# Requires a disposable Windows runner: installs, repairs, then removes the MSI. +param([Parameter(Mandatory = $true)][string]$MsiPath) +$ErrorActionPreference = 'Stop' +$msi = (Resolve-Path $MsiPath).Path +$installDir = Join-Path $env:ProgramFiles 'BetterFrame Startup Test' +$exe = Join-Path $installDir 'bin/betterframe-windows-client.exe' +$runKey = 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run' +$expected = '"' + $exe + '" desktop' +$originalProgramData = $env:ProgramData +$testData = Join-Path ([IO.Path]::GetTempPath()) ('betterframe-msi-' + [guid]::NewGuid()) +$agent = $null +$installed = $false + +function Invoke-Msi([string]$Arguments) { + $process = Start-Process msiexec.exe -ArgumentList $Arguments -Wait -PassThru + if ($process.ExitCode -notin 0, 3010) { throw "msiexec failed: $($process.ExitCode)" } +} + +function Assert-Startup { + $actual = Get-ItemPropertyValue -Path $runKey -Name BetterFrame + if ($actual -cne $expected) { throw "Incorrect startup command: $actual" } + $shortcutPath = Join-Path ([Environment]::GetFolderPath('CommonPrograms')) 'BetterFrame.lnk' + if (-not (Test-Path $shortcutPath)) { throw 'Start menu shortcut is missing' } +} + +try { + Invoke-Msi "/i `"$msi`" APPLICATIONFOLDER=`"$installDir`" /qn /norestart" + $installed = $true + Assert-Startup + # Verify the actual PE header: Explorer must not allocate a console window. + $image = [IO.File]::ReadAllBytes($exe) + $peOffset = [BitConverter]::ToInt32($image, 0x3c) + $subsystem = [BitConverter]::ToUInt16($image, $peOffset + 24 + 68) + if ($subsystem -ne 2) { throw "Expected Windows GUI subsystem, got $subsystem" } + + # Isolate enrollment from other tests and never contact the public service. + $env:ProgramData = $testData + $probe = Start-Process $exe -ArgumentList 'self-test' -Wait -PassThru + if ($probe.ExitCode -ne 0) { throw "Installed client self-test failed: $($probe.ExitCode)" } + $agent = Start-Process $exe -ArgumentList 'desktop --server http://127.0.0.1:9' -PassThru + $ready = $false + for ($attempt = 0; $attempt -lt 30; $attempt++) { + if ($agent.HasExited) { throw "Desktop startup exited: $($agent.ExitCode)" } + try { + $mutex = [Threading.Mutex]::OpenExisting('Local\BetterFrameWindowsAgent') + $mutex.Dispose() + $ready = $true + break + } catch [Threading.WaitHandleCannotBeOpenedException] { + Start-Sleep -Milliseconds 200 + } + } + if (-not $ready) { throw 'Desktop startup did not acquire the instance guard' } + # No-argument Explorer launch must select the same agent and exit harmlessly. + $duplicate = Start-Process $exe -PassThru + if (-not $duplicate.WaitForExit(10000)) { + Stop-Process -Id $duplicate.Id -Force + throw 'Duplicate launch did not exit' + } + if ($duplicate.ExitCode -ne 0) { throw "Duplicate launch failed: $($duplicate.ExitCode)" } + Stop-Process -Id $agent.Id -Force + $agent.WaitForExit() + $agent = $null + + Remove-ItemProperty -Path $runKey -Name BetterFrame + Invoke-Msi "/famus `"$msi`" /qn /norestart" + Assert-Startup + Invoke-Msi "/x `"$msi`" /qn /norestart" + $installed = $false + if (Get-ItemProperty -Path $runKey -Name BetterFrame -ErrorAction SilentlyContinue) { + throw 'Uninstall left automatic startup registered' + } + if (Test-Path $exe) { throw 'Uninstall left the client executable installed' } + Write-Host 'Windows MSI startup, GUI executable, duplicate launch, repair and uninstall passed.' +} finally { + if ($agent -and -not $agent.HasExited) { Stop-Process -Id $agent.Id -Force } + $env:ProgramData = $originalProgramData + if ($installed) { Invoke-Msi "/x `"$msi`" /qn /norestart" } + if (Test-Path $testData) { Remove-Item -LiteralPath $testData -Recurse -Force } +} From cd7f833616876a615449cfd144036f052bd109e8 Mon Sep 17 00:00:00 2001 From: bcbetterninja <327058824+bcbetterninja@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:22:26 +0000 Subject: [PATCH 2/8] fix(windows): keep host helper commands console-free --- client/src/platform/windows/host.rs | 4 ++++ client/src/platform/windows/mod.rs | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/client/src/platform/windows/host.rs b/client/src/platform/windows/host.rs index ec31cdb6..a8b54cc7 100644 --- a/client/src/platform/windows/host.rs +++ b/client/src/platform/windows/host.rs @@ -1,4 +1,6 @@ use super::*; +use std::os::windows::process::CommandExt; +use windows_sys::Win32::System::Threading::CREATE_NO_WINDOW; pub(super) fn set_monitor_power(on: bool) { unsafe { @@ -32,6 +34,7 @@ pub(super) fn set_volume_percent(percent: u32) { ) }; let _ = Command::new("powershell.exe") + .creation_flags(CREATE_NO_WINDOW) .args([ "-NoProfile", "-ExecutionPolicy", @@ -120,6 +123,7 @@ pub(super) fn uninstall_tasks() -> Result<(), String> { pub(super) fn run_command(program: &str, args: &[&str]) -> Result<(), String> { let status = Command::new(program) + .creation_flags(CREATE_NO_WINDOW) .args(args) .status() .map_err(|e| format!("{program}: {e}"))?; diff --git a/client/src/platform/windows/mod.rs b/client/src/platform/windows/mod.rs index db766c17..eb7803b5 100644 --- a/client/src/platform/windows/mod.rs +++ b/client/src/platform/windows/mod.rs @@ -836,7 +836,11 @@ async fn handle_agent_command( } AgentCommand::Reboot => { if current_policy.controls.host_reboot { - let _ = Command::new("shutdown").args(["/r", "/t", "5"]).spawn(); + use std::os::windows::process::CommandExt; + let _ = Command::new("shutdown") + .creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW) + .args(["/r", "/t", "5"]) + .spawn(); } else if current_policy.controls.app_restart { // Host reboot not permitted — degrade to restarting the app. restart_app(app)?; From 7b981fc3b76ae2e44368cdf63b69de0e49186c9f Mon Sep 17 00:00:00 2001 From: bcbetterninja <327058824+bcbetterninja@users.noreply.github.com> Date: Sat, 26 Sep 2026 01:12:14 +0000 Subject: [PATCH 3/8] feat(windows): recover automatic updates through signed BF-hosted MSI releases --- .github/workflows/build.yml | 58 +- .github/workflows/validate.yml | 4 + client/Cargo.lock | 65 +- client/Cargo.toml | 2 +- client/README.md | 6 + client/src/platform/windows/mod.rs | 47 +- client/src/platform/windows/renderer.rs | 12 + client/windows-updater/Cargo.toml | 25 + client/windows-updater/src/lib.rs | 592 ++++++++++++++++++ client/windows-updater/src/main.rs | 21 + client/windows-updater/src/platform.rs | 453 ++++++++++++++ client/windows-updater/src/worker.rs | 393 ++++++++++++ client/wix/main.wxs | 22 +- deploy/angie/betterframe.docker.conf | 12 + docs/release-delivery.md | 28 + docs/windows-updates.md | 97 +++ scripts/build-windows-msi.ps1 | 9 +- scripts/test-windows-msi.ps1 | 9 +- scripts/test-windows-update-recovery.ps1 | 135 ++++ scripts/windows-update-tests/fixture.wxs | 29 + scripts/windows-update-tests/server.py | 46 ++ .../service-admin-http/routes-firmware.ts | 20 +- server/src/plugins/service-api-http/index.ts | 19 + server/src/shared/db/migrations-pg.ts | 1 + server/src/shared/firmware-targets.ts | 5 +- server/src/shared/types.ts | 1 + server/src/shared/windows-updates.ts | 42 ++ server/src/web-templates/admin-pages.tsx | 1 + server/tests/firmware-import.test.ts | 6 + server/tests/windows-updates.test.ts | 31 + 30 files changed, 2160 insertions(+), 31 deletions(-) create mode 100644 client/windows-updater/Cargo.toml create mode 100644 client/windows-updater/src/lib.rs create mode 100644 client/windows-updater/src/main.rs create mode 100644 client/windows-updater/src/platform.rs create mode 100644 client/windows-updater/src/worker.rs create mode 100644 docs/release-delivery.md create mode 100644 docs/windows-updates.md create mode 100644 scripts/test-windows-update-recovery.ps1 create mode 100644 scripts/windows-update-tests/fixture.wxs create mode 100644 scripts/windows-update-tests/server.py create mode 100644 server/src/shared/windows-updates.ts create mode 100644 server/tests/windows-updates.test.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b7fc73e1..51a3726a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -387,6 +387,20 @@ jobs: "PKG_CONFIG=$pkgConfig" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append "PKG_CONFIG_PATH=$pkgConfigPath" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append "LIB=C:\Program Files\gstreamer\1.0\msvc_x86_64\lib;$env:LIB" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + - name: Prepare Windows update trust root + env: + BF_CLIENT_FIRMWARE_SIGNING_KEY: ${{ secrets.BF_CLIENT_FIRMWARE_SIGNING_KEY }} + run: | + if (-not $env:BF_CLIENT_FIRMWARE_SIGNING_KEY) { throw "Windows release signing key is required" } + $key = Join-Path $env:RUNNER_TEMP 'windows-update.key' + $pub = Join-Path $env:RUNNER_TEMP 'windows-update.pub.pem' + [IO.File]::WriteAllText($key, $env:BF_CLIENT_FIRMWARE_SIGNING_KEY) + openssl pkey -in $key -pubout -out $pub + if ($LASTEXITCODE -ne 0) { throw "Cannot derive Windows update trust root" } + "BF_FIRMWARE_SIGNING_PUBLIC_KEY<> $env:GITHUB_ENV + Get-Content $pub >> $env:GITHUB_ENV + "BF_KEY" >> $env:GITHUB_ENV + "BF_WINDOWS_SIGNING_KEY=$key" >> $env:GITHUB_ENV - name: Build and test Windows client working-directory: client run: cargo test --release --locked @@ -398,10 +412,22 @@ jobs: ../scripts/build-windows-msi.ps1 -InstallVersion "0.1.${{ github.run_number }}" $msi = Get-ChildItem target\wix\*.msi | Select-Object -First 1 Copy-Item $msi.FullName "betterframe-windows-client-${{ inputs.version }}-x86_64.msi" + $artifact = "betterframe-windows-client-${{ inputs.version }}-x86_64.msi" + $sha = (Get-FileHash $artifact -Algorithm SHA256).Hash.ToLowerInvariant() + [IO.File]::WriteAllText("$artifact.sha256", $sha) + openssl pkeyutl -sign -rawin -in "$artifact.sha256" -inkey $env:BF_WINDOWS_SIGNING_KEY -out "$artifact.sig.raw" + if ($LASTEXITCODE -ne 0) { throw "Windows MSI signing failed" } + $signature = [Convert]::ToBase64String([IO.File]::ReadAllBytes("$artifact.sig.raw")).TrimEnd('=').Replace('+','-').Replace('/','_') + [IO.File]::WriteAllText("$artifact.sig", $signature) + Remove-Item $env:BF_WINDOWS_SIGNING_KEY, "$artifact.sig.raw" + - uses: actions/upload-artifact@v7 with: name: betterframe-windows-client-x86_64 - path: client/betterframe-windows-client-${{ inputs.version }}-x86_64.msi + path: | + client/betterframe-windows-client-${{ inputs.version }}-x86_64.msi + client/betterframe-windows-client-${{ inputs.version }}-x86_64.msi.sig + client/betterframe-windows-client-${{ inputs.version }}-x86_64.msi.sha256 retention-days: 14 windows-client-package-test: @@ -422,11 +448,39 @@ jobs: throw "clean-host test runner unexpectedly contains a global GStreamer installation" } ../scripts/test-windows-msi.ps1 -MsiPath "betterframe-windows-client-${{ inputs.version }}-x86_64.msi" + windows-client-publish: + needs: windows-client-package-test + runs-on: ubuntu-24.04 + env: + BF_AUTOIMPORT_URL: ${{ secrets.BF_AUTOIMPORT_URL }} + BF_AUTOIMPORT_API_KEY: ${{ secrets.BF_AUTOIMPORT_API_KEY }} + steps: + - uses: actions/download-artifact@v8 + with: + name: betterframe-windows-client-x86_64 + path: client + - name: Publish signed Windows MSI to BF storage + working-directory: client + run: | + set -euo pipefail + test -n "$BF_AUTOIMPORT_URL" && test -n "$BF_AUTOIMPORT_API_KEY" || { echo "BF artifact publication is required"; exit 1; } + bin="betterframe-windows-client-${{ inputs.version }}-x86_64.msi" + base64 -w 0 "$bin" > "$bin.b64" + 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 \ + --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" - name: Upload Windows installer to GitHub Release uses: softprops/action-gh-release@v3 with: tag_name: ${{ inputs.tag }} - files: client/betterframe-windows-client-${{ inputs.version }}-x86_64.msi + files: | + client/betterframe-windows-client-${{ inputs.version }}-x86_64.msi + client/betterframe-windows-client-${{ inputs.version }}-x86_64.msi.sig + client/betterframe-windows-client-${{ inputs.version }}-x86_64.msi.sha256 # Source/dependency SBOM is attached to every release for licensing and # vulnerability inventory. Binary/image jobs remain traceable to this ref. diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 56d0c062..0083073e 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -138,6 +138,10 @@ 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 + - name: Recover Windows updates through BF after authentication and app failures + working-directory: client + run: ../scripts/test-windows-update-recovery.ps1 + iobox: runs-on: ubuntu-24.04 diff --git a/client/Cargo.lock b/client/Cargo.lock index e9b487d5..053cab0d 100644 --- a/client/Cargo.lock +++ b/client/Cargo.lock @@ -255,6 +255,23 @@ dependencies = [ "url", ] +[[package]] +name = "betterframe-windows-updater" +version = "0.1.0" +dependencies = [ + "base64", + "betterframe-client-core", + "chrono", + "chrono-tz", + "ed25519-dalek", + "reqwest", + "serde", + "serde_json", + "sha2", + "url", + "windows-sys 0.52.0", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -415,10 +432,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", + "wasm-bindgen", "windows-link 0.2.1", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", +] + [[package]] name = "cipher" version = "0.4.4" @@ -534,7 +563,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf", + "phf 0.13.1", "smallvec", ] @@ -2659,6 +2688,15 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + [[package]] name = "phf" version = "0.13.1" @@ -2666,7 +2704,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ "phf_macros", - "phf_shared", + "phf_shared 0.13.1", "serde", ] @@ -2677,7 +2715,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.13.1", ] [[package]] @@ -2687,7 +2725,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", ] [[package]] @@ -2697,12 +2735,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.13.1", "proc-macro2", "quote", "syn 2.0.119", ] +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + [[package]] name = "phf_shared" version = "0.13.1" @@ -3134,7 +3181,7 @@ dependencies = [ "derive_more", "log", "new_debug_unreachable", - "phf", + "phf 0.13.1", "phf_codegen", "precomputed-hash", "rustc-hash", @@ -3391,7 +3438,7 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared", + "phf_shared 0.13.1", "precomputed-hash", ] @@ -3402,7 +3449,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.13.1", "proc-macro2", "quote", ] @@ -4151,7 +4198,7 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" dependencies = [ - "phf", + "phf 0.13.1", "phf_codegen", "string_cache", "string_cache_codegen", diff --git a/client/Cargo.toml b/client/Cargo.toml index 8432f776..ba02f565 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["core", "android-bridge"] +members = ["core", "android-bridge", "windows-updater"] resolver = "2" [package] diff --git a/client/README.md b/client/README.md index 29bf1f63..eed14a60 100644 --- a/client/README.md +++ b/client/README.md @@ -89,3 +89,9 @@ it (this does not uninstall the MSI). Normal application removal uses Windows **Installed apps**. Explicit CLI commands such as `agent` and `self-test` still attach to an existing terminal; for scripts use PowerShell `Start-Process -Wait -PassThru` to wait and inspect the exit code of the GUI executable. + +The MSI also installs the independent **BetterFrameUpdater** service for automatic +app updates and rollback through BF-hosted, vendor-signed MSI packages. Updates +respect saved maintenance windows and can recover without working enrollment. +See [Windows updates and recovery](../docs/windows-updates.md) for first-deployment +requirements, retained installers, and recovery behavior. diff --git a/client/src/platform/windows/mod.rs b/client/src/platform/windows/mod.rs index eb7803b5..02ebf2dc 100644 --- a/client/src/platform/windows/mod.rs +++ b/client/src/platform/windows/mod.rs @@ -201,6 +201,10 @@ pub fn run() { use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; let args: Vec = std::env::args().collect(); let command = args.get(1).map(String::as_str); + if command == Some("installation-test") { + if installation_test().is_err() { std::process::exit(1); } + return; + } let desktop = matches!(command, None | Some("desktop")); if !desktop && command != Some("app") { // Keep explicit diagnostic/administration commands usable from a terminal. @@ -254,6 +258,7 @@ pub fn run() { Some("desktop" | "agent") => run_agent_cli(&args[2..]), Some("app") => run_app(), Some("self-test") => self_test(), + Some("installation-test") => installation_test(), Some("install") => install_tasks(&args[2..]), Some("uninstall") => uninstall_tasks(), _ => { @@ -289,15 +294,7 @@ fn self_test() -> Result<(), String> { return Err("protected state-file round-trip returned different data".to_string()); } - gstreamer::init().map_err(|error| format!("GStreamer initialization: {error}"))?; - for plugin in ["rtspsrc", "decodebin", "d3d11videosink"] { - if gstreamer::ElementFactory::find(plugin).is_none() { - return Err(format!( - "required GStreamer element is unavailable: {plugin}" - )); - } - } - + installation_test()?; let webview = wry::webview_version().map_err(|error| format!("WebView2: {error}"))?; let displays = query_native_displays(); if displays.is_empty() { @@ -310,11 +307,27 @@ fn self_test() -> Result<(), String> { Ok(()) } +// Safe under SYSTEM: never creates or rewrites the user's enrollment directory ACL. +fn installation_test() -> Result<(), String> { + gstreamer::init().map_err(|error| format!("GStreamer initialization: {error}"))?; + for plugin in ["rtspsrc", "decodebin", "d3d11videosink"] { + if gstreamer::ElementFactory::find(plugin).is_none() { + return Err(format!( + "required GStreamer element is unavailable: {plugin}" + )); + } + } + + Ok(()) +} + fn run_agent_cli(args: &[String]) -> Result<(), String> { loop { // Finish a persisted local exit before discovery, pairing or rendering. // The marker can outlive demo=true if a previous cleanup was interrupted. complete_demo_exit()?; + let app = Arc::new(Mutex::new(None::)); + start_app(&app)?; let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .build() @@ -326,11 +339,12 @@ fn run_agent_cli(args: &[String]) -> Result<(), String> { Ok(server) => break server, Err(error) => { warn!("server discovery: {error}; retrying"); + let _ = supervise_app(&app); tokio::time::sleep(Duration::from_secs(10)).await; } } }; - run_agent(server).await + run_agent(server, app).await }); // Drop the runtime first: old heartbeat/bundle tasks must never restore cleared state. drop(rt); @@ -366,7 +380,7 @@ async fn discover_server( Err("could not find BetterFrame server".to_string()) } -async fn run_agent(server_url: String) -> Result<(), String> { +async fn run_agent(server_url: String, app: Arc>>) -> Result<(), String> { ensure_secure_state_dir()?; ensure_default_policy()?; @@ -374,7 +388,6 @@ async fn run_agent(server_url: String) -> Result<(), String> { latest.server_url = server_url; Ok(()) })?; - let app = Arc::new(Mutex::new(None::)); // The renderer reads protected cached state independently. Start it before // regional discovery so an offline upgrade keeps showing the saved display. start_app(&app)?; @@ -834,6 +847,16 @@ async fn handle_agent_command( info!("volume mute ignored by Windows policy"); } } + AgentCommand::CancelUpdates => { + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default().as_nanos().to_string(); + write_protected(&state_dir().join("update-policy-suspended"), stamp.as_bytes())?; + } + AgentCommand::FirmwareCheck { .. } => { + // SYSTEM independently polls BF; an explicit push is persisted by BF + // and verified there, never elevated from a user-writable local flag. + info!("Windows updater will check the persisted BF update request"); + } AgentCommand::Reboot => { if current_policy.controls.host_reboot { use std::os::windows::process::CommandExt; diff --git a/client/src/platform/windows/renderer.rs b/client/src/platform/windows/renderer.rs index 4e923f38..d793d907 100644 --- a/client/src/platform/windows/renderer.rs +++ b/client/src/platform/windows/renderer.rs @@ -238,6 +238,7 @@ pub(super) fn paint_window(hwnd: HWND) { if show_demo { SetWindowPos(control, HWND_TOP, 12, 12, 140, 44, SWP_NOACTIVATE); } } EndPaint(hwnd, &ps); + confirm_runtime_health(); } } @@ -1150,3 +1151,14 @@ mod origin_cache_tests { } } } + +// Confirmation depends on a painted local window, never network or enrollment. +fn confirm_runtime_health() { + static LAST: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs(); + if now.saturating_sub(LAST.load(std::sync::atomic::Ordering::Relaxed)) < 15 { return; } + let bytes = serde_json::to_vec(&serde_json::json!({"version": kiosk_app_version(), "at": now})).unwrap(); + if write_protected(&state_dir().join("runtime-health.json"), &bytes).is_ok() { + LAST.store(now, std::sync::atomic::Ordering::Relaxed); + } +} diff --git a/client/windows-updater/Cargo.toml b/client/windows-updater/Cargo.toml new file mode 100644 index 00000000..f46d046d --- /dev/null +++ b/client/windows-updater/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "betterframe-windows-updater" +version = "0.1.0" +edition = "2024" +license = "AGPL-3.0-only OR Commercial" + +[dependencies] +betterframe-client-core = { path = "../core" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +reqwest = { version = "0.12", features = ["blocking", "json"] } +sha2 = "0.10" +ed25519-dalek = { version = "2", features = ["pem"] } +base64 = "0.22" +chrono = "0.4" +chrono-tz = "0.10" +url = "2" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.52", features = [ + "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Cryptography", + "Win32_System_Services", "Win32_System_Threading", "Win32_System_Environment", + "Win32_System_RemoteDesktop", "Win32_System_Diagnostics_ToolHelp", + "Win32_System_ApplicationInstallationAndServicing", "Win32_Storage_FileSystem", +] } diff --git a/client/windows-updater/src/lib.rs b/client/windows-updater/src/lib.rs new file mode 100644 index 00000000..b3bd786b --- /dev/null +++ b/client/windows-updater/src/lib.rs @@ -0,0 +1,592 @@ +//! BF-only update selection and validation, independent of the desktop and enrollment loops. +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use betterframe_client_core::{update_policy::Policy, version::is_version_upgrade}; +use chrono::{DateTime, Datelike, Timelike, Utc}; +use ed25519_dalek::{Signature, VerifyingKey, pkcs8::DecodePublicKey}; +use reqwest::blocking::{Client, Response}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + fs, + io::{Read, Write}, + path::Path, + time::Duration, +}; + +pub const TARGET: &str = "windows-x64"; +pub const MAX_BYTES: u64 = 512 * 1024 * 1024; +pub const VERSION: &str = match option_env!("BF_BUILD_VERSION") { + Some(v) => v, + None => "0.1.0", +}; +pub const PUBLIC_KEY: Option<&str> = option_env!("BF_FIRMWARE_SIGNING_PUBLIC_KEY"); +pub const UPGRADE_CODE: &str = "{C57DAC79-D926-492A-800D-190630390291}"; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Identity { + pub server_url: String, + pub kiosk_key: Option, + #[serde(default)] + pub demo: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Artifact { + pub release_id: String, + pub version: String, + pub sha256: String, + pub signature: String, + pub size_bytes: u64, + pub download_url: String, +} + +#[derive(Debug, Deserialize)] +pub struct Check { + pub up_to_date: bool, + pub update: Option, + pub update_policy: Option, + pub push_request: Option, +} + +#[derive(Debug)] +pub enum Failure { + Deferred(u64), + Other(String), +} +impl From for Failure { + fn from(s: String) -> Self { + Self::Other(s) + } +} +impl std::fmt::Display for Failure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Deferred(s) => write!(f, "BF deferred download for {s}s"), + Self::Other(s) => f.write_str(s), + } + } +} + +pub fn client() -> Result { + Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(30)) + .build() + .map_err(|e| e.to_string()) +} + +/// Only root-relative BF API paths can carry credentials. Never follow redirects. +pub fn endpoint(origin: &str, path: &str) -> Result { + betterframe_client_core::protocol::discovery_probe(origin)?; + if !path.starts_with("/api/") || path.contains(['\\', '?', '#']) || path.contains("..") { + return Err("invalid BF update endpoint".into()); + } + Ok(format!("{}{path}", origin.trim_end_matches('/'))) +} + +pub fn window_open(policy: &Policy, now: DateTime) -> bool { + let Ok(zone) = policy.schedule.timezone.parse::() else { + return false; + }; + let local = now.with_timezone(&zone); + policy.schedule.allows( + local.weekday().num_days_from_sunday() as u8, + (local.hour() * 60 + local.minute()) as u16, + ) +} + +fn decode(response: Response) -> Result { + if response.status().as_u16() == 429 { + return Err(deferred(&response)); + } + if !response.status().is_success() { + return Err(Failure::Other(format!( + "BF check HTTP {}", + response.status() + ))); + } + let body = response + .take(1024 * 1024) + .bytes() + .collect::, _>>() + .map_err(|e| Failure::Other(e.to_string()))?; + let check: Check = serde_json::from_slice(&body).map_err(|e| Failure::Other(e.to_string()))?; + if !check.up_to_date && check.update.is_none() { + return Err(Failure::Other("missing update metadata".into())); + } + Ok(check) +} + +/// A valid authenticated up-to-date response is authoritative. Failed control +/// checks recover using persisted policy; no policy means fail closed. +pub fn check( + client: &Client, + identity: &Identity, + policy: Option<&Policy>, + current: &str, +) -> Result { + if identity.demo { + return Err(Failure::Other("updates disabled in demo mode".into())); + } + let query = [("target", TARGET), ("current", current)]; + if let Some(key) = identity.kiosk_key.as_deref().filter(|k| !k.is_empty()) { + let url = endpoint(&identity.server_url, "/api/kiosk/firmware/check")?; + if let Ok(response) = client.get(url).query(&query).bearer_auth(key).send() { + match decode(response) { + Ok(mut check) => { + // The origin is selected locally, never by a manifest. + if let Some(policy) = check.update_policy.as_mut() { + policy.server = identity.server_url.clone(); + } + return Ok(check); + } + Err(error @ Failure::Deferred(_)) => return Err(error), + Err(_) => (), + } + } + } + let policy = policy + .filter(|p| p.server == identity.server_url) + .ok_or_else(|| { + Failure::Other("recovery requires a saved policy for this BF origin".into()) + })?; + public_check(client, &identity.server_url, policy, current) +} + +pub fn public_check( + client: &Client, + origin: &str, + policy: &Policy, + current: &str, +) -> Result { + let url = endpoint(origin, "/api/firmware/public/check")?; + let response = client + .get(url) + .query(&[("target", TARGET), ("current", current)]) + .query(&policy.selection(false)) + .send() + .map_err(|e| Failure::Other(e.to_string()))?; + let mut check = decode(response)?; + // Public recovery can never grant a maintenance-window override or replace policy. + check.push_request = None; + check.update_policy = None; + Ok(check) +} + +fn deferred(response: &Response) -> Failure { + let delay = response + .headers() + .get("retry-after") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()) + .unwrap_or(60) + .clamp(1, 3600); + let jitter = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .subsec_nanos() as u64 + % 61; + Failure::Deferred(delay + jitter) +} + +pub fn validate_offer(info: &Artifact, current: &str) -> Result<(), String> { + if !is_version_upgrade(&info.version, current) { + return Err("offered version is not an upgrade".into()); + } + if info.size_bytes == 0 || info.size_bytes > MAX_BYTES { + return Err("invalid MSI size".into()); + } + if info.release_id.is_empty() + || !info + .release_id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-') + { + return Err("invalid release ID".into()); + } + if ![ + format!("/api/kiosk/firmware/download/{}", info.release_id), + format!("/api/firmware/public/download/{}", info.release_id), + ] + .contains(&info.download_url) + { + return Err("artifact is not a BF download route".into()); + } + Ok(()) +} + +pub fn verify_file(path: &Path, info: &Artifact, public_key: &str) -> Result<(), String> { + let mut file = fs::File::open(path).map_err(|e| e.to_string())?; + if file.metadata().map_err(|e| e.to_string())?.len() != info.size_bytes + || info.size_bytes > MAX_BYTES + { + return Err("MSI size mismatch".into()); + } + let mut hash = Sha256::new(); + let mut buffer = [0u8; 64 * 1024]; + loop { + let size = file.read(&mut buffer).map_err(|e| e.to_string())?; + if size == 0 { + break; + } + hash.update(&buffer[..size]); + } + let digest = format!("{:x}", hash.finalize()); + if digest != info.sha256 { + return Err("MSI digest mismatch".into()); + } + let key = VerifyingKey::from_public_key_pem(public_key).map_err(|e| e.to_string())?; + let bytes = URL_SAFE_NO_PAD + .decode(info.signature.trim()) + .map_err(|e| e.to_string())?; + let signature = Signature::from_slice(&bytes).map_err(|e| e.to_string())?; + key.verify_strict(digest.as_bytes(), &signature) + .map_err(|_| "MSI publisher signature invalid".into()) +} + +pub fn download( + client: &Client, + identity: &Identity, + info: &Artifact, + path: &Path, + public_key: &str, +) -> Result<(), Failure> { + let public = format!("/api/firmware/public/download/{}", info.release_id); + let authenticated = format!("/api/kiosk/firmware/download/{}", info.release_id); + let send = |route: &str, key: Option<&str>| -> Result { + let mut request = client + .get(endpoint(&identity.server_url, route)?) + .timeout(Duration::from_secs(300)); + if let Some(key) = key { + request = request.bearer_auth(key); + } + request.send().map_err(|e| Failure::Other(e.to_string())) + }; + let response = if let Some(key) = identity.kiosk_key.as_deref() { + match send(&authenticated, Some(key)) { + Ok(response) if response.status().is_success() || response.status().as_u16() == 429 => { + response + } + _ => send(&public, None)?, + } + } else { + send(&public, None)? + }; + if response.status().as_u16() == 429 { + return Err(deferred(&response)); + } + if !response.status().is_success() { + return Err(Failure::Other(format!( + "BF download HTTP {}", + response.status() + ))); + } + let staged = path.with_extension("part"); + let result = (|| -> Result<(), String> { + let mut file = fs::File::create(&staged).map_err(|e| e.to_string())?; + let size = std::io::copy( + &mut response.take(info.size_bytes.min(MAX_BYTES) + 1), + &mut file, + ) + .map_err(|e| e.to_string())?; + file.sync_all().map_err(|e| e.to_string())?; + if size != info.size_bytes { + return Err("MSI body size mismatch".into()); + } + verify_file(&staged, info, public_key)?; + replace(&staged, path) + })(); + if result.is_err() { + let _ = fs::remove_file(&staged); + } + result.map_err(Failure::Other) +} + +pub fn save(path: &Path, value: &T) -> Result<(), String> { + let staged = path.with_extension("tmp"); + let mut file = fs::File::create(&staged).map_err(|e| e.to_string())?; + file.write_all(&serde_json::to_vec(value).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + file.sync_all().map_err(|e| e.to_string())?; + drop(file); + replace(&staged, path) +} +fn replace(from: &Path, to: &Path) -> Result<(), String> { + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, + }; + let wide = |p: &Path| { + p.as_os_str() + .encode_wide() + .chain(Some(0)) + .collect::>() + }; + if unsafe { + MoveFileExW( + wide(from).as_ptr(), + wide(to).as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + } == 0 + { + return Err(std::io::Error::last_os_error().to_string()); + } + Ok(()) + } + #[cfg(not(windows))] + { + fs::rename(from, to).map_err(|e| e.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::{Signer, SigningKey, pkcs8::EncodePublicKey}; + use std::net::TcpListener; + fn policy(origin: &str) -> Policy { + serde_json::from_value(serde_json::json!({"server":origin,"schedule":{"mode":"always","windows":[],"timezone":"UTC"},"firmware_channel":"beta","firmware_target_version":"1.2.0","os_update_channel":"stable","os_update_target_version":null})).unwrap() + } + fn fixture(bytes: &[u8]) -> (Artifact, String) { + let key = SigningKey::from_bytes(&[7; 32]); + let sha256 = format!("{:x}", Sha256::digest(bytes)); + let signature = URL_SAFE_NO_PAD.encode(key.sign(sha256.as_bytes()).to_bytes()); + ( + Artifact { + release_id: "release-1".into(), + version: "1.2.0".into(), + sha256, + signature, + size_bytes: bytes.len() as u64, + download_url: "/api/kiosk/firmware/download/release-1".into(), + }, + key.verifying_key() + .to_public_key_pem(Default::default()) + .unwrap(), + ) + } + fn temp() -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!( + "bf-updater-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir(&path).unwrap(); + path + } + #[test] + fn package_signature_size_and_hash_are_all_required() { + let root = temp(); + let path = root.join("artifact.msi"); + let bytes = b"signed test package"; + let (mut artifact, key) = fixture(bytes); + fs::write(&path, bytes).unwrap(); + verify_file(&path, &artifact, &key).unwrap(); + artifact.signature = URL_SAFE_NO_PAD.encode([0; 64]); + assert!(verify_file(&path, &artifact, &key).is_err()); + artifact = fixture(bytes).0; + fs::write(&path, b"changed test bytes").unwrap(); + assert!(verify_file(&path, &artifact, &key).is_err()); + fs::write(&path, [bytes.as_slice(), b"extra"].concat()).unwrap(); + assert!(verify_file(&path, &artifact, &key).is_err()); + fs::remove_dir_all(root).unwrap(); + } + #[test] + fn no_external_paths_credentials_downgrades_or_unbounded_bodies() { + for path in [ + "https://github.com/a", + "//github.com/a", + "/api/../a", + "/api/a?url=https://github.com", + "/api/\\github.com", + ] { + assert!(endpoint("https://bf.example", path).is_err()); + } + assert!(endpoint("https://user:secret@bf.example", "/api/a").is_err()); + assert!(endpoint("http://untrusted.example", "/api/a").is_err()); + let (mut artifact, _) = fixture(b"test"); + assert!(validate_offer(&artifact, "1.2.0").is_err()); + assert!(validate_offer(&artifact, "2.0.0").is_err()); + artifact.download_url = "https://github.com/a".into(); + assert!(validate_offer(&artifact, "1.0.0").is_err()); + artifact = fixture(b"test").0; + artifact.size_bytes = MAX_BYTES + 1; + assert!(validate_offer(&artifact, "1.0.0").is_err()); + } + #[test] + fn persisted_windows_apply_in_iana_timezone_and_fail_closed() { + let mut p = policy("https://bf.example"); + p.schedule.mode = "windows".into(); + p.schedule.timezone = "America/New_York".into(); + p.schedule.windows = vec![betterframe_client_core::update_policy::Window { + day: 1, + start: "09:00".into(), + end: "10:00".into(), + }]; + let summer = DateTime::parse_from_rfc3339("2026-07-06T13:30:00Z") + .unwrap() + .with_timezone(&Utc); + let winter = DateTime::parse_from_rfc3339("2026-01-05T14:30:00Z") + .unwrap() + .with_timezone(&Utc); + assert!(window_open(&p, summer)); + assert!(window_open(&p, winter)); + assert!(!window_open(&p, winter - Duration::from_secs(3600))); + p.schedule.timezone = "missing/timezone".into(); + assert!(!window_open(&p, summer)); + let root = temp(); + save(&root.join("policy.json"), &p).unwrap(); + let reloaded: Policy = + serde_json::from_slice(&fs::read(root.join("policy.json")).unwrap()).unwrap(); + assert_eq!(reloaded.firmware_target_version, Some("1.2.0".into())); + fs::remove_dir_all(root).unwrap(); + } + fn server( + responses: Vec<(&'static str, String)>, + ) -> (String, std::thread::JoinHandle>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + let handle = std::thread::spawn(move || { + let mut requests = Vec::new(); + for (status, body) in responses { + let (mut socket, _) = listener.accept().unwrap(); + socket + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut request = Vec::new(); + let mut byte = [0]; + while !request.ends_with(b"\r\n\r\n") { + socket.read_exact(&mut byte).unwrap(); + request.push(byte[0]); + } + requests.push(String::from_utf8(request).unwrap()); + write!(socket,"HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\nRetry-After: 60\r\nLocation: https://github.com/forbidden\r\n\r\n{body}",body.len()).unwrap(); + } + requests + }); + (origin, handle) + } + #[test] + fn auth_failures_and_malformed_responses_recover_without_auth_or_policy_override() { + for status in [ + "401 Unauthorized", + "500 Server Error", + "200 OK", + "302 Found", + ] { + let public=serde_json::json!({"up_to_date":true,"push_request":"untrusted-override","update_policy":policy("wrong")}).to_string(); + let (origin, thread) = server(vec![(status, "malformed".into()), ("200 OK", public)]); + let identity = Identity { + server_url: origin.clone(), + kiosk_key: Some("device-secret".into()), + demo: false, + }; + let result = check( + &client().unwrap(), + &identity, + Some(&policy(&origin)), + "1.0.0", + ) + .unwrap(); + assert!(result.up_to_date); + assert!(result.push_request.is_none()); + assert!(result.update_policy.is_none()); + let requests = thread.join().unwrap(); + assert!( + requests[0] + .to_lowercase() + .contains("authorization: bearer device-secret") + ); + assert!(!requests[1].to_lowercase().contains("authorization:")); + assert!(requests[1].contains("channel=beta")); + assert!(requests[1].contains("version=1.2.0")); + } + } + #[test] + fn up_to_date_and_rate_limit_do_not_trigger_public_fallback() { + for status in ["200 OK", "429 Too Many Requests"] { + let (origin, thread) = server(vec![(status, "{\"up_to_date\":true}".into())]); + let identity = Identity { + server_url: origin.clone(), + kiosk_key: Some("key".into()), + demo: false, + }; + let result = check( + &client().unwrap(), + &identity, + Some(&policy(&origin)), + "1.0.0", + ); + if status.starts_with("200") { + assert!(result.unwrap().up_to_date); + } else { + assert!(matches!(result, Err(Failure::Deferred(60..=120)))); + } + assert_eq!(thread.join().unwrap().len(), 1); + } + } + #[test] + fn recovery_without_policy_or_with_other_origins_is_disabled() { + let identity = Identity { + server_url: "http://127.0.0.1:9".into(), + kiosk_key: None, + demo: false, + }; + assert!(check(&client().unwrap(), &identity, None, "1.0.0").is_err()); + assert!( + check( + &client().unwrap(), + &identity, + Some(&policy("https://other.example")), + "1.0.0" + ) + .is_err() + ); + } + #[test] + fn download_falls_back_to_same_bf_artifact_and_verifies_before_publish() { + let (artifact, key) = fixture(b"package"); + let (origin, thread) = server(vec![ + ("403 Forbidden", "".into()), + ("200 OK", "package".into()), + ]); + let identity = Identity { + server_url: origin, + kiosk_key: Some("key".into()), + demo: false, + }; + let root = temp(); + let path = root.join("verified.msi"); + download(&client().unwrap(), &identity, &artifact, &path, &key).unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"package"); + let requests = thread.join().unwrap(); + assert!(requests[1].starts_with("GET /api/firmware/public/download/release-1")); + assert!(!requests[1].to_lowercase().contains("authorization:")); + fs::remove_dir_all(root).unwrap(); + } + #[test] + fn truncated_downloads_never_replace_a_verified_artifact() { + let (artifact, key) = fixture(b"package"); + let (origin, thread) = server(vec![("200 OK", "short".into())]); + let identity = Identity { + server_url: origin, + kiosk_key: None, + demo: false, + }; + let root = temp(); + let path = root.join("verified.msi"); + fs::write(&path, b"existing").unwrap(); + assert!(download(&client().unwrap(), &identity, &artifact, &path, &key).is_err()); + assert_eq!(fs::read(&path).unwrap(), b"existing"); + assert!(!path.with_extension("part").exists()); + thread.join().unwrap(); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/client/windows-updater/src/main.rs b/client/windows-updater/src/main.rs new file mode 100644 index 00000000..f3291e67 --- /dev/null +++ b/client/windows-updater/src/main.rs @@ -0,0 +1,21 @@ +#![cfg_attr(windows, windows_subsystem = "windows")] +#[cfg(windows)] +mod platform; +#[cfg(windows)] +mod worker; +#[cfg(windows)] +fn main() { + let args = std::env::args().collect::>(); + let result = match args.get(1).map(String::as_str) { + Some("service") => platform::service(), + Some("apply") => worker::apply(), + Some("probe") => Ok(()), + _ => Err("Updater is managed by the BetterFrame Windows service".into()), + }; + if let Err(error) = result { + platform::log(&error); + std::process::exit(1); + } +} +#[cfg(not(windows))] +fn main() {} diff --git a/client/windows-updater/src/platform.rs b/client/windows-updater/src/platform.rs new file mode 100644 index 00000000..abf27ce1 --- /dev/null +++ b/client/windows-updater/src/platform.rs @@ -0,0 +1,453 @@ +use std::{ + ffi::OsStr, + fs, + io::Write, + mem::{size_of, zeroed}, + os::windows::{ffi::OsStrExt, process::CommandExt}, + path::{Path, PathBuf}, + process::Command, + ptr::{null, null_mut}, + sync::atomic::{AtomicBool, Ordering}, + time::Duration, +}; +use windows_sys::Win32::{ + Foundation::*, + Security::{Cryptography::*, *}, + System::{ + ApplicationInstallationAndServicing::*, Diagnostics::ToolHelp::*, Environment::*, + RemoteDesktop::*, Services::*, Threading::*, + }, +}; + +pub const SERVICE: &str = "BetterFrameUpdater"; +pub static STOP: AtomicBool = AtomicBool::new(false); +pub fn wide(s: impl AsRef) -> Vec { + s.as_ref().encode_wide().chain(Some(0)).collect() +} +pub fn install_dir() -> PathBuf { + std::env::current_exe() + .expect("updater path") + .parent() + .unwrap() + .parent() + .unwrap() + .to_path_buf() +} +pub fn root() -> PathBuf { + install_dir().join("updates") +} +pub fn client_exe() -> PathBuf { + install_dir().join("bin/betterframe-windows-client.exe") +} +pub fn state_dir() -> PathBuf { + PathBuf::from(std::env::var_os("PROGRAMDATA").unwrap_or_else(|| "C:\\ProgramData".into())) + .join("BetterFrame/WindowsClient") +} +pub fn now() -> i64 { + chrono::Utc::now().timestamp() +} +pub fn log(text: &str) { + if let Ok(mut file) = fs::OpenOptions::new() + .create(true) + .append(true) + .open(root().join("updater.log")) + { + // Bounded local log; never log identities, credentials or response bodies. + if file.metadata().is_ok_and(|m| m.len() > 1024 * 1024) { + let _ = file.set_len(0); + } + let _ = writeln!(file, "{} {text}", chrono::Utc::now().to_rfc3339()); + } +} + +pub struct Handle(pub HANDLE); +impl Drop for Handle { + fn drop(&mut self) { + unsafe { + CloseHandle(self.0); + } + } +} +pub fn mutex(name: &str) -> Result, String> { + let h = unsafe { CreateMutexW(null(), 0, wide(name).as_ptr()) }; + if h == 0 { + return Err(std::io::Error::last_os_error().to_string()); + } + if unsafe { GetLastError() } == ERROR_ALREADY_EXISTS { + unsafe { + CloseHandle(h); + } + return Ok(None); + } + Ok(Some(Handle(h))) +} + +pub fn require_system() -> Result<(), String> { + unsafe { + let mut token = 0; + if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 { + return Err("cannot inspect updater token".into()); + } + let token = Handle(token); + let mut buffer = vec![0usize; 256]; + let mut needed = 0; + if GetTokenInformation( + token.0, + TokenUser, + buffer.as_mut_ptr().cast(), + (buffer.len() * size_of::()) as u32, + &mut needed, + ) == 0 + { + return Err("cannot inspect updater identity".into()); + } + let user = &*(buffer.as_ptr() as *const TOKEN_USER); + if IsWellKnownSid(user.User.Sid, WinLocalSystemSid) == 0 { + return Err("updates must run as the installed SYSTEM service".into()); + } + } + Ok(()) +} + +/// Read machine-DPAPI state without changing the desktop account's directory ACL. +pub fn read_state(path: &Path) -> Result { + let bytes = fs::read(path).map_err(|e| e.to_string())?; + let plain = if bytes.starts_with(b"BFW1") { + let input = CRYPT_INTEGER_BLOB { + cbData: (bytes.len() - 4) as u32, + pbData: bytes[4..].as_ptr() as *mut u8, + }; + let mut output: CRYPT_INTEGER_BLOB = unsafe { zeroed() }; + if unsafe { CryptUnprotectData(&input, null_mut(), null(), null(), null(), 0, &mut output) } + == 0 + { + return Err("cannot read protected client state".into()); + } + let plain = + unsafe { std::slice::from_raw_parts(output.pbData, output.cbData as usize).to_vec() }; + unsafe { + LocalFree(output.pbData.cast()); + } + plain + } else { + bytes + }; + serde_json::from_slice(&plain).map_err(|_| "invalid client state".into()) +} + +fn secure_root() -> Result<(), String> { + use windows_sys::Win32::Security::Authorization::*; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_ATTRIBUTE_REPARSE_POINT, GetFileAttributesW, + }; + fs::create_dir_all(root()).map_err(|e| e.to_string())?; + unsafe { + if GetFileAttributesW(wide(root()).as_ptr()) & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err("updater storage cannot be a reparse point".into()); + } + let mut descriptor = null_mut(); + if ConvertStringSecurityDescriptorToSecurityDescriptorW( + wide("D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)").as_ptr(), + SDDL_REVISION_1, + &mut descriptor, + null_mut(), + ) == 0 + { + return Err("cannot create updater ACL".into()); + } + let mut dacl = null_mut(); + let mut present = 0; + let mut defaulted = 0; + GetSecurityDescriptorDacl(descriptor, &mut present, &mut dacl, &mut defaulted); + let result = SetNamedSecurityInfoW( + wide(root()).as_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + null_mut(), + null_mut(), + dacl, + null_mut(), + ); + LocalFree(descriptor); + if result != 0 { + return Err("cannot protect updater storage".into()); + } + } + Ok(()) +} + +pub fn service() -> Result<(), String> { + require_system()?; + secure_root()?; + let mut name = wide(SERVICE); + let table = [ + SERVICE_TABLE_ENTRYW { + lpServiceName: name.as_mut_ptr(), + lpServiceProc: Some(service_main), + }, + SERVICE_TABLE_ENTRYW { + lpServiceName: null_mut(), + lpServiceProc: None, + }, + ]; + if unsafe { StartServiceCtrlDispatcherW(table.as_ptr()) } == 0 { + return Err(std::io::Error::last_os_error().to_string()); + } + Ok(()) +} +unsafe extern "system" fn control( + code: u32, + _: u32, + _: *mut core::ffi::c_void, + _: *mut core::ffi::c_void, +) -> u32 { + if code == SERVICE_CONTROL_STOP || code == SERVICE_CONTROL_SHUTDOWN { + STOP.store(true, Ordering::SeqCst); + } + 0 +} +unsafe extern "system" fn service_main(_: u32, _: *mut *mut u16) { + let handle = + unsafe { RegisterServiceCtrlHandlerExW(wide(SERVICE).as_ptr(), Some(control), null()) }; + if handle == 0 { + return; + } + let mut status = SERVICE_STATUS { + dwServiceType: SERVICE_WIN32_OWN_PROCESS, + dwCurrentState: SERVICE_RUNNING, + dwControlsAccepted: SERVICE_ACCEPT_STOP | SERVICE_ACCEPT_SHUTDOWN, + dwWin32ExitCode: 0, + dwServiceSpecificExitCode: 0, + dwCheckPoint: 0, + dwWaitHint: 0, + }; + unsafe { + SetServiceStatus(handle, &status); + } + std::thread::spawn(crate::worker::run); + while !STOP.load(Ordering::SeqCst) { + std::thread::sleep(Duration::from_millis(200)); + } + // Do not wait for network I/O on service stop. Process exit terminates workers; + // partial downloads are never executable and are overwritten next attempt. + status.dwCurrentState = SERVICE_STOPPED; + status.dwControlsAccepted = 0; + unsafe { + SetServiceStatus(handle, &status); + } +} + +pub fn start_service() -> Result<(), String> { + unsafe { + let manager = OpenSCManagerW(null(), null(), SC_MANAGER_CONNECT); + if manager == 0 { + return Err("cannot open service manager".into()); + } + let service = OpenServiceW(manager, wide(SERVICE).as_ptr(), SERVICE_START); + if service == 0 { + CloseServiceHandle(manager); + return Err("updater service is missing".into()); + } + let started = StartServiceW(service, 0, null()); + let error = GetLastError(); + CloseServiceHandle(service); + CloseServiceHandle(manager); + if started == 0 && error != ERROR_SERVICE_ALREADY_RUNNING { + return Err(format!("cannot start updater service: {error}")); + } + Ok(()) + } +} + +struct Msi(u32); +impl Drop for Msi { + fn drop(&mut self) { + unsafe { + MsiCloseHandle(self.0); + } + } +} +pub fn msi_property(path: &Path, property: &str) -> Result { + unsafe { + let mut db = 0; + if MsiOpenDatabaseW(wide(path).as_ptr(), null(), &mut db) != 0 { + return Err("cannot read verified MSI database".into()); + } + let db = Msi(db); + let mut view = 0; + let sql = format!("SELECT `Value` FROM `Property` WHERE `Property`='{property}'"); + if MsiDatabaseOpenViewW(db.0, wide(sql).as_ptr(), &mut view) != 0 { + return Err("cannot query MSI".into()); + } + let view = Msi(view); + if MsiViewExecute(view.0, 0) != 0 { + return Err("cannot execute MSI query".into()); + } + let mut record = 0; + if MsiViewFetch(view.0, &mut record) != 0 { + return Err(format!("MSI lacks {property}")); + } + let record = Msi(record); + let mut value = vec![0u16; 1024]; + let mut size = value.len() as u32; + if MsiRecordGetStringW(record.0, 1, value.as_mut_ptr(), &mut size) != 0 { + return Err("invalid MSI property".into()); + } + Ok(String::from_utf16_lossy(&value[..size as usize])) + } +} +pub fn validate_msi(path: &Path, version: &str) -> Result<(), String> { + if !msi_property(path, "UpgradeCode")? + .eq_ignore_ascii_case(betterframe_windows_updater::UPGRADE_CODE) + || msi_property(path, "BF_RELEASE_VERSION")? != version + || msi_property(path, "BF_UPDATE_TARGET")? != betterframe_windows_updater::TARGET + { + return Err("signed MSI does not match this product, platform and release".into()); + } + Ok(()) +} + +pub fn run_msi(path: &Path) -> Result<(), String> { + let code = Command::new(system_exe("msiexec.exe")) + .args(["/i"]) + .arg(path) + .args(["/qn", "/norestart", "REBOOT=ReallySuppress"]) + .raw_arg(format!("APPLICATIONFOLDER=\"{}\"", install_dir().display())) + .args(["/L*v"]) + .arg(root().join("install.log")) + .creation_flags(CREATE_NO_WINDOW) + .status() + .map_err(|e| e.to_string())?; + match code.code() { + Some(0 | 3010) => Ok(()), + _ => Err(format!("MSI installation failed: {code}")), + } +} +pub fn system_exe(name: &str) -> PathBuf { + PathBuf::from(std::env::var_os("SystemRoot").unwrap_or_else(|| "C:\\Windows".into())) + .join("System32") + .join(name) +} + +/// Stop only processes executing the installed client, not namesakes elsewhere. +/// Capture their sessions before replacement; resume under each user's token. +pub fn stop_clients() -> Result, String> { + let wanted = client_exe() + .to_string_lossy() + .replace('/', "\\") + .to_lowercase(); + let mut sessions = Vec::new(); + unsafe { + let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if snapshot == INVALID_HANDLE_VALUE { + return Err("cannot enumerate client processes".into()); + } + let snapshot = Handle(snapshot); + let mut entry: PROCESSENTRY32W = zeroed(); + entry.dwSize = size_of::() as u32; + let mut more = Process32FirstW(snapshot.0, &mut entry); + while more != 0 { + let process = OpenProcess( + PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_TERMINATE | PROCESS_SYNCHRONIZE, + 0, + entry.th32ProcessID, + ); + if process != 0 { + let process = Handle(process); + let mut path = vec![0u16; 32768]; + let mut len = path.len() as u32; + if QueryFullProcessImageNameW(process.0, 0, path.as_mut_ptr(), &mut len) != 0 + && String::from_utf16_lossy(&path[..len as usize]).to_lowercase() == wanted + { + let mut session = 0; + if ProcessIdToSessionId(entry.th32ProcessID, &mut session) != 0 + && session != 0 + && !sessions.contains(&session) + { + sessions.push(session); + } + TerminateProcess(process.0, 0); + WaitForSingleObject(process.0, 10000); + } + } + more = Process32NextW(snapshot.0, &mut entry); + } + } + Ok(sessions) +} +pub fn active_sessions() -> Vec { + let mut sessions = Vec::new(); + unsafe { + let mut entries = null_mut(); + let mut count = 0; + if WTSEnumerateSessionsW(0, 0, 1, &mut entries, &mut count) != 0 { + for entry in std::slice::from_raw_parts(entries, count as usize) { + if entry.SessionId != 0 && entry.State == WTSActive { + sessions.push(entry.SessionId); + } + } + WTSFreeMemory(entries.cast()); + } + } + sessions +} +pub fn launch_client(session: u32) -> Result<(), String> { + unsafe { + let mut token = 0; + if WTSQueryUserToken(session, &mut token) == 0 { + return Err(format!("no user token for session {session}")); + } + let token = Handle(token); + let mut environment = null_mut(); + if CreateEnvironmentBlock(&mut environment, token.0, 0) == 0 { + return Err("cannot create user environment".into()); + } + let mut startup: STARTUPINFOW = zeroed(); + startup.cb = size_of::() as u32; + let mut desktop = wide("winsta0\\default"); + startup.lpDesktop = desktop.as_mut_ptr(); + let exe = client_exe(); + let mut command = wide(format!("\"{}\" desktop", exe.display())); + let mut process: PROCESS_INFORMATION = zeroed(); + let ok = CreateProcessAsUserW( + token.0, + wide(&exe).as_ptr(), + command.as_mut_ptr(), + null(), + null(), + 0, + CREATE_UNICODE_ENVIRONMENT, + environment, + wide(exe.parent().unwrap()).as_ptr(), + &startup, + &mut process, + ); + DestroyEnvironmentBlock(environment); + if ok == 0 { + return Err(std::io::Error::last_os_error().to_string()); + } + CloseHandle(process.hProcess); + CloseHandle(process.hThread); + } + Ok(()) +} +pub fn package_probe() -> Result<(), String> { + let mut process = Command::new(client_exe()) + .arg("installation-test") + .creation_flags(CREATE_NO_WINDOW) + .spawn() + .map_err(|e| e.to_string())?; + for _ in 0..60 { + if let Some(status) = process.try_wait().map_err(|e| e.to_string())? { + return if status.success() { + Ok(()) + } else { + Err(format!("installed client probe failed: {status}")) + }; + } + std::thread::sleep(Duration::from_secs(1)); + } + let _ = process.kill(); + let _ = process.wait(); + Err("installed client probe timed out".into()) +} diff --git a/client/windows-updater/src/worker.rs b/client/windows-updater/src/worker.rs new file mode 100644 index 00000000..a40dac33 --- /dev/null +++ b/client/windows-updater/src/worker.rs @@ -0,0 +1,393 @@ +use crate::platform as os; +use betterframe_client_core::update_policy::Policy; +use betterframe_windows_updater::*; +use serde::{Deserialize, Serialize}; +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}; + +const INSTALL_MUTEX: &str = "Global\\BetterFrameUpdateInstall"; +#[derive(Clone, Serialize, Deserialize)] +struct Pending { + previous: Artifact, + candidate: Artifact, + stage: String, + started: i64, + #[serde(default)] + health_started: Option, + #[serde(default)] + sessions: Vec, +} +#[derive(Default, Serialize, Deserialize)] +struct Attempts { + version: String, + count: u32, + request: Option, + retry_at: i64, +} +#[derive(Serialize, Deserialize)] +struct SavedPolicy { + #[serde(flatten)] + policy: Policy, + #[serde(default)] + cancellation: Option>, +} +#[derive(Deserialize)] +struct Health { + version: String, + at: i64, +} +fn read(name: &str) -> Option { + serde_json::from_slice(&fs::read(os::root().join(name)).ok()?).ok() +} +fn write(name: &str, value: &T) -> Result<(), String> { + save(&os::root().join(name), value) +} +fn healthy(pending: &Pending) -> bool { + let health: Result = os::read_state(&os::state_dir().join("runtime-health.json")); + health.is_ok_and(|h| { + h.version == pending.candidate.version && h.at >= pending.started && h.at <= os::now() + 60 + }) +} + +pub fn run() { + while !os::STOP.load(Ordering::SeqCst) { + let delay = match std::panic::catch_unwind(tick) { + Ok(Ok(delay)) => delay, + Ok(Err(Failure::Deferred(seconds))) => { + os::log("download deferred by BF; no installation attempt consumed"); + seconds + } + Ok(Err(error)) => { + os::log(&format!("update check: {error}")); + 120 + } + Err(_) => { + os::log("update worker recovered from panic"); + 120 + } + }; + for _ in 0..delay { + if os::STOP.load(Ordering::SeqCst) { + return; + } + std::thread::sleep(Duration::from_secs(1)); + } + } +} + +fn tick() -> Result { + let Some(_lock) = os::mutex(INSTALL_MUTEX)? else { + return Ok(15); + }; + if os::root().join("pending.json").exists() { + let mut pending: Pending = + read("pending.json").ok_or("invalid update journal".to_string())?; + if pending.stage == "awaiting-health" && VERSION == pending.candidate.version { + if healthy(&pending) { + fs::remove_file(os::root().join("pending.json")).map_err(|e| e.to_string())?; + report(&pending.candidate.version, None); + os::log("updated display confirmed healthy"); + return Ok(120); + } + if os::active_sessions().is_empty() { + return Ok(30); + } + let start = *pending.health_started.get_or_insert_with(os::now); + write("pending.json", &pending)?; + if os::now() - start < 300 { + return Ok(15); + } + } + // A worker crash, interrupted install/reboot, or unhealthy candidate + // restores the previously verified package before another forward update. + pending.stage = "rollback".into(); + write("pending.json", &pending)?; + handoff()?; + return Ok(1); + } + let identity = match os::read_state::(&os::state_dir().join("state.json")) { + Ok(identity) => { + betterframe_client_core::protocol::discovery_probe(&identity.server_url)?; + write("identity.json", &identity)?; + identity + } + Err(_) => match read("identity.json") { + Some(saved) => saved, + None => return Ok(120), + }, + }; + if identity.demo { + return Ok(120); + } + let client = client()?; + let cancellation_path = os::state_dir().join("update-policy-suspended"); + let cancellation = fs::read(&cancellation_path).ok(); + let mut policy: Option = read::("policy.json") + .filter(|saved| saved.cancellation == cancellation) + .map(|saved| saved.policy); + let offered = check(&client, &identity, policy.as_ref(), VERSION)?; + if fs::read(&cancellation_path).ok() != cancellation { + return Ok(15); + } + if let Some(next) = offered.update_policy { + // A single service worker serializes policy updates, so stale desktop + // heartbeats cannot overwrite cancellation or newer maintenance windows. + write( + "policy.json", + &SavedPolicy { + policy: next.clone(), + cancellation: cancellation.clone(), + }, + )?; + policy = Some(next); + } + let policy = policy + .filter(|p| p.server == identity.server_url) + .ok_or("no saved update policy".to_string())?; + if offered.up_to_date { + return Ok(120); + } + if offered.push_request.is_none() && !window_open(&policy, chrono::Utc::now()) { + return Ok(120); + } + let candidate = offered.update.ok_or("missing candidate".to_string())?; + validate_offer(&candidate, VERSION)?; + let mut attempts: Attempts = if os::root().join("attempts.json").exists() { + read("attempts.json").ok_or("invalid installation attempt history".to_string())? + } else { + Attempts::default() + }; + if attempts.version != candidate.version + || (offered.push_request.is_some() && attempts.request != offered.push_request) + { + attempts = Attempts { + version: candidate.version.clone(), + request: offered.push_request.clone(), + ..Default::default() + }; + } + if attempts.count >= 3 || os::now() < attempts.retry_at { + return Ok(120); + } + let key = PUBLIC_KEY.ok_or( + "this build has no embedded publisher key; automatic installation disabled".to_string(), + )?; + // Never replace the application without a verified full rollback installer. + // MSI's LocalPackage cache may omit cabinets and is not a recovery artifact. + let mut previous_policy = policy.clone(); + previous_policy.firmware_target_version = Some(VERSION.into()); + let previous = public_check(&client, &identity.server_url, &previous_policy, "")? + .update + .ok_or("installed release is not available on BF for rollback".to_string())?; + if previous.version != VERSION { + return Err(Failure::Other("rollback release version mismatch".into())); + } + validate_offer(&previous, "")?; + let previous_path = os::root().join("previous.msi"); + if verify_file(&previous_path, &previous, key).is_err() { + download(&client, &identity, &previous, &previous_path, key)?; + } + os::validate_msi(&previous_path, &previous.version)?; + let candidate_path = os::root().join("candidate.msi"); + download(&client, &identity, &candidate, &candidate_path, key)?; + os::validate_msi(&candidate_path, &candidate.version)?; + // Recheck after download: a canceled rollout, changed pin/window, or yanked + // artifact must not be installed using the earlier decision. + if fs::read(&cancellation_path).ok() != cancellation { + return Ok(15); + } + let fresh = check(&client, &identity, Some(&policy), VERSION)?; + if fs::read(&cancellation_path).ok() != cancellation { + return Ok(15); + } + if let Ok(current) = os::read_state::(&os::state_dir().join("state.json")) { + if current.demo || current.server_url != identity.server_url { + return Ok(120); + } + } + let current_policy = fresh.update_policy.as_ref().unwrap_or(&policy); + if let Some(next) = fresh.update_policy.as_ref() { + write( + "policy.json", + &SavedPolicy { + policy: next.clone(), + cancellation: cancellation.clone(), + }, + )?; + } + if fresh.up_to_date + || fresh + .update + .as_ref() + .is_none_or(|a| a.sha256 != candidate.sha256 || a.version != candidate.version) + || (fresh.push_request.is_none() && !window_open(current_policy, chrono::Utc::now())) + { + return Ok(120); + } + attempts.count += 1; + attempts.retry_at = os::now() + 1800; + write("attempts.json", &attempts)?; + let pending = Pending { + previous, + candidate, + stage: "installing".into(), + started: os::now(), + health_started: None, + sessions: Vec::new(), + }; + write("pending.json", &pending)?; + handoff()?; + Ok(1) +} + +fn handoff() -> Result<(), String> { + let helper = os::root().join("install-worker.exe"); + fs::copy(std::env::current_exe().map_err(|e| e.to_string())?, &helper) + .map_err(|e| e.to_string())?; + Command::new(helper) + .arg("apply") + .creation_flags(DETACHED_PROCESS) + .spawn() + .map_err(|e| e.to_string())?; + os::STOP.store(true, Ordering::SeqCst); + Ok(()) +} + +pub fn apply() -> Result<(), String> { + os::require_system()?; + let result = apply_inner(); + if let Err(error) = os::start_service() { + os::log(&error); + } + result +} + +fn apply_inner() -> Result<(), String> { + // Original updater exits before this copy permits MSI to replace its files. + let _lock = loop { + if let Some(lock) = os::mutex(INSTALL_MUTEX)? { + break lock; + } + std::thread::sleep(Duration::from_millis(200)); + }; + let mut pending: Pending = read("pending.json").ok_or("missing update journal")?; + let key = PUBLIC_KEY.ok_or("missing embedded publisher key")?; + let previous = os::root().join("previous.msi"); + verify_file(&previous, &pending.previous, key)?; + os::validate_msi(&previous, &pending.previous.version)?; + let outcome = if pending.stage == "rollback" { + rollback(&mut pending, &previous) + } else { + install(&mut pending, key).or_else(|error| { + os::log(&format!( + "candidate failed; restoring previous release: {error}" + )); + rollback(&mut pending, &previous) + }) + }; + // MSI stops and recreates the service. Also restart it after a failed or + // rolled-back transaction so future published fixes remain reachable. + if let Err(error) = os::start_service() { + os::log(&error); + } + outcome +} + +fn install(pending: &mut Pending, key: &str) -> Result<(), String> { + let candidate = os::root().join("candidate.msi"); + verify_file(&candidate, &pending.candidate, key)?; + os::validate_msi(&candidate, &pending.candidate.version)?; + pending.sessions = os::active_sessions(); + pending.started = os::now(); + write("pending.json", pending)?; + let running = os::stop_clients()?; + for session in running { + if !pending.sessions.contains(&session) { + pending.sessions.push(session); + } + } + write("pending.json", pending)?; + let cancellation = fs::read(os::state_dir().join("update-policy-suspended")).ok(); + if read::("policy.json").is_none_or(|saved| saved.cancellation != cancellation) { + return Err("policy changed before installation".into()); + } + 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::start_service()?; + pending.stage = "awaiting-health".into(); + write("pending.json", pending)?; + for session in &pending.sessions { + let _ = os::launch_client(*session); + } + if os::active_sessions().is_empty() { + return Ok(()); + } + pending.health_started = Some(os::now()); + write("pending.json", pending)?; + for _ in 0..150 { + if healthy(pending) { + fs::remove_file(os::root().join("pending.json")).map_err(|e| e.to_string())?; + report(&pending.candidate.version, None); + os::log("update installed and display confirmed healthy"); + return Ok(()); + } + if os::active_sessions().is_empty() { + return Ok(()); + } + std::thread::sleep(Duration::from_secs(2)); + } + Err("new display failed to confirm local health within five minutes".into()) +} + +fn rollback(pending: &mut Pending, previous: &Path) -> Result<(), String> { + pending.stage = "rollback".into(); + write("pending.json", pending)?; + report( + &pending.candidate.version, + Some("Windows update failed local validation; restoring previous release"), + ); + let running = os::stop_clients()?; + os::run_msi(previous)?; + os::package_probe()?; + for session in pending.sessions.iter().chain(running.iter()) { + let _ = os::launch_client(*session); + } + fs::remove_file(os::root().join("pending.json")).map_err(|e| e.to_string())?; + os::log("previous release restored; failed version remains subject to retry limit"); + Ok(()) +} + +fn report(version: &str, error: Option<&str>) { + // Telemetry is best effort and cannot prevent installation or recovery. + let Some(identity) = read::("identity.json") else { + return; + }; + let Some(key) = identity.kiosk_key.as_deref() else { + return; + }; + let (Ok(client), Ok(url)) = ( + client(), + endpoint(&identity.server_url, "/api/kiosk/firmware/applied"), + ) else { + return; + }; + let _ = client + .post(url) + .bearer_auth(key) + .timeout(Duration::from_secs(5)) + .json(&serde_json::json!({"version": version, "error": error})) + .send(); +} diff --git a/client/wix/main.wxs b/client/wix/main.wxs index e23e7694..efd555ca 100644 --- a/client/wix/main.wxs +++ b/client/wix/main.wxs @@ -6,7 +6,7 @@ - + + AllowDowngrades="yes" /> + + + @@ -62,6 +66,19 @@ Value=""[#ClientExe]" desktop" KeyPath="yes" /> + + + + + + + @@ -95,6 +112,7 @@ Absent="disallow"> + diff --git a/deploy/angie/betterframe.docker.conf b/deploy/angie/betterframe.docker.conf index 47247e59..2a7f94a4 100644 --- a/deploy/angie/betterframe.docker.conf +++ b/deploy/angie/betterframe.docker.conf @@ -81,6 +81,18 @@ server { proxy_set_header X-Real-IP $remote_addr; } + # Signed release publication can carry an MSI with bundled media libraries. + # Scope the larger bound to the authenticated artifact import endpoint. + location = /api/admin/firmware/import { + client_max_body_size 700M; + proxy_pass http://betterframe_admin; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-BetterFrame-Tenant $http_x_betterframe_tenant; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + } + location /api/admin/ { proxy_pass http://betterframe_admin; proxy_set_header Host $host; diff --git a/docs/release-delivery.md b/docs/release-delivery.md new file mode 100644 index 00000000..bbb5295a --- /dev/null +++ b/docs/release-delivery.md @@ -0,0 +1,28 @@ +# BetterFrame release delivery + +## Requirement + +Devices obtain BetterFrame update metadata and artifacts from the configured BF origin or BF-managed origin pool. Customer firewalls must not need GitHub/CDN addresses for updates. Previously published releases remain available during an upstream GitHub outage. Update delivery must continue when kiosk enrollment authentication fails, while honoring locally saved windows, channels, and pins. Authenticated administrative pushes may bypass the maintenance window. + +## Current implementation + +Linux app, Linux RAUC OS, and ioBOX OTA already serve stored bytes from BF. Firmware imports upload signed bytes directly. OS imports currently ask BF to fetch a release URL once and store its content; device downloads do not proxy or redirect to that upstream. Linux API clients reject redirects, and ioBOX verifies the configured HTTPS origin. + +Remaining gaps are Linux installer downloads (including MediaMTX), OS release ingestion tied to GitHub download URLs, and a unified catalog across all artifact types and Android APK installers. Windows now has a native independent updater service and direct signed-MSI publication to BF; see [Windows updates](windows-updates.md). Android release installs are manual or Google Play; ordinary installations cannot silently replace the package without platform authorization. + +## Delivery service + +Extend the existing BF artifact storage into one release catalog and delivery layer rather than an on-demand GitHub proxy: + +1. A publisher uploads immutable artifact bytes and metadata to BF directly. GitHub Actions can be one publisher; the upload protocol must also work from another trusted builder or an operator's machine. +2. BF verifies the artifact digest and publisher signature before making the release discoverable. Interrupted uploads are private staging files. Publication is atomic and idempotent; an existing version/target/digest cannot be silently overwritten. +3. Publish per-platform manifests for Linux binaries, RAUC bundles, ioBOX images, Windows MSIs, Android APKs, bootstrap installers, and required bundled release dependencies. Keep signing identity, architecture/compatibility, size, digest, version, channel, and supported installation mechanism explicit. +4. Serve manifests and blobs from BF. Do not redirect customer devices to GitHub or an external object-store hostname. Use internal storage replication or an internal reverse proxy behind the BF endpoint. All advertised origins must belong to the operator's documented allowlist. +5. Separate device-specific rollout commands from public recovery discovery. Healthy fleet downloads use their authenticated routes. Public recovery downloads use the saved channel/pin, with retry deferral and jitter that never marks a deferred download as an installation failure. +6. Persist rollout policy locally and retain existing signature, upgrade-only, platform eligibility, and rollback checks. BF stages/publishes the release and notifies clients; clients choose the installation time unless an authenticated explicit push overrides the window. + +## Availability and rollout + +Replicate both immutable blobs and catalog metadata across the BF serving pool before announcing a release. A manifest must never select an artifact absent from that serving origin. Keep prior releases for rollback/recovery and expose availability separately from build success. Missing artifacts produce retryable delivery errors, not an upstream download/redirect on the device's critical path. + +Deploy the BF publication/delivery API first, import the current signed releases, verify downloads with GitHub access blocked, then update installers and clients. Add Windows/Android installation mechanisms explicitly as a separate platform capability; serving a signed MSI/APK is not equivalent to unattended installation. BF server deployment itself is a separate scope from device release delivery and may require its container images, source bundles, and dependencies to be mirrored too. diff --git a/docs/windows-updates.md b/docs/windows-updates.md new file mode 100644 index 00000000..df109a5b --- /dev/null +++ b/docs/windows-updates.md @@ -0,0 +1,97 @@ +# Windows installation, updates and recovery + +The MSI installs an automatic **BetterFrameUpdater** LocalSystem service, independent +of the signed-in desktop agent. The service starts at boot and polls BF every two +minutes. The desktop still starts at sign-in; the service can update an installation +when the desktop is broken, stopped, unpaired or has invalid credentials. + +## Publication and trust + +Windows release builds embed the existing vendor Ed25519 public key in the updater. +CI signs the completed MSI and uploads it directly to the authenticated BF firmware +import endpoint as target `windows-x64`. The server verifies the signature and stores +immutable, digest-addressed bytes before registering the release. BF publication is +required before the Windows asset is advertised on GitHub. GitHub remains a build +and optional distribution source, never a device download dependency. + +The service checks `/api/kiosk/firmware/check` and downloads from the corresponding +BF artifact endpoint. No HTTP redirects are followed. Download metadata cannot +select another origin. Before executing an MSI, the updater verifies bounded size, +SHA-256 and the embedded vendor signature, then checks the signed MSI's UpgradeCode, +`BF_UPDATE_TARGET` and `BF_RELEASE_VERSION`. A signature alone is insufficient to +install a different product, platform, or release. Forward updates must increase +the semantic version. + +## Saved policy and authentication recovery + +Authenticated checks return the schedule, server IANA timezone, channel and pin even +when already up to date. The service persists them in its own protected storage; +it does not depend on desktop heartbeats. Missing or corrupt policy never opens a +window. DST-aware evaluation uses the saved timezone. + +Rejected, unavailable or malformed authenticated checks fall back to +`/api/firmware/public/check` on the same BF origin, using the saved channel and pin. +A valid up-to-date response is authoritative. Artifact downloads first use the +normal authenticated route, then the public route if control authentication fails. +HTTP 429 uses Retry-After plus jitter without consuming an installation attempt. +Previously saved identity/origin remains available if the enrollment file cannot +be read. Demo mode disables updates. + +An explicit admin push is recorded on BF for the selected version and exact current +policy, expires after 30 minutes, and is returned only by authenticated checks. +It can bypass the time window. Public responses cannot authorize that override. +A changed policy invalidates the push. Desktop cancellation records suspend cached +recovery until the service receives replacement policy; cancellation acknowledgments +are persisted without deleting potentially newer cancellation records. Selection +and the maintenance window are checked again after downloads. + +## Installation and rollback + +Before updating, the service fetches and verifies the full installer for the +currently installed version. If BF cannot supply it, the update is deferred; +Windows Installer's stripped LocalPackage cache is not used as a rollback source. +Retain previously published Windows releases on BF. + +The service stages installers, attempt history and an atomic recovery journal in +`\updates`, restricted to SYSTEM and administrators. A +standalone copy of the updater handles installation so MSI can replace the service +itself. It stops only processes whose executable path matches the installed client, +installs silently without rebooting, and restarts the client in its users' desktop +sessions with their existing Windows tokens. + +The new package must pass an installation probe. A painted native display window +then records local health, including its build version and timestamp. This does not +require pairing or a working network; the renderer starts before server discovery. +A candidate that does not confirm within five minutes of an active desktop session +is rolled back to the retained verified MSI. With no signed-in session, display +confirmation waits for sign-in. A surviving journal lets the service recover an +interrupted installation or resume health evaluation after reboot. MSI transaction +rollback also protects failed package installations. + +Failed versions are limited to three installation attempts, at least 30 minutes +apart. A newer version or a new explicit admin push resets that version's retry +budget. The service is restarted after installer failure and rollback, and SCM +restarts it after service crashes. A failure that prevents Windows itself or both +retained updater/installer copies from running still requires machine recovery. + +## First deployment + +Deploy the BF server migration and import-endpoint proxy limit before publishing +Windows installers. Install the first updater-enabled MSI once using an administrator +account; older Windows builds cannot acquire this service automatically. Let it +receive one valid authenticated policy. Subsequent recovery can then operate without +working enrollment. The vendor signing key and BF import URL/API key must be present +in the release environment; an unsigned build cannot auto-install updates. + +Do not remove the current installed release from BF until devices have moved on. +Use a dedicated Windows kiosk account. Uninstall removes the service and sign-in +entry; protected recovery installers/logs are retained in the installation's `updates` +directory for diagnosis and can be removed by an administrator after uninstall. + +## Validation + +Updater tests cover signature/hash/size rejection, origin restrictions, no redirects, +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. diff --git a/scripts/build-windows-msi.ps1 b/scripts/build-windows-msi.ps1 index 52137c54..3e66a01f 100644 --- a/scripts/build-windows-msi.ps1 +++ b/scripts/build-windows-msi.ps1 @@ -1,6 +1,13 @@ # Run from client/ with the GStreamer SDK and WiX available (Windows CI). param([Parameter(Mandatory = $true)][string]$InstallVersion) $ErrorActionPreference = 'Stop' +if (-not $env:BF_BUILD_VERSION) { $env:BF_BUILD_VERSION = '0.1.0' } +$previousRustFlags = $env:RUSTFLAGS +try { + $env:RUSTFLAGS = '-C target-feature=+crt-static' + cargo build --release --locked -p betterframe-windows-updater --target-dir target/updater + if ($LASTEXITCODE -ne 0) { throw "Windows updater build failed" } +} finally { $env:RUSTFLAGS = $previousRustFlags } cargo install cargo-wix --version 0.3.9 --locked if ($LASTEXITCODE -ne 0) { throw "cargo-wix install failed" } @@ -25,5 +32,5 @@ $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 +cargo wix --package betterframe-client --nocapture --install-version $InstallVersion -L -sice:ICE30 -L -sice:ICE80 -L -ext -L WixUtilExtension if ($LASTEXITCODE -ne 0) { throw "MSI build failed" } diff --git a/scripts/test-windows-msi.ps1 b/scripts/test-windows-msi.ps1 index 181a002a..e3bc471c 100644 --- a/scripts/test-windows-msi.ps1 +++ b/scripts/test-windows-msi.ps1 @@ -27,6 +27,10 @@ try { Invoke-Msi "/i `"$msi`" APPLICATIONFOLDER=`"$installDir`" /qn /norestart" $installed = $true Assert-Startup + $service = Get-CimInstance Win32_Service -Filter "Name='BetterFrameUpdater'" + if ($service.StartMode -ne 'Auto' -or $service.StartName -ne 'LocalSystem' -or $service.State -ne 'Running') { + throw 'Independent SYSTEM updater service was not installed and started' + } # Verify the actual PE header: Explorer must not allocate a console window. $image = [IO.File]::ReadAllBytes($exe) $peOffset = [BitConverter]::ToInt32($image, 0x3c) @@ -58,7 +62,7 @@ try { throw 'Duplicate launch did not exit' } if ($duplicate.ExitCode -ne 0) { throw "Duplicate launch failed: $($duplicate.ExitCode)" } - Stop-Process -Id $agent.Id -Force + & taskkill.exe /PID $agent.Id /T /F | Out-Null $agent.WaitForExit() $agent = $null @@ -70,10 +74,11 @@ try { if (Get-ItemProperty -Path $runKey -Name BetterFrame -ErrorAction SilentlyContinue) { throw 'Uninstall left automatic startup registered' } + if (Get-Service BetterFrameUpdater -ErrorAction SilentlyContinue) { throw 'Uninstall left the updater service installed' } if (Test-Path $exe) { throw 'Uninstall left the client executable installed' } Write-Host 'Windows MSI startup, GUI executable, duplicate launch, repair and uninstall passed.' } finally { - if ($agent -and -not $agent.HasExited) { Stop-Process -Id $agent.Id -Force } + if ($agent -and -not $agent.HasExited) { & taskkill.exe /PID $agent.Id /T /F | Out-Null } $env:ProgramData = $originalProgramData if ($installed) { Invoke-Msi "/x `"$msi`" /qn /norestart" } if (Test-Path $testData) { Remove-Item -LiteralPath $testData -Recurse -Force } diff --git a/scripts/test-windows-update-recovery.ps1 b/scripts/test-windows-update-recovery.ps1 new file mode 100644 index 00000000..88363f28 --- /dev/null +++ b/scripts/test-windows-update-recovery.ps1 @@ -0,0 +1,135 @@ +# Disposable Windows runner only. Exercise the real SYSTEM updater with three +# vendor-signed fixture MSIs: installed, healthy upgrade, and broken upgrade. +$ErrorActionPreference = 'Stop' +$fixture = Join-Path $env:RUNNER_TEMP 'bf-update-recovery' +$installDir = Join-Path $env:ProgramFiles 'BetterFrame Recovery Test' +$stateDir = Join-Path $env:ProgramData 'BetterFrame/WindowsClient' +$updateDir = Join-Path $installDir 'updates' +$originalVersion = $env:BF_BUILD_VERSION +$originalKey = $env:BF_FIRMWARE_SIGNING_PUBLIC_KEY +$originalFlags = $env:RUSTFLAGS +$server = $null +New-Item -ItemType Directory -Force $fixture | Out-Null +$candle = Join-Path $env:WIX 'bin/candle.exe' +$light = Join-Path $env:WIX 'bin/light.exe' +$csc = Join-Path $env:WINDIR 'Microsoft.NET/Framework64/v4.0.30319/csc.exe' + +function Write-Json($Path, $Value) { + $temp = "$Path.tmp" + [IO.File]::WriteAllText($temp, ($Value | ConvertTo-Json -Depth 10 -Compress)) + Move-Item -Force $temp $Path +} +function Wait-For([scriptblock]$Condition, [string]$Message, [int]$Seconds = 180) { + $deadline = (Get-Date).AddSeconds($Seconds) + do { + if (& $Condition) { return } + Start-Sleep -Seconds 2 + } while ((Get-Date) -lt $deadline) + if (Test-Path "$updateDir/updater.log") { Get-Content "$updateDir/updater.log" -Tail 30 } + if (Test-Path "$updateDir/install.log") { Get-Content "$updateDir/install.log" -Tail 30 } + 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)" } +} +try { + if (Get-Service BetterFrameUpdater -ErrorAction SilentlyContinue) { throw 'Test requires no existing BetterFrame installation' } + if (Test-Path "$stateDir/state.json") { throw 'Test refuses to overwrite existing enrollment' } + openssl genpkey -algorithm ED25519 -out "$fixture/key.pem" + if ($LASTEXITCODE -ne 0) { throw 'Fixture key generation failed' } + openssl pkey -in "$fixture/key.pem" -pubout -out "$fixture/pub.pem" + if ($LASTEXITCODE -ne 0) { throw 'Fixture public key generation failed' } + $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" + $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' } + Copy-Item target/updater/release/betterframe-windows-updater.exe "$dir/updater.exe" + $probeExit = if ($version -eq '1.0.2') { 42 } else { 0 } + # Real service/MSI transaction, deterministic client health signal. + $source = @" +using System; +using System.IO; +using System.Threading; +class Client { + static int Main(string[] args) { + if (args.Length > 0 && args[0] == "installation-test") return $probeExit; + string dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "BetterFrame", "WindowsClient"); + Directory.CreateDirectory(dir); + while (true) { + string data = "{\"version\":\"$version\",\"at\":" + DateTimeOffset.UtcNow.ToUnixTimeSeconds() + "}"; + File.WriteAllText(Path.Combine(dir,"runtime-health.json"),data); + Thread.Sleep(1000); + } + } +} +"@ + [IO.File]::WriteAllText("$dir/client.cs", $source) + & $csc /nologo /target:winexe "/out:$dir/client.exe" "$dir/client.cs" + 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' } + & $light -nologo -out "$fixture/$version.msi" "$dir/package.wixobj" + if ($LASTEXITCODE -ne 0) { throw 'Fixture MSI link failed' } + $sha = (Get-FileHash "$fixture/$version.msi" -Algorithm SHA256).Hash.ToLowerInvariant() + [IO.File]::WriteAllText("$dir/hash", $sha) + openssl pkeyutl -sign -rawin -in "$dir/hash" -inkey "$fixture/key.pem" -out "$dir/signature" + if ($LASTEXITCODE -ne 0) { throw 'Fixture signature failed' } + $signature = [Convert]::ToBase64String([IO.File]::ReadAllBytes("$dir/signature")).TrimEnd('=').Replace('+','-').Replace('/','_') + # Release IDs use the same UUID-safe alphabet as production. + $id = $version.Replace('.','-') + Copy-Item "$fixture/$version.msi" "$fixture/$id.msi" + Write-Json "$fixture/$version.json" @{release_id=$id;version=$version;sha256=$sha;signature=$signature;size_bytes=(Get-Item "$fixture/$version.msi").Length;download_url="/api/firmware/public/download/$id"} + } + Write-Json "$fixture/control.json" @{version='1.0.0';reject_auth=$false} + $serverScript = (Resolve-Path ../scripts/windows-update-tests/server.py).Path + $server = Start-Process python -ArgumentList "`"$serverScript`" `"$fixture`"" -PassThru + Wait-For { Test-Path "$fixture/port" } 'Mock BF server did not start' 30 + $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" + 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 + # Enrollment is rejected, and then its file is unavailable: recovery must + # depend only on the independent service's saved origin/policy. + Remove-Item "$stateDir/state.json" + Write-Json "$fixture/control.json" @{version='1.0.1';reject_auth=$true} + Start-Service BetterFrameUpdater + Wait-For { + if (-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' -and -not (Test-Path "$updateDir/pending.json") + } 'Signed update did not recover after authentication and enrollment failure' 240 + Write-Host 'Recovery upgrade passed with enrollment missing and BF authentication rejected.' + Stop-Service BetterFrameUpdater + Write-Json "$fixture/control.json" @{version='1.0.2';reject_auth=$true} + Start-Service BetterFrameUpdater + Wait-For { + if (-not (Test-Path "$updateDir/updater.log")) { return $false } + return (Get-Content "$updateDir/updater.log" -Raw).Contains('previous release restored') -and -not (Test-Path "$updateDir/pending.json") + } 'Broken candidate was not rolled back' 240 + $health = Get-Content "$stateDir/runtime-health.json" -Raw | ConvertFrom-Json + if ($health.version -ne '1.0.1') { throw 'Rollback did not restart the previously healthy client' } + $attempts = Get-Content "$updateDir/attempts.json" -Raw | ConvertFrom-Json + 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.' +} finally { + Stop-Service BetterFrameUpdater -ErrorAction SilentlyContinue + Get-Process betterframe-windows-client -ErrorAction SilentlyContinue | Stop-Process -Force + # Identify the fixture product via Windows Installer, without Win32_Product repair scans. + $installer = New-Object -ComObject WindowsInstaller.Installer + foreach ($product in $installer.RelatedProducts('{C57DAC79-D926-492A-800D-190630390291}')) { + Invoke-Msi "/x $product /qn /norestart" + } + if ($server -and -not $server.HasExited) { Stop-Process -Id $server.Id -Force } + Remove-Item "$stateDir/state.json", "$stateDir/runtime-health.json" -Force -ErrorAction SilentlyContinue + $env:BF_BUILD_VERSION = $originalVersion + $env:BF_FIRMWARE_SIGNING_PUBLIC_KEY = $originalKey + $env:RUSTFLAGS = $originalFlags +} diff --git a/scripts/windows-update-tests/fixture.wxs b/scripts/windows-update-tests/fixture.wxs new file mode 100644 index 00000000..ab60413c --- /dev/null +++ b/scripts/windows-update-tests/fixture.wxs @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scripts/windows-update-tests/server.py b/scripts/windows-update-tests/server.py new file mode 100644 index 00000000..066e58c3 --- /dev/null +++ b/scripts/windows-update-tests/server.py @@ -0,0 +1,46 @@ +"""Disposable BF origin for native updater tests. No GitHub or external fetches.""" +import hashlib +import http.server +import json +import pathlib +import sys +import urllib.parse + +root = pathlib.Path(sys.argv[1]) +class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, *_): + pass + def do_GET(self): + url = urllib.parse.urlparse(self.path) + query = urllib.parse.parse_qs(url.query) + config = json.loads((root / "control.json").read_text(encoding="utf-8-sig")) + with (root / "requests.log").open("a") as f: + f.write(url.path + "\n") + if url.path.startswith("/api/kiosk/") and config["reject_auth"]: + self.send_response(401); self.end_headers(); return + if "/download/" in url.path: + name = url.path.rsplit("/", 1)[-1] + artifact = root / (name + ".msi") + if not artifact.is_file(): + self.send_response(404); self.end_headers(); return + data = artifact.read_bytes() + else: + version = query.get("version", [config["version"]])[0] + current = query.get("current", [""])[0] + artifact = root / (version + ".msi") + metadata = json.loads((root / (version + ".json")).read_text(encoding="utf-8-sig")) + up_to_date = bool(current) and tuple(map(int, version.split("."))) <= tuple(map(int, current.split("."))) + body = {"up_to_date": up_to_date} + if not up_to_date: + body["update"] = metadata + if url.path.startswith("/api/kiosk/"): + body["update_policy"] = {"server":"", "schedule":{"mode":"always", "windows":[], "timezone":"UTC"}, + "firmware_channel":"stable", "firmware_target_version":None, "os_update_channel":"stable", "os_update_target_version":None} + data = json.dumps(body).encode() + self.send_response(200) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) +server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) +(root / "port").write_text(str(server.server_port)) +server.serve_forever() diff --git a/server/src/plugins/service-admin-http/routes-firmware.ts b/server/src/plugins/service-admin-http/routes-firmware.ts index eb783c29..f2e8a279 100644 --- a/server/src/plugins/service-admin-http/routes-firmware.ts +++ b/server/src/plugins/service-admin-http/routes-firmware.ts @@ -1,3 +1,4 @@ +import { createWindowsPush, selectWindowsRelease, windowsUpdatePolicy } from "../../shared/windows-updates.js"; /** * Admin firmware routes — release upload, list, yank, per-kiosk push. * @@ -25,6 +26,7 @@ import type { FirmwareChannel } from "../../shared/types.js"; import { currentTenantSchema, isDefaultTenant, withDefaultTenant } from "../../shared/default-tenant.js"; import { FIRMWARE_TARGET_PC_X86_64, + FIRMWARE_TARGET_WINDOWS, FIRMWARE_TARGET_RPI5, normalizeFirmwareTarget, } from "../../shared/firmware-targets.js"; @@ -33,6 +35,7 @@ import { verifyDetached } from "../../shared/firmware.js"; const ALLOWED_CHANNELS: ReadonlySet = 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: