From fe6dcc2ad2392eafb21dae236b77c69e2f15f6e7 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 25 Sep 2026 14:33:49 +0200 Subject: [PATCH 1/4] Launch the notarized Mac DMG on both CPUs in CI A DMG without its web UI passed every check we had: notarization accepts a bundle regardless of what's in it, and no job ever started the app. The new launch-mac job downloads the DMG, checks that Gatekeeper accepts it as notarized, then launches the app on Apple Silicon and Intel runners and waits for a new "Viewer loaded" log line, which SvelteLayout writes once the viewer's main.js has imported. create-release now needs it. Also check for the viewer's main.js, not just index.html, before notarizing, so a build that loses only the viewer assets fails early. Co-Authored-By: Claude Opus 5.5 --- .github/AGENTS.md | 1 + .github/workflows/fw-lite.yaml | 63 ++++++++++++++++++- .../FwLiteShared/Layout/SvelteLayout.razor | 2 + 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 8d6d933f94..fbaacc9ded 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -231,6 +231,7 @@ This is the most complex workflow because it: | `frontend` | ubuntu-latest | Build viewer, Playwright snapshots | | `frontend-component-unit-tests` | ubuntu-latest | Vitest unit tests | | `build-apple` | macos-latest | MAUI Release builds for iOS simulator + Mac Catalyst; signs Mac Catalyst with the SIL Developer ID and notarizes a DMG when the signing secret is present (upstream), else unsigned compile check (fork PRs) | +| `launch-mac` | macos-latest + macos-15-intel | Checks Gatekeeper accepts the notarized DMG, then launches the app on each CPU and waits for its "Viewer loaded" log line (upstream only; gates `create-release`) | | `publish-linux` | ubuntu-latest | Linux binaries | | `publish-win` | windows-latest | MAUI tests, Windows MAUI publish + MSIX | diff --git a/.github/workflows/fw-lite.yaml b/.github/workflows/fw-lite.yaml index f9de7ca88c..9276cac8d1 100644 --- a/.github/workflows/fw-lite.yaml +++ b/.github/workflows/fw-lite.yaml @@ -293,6 +293,8 @@ jobs: needs: [build-and-test, frontend] timeout-minutes: 60 runs-on: macos-latest + outputs: + signed: ${{ steps.signing.outputs.signed }} env: # Mirrors the HAS_SIGNING_KEY pattern in publish-android (fork PRs get no secrets → unsigned). HAS_APPLE_SIGNING: ${{ secrets.SIL_APPLE_DEVELOPER_ID_APPLICATION_CERT_URL != '' }} @@ -332,6 +334,7 @@ jobs: -p:InformationalVersion=${{ needs.build-and-test.outputs.version }} - name: Import Developer ID signing certificate + id: signing if: env.HAS_APPLE_SIGNING == 'true' env: CERT_URL: ${{ secrets.SIL_APPLE_DEVELOPER_ID_APPLICATION_CERT_URL }} @@ -356,6 +359,7 @@ jobs: IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN" | awk '/Developer ID Application/ {print $2; exit}')" if [ -z "$IDENTITY" ]; then echo "No Developer ID Application identity in the certificate" >&2; exit 1; fi echo "CODESIGN_IDENTITY=$IDENTITY" >> "$GITHUB_ENV" + echo "signed=true" >> "$GITHUB_OUTPUT" rm -f "$RUNNER_TEMP/devid.p12" # Universal (x86_64 + arm64) so the DMG runs on both Intel and Apple Silicon. The two RIDs come @@ -388,7 +392,9 @@ jobs: # The SDK merges and signs the universal bundle here; the maccatalyst-*/ bundles are incomplete intermediates. APP="bin/Release/net10.0-maccatalyst/FieldWorks Lite.app" codesign --verify --deep --strict --verbose=2 "$APP" - [ -f "$APP/Contents/Resources/wwwroot/index.html" ] || { echo "Missing wwwroot/index.html in $APP" >&2; exit 1; } + for f in index.html _content/FwLiteShared/viewer/main.js; do + [ -f "$APP/Contents/Resources/wwwroot/$f" ] || { echo "Missing wwwroot/$f in $APP" >&2; exit 1; } + done # Fail loudly if the merged bundle isn't actually universal — the whole point is Intel + Apple Silicon. EXE="$APP/Contents/MacOS/$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$APP/Contents/Info.plist")" ARCHS="$(lipo -archs "$EXE" 2>/dev/null || true)" @@ -439,6 +445,59 @@ jobs: if-no-files-found: error path: backend/FwLite/artifacts/apple/* + # Opens the notarized DMG the way a user would, natively on each CPU the universal app supports. + launch-mac: + name: Launch the Mac DMG (${{ matrix.arch }}) + needs: build-apple + if: needs.build-apple.outputs.signed == 'true' + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - arch: arm64 + runner: macos-latest + - arch: x86_64 + runner: macos-15-intel + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: fw-lite-apple + + - name: Check Gatekeeper accepts the DMG and the app + run: | + set -euo pipefail + # -vv names what the verdict is based on; require notarization, not just a valid signature. + gatekeeper() { + local out; out="$(spctl --assess -vv "$@" 2>&1)" || true + echo "$out" + [[ "$out" == *": accepted"* && "$out" == *"source=Notarized Developer ID"* ]] + } + xcrun stapler validate FieldWorksLite.dmg + gatekeeper --type open --context context:primary-signature FieldWorksLite.dmg + hdiutil attach FieldWorksLite.dmg -nobrowse -readonly -mountpoint /Volumes/FieldWorksLite + gatekeeper --type execute "/Volumes/FieldWorksLite/FieldWorks Lite.app" + + - name: Launch the app and wait for the viewer to load + run: | + set -euo pipefail + echo "Runner CPU: $(uname -m)" + OUT="$RUNNER_TEMP/stdout.log" + # The app is sandboxed, so its app.log lands in its container. + CONTAINER="$HOME/Library/Containers/org.sil.FwLiteMaui" + "/Volumes/FieldWorksLite/FieldWorks Lite.app/Contents/MacOS/FwLiteMaui" > "$OUT" 2>&1 & + PID=$! + for _ in $(seq 120); do + grep -rqs 'Viewer loaded' "$OUT" "$CONTAINER" && break + kill -0 "$PID" 2>/dev/null || break + sleep 1 + done + kill "$PID" 2>/dev/null || true + cat "$OUT" + find "$CONTAINER" -name 'app*.log' -exec cat {} + 2>/dev/null || true + grep -rqs 'Viewer loaded' "$OUT" "$CONTAINER" || { echo "The app never logged 'Viewer loaded'" >&2; exit 1; } + publish-linux: name: Publish FW Lite app for Linux needs: [ build-and-test, frontend ] @@ -657,7 +716,7 @@ jobs: name: production url: https://lexbox.org/fw-lite name: Create Release - needs: [ build-and-test, publish-win, publish-linux, build-apple, publish-android] + needs: [ build-and-test, publish-win, publish-linux, build-apple, launch-mac, publish-android] runs-on: ubuntu-latest permissions: contents: write diff --git a/backend/FwLite/FwLiteShared/Layout/SvelteLayout.razor b/backend/FwLite/FwLiteShared/Layout/SvelteLayout.razor index f62f9c8219..59e27637e0 100644 --- a/backend/FwLite/FwLiteShared/Layout/SvelteLayout.razor +++ b/backend/FwLite/FwLiteShared/Layout/SvelteLayout.razor @@ -83,6 +83,8 @@ else await JS.InvokeAsync("import", "/" + Assets["_content/FwLiteShared/viewer/main.js"]); } + // CI's packaged-app launch checks (fw-lite.yaml) wait for this line. + Logger.LogInformation("Viewer loaded"); } catch (Exception e) { From cf30045364e1caba80c48ba8f0176c96204e25e6 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 25 Sep 2026 14:34:19 +0200 Subject: [PATCH 2/4] Launch the Windows portable app in CI Same check as the Mac DMG: start the published FwLiteMaui.exe and wait for its "Viewer loaded" log line, reading app.log from a temp data dir. Co-Authored-By: Claude Opus 5.5 --- .github/AGENTS.md | 2 +- .github/workflows/fw-lite.yaml | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/AGENTS.md b/.github/AGENTS.md index fbaacc9ded..a9939b7f81 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -233,7 +233,7 @@ This is the most complex workflow because it: | `build-apple` | macos-latest | MAUI Release builds for iOS simulator + Mac Catalyst; signs Mac Catalyst with the SIL Developer ID and notarizes a DMG when the signing secret is present (upstream), else unsigned compile check (fork PRs) | | `launch-mac` | macos-latest + macos-15-intel | Checks Gatekeeper accepts the notarized DMG, then launches the app on each CPU and waits for its "Viewer loaded" log line (upstream only; gates `create-release`) | | `publish-linux` | ubuntu-latest | Linux binaries | -| `publish-win` | windows-latest | MAUI tests, Windows MAUI publish + MSIX | +| `publish-win` | windows-latest | MAUI tests, Windows MAUI publish + MSIX; launches the portable exe and waits for its "Viewer loaded" log line | ### Solution filters diff --git a/.github/workflows/fw-lite.yaml b/.github/workflows/fw-lite.yaml index 9276cac8d1..1a691ce159 100644 --- a/.github/workflows/fw-lite.yaml +++ b/.github/workflows/fw-lite.yaml @@ -696,6 +696,23 @@ jobs: description: 'Release for version ${{ needs.build-and-test.outputs.version }} from branch ${{ github.ref_name || github.head_ref }}' description-url: 'https://github.com/sillsdev/languageforge-lexbox' + - name: Launch the portable app and wait for the viewer to load + shell: pwsh + run: | + $exe = Get-ChildItem backend/FwLite/artifacts/sign/portable -Recurse -Filter FwLiteMaui.exe | Select-Object -First 1 + if (-not $exe) { throw 'FwLiteMaui.exe not found in the portable publish output' } + $env:FwLiteMaui__BaseDataDir = Join-Path $env:RUNNER_TEMP 'fwlite-data' + $log = Join-Path $env:FwLiteMaui__BaseDataDir 'app.log' + $app = Start-Process -FilePath $exe.FullName -PassThru + $loaded = $false + for ($i = 0; $i -lt 120 -and -not $app.HasExited; $i++) { + if ((Test-Path $log) -and (Select-String -Path $log -Pattern 'Viewer loaded' -Quiet)) { $loaded = $true; break } + Start-Sleep -Seconds 1 + } + if (-not $app.HasExited) { Stop-Process -Id $app.Id -Force } + if (Test-Path $log) { Get-Content $log } + if (-not $loaded) { throw "The app never logged 'Viewer loaded'" } + - name: Upload FWLite Portable uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: From aa1112b96220bfa91a43d838b2fe50898b3b99a4 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 25 Sep 2026 15:49:30 +0200 Subject: [PATCH 3/4] Only log "Viewer loaded" once the viewer has mounted main.ts started the locale load without awaiting it and mounted Svelte in a later callback, so SvelteLayout's import() resolved and logged "Viewer loaded" before the app existed. A missing locale chunk or a mount error would still pass both launch checks with a blank window. Awaiting at the top level makes the import resolve only after mount, and a failure now rejects the import into SvelteLayout's "Failed to load assets" handler instead of becoming an unhandled rejection. Raised by Devin and CodeRabbit. Co-Authored-By: Claude Opus 5.5 --- frontend/viewer/src/main.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/frontend/viewer/src/main.ts b/frontend/viewer/src/main.ts index 9a5b060b0b..6fb33a3c35 100644 --- a/frontend/viewer/src/main.ts +++ b/frontend/viewer/src/main.ts @@ -24,10 +24,8 @@ useEventBus(); // Wire up globally-accessible helpers for hosts (e.g., MAUI) window.lexbox.SvelteNavigate = (url: string, options?: { replace?: boolean }) => navigate(url, options); -//don't mount the app until after we've loaded the local -void setLanguage('default') - .then(() => { - mount(App, { - target: document.getElementById('svelte-app')!, - }); - }); +// Awaited at the top level, so the host's import() of this module only resolves once the app has mounted. +await setLanguage('default'); +mount(App, { + target: document.getElementById('svelte-app')!, +}); From 58e47be8aff63e45f97561f241a3b44d771420f1 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 25 Sep 2026 15:49:37 +0200 Subject: [PATCH 4/4] Keep the Windows launch check out of the shipped portable folder The app writes its WebView2 profile next to the exe, so launching it in place added ~160 files of the runner's browser profile (cache, local storage, history) to the fw-lite-portable artifact that create-release zips. Run a copy instead, and skip the update check so CI doesn't call the production update endpoint. Also reap the Mac app after killing it, print each log's path, fix the job comment, and correct two stale artifact names in the CI guide. Co-Authored-By: Claude Opus 5.5 --- .github/AGENTS.md | 4 ++-- .github/workflows/fw-lite.yaml | 11 ++++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/AGENTS.md b/.github/AGENTS.md index a9939b7f81..561016df7b 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -250,8 +250,8 @@ The workflow produces: - `fw-lite-viewer-js` - Built viewer (shared by publish jobs) - `fw-lite-apple` - iOS simulator .app (zipped) + the universal (Intel + Apple Silicon) notarized Mac Catalyst `FieldWorksLite.dmg` (or an unsigned arm64 Mac Catalyst .app zip on fork PRs) - `fw-lite-web-linux` - Linux binaries -- `fw-lite-windows-exe` - Windows binaries -- `fw-lite-maui-msix` - MAUI installer +- `fw-lite-portable` - Windows portable app +- `fw-lite-msix` - MAUI installer --- diff --git a/.github/workflows/fw-lite.yaml b/.github/workflows/fw-lite.yaml index 1a691ce159..d6b03a3f3b 100644 --- a/.github/workflows/fw-lite.yaml +++ b/.github/workflows/fw-lite.yaml @@ -445,7 +445,7 @@ jobs: if-no-files-found: error path: backend/FwLite/artifacts/apple/* - # Opens the notarized DMG the way a user would, natively on each CPU the universal app supports. + # Checks that Gatekeeper accepts the notarized DMG, then runs the app natively on each CPU the universal build supports. launch-mac: name: Launch the Mac DMG (${{ matrix.arch }}) needs: build-apple @@ -494,8 +494,9 @@ jobs: sleep 1 done kill "$PID" 2>/dev/null || true + wait "$PID" 2>/dev/null || true cat "$OUT" - find "$CONTAINER" -name 'app*.log' -exec cat {} + 2>/dev/null || true + find "$CONTAINER" -name 'app*.log' -print -exec cat {} \; 2>/dev/null || true grep -rqs 'Viewer loaded' "$OUT" "$CONTAINER" || { echo "The app never logged 'Viewer loaded'" >&2; exit 1; } publish-linux: @@ -699,9 +700,13 @@ jobs: - name: Launch the portable app and wait for the viewer to load shell: pwsh run: | - $exe = Get-ChildItem backend/FwLite/artifacts/sign/portable -Recurse -Filter FwLiteMaui.exe | Select-Object -First 1 + # Run a copy: the app writes its WebView2 profile next to the exe, and the portable folder ships. + $dir = Join-Path $env:RUNNER_TEMP 'fwlite-portable' + Copy-Item -Recurse backend/FwLite/artifacts/sign/portable $dir + $exe = Get-ChildItem $dir -Recurse -Filter FwLiteMaui.exe | Select-Object -First 1 if (-not $exe) { throw 'FwLiteMaui.exe not found in the portable publish output' } $env:FwLiteMaui__BaseDataDir = Join-Path $env:RUNNER_TEMP 'fwlite-data' + $env:FwLite__UpdateCheckCondition = 'Never' $log = Join-Path $env:FwLiteMaui__BaseDataDir 'app.log' $app = Start-Process -FilePath $exe.FullName -PassThru $loaded = $false