Desktop App Architecture

Status: Phase 1 Linux MVP fully wired. The Tauri v2 shell ships, 88 adapter families are linked into the router (Waves 0-3 complete), TauriEnv::fetch_rendered is implemented via a hidden Tauri WebviewWindow (same WebKitGTK engine as the main UI — no SaaS dependency), the updater is armed with a real Ed25519 keypair, and all three Linux bundles (deb/rpm/AppImage) produce cleanly from the tag-driven release workflow. Remaining open work is Phase 2 multi-OS (macOS + Windows) and AUR PKGBUILD activation.

This doc supersedes the desktop sections of jobcache/wiki/content/architecture/desktop-edge-runtime.md on points where they disagree. The Jobcache doc was written before the workspace-bound + network-loaded model crystallised.

STATUS — AS OF 2026-05-30

SHIPPED

  • Tauri v2 shell at ui/desktop/@cv/desktop package with tauri-cli ^2 wrapper scripts, src-tauri/ depending on tauri 2 + http/updater/single-instance/os/fs plugins + jobcache + every adapter crate. tauri.conf.json points the main window at https://careervector.corbet.ch/ with CSP allowlist, ships deb/rpm/appimage bundle targets, and embeds the real Ed25519 updater pubkey. local-shell/index.html splash fallback is in place. cargo tauri info all-green; cargo check -p careervector-desktop clean.
  • 88 adapter families wired into runtime::router::Router::default (ui/desktop/src-tauri/src/runtime/router.rs):
    • Wave 0 (19, ATS APIs / static HTML): ashby, bamboohr, breezyhr, eightfold, greenhouse, homerun, icims, jibe, lever, oracle-hcm, prospective, recruitee, remoteok, rss-feed, sf-rmk, smartrecruiters, teamtailor, workable-widget, workday.
    • Wave 1 (8, additional static HTML): custom-html, joincom, personio, persoware, refline, sf-html, softgarden, umantis. (xiag-board was superseded by yousty and moved to crates-archive/.)
    • Wave 2 (5, JS-rendered): csod, hibob, phenom, playwright-html, sf-spa. These call env.fetch_rendered and currently propagate FetchError::Unavailable("rendered fetch") until a headless backend is wired.
    • Wave 3 (56, employer-bespoke + public boards + Yousty platform):
      • Public boards (11): linkedin, xing, indeed, glassdoor, adzuna, reed, totaljobs, cwjobs, weworkremotely, wttj, hackernews. Hostile boards (linkedin, xing, indeed, glassdoor) use env.fetch_rendered; the rest plain HTTP.
      • Swiss job boards (13): jobs-ch, jobup-ch, jobwatch, jobscout24, ictjobs-ch, ictcareer, itjobs-ch, swissdevjobs, jobwinner, publicjobs, ipersonal, stelle-admin, eures.
      • Recruitment agencies (15): hays, randstad, michaelpage, adecco, manpower, experis, roberthalf, swisslinx, coopersgroup, huxley, rmgroup, morganphilips, darwinrecruitment, nexus, divisions.
      • Single-employer JSON APIs (10): glencore, un, alpha, bcge, bankcler, gavi, citi, creditagricole, cargill, cbre.
      • Specialized vertical boards (6): reliefweb, hotelcareer, hoteljob, europharmajobs, devjobsscanner, wearedevelopers.
      • custom-html catch-all extension (9 new tenants): sbb, axpo, alpiq, bkw, migros, securitas, cern, syzgroup, startupch — appended to the existing custom-html SOURCES table (no new crate; the catch-all family now carries 16 tenants total).
  • jobcache::Env::fetch_rendered capability extension landed in crates/jobcache/src/adapter.rs. TauriEnv in ui/desktop/src-tauri/src/runtime/env.rs now implements it via a hidden Tauri WebviewWindow — same WebKitGTK engine the main UI already uses, zero extra binary. Bridge: initialization script invokes the cv_renderer_html Tauri command with the post-render HTML after a 2s settle delay; RendererRegistry (an Arc<Mutex< HashMap<label, oneshot::Sender>>>) fans completions back to the spawning task. Race-safe: labels generated by atomic counter (cv-render-<8-hex>), per-call timeout + cleanup on success / timeout / JS error. Chromium-via-chromiumoxide stays in tools/render-bench/ as the documented fallback if WebKit ever proves insufficient for a future adapter family.
  • Updater armed: Ed25519 keypair generated 2026-05-30 (no password). Pubkey embedded in ui/desktop/src-tauri/tauri.conf.json; private key sops-encrypted at ~/.agent/secrets/desktop-updater.yml. CI consumes TAURI_SIGNING_PRIVATE_KEY + TAURI_SIGNING_PRIVATE_KEY_PASSWORD from the CircleCI context/project env used by desktop_release_linux.
  • AppImage post-bundle Wayland fix — ui/desktop/scripts/post-bundle-appimage.sh replaces the autogenerated AppRun with ui/desktop/scripts/AppRun.patched (runtime backend detection) and re-packs + re-signs the AppImage. Wired into ui/desktop/package.json's build script and the CircleCI desktop_release_linux job.
  • All three Linux bundles producing clean artifacts: .deb ~6.8 MiB, .rpm ~6.8 MiB, AppImage ~108 MiB.
  • rust-toolchain.toml at repo root pins stable + rustfmt/clippy/ rust-analyzer/rust-src/llvm-tools + wasm32-wasip1 and wasm32-unknown-unknown targets.
  • SubHeaderBar.leadingSlot + DownloadDesktopButton + ScrapingPill
    • RuntimeCapabilities TS API + @tauri-apps/api dep landed.
  • CircleCI desktop_release_linux — Linux-only, tag-driven on desktop-v*, running on resource_class: corbet/unraid.

IN PROGRESS / NEXT

  • TauriEnv::fetch_rendered backend. Default impl returns FetchError::Unavailable("rendered fetch") so the five Wave-2 adapters (csod, hibob, phenom, playwright-html, sf-spa) cleanly signal "needs rendered fetch backend" instead of failing as transport errors. Backend choice between Browserbase (~/.agent/secrets/browserbase.yml) and Firecrawl (~/.agent/secrets/firecrawl.yml) is pending — pricing tier and monthly call budget drive it more than technical fit. See the comment block at the end of ui/desktop/src-tauri/src/runtime/env.rs.

  • Wave 3 — LANDED 2026-05-30. 55 new adapter crates wiring up public boards (LinkedIn, Xing, Indeed, Glassdoor, Adzuna, Reed, TotalJobs, CWJobs, WeWorkRemotely, WTTJ, HackerNews), Swiss boards (jobs-ch, jobup-ch, jobwatch, jobscout24, ictjobs-ch, ictcareer, itjobs-ch, swissdevjobs, jobwinner, publicjobs, ipersonal, stelle-admin, eures), recruitment agencies (hays, randstad, michaelpage, adecco, manpower, experis, roberthalf, swisslinx, coopersgroup, huxley, rmgroup, morganphilips, darwinrecruitment, nexus, divisions), single-employer JSON APIs (glencore, un, alpha, bcge, bankcler, gavi, citi, creditagricole, cargill, cbre), and specialized vertical boards (reliefweb, hotelcareer, hoteljob, europharmajobs, devjobsscanner, wearedevelopers). Plus 9 new Swiss employer tenants appended to the existing custom-html crate (sbb, axpo, alpiq, bkw, migros, securitas, cern, syzgroup, startupch). Was the original "~37 employer-bespoke crates" plan; expanded to 55 once the public boards and Swiss aggregators were folded in.

  • Wave 3 follow-up. Per-employer adapters that don't fit any beyond the multi-tenant families.

  • Public job boards. LinkedIn, Xing, Indeed, Adzuna, Reed, Stellenanzeiger, Jobwatch — each as its own Rust crate, dropped into Router::default via one extend_with line.

  • TS→Rust ports. jobs-ch, jobup-ch, yousty currently exist as TypeScript adapter implementations and need Rust ports for the desktop dock.

  • AUR PKGBUILD wired into the release workflow (placeholder job exists; needs AUR SSH key + maintainer credentials to be activated).

DEFERRED

  • Phase 2 — macOS + Windows matrix. Needs Windows self-hosted runner (Julian's Windows VM) + macOS code-signing certs. The tauri-apps/tauri-action swap is straightforward once both are in place.
  • Broker lease / observation submission loop in the Tauri runtime — blocked until the JobCache broker endpoints are wired.
  • System tray, native notifications, deep links — phase 3.
  • Full contribution dashboard surface — phase 3.
  • Multi-workspace switcher — UX TBD.

PHASED PLAN

  • Phase 1 (landed). Tauri v2 shell, remote-loaded UI pointing at careervector.corbet.ch, 89 adapter families wired into Router::default (Wave 0 + Wave 1 + Wave 2 + Wave 3), Download + Scraping surface in SubHeaderBar, Linux-only tag-driven CircleCI release job producing AppImage/deb/rpm/tarball, armed Ed25519 updater, AppImage Wayland-fix post-bundle step. AUR PKGBUILD job stubbed.
  • Phase 2. TauriEnv::fetch_rendered backend (Browserbase vs Firecrawl decision pending). Wave 3 employer-bespoke crates + public-board adapters + jobs-ch/jobup-ch/yousty TS→Rust ports. Multi-OS CI matrix (windows-self-hosted runner on Julian's VM + a macOS runner). Mac/Windows code signing. AUR publish job activated. Broker lease/observation loop on top of the Tauri runtime once the JobCache broker endpoints are ready.
  • Phase 3. System tray, native notifications, deep links, auto-start pref. Full contribution dashboard surface inside the workspace UI. Multi-workspace switcher (UX TBD — see Open Decisions).

Purpose

The desktop app is a Tauri-shelled CareerVector for users who want one or more of:

  1. Better scraping — native HTTP without browser CORS restrictions, delegated to Rust adapters that the cloud Jina chain can't match on quality.
  2. Contribution — opt-in spare-compute capacity for JobCache top-down ingestion while the app is open (per the desktop-edge-runtime contribution model).
  3. OS integration — proper window, system tray (later), native notifications (later), single-instance enforcement, file associations.

It is not an offline-first editor, not a local data store, not a desktop-only CareerVector variant. Workspaces stay collaborative; one user installing the desktop app does not break sharing with the other nine people on that workspace.

The big idea: thin shell + network-loaded UI

Tauri's default pattern bundles the entire SvelteKit build into the binary as static assets. That makes every UI change a desktop release. Going-nuts territory.

Tauri v2 supports pointing the webview at a remote URL instead. We use that:

Component Where it lives Update channel
Rust shell (Tauri runtime, native commands, adapter crates linked as rlib) binary-resident Tauri updater, rare releases
Workspace-bound bootstrap manifest binary-resident at first launch, persisted to OS app-data per-tag release
Svelte UI NOT bundled — webview loads https://{workspace-host}/ directly Cloudflare cache invalidation on every web deploy

Consequences:

  • UI changes ship to desktop users automatically on next app launch. Zero desktop release required.
  • Desktop release tags (desktop-vX.Y.Z) fire only when Rust shell, adapter crates, or Tauri config change.
  • Steady-state cadence drops from "release per commit" to maybe one desktop release per quarter.
  • Reinforces "no persistent offline" — without network the app can't load the UI, but that wasn't the use case anyway.

Security: loading a remote app gives the loaded code access to Tauri IPC commands, so we explicitly allowlist the production domains in tauri.conf.json (app.security.csp + withRemoteDomainIpcAccess per Tauri v2 docs on the remote-app pattern). Only careervector.corbet.ch and jobcache.corbet.ch (and equivalents for any future workspace hosts) can invoke invoke(). Random pages the user navigates to inside the webview get no IPC.

Components

ui/                                  existing SvelteKit app (unchanged for desktop)
├── src/                             Svelte source — one codebase, web + desktop both consume it
│   └── lib/components/workspace/    SubHeaderBar mount glue (NEW)
│       ├── WorkspaceIdentityEditor.svelte
│       ├── WorkspaceIdentityReadOnly.svelte
│       ├── WorkspaceActionButtons.svelte
│       └── workspace-actions.ts
├── build/                           output from `bun --filter @cv/ui build` — served by Cloudflare
└── desktop/                         Tauri shell only, no duplicate UI
    ├── package.json                 @cv/desktop — npm scripts wrapping tauri-cli ^2
    ├── README.md                    build + run notes
    ├── local-shell/
    │   └── index.html               splash fallback if remote UI is unreachable on launch
    ├── scripts/
    │   ├── post-bundle-appimage.sh  re-pack AppImage with Wayland-capable AppRun
    │   └── AppRun.patched           runtime backend detection (X11 if available, else Wayland)
    └── src-tauri/
        ├── Cargo.toml               tauri 2 + plugins (http, updater, single-instance, os, fs)
        │                            + jobcache + all 33 jobcache-adapter-* crates
        ├── tauri.conf.json          remote URL pointer (https://careervector.corbet.ch/),
        │                            CSP allowlist, deb/rpm/appimage bundle targets,
        │                            armed Ed25519 updater pubkey
        ├── build.rs
        ├── gen/                     Tauri-generated artifacts (gitignored)
        ├── icons/                   placeholder RGBA PNGs + icon.ico + icon.icns
        ├── capabilities/
        │   └── default.json         Tauri v2 permission set for the main window
        └── src/
            ├── main.rs              Tauri entry; configures windows, loads workspace bootstrap
            ├── lib.rs               crate root, re-exports
            ├── commands.rs          #[tauri::command] surface invoked from Svelte
            ├── state.rs             tiny JSON store: bindings, device id, prefs
            ├── enrollment.rs        first-launch bootstrap consumption
            └── runtime/
                ├── mod.rs           runtime module root
                ├── env.rs           TauriEnv: Env impl over Tauri http plugin (reqwest).
                │                    fetch_rendered intentionally NOT overridden — propagates
                │                    FetchError::Unavailable until a backend is wired.
                └── router.rs        URL → adapter routing for scrapeUrl. Collects
                                     all_adapters() from every linked family into one
                                     Vec<Box<dyn Adapter>>; first-prefix-match.
                                     (lease.rs + leader.rs deferred — phase 2 with broker)

rust-toolchain.toml at the repo root pins the toolchain (stable + rustfmt/clippy/rust-analyzer/rust-src/llvm-tools + wasm32-wasip1 + wasm32-unknown-unknown), so anyone with rustup gets the right environment automatically. The careervector-desktop crate is a member of the root Cargo workspace.

The Svelte side adds a RuntimeCapabilities interface (already mentioned in desktop-edge-runtime.md):

interface RuntimeCapabilities {
  canNativeFetch: boolean;          // true under Tauri, false in browser
  canMatchUrlToAdapter(url: string): Promise<{ adapterId: string; method: string } | null>;
  observeUrl(url: string): Promise<Observation | null>;  // local Rust adapter path
  // ... lease/observation APIs land in phase 2
}

Web build reports canNativeFetch: false. Tauri build reports true and exposes the IPC methods. Callsites check the capability and dispatch accordingly.

State model: multi-install peer devices

Each desktop install is its own enrolled device (device_id). Multiple installs (Julian on laptop + Julian on desktop + Julian's collaborator on their laptop) are peer devices, not competing copies. The CRDT/Yjs realtime sync layer already handles N writers per workspace, so the desktop just becomes one more participant.

Local state on disk is intentionally minimal:

~/.config/CareerVector/state.json     Linux  (XDG_CONFIG_HOME/CareerVector/)
~/Library/Application Support/CareerVector/state.json     macOS
%APPDATA%\CareerVector\state.json     Windows

Contents:

{
  "bindings": [
    {
      "workspace_id": "...",
      "workspace_url": "https://...",
      "broker_url": "https://...",
      "device_id": "...",
      "enrolled_at": "..."
    }
  ],
  "active_binding": "<workspace_id>",
  "device_runtime_version": "0.1.0",
  "contribution_enabled": true,
  "prefs": { "theme": "auto", "auto_start": false }
}

Notably not present:

  • workspace data (jobs, CVs, scoring, etc. — all in cloud Yjs)
  • credentials beyond the workspace's anonymous slug (Link = Identity per CareerVector §4)
  • cached scrapes (each scrape is one Observation, submitted live)
  • Yjs persistence

If state.json is deleted, the user re-enrolls and rejoins workspaces with no data loss — workspace state is in the cloud.

scrapeUrl: native first, cloud fallback

When a user pastes a job URL into a workspace from within the desktop app, the existing scrapeUrl() call site checks the runtime:

  1. Tauri runtime? → call invoke("observe_url", { url }). 1a. Rust router matches URL against every loaded adapter's descriptor().homepage (first-prefix-match, with scheme/www. variants normalised). If an adapter matches, the router calls adapter.observe(url, env, opts) against the native TauriEnv (no CORS). 1b. No adapter matched? → fall back to the cloud Jina chain.
  2. Browser runtime? → existing cloud Jina chain path.

Marketing line: "Desktop scrapes better than the web. That's the deal."

Adapter dispatch (current)

runtime::router::Router::default() (in ui/desktop/src-tauri/src/runtime/router.rs) collects all_adapters() from every linked family into one flat Vec<Box<dyn Adapter>>. Each multi-tenant family (Greenhouse, Workday, Lever, etc.) returns its full tenant set; static-HTML families that serve one tenant per crate return a singleton vec.

Router::match_url iterates in order and returns the first adapter whose descriptor().homepage is a prefix of the input URL (normalising http/https and www. variants). Per-family homepage prefixes don't overlap, so iteration order is irrelevant. Router::observe_url returns Ok(None) when no adapter matches — the UI surfaces that as "no native adapter — fall back to cloud Jina".

JS-rendered families (csod, hibob, phenom, playwright-html, sf-spa) call env.fetch_rendered(req) inside their observe(). The default TauriEnv impl in ui/desktop/src-tauri/src/runtime/env.rs does not override fetch_rendered, so the trait default returns FetchError::Unavailable("rendered fetch"). These adapters surface that back to the router as a clean "needs rendered backend" signal — not a transport failure — and the runtime can mark the source as skipped until a backend is wired.

Adding a new adapter family

Drop the crate under crates/, add a path = "../../../crates/jobcache-adapter-NAME" dependency to ui/desktop/src-tauri/Cargo.toml, and append one extend_with(&mut adapters, jobcache_adapter_NAME::all_adapters()) line to Router::default. No router restructuring needed.

Workspace enrollment

Per the chosen flow (workspace-bound bootstrap bundle):

  1. User clicks Download desktop app inside a CareerVector workspace.
  2. Server creates a short-lived enrollment record:
    {
      "enrollment_id": "...",
      "workspace_id": "...",
      "workspace_url": "https://careervector.corbet.ch/w/<slug>",
      "broker_url": "https://jobcache.corbet.ch/api",
      "expires_at": "2026-05-30T12:00:00Z"
    }
    
  3. Browser downloads a generic, signed Tauri binary plus a per-workspace workspace-bootstrap.json file containing the enrollment record above.
  4. On first launch, the Tauri shell looks for workspace-bootstrap.json next to the binary (or in the OS download dir), consumes the enrollment token via POST {broker_url}/devices/enroll, gets back a durable device_id, persists it to state.json, deletes the bootstrap file.
  5. Subsequent launches read state.json and go straight to the bound workspace URL inside the webview.

If no bootstrap file is found at first launch (e.g., user downloaded generic binary from elsewhere), the shell shows an enrollment screen where they paste a workspace URL manually as a fallback.

Distribution: Linux package matrix

MVP (Phase 1) — Tauri-native bundle outputs from one CI build. Current artifact sizes for the x86_64 build at version 0.0.1:

Format Audience Size Notes
AppImage universal, all distros ~108 MiB single file, no install needed; self-contained (bundles webkit2gtk + GTK). Patched AppRun handles Wayland-only sessions (see below).
.deb Debian, Ubuntu, Mint, Pop!_OS, Elementary, MX, Sparky, antiX ~6.8 MiB apt installs; depends on system libwebkit2gtk-4.1-0 + libgtk-3-0
.rpm Fedora, RHEL, CentOS Stream, openSUSE, Mageia, Rocky, Alma ~6.8 MiB dnf/zypper installs
tarball (.tar.gz) distro-agnostic, anyone happy to extract + run small raw binary, packaged in the CI workflow alongside the official bundles
AUR (careervector-bin) Arch, CachyOS, EndeavourOS, Manjaro, Garuda n/a PKGBUILD fetches the GitHub release; the publish job is stubbed in CI, waiting on AUR maintainer credentials

Phase 2 — store/sandbox distribution (each is a half-day setup):

Format Audience Notes
Flatpak (Flathub) universal, growing share sandboxed; visible in GNOME Software, KDE Discover, Pop!_Shop, AppCenter
Snap (Snap Store) Ubuntu defaults sandboxed; visible in Ubuntu Store
Nix flake (nixpkgs PR) NixOS, Home Manager users nix run, home.packages = [pkgs.careervector]
Alpine APK (aports template) Alpine, postmarketOS apk add

Phase 3 — community-contributed / by request:

Format Audience Notes
Gentoo ebuild (overlay) Gentoo, Funtoo source-built, USE flags
Void Linux xbps template Void accepts PR upstream once it stabilises
Solus eopkg Solus recipe in their repo
Pacstall Ubuntu power-users PPA-like alt
Chaotic-AUR Arch users avoiding AUR build mirrors the AUR binary
Slackware SlackBuild Slackware small audience

Out of scope (not Linux): NetBSD pkgsrc, FreeBSD ports.

AppImage Wayland fix

The AppImage produced by Tauri's bundler ships with an autogenerated AppRun script from linuxdeploy-plugin-gtk that hard-codes GDK_BACKEND=x11. That hardcode is a workaround for tauri-apps/tauri#8541, a Tauri 1.x bug. Tauri 2.x handles Wayland natively, so the workaround is no longer needed — and actively harmful on Wayland-only compositors that don't auto-export DISPLAY (niri and similar minimal wlroots compositors). On those sessions GTK init hard-fails before the webview ever loads.

Fix: ui/desktop/scripts/post-bundle-appimage.sh runs after cargo tauri build and:

  1. Replaces target/release/bundle/appimage/CareerVector.AppDir/AppRun with ui/desktop/scripts/AppRun.patched. The patched script sources linuxdeploy-plugin-gtk's hook for theme/path env, unsets the forced GDK_BACKEND, then picks a backend at runtime: prefer X11 if a DISPLAY socket is discoverable (including Xwayland sockets the compositor opened but didn't auto-export), otherwise fall back to native Wayland.
  2. Re-packs the .AppImage in place via appimagetool (extracted from Tauri's cached linuxdeploy-plugin-appimage.AppImage).
  3. If TAURI_SIGNING_PRIVATE_KEY is set in env (CI path), invalidates the original .sig and re-signs the new bundle so the updater accepts it.

The script is wired into both the local build path (ui/desktop/package.json's build script chains cargo tauri build && bash scripts/post-bundle-appimage.sh) and CI (the "Patch AppImage AppRun for Wayland-only sessions" step in the CircleCI desktop_release_linux job).

CI / release cadence

  • Trigger: desktop release builds run only for tags matching desktop-v*, plus explicit CircleCI dispatch for manual dry-runs. Commits to main do not trigger desktop CI.
  • Job: CircleCI desktop_release_linux on resource_class: corbet/unraid.
  • MVP (Phase 1): single Linux runner on corbet/unraid. Produces AppImage + .deb + .rpm + raw-binary tarball. AUR PKGBUILD publish job is stubbed (see RELEASE PROCESS for the activation prerequisites).
  • Phase 2: matrix expands to windows-self-hosted (Julian's Windows VM as registered runner) and a macOS runner.
  • Updater signing is armed at MVP. Bundle signatures use the real Ed25519 keypair; CI consumes both TAURI_SIGNING_* secrets. OS code signing (Mac/Windows) is still phase 2 — when those land, document the "trust this app first launch" dance for any unsigned interim builds.

The job uses cargo tauri build directly (rather than tauri-apps/tauri-action) so the bundling step composes cleanly with the Wayland-fix post-bundle step. The release upload step attaches the four-glob list (AppImage, deb, rpm, tarball) to the GitHub Release. AUR publish runs as a follow-up publish-aur job after the Linux artifacts land — currently a placeholder waiting on AUR maintainer credentials.

RELEASE PROCESS

Phase 1 — Linux-only, tag-driven. The release lane is the CircleCI desktop_release_linux job on resource_class: corbet/unraid.

Cut a release by pushing a tag matching desktop-vX.Y.Z:

git tag desktop-v0.1.0
git push origin desktop-v0.1.0

The job then:

  1. Checks out the repo on the corbet/unraid Linux runner.
  2. Installs the Linux system deps Tauri needs (libwebkit2gtk-4.1-dev, libgtk-3-dev, libayatana-appindicator3-dev, librsvg2-dev, patchelf, libfuse2) plus the latest linuxdeploy continuous build.
  3. Installs the pinned Rust toolchain via rust-toolchain.toml.
  4. Runs cargo tauri build --bundles deb,rpm,appimage with NO_STRIP=true and both TAURI_SIGNING_* secrets in env. Bundles land under target/release/bundle/ (workspace root, not under ui/desktop/src-tauri/target/ — the desktop crate is a workspace member).
  5. Runs bash ui/desktop/scripts/post-bundle-appimage.sh to patch the AppImage's AppRun for Wayland-only sessions and re-sign the re-packed bundle.
  6. Packages the raw careervector-desktop binary as careervector-${tag}-x86_64-linux.tar.gz.
  7. Publishes the GitHub Release, attaching the .AppImage / .deb / .rpm / .tar.gz artifacts. Tags containing - (e.g. desktop-v0.1.0-beta) flag the release as a pre-release.

The AUR publish job (PKGBUILD push to AUR via SSH) is stubbed but not yet wired — careervector-bin will fetch the GitHub release artifacts once the AUR SSH key + maintainer credentials are in place. The implementation outline lives in the desktop_release_linux job notes.

Phase 2 expands the matrix to windows-self-hosted (Julian's Windows VM as a registered runner) and a macOS runner. Both need real code-signing certs at that point — Windows EV cert and Apple Developer ID — to avoid first-launch friction.

Updater

Armed. Tauri's built-in updater plugin is configured with a real Ed25519 keypair.

  • Keypair: generated 2026-05-30 with cargo tauri signer generate (no password).

  • Public key: embedded in ui/desktop/src-tauri/tauri.conf.json under plugins.updater.pubkey. Compiled into the binary.

  • Private key: sops-encrypted at ~/.agent/secrets/desktop-updater.yml. Never committed; never read in plain into chat or logs.

  • CI signing: CircleCI desktop_release_linux reads TAURI_SIGNING_PRIVATE_KEY and TAURI_SIGNING_PRIVATE_KEY_PASSWORD from the CircleCI context/project env on both the cargo tauri build step and the post-bundle re-sign step. The one-time CircleCI secret setup commands are documented in ui/desktop/README.md under "Updater".

  • Endpoint (placeholder URL — Worker route not yet hosted; updater will silently 404 until manifest hosting is set up — that's the next deploy concern):

    https://careervector.corbet.ch/desktop/{{target}}-{{arch}}/{{current_version}}.json
    

    Tauri substitutes {{target}} (e.g. linux), {{arch}} (e.g. x86_64), and {{current_version}} at poll time.

  • Manifest shape, signature scope: the per-bundle .sig is what Tauri verifies — the manifest JSON envelope itself is unsigned. An unsigned Windows install (Phase 2) can still verify it's pulling a real update from us via the embedded pubkey.

Open decisions

Resolved

  1. Where does the "Download desktop app" button live in the workspace UI? Resolved — DownloadDesktopButton lives in SubHeaderBar.leadingSlot, the ScrapingPill lives in SubHeaderBar.actionsSlot. Both are wired through lib/ui-product's SubHeaderBar and mounted by ui/src/routes/[wsId]/+layout.svelte and view/[wsId]/+layout.svelte.
  2. What desktop-specific functionality goes in the desktop UI? Mechanism resolved — Svelte detects Tauri via the RuntimeCapabilities TS API (canNativeFetch, etc.). The inventory of desktop-only surfaces remains open (see #4 below).

Still open (UX session)

  1. System tray, native notifications, deep links — assigned to phase 3.
  2. Auto-start on boot — exposed as a pref under Settings → Desktop? Default off per desktop-edge-runtime.md product guardrails. Also: what other desktop-only panels/actions should the RuntimeCapabilities feature flag gate? Inventory TBD.
  3. Contribution dashboard surface — where in the workspace UI does a user see "you've contributed N scrapes / N minutes / N tasks this month"? Builds trust + visibility for the contribution model. Assigned to phase 3.
  4. Multi-workspace switcher — how does the user switch between workspaces inside the desktop app? Browser-style URL bar? Sidebar workspace list? Window-per-workspace?

These belong to the UX session that follows this doc.

Source: wiki/content/architecture/DESKTOP-APP.md