diff --git a/api/auth/callback.mjs b/api/auth/callback.mjs
index c818bbd..f9f8c43 100644
--- a/api/auth/callback.mjs
+++ b/api/auth/callback.mjs
@@ -4,6 +4,7 @@ import { github, appUrl, sessionLeaseMs } from '../../lib/config.mjs'
import { supabase } from '../../lib/db.mjs'
import { kickSync, runSync, setUserSync, SYNC_DETAIL_INIT } from '../../lib/sync.mjs'
import { createAuthSession, gcAuthSessions, isSessionLive } from '../../lib/sessions.mjs'
+import { desktopHandoff } from '../../lib/desktop-auth.mjs'
import { requestQuery } from '../../lib/request-query.mjs'
import { securityEvent } from '../../lib/security-events.mjs'
@@ -16,7 +17,10 @@ export default async function handler(req, res) {
// installation directly; without one, restart login so OAuth completes.
if (installationId && setupAction) {
if (!session.userId || (supabase && !(await isSessionLive(session.sid)))) {
- res.writeHead(302, { Location: '/api/auth/login' })
+ // Carry a pending desktop flow through the restarted login so the
+ // handoff still returns to the app after re-auth.
+ const flow = session?.desktopFlow ? `?desktop_flow=${encodeURIComponent(session.desktopFlow)}` : ''
+ res.writeHead(302, { Location: `/api/auth/login${flow}` })
res.end()
return
}
@@ -42,6 +46,7 @@ export default async function handler(req, res) {
waitUntil(runSync(session.userId, { budgetMs: 45_000, force: true }).catch(() => {}))
}
}
+ if (await desktopHandoff(res, session, '/')) return
res.writeHead(302, { Location: '/' })
res.end()
return
@@ -182,12 +187,15 @@ export default async function handler(req, res) {
// No installation yet → send the user through the GitHub App install flow
// (all or selected repositories), which returns via the app Setup URL.
+ // session.desktopFlow intentionally survives this hop — /api/setup
+ // completes the deep-link handoff once the install lands.
if (!installations.length && github.appSlug) {
res.writeHead(302, { Location: `https://github.com/apps/${github.appSlug}/installations/new` })
res.end()
return
}
+ if (await desktopHandoff(res, session, '/')) return
res.writeHead(302, { Location: '/' })
res.end()
}
diff --git a/api/auth/login.mjs b/api/auth/login.mjs
index 35ae095..24042c6 100644
--- a/api/auth/login.mjs
+++ b/api/auth/login.mjs
@@ -1,6 +1,7 @@
import crypto from 'node:crypto'
import { getSession } from '../../lib/auth.mjs'
import { github, appUrl } from '../../lib/config.mjs'
+import { isDesktopFlowId, isDesktopChallenge } from '../../lib/desktop-auth.mjs'
import { requestQuery } from '../../lib/request-query.mjs'
export default async function handler(req, res) {
@@ -19,6 +20,19 @@ export default async function handler(req, res) {
// OAuth round-trip without weakening state validation.
session.remember = remember
session.persistent = remember
+ // Desktop handoff: the app generated a flow id and opened this URL in the
+ // system browser. Sealed inside the session so the callback knows to
+ // return the session through a dev-ledger:// deep link instead of '/'.
+ // Anything that is not a well-formed UUID is ignored — the web flow is
+ // unchanged when the parameter is absent or malformed.
+ // The flow is honored only as a complete pair: the UUID binds the
+ // handoff to this app instance's login intent, and the challenge (sha256
+ // of a verifier held only in app memory) makes an intercepted
+ // dev-ledger:// URL unexchangeable — PKCE discipline for custom schemes.
+ if (isDesktopFlowId(query.desktop_flow) && isDesktopChallenge(query.desktop_challenge)) {
+ session.desktopFlow = query.desktop_flow.toLowerCase()
+ session.desktopChallenge = query.desktop_challenge.toLowerCase()
+ }
await session.save()
const redirectUri = `${appUrl}/api/auth/callback`
const url = new URL('https://github.com/login/oauth/authorize')
diff --git a/api/setup.mjs b/api/setup.mjs
index f21a1ff..0c7f6f9 100644
--- a/api/setup.mjs
+++ b/api/setup.mjs
@@ -2,6 +2,7 @@ import { waitUntil } from '@vercel/functions'
import { getSession, getAppOctokit } from '../lib/auth.mjs'
import { supabase } from '../lib/db.mjs'
import { isSessionLive } from '../lib/sessions.mjs'
+import { desktopHandoff } from '../lib/desktop-auth.mjs'
import { kickSync, runSync } from '../lib/sync.mjs'
import { requestQuery } from '../lib/request-query.mjs'
@@ -10,7 +11,11 @@ import { requestQuery } from '../lib/request-query.mjs'
export default async function handler(req, res) {
const session = await getSession(req, res)
if (!session?.userId || (supabase && !(await isSessionLive(session.sid)))) {
- res.writeHead(302, { Location: '/api/auth/login' })
+ // Restarting login mid-install must not strand a desktop handoff —
+ // carry the sealed flow id forward so the resumed OAuth still returns
+ // to the app. Unauthenticated installs (no flow) are unchanged.
+ const flow = session?.desktopFlow ? `?desktop_flow=${encodeURIComponent(session.desktopFlow)}` : ''
+ res.writeHead(302, { Location: `/api/auth/login${flow}` })
res.end()
return
}
@@ -45,6 +50,9 @@ export default async function handler(req, res) {
}
}
+ // A desktop login pending handoff finishes here (first-time installs
+ // arrive via this Setup URL rather than the OAuth callback tail).
+ if (await desktopHandoff(res, session, '/?installed=1')) return
res.writeHead(302, { Location: '/?installed=1' })
res.end()
}
diff --git a/api/user.mjs b/api/user.mjs
index 46415ed..2a77454 100644
--- a/api/user.mjs
+++ b/api/user.mjs
@@ -1,6 +1,8 @@
import { getSession } from '../lib/auth.mjs'
import { github } from '../lib/config.mjs'
import { supabase } from '../lib/db.mjs'
+import { authenticateBearer } from '../lib/desktop-auth.mjs'
+import { handleDesktopEndpoint } from '../lib/desktop-endpoint.mjs'
import { getUserSync } from '../lib/sync.mjs'
import { syncErrorMessage } from '../lib/sync-errors.mjs'
import { revokeAllSessions, isSessionLive } from '../lib/sessions.mjs'
@@ -31,10 +33,23 @@ export default async function handler(req, res) {
return
}
- const session = await getSession(req, res)
+ // The desktop app authenticates every call with its bearer token instead
+ // of the sealed cookie — resolve both to the same principal shape:
+ // { userId, sid, persistent }. Bearer tokens are session-bound and
+ // already validated (token row + auth_sessions liveness); the cookie
+ // path below does its own sid check. A bearer principal reports
+ // persistent: true — desktop tokens live in the OS keychain and carry
+ // no inactivity lease, so the heartbeat must stay quiet.
+ const bearer = await authenticateBearer(req)
+ const session = bearer ? null : await getSession(req, res)
+ const principal = bearer
+ ? { userId: bearer.userId, sid: bearer.sid, persistent: true }
+ : session?.userId
+ ? { userId: session.userId, sid: session.sid, persistent: session.persistent === true }
+ : null
if (req.method === 'DELETE') {
- if (!session?.userId || !supabase || !(await isSessionLive(session.sid))) {
+ if (!principal || !supabase || !(await isSessionLive(principal.sid))) {
if (session?.userId && supabase) sessionDenied(req, session)
res.status(401).json({ error: 'Unauthenticated' })
return
@@ -44,14 +59,16 @@ export default async function handler(req, res) {
return
}
// Revoke every session server-side first — any other live copies of
- // this user's cookies die immediately, even before the cascade lands.
- await revokeAllSessions(session.userId)
+ // this user's cookies and desktop tokens die immediately (the token
+ // rows cascade with their bound sessions), even before the users-row
+ // cascade lands.
+ await revokeAllSessions(principal.userId)
// users row cascades: installations, repositories (+languages), commits,
- // pull requests, repo_sync, user_sync, auth_sessions.
- await supabase.from('users').delete().eq('id', session.userId)
+ // pull requests, repo_sync, user_sync, auth_sessions, desktop tokens.
+ await supabase.from('users').delete().eq('id', principal.userId)
// destroy() clears session.userId — capture the actor first.
- const actorId = session.userId
- await session.destroy()
+ const actorId = principal.userId
+ if (session) await session.destroy()
securityEvent('account_deleted', { req, actorId })
res.status(200).json({ ok: true, deleted: true })
return
@@ -59,8 +76,16 @@ export default async function handler(req, res) {
if (req.method === 'POST') {
- // Repository disconnect/keep/delete — mutations stay same-origin only.
- if (!session?.userId || !supabase || !(await isSessionLive(session.sid))) {
+ // Desktop auth endpoint — /api/auth/desktop rewrites here so the
+ // serverless function budget stays untouched (same precedent as
+ // /api/feedback). Exchange is unauthenticated-by-design (the one-time
+ // code is the credential); logout re-verifies the bearer itself.
+ if (requestQuery(req).action === 'desktop') {
+ return await handleDesktopEndpoint(req, res)
+ }
+ // Repository disconnect/keep/delete — mutations stay same-origin only
+ // for cookie callers; bearer clients carry no ambient credential.
+ if (!principal || !supabase || !(await isSessionLive(principal.sid))) {
if (session?.userId && supabase) sessionDenied(req, session)
res.status(401).json({ error: 'Unauthenticated' })
return
@@ -72,7 +97,7 @@ export default async function handler(req, res) {
}
if (requestQuery(req).action === 'feedback') {
- return await handleFeedback(res, session.userId, body)
+ return await handleFeedback(res, principal.userId, body)
}
const repositoryId = body?.repositoryId
@@ -82,7 +107,7 @@ export default async function handler(req, res) {
return
}
- const userId = session.userId
+ const userId = principal.userId
const { data: repo } = await supabase
.from('repositories')
.select('id, full_name, disconnected_at')
@@ -128,8 +153,9 @@ export default async function handler(req, res) {
// A revoked/unknown sid must not authenticate — the boot gate reads this
// endpoint, so a stolen cookie has to die here too. When no DB is present
- // there is nothing to check (dev bypass path).
- if (!session?.userId || (supabase && !(await isSessionLive(session.sid)))) {
+ // there is nothing to check (dev bypass path). Bearer principals arrived
+ // pre-validated (live token row + live session).
+ if (!principal || (supabase && !(await isSessionLive(principal.sid)))) {
if (session?.userId) sessionDenied(req, session)
res.status(401).json({ authenticated: false })
return
@@ -140,21 +166,23 @@ export default async function handler(req, res) {
let sync = null
if (supabase) {
const [{ data: u }, { data: i }] = await Promise.all([
- supabase.from('users').select('*').eq('id', session.userId).single(),
- supabase.from('github_installations').select('*').eq('user_id', session.userId),
+ supabase.from('users').select('*').eq('id', principal.userId).single(),
+ supabase.from('github_installations').select('*').eq('user_id', principal.userId),
])
user = u
installs = i || []
- sync = await getUserSync(session.userId)
+ sync = await getUserSync(principal.userId)
} else {
- user = { github_login: session.githubLogin }
+ user = { github_login: session?.githubLogin }
}
res.status(200).json({
authenticated: true,
// Lets the frontend run the inactivity heartbeat only for
- // non-persistent sessions; remembered sessions need none.
- persistent: session.persistent === true,
+ // non-persistent sessions; remembered sessions need none. Bearer
+ // principals always report persistent — the token itself is the
+ // long-lived credential.
+ persistent: principal.persistent,
user: user
? {
githubLogin: user.github_login,
diff --git a/distribution/README.md b/distribution/README.md
new file mode 100644
index 0000000..3e50498
--- /dev/null
+++ b/distribution/README.md
@@ -0,0 +1,48 @@
+# Distribution metadata
+
+Package-manager manifest TEMPLATES for the Dev Ledger desktop app.
+Nothing here is submitted anywhere — every file is a preparation
+artifact whose `{{VERSION}}` / `{{TAG}}` / `{{DATE}}` / `{{SHA256*}}`
+placeholders get filled from a real GitHub Release at release time.
+
+| Directory | Target | Files |
+| --- | --- | --- |
+| `winget/` | `microsoft/winget-pkgs` (later) | Multi-file manifest set for the NSIS installer |
+| `homebrew/` | A `homebrew-*` tap (later) | `dev-ledger.rb` Cask around the `.dmg` assets |
+| `flatpak/` | Flathub (later) | Manifest + `flathub.json` + `.desktop` + AppStream metainfo |
+
+## Canonical release-asset names
+
+CI normalizes every copied artifact through
+`scripts/release-assets.mjs` — the filenames below are the ONLY names the
+manifests may reference (tests enforce this):
+
+| Platform | Canonical filenames |
+| --- | --- |
+| Windows | `Dev-Ledger__x64-setup.exe`, `Dev-Ledger__x64_en-US.msi` |
+| macOS | `Dev-Ledger__aarch64.dmg`, `Dev-Ledger__x64.dmg` |
+| Linux | `dev-ledger__amd64.deb`, `Dev-Ledger__amd64.AppImage` |
+
+The installed product is still `Dev Ledger` / `Dev Ledger.app` — only
+the release-asset filenames are normalized.
+
+## Per-release update process
+
+0. **Prerequisite:** `migrations/014_desktop_auth.sql` must already be
+ applied to the production database — desktop sign-in fails without
+ it. Also required BEFORE tagging: bump `package.json` to a **new**
+ version (e.g. `1.4.0` — `v1.3.0` is already released and must never
+ be reused for desktop assets).
+1. Run `npm run desktop:version` — it substitutes the `{{VERSION}}`,
+ `{{TAG}}` and `{{DATE}}` placeholders in these manifests and syncs
+ `src-tauri/` versions.
+2. Tag `v` — the desktop CI's `verify` job runs the full
+ quality gates first, then the matrix builds artifacts, then a draft
+ release is created with `SHA256SUMS.txt` covering the canonical
+ filenames above.
+3. Fill every `{{SHA256*}}` placeholder from `SHA256SUMS.txt`.
+4. Validate the generated manifests (winget-pkgs validation,
+ `brew audit --cask --strict`, `flatpak-builder-lint`).
+5. Submit/copy the updated manifests to the package-manager repositories.
+
+See `docs/desktop.md` for the full submission walkthroughs.
diff --git a/distribution/flatpak/flathub.json b/distribution/flatpak/flathub.json
new file mode 100644
index 0000000..637604e
--- /dev/null
+++ b/distribution/flatpak/flathub.json
@@ -0,0 +1,3 @@
+{
+ "only-arches": ["x86_64"]
+}
diff --git a/distribution/flatpak/site.devledger.desktop.desktop b/distribution/flatpak/site.devledger.desktop.desktop
new file mode 100644
index 0000000..e9b109f
--- /dev/null
+++ b/distribution/flatpak/site.devledger.desktop.desktop
@@ -0,0 +1,11 @@
+[Desktop Entry]
+Name=Dev Ledger
+Comment=Open-source GitHub analytics for developers
+Exec=dev-ledger %u
+Icon=site.devledger.desktop
+Type=Application
+Terminal=false
+Categories=Development;Utility;
+StartupWMClass=dev-ledger
+MimeType=x-scheme-handler/dev-ledger;
+Keywords=github;analytics;metrics;developer;dashboard;
diff --git a/distribution/flatpak/site.devledger.desktop.metainfo.xml b/distribution/flatpak/site.devledger.desktop.metainfo.xml
new file mode 100644
index 0000000..8b5bf0a
--- /dev/null
+++ b/distribution/flatpak/site.devledger.desktop.metainfo.xml
@@ -0,0 +1,47 @@
+
+
+ site.devledger.desktop
+ Dev Ledger
+ Open-source GitHub analytics for developers
+ CC0-1.0
+ AGPL-3.0-only
+
+ Noaman Ali
+
+ https://devledger.site
+ https://github.com/Aliferous3/dev-ledger/issues
+ site.devledger.desktop.desktop
+
+
+ Dev Ledger turns your GitHub activity into a living analytics ledger —
+ repositories, streaks and momentum — in a native desktop window.
+
+
+ It signs in with your GitHub account through the system browser and
+ reads repository metrics through the hosted Dev Ledger service. No
+ repository source code is collected.
+
+
+
+
+ https://devledger.site/og/dev-ledger.png
+
+
+
+
+
+
+
+ dev-ledger
+
+
+ pointing
+ keyboard
+
+
+ 768
+
+
+ always
+
+
diff --git a/distribution/flatpak/site.devledger.desktop.png b/distribution/flatpak/site.devledger.desktop.png
new file mode 100644
index 0000000..7804318
Binary files /dev/null and b/distribution/flatpak/site.devledger.desktop.png differ
diff --git a/distribution/flatpak/site.devledger.desktop.yml b/distribution/flatpak/site.devledger.desktop.yml
new file mode 100644
index 0000000..bfd8ae4
--- /dev/null
+++ b/distribution/flatpak/site.devledger.desktop.yml
@@ -0,0 +1,60 @@
+# Flatpak manifest for Dev Ledger (site.devledger.desktop).
+#
+# Strategy: repackage the .deb published on GitHub Releases as a NORMAL
+# remote file source — NOT extra-data. extra-data is for proprietary,
+# non-redistributable binaries downloaded at install time on the user
+# machine; Dev Ledger is first-party AGPL-3.0 software built by our own
+# CI, so the deb is fetched at flatpak-builder time like any pinned
+# source and installed during build-commands.
+#
+# If Flathub reviewers prefer a full source build, the alternative is
+# flatpak-node-generator + cargo vendoring against src-tauri/
+# (documented in docs/desktop.md). flathub.json restricts to x86_64 —
+# the only deb architecture produced today.
+#
+# THIS IS A TEMPLATE: {{TAG}}, {{VERSION}} and the sha256 below are
+# filled by `npm run desktop:version` + the release's SHA256SUMS.txt when
+# a real desktop release is prepared (see distribution/README.md).
+id: site.devledger.desktop
+runtime: org.gnome.Platform
+runtime-version: "48"
+sdk: org.gnome.Sdk
+command: dev-ledger
+
+finish-args:
+ # WebKitGTK needs X/Wayland + IPC; the app talks to the hosted API only.
+ - --socket=wayland
+ - --socket=fallback-x11
+ - --share=ipc
+ - --share=network
+ # OS keychain for the desktop bearer token (Secret Service).
+ - --talk-name=org.freedesktop.secrets
+ # GPU acceleration for the webview.
+ - --device=dri
+ # WebKitGTK dmabuf workaround used across Tauri flatpaks.
+ - --env=WEBKIT_DISABLE_DMABUF_RENDERER=1
+ # No host filesystem, no session bus beyond secrets, no devices — the
+ # app needs nothing else.
+
+modules:
+ - name: dev-ledger
+ buildsystem: simple
+ sources:
+ - type: file
+ url: https://github.com/Aliferous3/dev-ledger/releases/download/{{TAG}}/dev-ledger_{{VERSION}}_amd64.deb
+ sha256: "{{SHA256_AMD64_DEB}}"
+ dest-filename: dev-ledger_amd64.deb
+ only-arches: [x86_64]
+ - type: file
+ path: site.devledger.desktop.desktop
+ - type: file
+ path: site.devledger.desktop.metainfo.xml
+ - type: file
+ path: site.devledger.desktop.png
+ build-commands:
+ - ar x dev-ledger_amd64.deb
+ - tar -xf data.tar.*
+ - install -Dm755 usr/bin/dev-ledger /app/bin/dev-ledger
+ - install -Dm644 site.devledger.desktop.desktop /app/share/applications/site.devledger.desktop.desktop
+ - install -Dm644 site.devledger.desktop.metainfo.xml /app/share/metainfo/site.devledger.desktop.metainfo.xml
+ - install -Dm644 site.devledger.desktop.png /app/share/icons/hicolor/512x512/apps/site.devledger.desktop.png
diff --git a/distribution/homebrew/dev-ledger.rb b/distribution/homebrew/dev-ledger.rb
new file mode 100644
index 0000000..64e961b
--- /dev/null
+++ b/distribution/homebrew/dev-ledger.rb
@@ -0,0 +1,39 @@
+# Homebrew Cask TEMPLATE for the Dev Ledger desktop app (macOS).
+#
+# This file lives in the repo so every release can be regenerated
+# mechanically — it is NOT submitted anywhere yet. When it is, it lands in
+# a tap (e.g. Aliferous3/homebrew-tap) verbatim.
+#
+# Fill per release (see distribution/README.md):
+# 1. bump package.json to the NEW release version, then
+# npm run desktop:version (substitutes {{VERSION}} below)
+# 2. fill both sha256 values from the GitHub Release SHA256SUMS.txt
+# 3. `brew audit --cask --strict dev-ledger.rb`
+#
+# The URL filename matches the canonical release-asset contract produced
+# by scripts/release-assets.mjs — do not rename it here.
+cask "dev-ledger" do
+ version "{{VERSION}}"
+ sha256 arm: "{{SHA256_ARM64}}",
+ intel: "{{SHA256_X64}}"
+
+ arch arm: "aarch64", intel: "x64"
+
+ url "https://github.com/Aliferous3/dev-ledger/releases/download/v#{version}/Dev-Ledger_#{version}_#{arch}.dmg"
+ name "Dev Ledger"
+ desc "Open-source GitHub analytics for developers"
+ homepage "https://devledger.site"
+
+ livecheck do
+ url :url
+ strategy :github_latest
+ end
+
+ app "Dev Ledger.app"
+
+ zap trash: [
+ "~/Library/Application Support/site.devledger.desktop",
+ "~/Library/Caches/site.devledger.desktop",
+ "~/Library/WebKit/site.devledger.desktop",
+ ]
+end
diff --git a/distribution/winget/NoamanAli.DevLedger.installer.yaml b/distribution/winget/NoamanAli.DevLedger.installer.yaml
new file mode 100644
index 0000000..c2f6dc6
--- /dev/null
+++ b/distribution/winget/NoamanAli.DevLedger.installer.yaml
@@ -0,0 +1,29 @@
+# WinGet installer manifest TEMPLATE — matches the NSIS output produced
+# by the desktop CI matrix on windows-latest.
+#
+# THIS IS A TEMPLATE, not a submittable manifest: {{VERSION}}, {{TAG}},
+# {{DATE}} and InstallerSha256 are filled by `npm run desktop:version` +
+# the release's SHA256SUMS.txt when a real desktop release is prepared
+# (see distribution/README.md). The canonical InstallerUrl filename is
+# produced by scripts/release-assets.mjs in CI — do not rename it here.
+PackageIdentifier: NoamanAli.DevLedger
+PackageVersion: "{{VERSION}}"
+InstallerLocale: en-US
+MinimumOSVersion: 10.0.0.0
+InstallerType: nullsoft
+InstallModes:
+ - interactive
+ - silent
+ - silentWithProgress
+InstallerSwitches:
+ Silent: /S
+ SilentWithProgress: /S
+UpgradeBehavior: install
+Scope: user
+ReleaseDate: "{{DATE}}"
+Installers:
+ - Architecture: x64
+ InstallerUrl: https://github.com/Aliferous3/dev-ledger/releases/download/{{TAG}}/Dev-Ledger_{{VERSION}}_x64-setup.exe
+ InstallerSha256: "{{SHA256}}"
+ManifestType: installer
+ManifestVersion: 1.9.0
diff --git a/distribution/winget/NoamanAli.DevLedger.locale.en-US.yaml b/distribution/winget/NoamanAli.DevLedger.locale.en-US.yaml
new file mode 100644
index 0000000..325d286
--- /dev/null
+++ b/distribution/winget/NoamanAli.DevLedger.locale.en-US.yaml
@@ -0,0 +1,19 @@
+# WinGet default-locale manifest.
+PackageIdentifier: NoamanAli.DevLedger
+PackageVersion: "{{VERSION}}"
+PackageLocale: en-US
+Publisher: Noaman Ali
+PublisherUrl: https://devledger.site
+PackageName: Dev Ledger
+PackageUrl: https://devledger.site
+License: AGPL-3.0-only
+LicenseUrl: https://github.com/Aliferous3/dev-ledger/blob/main/LICENSE
+ShortDescription: Open-source GitHub analytics for developers — repositories, streaks and momentum as a living ledger.
+Tags:
+ - github
+ - analytics
+ - developer-tools
+ - metrics
+ - dashboard
+ManifestType: defaultLocale
+ManifestVersion: 1.9.0
diff --git a/distribution/winget/NoamanAli.DevLedger.yaml b/distribution/winget/NoamanAli.DevLedger.yaml
new file mode 100644
index 0000000..f18735c
--- /dev/null
+++ b/distribution/winget/NoamanAli.DevLedger.yaml
@@ -0,0 +1,10 @@
+# WinGet version manifest — root of the multi-file manifest set.
+# Regenerate the version + hashes per release with:
+# npm run desktop:version
+# then fill InstallerSha256 in the installer manifest from the GitHub
+# Release checksums file (SHA256SUMS.txt).
+PackageIdentifier: NoamanAli.DevLedger
+PackageVersion: "{{VERSION}}"
+DefaultLocale: en-US
+ManifestType: version
+ManifestVersion: 1.9.0
diff --git a/docs/desktop.md b/docs/desktop.md
new file mode 100644
index 0000000..da70378
--- /dev/null
+++ b/docs/desktop.md
@@ -0,0 +1,280 @@
+# Dev Ledger Desktop
+
+Native desktop distribution of Dev Ledger for Windows, macOS and Linux —
+a [Tauri 2](https://v2.tauri.app) shell around the **same** React + Vite
+frontend that powers . The hosted web app remains
+the canonical deployment; the desktop app reuses it architecturally and
+never replaces it.
+
+- Product name: **Dev Ledger**
+- Application ID: **`site.devledger.desktop`**
+- Executable / package name: **`dev-ledger`**
+- Deep-link scheme: **`dev-ledger://`**
+
+## Architecture
+
+```
+┌──────────────────────────────┐ ┌─────────────────────────────┐
+│ Dev Ledger desktop (Tauri) │ │ devledger.site (Vercel) │
+│ │ │ │
+│ webview: bundled dist/ │ HTTPS │ api/* — unchanged server │
+│ src/desktop/transport.ts ───┼───────▶│ surface. Bearer tokens │
+│ (tauri-plugin-http, Rust) │ Bearer │ accepted alongside the │
+│ │ token │ sealed session cookie. │
+│ OS keychain (Rust keyring) │ │ │
+│ dev-ledger:// deep link ◀───┼────────│ system-browser OAuth │
+│ │ hand │ hands off a 1-time code │
+└──────────────────────────────┘ └─────────────────────────────┘
+```
+
+- **Frontend**: the exact `dist/` output of `npm run build` — same HTML,
+ same components, same API call sites. Nothing forks the UI.
+- **API transport**: `src/desktop/transport.ts` shims `window.fetch` so
+ `/api/*` requests go through `tauri-plugin-http` (Rust reqwest) to
+ `https://devledger.site`, carrying `Authorization: Bearer ` and
+ `Origin: https://devledger.site`. Because requests happen in Rust, the
+ webview never participates in CORS and the server keeps its strict
+ browser-only origin posture.
+- **State**: when "keep me signed in" is checked the bearer token lives
+ in the OS credential store (Windows Credential Manager, macOS Keychain,
+ Linux Secret Service) via the Rust `keyring` crate — never in
+ localStorage or files. Unchecked logins keep the token in process
+ memory for the running session only; the same memory-only fallback
+ applies when no keychain daemon is reachable.
+- **Native glue**: `tauri-plugin-opener` sends every external link to the
+ system browser; `tauri-plugin-deep-link` receives `dev-ledger://`
+ handoffs; `tauri-plugin-single-instance` keeps one window. Capabilities
+ grant exactly those plugins plus HTTP-scoped-to-devledger.site — no fs,
+ no shell, no clipboard.
+
+## Authentication flow
+
+The desktop app never renders a GitHub credential form and never sees an
+OAuth code or session cookie:
+
+1. Sign in → `GET {API}/api/auth/login?desktop_flow=` opens in the
+ **system browser** (the flow id is a random UUID minted in-app).
+2. The existing GitHub OAuth → `/api/auth/callback` → optional GitHub App
+ install → `/api/setup` chain runs unchanged in the browser.
+3. The final redirect goes to `dev-ledger://auth?flow=&code=`
+ instead of `/`. Only a **single-use handoff code** (2-minute TTL,
+ stored hashed) travels — never a session id or token.
+4. The deep link wakes the app; `flow` must equal the UUID it minted in
+ step 1 (deep links from other sources are dropped).
+5. `POST /api/auth/desktop?op=exchange` (rewritten to
+ `/api/user?action=desktop` to keep the serverless function count) with
+ `{flow, code, verifier}` consumes the code atomically, verifies the
+ PKCE-style challenge, and returns an opaque `dlt_…` bearer token
+ (stored hashed server-side, plaintext shown once). The token is bound
+ to a dedicated server-side `auth_sessions` row — logout, account
+ deletion and kill-all sessions revoke it through the same machinery
+ as browser sessions.
+
+### KEEP ME SIGNED IN on desktop
+
+The login checkbox is honored locally — server-side expiry and
+revocation are identical either way:
+
+- **Unchecked**: the exchanged token is kept in process memory only and
+ any previous keychain credential is deleted. The session works for the
+ running app; after the app closes, sign-in is required again. (This is
+ a desktop persistence choice — not a browser-cookie lifetime.)
+- **Checked**: the token is written to the OS keychain and restored on
+ the next launch, subject to the usual server-side expiry/revocation.
+
+Credentials never touch localStorage/sessionStorage in either mode.
+
+No embedded webview login: GitHub OAuth happens in the user's real
+browser with full passkey/2FA support, and the app never touches the
+credential material.
+
+### Manual configuration required
+
+**GitHub App / OAuth callback**: none. The OAuth redirect stays
+`https://devledger.site/api/auth/callback`; the deep link happens *after*
+the callback, so no custom-scheme redirect URI needs registering with
+GitHub.
+
+**Supabase**: no Auth redirect URLs — Supabase Auth is not used;
+authentication is the app's own iron-session/bearer system.
+
+**Database — release prerequisite**: desktop authentication cannot work
+in production until migration `014_desktop_auth.sql`
+(`desktop_auth_codes` + `desktop_tokens` tables) has been applied
+successfully to the production database via the normal migration path.
+Do this BEFORE publishing a desktop release — the app has no fallback.
+
+### Why bearer instead of cookies
+
+Webview cookie storage against `tauri://` cannot carry the hosted
+`devledger.site` session — cross-site cookies and SameSite rules apply
+inside webviews too. A revocable opaque token in the OS keychain is the
+established pattern (GitHub Desktop, VS Code use equivalents) and gives
+per-device revocation "Delete my data" already provides.
+
+## Local development
+
+Prerequisites: Node 24, Rust stable, and the platform toolchain
+(Windows: MSVC build tools; macOS: Xcode CLT; Linux: webkit2gtk-4.1 dev
+packages — the CI workflow lists them).
+
+```bash
+npm ci
+npm run dev # web-only Vite dev server (browser)
+
+npm run desktop:dev # tauri dev — hot-reloads the frontend in the shell
+npm run desktop:build # tauri build — production bundles for this OS
+npm run desktop:icon # regenerate icon-src + all platform icon formats
+npm run desktop:version# sync package.json version into Tauri/manifests
+```
+
+To develop against a local API, set `VITE_DESKTOP_API_BASE` (see
+`.env.example`) — production builds must leave it unset so the transport
+targets `https://devledger.site`.
+
+## Build outputs
+
+`npm run desktop:build` (or CI) writes bundles under
+`src-tauri/target/**/release/bundle/`. Tauri names them with the product
+name (`Dev Ledger_…`); CI's collect step renames every copied artifact
+through `scripts/release-assets.mjs` into the **canonical release-asset
+contract** — the only names package manifests may reference:
+
+| Platform | Bundles | Canonical release filenames |
+| --- | --- | --- |
+| Windows | NSIS `.exe`, MSI | `Dev-Ledger__x64-setup.exe`, `Dev-Ledger__x64_en-US.msi` |
+| macOS | `.app`, `.dmg` | `Dev-Ledger__aarch64.dmg`, `Dev-Ledger__x64.dmg` |
+| Linux | `.deb`, AppImage | `dev-ledger__amd64.deb`, `Dev-Ledger__amd64.AppImage` |
+
+The contract is enforced by tests — a drift between CI output and
+manifest URLs fails `desktop-distribution.test.mjs`.
+
+macOS x64 builds cross-compile from the arm64 runner via
+`--target x86_64-apple-darwin` (the `macos-13` Intel runner is retired).
+
+## CI / release flow
+
+`.github/workflows/desktop.yml`:
+
+- Every job checks out with `persist-credentials: false` and installs
+ with `npm ci --ignore-scripts`.
+- A `verify` job (build + typecheck + test + audit) runs **first**;
+ the desktop matrix `needs` it, and the draft-release job needs both.
+- **PRs / main / manual**: build matrix (windows x64, macOS arm64, macOS
+ x64, linux x64) → uploads `desktop-