Skip to content

Packaging & the shareable installer (technical)

Friendlier version: ../plain/packaging.md

WorkWingman ships as a single Windows installer .exe that a non-developer can double-click. It installs and runs with no .NET SDK, no Node.js, and no internet on first launch, because the installer carries a self-contained copy of the API (the .NET runtime is bundled), the already-built Angular bundle, and the Playwright Chromium browser that drives the LinkedIn / Workday sign-in automation — all three inside the Electron app.

Why the browser must ship in the box. The vault "connect LinkedIn" flow launches a real (headed) Chromium via Playwright. If Chromium isn't present, Playwright tries to download ~130 MB mid-request; the frontend times out first and the UI shows the misleading "Could not reach the local engine" (the engine is up — its browser was missing). Bundling Chromium and pointing the API at it (PLAYWRIGHT_BROWSERS_PATH, set in electron/main.js) makes sign-in work instantly and offline on a clean machine.

What's inside the shell

Three pieces are stitched together at build time (see architecture):

Piece Source Where it lands in the packaged app
Electron main + preload electron/main.js, electron/preload.js resources/app.asar
.NET API (self-contained, win-x64) dotnet publish -r win-x64 --self-containedelectron/resources/api/ resources/api/WorkWingman.Api.exe
Angular production bundle frontend/dist/frontend/ resources/frontend/browser/index.html
Playwright Chromium (headed login browser) playwright.ps1 install chromiumelectron/resources/playwright-browsers/ resources/playwright-browsers/chromium-*

electron/main.js resolves these from process.resourcesPath when packaged, and falls back to the source tree (dotnet run, frontend/dist/) in a dev checkout:

  • API: path.join(process.resourcesPath, 'api', 'WorkWingman.Api.exe') — if present, spawn it; else dotnet run --project src/WorkWingman.Api.
  • UI: path.join(process.resourcesPath, 'frontend', 'browser', 'index.html') — if present, loadFile it; else the source-tree dist/, else the http://localhost:4200 dev server.

The loopback-token security is unchanged: the API writes a per-launch token to %LOCALAPPDATA%\WorkWingman\api-token, electron/preload.js reads it and forwards it as the X-WorkWingman-Token header. Only a process running as the user can read that file.

Why the API is published, not compiled into the csproj

src/WorkWingman.Api/WorkWingman.Api.csproj deliberately carries no <RuntimeIdentifier> or <SelfContained>. CI runs plain dotnet build / dotnet test and the Electron dev path runs dotnet run; hardcoding a RID would break framework-dependent CI and force a runtime download on every build. Self-contained-ness is therefore a publish-time decision, passed as CLI args:

dotnet publish src/WorkWingman.Api -c Release -r win-x64 --self-contained true -o electron/resources/api

The published output (~233 MB, includes the .NET runtime) is a build artifact — it is .gitignored (electron/resources/) and never committed.

electron-builder config

Packaging is driven by electron-builder (dev dependency in electron/package.json), configured in the "build" field:

  • appId: dev.workwingman.desktop, productName: WorkWingman.
  • Windows target: NSIS (assisted, oneClick: false), per-user (perMachine: false, so no admin needed), install dir changeable, desktop + Start-menu shortcuts.
  • icon: build/icon.ico — a generated multi-resolution placeholder (16–256 px). Replace with a real brand icon later; no real code-signing cert is used.
  • extraResources copies resources/apiapi, ../frontend/dist/frontendfrontend, and resources/playwright-browsersplaywright-browsers, which is why main.js finds them under process.resourcesPath. For the packaged API only, main.js sets PLAYWRIGHT_BROWSERS_PATH=<resources>/playwright-browsers so Playwright loads the bundled Chromium instead of hitting the per-user cache (which is empty on a fresh machine).
  • artifactName: WorkWingman-Setup-${version}.${ext}, output to electron/dist/.

npm scripts:

  • npm run dist → NSIS installer (electron-builder --win nsis --publish never).
  • npm run dist:portable → single portable .exe (no installer).

Full build recipe (from a clean checkout)

One command (recommended — runs all four steps in order so the browser step can't be skipped):

pwsh electron/build-installer.ps1
# → electron/dist/WorkWingman-Setup-<version>.exe

Equivalent manual steps:

# 1. Frontend production bundle
cd frontend && npm ci && npm run build && cd ..

# 2. Self-contained API into the shell's resources dir
dotnet publish src/WorkWingman.Api -c Release -r win-x64 --self-contained true -o electron/resources/api

# 3. Playwright Chromium into the bundled browser folder (uses the driver just published in step 2)
PLAYWRIGHT_BROWSERS_PATH=electron/resources/playwright-browsers \
  pwsh electron/resources/api/playwright.ps1 install chromium

# 4. Installer
cd electron && npm install && npm run dist
# → electron/dist/WorkWingman-Setup-<version>.exe

The published output is a build artifact — electron/resources/ (API + browsers, ~400 MB total, adding ~170 MB of Chromium) is .gitignored and never committed. The Chromium bundle grows the installer by roughly 100–150 MB compressed.

On a machine without admin rights or Developer Mode, electron-builder's first NSIS build can fail extracting its winCodeSign helper: the archive contains macOS .dylib symlinks, and Windows refuses to create symlinks without the privilege (Cannot create symbolic link: A required privilege is not held by the client). We are not code-signing, but the helper is still fetched because NSIS uses rcedit from it to stamp the exe.

Workaround (automatic): build-installer.ps1 installs a 7za shim (electron/build/make-7za-shim.ps1) into node_modules/7zip-bin/win/x64/ that relays to the real 7za.exe and rewrites exit code 2 → 0 only when every extraction error is a symlink-creation error — everything Windows actually needs (signtool, rcedit) extracts fine; only the two macOS .dylib symlinks fail. Real corruption still fails the build. The shim lives inside node_modules, so any npm reinstall wipes it; the build script re-installs it idempotently before npm run dist.

History: an older workaround pre-seeded the cache as winCodeSign-2.6.0/. That stopped working with app-builder-bin 5.0.0-alpha.x (electron-builder 25.1.8), which changed its cache layout and re-downloads regardless — the shim fixes it at the extraction layer instead. The cleaner long-term fix remains enabling Windows Developer Mode (allows unprivileged symlink creation). This only affects the build host — the produced installer runs anywhere.

Trust & code signing (the real "not sketchy" lever)

Per the 2026-07-07 model council, the installer format barely affects tester trust — the signature is the whole game. An unsigned .exe trips SmartScreen's "Windows protected your PC — Unknown Publisher" prompt regardless of NSIS/MSI/MSIX, which is exactly what makes a beta feel sketchy. We stay on NSIS (best per-user, no-admin, 400 MB, electron-updater fit) and add signing + wizard polish rather than switching format.

Recommended: Azure Trusted Signing (~$10/mo, no hardware token, clean CI). To enable, set win.azureSignOptions in electron/package.json and provide the Azure creds via env before npm run dist:

"win": {
  "azureSignOptions": {
    "publisherName": "WorkWingman",
    "endpoint": "https://<region>.codesigning.azure.net/",
    "codeSigningAccountName": "<account>",
    "certificateProfileName": "<profile>"
  }
}

Requirements: a paid Azure subscription (not free/trial) and an Entra identity validation (individual or 3+ yr legal entity). win.publisherName must match the certificate subject exactly. Until the cert is provisioned the build stays unsigned and still works — signing is purely additive.

Do not buy an EV cert expecting a day-one clean install: Microsoft walked back EV's automatic SmartScreen bypass, so it now builds reputation like OV. Nothing clears SmartScreen instantly — signing starts a reputation clock that download volume advances. Pair signing with an HTTPS download page that shows the publisher name and the installer's SHA-256 as a cheap, zero-cost trust signal.

Wizard branding (assisted NSIS)

The installer is an assisted (multi-step) wizard (oneClick: false, perMachine: false, so no UAC shield). Branding assets live in electron/build/ and are wired in nsis:

  • installerSidebar.bmp (164×314) + installerHeader.bmp (150×57) — generated from the app's warm palette by electron/build/make-installer-art.ps1 (re-run to change art/copy).
  • licensebuild/license.txt — a short, friendly beta agreement that leads with the local-first / your-data-stays-on-your-machine promise; its presence reads as legitimate.
  • runAfterFinish: true — the finish page offers "Launch WorkWingman", checked by default.

Wizard flow the tester sees: Welcome (branded sidebar) → License → Install location (%LOCALAPPDATA%\Programs\WorkWingman, changeable, no admin) → Progress → Finish + launch.

Auto-update

Updates ride the NSIS target via electron-updater (the council-endorsed path). The user picks the behavior in Settings → Updates, power-user-first:

Mode On launch / periodically Download Install
Manual never checks on its own on click on click
Notify me (default) checks quietly, notifies on click on click
Automatic checks automatic on next quit (or "Restart & install now")

Wiring:

  • electron/updater.js — mode persistence (%APPDATA%/WorkWingman/update-config.json), guarded electron-updater calls, and the wingman-updates:* IPC handlers. Every call is guarded by app.isPackaged && electron-updater present, so a dev checkout reports status unsupported instead of throwing.
  • electron/preload.js — exposes window.workWingmanUpdates (get / setMode / check / download / install / onEvent) over ipcRenderer.
  • frontend/src/app/core/update.service.ts + the Settings Updates card — the UI. In a plain browser the bridge is absent and the card degrades to "managed by the desktop app".

The release feed is configured in electron/package.jsonbuild.publish as a generic provider at https://updates.workwingman.app/beta. electron-builder embeds this into the app and emits latest.yml next to the installer at build time. To actually serve updates: host the new WorkWingman-Setup-<version>.exe and latest.yml at that URL (any static HTTPS host / S3 / the download page). electron-updater compares the running version to latest.yml and, per the user's mode, notifies / downloads / installs. Until that URL serves those files, check returns not-available/error and the app keeps working — the feature is inert, not broken.

Signing note: for a fully seamless auto-update the delta .exe should be signed with the same cert as the installed app (see Trust & code signing above). Unsigned updates still apply, but re-trip SmartScreen on each version.

Installing / running locally

  • Silent per-user install: WorkWingman-Setup-<version>.exe /S (NSIS silent flag; installs to %LOCALAPPDATA%\Programs\WorkWingman, no admin prompt).
  • Interactive: double-click; choose the install directory.
  • Launch: %LOCALAPPDATA%\Programs\WorkWingman\WorkWingman.exe. On start it spawns the bundled API, waits for loopback http://127.0.0.1:5211, then loads the Angular shell. Verify the API is up with curl http://127.0.0.1:5211/api/jobs (200) — gated routes like /api/vault/status correctly answer 401 without the token header.
  • Uninstall: %LOCALAPPDATA%\Programs\WorkWingman\Uninstall WorkWingman.exe, or via Settings → Apps.