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..a8b85be --- /dev/null +++ b/.github/workflows/desktop.yml @@ -0,0 +1,175 @@ +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: + # Quality gates — the desktop matrix and any tag-driven draft release + # may only run after the full web-quality suite passes. A v* tag can + # never produce even a draft release from a commit that fails these. + verify: + name: quality gates + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + cache: npm + + - name: Install dependencies + # Lockfile-enforced; install lifecycle hooks never run (esbuild / + # fsevents resolve through optional platform binaries instead). + run: npm ci --ignore-scripts + + - name: Build + run: npm run build + + - name: Typecheck + run: npm run typecheck + + - name: Test + run: npm test + + - name: Audit + run: npm audit --audit-level=moderate + + build: + name: ${{ matrix.label }} + needs: verify + 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 + with: + persist-credentials: false + + - 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 --ignore-scripts + + - 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/ \; + # Normalize to the canonical release-asset filename contract — + # package-manager manifests point at these exact names. + node scripts/release-assets.mjs 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: [verify, build] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - 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/.github/workflows/security.yml b/.github/workflows/security.yml index e7a6a5e..af3f8e3 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -20,6 +20,9 @@ on: pull_request: push: branches: [main] + # Release tags also get the full-history scan — a desktop draft + # release must never be cut from history containing a leaked secret. + tags: ['v*'] permissions: contents: read 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..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-