diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4df2e75f80..9d168c7aa6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,9 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Windows font notice regressions + shell: pwsh + run: ./packaging/windows/test-font-notices.ps1 - name: Git dependencies come from storytold run: | set -euo pipefail diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 6d319f032d..25f732d100 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -82,6 +82,8 @@ fn startup_deadline_expired() -> String { /// One request from a client, answered on `reply`. pub struct ControlRequest { + /// Admission time on the sending thread, including time waiting for the next app tick. + pub since: web_time::Instant, pub method: String, pub params: Value, pub reply: Sender, @@ -98,7 +100,7 @@ impl ControlClient { /// Queue a request; the reply arrives on the returned receiver once the app has handled it. pub fn send(&self, method: &str, params: Value) -> Receiver { let (reply, rx) = channel(); - let req = ControlRequest { method: method.into(), params, reply }; + let req = ControlRequest { since: web_time::Instant::now(), method: method.into(), params, reply }; if let Err(e) = self.tx.send(req) { let _ = e.0.reply.send(Err("the app has closed".into())); } @@ -620,8 +622,13 @@ impl Control { } while let Ok(req) = self.rx.try_recv() { // One admission timestamp per request: queued input, pending frames and an admitted - // screenshot all measure their deadline from this arrival, never from a later phase. - let since = web_time::Instant::now(); + // screenshot all measure their deadline from the sending thread, including time + // waiting in this channel, never from a later phase. + let since = req.since; + if web_time::Instant::now().duration_since(since) > timeout() { + let _ = req.reply.send(Err(if ready { startup_deadline_expired() } else { still_starting() })); + continue; + } if !ready { if self.deferred.len() < 32 { self.deferred.push_back(DeferredRequest { request: req, since }); @@ -2384,6 +2391,23 @@ mod lifecycle_tests { } } + #[test] + fn a_request_expired_in_the_channel_is_never_dispatched_or_deferred() { + for ready in [false, true] { + let ctx = egui::Context::default(); + let (mut control, client) = attach(&ctx); + let mut host = ReadinessHost::new(ready); + let (reply, waiting) = channel(); + let since = web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap(); + client.tx.send(ControlRequest { since, method: "ui.state".into(), params: json!({}), reply }).unwrap(); + control.tick(&ctx, &mut host); + let error = waiting.try_recv().expect("expired channel request replies on the first tick").expect_err("expired request cannot dispatch"); + assert!(error.contains(if ready { "finished starting" } else { "still starting" }), "{error}"); + assert_eq!(host.states.get(), 0); + assert!(control.deferred.is_empty() && control.pending.is_empty()); + } + } + /// A request held during startup keeps its arrival time. Once the app accepts input, one /// older than the control deadline fails instead of being dispatched into a later UI. #[test] diff --git a/docs/releasing.md b/docs/releasing.md index a39d7cca79..496ef4a672 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -152,6 +152,12 @@ neither the MSI nor the portable zip needs the Visual C++ redistributable. OFL notices; `-SkipBuild` requires `CRAFT_FONTS_DIR` so they can be staged, or `-BuiltWithoutCraftFonts` for binaries built without that input, and `test-msi.ps1` checks that the compiled MSI has OFL notice rows when expected (and none for a no-font build). + Before either package is created, `font-notices.ps1` validates every declared OFL notice in + `fonts/manifest.txt` and the native `fonts/simplified-chinese-manifest.txt` when present + (required when `CRAFT_FONTS_REQUIRED` is set). Missing or empty family notices fail packaging; + one family's notice cannot cover another. The check conservatively includes declared faces + omitted by build-time size caps. `test-font-notices.ps1` exercises these failures in CI. + The supplied checkout must match the existing binaries when using `-SkipBuild`. That presence check does not establish byte-for-byte payload or installation coverage; final package qualification still checks all notices against the staged files. - **Signing:** `packaging/windows/sign.ps1` signs both executables and the MSI with `signtool` diff --git a/packaging/windows/font-notices.ps1 b/packaging/windows/font-notices.ps1 new file mode 100644 index 0000000000..4cb0a9a934 --- /dev/null +++ b/packaging/windows/font-notices.ps1 @@ -0,0 +1,42 @@ +# Validate every declared native font notice before either Windows package is created. +# Checking all declared faces is deliberately conservative: size-capped faces may not be embedded. +function Get-CraftFontNotices([string] $FontRoot, [bool] $RequireChinese = $false) { + $rootPath = (Resolve-Path -LiteralPath $FontRoot).Path + $notices = @{} + foreach ($name in 'manifest.txt', 'simplified-chinese-manifest.txt') { + $manifest = Join-Path $rootPath "fonts/$name" + if (-not (Test-Path -LiteralPath $manifest -PathType Leaf)) { + if ($name -eq 'manifest.txt' -or $RequireChinese) { throw "Missing font manifest: $manifest" } + continue + } + foreach ($line in Get-Content -LiteralPath $manifest) { + $line = $line.Trim() + if (-not $line -or $line.StartsWith('#')) { continue } + $fields = @($line -split ' \| ' | ForEach-Object { $_.Trim() }) + if ($fields.Count -lt 6 -or $fields[4] -ne 'OFL-1.1') { throw "Missing OFL metadata in ${manifest}: $line" } + # Manifest notice paths must name a family-local file, never escape the font input. + $relative = $fields[5].Replace('\', '/') + if ($relative -notmatch '^fonts/([a-zA-Z0-9_-]+)/OFL\.txt$') { throw "Invalid font notice path: $relative" } + $family = $Matches[1] + $fontPath = $fields[2].Replace('\', '/') + if ($fontPath -notmatch ('^fonts/' + [regex]::Escape($family) + '/[^/]+$')) { + throw "Font and notice must belong to the same family directory: $fontPath / $relative" + } + $notice = Join-Path $rootPath $relative + if (-not (Test-Path -LiteralPath $notice -PathType Leaf) -or (Get-Item -LiteralPath $notice).Length -eq 0) { + throw "Missing or empty OFL notice for $($fields[0]): $notice" + } + $notices[$family] = Get-Item -LiteralPath $notice + } + } + if ($notices.Count -eq 0) { throw "No declared font notices in $rootPath" } + foreach ($family in @($notices.Keys | Sort-Object)) { $notices[$family] } +} + +# MSI FileName may be short|long. Compare the long names with the staged inventory. +function Assert-CraftFontNoticeNames([string[]] $Expected, [string[]] $Actual) { + $names = @($Actual | ForEach-Object { ($_ -split '\|')[-1] }) + foreach ($name in $Expected) { + if ($name -notin $names) { throw "The MSI is missing staged font notice: $name" } + } +} diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index aaddb78560..dccc21f559 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -128,15 +128,14 @@ $Docs = Join-Path $Stage 'font-licences' New-Item -ItemType Directory -Force -Path $Docs | Out-Null $HasCraftFontLicences = 0 if ($env:CRAFT_FONTS_DIR) { - foreach ($ofl in Get-ChildItem -Path (Join-Path $env:CRAFT_FONTS_DIR 'fonts\*\OFL.txt') -File -ErrorAction SilentlyContinue) { + . (Join-Path $PSScriptRoot 'font-notices.ps1') + # Validate both native manifests before copying anything. One family's notice cannot + # stand in for another's; retain complete files, including copyright and reserved names. + $fontNotices = @(Get-CraftFontNotices $env:CRAFT_FONTS_DIR (Test-Path Env:CRAFT_FONTS_REQUIRED)) + foreach ($ofl in $fontNotices) { Copy-Item -LiteralPath $ofl.FullName -Destination (Join-Path $Docs "OFL-$($ofl.Directory.Name).txt") $HasCraftFontLicences = 1 } - # CRAFT_FONTS_DIR means the build embeds its faces; packaging without their notices would be - # a licence omission. Fail instead of silently shipping HasCraftFontLicences=0. - if ($HasCraftFontLicences -ne 1) { - throw "CRAFT_FONTS_DIR=$env:CRAFT_FONTS_DIR has no fonts\\OFL.txt; refusing to package embedded craft-fonts without their licence notices" - } } # ---- end font notices for the MSI -------------------------------------------------------------- @@ -151,7 +150,10 @@ Invoke-Native 'wix build' { } # Inspect the built MSI, not just the XML, before signing/publishing it. In a child process, so # its Windows Installer database handle is gone before signtool opens the MSI. -Invoke-Native 'test-msi' { & (Get-Process -Id $PID).Path -NoProfile -File (Join-Path $PSScriptRoot 'test-msi.ps1') $Msi -CraftFontLicences $HasCraftFontLicences } +Invoke-Native 'test-msi' { + & (Get-Process -Id $PID).Path -NoProfile -File (Join-Path $PSScriptRoot 'test-msi.ps1') $Msi ` + -CraftFontLicences $HasCraftFontLicences -ExpectedFontNoticesDirectory $Docs +} # wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets. Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($Msi, '.wixpdb')) & (Join-Path $PSScriptRoot 'sign.ps1') $Msi diff --git a/packaging/windows/test-font-notices.ps1 b/packaging/windows/test-font-notices.ps1 new file mode 100644 index 0000000000..4d2c81b08b --- /dev/null +++ b/packaging/windows/test-font-notices.ps1 @@ -0,0 +1,43 @@ +param([string] $CraftFontsRoot) +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'font-notices.ps1') +$fixture = Join-Path ([IO.Path]::GetTempPath()) ('pdfcraft-font-notices-' + [guid]::NewGuid()) +function Assert-Rejected([scriptblock] $Action, [string] $Message) { + $rejected = $false + try { & $Action | Out-Null } catch { $rejected = $true } + if (-not $rejected) { throw "Expected rejection: $Message" } +} +try { + foreach ($family in 'core', 'chinese') { + New-Item -ItemType Directory -Path (Join-Path $fixture "fonts/$family") -Force | Out-Null + Set-Content -LiteralPath (Join-Path $fixture "fonts/$family/OFL.txt") -Value 'Synthetic notice fixture' + } + $core = 'Core | Regular | fonts/core/face.ttf | Latn | OFL-1.1 | fonts/core/OFL.txt' + $chinese = 'Chinese | Regular | fonts/chinese/face.ttf | Hans | OFL-1.1 | fonts/chinese/OFL.txt' + Set-Content (Join-Path $fixture 'fonts/manifest.txt') @('# comment', $core, $core) + Set-Content (Join-Path $fixture 'fonts/simplified-chinese-manifest.txt') $chinese + if (@(Get-CraftFontNotices $fixture $true).Count -ne 2) { throw 'Expected both unique family notices' } + Set-Content (Join-Path $fixture 'fonts/simplified-chinese-manifest.txt') ($chinese.Replace('fonts/chinese/OFL.txt', 'fonts/core/OFL.txt')) + Assert-Rejected { Get-CraftFontNotices $fixture $true } 'another family cannot supply this notice' + Set-Content (Join-Path $fixture 'fonts/simplified-chinese-manifest.txt') $chinese + Remove-Item (Join-Path $fixture 'fonts/chinese/OFL.txt') + Assert-Rejected { Get-CraftFontNotices $fixture $true } 'one remaining OFL cannot cover the Chinese family' + Set-Content (Join-Path $fixture 'fonts/chinese/OFL.txt') '' -NoNewline + Assert-Rejected { Get-CraftFontNotices $fixture $true } 'empty family notice' + Set-Content (Join-Path $fixture 'fonts/simplified-chinese-manifest.txt') ($chinese.Replace('fonts/chinese/OFL.txt', '../OFL.txt')) + Assert-Rejected { Get-CraftFontNotices $fixture $true } 'notice path escapes font input' + Remove-Item (Join-Path $fixture 'fonts/simplified-chinese-manifest.txt') + Assert-Rejected { Get-CraftFontNotices $fixture $true } 'required Chinese manifest missing' + if (@(Get-CraftFontNotices $fixture).Count -ne 1) { throw 'Optional Chinese manifest may be absent' } + Set-Content (Join-Path $fixture 'fonts/manifest.txt') 'malformed' + Assert-Rejected { Get-CraftFontNotices $fixture } 'malformed metadata' + Assert-CraftFontNoticeNames @('OFL-core.txt', 'OFL-chinese.txt') @('SHORT.TXT|OFL-core.txt', 'OFL-chinese.txt') + Assert-Rejected { Assert-CraftFontNoticeNames @('OFL-core.txt', 'OFL-chinese.txt') @('OFL-core.txt') } 'MSI lost one staged notice' + if ($CraftFontsRoot) { + $actual = @(Get-CraftFontNotices $CraftFontsRoot $true) + Write-Output "Verified $($actual.Count) notices from the actual native manifests" + } + Write-Output 'Font notice regression checks passed' +} finally { + Remove-Item -LiteralPath $fixture -Recurse -Force +} diff --git a/packaging/windows/test-msi.ps1 b/packaging/windows/test-msi.ps1 index 1a37516897..24a4c6d75a 100644 --- a/packaging/windows/test-msi.ps1 +++ b/packaging/windows/test-msi.ps1 @@ -8,7 +8,8 @@ param( [Parameter(Mandatory)] [string] $Path, # 1 when package.ps1 staged craft-fonts OFL notices, 0 when the binaries embed no craft-fonts. # -1 (the default) skips the check when the MSI is inspected by hand. - [int] $CraftFontLicences = -1 + [int] $CraftFontLicences = -1, + [string] $ExpectedFontNoticesDirectory ) $ErrorActionPreference = 'Stop' $Installer = New-Object -ComObject WindowsInstaller.Installer @@ -93,6 +94,11 @@ if ($CraftFontLicences -eq 1 -and $fontNotices.Count -eq 0) { if ($CraftFontLicences -eq 0 -and $fontNotices.Count -gt 0) { throw "the MSI carries craft-font notices that packaging did not stage: $($fontNotices -join ', ')" } +if ($ExpectedFontNoticesDirectory) { + . (Join-Path $PSScriptRoot 'font-notices.ps1') + $expected = @(Get-ChildItem -LiteralPath $ExpectedFontNoticesDirectory -File -Filter 'OFL-*.txt' | ForEach-Object { $_.Name }) + Assert-CraftFontNoticeNames $expected $fontNotices +} $status = Read-Row 'SELECT `Text` FROM `Control` WHERE `Dialog_` = ''InstallProgress'' AND `Control` = ''Status''' 1 Assert-Equal $status[0] 'Please wait while setup completes.' 'Persistent progress message' Assert-NoRow 'SELECT `Event` FROM `EventMapping` WHERE `Dialog_` = ''InstallProgress'' AND `Control_` = ''Status''' 'progress text subscription'