@burdenoff/desktop-shell
The shared Electron runtime and build tooling behind every Burdenoff desktop app
shell — tray, native menus, window state, notifications, badges, theme sync,
auto-update UX and deep links in one package, plus the burdenoff-desktop CLI
that stages, launches, smoke-tests and verifies a shell.
Before this package each of the ~40 app shells carried its own 30 kB
electron/main.cjs, its own scripts/prepare-electron.mjs, and its own copy of
a "build" block in package.json. They drifted, and the drift shipped.
The two bugs this fixes
1. The packaged app shipped no node_modules.
directories.app pointed at electron/build/app, a folder the stage script
filled with the web build and two .cjs files — and nothing else. Inside the
packaged asar, require('electron-updater') and
require('@capgo/electron-updater') therefore threw at startup. Both were
wrapped in try/catch, so every released installer looked healthy while
auto-update and CapGo OTA were silently dead.
burdenoff-desktop stage now bundles electron/main.cjs with
bun build --target=node --format=cjs --external electron, inlining
@burdenoff/desktop-shell and electron-updater, and then asserts the output
contains no bare updater imports. CapGo is already embedded in the published
desktop-shell bundle with a streaming extractor that rejects symlinks, path
escapes and oversized archives. A staged app that still needs node_modules
fails the build.
2. The sandboxed preload could not require CapGo.
Sandboxed preloads resolve only electron and a handful of builtins. Any npm
require from the preload is a hard failure, so the renderer bridge never
appeared. The preload is bundled the same way and asserted against an explicit
allow-list (electron, events, timers, url).
Both assertions run in stage, in this repo's scripts/verify-bundle.ts, and
again as an "assert staged bundle" step in the release workflow.
A third, quieter bug: 38 of 40 shells shipped the default Electron icon,
because the old stage script copied build/icon.png only if it happened to
exist. stage now resolves an icon explicitly and fails when there isn't a
square PNG of at least 512px.
Install into a shell
Four wrapper files, one config slice, one package.json delta:
electron/main.cjs require('@burdenoff/desktop-shell/main').createDesktopApp(require('./desktop.config.cjs'));
electron/preload.cjs require('@burdenoff/desktop-shell/preload').installPreload(require('./desktop.config.cjs'));
electron/desktop.config.cjs module.exports = require('../native.config.json').desktop ?? require('../native.config.json');
electron-builder.config.cjs module.exports = require('@burdenoff/desktop-shell/builder').createElectronBuilderConfig(require('./electron/desktop.config.cjs'));
{
"main": "electron/main.cjs",
"dependencies": { "@burdenoff/desktop-shell": "<CalVer>" },
"devDependencies": {
"electron": "^43.4.1",
"electron-builder": "^26.0.12",
"playwright-core": "^1.50.0" // drives `burdenoff-desktop smoke`
}
}
Removed from the shell: the "build" key, electron-updater,
@capgo/electron-updater, @capacitor-community/electron,
scripts/prepare-electron.mjs, scripts/electron-launch.mjs,
scripts/electron-before-build.cjs, src/electronRuntime.ts.
Canonical scripts, templates and every {{TOKEN}} the fleet generator
substitutes: templates/README.md.
Verify with bunx burdenoff-desktop doctor — it must exit 0.
Renderer surface
The renderer is treated as untrusted: it runs shared MFE bundles and, in production, whatever web bundle CapGo OTA made active. What it can reach:
| Global | Contents |
|---|---|
window.__burdenoffDesktop | the desktop bridge (BurdenoffDesktopBridge in src/shared/types.ts) |
window.<product>Electron | legacy subset; legacyGlobal: false removes it |
window.electronUpdater | notifyAppReady() only |
Every ipcMain entry point checks that the calling frame is on the app origin
(or the dev server) before acting. OTA bundle selection — download, activation,
reset, channel and endpoint changes — happens only in the main process; the
CapGo package's own renderer API and IPC handlers are not installed. The
renderer's single OTA input is the app-ready signal, which both
__burdenoffDesktop.notifyAppReady() and electronUpdater.notifyAppReady()
send over IPC.appReady, and which the main process forwards to CapGo so the
running bundle is kept rather than rolled back.
Desktop focus mode (focusMode: true)
Products with focus sessions (TimeCampus) can turn on
window.__burdenoffDesktop.focus. The renderer, which holds the user's
session, calls focus.setState({ active, sessionId, endsAt, strict, blockedApps, doNotDisturb, trackAppSwitches }) every few seconds while a
session runs; the main process then:
- turns the OS do-not-disturb mode on and restores it afterwards, only if it
was the one that turned it on. macOS has no public Focus API, so this runs
two user-created Shortcuts (" Focus On" / " Focus Off");
GNOME uses
gsettings(show-banners); Windows has no supported API, so the status isunsupportedwith instructions; - samples the foreground app (macOS
osascript+ System Events, which needs the Automation permission; Windows PowerShell + user32; Linux X11xdotool, Wayland unsupported). A switch into a blocked app is hidden on strict sessions or warned about on standard ones and reported throughfocus.onBlockedApp; withtrackAppSwitchesevery finished foreground stretch is reported throughfocus.onAppSegment; - switches everything off again if the renderer stops refreshing for five minutes, so a crashed or logged-out renderer never leaves apps hidden or notifications muted.
focus.getStatus() / focus.onStatus() report what actually works on this
machine (on, needs-setup, unsupported, error) with a human-readable
detail. All helpers run through execFile with argument arrays and a
timeout; no renderer string reaches a shell or a script body.
Desktop idle detection (idleDetection: true)
Products with automatic capture (TimeCampus) can turn on
window.__burdenoffDesktop.idle. The renderer calls
idle.configure({ enabled, thresholdSeconds }) with the user's settings
(the threshold is clamped to 60..3600 seconds); the main process then samples
powerMonitor.getSystemIdleTime() (every quarter of the threshold, at most
every 15 seconds) and listens for screen lock, unlock, suspend and resume.
Every finished stretch the person was away, from their last keyboard or
mouse input to their first input after it (or to unlock / resume), is
reported through idle.onIdlePeriod({ startedAt, endedAt, reason }) with
reason inactive, locked or suspended. Stretches shorter than the
threshold are never reported, and turning detection off drops an open
stretch. idle.getStatus() / idle.onStatus() report whether the person is
away right now and whether this OS can report input inactivity at all
(supported). The monitor only reads the seconds since the last input; it
never sees which keys were pressed or where the pointer went.
Content Security Policy
Every HTML document the <scheme>://app protocol serves (the builtin bundle,
an OTA bundle, and the SPA fallback) carries two policies, built by
src/main/csp.ts from the runtime config:
| Header | Policy |
|---|---|
Content-Security-Policy | object-src 'none'; base-uri 'none'; frame-ancestors 'self' |
Content-Security-Policy-Report-Only | the full target policy: 'self', the gateway origins (https + wss), the SSO origin, and the third parties the shared MFEs load (Stripe.js, Razorpay Checkout; package-CDN stylesheets and fonts). Images and media may come from any https host |
Report-only violations do not block anything; the main process logs each
distinct one ([burdenoff-desktop:<product>:csp]), so packaged builds without
DevTools still show them. Package CDNs are deliberately absent from the target
script-src: scripts loaded from them are reported and should be bundled with
the app before script-src moves into the enforced policy. frame-ancestors
is 'self', not 'none', so the app can still frame its own documents; the
smoke command checks both the headers and a same-origin iframe.
CLI reference
burdenoff-desktop <command> [options]
-h, --help this help (or `<command> --help`)
-v, --version package version
stage
Builds electron/build/app — the folder electron-builder packages. Replaces
scripts/prepare-electron.mjs.
| Option | Default | Meaning |
|---|---|---|
--root <dir> | cwd | Shell root |
--env <name> | $BURDENOFF_DESKTOP_ENV | local | alpha | prod (staging/production accepted) |
--web-dist <dir> | dist | Web build to copy in |
--deps-mode <mode> | bundle | bundle inlines every dependency; install writes dependencies into the staged package.json and runs bun install --production --ignore-scripts inside it (documented fallback for a genuinely un-bundleable native module) |
--launch | off | Run launch afterwards |
It: reads the config → resolves the icon (fails if missing) → wipes
electron/build/app → copies the web build → bundles main.cjs + preload.cjs
→ asserts self-containment → writes electron/runtime-config.json and a
dependency-free package.json → stages icons + entitlements → on macOS
rebrands a dev copy of Electron.app (PlistBuddy + ad-hoc codesign) so the dock
icon and menu title are the product's.
launch
burdenoff-desktop launch [--root <dir>] [--env <name>] [-- <electron args>]
Spawns the local Electron on the staged app with stdio inherited, forwarding
every BURDENOFF_DESKTOP_* variable (legacy <PREFIX>_ELECTRON_* values are
normalised to the canonical names first). On macOS it prefers the rebranded
electron/build/<ProductName>.app. Replaces scripts/electron-launch.mjs.
No runtime-config argument is passed on the command line:
RUNTIME_ARG_PREFIXis the main → preload channel (URI-encoded JSON viaadditionalArguments, withcfAccessstripped). Main reads the stagedruntime-config.jsonitself, andBURDENOFF_DESKTOP_*overrides it.
smoke
burdenoff-desktop smoke [--root <dir>] [--packaged <dir>] [--timeout <ms>] [--screenshot <path>] [--no-tray] [--allow-missing-driver]
Launches the staged app (or a packaged build, e.g.
electron/dist/linux-unpacked) through playwright-core's Electron driver with
BURDENOFF_DESKTOP_SMOKE=1 and asserts:
| Check | Assertion |
|---|---|
window | a first window opens |
title | equals productName |
bridge | window.__burdenoffDesktop.product.id equals product |
diagnostics | globalThis.__burdenoffDesktopDiagnostics is published |
protocol | protocolRegistered |
menu | menu |
tray | tray (skipped with --no-tray — headless CI has no tray host) |
deep-link | app.emit('open-url', …, '<scheme>://app/smoke') reaches the renderer |
updater-surface | window.electronUpdater exposes notifyAppReady and nothing else |
csp | the app document carries the enforced baseline and a report-only policy (skipped off the app protocol) |
csp-frame | a same-origin iframe of an app document still loads |
updater / capgo / webRoot | reported, not asserted |
Writes electron/dist/smoke/window.png and prints a table; exits non-zero with
a readable report on failure. Run under xvfb-run on Linux. Without
playwright-core it exits 1, or 0 with --allow-missing-driver.
icons
burdenoff-desktop icons [--root <dir>] [--check]
Materialises electron/build/icons/ (electron-builder's buildResources):
icon.png from iconSource → public/icons/icon-512x512.png →
public/icons/icon-512.png → electron/icon.png → build/icon.png, plus
entitlements.mac.plist from this package. --check reports only.
electron-builder renders .icns/.ico from the PNG, so no image library is
involved.
doctor
burdenoff-desktop doctor [--root <dir>] [--json]
Read-only verification of a shell — config validity (every problem reported, not
just the first), thin wrappers, electron-builder.config.cjs, resolvable icons,
required/forbidden dependencies, canonical scripts, leftover legacy files, and
the removed "build" key. Prints a table; --json emits
{ root, product, ok, checks: [{ id, status, summary, problems }] } for the
fleet verifier. Exit 1 on any failure.
The expected package.json delta is read from this package's
templates/package-scripts.json, so the migration template and the verifier
can never drift.
Config contract
src/shared/types.ts is authoritative. The desktop slice of a shell's
native.config.json:
{
"desktop": {
"product": "vibecontrols", // slug: Linux executableName, staged package name
"productName": "VibeControls", // window title, tray, installer, artifact names
"appId": "com.vibecontrols.www", // NEVER rewrite — updater identity + Windows AUMID
"capgoAppId": "com.vibecontrols.desktop", // omit to disable CapGo OTA
"scheme": "vibecontrols", // deep links + Linux MimeType
"domain": "vibecontrols.com", // app origins + update feeds
"devServerPort": 5200,
"envPrefix": "VIBE", // legacy VIBE_ELECTRON_* fallback ("VIBE_ELECTRON" also accepted)
"appOrigins": { "prod": "https://admin.burdenoff.com" }, // optional override
"category": { "mac": "public.app-category.developer-tools", "linux": "Development" },
"links": { "docs": "…", "support": "…", "status": "…" },
"window": { "width": 1440, "height": 900, "minWidth": 960, "minHeight": 600 },
"closeToTray": { "win32": true, "linux": false },
"iconSource": "public/icons/icon-512x512.png",
"maintainer": "Name <email>",
"legacyGlobal": true,
"focusMode": false, // true exposes __burdenoffDesktop.focus (see below)
"idleDetection": false // true exposes __burdenoffDesktop.idle (see below)
}
}
electron/desktop.config.cjs is also accepted as the source and takes priority.
Environment
Every variable is BURDENOFF_DESKTOP_<KEY>, with a fallback to the legacy
<envPrefix>_ELECTRON_<KEY> so un-migrated shells and workflows keep working:
| Variable | Effect |
|---|---|
ENV | local | alpha | prod |
VERSION | CalVer baked into the staged package.json |
START_URL | dev-server origin the window loads |
UPDATE_FEED_URL | overrides the derived electron-updater feed |
GLOBAL_BASE_URL / WORKSPACE_BASE_URL / PUBLIC_STORE_BASE_URL | gateway overrides |
CF_ACCESS_CLIENT_ID / CF_ACCESS_CLIENT_SECRET | alpha only — baked in so electron-updater can reach the Access-gated alpha downloads host |
DEVTOOLS | open devtools on start |
Defaults derived from domain: prod feed https://<domain>/downloads/latest,
alpha feed https://alphadownloads.<domain>/downloads/latest, app origins
https://app.<domain> / https://alphaapp.<domain>.
CF Access credentials are baked into alpha builds only — possessing an alpha binary means being on the team. They are never written into a prod or local build, and the CLI redacts them in every log line.
electron-builder config
createElectronBuilderConfig(config, overrides?) derives the whole
configuration. Same artifacts as the old hand-copied "build" blocks
(AppImage + deb x64, universal dmg + zip, NSIS x64, asar,
${productName}-${version}-${arch}.${ext}), with the fleet fixes:
directories.buildResourcesiselectron/build/icons, notbuild/— several shells generatebuild/i18n-bundle.jsonthere;- Linux
.desktopgetsMimeType=x-scheme-handler/<scheme>;— without it deep links silently do nothing on Linux; npmRebuild: falsereplaces the oldbeforeBuild: () => falsehack;- macOS: hardened runtime,
gatekeeperAssess: false, entitlements, andnotarize: false— notarization is an explicitxcrun notarytoolCI step so a build never blocks on Apple credentials; - no
publishkey: the release workflow syncs to S3 itself.
appId is passed through verbatim. Shells differ (com.burdenoff.<p> vs
com.<p>.www) and rewriting one orphans every installed user's app data and
breaks the updater.
Release flow
bun run electron:build:<env> → burdenoff-desktop stage → assert staged
bundle → electron-builder --config electron-builder.config.cjs → sign →
smoke → S3 → CloudFront invalidation.
templates/workflows/release-electron.yml is the canonical pipeline
(workflow_dispatch with platforms/channel/version/dry_run/mark_latest):
a validate-inputs job every other job depends on (CalVer version, known
choice values, no secrets), a capgo-ota job (production only, in
electron-production, CLI from the integrity-locked
.github/tools/capgo-cli), linux / macos (macos-15) / windows build
jobs, and a publish job uploading downloads/<ver>/<os>/, mirroring
downloads/latest/<os>/ with stable un-versioned aliases, publishing the
auto-updater feeds (latest-mac.yml, latest.yml, latest-linux.yml plus the
installers they name) flat at downloads/latest/ where electron-updater's
generic provider looks for them, writing
manifest.json (built with jq) and invalidating CloudFront. macOS signs with
CSC_LINK and notarizes/staples via notarytool; Windows signs with
azure/artifact-signing-action (v2.0.0) and then re-computes the
latest.yml sha512/size, because signing rewrites the binary after
electron-builder hashed it and electron-updater would otherwise reject the
download. Installs are --frozen-lockfile, third-party actions are SHA-pinned,
and dispatch inputs reach shell steps only through env: — see
templates/README.md.
templates/workflows/native-smoke.yml is the ~6-minute path-filtered PR guard:
install → doctor → build → stage → electron-builder --dir --linux →
xvfb-run burdenoff-desktop smoke --packaged → screenshot artifact.
Migration checklist
- Add the
desktopslice tonative.config.json. - Stamp
electron/main.cjs,electron/preload.cjs,electron/desktop.config.cjs,electron-builder.config.cjs. - Apply
templates/package-scripts.json(mergescripts/deps, applyremove). - Delete
scripts/prepare-electron.mjs,scripts/electron-launch.mjs,scripts/electron-before-build.cjs,src/electronRuntime.ts, and the"build"key. - Ensure a square PNG ≥512px exists (
public/icons/icon-512x512.png). - Stamp both workflows.
bun install && bunx burdenoff-desktop doctor→ must exit 0.bun run electron:run:local— window, tray, menu, deep link.bunx burdenoff-desktop smoke(add--no-trayon headless Linux).- Release dry run:
Release Electronwithdry_run=true.
Developing this package
bun install
bun run sanity # lint + format + type:check + test + build
bun run build # dist/{main,preload,builder,config,cli}.cjs + d.ts
bun run scripts/verify-bundle.ts
test/fixture-shell/ is a minimal shell used by .github/workflows/smoke.yml
to exercise doctor, icons, stage and smoke end to end without depending
on a product repo.
License: proprietary — Burdenoff Consultancy Services Pvt. Ltd.