Desktop — architecture spec
⚠️ Status (2026-07 — this doc is largely superseded): Desktop is now a first-class ProjectType and compiles in the harness.
ElectronDockerBuilder+DesktopLocalStub+ thecloud-build-desktop/image were deleted; so were the short-lived per-typeHarnessDesktopBuilderand the wholeartifact/webdeployer/package. What exists today is ONEcloudbuild.HarnessBuilderwithProjectType: "desktop"— it POSTs/v1/projects/{id}/buildto the harness, which runs electron-builder in therunner-desktopcontainer, uploads every installer content-addressed to the artifact object store itself, and returns only a manifest. Atemplates/desktopappscaffold + therunner-desktopimage are wired; real.deb(70 MB) /.dmg(96 MB) /.exe(76 MB) installers verified. The canonical current model isproject-types+cloud-build; sections below marked superseded describe the RETIRED server-side docker / deployer model, kept for historical reference only. Per-OS code signing is still not wired.
Desktop is the third pipeline (see project-types).
It wraps Web output — Desktop cannot start until Web ships. The desktop
“build” is mostly orchestration: take a fully-built Web site, package it
with Electron’s main-process shim, sign per-OS, publish auto-updater
manifest.
Reference: how Cursor / VS Code / ChatGPT Desktop ship. All three: Chromium under the hood, electron-builder for packaging, Squirrel (Windows) + native pkg (macOS) + auto-updater feed.
Why Electron (not Tauri)
The reasoning is locked in project-types § Desktop:
short answer is AI builders can’t visually verify three different
webviews (WebKit / WebView2 / WebKitGTK), and the corpus of “code that
just works on Chromium” is much bigger than “code that works across
three engines”. Spending an extra 140 MB per install to get “one engine
the AI can be confident about” is the right trade.
Future flip condition: if Servo (Mozilla’s Rust browser engine, 2024 reboot) becomes production-ready in 2-3 years and Tauri adopts it as single-engine default, revisit.
Pipeline overview (current)
There is no separate “wrap the web build” step any more: a Desktop project is
its own scaffold (templates/desktopapp) whose npm run build produces both the
Vite renderer and the tsc’d Electron main, and electron-builder packages it in
the project’s own runner.
fabric-server: cloudbuild.HarnessBuilder{ProjectType: "desktop"}
POST /v1/projects/{id}/build {project_type:"desktop", version}
│
▼
┌──────────────────────────────────────────────────────┐
│ harness: preview.Manager.Build → buildDesktopInstallers │
│ (runner-desktop container, project's own worktree) │
│ 1. npm install (project's own deps) │
│ 2. npm run build → vite renderer + tsc main │
│ 3. npx electron-builder --linux │
│ -c.extraMetadata.version=<semver from version> │
│ 4. tar the TOP-LEVEL files of release/ │
│ (.deb / latest*.yml / .blockmap — not the fat │
│ linux-unpacked/ dir) │
└──────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ harness: uploadManifest │
│ - Put() each file into the artifact object store, │
│ keyed by sha256 (_artifacts/<hex>) │
│ - returns artifact.Manifest{entry, files[], size} │
│ NOTHING streams back through fabric-server │
└──────────────────────────────────────────────────────┘
│
▼
fabric-server: record manifest on app_bundles.manifest (jsonb),
serve installers per-path at
GET /web-preview/<project>/<version>/<installer>
by resolving path → hash → object store.
│
▼
user downloads + installs
│
▼
On startup: electron-updater polls the stable feed URL
(/v1/updates/<projectid>/manifest.json → 302 to the per-version
deploy root), compares versions, downloads if newer.
Signing is not part of this pipeline yet — see § Code signing for the intended model.
What’s scaffolded
| Component | Location | Status |
|---|---|---|
RUNTIME_KIND_DESKTOP_ELECTRON | shared/proto/project.proto | ✅ added |
RuntimeKindDesktopElectron enum | generated from shared/proto/project.proto | ✅ |
| Desktop scaffold | templates/desktopapp (vite renderer + electron main + electron-builder config) | ✅ |
runner-desktop image | fabric/harness/runner-images/desktop/ (debian + electron-builder + wine) | ✅ |
| Harness desktop packager | fabric/harness/internal/preview/desktop.go (buildDesktopInstallers) | ✅ |
| Server-side client | cloudbuild.HarnessBuilder{ProjectType: "desktop"}, fabric/server/internal/cloudbuild/harness.go | ✅ wired in cmd/server/main.go → Service.Builders[RUNTIME_KIND_DESKTOP_ELECTRON] |
| Installer serving | app_bundles.manifest (goose 00013_bundle_manifest) + handler/webpreview.go | ✅ |
cloudbuild.DesktopLocalStubcloudbuild.ElectronDockerBuildercloud-build-desktop imageartifact/webdeployer | — | ❌ deleted (superseded by the harness path above; publish now REQUIRES a harness, there is no local stub) |
What’s still missing for Desktop GA
D1. Real Electron wrapper builder ✅ done (implementation since replaced)
Superseded. The text below describes the retired
ElectronDockerBuilderand is kept only so old commits/PRs read sensibly. That builder, itscloud-build-desktopimage,MultiResult, and theweb/-prefix composition rule are all deleted. Today: the harness’sbuildDesktopInstallersruns electron-builder against the project’s own Desktop scaffold — there is no web-output remapping step at all.
Landed. ElectronDockerBuilder mirrored Web’s WebDockerBuilder:
- Required
Source.Filesto include aweb/prefixed tree (caller composed Web output → Desktop input by prefix-remapping the Web MultiResult).hasWebPrefix()enforced this at entry. - Synthesized an Electron skeleton (
main.js+package.json+ electron-builder config) at the bind-mount root. - Ran
electron-builderinsideappunvs/cloud-build-desktop:latest, targeting platforms fromElectronDockerBuilder.Targets. - Walked
/work/dist/for installers, synthesizedupdater-manifest.json, returnedMultiResult{Entry: "updater-manifest.json"}.
Optional macOS signing: MacSigningIdentity forwarded as CSC_NAME
env to electron-builder (cert itself bind-mounted via keychain or
p12 — operator concern).
D2/D4/D5 — all landed (✅ or operator-runbook)
Operator runbook at desktop-launch-runbook
covers:
- D2 cert procurement — Apple Developer ID (~3-14 days), Windows EV cert + HSM (~1-3 weeks). Process docs only; not code.
- D4 host shell “Open in Desktop” ✅ — landed iOS + Android.
PreviewView for Desktop projects shows web preview + download panel
with macOS/Windows/Linux buttons that open installer URLs via
system browser. Manifest fetched from
<bundle.URI>updater-manifest.json— subject to the same open gap noted under D3. - D5 build pool — Aliyun ECI for Linux/Windows-via-wine containers, separate Mac mini (or MacStadium) for macOS .dmg builds. Pool size starts at 4 ECI tasks; macOS is single host with GH Actions macOS fallback.
What’s still TODO post-Desktop GA:
D2 historical notes
Each desktop OS has its own signing model:
| OS | Signing requirement | Cost | Notes |
|---|---|---|---|
| macOS | Developer ID Application cert | $99/yr | Required for non-quarantined .app; notarization via altool |
| Windows | EV code-signing cert | $300-500/yr | Required to avoid SmartScreen warnings; HSM-backed cert |
| Linux | No requirement; can self-sign for repos | free | .deb / .rpm; AppImage signed via GPG optional |
Desktop GA scope: support unsigned Linux + signed macOS. Defer signed Windows to a later Desktop release — the EV cert procurement + HSM setup is ~2 weeks lead time and shouldn’t block macOS users.
Cert storage: HashiCorp Vault or aliyun KMS — never inline in the docker image.
D3. Auto-updater integration — endpoint landed, feed file OPEN
fabric-server serves the auto-updater feed endpoint at:
GET /v1/updates/:projectid/manifest.json
→ 302 redirect to <bundle.URI>updater-manifest.json
bundle.URI is the per-version publish root
(<WebPreviewBaseURL>/<project>/<version>/), so the redirect lands on the
manifest-served file route.
⚠️ Open gap. The retired
ElectronDockerBuilder.buildUpdaterManifestsynthesized thatupdater-manifest.json; the harness packager (buildDesktopInstallers) does not — it ships electron-builder’s ownlatest*.ymlfeed files instead. So the redirect currently points at a file the publish no longer produces. Either the harness must emitupdater-manifest.json, or the endpoint must redirect tolatest-linux.yml/latest.yml. Not yet resolved.
Endpoint properties:
- Public (no auth) — desktop apps poll without user context.
- Stable URL across publishes — install-time configured once; redirect target rotates per publish.
- Cache-Control: max-age=60 — sub-minute publish-to-update-prompt latency while still letting CDN serve hot.
- Runtime gate — 404 on non-Desktop projects so polling a wrong project stops cleanly.
Wired in cmd/server/main.go via handler.RegisterUpdatesRoutes.
7 tests cover happy path + 4 negative cases.
UX of the desktop app itself: electron-updater config provider: 'generic' + url: 'https://api.appunvs.com/v1/updates/<projectid>/'
(the manifest.json suffix is added by electron-updater). Silent
download, prompt on next launch. No background restarts.
D4. Preview tab “Open in Desktop App” UX
In fabric-server’s Project response for RuntimeKindDesktopElectron,
include a download URL alongside the preview URL. Host shell Preview
tab renders a “Download for macOS / Windows / Linux” panel rather
than embedded preview.
(Mobile host shells cannot run desktop builds, so this is informational only on mobile — paste into computer to download.)
D5. Per-OS test matrix
CI cost reminder: macOS runners are 10× ubuntu-latest. Test plan:
- Linux .deb install test on ubuntu-latest (cheap, 1×)
- macOS .dmg install test on macos-latest (only on tag releases, not every PR)
- Windows .exe install test on windows-latest (only on tag releases)
Per-PR: unit-test the harness packager + the server-side seam
(go test ./fabric/harness/internal/preview/ ./fabric/server/internal/cloudbuild/).
Per-tag: full matrix.
Decision points before D1 starts
-
electron-builder vs forge? Both exist. electron-builder is more popular + has wider plugin ecosystem. Use electron-builder. No interesting trade-off.
-
Auto-updater channel strategy? Need at minimum a
stablechannel. Deferbetachannel to a later Desktop release — users on stable updates stay on stable; we can add beta opt-in later without breaking anyone. -
Window chrome / system tray / menu bar customization in scaffold? Decision: ship the minimum default (single window, default menu bar). AI can customize via electron API calls if the user asks. Don’t force a custom chrome onto every project.
Connection to Web
Superseded. The A/B “who runs the web build first” question below is moot: neither option was taken. A Desktop project is its own scaffold whose
npm run buildproduces the Vite renderer inside the same runner build, so there is no separate Web build to compose with and noweb/-prefixed input tree. Desktop still shares Web’s technology (Vite renderer, same Mortar SDK), but not its build artifacts.
Historical framing — Desktop explicitly depends on Web succeeding for the same project: the desktop build wraps the web build output. Two implementation options were considered:
- A. Implicit: when user publishes a Desktop project, fabric-server internally runs Web build first, then Desktop wraps it
- B. Explicit: Desktop projects have two artifacts — a Web “shadow” build (always created) and a Desktop wrapper. Both surface in the publish response
Connection to Mortar BaaS
Same SDK as Web — @mortar-ai/client browser bundle runs unchanged
inside Electron’s renderer process. No desktop-specific code.
Caveat: Mortar’s CORS-allowed origins need to include
file:// for local Electron pages (or wrap loaded HTML in a custom
protocol — app://, the cleaner pattern). TODO: confirm Mortar’s
CORS middleware tolerates app:// schemes.
Code signing
Desktop ships (planned) installers for three operating systems and each has its own
signing model. This section covers all three — Windows EV signing
(merged from the former v3-windows-signing), macOS notarization,
and Linux .deb GPG signing.
Windows EV code signing
How Desktop gets signed for Windows so that SmartScreen doesn’t scare off first-time users.
EV vs OV
Windows SmartScreen tracks a per-certificate reputation score. With an OV (Organization Validation) certificate, every fresh release starts at zero reputation and shows the “Windows protected your PC” full-screen warning until ~thousands of users have run the binary without complaint — easily weeks of friction.
An EV (Extended Validation) certificate bypasses this immediately: the SmartScreen team grants EV certs starting reputation, so the very first user gets a clean install dialog. For a launch where every first-run dropoff costs us a real user, EV is the only sensible choice.
Vendor options
| Vendor | Notes |
|---|---|
| DigiCert | Recommended. Fastest validation in China (~3 days when documents are in Chinese + English); best Azure Key Vault integration. |
| Sectigo | Cheapest; slower China validation (~2 weeks); |
| SSL.com | Good HSM integration but Chinese-entity validation is uneven. |
Validation requires Dun & Bradstreet (邓白氏) registration for the issuing entity. If we don’t have a DUNS number yet, start that in parallel — it gates the cert issue regardless of vendor.
Hardware
- Azure Key Vault HSM — preferred for CI. Cert lives in the HSM, CI signs over a remote API, key material never leaves Azure.
- YubiKey — fine for a single developer doing local releases, but doesn’t scale to CI without a physical machine attached. Skip.
Build pipeline
signtool.exe with HSM-backed cert. The cert can’t leave the HSM;
we sign via Azure’s remote signing API, which signtool reaches
through the Azure.CodeSigning.Dlib plugin:
signtool sign ^
/v ^
/fd SHA256 ^
/tr http://timestamp.digicert.com ^
/td SHA256 ^
/dlib "Azure.CodeSigning.Dlib.dll" ^
/dmdf signing-metadata.json ^
build\AppUnvs-Setup.exe
signing-metadata.json references the Key Vault URI + cert profile;
no key material on the build agent.
Verify
> signtool verify /pa /v build\AppUnvs-Setup.exe
Verifying: build\AppUnvs-Setup.exe
Signature Index: 0 (Primary Signature)
Hash of file (sha256): ...
Signing Certificate Chain:
Issued to: DigiCert Trusted Root G4
Issued to: AppUnvs Inc. (EV)
The signature is timestamped: ...
Successfully verified: build\AppUnvs-Setup.exe
The (EV) marker on the leaf cert is the one to check — without it
SmartScreen treats the binary as OV-signed.
Cost
| Item | Annual cost |
|---|---|
| DigiCert EV Code Signing (1 year) | ~$300 |
| Azure Key Vault Premium HSM | |
| Total | ~$360/year |
Trivial compared to the user-acquisition cost of one SmartScreen-warned first-run.
CI integration
GitHub Actions pulls the cert from Azure Key Vault via federated identity (OIDC) — no long-lived secrets in the repo:
jobs:
sign:
runs-on: windows-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Sign installer
run: |
signtool sign /v /fd SHA256 /tr http://timestamp.digicert.com `
/td SHA256 /dlib Azure.CodeSigning.Dlib.dll `
/dmdf signing-metadata.json build\AppUnvs-Setup.exe
The federated trust is configured once in Azure (App Registration → Federated credentials → GitHub Actions), then the workflow gets a short-lived token on each run.
Later-release deferral
Desktop launch can ship unsigned and accept the SmartScreen warning for the first ~1k installs — Microsoft’s reputation system auto-clears the warning after enough clean runs accumulate. The argument for deferring EV to a later Desktop release: DUNS + DigiCert validation can run in parallel with Desktop beta, and unsigned-binary first-run friction is recoverable (annoying-but-installable), whereas slipping Desktop’s ship date for the cert is not.
Caveat: if telemetry shows >30% drop-off at the SmartScreen dialog, escalate to “block Desktop GA on EV.” We’ll know by week 2 of beta.
Reference: Microsoft, SignTool.exe (Sign Tool): https://learn.microsoft.com/en-us/dotnet/framework/tools/signtool-exe
macOS notarization
macOS Gatekeeper requires every distributed .app / .dmg to be
both code-signed with a Developer ID Application certificate and
notarized by Apple before it will run without the “unidentified
developer” prompt.
Apple Developer enrollment
- Apple Developer Program enrollment — https://developer.apple.com. Organization account preferred (individual account works but harder to transfer later). $99/year billed in USD. Verification: ~1-3 business days for individual, up to 2 weeks for organization (D-U-N-S number needed).
- Generate “Developer ID Application” certificate from developer.apple.com → Certificates → +.
- Export from Keychain as
.p12(private key + cert), set a strong password. Store the.p12+ password in HashiCorp Vault (or Aliyun KMS) underappunvs/signing/macos-developer-id. - App-specific password for notarytool: developer.apple.com →
Account → App-Specific Passwords → +. Store under
appunvs/signing/macos-altool-password. - Team ID + bundle ID prefix: from developer.apple.com →
Membership. These go into
electron-builder.ymlfor Desktop builds.
Total cert procurement time: 3-14 days depending on verification.
Sign + notarize pipeline
electron-builder signs the .dmg automatically when the
Developer ID Application cert is in the build host’s keychain and
CSC_NAME is set:
CSC_NAME="Developer ID Application: AppUnvs Inc. (TEAMID)" \
electron-builder --mac --publish never
Then notarytool uploads the signed .dmg to Apple, waits for the
ticket, and staples it back onto the artifact:
xcrun notarytool submit dist/AppUnvs-1.2.3.dmg \
--apple-id "ci@appunvs.com" \
--team-id "TEAMID" \
--password "@keychain:appunvs-altool" \
--wait
xcrun stapler staple dist/AppUnvs-1.2.3.dmg
Verify with spctl -a -t open --context context:primary-signature dist/AppUnvs-1.2.3.dmg → expect accepted, source=Notarized Developer ID.
Build host
notarytool needs macOS Sonoma 14+ on the build host. Options:
dedicated Mac mini in the office ($1000 one-time), AWS EC2
$25 minimum/instance, 24h Apple license minimum), or
GitHub Actions mac1.metal (macos-14 runner (~$0.16/min for M-series, 10×
ubuntu cost — only for tag releases).
Desktop GA plan: single office Mac mini as the dedicated builder; GH
Actions macos-14 as fallback when the mini is offline.
Linux .deb signing
.deb signing is optional for Desktop GA but recommended once we
host an apt repo.
When you need it
| Distribution channel | Signing required? |
|---|---|
Direct download (.deb on website / OSS) | No — users sudo dpkg -i and dpkg never checks signatures |
apt repo (custom + apt-get install appunvs) | Yes — apt verifies the Release file’s GPG signature |
| Snap Store | Optional — Snap signs the artifact on upload; useful to also sign the source .deb for parity |
Desktop GA scope: ship .deb as a direct download → no signing needed.
Defer apt repo + Snap Store distribution to a later Desktop release.
GPG signing with dpkg-sig
When a later Desktop release stands up the apt repo, sign .deb packages with
dpkg-sig:
gpg --gen-key # one-time, dedicated package-signing key
gpg --armor --export ops@appunvs.com > appunvs-archive-keyring.gpg
dpkg-sig --sign builder dist/appunvs_1.2.3_amd64.deb
dpkg-sig --verify dist/appunvs_1.2.3_amd64.deb # expect "GOODSIG"
Then sign the apt repo’s Release file (the actually-verified
artifact):
cd /var/www/apt/dists/stable
gpg --default-key ops@appunvs.com --armor --detach-sign \
--output Release.gpg Release
gpg --default-key ops@appunvs.com --clearsign \
--output InRelease Release
Store the package-signing GPG key in HashiCorp Vault under
appunvs/signing/linux-deb-gpg. Rotate annually.
Reference: Debian, Secure APT: https://wiki.debian.org/SecureApt.