Skip to content

feat: add desktop distribution for Windows, macOS and Linux - #84

Merged
Aliferous3 merged 7 commits into
mainfrom
feat/desktop-distribution
Oct 20, 2026
Merged

Aliferous3 merged 7 commits into
mainfrom
feat/desktop-distribution

Conversation

@Aliferous3

Copy link
Copy Markdown
Owner

Summary

Adds a production-grade Tauri 2 desktop distribution of Dev Ledger for Windows, macOS and Linux — the same React + Vite frontend inside a least-privilege native shell. The hosted Vercel app stays canonical and is completely unchanged for browsers.

  • src-tauri/ — site.devledger.desktop, resizable native window, strict CSP, Rust-side navigation guard, brand icon set, no fs/shell/clipboard permissions.
  • Desktop auth — system-browser OAuth handoff: dev-ledger:// deep link carries a single-use hashed code bound to a PKCE-style challenge; exchange mints an opaque dlt_ bearer token stored hashed server-side and kept in the OS keychain (never localStorage). Revocation rides the existing auth_sessions machinery. Serverless function count stays at 12 via the /api/feedback rewrite precedent (/api/auth/desktop → /api/user?action=desktop).
  • src/desktop/ — fetch transport shim (tauri-plugin-http → https://devledger.site with Bearer + Origin, no CORS loosening), external-link interception, keyboard shortcuts, deep-link handling.
  • Migration 014_desktop_auth.sql — desktop_auth_codes + desktop_tokens, hash-only, RLS-deny + service_role-only.
  • CI — new desktop.yml matrix: windows x64 (nsis+msi), macOS arm64+x64 (app+dmg), linux x64 (deb+AppImage); v* tags → draft release + SHA256SUMS.txt (nothing auto-publishes).
  • distribution/ — WinGet manifest set (NoamanAli.DevLedger, nullsoft/NSIS), Homebrew Cask dev-ledger.rb, Flatpak manifest + AppStream + desktop entry (GNOME runtime for WebKitGTK).
  • npm run desktop:version — package.json stays the single version source across Tauri config, Cargo, and all manifests.
  • docs/desktop.md — architecture, auth, builds, signing/notarization, updater activation, and all three submission paths.

Security/privacy implications

  • Auth: bearer tokens are opaque, hashed at rest, bound to revocable server-side sessions, and stored in OS credential storage. Deep-link handoff is single-use, 2-minute TTL, flow- and challenge-bound. Cookies/CSRF posture for browsers is unchanged.
  • Capabilities: core:default, deep-link, opener (https/mailto), http (devledger.site only). No filesystem, shell execution, clipboard, or OS info.
  • No server secrets reach the bundle — verified by test (desktop-distribution.test.mjs scans the desktop surface + dist bundle).
  • Desktop ships no analytics (PostHog/GA4/Vercel) — those are web-deployment plumbing.
  • Updater intentionally disabled (requires signing keys; activation steps documented).
  • New tables classified EPHEMERAL in backup-scope.mjs — credentials are never backed up.

Test plan

  • npm test — 725/725 pass (+38 new desktop tests)
  • npm run typecheck — clean
  • npm run build — clean
  • npm audit --audit-level=moderate — 0 vulnerabilities
  • CI desktop matrix builds artifacts (runs on this PR)

Nothing merges, publishes, or submits to WinGet/Homebrew/Flathub.

Generated with Devin

Tauri 2 shell around the existing React + Vite frontend — the hosted
Vercel deployment stays canonical and unchanged for browsers.

Desktop shell (src-tauri):
- product name Dev Ledger, identifier site.devledger.desktop
- resizable native window (1320x860, min 960x600), brand icon set
- least-privilege capabilities: opener (https/mailto only), http scoped
  to devledger.site, deep-link + single-instance; no fs/shell/clipboard
- strict CSP matching the web posture; Rust on_navigation restricts the
  webview to the app origin; external links go to the system browser
- OS keychain via the keyring crate holds the bearer token (Windows
  Credential Manager / macOS Keychain / Secret Service)

Desktop auth — system-browser OAuth, never embedded:
- GET /api/auth/login?desktop_flow=<uuid>&desktop_challenge=<sha256>
  runs the existing GitHub OAuth + App install chain in the browser
- callback/setup redirect to dev-ledger://auth?flow&code carrying a
  single-use hashed code (2-min TTL) bound to a PKCE-style challenge —
  an intercepted deep link is unexchangeable
- POST /api/user?action=desktop&op=exchange mints an opaque dlt_ bearer
  token bound to a dedicated server-side session (function count stays
  at 12 via the /api/feedback rewrite precedent)
- token rows are hash-only; logout/delete/kill-all revoke through the
  same auth_sessions machinery; migration 014 adds both tables with
  RLS-deny and service_role-only access
- frontend transport shim sends Authorization: Bearer + the hosted
  Origin via tauri-plugin-http — no CORS loosening, no cookies

Distribution readiness (prepared, nothing submitted):
- CI matrix: windows x64 (nsis+msi), macOS arm64+x64 (app+dmg),
  linux x64 (deb+AppImage); v* tags produce a DRAFT release + SHA256SUMS
- distribution/winget manifests, homebrew cask, flatpak manifest +
  AppStream metainfo + desktop entry
- npm run desktop:version keeps package.json the single version source
- icons generated from the brand mark (scripts/gen-icon.mjs)

Docs: docs/desktop.md covers architecture, auth, builds, signing,
notarization, updater activation and all three submission paths.

Tests: +21 desktop-auth tests (handoff, PKCE, single-use, revocation,
bearer), +17 distribution-invariant tests (identity, versions, HTTPS
URLs, least-privilege, no secrets). 725/725 pass, typecheck clean.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@vercel

vercel Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
dev-ledger Ready Ready Preview Oct 20, 2026 8:02pm UTC

installMode 'perUser' is v1 syntax — v2 uses 'currentUser'. The macOS
bundle section no longer accepts 'category' (top-level bundle.category
remains valid).

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
- tauri::Builder has no on_navigation in Tauri 2 — it lives on
  WebviewWindowBuilder, so the main window is now constructed in
  setup() with identical properties and the origin guard attached.
- tauri-plugin-deep-link 2.6.2 fails to compile on Linux against
  rust-ini 0.21.3 (ParseOption gained required fields); pinned to
  =2.6.1 per plugins-workspace#3710 until a fixed release ships.
- Commit Cargo.lock for reproducible desktop builds; cargo check and
  cargo fmt --check both clean locally now.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
cargo check on Windows masked this; Linux/macOS CI caught it. Verified
every other builder method used is ungated.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…I gates

Release-asset contract (verified CI bug — manifest URLs would 404):
- scripts/release-assets.mjs is now the single source of truth mapping
  Tauri's "Dev Ledger_*" outputs to canonical release names
  (Dev-Ledger_*; dev-ledger_ for the .deb). The CI collect step calls it;
  tests import it so filename drift is a test failure.
- Manifest URLs/hashes/dates are now {{TAG}}/{{VERSION}}/{{DATE}}/
  {{SHA256*}} placeholders — nothing points at the already-published
  v1.3.0 release. ReleaseDate's bogus 2025-01-01 is gone; the version
  script stamps the real date during release prep.

Flatpak: the .deb was incorrectly declared extra-data (that type is for
non-redistributable third-party binaries fetched at install time). It is
now a normal pinned `type: file` source unpacked at build time — the
right model for our own AGPL artifact. flathub.json restricts builds to
x86_64 since only an amd64 deb exists.

Keep me signed in: the checkbox is now honored — pendingRemember rides
with pendingFlow/pendingVerifier; exchange stores the token via
storeToken(token, {persistent}). Unchecked = process memory only plus an
explicit keychain_delete so no stale credential resurrects; checked =
OS keychain restore on launch. Server-side expiry/revocation unchanged.

CI hardening: a `verify` job (npm ci --ignore-scripts, build, typecheck,
test, audit) now gates the desktop matrix, and draft-release needs both.
Every checkout sets persist-credentials: false. Release tags also run
the security workflow's full-history gitleaks scan.

External links: SyncMonitor's window.open now routes through the shared
openExternalUrl helper (plugin-opener on desktop, window.open on web);
an invariant test scans src/ for bypasses.

Docs: canonical asset table, v1.3.0-must-not-be-reused release flow,
migration 014 as an explicit production prerequisite, honest
keep-signed-in semantics.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
The transport shim classified both /api/* and /rly/* as native hosted
routes, so a /rly/* call would carry Authorization: Bearer <desktop
token>. /rly/* is the hosted analytics relay — vercel.json rewrites it
to eu.i.posthog.com — so a Dev Ledger credential on that path would be
handed to a third party. Desktop analytics is disabled, so no normal
traffic hits it today; this closes the credential-boundary footgun.

- isApiPath now admits only /api/*; /rly/*, external URLs and bundled
  assets fall through to window.fetch verbatim (no headers added).
- apiHeaders() owns the bearer + hosted Origin for the api branch and
  strips caller-injected Authorization.
- test/desktop-transport.test.mjs runs the real shim: proves /api/user
  and /api/sync qualify, /rly/e/ and /rly/i/v0/e/ do not, external URLs
  do not, non-api paths pass through untouched, and pins invariants so
  /rly/ can never be re-admitted to the authenticated transport.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
isApiPath used a raw startsWith('/api/') check, so traversal inputs
like /api/../rly/e/ or /api/%2e%2e/rly/e/ were classified as API paths
while resolving to /rly/e/ on the hosted origin — a desktop bearer
would have been attached to a request whose final path left /api/*.

resolveApiTarget() replaces classification: root-relative inputs only,
resolved through new URL(raw, API_BASE) so the WHATWG parser normalizes
literal, percent-encoded and backslash dot segments, then require the
resolved origin to equal API_BASE and the normalized pathname to begin
/api/. The returned canonical URL is the exact string handed to
tauriFetch — validation and dispatch share one value and cannot drift.

Tests exercise the real shim: literal and percent-encoded traversal
rejection, protocol-relative/absolute/external ineligibility, pass-
through integrity for non-API paths, and source invariants pinning the
canonicalized check and the single tauriFetch call site.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@Aliferous3
Aliferous3 merged commit b7df038 into main Oct 20, 2026
11 checks passed

This branch was successfully deployed

1 active deployment
Preview — a688307e Deployed Oct 20, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant