* feat(desktop): one-click updates through electron-updater on the update-feeds release * feat(tray): signed one-click updates through tauri-plugin-updater; feed publishing and release docs * ci(tray): build without the updater key until it is configured * chore(windows): set the tray updater public key * ci(windows): skip updater signing in the release build smoke * ci(windows): build without updater artifacts when no signing key is available * ci(windows): turn off updater artifacts through TAURI_CONFIG, not CLI flags pwsh mangles * ci(windows): run tauri build under bash so --no-sign reaches the CLI (pwsh drops the -- separator)
33 KiB
Distributing CodeBurn Desktop
This document describes how to produce distributable macOS, Windows, and Linux
builds of the Electron desktop app. Release macOS builds are signed with a
Developer ID Application certificate and notarized by Apple (see "macOS code
signing and notarization" below); the Windows and Linux builds are
unsigned. Windows NSIS packages are built and checked by the
Build Windows installer GitHub Actions workflow; the other desktop packages
are still produced by hand. All three targets are produced by
electron-builder.
The bundled CLI (no install prerequisite)
The packaged app ships its own copy of the codeburn CLI and needs nothing
installed on the target machine. Packaging stages a self-contained copy of the
root repo's CLI into app/build/cli (see scripts/stage-cli.mjs) — the tsup
bundle (dist/main.js), its Node-version launcher (dist/cli.js), the root
package.json (the bundle reads its version), plus the CLI's production
node_modules closure. An afterPack hook (scripts/after-pack.cjs) copies
that tree into the app at Contents/Resources/cli/ after packaging and before
signing, so it lands inside the code signature.
At runtime a packaged build spawns the bundled CLI with Electron's own binary
acting as Node (ELECTRON_RUN_AS_NODE=1), so no Node install is required —
the app is version-matched to itself. main.ts sets CODEBURN_BUNDLED_CLI to
Resources/cli/dist/launch.js (a small shim that preserves the argument shape
expected by the resident and one-shot CLI paths, then hands off to main.js),
and electron/cli.ts
resolves it ahead of any persisted path or PATH lookup.
A user-installed CLI is only consulted outside a packaged build (dev via the
Vite dev server) or when explicitly overridden — CODEBURN_BIN=/abs/path, or a
persisted path file (Application Support/CodeBurn/codeburn-cli-path.v1). The
full resolution order in electron/cli.ts is: CODEBURN_BIN → dev repo CLI
(Vite) → bundled CLI → persisted path → PATH/Homebrew/nvm/volta/asdf.
Freshness
Packaging always rebuilds the root CLI before staging: the app stage-cli
script runs the root build:cli (tsup only — no dashboard build, no network
bundle-litellm fetch), so dist/main.js and dist/cli.js are regenerated
from current src/ on every package* run. A stale global codeburn can never
ship, and the app can never be older than the JSON surfaces it calls. The
bundled CLI's deps are pure JS (no native bindings), so the same staged tree is
valid for every arch.
Why afterPack, not extraResources
electron-builder routes every node_modules directory it copies through its
production-dependency filter, which keeps only the app's own deps — so the
bundled CLI's dependency tree gets stripped out of an extraResources copy
(filter: ["**/*"] does not defeat this). afterPack copies the staged tree
in verbatim, which is the electron-builder-recommended mechanism for adding
unpacked files that must not be ASAR-archived.
The bundled tray app and Capacity Dock (Windows only)
The Windows desktop app also carries the Tauri tray app from windows/, so
installing the desktop app gives the user the tray icon and the Capacity Dock
without their having to know that codeburn menubar exists. Two switches in the
sidebar's bottom-left corner turn each surface off ("Menu bar" is the tray
process, "Sidebar" is the dock rail); both default on, and neither appears on
macOS or Linux, or in a build with nothing staged.
scripts/stage-menubar.mjs puts the tray app into app/build/menubar, which
build.win.extraResources ships as resources/menubar. It copies rather than
downloads, exactly like stage-cli.mjs: with no --from it takes the local
windows/src-tauri/target/release output, and CI downloads the windows-v*
release assets in its own step and passes --from <dir>.
The two routes need different things staged, which is why there are two scripts:
npm run stage-menubar(run bypackage:win) stagesCodeBurn.Menubar_<version>_x64_en-US.msiand its.sha256. The digest is copied from the release when it is there and computed when it is not, so a localcargo tauri buildstages the same shape a release does.npm run stage-menubar:store(run bypackage:store) stagescodeburn-menubar.exeitself, plus any sidecar DLL beside it.
The staged tray app is not the released one, even at the same version.
build-windows-installer.yml runs npm run tauri build itself and stages the
MSI that run produced. release-menubar-windows.yml runs its own
npm run tauri build on a windows-v* tag and uploads that MSI as the release
asset. Two builds, two binaries: a Tauri MSI is not byte-reproducible (build
timestamps, the package GUID and the PDB signature differ per run), so the
CodeBurn.Menubar_<version>_x64_en-US.msi inside a desktop installer and the
windows-v<version> release asset of the same name are different files with
different .sha256 digests, even off the same commit.
Nothing in the install path depends on them matching: the CLI's installer
verifies the digest of the file it was handed, and the upgrade rules go by the
DisplayVersion in the uninstall registry rather than by a hash. What it does
mean is that a version number is not a statement about which binary is on a
machine, and the release asset is not a way to reproduce or verify what a
desktop installer carries. So cut both from one commit when releasing, and say
"built by the desktop installer" or "the windows-v asset" rather than treating
the version as an identifier for either.
The NSIS route: install through the CLI, never downgrade
app/electron/menubar.ts runs at every launch (which is also what covers a
desktop-app update, since the staged MSI travels with the app) and hands the
staged file to the CLI's own Windows installer by spawning
codeburn menubar --staged-msi <absolute path>. The path is an argument of the
command rather than an environment variable, so nothing a variable in the
inherited environment happens to name can reach msiexec; the
CODEBURN_MENUBAR_MSI variable this replaced is now rejected by the CLI. The
install itself is not reimplemented on the Electron side:
src/menubar-installer.ts owns the uninstall-registry read, the checksum
verification, the msiexec /i ... /passive /norestart call and the rules that
go with a bundled copy. It prints one CODEBURN_MENUBAR_RESULT <json> line that
the desktop app reads.
The spawn itself is not made at every launch. msiexec /passive puts an admin
prompt in front of the user, so companion.v1.json records the version of the
tray app that is on disk and the version of any staged MSI whose install the
user declined at that prompt (exit 1602). A launch skips the probe when the
recorded tray app is the staged version and is still there, and skips it when
this exact staged version was already refused. A newer staged version is always
offered, and so is turning the "Menu bar" switch on by hand, which clears the
refusal.
Only a refusal is remembered. A spawn that timed out, a CLI that was not there and a run that died before printing its result are all transient, so nothing is written down and the install is attempted again at the next launch; recording them would turn one bad launch into a tray app that is never installed.
When msiexec exits 3010 the install is real but unfinished: Windows could not
replace a file that was in use and has deferred it to the next restart, so the
binary on disk is still the previous tray app while the uninstall registry
already reports the new version. The desktop app starts nothing in that state
and the sidebar corner says "Restart Windows to finish installing the menu bar
app." instead. BundledInstallResult carries no field for this, so the desktop
side reads the installer's own notice line; the optional rebootRequired in
MenubarInstallResult is believed first, for when the CLI starts sending it.
Two of those rules matter here:
- Deduplication. The MSI carries a fixed WiX upgrade code
(
windows/src-tauri/tauri.conf.json,bundle.windows.wix.upgradeCode), which is the value Tauri already derived from the product name, so installing the bundled copy over a manualcodeburn menubarinstall is an in-place upgrade rather than a second entry in Programs and Features. - Never downgrade. If the installed
DisplayVersionis newer than the bundled one, nothing is installed and the existing app is left alone.
The marker. The installer writes
%LOCALAPPDATA%\codeburn-menubar\installed-by.json, crediting desktop only
when it installed onto a machine that had no tray app, and manual when it
upgraded one that was already there. An existing marker's verdict is never
rewritten. build/installer.nsh (wired as nsis.include) reads it in the
uninstaller and removes the tray app only when the marker says desktop, so a
tray app the user installed by hand survives an uninstall of the desktop app.
Launch at login on this route is a single HKCU\...\CurrentVersion\Run value
named CodeBurn, the same value the tray app's own settings toggle writes
(windows/src-tauri/src/autostart.rs), so the two can never leave two entries.
The Store route: no msiexec
A packaged app cannot run msiexec and would be fighting the package manager if
it could, so the AppX build ships codeburn-menubar.exe inside the package and
launches it from app\resources\menubar\. Launch at login is a
windows.startupTask extension (build/appx-extensions.xml, wired as
appx.customExtensionsPath), which also lets the user turn it off in
Settings > Apps > Startup; no Run value is written. The tray app detects the
package with GetCurrentPackageFullName and skips its own update checker
entirely, since Store updates cover it.
That is why the Store is the recommended Windows install: it is the only Windows route with a signed artifact (Microsoft signs the package at submission) and a real update path. The GitHub downloads stay a developer preview, unsigned and without self-update, until an Authenticode certificate exists.
The startup task is Windows' to set, not this app's: reaching one would take the
WinRT Windows.ApplicationModel.StartupTask API, which Electron has no binding
for and which this app ships no native code to reach, and a Run value written
beside it would start the tray app twice. So trayPrefs() reports
launchAtLoginManaged: true on this route and the Menu bar pane renders that row
as text plus a button that opens ms-settings:startupapps, rather than a switch
that would silently move nothing. That URL is the one exception in the main
process's external-open guard, which is otherwise http(s) only.
Talking to a running tray app
The tray app has no window of its own, so its argv is the control channel: the
Tauri single-instance plugin hands a running instance whatever a second launch
was started with (windows/src-tauri/src/lib.rs). --quit exits and
--reload-settings re-reads the preference files. Turning "Sidebar" off writes
enabled: false into ~/.config/codeburn/windows-dock.json and then sends
--reload-settings, so a running rail disappears at once rather than at the next
launch.
Versioning
app/package.json's version tracks the CLI's version (root
package.json) — one CodeBurn version across CLI, menubar, and desktop.
Bump it in the same change that bumps the root version; the splash, the
About dialog, and the artifact filenames all read it from there.
Build
npm --prefix app install
npm --prefix app run package # macOS, both arm64 and x64
npm --prefix app run package:arm64 # macOS arm64 only (faster on Apple Silicon)
npm --prefix app run package:x64 # macOS x64 only
npm --prefix app run package:win # Windows NSIS installer, x64
npm --prefix app run package:store # Microsoft Store AppX, x64 (Windows host only)
npm --prefix app run package:linux # Linux AppImage, x64
The Linux targets and arches can be widened by calling electron-builder
directly, all from the same macOS host:
cd app
npx electron-builder --linux AppImage deb # x64 AppImage + deb
npx electron-builder --linux AppImage deb --x64 # force x64 on an arm64 mac
npx electron-builder --linux AppImage deb --arm64
npx electron-builder --linux rpm --x64 # rpm; needs `brew install rpm` (rpmbuild)
The rpm target is the only one with an extra prerequisite — fpm (bundled by
electron-builder) shells out to rpmbuild, so brew install rpm must be present
or the build fails with "executable rpmbuild is required". It emits
codeburn-desktop-<version>.x86_64.rpm, the name the website's Fedora/RHEL
download links.
package runs npm run stage-cli (rebuilds the root CLI and stages the
self-contained bundle into app/build/cli; see "The bundled CLI" above), then
npm run build (compiles electron/ with tsc, builds the renderer with
vite), then electron-builder --mac (whose afterPack hook copies the
staged CLI into the app). package:win and package:linux mirror it exactly,
swapping the final flag for electron-builder --win and electron-builder --linux. Developers can run all three locally on the same macOS host —
electron-builder downloads the NSIS and AppImage tooling on first use. Release
Windows installers are built by the windows-latest workflow described below.
Artifacts
electron-builder writes to app/release/ (gitignored, like dist/):
CodeBurn-<version>-arm64.dmg,CodeBurn-<version>.dmg— installer imagesCodeBurn-<version>-arm64-mac.zip,CodeBurn-<version>-mac.zip— zipped.appbundlesrelease/mac-arm64/CodeBurn.app,release/mac/CodeBurn.app— the raw unpacked bundles (arm64 and x64 respectively).blockmapfiles alongside each zip/dmg (electron-updater uses the zip blockmaps for differential downloads)latest-mac.yml— the update metadata for both zips, written because both arches build in one run (see "Auto-update" below)
Both dmg and zip targets are built for both arm64 and x64 — four
artifacts total, not a universal binary. This keeps each download roughly
half the size of a universal build. Pick the zip if you just want to unpack
and drag to /Applications; the dmg gives users the familiar drag-to-Applications
installer window.
Build configuration
The build block lives in app/package.json (small enough not to warrant a
separate electron-builder.yml):
appId: "org.agentseal.codeburn-desktop"— reuses theorg.agentseal.*prefix from the menubar app's bundle id (org.agentseal.codeburn-menubar, seemac/Scripts/package-app.sh); there is nocom.codeburn.*bundle id anywhere in the codebase, soorg.agentseal.*is the actual house convention.productName: "CodeBurn".files: onlydist/electron/**/*,dist/renderer/**/*, andpackage.json. electron-builder still adds the productiondependenciestoapp.asar; the main process needs onlyelectron-updaterfrom them (the renderer is a single Vite bundle). (The bundled CLI has its ownnode_modules, added toResources/cli/by theafterPackhook — see "The bundled CLI" above.)afterPack: "./scripts/after-pack.cjs"— copies the staged CLI bundle (app/build/cli) intoContents/Resources/cliafter packaging and before signing.mac.identity: "-"— forces ad-hoc signing by default.identity: nulldoes NOT ad-hoc sign — it skips signing entirely, which produces a bundle with a broken/absent seal (codesign --verify --deep --strictfails withcode has no resources but signature indicates they must be present, and Apple Silicon refuses to run it at all)."-"is the same ad-hoc identitymac/Scripts/package-app.shfalls back to for the menubar app's local builds. This is the local/dev default; signed release builds pass the real Developer ID identity as a CLI override instead of changing this file (see "macOS code signing and notarization" below) — worth baking intobuild.macdirectly so a release can't accidentally ship ad-hoc.mac.hardenedRuntime: false— matches the ad-hoc default above (hardened runtime needs a Developer ID signature). Signed release builds turn this on via the same CLI override.mac.gatekeeperAssess: false— skips electron-builder's post-signspctlcheck, which always fails for an ad-hoc/unnotarized build. A signed, notarized release build passes Gatekeeper's real check regardless of this setting.icon: build/icon.png— a pre-existing 1024x1024 PNG atapp/build/icon.png. No.icnsexists in the repo; electron-builder generates one from the PNG at build time. This is the same source PNG used for the app icon; the menubar app has its own separate icon (assets/menubar-logo.png, converted to.icnsinpackage-app.sh).directories.output: "release"— electron-builder's default output dir isdist, which collides with this app's existingtsc/vitebuild output (app/dist/electron,app/dist/renderer) thatfilesreads from. Using a separaterelease/directory keeps build inputs and packaging outputs apart.
Windows and Linux builds
Developers can cross-build both locally from the same macOS host used for the
mac build — no Windows or Linux machine, and no wine, is required.
Release-authoritative Windows NSIS installers are instead built by the Build Windows installer workflow on windows-latest. electron-builder 26 embeds the
Windows executable's icon/version resources natively and downloads the NSIS and
AppImage tooling on first run.
Windows (package:win)
electron-builder --win produces a single installer in app/release/:
CodeBurn-Setup-0.9.15.exe— the NSIS installer (the version number trackspackage.json). A.exe.blockmapandlatest.ymlare written alongside it; they stay unused while Windows auto-update is off (see "Auto-update" below).
Config (build.win + build.nsis):
win.target: nsis,arch: x64.win.icon: build/icon.png— electron-builder converts the 1024x1024 PNG to a multi-resolution.icoat build time (same source PNG as the mac icon).win.extraResources: build/menubar -> menubar: the staged tray app (see "The bundled tray app and Capacity Dock" above). Nonode_modulesis involved, so unlike the CLI this one can useextraResources.nsis.include: build/installer.nsh: the uninstall macro that removes the tray app only when the marker says the desktop app installed it.nsis.oneClick: false— an assisted installer with a wizard, so users get an install-directory choice instead of a silent one-click install.nsis.perMachine: false— installs per-user (into the user'sAppData), so no administrator/UAC elevation is needed.
The build is UNSIGNED (no Authenticode certificate). electron-builder logs
signing with signtool.exe, but with no certificate configured that step is a
no-op — the .exe ships without a signature. On first run, Windows SmartScreen
shows "Windows protected your PC". Users click "More info" → "Run
anyway" to launch it. This is expected for an unsigned build; the only fix is
a purchased code-signing (Authenticode/EV) certificate.
ARM64 builds. npm --prefix app run package:win:arm64 builds the installer for ARM64
Windows. It sets ELECTRON_BUILDER_7Z_FILTER=BCJ, and that is not optional: 7-Zip
compresses ARM64 executables with its ARM64 branch filter by default, the 7-Zip decoder
inside the NSIS installer predates that filter, and the installer then silently drops
every .exe and .dll while the data files land, leaving an install with no program in it.
The x64 build is unaffected because its default filter is one the decoder knows. The
per-architecture archives inside a universal installer get the same treatment, so an
installer built with both --x64 and --arm64 needs the variable too.
The ARM64 installer carries the x64 tray app. package:win:arm64 runs the same
npm run stage-menubar as package:win, and there is no ARM64 tray app for it to stage:
tauri buildbuilds for the host, so an ARM64 Windows machine producesCodeBurn Menubar_<version>_arm64_en-US.msi, andstage-menubar.mjsmatches onlyCodeBurn[ .]Menubar_<version>_x64_en-US.msi. Staging on an ARM64 host therefore fails outright withno CodeBurn.Menubar_<version>_x64_en-US.msirather than staging the arm64 bundle. Whatpackage:win:arm64actually ships is the x64 MSI, from an x64 host build or from thewindows-v*release assets passed with--from.- Teaching the staging script the arm64 name would not help on its own. The CLI is what runs
the install, and
src/menubar-installer.tsaccepts only the x64 name: its release-asset pattern,parseWindowsMsiVersionand the staged-install path all require_x64_en-US.msi, and a file named anything else is rejected before msiexec sees it. An ARM64 tray app would need a matching change there and in the tray's own updater, which is a larger piece of work than the packaging script. - What it means in practice: on ARM64 Windows the desktop app installs the x64 tray app, and Windows runs it under x64 emulation. It works, and it is slower and heavier than a native build would be. Nothing misreports its architecture, because the tray app's version and the uninstall-registry entry are both the x64 MSI's own.
build-windows-installer.yml builds package:win on windows-latest only, so no released
artifact is affected: package:win:arm64 is a local build today.
Distribution policy. The Microsoft Store build (Store ID 9P0R4ZL5XMB8) is
the recommended Windows install. This NSIS setup .exe and the tray .msi
under the windows-v* releases are a developer preview: unsigned, SmartScreen
warns on first run. The desktop installer does not update itself yet (see
"Auto-update" below). The tray app installs a newer .msi in one click once
its updater key is set (RELEASING.md); until then it points at the GitHub
release and codeburn menubar --force.
Microsoft Store (package:store)
The Store build is a separate AppX target so the GitHub NSIS installer remains
unchanged. AppX packaging requires Windows 10 or newer and is built by the
manual Build Windows Store package GitHub Actions workflow on
windows-latest. Download its CodeBurn-Microsoft-Store workflow artifact and
upload the contained CodeBurn-Store-<version>-x64.appx file in Partner Center.
The manifest identity must exactly match the reserved Partner Center product:
- Identity name:
Codeburn.CodeBurn - Publisher:
CN=3EFA3336-87E1-46F2-9DFA-2EB5A7693F89 - Publisher display name:
Codeburn - Store ID:
9P0R4ZL5XMB8
The Store package is intentionally unsigned: Microsoft signs it during Store
submission. Direct sideloading requires a separate trusted or development
certificate. The AppX declares runFullTrust (electron-builder's required
default for Electron apps), so CodeBurn retains access to the user's local
provider session files rather than running in a UWP application sandbox.
The tray app ships inside this package too (app\resources\menubar\), with a
windows.startupTask extension for launch at login. msiexec is never run on
this route. See "The Store route" above.
Linux (package:linux)
electron-builder --linux produces a single artifact in app/release/:
CodeBurn-0.9.15.AppImage— a self-contained AppImage (no install step, no package manager).
Config (build.linux):
linux.target: AppImage,arch: x64.linux.category: "Development"— the freedesktop menu category.linux.icon: build/icon.png— reuses the same source PNG.linux.maintainer: "AgentSeal <hello@agentseal.org>"— matches the rootpackage.jsonauthor.linux.executableName: "codeburn"— the binary name inside the AppImage (lowercase, no spaces), distinct from theCodeBurnproduct name.
After downloading, the AppImage must be made executable before it will run:
chmod +x CodeBurn-0.9.15.AppImage
./CodeBurn-0.9.15.AppImage
The build logs one benign warning — desktopName is not set — which only
affects how some desktop environments group the app's windows in the
taskbar/dock; it does not affect packaging or launch.
Releases
When a maintainer cuts a desktop release, the GitHub tag convention is:
desktop-v<version> # e.g. desktop-v0.9.15
This mirrors the menubar's mac-v<version> convention (see ../RELEASING.md)
and keeps the desktop app's tags in their own namespace, separate from the CLI
(v<version>) and the menubar (mac-v<version>).
Pushing a desktop-v<version> tag runs the Build Windows installer workflow
on windows-latest. The workflow requires the tag version, root package
version, and app package version to agree, and it fails unless the build emits
exactly one CodeBurn-Setup-<version>.exe and one matching
.exe.blockmap at the top level of app/release/. It uploads those exact
top-level filenames as the CodeBurn-Windows-Installer
Actions artifact. The workflow has read-only repository permissions and does
not publish release assets automatically. Artifacts are retained for 30 days.
Before publishing the GitHub Release, the release owner must download that
workflow artifact and manually upload both Windows files along with the four
macOS .dmg/.zip files, CodeBurn-<version>.AppImage,
codeburn-desktop_<version>_amd64.deb, and
codeburn-desktop-<version>.x86_64.rpm, plus latest-mac.yml (from the
CodeBurn-macOS artifact) and latest-linux.yml (from the Linux build). Confirm the live release contains
every required platform asset before announcing it. The
website's download links pin that tag in their URLs, so a release with a
missing installer is broken even when another Windows distribution channel is
available. The Windows installer uses an explicit nsis.artifactName of
CodeBurn-Setup-${version}.${ext}.
Publishing the Release triggers a read-only live-asset check. If the files are
uploaded afterward, rerun the workflow manually with release_tag set to the
existing desktop-v<version> tag and require the verification job to pass.
Auto-update
The app uses electron-updater with the generic provider pointed at the fixed
update-feeds release (build.publish in package.json), not GitHub's
/releases/latest, which the CLI, menubar, tray and desktop lines share. The
publish-update-feeds job in build-windows-installer.yml rewrites the file
names in latest*.yml to absolute desktop-v<version> URLs and uploads them
there, after the live-asset check passes. Release order is in RELEASING.md.
Who updates itself (app/electron/auto-update.ts):
- macOS, signed release builds: Update, then Restart to update. Ad-hoc local builds fail the Squirrel signature check and fall back to the download link.
- Linux AppImage only (
APPIMAGEis set). deb, rpm, snap and Flathub keep the link banner or their store. - Windows NSIS: off behind
WINDOWS_AUTO_UPDATEuntil a trust model is chosen (RELEASING.md, "Windows NSIS auto-update"). The Store build never checks.
Verifying a build
codesign -dv --verbose=2 app/release/mac-arm64/CodeBurn.app
codesign --verify --deep --strict app/release/mac-arm64/CodeBurn.app
spctl --assess --type execute --verbose app/release/mac-arm64/CodeBurn.app
A local ad-hoc build shows Signature=adhoc; a signed release build shows
Authority=Developer ID Application: Resham Joshi (XRVP7P7F9M) and spctl
reports source=Notarized Developer ID once the notarization ticket is
stapled. Either way expect a real Identifier=org.agentseal.codeburn-desktop
and Sealed Resources present, and the deep-verify command should exit 0.
To smoke-test that the packaged renderer actually loads (the classic failure
is a white screen from a wrong loadFile path once assets are behind
app.asar), launch the built binary directly and confirm the process tree
stays up and the main process logs no did-fail-load errors:
"app/release/mac-arm64/CodeBurn.app/Contents/MacOS/CodeBurn" --user-data-dir=/tmp/codeburn-smoke
A healthy launch spawns CodeBurn, CodeBurn Helper (gpu-process),
CodeBurn Helper (utility/network), and CodeBurn Helper (Renderer)
processes and keeps running with no stderr output. main.ts's
did-fail-load handler (console.error('Renderer failed to load ...'))
prints to that same stderr if the packaged loadFile(path.join(__dirname, '..', 'renderer', 'index.html')) path is ever wrong.
macOS code signing and notarization
Release macOS builds — the desktop .dmg/.zip files and the
CodeBurnMenubar-<version>.zip menubar app — are signed with the Developer
ID Application certificate "Developer ID Application: Resham Joshi
(XRVP7P7F9M)" (team XRVP7P7F9M), built with hardened runtime on, and
notarized by Apple via xcrun notarytool (using the codeburn-notary
keychain profile), then stapled. spctl --assess reports
source=Notarized Developer ID, and the app opens with no Gatekeeper
warning at all — no right-click → Open, no quarantine removal, no
"unidentified developer" dialog.
Entitlements
Hardened runtime needs a handful of entitlements or Electron's JIT-compiled renderer fails to start:
com.apple.security.cs.allow-jitcom.apple.security.cs.allow-unsigned-executable-memorycom.apple.security.cs.allow-dyld-environment-variablescom.apple.security.cs.disable-library-validation
Two gotchas
- electron-builder rejects the
Developer ID Application:prefix in the identity argument. Pass the bare certificate common name —"Resham Joshi (XRVP7P7F9M)"— not the full"Developer ID Application: Resham Joshi (XRVP7P7F9M)"stringcodesignitself accepts. - electron-builder notarizes the
.app, not the.dmg. Onceelectron-builder --macfinishes signing and notarizing the app bundle, the.dmgit produces around that app still needs its own codesign + notarize + staple pass:
(repeat for thecodesign --force --sign "Resham Joshi (XRVP7P7F9M)" CodeBurn-<version>-arm64.dmg xcrun notarytool submit CodeBurn-<version>-arm64.dmg --keychain-profile codeburn-notary --wait xcrun stapler staple CodeBurn-<version>-arm64.dmgx64dmg).
CI build
.github/workflows/build-desktop-mac.yml runs on every desktop-v* tag: it
builds both arches in one run, signs with the Developer ID from
MACOS_CERT_P12_BASE64, notarizes the apps and the dmgs with the App Store
Connect API key, verifies them, and uploads a CodeBurn-macOS artifact
(dmgs, zips, blockmaps, latest-mac.yml) for the release owner to attach.
build.mac carries the entitlements; the workflow passes the identity and
hardened runtime as overrides.
Release builds vs. the committed config
app/package.json's committed build.mac still declares identity: "-"
and hardenedRuntime: false — the ad-hoc/local-dev default described in
"Build configuration" above. A signed release build passes the real
Developer ID settings as electron-builder CLI overrides instead of
changing that file:
npx electron-builder --mac \
-c.mac.identity="Resham Joshi (XRVP7P7F9M)" \
-c.mac.hardenedRuntime=true \
-c.mac.entitlements=build/entitlements.mac.plist \
-c.mac.entitlementsInherit=build/entitlements.mac.plist \
-c.mac.gatekeeperAssess=true
This works, but it is easy to forget on a manual release — baking these
settings into build.mac directly (falling back to ad-hoc only when no
Developer ID identity is present in the keychain) would remove the risk of a
release accidentally shipping unsigned.
The menubar app is signed, notarized and stapled in CI by
.github/workflows/release-menubar.yml (see RELEASING.md for the secrets).
For a local signed build, mac/Scripts/package-app.sh takes the identity in
CODESIGN_IDENTITY and notarizes and staples the .app before zipping when
NOTARY_KEY_PATH, NOTARY_KEY_ID and NOTARY_ISSUER_ID are also set.
Ad-hoc is still the fallback
Building locally with no Developer ID identity in the keychain (or in CI,
where none is configured) falls back to ad-hoc signing, same as before:
enough to satisfy the kernel's code-signing check on Apple Silicon, but not
Gatekeeper. A quarantined ad-hoc build still shows the "Apple could not
verify..." / "is damaged and can't be opened" dialogs, and stripping the
quarantine attribute (xattr -cr /Applications/CodeBurn.app) is still the
workaround for a build tested this way. This path is for local development
only — the distributed release builds are signed and notarized and never
hit it.
Folder-access prompts
CodeBurn requests access to folders like Documents, Desktop, and Downloads
(via mac.extendInfo in app/package.json) to read local AI coding tool
session logs. Signed release builds use the stable Developer ID identity
above, so macOS TCC treats every release build as the same app and folder
grants persist across updates. This only regresses for an ad-hoc/local-dev
build: with no stable Developer ID, TCC treats each rebuild as a new app
identity and re-prompts for folder access every time.