Machine-translated draft — terminology + flow still being reviewed.

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 + the cloud-build-desktop/ image were deleted; so were the short-lived per-type HarnessDesktopBuilder and the whole artifact/webdeployer/ package. What exists today is ONE cloudbuild.HarnessBuilder with ProjectType: "desktop" — it POSTs /v1/projects/{id}/build to the harness, which runs electron-builder in the runner-desktop container, uploads every installer content-addressed to the artifact object store itself, and returns only a manifest. A templates/desktopapp scaffold + the runner-desktop image are wired; real .deb (70 MB) / .dmg (96 MB) / .exe (76 MB) installers verified. The canonical current model is project-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

ComponentLocationStatus
RUNTIME_KIND_DESKTOP_ELECTRONshared/proto/project.proto✅ added
RuntimeKindDesktopElectron enumgenerated from shared/proto/project.proto
Desktop scaffoldtemplates/desktopapp (vite renderer + electron main + electron-builder config)
runner-desktop imagefabric/harness/runner-images/desktop/ (debian + electron-builder + wine)
Harness desktop packagerfabric/harness/internal/preview/desktop.go (buildDesktopInstallers)
Server-side clientcloudbuild.HarnessBuilder{ProjectType: "desktop"}, fabric/server/internal/cloudbuild/harness.go✅ wired in cmd/server/main.goService.Builders[RUNTIME_KIND_DESKTOP_ELECTRON]
Installer servingapp_bundles.manifest (goose 00013_bundle_manifest) + handler/webpreview.go
cloudbuild.DesktopLocalStub · cloudbuild.ElectronDockerBuilder · cloud-build-desktop image · artifact/webdeployerdeleted (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 ElectronDockerBuilder and is kept only so old commits/PRs read sensibly. That builder, its cloud-build-desktop image, MultiResult, and the web/-prefix composition rule are all deleted. Today: the harness’s buildDesktopInstallers runs electron-builder against the project’s own Desktop scaffold — there is no web-output remapping step at all.

Landed. ElectronDockerBuilder mirrored Web’s WebDockerBuilder:

  1. Required Source.Files to include a web/ prefixed tree (caller composed Web output → Desktop input by prefix-remapping the Web MultiResult). hasWebPrefix() enforced this at entry.
  2. Synthesized an Electron skeleton (main.js + package.json + electron-builder config) at the bind-mount root.
  3. Ran electron-builder inside appunvs/cloud-build-desktop:latest, targeting platforms from ElectronDockerBuilder.Targets.
  4. Walked /work/dist/ for installers, synthesized updater-manifest.json, returned MultiResult{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:

OSSigning requirementCostNotes
macOSDeveloper ID Application cert$99/yrRequired for non-quarantined .app; notarization via altool
WindowsEV code-signing cert$300-500/yrRequired to avoid SmartScreen warnings; HSM-backed cert
LinuxNo requirement; can self-sign for reposfree.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.buildUpdaterManifest synthesized that updater-manifest.json; the harness packager (buildDesktopInstallers) does not — it ships electron-builder’s own latest*.yml feed files instead. So the redirect currently points at a file the publish no longer produces. Either the harness must emit updater-manifest.json, or the endpoint must redirect to latest-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

  1. electron-builder vs forge? Both exist. electron-builder is more popular + has wider plugin ecosystem. Use electron-builder. No interesting trade-off.

  2. Auto-updater channel strategy? Need at minimum a stable channel. Defer beta channel to a later Desktop release — users on stable updates stay on stable; we can add beta opt-in later without breaking anyone.

  3. 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 build produces the Vite renderer inside the same runner build, so there is no separate Web build to compose with and no web/-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

VendorNotes
DigiCertRecommended. Fastest validation in China (~3 days when documents are in Chinese + English); best Azure Key Vault integration.
SectigoCheapest; slower China validation (~2 weeks);
SSL.comGood 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

ItemAnnual cost
DigiCert EV Code Signing (1 year)~$300
Azure Key Vault Premium HSM$60 ($5/month)
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

  1. Apple Developer Program enrollmenthttps://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).
  2. Generate “Developer ID Application” certificate from developer.apple.com → Certificates → +.
  3. Export from Keychain as .p12 (private key + cert), set a strong password. Store the .p12 + password in HashiCorp Vault (or Aliyun KMS) under appunvs/signing/macos-developer-id.
  4. App-specific password for notarytool: developer.apple.com → Account → App-Specific Passwords → +. Store under appunvs/signing/macos-altool-password.
  5. Team ID + bundle ID prefix: from developer.apple.com → Membership. These go into electron-builder.yml for 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 mac1.metal ($25 minimum/instance, 24h Apple license minimum), or GitHub Actions 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 channelSigning 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 StoreOptional — 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.