From 708927a29e96246971f04df6f991c22ae104ec0a Mon Sep 17 00:00:00 2001 From: aliferous3 Date: Tue, 20 Oct 2026 22:04:54 +0400 Subject: [PATCH 1/7] feat: add desktop distribution for Windows, macOS and Linux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=&desktop_challenge= 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> --- .env.example | 6 + .github/workflows/desktop.yml | 135 ++++++ AGENTS.md | 3 + README.md | 1 + api/auth/callback.mjs | 10 +- api/auth/login.mjs | 14 + api/setup.mjs | 10 +- api/user.mjs | 68 ++- distribution/README.md | 25 ++ .../flatpak/site.devledger.desktop.desktop | 11 + .../site.devledger.desktop.metainfo.xml | 47 ++ .../flatpak/site.devledger.desktop.png | Bin 0 -> 8795 bytes .../flatpak/site.devledger.desktop.yml | 54 +++ distribution/homebrew/dev-ledger.rb | 35 ++ .../winget/NoamanAli.DevLedger.installer.yaml | 24 + .../NoamanAli.DevLedger.locale.en-US.yaml | 19 + distribution/winget/NoamanAli.DevLedger.yaml | 10 + docs/desktop.md | 238 ++++++++++ lib/desktop-auth.mjs | 189 ++++++++ lib/desktop-endpoint.mjs | 120 +++++ lib/require-user.mjs | 15 +- lib/security-events.mjs | 15 + migrations/014_desktop_auth.sql | 72 +++ package-lock.json | 274 ++++++++++++ package.json | 12 +- scripts/backup-scope.mjs | 4 + scripts/desktop-version.mjs | 79 ++++ scripts/gen-icon.mjs | 81 ++++ src-tauri/.gitignore | 2 + src-tauri/Cargo.toml | 40 ++ src-tauri/build.rs | 3 + src-tauri/capabilities/default.json | 25 ++ src-tauri/icons/128x128.png | Bin 0 -> 2026 bytes src-tauri/icons/128x128@2x.png | Bin 0 -> 4120 bytes src-tauri/icons/32x32.png | Bin 0 -> 568 bytes src-tauri/icons/64x64.png | Bin 0 -> 863 bytes src-tauri/icons/StoreLogo.png | Bin 0 -> 808 bytes src-tauri/icons/icon-src.png | Bin 0 -> 11305 bytes src-tauri/icons/icon.icns | Bin 0 -> 46663 bytes src-tauri/icons/icon.ico | Bin 0 -> 7887 bytes src-tauri/icons/icon.png | Bin 0 -> 8795 bytes src-tauri/src/lib.rs | 130 ++++++ src-tauri/src/main.rs | 6 + src-tauri/tauri.conf.json | 72 +++ src/components/AccountMenu.tsx | 9 + src/components/SyncMonitor.tsx | 3 +- src/desktop/auth.ts | 148 +++++++ src/desktop/index.ts | 25 ++ src/desktop/keys.ts | 61 +++ src/desktop/links.ts | 60 +++ src/desktop/runtime.ts | 29 ++ src/desktop/shortcuts.ts | 53 +++ src/desktop/transport.ts | 59 +++ src/ledger/LoginScreen.tsx | 9 +- src/ledger/shared.tsx | 4 +- src/main.tsx | 62 ++- test/auth-login.test.mjs | 2 +- test/backup-schema.test.mjs | 9 +- test/desktop-auth.test.mjs | 411 ++++++++++++++++++ test/desktop-distribution.test.mjs | 238 ++++++++++ test/ownership-checkpoint-schema.test.mjs | 2 +- test/ownership-schema.test.mjs | 2 +- test/security-events.test.mjs | 8 + test/supply-chain.test.mjs | 7 +- vercel.json | 4 + 65 files changed, 3001 insertions(+), 53 deletions(-) create mode 100644 .github/workflows/desktop.yml create mode 100644 distribution/README.md create mode 100644 distribution/flatpak/site.devledger.desktop.desktop create mode 100644 distribution/flatpak/site.devledger.desktop.metainfo.xml create mode 100644 distribution/flatpak/site.devledger.desktop.png create mode 100644 distribution/flatpak/site.devledger.desktop.yml create mode 100644 distribution/homebrew/dev-ledger.rb create mode 100644 distribution/winget/NoamanAli.DevLedger.installer.yaml create mode 100644 distribution/winget/NoamanAli.DevLedger.locale.en-US.yaml create mode 100644 distribution/winget/NoamanAli.DevLedger.yaml create mode 100644 docs/desktop.md create mode 100644 lib/desktop-auth.mjs create mode 100644 lib/desktop-endpoint.mjs create mode 100644 migrations/014_desktop_auth.sql create mode 100644 scripts/desktop-version.mjs create mode 100644 scripts/gen-icon.mjs create mode 100644 src-tauri/.gitignore create mode 100644 src-tauri/Cargo.toml create mode 100644 src-tauri/build.rs create mode 100644 src-tauri/capabilities/default.json create mode 100644 src-tauri/icons/128x128.png create mode 100644 src-tauri/icons/128x128@2x.png create mode 100644 src-tauri/icons/32x32.png create mode 100644 src-tauri/icons/64x64.png create mode 100644 src-tauri/icons/StoreLogo.png create mode 100644 src-tauri/icons/icon-src.png create mode 100644 src-tauri/icons/icon.icns create mode 100644 src-tauri/icons/icon.ico create mode 100644 src-tauri/icons/icon.png create mode 100644 src-tauri/src/lib.rs create mode 100644 src-tauri/src/main.rs create mode 100644 src-tauri/tauri.conf.json create mode 100644 src/desktop/auth.ts create mode 100644 src/desktop/index.ts create mode 100644 src/desktop/keys.ts create mode 100644 src/desktop/links.ts create mode 100644 src/desktop/runtime.ts create mode 100644 src/desktop/shortcuts.ts create mode 100644 src/desktop/transport.ts create mode 100644 test/desktop-auth.test.mjs create mode 100644 test/desktop-distribution.test.mjs diff --git a/.env.example b/.env.example index af623f9..f31a71a 100644 --- a/.env.example +++ b/.env.example @@ -68,3 +68,9 @@ OWNERSHIP_INGEST_ENABLED=false # Comma-separated internal repository UUIDs only — never repo names, # full_name, or GitHub logins. Empty/malformed = nothing can ingest. OWNERSHIP_CANARY_REPOSITORY_IDS= + +# Desktop development only: point the Tauri shell's API transport at a +# local `vercel dev` instance (e.g. http://localhost:3000). MUST stay +# unset for production desktop builds — the transport then targets +# https://devledger.site. See docs/desktop.md. +VITE_DESKTOP_API_BASE= diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml new file mode 100644 index 0000000..9ef5dad --- /dev/null +++ b/.github/workflows/desktop.yml @@ -0,0 +1,135 @@ +name: desktop + +# Desktop build matrix. PR/main pushes produce CI artifacts only; an +# explicit `v*` tag produces the same artifacts and a DRAFT GitHub Release +# (never auto-published) with SHA-256 checksums for the package-manager +# manifests in distribution/. + +on: + pull_request: + push: + branches: [main] + tags: ['v*'] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: desktop-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: ${{ matrix.label }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + target: x86_64-pc-windows-msvc + bundles: nsis,msi + label: windows-x64 + artifact-glob: 'src-tauri/target/**/release/bundle/{nsis,msi}/*' + - os: macos-latest + target: aarch64-apple-darwin + bundles: dmg,app + label: macos-arm64 + artifact-glob: 'src-tauri/target/**/release/bundle/{dmg,macos}/*' + - os: macos-latest + target: x86_64-apple-darwin + bundles: dmg,app + label: macos-x64 + artifact-glob: 'src-tauri/target/**/release/bundle/{dmg,macos}/*' + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + bundles: deb,appimage + label: linux-x64 + artifact-glob: 'src-tauri/target/**/release/bundle/{deb,appimage}/*' + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + cache: npm + + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + targets: ${{ matrix.target }} + + - uses: swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + with: + workspaces: src-tauri -> target + + - name: Linux build dependencies + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential curl wget file patchelf \ + libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev + + - name: Install dependencies + run: npm ci + + - name: Build desktop bundles + env: + # Signing is intentionally not configured — artifacts are unsigned + # until the secrets documented in docs/desktop.md are provided. + TAURI_SIGNING_PRIVATE_KEY: '' + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: '' + run: npx tauri build --target ${{ matrix.target }} --bundles ${{ matrix.bundles }} + + - name: Collect artifacts + shell: bash + run: | + mkdir -p dist-artifacts + find src-tauri/target -type f \( \ + -name '*.exe' -o -name '*.msi' -o \ + -name '*.dmg' -o -name '*.deb' -o -name '*.AppImage' \) \ + -path '*/bundle/*' -exec cp {} dist-artifacts/ \; + ls -la dist-artifacts/ + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: desktop-${{ matrix.label }} + path: dist-artifacts/ + if-no-files-found: error + + release: + name: draft-release + if: startsWith(github.ref, 'refs/tags/v') + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + pattern: desktop-* + path: artifacts + merge-multiple: true + + - name: SHA-256 checksums + working-directory: artifacts + run: | + sha256sum * > SHA256SUMS.txt + cat SHA256SUMS.txt + + - name: Create draft release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "${GITHUB_REF_NAME}" \ + --draft \ + --title "Dev Ledger ${GITHUB_REF_NAME}" \ + --notes "Desktop builds for Windows, macOS and Linux. See SHA256SUMS.txt for checksums. Artifacts are unsigned — do not publish before signing/notarization is configured." \ + artifacts/* + +# NOTE: release is a DRAFT — a human verifies artifacts + checksums before +# publishing. Never publish unsigned artifacts as a stable release. diff --git a/AGENTS.md b/AGENTS.md index 0815c42..4708de7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,9 @@ These notes define the public development contract for humans and coding agents - Dependency audit: `npm audit --audit-level=moderate` - Database migration: `npm run db:migrate` - Local recovery drill: `npm run db:drill` +- Desktop dev (Tauri): `npm run desktop:dev` +- Desktop bundle build: `npm run desktop:build` +- Desktop version sync: `npm run desktop:version` ## Product invariants diff --git a/README.md b/README.md index c897aea..839eb46 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ The frontend is React + Vite. API routes run as Vercel Functions. GitHub App ing DataSupabase Postgres Product analyticsPostHog · custom-event-only Traffic analyticsVercel Web Analytics +DesktopTauri 2 · docs/desktop.md CIGitHub Actions Security checksTypecheck · tests · npm audit · gitleaks · artifact scan · recovery drill 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..0e443dd --- /dev/null +++ b/distribution/README.md @@ -0,0 +1,25 @@ +# Distribution metadata + +Package-manager manifests for the Dev Ledger desktop app. Nothing here is +submitted anywhere — every file is a preparation artifact whose +`version`/`sha256` placeholders get filled from a real GitHub Release. + +| 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 + `.desktop` + AppStream metainfo | + +## Per-release update process + +1. Bump `package.json` version, then run `npm run desktop:version` — it + syncs the version into `src-tauri/tauri.conf.json`, + `src-tauri/Cargo.toml`, these manifests, and the AppStream release entry. +2. Tag `v` — the desktop CI builds artifacts and a draft release + with `SHA256SUMS.txt`. +3. Fill every `sha256`/`InstallerSha256`/`size` placeholder from + `SHA256SUMS.txt` (all-zero placeholders mean "not releasable yet" — + tests enforce this rule). +4. Submit/copy the updated manifests to the package-manager repositories. + +See `docs/desktop.md` for the full submission walkthroughs. 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 0000000000000000000000000000000000000000..78043184608cdfe06df7e5e6d5c2eb6e77dae02f GIT binary patch literal 8795 zcmbt)XH-*L)BjFFQ@|jCAYD;Vsa`}-fuMM?AOaRZia`;iw@8te03s;OdwD2Q1i_9p z={2zcD$<+OU?NRw2ql5!ocHjo|Cj%V_j%U3?}x0DwP()knb~{Kp83srXnyIe@OG)~ z0080h=gwRP0FC@b0|7qdu@cm~0l>a}=g*wF8ZK=c4;)(oHl{&%S?C8yUhd&eq zA5(M{DYqmBgPPh{@6+E|4R!@9I-VVjtB}vr{cG@@?AO1=Re7b~eg5i(vp#R&U|XrG zx<{>J; z-+B4!Rg&r&bp3RMp#q5SU6rmsSZ72$R^ix^80On}A~Eo@Am>|9Dc*8>7>xT9CVrZY z3-z9M*WP`vNY9&caE(t0R}@}!oHM4)F-VU(6}n)amGcNo6m9viOZk$X?9|M඿ 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. 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**: 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. If no + keychain daemon is reachable the token degrades to process memory only. +- **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?action=exchange` consumes the code atomically + 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. + +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**: apply migration `014_desktop_auth.sql` +(`npm run db:migrate`) to create `desktop_auth_codes` + `desktop_tokens` +before desktop sign-in works in production. + +### 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/`: + +| Platform | Bundles | Typical names | +| --- | --- | --- | +| Windows | NSIS `.exe`, MSI | `Dev-Ledger_1.3.0_x64-setup.exe`, `Dev-Ledger_1.3.0_x64_en-US.msi` | +| macOS | `.app`, `.dmg` | `Dev-Ledger_1.3.0_aarch64.dmg`, `Dev-Ledger_1.3.0_x64.dmg` | +| Linux | `.deb`, AppImage | `dev-ledger_1.3.0_amd64.deb`, `dev-ledger_1.3.0_amd64.AppImage` | + +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`: + +- **PRs / main / manual**: build matrix (windows x64, macOS arm64, macOS + x64, linux x64) → uploads `desktop-