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 inelectron/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-contained → electron/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 chromium → electron/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; elsedotnet run --project src/WorkWingman.Api. - UI:
path.join(process.resourcesPath, 'frontend', 'browser', 'index.html')— if present,loadFileit; else the source-treedist/, else thehttp://localhost:4200dev 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.extraResourcescopiesresources/api→api,../frontend/dist/frontend→frontend, andresources/playwright-browsers→playwright-browsers, which is whymain.jsfinds them underprocess.resourcesPath. For the packaged API only,main.jssetsPLAYWRIGHT_BROWSERS_PATH=<resources>/playwright-browsersso 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 toelectron/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.
Windows-without-admin build note (winCodeSign symlink error)¶
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 withapp-builder-bin5.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 byelectron/build/make-installer-art.ps1(re-run to change art/copy).license→build/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), guardedelectron-updatercalls, and thewingman-updates:*IPC handlers. Every call is guarded byapp.isPackaged && electron-updater present, so a dev checkout reports statusunsupportedinstead of throwing.electron/preload.js— exposeswindow.workWingmanUpdates(get / setMode / check / download / install / onEvent) overipcRenderer.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.json → build.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
.exeshould 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 loopbackhttp://127.0.0.1:5211, then loads the Angular shell. Verify the API is up withcurl http://127.0.0.1:5211/api/jobs(200) — gated routes like/api/vault/statuscorrectly answer401without the token header. - Uninstall:
%LOCALAPPDATA%\Programs\WorkWingman\Uninstall WorkWingman.exe, or via Settings → Apps.
Related¶
- architecture — how the shell, API, and UI fit together.
- getting-started — running from source for development.
- references — electron-builder and the toolchain links.
- Doc index: ../README.md