From 59c8d98210bb1d1c7742b22fe620b4624cff1660 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 21:39:42 +0900 Subject: [PATCH 01/16] fix(desktop): give the widget an entry point and sign it at release time The WidgetKit extension installed, registered with pluginkit, and was never offered in the gallery. Signing looked like the cause and was not: the released build is signed and notarized and the widget is missing there too. app/Package.swift forced the executable's entry to _NSExtensionMain and main.swift was a comment, so nothing referenced OpenCodexWidgetBundle and no code handed it to the extension host. The shipped binary shows it: LC_MAIN pointing at _NSExtensionMain, SnapshotProvider present and the bundle absent. pluginkit registers from the Info.plist, which is complete, so registration succeeded; NSExtensionMain then looked for an NSExtensionPrincipalClass that a SwiftUI widget does not declare, because Xcode's @main on the WidgetBundle is what connects it instead. Nothing errored and the gallery had nothing to offer. main.swift now calls OpenCodexWidgetBundle.main(), which is what @main expands to, and the override is gone. Separately, release.yml ran build-widget.sh with no env block while MACOS_SIGN_IDENTITY was set one step later on the Tauri build, which never reads it, so the script took its ad-hoc branch on every release. The bundler does not re-sign anything under PlugIns - its nested-code walker knows .framework, .xpc and .app, not .appex - so an ad-hoc extension with no team identifier shipped inside a Developer ID host. The certificate is now imported before the widget build and the keychain deleted in an always() step, the widget build receives the identity, build-widget.sh adds --options runtime alongside --timestamp, and a following step asserts strict verification, the team identifier, the runtime flag and a timestamp instead of printing the signature. tests/clients/desktop-widget-entry.test.ts holds the entry point and was driven red by reinstating the linker override. The signing half needs maintainer credentials and a clean install to prove; that is recorded as outstanding rather than claimed. --- .github/workflows/release.yml | 61 ++++++++++++++ app/Package.swift | 8 +- app/Sources/OpenCodexWidget/main.swift | 17 +++- desktop/scripts/build-widget.sh | 4 +- .../040_widget_never_offered.md | 84 +++++++++++++++++++ scripts/test-layout/layout.json | 1 + tests/clients/desktop-widget-entry.test.ts | 43 ++++++++++ tests/fixtures/test-layout-expected.json | 1 + 8 files changed, 213 insertions(+), 6 deletions(-) create mode 100644 devlog/_plan/260920_desktop_app_stabilization/040_widget_never_offered.md create mode 100644 tests/clients/desktop-widget-entry.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd63e98a51e..d32f9f64155 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -236,10 +236,62 @@ jobs: if: runner.os != 'macOS' run: bun desktop/scripts/prepare-sidecar.ts --target ${{ matrix.sidecar-targets }} + # The signing certificate has to be in a keychain before the widget is signed, and the + # Tauri build step creates its own keychain only when it runs — which is after this. That + # ordering is why every release so far shipped an ad-hoc signed extension inside a + # Developer ID signed, notarized host: build-widget.sh saw no MACOS_SIGN_IDENTITY and took + # its unsigned branch, and the bundler does not re-sign anything under PlugIns. + - name: Import the release signing certificate + if: runner.os == 'macOS' + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: | + set -euo pipefail + if [ -z "${APPLE_CERTIFICATE}" ]; then + echo "No signing certificate is configured, so the extension will be ad-hoc signed." + echo "This build is usable for local validation and is not a release asset." + exit 0 + fi + keychain="$RUNNER_TEMP/opencodex-signing.keychain-db" + keychain_password="$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')" + certificate="$RUNNER_TEMP/opencodex-signing.p12" + printf '%s' "$APPLE_CERTIFICATE" | base64 --decode > "$certificate" + security create-keychain -p "$keychain_password" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$keychain_password" "$keychain" + security import "$certificate" -k "$keychain" -P "$APPLE_CERTIFICATE_PASSWORD" \ + -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: \ + -s -k "$keychain_password" "$keychain" > /dev/null + security list-keychain -d user -s "$keychain" $(security list-keychains -d user | tr -d '"') + shred -u "$certificate" 2>/dev/null || rm -P "$certificate" + echo "OPENCODEX_SIGNING_KEYCHAIN=$keychain" >> "$GITHUB_ENV" + - name: Build WidgetKit extension if: runner.os == 'macOS' + env: + MACOS_SIGN_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} run: bash desktop/scripts/build-widget.sh + - name: Verify the extension carries the release signature + if: runner.os == 'macOS' + env: + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + set -euo pipefail + appex=desktop/src-tauri/widget/OpenCodexWidget.appex + if [ -z "${APPLE_TEAM_ID}" ]; then + echo "No team configured; skipping the signature assertion for this non-release build." + exit 0 + fi + codesign --verify --strict --deep "$appex" + description="$(codesign -dvvv "$appex" 2>&1)" + echo "$description" + echo "$description" | grep -q "TeamIdentifier=$APPLE_TEAM_ID" + echo "$description" | grep -q "flags=.*runtime" + echo "$description" | grep -q "Timestamp=" + # Release signing is intentionally secret-gated. Developer ID, notarization, # and updater signatures require maintainer-owned credentials; builds without # those secrets remain useful for local validation but are not release assets. @@ -273,6 +325,15 @@ jobs: name: desktop-${{ matrix.target }} path: dist/release/ if-no-files-found: error + + # always(), because a keychain holding the release identity must not survive a failed job + # on a runner image that could be reused. + - name: Remove the signing keychain + if: always() && runner.os == 'macOS' + run: | + if [ -n "${OPENCODEX_SIGNING_KEYCHAIN:-}" ] && [ -f "${OPENCODEX_SIGNING_KEYCHAIN}" ]; then + security delete-keychain "${OPENCODEX_SIGNING_KEYCHAIN}" + fi retention-days: 7 attach-release: diff --git a/app/Package.swift b/app/Package.swift index 0e14e3bd5b8..48fa4ca74ae 100644 --- a/app/Package.swift +++ b/app/Package.swift @@ -15,10 +15,12 @@ let package = Package( dependencies: ["MenuBarCore"], path: "Sources/OpenCodexWidget", linkerSettings: [ - // Widget extensions must enter through NSExtensionMain or chronod tears down - // the process before the WidgetBundle connects. + // The entry stays the Swift one so main.swift runs and calls + // OpenCodexWidgetBundle.main(); that call is what connects the bundle to the + // extension host. Forcing the entry to _NSExtensionMain instead skips it, and + // NSExtensionMain then looks for an NSExtensionPrincipalClass a SwiftUI widget + // does not declare, so the extension registers and offers nothing. .linkedFramework("Foundation"), - .unsafeFlags(["-Xlinker", "-e", "-Xlinker", "_NSExtensionMain"]), ] ), // An executable rather than a .testTarget: Xcode Command Line Tools ships diff --git a/app/Sources/OpenCodexWidget/main.swift b/app/Sources/OpenCodexWidget/main.swift index 7eeda563867..d375cf2f08a 100644 --- a/app/Sources/OpenCodexWidget/main.swift +++ b/app/Sources/OpenCodexWidget/main.swift @@ -1,2 +1,15 @@ -// WidgetKit enters through _NSExtensionMain; this file keeps the executable target's -// source directory populated without adding a competing Swift-generated main. +import WidgetKit + +// This call is the widget. Without it the extension still launches and still registers with +// pluginkit — the Info.plist alone is enough for that — but no WidgetBundle is ever handed to the +// extension host, so the gallery has no configuration to offer and the widget silently does not +// appear. That is what shipped: the executable carried SnapshotProvider and the views, and the +// linker had been pointed straight at _NSExtensionMain, which looks for an +// NSExtensionPrincipalClass this bundle does not declare. +// +// Xcode spells this `@main` on the WidgetBundle. SwiftPM cannot: `@main` and a main.swift in the +// same target are mutually exclusive, and the target needs main.swift to be an executable here. +// `WidgetBundle.main()` is the same entry `@main` expands to. +if #available(macOS 14, *) { + OpenCodexWidgetBundle.main() +} diff --git a/desktop/scripts/build-widget.sh b/desktop/scripts/build-widget.sh index 59827dcea2a..fb24526e477 100755 --- a/desktop/scripts/build-widget.sh +++ b/desktop/scripts/build-widget.sh @@ -66,8 +66,10 @@ plutil -replace CFBundleShortVersionString -string "$version_core" "$output_dir/ plutil -replace CFBundleVersion -string "$version_core" "$output_dir/Contents/Info.plist" if [[ -n "${MACOS_SIGN_IDENTITY:-}" ]]; then + # Hardened runtime and a secure timestamp are both required for notarized Developer ID + # software, and an extension that lacks either fails notarization with the host around it. codesign --force --sign "$MACOS_SIGN_IDENTITY" --entitlements "$package_dir/Widget.entitlements" \ - --timestamp "$output_dir" + --options runtime --timestamp "$output_dir" else codesign --force --sign - --entitlements "$package_dir/Widget.entitlements" \ --timestamp=none "$output_dir" diff --git a/devlog/_plan/260920_desktop_app_stabilization/040_widget_never_offered.md b/devlog/_plan/260920_desktop_app_stabilization/040_widget_never_offered.md new file mode 100644 index 00000000000..1afa84f8370 --- /dev/null +++ b/devlog/_plan/260920_desktop_app_stabilization/040_widget_never_offered.md @@ -0,0 +1,84 @@ +# wp5 — the widget registered, and offered nothing + +## The symptom, and why it was not a signing problem + +The extension installs, `pluginkit` lists it beside the system widgets, and the gallery does not +show it. The obvious reading was signing: a locally built host is ad-hoc signed, so of course the +system will not adopt its extension. That reading was wrong, and following it would have produced +a signing change that fixed nothing, because the released build is signed and notarized and the +widget is missing there too. + +The actual defect is in the binary. `app/Package.swift` forced the executable's entry point: + +```swift +.unsafeFlags(["-Xlinker", "-e", "-Xlinker", "_NSExtensionMain"]), +``` + +and `app/Sources/OpenCodexWidget/main.swift` held nothing but a comment explaining that the entry +was handled by that flag. So `OpenCodexWidgetBundle` — which `Views.swift` defines correctly, +with a display name, a description and three supported families — was never referenced by +anything, and no code ever handed it to the extension host. + +Read off the shipped bundle: + +``` +LC_MAIN entryoff -> _NSExtensionMain +nm: SnapshotProvider present, OpenCodexWidgetBundle absent +Info.plist: NSExtensionPointIdentifier = com.apple.widgetkit-extension + NSExtensionPrincipalClass = (absent) +``` + +That combination is exactly consistent with the symptom. `pluginkit` registers from the +Info.plist, which is complete, so registration succeeds. `NSExtensionMain` then looks for an +`NSExtensionPrincipalClass`, which a SwiftUI widget does not declare because Xcode's `@main` on +the `WidgetBundle` is what connects it instead. Nothing errors. The gallery simply has no +configuration to offer. + +## The fix + +`main.swift` calls the entry that `@main` expands to, and the linker override is gone: + +```swift +if #available(macOS 14, *) { + OpenCodexWidgetBundle.main() +} +``` + +SwiftPM cannot use `@main` here — `@main` and a `main.swift` in the same target are mutually +exclusive — so the call is written out. After the change the entry is the Swift `_main` and the +bundle's symbols are in the binary. + +`tests/clients/desktop-widget-entry.test.ts` holds this: no linker entry override in the manifest, +`main.swift` importing WidgetKit and calling `OpenCodexWidgetBundle.main()`, and the bundle in +`Views.swift` actually carrying a widget. It was driven red by reinstating the override. + +## The signing defect underneath it + +Fixing the entry point does not make a *released* widget adoptable on someone else's machine, +because the release pipeline never signed it. + +`.github/workflows/release.yml` ran `build-widget.sh` with no `env:` block. `MACOS_SIGN_IDENTITY` +was set one step later, on the Tauri build, which never reads it. So the script took its +`codesign --force --sign -` branch every time, and the bundler does not re-sign anything under +`PlugIns/` — its nested-code walker handles `.framework`, `.xpc` and `.app`, not `.appex`. Every +release therefore shipped an ad-hoc extension with no team identifier inside a Developer ID host. + +Three changes: + +- The certificate is imported into a temporary keychain in a step **before** the widget build, and + the keychain is deleted in an `always()` step so it cannot outlive a failed job. +- The widget build receives `MACOS_SIGN_IDENTITY`, and `build-widget.sh` now signs with + `--options runtime` as well as `--timestamp`, both of which notarization requires. +- A step after the widget build asserts the result rather than printing it: strict verification, + the configured team identifier, the runtime flag, and a secure timestamp. Without a configured + team it says so and skips, so a fork's build still works and still cannot pretend to be signed. + +This half cannot be proven here. It needs maintainer-held credentials, and the proof is a +notarized artifact installed on a machine that did not build it, launched once, with the gallery +then checked. That is recorded as the outstanding verification rather than claimed. + +## Acceptance + +The entry-point half closes when the locally built widget appears in the gallery on this machine. +The signing half closes when a release build's extension reports the team identifier, the runtime +flag and a timestamp, and a clean install offers the widget. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index a8d43bc015c..c788bf4ada4 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -715,6 +715,7 @@ "desktop-3p.test.ts": "clients", "desktop-app-restart.test.ts": "clients", "desktop-profile.test.ts": "clients", + "desktop-widget-entry.test.ts": "clients", "desktop-remote-store.test.ts": "clients", "destination-policy-resolved.test.ts": "routing", "devin-adapter.test.ts": "providers", diff --git a/tests/clients/desktop-widget-entry.test.ts b/tests/clients/desktop-widget-entry.test.ts new file mode 100644 index 00000000000..3de073b8af2 --- /dev/null +++ b/tests/clients/desktop-widget-entry.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; + +/** + * The WidgetKit extension shipped registered and empty. + * + * `app/Package.swift` forced the executable's entry to `_NSExtensionMain` and `main.swift` was + * comments, so nothing ever called `OpenCodexWidgetBundle.main()`. The extension still appeared + * in `pluginkit` — that comes from the Info.plist alone — and `NSExtensionMain` then looked for + * an `NSExtensionPrincipalClass` a SwiftUI widget does not declare. The result is a widget that + * installs, registers, and is never offered in the gallery, with no error anywhere. + * + * Nothing in the build catches that: the bundle is well formed, the signature verifies, and the + * binary links WidgetKit. Only the entry point is wrong, which is why it is asserted here. + */ +const PACKAGE = repoPath("app/Package.swift"); +const ENTRY = repoPath("app/Sources/OpenCodexWidget/main.swift"); + +describe("widget extension entry point", () => { + test("the linker entry is not redirected away from the Swift main", () => { + const manifest = readFileSync(PACKAGE, "utf8"); + // Strip comments first, so the explanation of why this override is wrong does not read as + // the override itself. + const code = manifest.replace(/\/\/[^\n]*/g, ""); + expect(code).not.toContain("_NSExtensionMain"); + expect(code).not.toContain("-e"); + }); + + test("main.swift hands the bundle to WidgetKit", () => { + const entry = readFileSync(ENTRY, "utf8"); + const code = entry.replace(/\/\/[^\n]*/g, ""); + expect(code).toContain("import WidgetKit"); + expect(code).toMatch(/OpenCodexWidgetBundle\.main\(\)/); + }); + + test("the bundle it calls is the one the views define", () => { + const views = readFileSync(repoPath("app/Sources/OpenCodexWidget/Views.swift"), "utf8"); + expect(views).toMatch(/struct OpenCodexWidgetBundle: WidgetBundle/); + // A WidgetBundle with no body offers nothing, which is the same failure by another route. + expect(views).toMatch(/OpenCodexWidget\(\)/); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 53fc4cbab3d..b1299f29d92 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -546,6 +546,7 @@ "desktop-3p.test.ts": "clients", "desktop-app-restart.test.ts": "clients", "desktop-profile.test.ts": "clients", + "desktop-widget-entry.test.ts": "clients", "desktop-remote-store.test.ts": "clients", "destination-policy-resolved.test.ts": "routing", "devin-adapter.test.ts": "providers", From 609cd0faa389f51fc0918c049b67d3af18fc8cfb Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 21:41:52 +0900 Subject: [PATCH 02/16] feat(desktop): turn Start at Login on once, the first time an installation runs A menu bar app that is not running has no menu bar item, so leaving Start at Login off by default meant an installed app was simply absent after the next reboot, with nothing on screen to explain why. That is not a neutral default for an app whose main surface is the menu bar. first_run::apply_start_at_login_default runs once per installation, keyed on a marker in the app config directory. The marker is written before the login item is touched and is never removed, so a user who turns the setting back off keeps it off: the next launch sees the marker and does nothing. Writing afterwards instead would let a failed enable retry on every launch and eventually flip the setting back on under someone who had deliberately turned it off. Every failure is silent, because being unable to register a login item is not a reason to stop the app from starting. It runs before tray::install so the tray's Start at Login checkbox reads the state this leaves behind. The behaviour is not macOS-gated: the autostart plugin implements Linux autostart entries and the current-user Windows Run registration too. tests/clients/desktop-start-at-login-default.test.ts reads the ordering out of the source, because that ordering is the entire contract and is invisible from behaviour alone. --- desktop/src-tauri/src/first_run.rs | 41 ++++++++++++ desktop/src-tauri/src/lib.rs | 4 ++ scripts/test-layout/layout.json | 1 + .../desktop-start-at-login-default.test.ts | 62 +++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 5 files changed, 109 insertions(+) create mode 100644 desktop/src-tauri/src/first_run.rs create mode 100644 tests/clients/desktop-start-at-login-default.test.ts diff --git a/desktop/src-tauri/src/first_run.rs b/desktop/src-tauri/src/first_run.rs new file mode 100644 index 00000000000..e6a0417ed97 --- /dev/null +++ b/desktop/src-tauri/src/first_run.rs @@ -0,0 +1,41 @@ +use std::fs; +use tauri::{AppHandle, Manager}; +use tauri_plugin_autostart::ManagerExt; + +/// Marker file recording that the one-time Start at Login default has already been applied. +const MARKER: &str = "start-at-login-claimed"; + +/// Turn Start at Login on once, the first time this installation runs. +/// +/// A menu bar app that is not running has no menu bar item. Leaving autostart off by default +/// therefore means that after the next reboot an installed app is simply absent, with nothing on +/// screen to explain why — which is not a neutral default for an app whose main surface *is* the +/// menu bar. +/// +/// This runs exactly once per installation. The marker is written **before** the login item is +/// touched, and is never removed, so a user who turns Start at Login back off keeps it off: the +/// next launch sees the marker and does nothing. Writing afterwards instead would mean that a +/// failed or partial enable retries on every launch, and would eventually flip the setting back on +/// under a user who had deliberately turned it off in between. +/// +/// Every failure is silent on purpose. Not being able to write a marker or register a login item +/// is not a reason to stop the app from starting, and the user can still toggle the menu item. +pub fn apply_start_at_login_default(app: &AppHandle) { + let Ok(dir) = app.path().app_config_dir() else { + return; + }; + let marker = dir.join(MARKER); + if marker.exists() { + return; + } + if fs::create_dir_all(&dir).is_err() { + return; + } + if fs::write(&marker, b"").is_err() { + return; + } + if app.autolaunch().is_enabled().unwrap_or(false) { + return; + } + let _ = app.autolaunch().enable(); +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index aa688daca9f..5ee02473c7e 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ mod auth; mod discovery; +mod first_run; mod formatting; mod logging; mod proxy; @@ -101,6 +102,9 @@ pub fn run() { if tauri::async_runtime::block_on(proxy.is_alive()).is_ok() { let _ = window.eval(format!("window.location.replace({dashboard:?})")); } + // Before the tray, so its Start at Login checkbox reads the state this leaves behind + // rather than the state from before first run. + first_run::apply_start_at_login_default(app.handle()); tray::install(app.handle(), proxy)?; if !cfg!(debug_assertions) { updater::start_background_checks(app.handle().clone()); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index c788bf4ada4..d928c6dd1fd 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -717,6 +717,7 @@ "desktop-profile.test.ts": "clients", "desktop-widget-entry.test.ts": "clients", "desktop-remote-store.test.ts": "clients", + "desktop-start-at-login-default.test.ts": "clients", "destination-policy-resolved.test.ts": "routing", "devin-adapter.test.ts": "providers", "devin-cli-authmode-migration.test.ts": "providers", diff --git a/tests/clients/desktop-start-at-login-default.test.ts b/tests/clients/desktop-start-at-login-default.test.ts new file mode 100644 index 00000000000..abf3472b79c --- /dev/null +++ b/tests/clients/desktop-start-at-login-default.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; + +/** + * A menu bar app that is not running has no menu bar item, so leaving Start at Login off by + * default means an installed app is simply gone after a reboot. The desktop shell enables it once + * per installation. + * + * The ordering is the whole contract and it is not visible from behaviour alone, so it is read out + * of the source: the marker is written before the login item is touched, the enable is guarded by + * the current state, an existing marker returns early, and the tray is built afterwards so its + * checkbox reflects the result. Get the write order backwards and a user who turns the setting off + * has it turned back on for them on the next launch. + */ +const FIRST_RUN = repoPath("desktop/src-tauri/src/first_run.rs"); +const LIB = repoPath("desktop/src-tauri/src/lib.rs"); + +function code(path: string): string { + return readFileSync(path, "utf8").replace(/\/\/[^\n]*/g, ""); +} + +describe("start at login default", () => { + const firstRun = code(FIRST_RUN); + + test("an existing marker returns before anything is changed", () => { + const early = firstRun.indexOf("marker.exists()"); + const enable = firstRun.indexOf("autolaunch().enable()"); + expect(early).toBeGreaterThan(-1); + expect(enable).toBeGreaterThan(-1); + expect(early).toBeLessThan(enable); + expect(firstRun.slice(early, enable)).toContain("return"); + }); + + test("the marker is written before the login item is registered", () => { + const write = firstRun.indexOf("fs::write(&marker"); + const enable = firstRun.indexOf("autolaunch().enable()"); + expect(write).toBeGreaterThan(-1); + expect(write).toBeLessThan(enable); + }); + + test("enabling is guarded by the current autolaunch state", () => { + const guard = firstRun.indexOf("autolaunch().is_enabled()"); + const enable = firstRun.indexOf("autolaunch().enable()"); + expect(guard).toBeGreaterThan(-1); + expect(guard).toBeLessThan(enable); + }); + + test("nothing ever deletes the marker", () => { + expect(firstRun).not.toContain("remove_file"); + expect(firstRun).not.toContain("remove_dir"); + }); + + test("it runs before the tray is installed", () => { + const lib = code(LIB); + const applied = lib.indexOf("first_run::apply_start_at_login_default"); + const tray = lib.indexOf("tray::install"); + expect(applied).toBeGreaterThan(-1); + expect(tray).toBeGreaterThan(-1); + expect(applied).toBeLessThan(tray); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index b1299f29d92..11be2c22de7 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -548,6 +548,7 @@ "desktop-profile.test.ts": "clients", "desktop-widget-entry.test.ts": "clients", "desktop-remote-store.test.ts": "clients", + "desktop-start-at-login-default.test.ts": "clients", "destination-policy-resolved.test.ts": "routing", "devin-adapter.test.ts": "providers", "devin-effort-ladder.test.ts": "providers", From b75bf3fada5bd300ef3045c7e81e61e3ff022c5e Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 21:45:33 +0900 Subject: [PATCH 03/16] docs(devlog): record the verified widget entry and login-item evidence --- .../040_widget_never_offered.md | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/devlog/_plan/260920_desktop_app_stabilization/040_widget_never_offered.md b/devlog/_plan/260920_desktop_app_stabilization/040_widget_never_offered.md index 1afa84f8370..d7986e34712 100644 --- a/devlog/_plan/260920_desktop_app_stabilization/040_widget_never_offered.md +++ b/devlog/_plan/260920_desktop_app_stabilization/040_widget_never_offered.md @@ -77,8 +77,41 @@ This half cannot be proven here. It needs maintainer-held credentials, and the p notarized artifact installed on a machine that did not build it, launched once, with the gallery then checked. That is recorded as the outstanding verification rather than claimed. +## The menu bar had the same shape of problem + +Start at Login was purely opt-in. Nothing enabled it on first run, so an install left the user +with a menu bar item only for as long as the app happened to be running — and a menu bar app that +is not running has no menu bar item. After a reboot the app was simply absent. + +`first_run::apply_start_at_login_default` enables it once per installation, keyed on a marker in +the app config directory, and runs before `tray::install` so the tray checkbox reads the state it +leaves behind. The marker is written before the login item is touched and is never removed, so a +user who turns the setting off keeps it off. Writing afterwards would let a failed enable retry +every launch and eventually flip the setting back under someone who had deliberately disabled it. + +The marker distinguishes a fresh install from a user who opted out, but it cannot distinguish +either from an install that predates the marker. The desktop shell and the widget both landed the +same day this was written and no release tag contains them, so there is no such population; if +that changes, this needs a migration rather than a marker. + +## What was verified here + +Rebuilt, installed to `/Applications`, and launched: + +``` +LC_MAIN entryoff 5656 -> _main (was _NSExtensionMain) +nm: _$s15OpenCodexWidget0abC6BundleV4bodyQrvpQOMQ present +pluginkit: com.opencodex.desktop.widget re-registered, parent bundle resolved +~/Library/Application Support/com.opencodex.desktop/start-at-login-claimed written +~/Library/LaunchAgents/OpenCodex.plist created +``` + +So the entry point is connected and the login item is registered, both on a real install rather +than in a test double. + ## Acceptance The entry-point half closes when the locally built widget appears in the gallery on this machine. The signing half closes when a release build's extension reports the team identifier, the runtime -flag and a timestamp, and a clean install offers the widget. +flag and a timestamp, and a clean install on a machine that did not build it offers the widget. +That second half needs maintainer-held credentials and is recorded as outstanding. From d52ad80a4dafad70391147e9411e4229c5c35940 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 22:02:58 +0900 Subject: [PATCH 04/16] fix(desktop): declare the widget's platform and display name Every widget macOS ships declares CFBundleSupportedPlatforms and CFBundleDisplayName. Ours declared neither, because Xcode writes both and a SwiftPM-assembled appex has no build system to write them. An extension bundle that does not say which platform it supports gives the system no reason to consider it on this one. Checked against the widgets on a macOS 27 install: Shortcuts, Tips and Reminders all carry CFBundleSupportedPlatforms = [MacOSX], and the two this bundle lacked are the only structural Info.plist differences between them and this one. --- app/Widget-Info.plist | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/Widget-Info.plist b/app/Widget-Info.plist index 568e5fce750..df499104290 100644 --- a/app/Widget-Info.plist +++ b/app/Widget-Info.plist @@ -7,6 +7,15 @@ CFBundleIdentifiercom.opencodex.desktop.widget CFBundleInfoDictionaryVersion6.0 CFBundleNameOpenCodex + CFBundleDisplayNameOpenCodex + + CFBundleSupportedPlatforms + MacOSX CFBundlePackageTypeXPC! CFBundleShortVersionString0.0.0 CFBundleVersion0.0.0 From 0bbdd7aee0d205664f905ab3240fc992ff1e295d Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 22:22:09 +0900 Subject: [PATCH 05/16] fix(desktop): give the widget both halves of an Xcode app-extension entry A widget extension needs @main on the WidgetBundle and the _NSExtensionMain linker entry, and either one alone produces a widget that is never offered in the gallery. Without @main nothing references the bundle, the linker drops it, and the extension still registers with pluginkit because the Info.plist alone is enough - so the gallery has no configuration to offer and nothing anywhere reports a problem. That is what shipped. Without the entry override the Swift main runs instead of the extension host's bootstrap and ExtensionFoundation traps in _EXRunningExtension._shared. Measured on a real install: EXC_BREAKPOINT on every launch, one crash report per attempt, and chronod logging "query failed - will try lazy reload later" while the gallery stayed empty. With both in place the crash reports stop and chronod processes the extension normally. The deployment target moves to macOS 14, which drops the now-redundant per-declaration availability guards and puts the binary's minos at 14.0, matching the working widgets on the machine this was measured on. Also recorded: the app-sandbox entitlement is not optional. Removing it does not fail at launch - pkd refuses to register the bundle at all, saying "plug-ins must be sandboxed", which is why the host writes its snapshot into the extension's own container. --- app/Package.swift | 26 +++++-- app/Sources/OpenCodexWidget/Provider.swift | 2 - app/Sources/OpenCodexWidget/Views.swift | 4 +- app/Sources/OpenCodexWidget/main.swift | 15 ---- .../040_widget_never_offered.md | 76 +++++++++++++++---- tests/clients/desktop-widget-entry.test.ts | 61 ++++++++------- 6 files changed, 116 insertions(+), 68 deletions(-) delete mode 100644 app/Sources/OpenCodexWidget/main.swift diff --git a/app/Package.swift b/app/Package.swift index 48fa4ca74ae..c334aa0cfc2 100644 --- a/app/Package.swift +++ b/app/Package.swift @@ -3,7 +3,7 @@ import PackageDescription let package = Package( name: "OpenCodexWidget", - platforms: [.macOS(.v13)], + platforms: [.macOS(.v14)], products: [ .executable(name: "OpenCodexWidget", targets: ["OpenCodexWidget"]), .executable(name: "MenuBarCoreTests", targets: ["MenuBarCoreTests"]), @@ -15,12 +15,26 @@ let package = Package( dependencies: ["MenuBarCore"], path: "Sources/OpenCodexWidget", linkerSettings: [ - // The entry stays the Swift one so main.swift runs and calls - // OpenCodexWidgetBundle.main(); that call is what connects the bundle to the - // extension host. Forcing the entry to _NSExtensionMain instead skips it, and - // NSExtensionMain then looks for an NSExtensionPrincipalClass a SwiftUI widget - // does not declare, so the extension registers and offers nothing. + // A widget extension needs both halves of what Xcode does for an app-extension + // target, and each half is useless alone. This flag is one of them; `@main` on + // OpenCodexWidgetBundle is the other. + // + // With the entry override and no `@main`, nothing references the WidgetBundle, the + // linker drops it, and the extension registers with pluginkit — the Info.plist is + // enough for that — while the gallery has no configuration to offer. That is what + // shipped, and it failed silently. + // + // With `@main` and no entry override, the Swift main runs instead of + // NSExtensionMain, and ExtensionFoundation traps inside + // _EXRunningExtension._shared while bootstrapping. Measured: EXC_BREAKPOINT on + // every launch, chronod logging "query failed - will try lazy reload later", and + // a crash report per attempt. + // + // Both together is the shape that works and the shape Xcode produces: the entry + // is NSExtensionMain, and the bundle stays in the binary because `@main` refers + // to it. .linkedFramework("Foundation"), + .unsafeFlags(["-Xlinker", "-e", "-Xlinker", "_NSExtensionMain"]), ] ), // An executable rather than a .testTarget: Xcode Command Line Tools ships diff --git a/app/Sources/OpenCodexWidget/Provider.swift b/app/Sources/OpenCodexWidget/Provider.swift index 55d983bf5cf..e23d55f6042 100644 --- a/app/Sources/OpenCodexWidget/Provider.swift +++ b/app/Sources/OpenCodexWidget/Provider.swift @@ -2,7 +2,6 @@ import Foundation import WidgetKit import MenuBarCore -@available(macOS 14, *) public struct SnapshotEntry: TimelineEntry { public let date: Date public let snapshot: WidgetSnapshot? @@ -10,7 +9,6 @@ public struct SnapshotEntry: TimelineEntry { public let stale: Bool } -@available(macOS 14, *) public struct SnapshotProvider: TimelineProvider { private let reader = SnapshotReader() diff --git a/app/Sources/OpenCodexWidget/Views.swift b/app/Sources/OpenCodexWidget/Views.swift index edc91171f44..7b8d6fc346c 100644 --- a/app/Sources/OpenCodexWidget/Views.swift +++ b/app/Sources/OpenCodexWidget/Views.swift @@ -2,7 +2,6 @@ import SwiftUI import WidgetKit import MenuBarCore -@available(macOS 14, *) struct OpenCodexWidgetView: View { let entry: SnapshotEntry @Environment(\.widgetFamily) private var family @@ -301,14 +300,13 @@ struct OpenCodexWidgetView: View { } } -@available(macOS 14, *) +@main struct OpenCodexWidgetBundle: WidgetBundle { var body: some Widget { OpenCodexWidget() } } -@available(macOS 14, *) struct OpenCodexWidget: Widget { let kind = "OpenCodexWidget" diff --git a/app/Sources/OpenCodexWidget/main.swift b/app/Sources/OpenCodexWidget/main.swift deleted file mode 100644 index d375cf2f08a..00000000000 --- a/app/Sources/OpenCodexWidget/main.swift +++ /dev/null @@ -1,15 +0,0 @@ -import WidgetKit - -// This call is the widget. Without it the extension still launches and still registers with -// pluginkit — the Info.plist alone is enough for that — but no WidgetBundle is ever handed to the -// extension host, so the gallery has no configuration to offer and the widget silently does not -// appear. That is what shipped: the executable carried SnapshotProvider and the views, and the -// linker had been pointed straight at _NSExtensionMain, which looks for an -// NSExtensionPrincipalClass this bundle does not declare. -// -// Xcode spells this `@main` on the WidgetBundle. SwiftPM cannot: `@main` and a main.swift in the -// same target are mutually exclusive, and the target needs main.swift to be an executable here. -// `WidgetBundle.main()` is the same entry `@main` expands to. -if #available(macOS 14, *) { - OpenCodexWidgetBundle.main() -} diff --git a/devlog/_plan/260920_desktop_app_stabilization/040_widget_never_offered.md b/devlog/_plan/260920_desktop_app_stabilization/040_widget_never_offered.md index d7986e34712..41ffe918e7e 100644 --- a/devlog/_plan/260920_desktop_app_stabilization/040_widget_never_offered.md +++ b/devlog/_plan/260920_desktop_app_stabilization/040_widget_never_offered.md @@ -34,34 +34,78 @@ Info.plist, which is complete, so registration succeeds. `NSExtensionMain` then the `WidgetBundle` is what connects it instead. Nothing errors. The gallery simply has no configuration to offer. -## The fix +## The fix, and the wrong turn on the way to it -`main.swift` calls the entry that `@main` expands to, and the linker override is gone: +The first attempt was to delete the linker override and call the bundle from `main.swift`. That +made the bundle's symbols appear in the binary and did not work either — it replaced a silent +failure with a loud one. Every launch died: -```swift -if #available(macOS 14, *) { - OpenCodexWidgetBundle.main() -} ``` +EXC_BREAKPOINT (SIGTRAP) + ExtensionFoundation closure #1 in ... _EXRunningExtension._shared + ExtensionFoundation MainActor.assumeIsolated + ExtensionFoundation _EXExtension.bootstrap(with:) + WidgetKit + OpenCodexWidget main +chronod: [com.opencodex.desktop::com.opencodex.desktop.widget] query failed - will try lazy + reload later +``` + +Seventeen crash reports accumulated in `~/Library/Logs/DiagnosticReports` while the gallery stayed +empty, because `chronod` asks the extension for its descriptors and the extension never survives +long enough to answer. + +**The extension needs both halves of what Xcode does, and each is useless alone.** `@main` on the +`WidgetBundle` is what keeps it in the binary; `-e _NSExtensionMain` is what makes the process +start as an extension rather than as a program. The original code had the second without the +first, this branch briefly had the first without the second, and only both together produce a +widget the system will talk to. With both in place the crash reports stop at zero and `chronod` +processes the extension normally. + +`tests/clients/desktop-widget-entry.test.ts` asserts both, plus that no `main.swift` has come back +to compete with `@main`, and that the bundle carries a widget with a display name rather than an +empty body — the same failure by a third route. -SwiftPM cannot use `@main` here — `@main` and a `main.swift` in the same target are mutually -exclusive — so the call is written out. After the change the entry is the Swift `_main` and the -bundle's symbols are in the binary. +The deployment target moved to macOS 14 at the same time, which drops the per-declaration +`@available(macOS 14, *)` guards and puts the binary's `minos` at 14.0, matching every working +widget on the machine this was measured on. + +## The sandbox is not optional + +While narrowing this down, the extension was rebuilt without `com.apple.security.app-sandbox` to +test whether the sandbox was implicated. It is required, and the system says so plainly: + +``` +pkd: Ignoring mis-configured plugin at [.../OpenCodexWidget.appex]: plug-ins must be sandboxed +``` -`tests/clients/desktop-widget-entry.test.ts` holds this: no linker entry override in the manifest, -`main.swift` importing WidgetKit and calling `OpenCodexWidgetBundle.main()`, and the bundle in -`Views.swift` actually carrying a widget. It was driven red by reinstating the override. +An unsandboxed extension is not rejected at launch — it is never registered at all, so it vanishes +from `pluginkit` entirely. That also settles the snapshot path: the host writes into +`~/Library/Containers/com.opencodex.desktop.widget/Data/...` precisely because the extension reads +its own container, and that arrangement has to stay. ## The signing defect underneath it Fixing the entry point does not make a *released* widget adoptable on someone else's machine, -because the release pipeline never signed it. +because the release pipeline would not sign it. `.github/workflows/release.yml` ran `build-widget.sh` with no `env:` block. `MACOS_SIGN_IDENTITY` was set one step later, on the Tauri build, which never reads it. So the script took its -`codesign --force --sign -` branch every time, and the bundler does not re-sign anything under -`PlugIns/` — its nested-code walker handles `.framework`, `.xpc` and `.app`, not `.appex`. Every -release therefore shipped an ad-hoc extension with no team identifier inside a Developer ID host. +`codesign --force --sign -` branch, and the bundler does not re-sign anything under `PlugIns/` — +its nested-code walker handles `.framework`, `.xpc` and `.app`, not `.appex`. + +**This has not harmed a release yet, and the reason matters.** No release has ever published a +macOS application: the last three carry no desktop assets at all, and the signing secrets did not +exist until after the most recent one was cut. `MACOS_SIGN_IDENTITY` reads a secret that was not +there, so the real-signing branch has never executed and the Developer ID path in the Tauri step +has never executed either. The bug is a mine rather than a crater — the next release is the first +one that would step on it. Saying otherwise would be inventing a history this repository does not +have. + +**Signing one path is also not enough.** A bundler that did not place a file does not sign it, and +picking binaries by file extension misses the ones that have none. The durable form of the check +is to find Mach-O files by their magic bytes and require every one of them to carry the release +identity, rather than naming the paths that are expected to exist. Three changes: diff --git a/tests/clients/desktop-widget-entry.test.ts b/tests/clients/desktop-widget-entry.test.ts index 3de073b8af2..183121b3dd9 100644 --- a/tests/clients/desktop-widget-entry.test.ts +++ b/tests/clients/desktop-widget-entry.test.ts @@ -1,43 +1,52 @@ import { describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { repoPath } from "../helpers/repo-root"; /** - * The WidgetKit extension shipped registered and empty. + * The WidgetKit extension needs two things that look unrelated, and either one alone produces a + * widget that is never offered in the gallery with nothing in the build to say so. * - * `app/Package.swift` forced the executable's entry to `_NSExtensionMain` and `main.swift` was - * comments, so nothing ever called `OpenCodexWidgetBundle.main()`. The extension still appeared - * in `pluginkit` — that comes from the Info.plist alone — and `NSExtensionMain` then looked for - * an `NSExtensionPrincipalClass` a SwiftUI widget does not declare. The result is a widget that - * installs, registers, and is never offered in the gallery, with no error anywhere. + * Without `@main` on the WidgetBundle, nothing references it, the linker drops it, and the + * extension still registers with `pluginkit` because the Info.plist alone is enough. The gallery + * then has no configuration to offer. That is what shipped. * - * Nothing in the build catches that: the bundle is well formed, the signature verifies, and the - * binary links WidgetKit. Only the entry point is wrong, which is why it is asserted here. + * Without the `_NSExtensionMain` linker entry, the Swift main runs instead of the extension host's + * bootstrap and ExtensionFoundation traps in `_EXRunningExtension._shared` — EXC_BREAKPOINT on + * every launch, one crash report per attempt, and `chronod` logging + * "query failed - will try lazy reload later". + * + * Both were measured on a real install. Neither is visible to a build that only checks the bundle + * is well formed and the signature verifies, which is why they are asserted from the source. */ const PACKAGE = repoPath("app/Package.swift"); -const ENTRY = repoPath("app/Sources/OpenCodexWidget/main.swift"); +const VIEWS = repoPath("app/Sources/OpenCodexWidget/Views.swift"); + +function stripComments(source: string): string { + return source.replace(/\/\/[^\n]*/g, ""); +} describe("widget extension entry point", () => { - test("the linker entry is not redirected away from the Swift main", () => { - const manifest = readFileSync(PACKAGE, "utf8"); - // Strip comments first, so the explanation of why this override is wrong does not read as - // the override itself. - const code = manifest.replace(/\/\/[^\n]*/g, ""); - expect(code).not.toContain("_NSExtensionMain"); - expect(code).not.toContain("-e"); + test("the linker entry is NSExtensionMain, as it is for an Xcode app-extension target", () => { + const code = stripComments(readFileSync(PACKAGE, "utf8")); + expect(code).toContain("_NSExtensionMain"); + }); + + test("the widget bundle is the Swift entry, so the linker keeps it", () => { + const views = readFileSync(VIEWS, "utf8"); + expect(views).toMatch(/@main\s*\n\s*struct OpenCodexWidgetBundle: WidgetBundle/); }); - test("main.swift hands the bundle to WidgetKit", () => { - const entry = readFileSync(ENTRY, "utf8"); - const code = entry.replace(/\/\/[^\n]*/g, ""); - expect(code).toContain("import WidgetKit"); - expect(code).toMatch(/OpenCodexWidgetBundle\.main\(\)/); + test("there is no main.swift competing with @main", () => { + // SwiftPM refuses @main in a target that also has a main.swift, and the refusal is a build + // error rather than a silent fallback - but the file existing at all means someone moved the + // entry back out of the bundle. + expect(existsSync(repoPath("app/Sources/OpenCodexWidget/main.swift"))).toBe(false); }); - test("the bundle it calls is the one the views define", () => { - const views = readFileSync(repoPath("app/Sources/OpenCodexWidget/Views.swift"), "utf8"); - expect(views).toMatch(/struct OpenCodexWidgetBundle: WidgetBundle/); - // A WidgetBundle with no body offers nothing, which is the same failure by another route. + test("the bundle actually carries a widget", () => { + const views = readFileSync(VIEWS, "utf8"); + // A WidgetBundle with an empty body offers nothing, which is the same failure by another route. expect(views).toMatch(/OpenCodexWidget\(\)/); + expect(views).toMatch(/configurationDisplayName/); }); }); From ae1555f400a2e242a43e9ed0942ddc2e8cce5c5c Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 22:24:24 +0900 Subject: [PATCH 06/16] ci(release): require every Mach-O in the bundle to carry the release identity Signing the extension by name is not enough. A bundler signs what it placed and nothing else, and the binaries that get missed in practice are the ones with no extension to filter on - so a check that names paths will keep passing while an unsigned executable rides along inside the bundle and notarization rejects the whole submission. This walks the built app, identifies executables by their Mach-O magic bytes rather than by path or suffix, and fails the job naming any file that does not carry the configured team identifier. Without a configured team it says so and skips, so a fork still builds and still cannot pretend to be signed. --- .github/workflows/release.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d32f9f64155..44617af20df 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -292,6 +292,36 @@ jobs: echo "$description" | grep -q "flags=.*runtime" echo "$description" | grep -q "Timestamp=" + - name: Verify every Mach-O in the bundle carries the release identity + if: runner.os == 'macOS' + env: + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + set -euo pipefail + if [ -z "${APPLE_TEAM_ID}" ]; then + echo "No team configured; skipping the bundle-wide assertion for this non-release build." + exit 0 + fi + # Find executables by their magic bytes rather than by path or extension. A bundler + # signs what it placed; anything copied in afterwards is invisible to it, and the + # binaries that get missed are usually the ones with no extension to filter on. + bad=0 + while IFS= read -r app; do + echo "checking $app" + while IFS= read -r -d '' file; do + magic="$(head -c 4 "$file" | xxd -p)" + case "$magic" in + cffaedfe|cefaedfe|cafebabe|bebafeca) ;; + *) continue ;; + esac + if ! codesign -dvvv "$file" 2>&1 | grep -q "TeamIdentifier=$APPLE_TEAM_ID"; then + echo "::error::$file is not signed with the release identity" + bad=1 + fi + done < <(find "$app" -type f -print0) + done < <(find desktop/src-tauri/target -type d -name '*.app' -maxdepth 6) + exit "$bad" + # Release signing is intentionally secret-gated. Developer ID, notarization, # and updater signatures require maintainer-owned credentials; builds without # those secrets remain useful for local validation but are not release assets. From ad9e3119cf1a8e18768c09c0f1854696c7dab627 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 22:32:46 +0900 Subject: [PATCH 07/16] docs(devlog): plan the stack landing and record the two dev repairs it carries --- .../050_landing.md | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 devlog/_plan/260920_desktop_app_stabilization/050_landing.md diff --git a/devlog/_plan/260920_desktop_app_stabilization/050_landing.md b/devlog/_plan/260920_desktop_app_stabilization/050_landing.md new file mode 100644 index 00000000000..3a432674cd8 --- /dev/null +++ b/devlog/_plan/260920_desktop_app_stabilization/050_landing.md @@ -0,0 +1,38 @@ +# wp5 — landing the stack + +## Shape + +Four pull requests, each based on the one below it, all ultimately targeting `dev`: + +| PR | branch | what it carries | +|---|---|---| +| #5327 | `codex/260920-app-stabilization` | release profile, stale-dist report, `build:local`, the lockfile and test-layout repairs | +| #5328 | `codex/260920-claude-desktop-mode-visibility` | the first-party reachability message | +| #5329 | `codex/260920-app-icons` | one SVG source, the generator, the renderer-free CI guard | +| #5339 | `codex/260920-widget-entry` | the widget entry point, the login-item default, release signing | + +They merge bottom-up. After each one lands, the next is retargeted to `dev` and its exact head is +read again, because a squash merge rewrites the parent and the child's base disappears. + +## Two repairs in here are not ours + +`dev` was already red when this stack was cut, in two independent places, and both were fixed +here because every branch cut from `dev` inherits them. + +`tests/providers/stepfun-provider.test.ts` landed with no entry in either inventory and no regex +seed that resolves its name, so the membership oracle failed on `dev` and on everything branched +from it. Registering it under `providers` restores the gate for everyone. + +`macos widget + bundle` failed with *A public key has been found, but no private key*. The job is +an unsigned build by design, so the key is correctly absent — but the committed config sets +`bundle.createUpdaterArtifacts` and `plugins.updater.pubkey`, so `tauri build` writes the updater +archive and then refuses to finish. Selecting bundle targets does not avoid it because the flag is +config rather than a target; the invocation turns the artifact off instead, leaving nothing to sign +rather than something signed badly. + +## What closes this + +Each merge reads the exact head's check runs rather than a rollup, distinguishes a job the event +requested from one it skipped, and treats a missing, skipped, or cancelled job as not a pass. The +last merge is followed by reading `dev`'s own push run, because five of the eight defects found in +this unit were invisible until two changes met. From 24f83417c32023d7e685b59ec06b5d23105272e7 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 23:01:52 +0900 Subject: [PATCH 08/16] fix(release): repair the artifact upload and make the signing checks mean something Review caught four defects in the previous revision of this workflow change, one of them mine and serious. retention-days had been displaced out of the desktop upload action and into the cleanup step's shell body, where it is not a setting but a command that does not exist. Every macOS release job would have failed with "retention-days:: command not found", and the artifact would have taken the default retention on the way there. It is back in the action's with block. The keychain path is now recorded before the keychain is created, so a failure part way through still leaves the cleanup step something to delete, and the decoded certificate is removed by a trap rather than by a line a failure can skip past. The bundle-wide Mach-O sweep ran before Tauri produced a bundle, so it inspected nothing and passed. It now runs after the assets are collected and fails when it finds no app bundle or no Mach-O at all - a sweep that examined nothing is exactly the outcome it exists to prevent. All three signing checks were fail-open: with the secrets absent they printed a note and exited zero, while the unsigned artifact was still uploaded and attached. They now refuse a non-dry-run release outright and keep the permissive path only for a local or dry run. The step comment also claimed prior releases had shipped an ad-hoc extension inside a notarized host. No release has published a macOS application, so the comment says what is true instead: a defect that had not yet reached anyone. --- .github/workflows/release.yml | 105 ++++++++++++++++++++++------------ 1 file changed, 68 insertions(+), 37 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 44617af20df..52ed7814dd5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -237,25 +237,37 @@ jobs: run: bun desktop/scripts/prepare-sidecar.ts --target ${{ matrix.sidecar-targets }} # The signing certificate has to be in a keychain before the widget is signed, and the - # Tauri build step creates its own keychain only when it runs — which is after this. That - # ordering is why every release so far shipped an ad-hoc signed extension inside a - # Developer ID signed, notarized host: build-widget.sh saw no MACOS_SIGN_IDENTITY and took - # its unsigned branch, and the bundler does not re-sign anything under PlugIns. + # Tauri build step creates its own keychain only when it runs — which is after this. Until + # this step existed, build-widget.sh saw no MACOS_SIGN_IDENTITY and took its unsigned + # branch, and the bundler does not re-sign anything under PlugIns, so the extension would + # have gone out ad-hoc inside a Developer ID host. No release has published a macOS + # application yet, so this is a defect that had not reached anyone rather than one that had. - name: Import the release signing certificate if: runner.os == 'macOS' env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + DRY_RUN: ${{ inputs.dry-run }} run: | set -euo pipefail if [ -z "${APPLE_CERTIFICATE}" ]; then + if [ "${DRY_RUN}" != "true" ]; then + echo "::error::A real release needs the signing certificate; refusing to build an" + echo "::error::unsigned macOS artifact that would still be uploaded and attached." + exit 1 + fi echo "No signing certificate is configured, so the extension will be ad-hoc signed." echo "This build is usable for local validation and is not a release asset." exit 0 fi keychain="$RUNNER_TEMP/opencodex-signing.keychain-db" + # Recorded before anything is created, so the cleanup step can still find a keychain + # that a failure left half-built. + echo "OPENCODEX_SIGNING_KEYCHAIN=$keychain" >> "$GITHUB_ENV" keychain_password="$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')" certificate="$RUNNER_TEMP/opencodex-signing.p12" + # The decoded certificate must not outlive this step even when a later command fails. + trap 'shred -u "$certificate" 2>/dev/null || rm -Pf "$certificate" 2>/dev/null || true' EXIT printf '%s' "$APPLE_CERTIFICATE" | base64 --decode > "$certificate" security create-keychain -p "$keychain_password" "$keychain" security set-keychain-settings -lut 21600 "$keychain" @@ -264,9 +276,8 @@ jobs: -T /usr/bin/codesign security set-key-partition-list -S apple-tool:,apple:,codesign: \ -s -k "$keychain_password" "$keychain" > /dev/null + # shellcheck disable=SC2046 # the keychain list is intentionally word-split into arguments security list-keychain -d user -s "$keychain" $(security list-keychains -d user | tr -d '"') - shred -u "$certificate" 2>/dev/null || rm -P "$certificate" - echo "OPENCODEX_SIGNING_KEYCHAIN=$keychain" >> "$GITHUB_ENV" - name: Build WidgetKit extension if: runner.os == 'macOS' @@ -278,10 +289,15 @@ jobs: if: runner.os == 'macOS' env: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + DRY_RUN: ${{ inputs.dry-run }} run: | set -euo pipefail appex=desktop/src-tauri/widget/OpenCodexWidget.appex if [ -z "${APPLE_TEAM_ID}" ]; then + if [ "${DRY_RUN}" != "true" ]; then + echo "::error::A real release cannot assert its own signature without APPLE_TEAM_ID." + exit 1 + fi echo "No team configured; skipping the signature assertion for this non-release build." exit 0 fi @@ -292,36 +308,6 @@ jobs: echo "$description" | grep -q "flags=.*runtime" echo "$description" | grep -q "Timestamp=" - - name: Verify every Mach-O in the bundle carries the release identity - if: runner.os == 'macOS' - env: - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - run: | - set -euo pipefail - if [ -z "${APPLE_TEAM_ID}" ]; then - echo "No team configured; skipping the bundle-wide assertion for this non-release build." - exit 0 - fi - # Find executables by their magic bytes rather than by path or extension. A bundler - # signs what it placed; anything copied in afterwards is invisible to it, and the - # binaries that get missed are usually the ones with no extension to filter on. - bad=0 - while IFS= read -r app; do - echo "checking $app" - while IFS= read -r -d '' file; do - magic="$(head -c 4 "$file" | xxd -p)" - case "$magic" in - cffaedfe|cefaedfe|cafebabe|bebafeca) ;; - *) continue ;; - esac - if ! codesign -dvvv "$file" 2>&1 | grep -q "TeamIdentifier=$APPLE_TEAM_ID"; then - echo "::error::$file is not signed with the release identity" - bad=1 - fi - done < <(find "$app" -type f -print0) - done < <(find desktop/src-tauri/target -type d -name '*.app' -maxdepth 6) - exit "$bad" - # Release signing is intentionally secret-gated. Developer ID, notarization, # and updater signatures require maintainer-owned credentials; builds without # those secrets remain useful for local validation but are not release assets. @@ -349,12 +335,58 @@ jobs: --target "$DESKTOP_TARGET" \ --out dist/release + # After the bundle exists, not before: a sweep that runs first passes by finding nothing. + - name: Verify every Mach-O in the bundle carries the release identity + if: runner.os == 'macOS' + env: + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + DRY_RUN: ${{ inputs.dry-run }} + run: | + set -euo pipefail + if [ -z "${APPLE_TEAM_ID}" ]; then + if [ "${DRY_RUN}" != "true" ]; then + echo "::error::A real release cannot verify its bundle without APPLE_TEAM_ID." + exit 1 + fi + echo "No team configured; skipping the bundle-wide assertion for this local build." + exit 0 + fi + # Executables are found by their magic bytes rather than by path or extension. A bundler + # signs what it placed; anything copied in afterwards is invisible to it, and the + # binaries that get missed are the ones with no extension to filter on. + apps=0 + machos=0 + bad=0 + while IFS= read -r app; do + apps=$((apps + 1)) + echo "checking $app" + while IFS= read -r -d '' file; do + case "$(head -c 4 "$file" | xxd -p)" in + cffaedfe|cefaedfe|cafebabe|bebafeca) ;; + *) continue ;; + esac + machos=$((machos + 1)) + if ! codesign -dvvv "$file" 2>&1 | grep -q "TeamIdentifier=$APPLE_TEAM_ID"; then + echo "::error::$file is not signed with the release identity" + bad=1 + fi + done < <(find "$app" -type f -print0) + done < <(find desktop/src-tauri/target -maxdepth 6 -type d -name '*.app') + echo "inspected $machos Mach-O files across $apps app bundles" + # A sweep that inspected nothing is the failure mode this step exists to prevent. + if [ "$apps" -eq 0 ] || [ "$machos" -eq 0 ]; then + echo "::error::found $apps app bundles and $machos Mach-O files; the sweep inspected nothing" + exit 1 + fi + exit "$bad" + - name: Upload desktop release uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: desktop-${{ matrix.target }} path: dist/release/ if-no-files-found: error + retention-days: 7 # always(), because a keychain holding the release identity must not survive a failed job # on a runner image that could be reused. @@ -364,7 +396,6 @@ jobs: if [ -n "${OPENCODEX_SIGNING_KEYCHAIN:-}" ] && [ -f "${OPENCODEX_SIGNING_KEYCHAIN}" ]; then security delete-keychain "${OPENCODEX_SIGNING_KEYCHAIN}" fi - retention-days: 7 attach-release: runs-on: ubuntu-latest From c8a90efd9030c3ca29d907499c70132e5fec3745 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 23:03:23 +0900 Subject: [PATCH 09/16] docs(structure): record the first-run login item and the widget's two-part entry structure/ ownership obliges the doc for an area to move with the source, and this branch changed two things the desktop-shell note did not describe. first_run.rs turns Start at Login on once per installation, with the marker written before the login item so a user who turns it back off keeps it off. The widget needs @main on the bundle and the _NSExtensionMain linker entry together, and the sandbox entitlement is mandatory because pkd refuses to register an unsandboxed plug-in at all - which is the reason the shell writes its snapshot into the extension's container rather than anywhere more obvious. --- structure/desktop-shell.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index ac1cb85b322..2c94df53e79 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -11,6 +11,23 @@ navigates the webview to the proxy's loopback dashboard Only the bootstrap page has Tauri IPC capability; the loopback dashboard never does because `dangerousRemoteDomainIpcAccess` is not configured. +`desktop/src-tauri/src/first_run.rs` turns Start at Login on once per installation, +before the tray is built so its checkbox reads the resulting state. A menu bar app +that is not running has no menu bar item, so leaving autostart off by default left an +installed app absent after a reboot. The marker in the app config directory is written +before the login item is touched and is never removed, so a user who turns the setting +off keeps it off; writing it afterwards would let a failed enable retry on every launch. +The behaviour is not macOS-only — the autostart plugin implements the Linux autostart +entry and the current-user Windows Run registration too. + +The WidgetKit extension in `app/` needs both `@main` on `OpenCodexWidgetBundle` and the +`-e _NSExtensionMain` linker entry in `app/Package.swift`. Either alone yields a widget +that never appears: without `@main` the linker drops the bundle and the extension +registers with nothing to offer, and without the entry override ExtensionFoundation traps +during bootstrap. `com.apple.security.app-sandbox` is also mandatory — `pkd` refuses to +register an unsandboxed plug-in at all — which is why the shell writes its snapshot into +the extension's own container. + `desktop/scripts/prepare-sidecar.ts` maps Rust target triples to the standalone Bun targets and prepares the external binary plus dashboard resources used by Tauri. Generated files under desktop/src-tauri/binaries/ and From c97d52ceeb1f30f2af36ce3f3318a62e6ec0c3d8 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 23:12:23 +0900 Subject: [PATCH 10/16] fix(release): check the notarization credentials as a set and widen the Mach-O sweep Two holes left by the previous revision. Only the certificate and the team id were guarded, so a real release missing APPLE_ID or APPLE_PASSWORD still ran: the Tauri CLI skips notarization without failing when the notary credentials are absent, and the unnotarized artifact is uploaded and attached exactly as a good one would be. The five credentials are now checked together, and a non-dry-run release stops with the missing names rather than shipping something that looks finished. The bundle sweep recognised four of the eight Mach-O leading words, so a fat 64-bit or big-endian binary was skipped in silence while the other files kept the inspected-something counter healthy. All eight are listed now. The public install guidance is left alone on purpose, and the landing note says why: those pages describe an artifact that does not exist yet, and they should move with the first notarized release rather than ahead of it. --- .github/workflows/release.yml | 27 ++++++++++++++----- .../050_landing.md | 13 +++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 52ed7814dd5..5a10187cb5a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -247,17 +247,28 @@ jobs: env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} DRY_RUN: ${{ inputs.dry-run }} run: | set -euo pipefail - if [ -z "${APPLE_CERTIFICATE}" ]; then + # Checked as a set, because a partial set is the dangerous case: the Tauri CLI skips + # notarization without failing when the notary credentials are missing, and the + # unnotarized artifact is uploaded and attached exactly as a good one would be. + missing="" + for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID; do + eval "value=\${$name:-}" + [ -n "$value" ] || missing="$missing $name" + done + if [ -n "$missing" ]; then if [ "${DRY_RUN}" != "true" ]; then - echo "::error::A real release needs the signing certificate; refusing to build an" - echo "::error::unsigned macOS artifact that would still be uploaded and attached." + echo "::error::A real release needs the full signing and notarization credential set." + echo "::error::Missing:$missing" exit 1 fi - echo "No signing certificate is configured, so the extension will be ad-hoc signed." - echo "This build is usable for local validation and is not a release asset." + echo "Signing credentials are incomplete, so this build stays ad-hoc signed:$missing" + echo "It is usable for local validation and is not a release asset." exit 0 fi keychain="$RUNNER_TEMP/opencodex-signing.keychain-db" @@ -361,8 +372,12 @@ jobs: apps=$((apps + 1)) echo "checking $app" while IFS= read -r -d '' file; do + # All eight Mach-O leading words: thin and fat, 32- and 64-bit, both byte orders. + # A list that covers only the common ones skips the rest in silence while the + # non-zero counter below still reports a healthy sweep. case "$(head -c 4 "$file" | xxd -p)" in - cffaedfe|cefaedfe|cafebabe|bebafeca) ;; + cefaedfe|cffaedfe|feedface|feedfacf) ;; + cafebabe|bebafeca|cafebabf|bfbafeca) ;; *) continue ;; esac machos=$((machos + 1)) diff --git a/devlog/_plan/260920_desktop_app_stabilization/050_landing.md b/devlog/_plan/260920_desktop_app_stabilization/050_landing.md index 3a432674cd8..d9af6df77bb 100644 --- a/devlog/_plan/260920_desktop_app_stabilization/050_landing.md +++ b/devlog/_plan/260920_desktop_app_stabilization/050_landing.md @@ -36,3 +36,16 @@ Each merge reads the exact head's check runs rather than a rollup, distinguishes requested from one it skipped, and treats a missing, skipped, or cancelled job as not a pass. The last merge is followed by reading `dev`'s own push run, because five of the eight defects found in this unit were invisible until two changes met. + +## Deliberately not changed here + +Review asked for the public macOS install guidance to move with the release path, since +`README.md`, `guides/desktop-app.md` and `guides/macos-menu-bar.md` all tell the reader the app is +ad-hoc signed and not notarized, while this stack makes a real release refuse to run without a +Developer ID and the full notarization credential set. + +Those pages are accurate today and will stop being accurate at the next release, not at this +merge. No release has ever published a macOS application, so rewriting them now would describe an +artifact nobody can download and would leave the Gatekeeper walkthrough — still correct for a +locally built app — reading as though it were obsolete. The pages move with the first notarized +artifact, which is also when someone can check the instructions against a real download. From 868db0a96d93add4804a7724c2bafbbc7d9376a5 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 23:17:06 +0900 Subject: [PATCH 11/16] ci(desktop): assert that the widget bundle is linked into the extension The executable-name half of this landed separately as #5351, in a better form: it reads CFBundleExecutable from the bundle rather than restating the name, so it follows the config instead of drifting from it. What remains here is the assertion that has no equivalent. A widget extension with no widget in it is indistinguishable from a working one by every other check in this job: the appex builds, the signature verifies, pluginkit registers it, and the gallery is simply empty. That is what shipped, and it shipped silently. Reading the WidgetBundle symbol out of the binary is the only place in the build where its absence is visible. --- .github/workflows/ci.yml | 6 ++++++ .../260920_desktop_app_stabilization/050_landing.md | 11 ++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f10daa3fae6..c7d1c5de8f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1215,6 +1215,12 @@ jobs: test -x "$app/Contents/PlugIns/OpenCodexWidget.appex/Contents/MacOS/OpenCodexWidget" test -x "$app/Contents/MacOS/ocx" codesign -dv "$app/Contents/PlugIns/OpenCodexWidget.appex" + # The widget is only offered in the gallery when its bundle is actually linked in, and + # nothing else here would notice its absence: the appex builds, signs and registers + # exactly the same way with the WidgetBundle dropped by the linker. + nm -a "$app/Contents/PlugIns/OpenCodexWidget.appex/Contents/MacOS/OpenCodexWidget" \ + | grep -q "OpenCodexWidget0abC6BundleV" \ + || { echo "::error::the widget bundle is not linked into the extension"; exit 1; } desktop-shell: name: desktop shell diff --git a/devlog/_plan/260920_desktop_app_stabilization/050_landing.md b/devlog/_plan/260920_desktop_app_stabilization/050_landing.md index d9af6df77bb..7178eafb510 100644 --- a/devlog/_plan/260920_desktop_app_stabilization/050_landing.md +++ b/devlog/_plan/260920_desktop_app_stabilization/050_landing.md @@ -26,9 +26,14 @@ from it. Registering it under `providers` restores the gate for everyone. `macos widget + bundle` failed with *A public key has been found, but no private key*. The job is an unsigned build by design, so the key is correctly absent — but the committed config sets `bundle.createUpdaterArtifacts` and `plugins.updater.pubkey`, so `tauri build` writes the updater -archive and then refuses to finish. Selecting bundle targets does not avoid it because the flag is -config rather than a target; the invocation turns the artifact off instead, leaving nothing to sign -rather than something signed badly. +archive and then refuses to finish. That half is #5338's, which turns the artifact off for that one +invocation; this stack does not duplicate it. + +Fixing the build revealed the rest of the job, which had never run. Its first assertion looked for +`Contents/MacOS/OpenCodex` — `productName` — while the bundle carries `opencodex-desktop`, the +crate name. That is corrected here, along with a new assertion that the WidgetBundle is actually +linked into the extension: the appex builds, signs and registers identically with the bundle +dropped by the linker, so nothing else in this job would have noticed the defect that shipped. ## What closes this From 7027295db63533fa962ae82ed7ccaa08fb598cd7 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 23:39:35 +0900 Subject: [PATCH 12/16] fix(desktop): build the widget in extension-only mode Xcode sets APPLICATION_EXTENSION_API_ONLY on an app-extension target, and SwiftPM has no such target, so the flag has to be passed by hand. The two projects that have a SwiftPM-built widget extension working both do exactly this alongside the _NSExtensionMain linker entry, and the public report of the ExtensionFoundation crash this branch hit traces it to precisely the setup SwiftPM cannot express. Verified on a real install after the change: the extension registers, chronod captures its descriptors, and no crash report is produced. The devlog records what the same report settles - ad-hoc signing does not block the gallery, App Groups do not work ad-hoc so writing into the extension's own container is the documented fallback, and the host and extension CFBundleVersion must match, which they do. --- app/Package.swift | 7 ++++ .../040_widget_never_offered.md | 32 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/app/Package.swift b/app/Package.swift index c334aa0cfc2..a741d5bfd12 100644 --- a/app/Package.swift +++ b/app/Package.swift @@ -14,6 +14,13 @@ let package = Package( name: "OpenCodexWidget", dependencies: ["MenuBarCore"], path: "Sources/OpenCodexWidget", + swiftSettings: [ + // Xcode sets APPLICATION_EXTENSION_API_ONLY on an app-extension target, and the + // two projects that have this working from SwiftPM pass its compiler spelling by + // hand. It restricts the target to the extension-safe API surface, which is the + // contract the extension host assumes it was built against. + .unsafeFlags(["-application-extension"]), + ], linkerSettings: [ // A widget extension needs both halves of what Xcode does for an app-extension // target, and each half is useless alone. This flag is one of them; `@main` on diff --git a/devlog/_plan/260920_desktop_app_stabilization/040_widget_never_offered.md b/devlog/_plan/260920_desktop_app_stabilization/040_widget_never_offered.md index 41ffe918e7e..6502bb152a3 100644 --- a/devlog/_plan/260920_desktop_app_stabilization/040_widget_never_offered.md +++ b/devlog/_plan/260920_desktop_app_stabilization/040_widget_never_offered.md @@ -84,6 +84,38 @@ from `pluginkit` entirely. That also settles the snapshot path: the host writes `~/Library/Containers/com.opencodex.desktop.widget/Data/...` precisely because the extension reads its own container, and that arrangement has to stay. +## What the public record says about this failure + +The `_EXRunningExtension` crash is not unique to this repository, and finding the precedent +changed how much of the fix is guesswork. A forensic report on macOS 26.5 with Swift 6.3.2 +describes the same trap from the same cause — a widget extension assembled from a SwiftPM +`.executableTarget` and wrapped into an `.appex` by hand — and records that neither Info.plist +shape avoids it, because SwiftPM has no app-extension target and therefore never applies the +entry-point setup Xcode's WidgetKit template provides. That project's resolution was to stop +using SwiftPM for the extension and build a real Xcode app-extension target instead. + +Two other projects keep SwiftPM and supply the missing pieces by hand, which is the route taken +here: the linker entry (`-Xlinker -e -Xlinker _NSExtensionMain`) and the compiler's +extension-only mode (`-application-extension`, which is what Xcode spells +`APPLICATION_EXTENSION_API_ONLY`). Both are now set, and the extension launches and answers +`chronod` without a crash report. + +Three things the same record settles that were open questions here: + +- **Ad-hoc signing does not prevent gallery appearance.** Developer ID and notarization matter for + Gatekeeper, not for gallery mechanics. The containing app does have to be launched once after + installation, which is what makes the first-run behaviour in this branch load-bearing for more + than the menu bar. +- **App Groups do not work under ad-hoc signing**, and the documented fallback is exactly what + this repository already does — the host writes into the extension's own container. +- **`CFBundleVersion` must match between host and extension** or WidgetKit rejects timeline + reloads. Verified on the installed bundle: both read 2.61.0. + +If the gallery still refuses this extension after the entry point and the extension-only build, +the remaining known cause is the Xcode app-extension target itself, and that is a larger change +than this unit: it means adding an Xcode project for the widget and building it with +`xcodebuild` rather than `swift build`. + ## The signing defect underneath it Fixing the entry point does not make a *released* widget adoptable on someone else's machine, From 32875c3ebfc67fbb20a70c5d8439e21fb4641673 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 23:41:20 +0900 Subject: [PATCH 13/16] fix(tests): bound the release injection guard at the job it is reading The guard splits the workflow on step names and scans from each "run: |" to the end of the block, which for the last step of a job runs on into the next job's header. Adding a cleanup step at the end of package-desktop made it read attach-release's job-level "if: inputs.dry-run != true" as shell interpolation inside the step above it - a condition, not a script, and not reachable by injection. Each block is now cut at the first line that dedents to job level, which is where the step's script actually ends. The guard still fails on a real interpolation: driven red by putting an inputs expression inside a run block, and green again once removed. --- tests/ci-workflows/ci-workflows.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/ci-workflows/ci-workflows.test.ts b/tests/ci-workflows/ci-workflows.test.ts index 8746d8539f9..6d7959c3e57 100644 --- a/tests/ci-workflows/ci-workflows.test.ts +++ b/tests/ci-workflows/ci-workflows.test.ts @@ -918,9 +918,15 @@ describe("GitHub Actions hardening", () => { // Workflow-dispatch inputs must reach shell code via env, never by direct // interpolation into run: source (script-injection hardening). + // The split alone does not bound a block: the last step of a job runs on into the next + // job's header, so a job-level `if: ${{ inputs.dry-run != true }}` — which is a condition, + // not shell — read as an injection in the step above it. Each block is cut at the first + // line that dedents to job level, which is where the step's script actually ends. const runBlocks = workflow.split(/\n {6,}- name: /).filter(block => block.includes("run: |")); for (const block of runBlocks) { - const runSource = block.slice(block.indexOf("run: |")); + const afterRun = block.slice(block.indexOf("run: |")); + const jobBoundary = afterRun.search(/\n {2}\S/); + const runSource = jobBoundary === -1 ? afterRun : afterRun.slice(0, jobBoundary); expect(runSource).not.toContain("${{ inputs."); } From b1a94b2ee7cb9d2436e628b100027447e8ee3f2a Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 23:46:42 +0900 Subject: [PATCH 14/16] test(clients): hold the extension-only build flag the same way as the linker entry -application-extension is load-bearing and nothing asserted it: the existing nm check proves the WidgetBundle was linked, which stays true with the flag removed, and the runtime crash it prevents leaves the build, the signature and the pluginkit registration all looking fine. It sits beside the _NSExtensionMain assertion because the two are one contract - the projects that have a SwiftPM widget extension working supply both. structure/desktop-shell.md now records all three requirements together and says plainly that nothing observable distinguishes a broken one from a working widget. Driven red by deleting the flag. --- structure/desktop-shell.md | 18 +++++++++++------- tests/clients/desktop-widget-entry.test.ts | 10 ++++++++++ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index 2c94df53e79..cb5768a0c92 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -20,13 +20,17 @@ off keeps it off; writing it afterwards would let a failed enable retry on every The behaviour is not macOS-only — the autostart plugin implements the Linux autostart entry and the current-user Windows Run registration too. -The WidgetKit extension in `app/` needs both `@main` on `OpenCodexWidgetBundle` and the -`-e _NSExtensionMain` linker entry in `app/Package.swift`. Either alone yields a widget -that never appears: without `@main` the linker drops the bundle and the extension -registers with nothing to offer, and without the entry override ExtensionFoundation traps -during bootstrap. `com.apple.security.app-sandbox` is also mandatory — `pkd` refuses to -register an unsandboxed plug-in at all — which is why the shell writes its snapshot into -the extension's own container. +The WidgetKit extension in `app/` needs three things that Xcode's app-extension target +would supply on its own, and SwiftPM has no such target: `@main` on +`OpenCodexWidgetBundle`, the `-e _NSExtensionMain` linker entry, and +`-application-extension` — the compiler spelling of `APPLICATION_EXTENSION_API_ONLY` — all +in `app/Package.swift`. Any one missing yields a widget that never appears: without +`@main` the linker drops the bundle and the extension registers with nothing to offer, and +without the entry override ExtensionFoundation traps during bootstrap. Nothing observable +distinguishes these from a working widget, because the bundle still builds, signs and +registers. `com.apple.security.app-sandbox` is also mandatory — `pkd` refuses to register +an unsandboxed plug-in at all — which is why the shell writes its snapshot into the +extension's own container rather than a shared App Group, which ad-hoc signing cannot use. `desktop/scripts/prepare-sidecar.ts` maps Rust target triples to the standalone Bun targets and prepares the external binary plus dashboard resources used by diff --git a/tests/clients/desktop-widget-entry.test.ts b/tests/clients/desktop-widget-entry.test.ts index 183121b3dd9..6871bbd56cc 100644 --- a/tests/clients/desktop-widget-entry.test.ts +++ b/tests/clients/desktop-widget-entry.test.ts @@ -31,6 +31,16 @@ describe("widget extension entry point", () => { expect(code).toContain("_NSExtensionMain"); }); + test("the target is compiled in extension-only mode", () => { + // Xcode's app-extension target sets APPLICATION_EXTENSION_API_ONLY; SwiftPM has no such + // target, so the compiler's spelling is passed by hand. It belongs beside the linker entry + // because the two are one contract: the projects that have a SwiftPM widget extension + // working supply both, and dropping either brings back a failure that the build, the + // signature and the registration all continue to look fine through. + const code = stripComments(readFileSync(PACKAGE, "utf8")); + expect(code).toContain("-application-extension"); + }); + test("the widget bundle is the Swift entry, so the linker keeps it", () => { const views = readFileSync(VIEWS, "utf8"); expect(views).toMatch(/@main\s*\n\s*struct OpenCodexWidgetBundle: WidgetBundle/); From d0a862cc9548e9d77709a8812e2b4408fa8d7fb6 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 00:15:55 +0900 Subject: [PATCH 15/16] docs(devlog): record that the widget now appears in the gallery --- .../040_widget_never_offered.md | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/devlog/_plan/260920_desktop_app_stabilization/040_widget_never_offered.md b/devlog/_plan/260920_desktop_app_stabilization/040_widget_never_offered.md index 6502bb152a3..062fbfa4d27 100644 --- a/devlog/_plan/260920_desktop_app_stabilization/040_widget_never_offered.md +++ b/devlog/_plan/260920_desktop_app_stabilization/040_widget_never_offered.md @@ -185,9 +185,31 @@ pluginkit: com.opencodex.desktop.widget re-registered, parent bundle resolved So the entry point is connected and the login item is registered, both on a real install rather than in a test double. +## Verdict: it appears + +The gallery was opened on this machine after the fix and OpenCodex is in it, between OKX and +PASS, with all three declared families rendering real data rather than placeholders: + +``` +com.opencodex.desktop::com.opencodex.desktop.widget:OpenCodexWidget:systemSmall +com.opencodex.desktop::com.opencodex.desktop.widget:OpenCodexWidget:systemMedium +com.opencodex.desktop::com.opencodex.desktop.widget:OpenCodexWidget:systemLarge + "OpenCodex — Proxy status, today's usage, and quota at a glance." +``` + +Small shows the token count for the day, medium adds requests, cost and the account quota rows, +large adds the 24-hour per-model timeline. The list icon is the mark generated from `icon.svg`. + +That settles the whole question the acceptance note left open, and it settles it the right way +round: the extension was never rejected by signing or by the sandbox. It had no widget in it, and +then it had one that could not start. Both are fixed, and the fix is a SwiftPM configuration +rather than the Xcode app-extension target the public record recommends — so the cheaper route +does work, provided all three of `@main`, the `_NSExtensionMain` entry and +`-application-extension` are present. + ## Acceptance -The entry-point half closes when the locally built widget appears in the gallery on this machine. -The signing half closes when a release build's extension reports the team identifier, the runtime -flag and a timestamp, and a clean install on a machine that did not build it offers the widget. -That second half needs maintainer-held credentials and is recorded as outstanding. +The entry-point half is closed by the gallery observation above. The signing half closes when a +release build's extension reports the team identifier, the runtime flag and a timestamp, and a +clean install on a machine that did not build it offers the widget. That second half needs a real +release and is recorded as outstanding. From 79d2fc80623fd5e50a6f31b75006680d52d9fc4b Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 00:27:52 +0900 Subject: [PATCH 16/16] docs(devlog): correct the landing note for the three repairs that landed elsewhere --- .../050_landing.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/devlog/_plan/260920_desktop_app_stabilization/050_landing.md b/devlog/_plan/260920_desktop_app_stabilization/050_landing.md index 7178eafb510..4e6135b9d70 100644 --- a/devlog/_plan/260920_desktop_app_stabilization/050_landing.md +++ b/devlog/_plan/260920_desktop_app_stabilization/050_landing.md @@ -31,9 +31,19 @@ invocation; this stack does not duplicate it. Fixing the build revealed the rest of the job, which had never run. Its first assertion looked for `Contents/MacOS/OpenCodex` — `productName` — while the bundle carries `opencodex-desktop`, the -crate name. That is corrected here, along with a new assertion that the WidgetBundle is actually -linked into the extension: the appex builds, signs and registers identically with the bundle -dropped by the linker, so nothing else in this job would have noticed the defect that shipped. +crate name. That half landed separately as #5351, and better than the version written here: it +reads `CFBundleExecutable` out of the bundle instead of restating the name, so the check follows +the config rather than drifting from it. This stack's copy was dropped in favour of it. + +What remains here is the assertion with no equivalent: that the WidgetBundle is actually linked +into the extension. The appex builds, signs and registers identically with the bundle dropped by +the linker, so nothing else in this job would have noticed the defect that shipped. + +Three of this stack's incidental repairs turned out to be running in parallel with the +maintainer's own: the StepFun layout registration (#5335), the widget job's updater override +(#5338), and this executable assertion (#5351). Each was dropped here once the other landed. The +pattern is worth noting for the next batch — a repair found while passing through is worth +checking against open pull requests before it is written. ## What closes this