diff --git a/src/App.jsx b/src/App.jsx
index bdd7a2a..eb9c7d7 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -5,6 +5,7 @@ import Login from './pages/Login'
import Signup from './pages/Signup'
import ActivateInvite from './pages/ActivateInvite'
import CliAuthorize from './pages/CliAuthorize'
+import Download from './pages/Download'
import Onboarding from './pages/Onboarding'
import PublicDashboardPage from './pages/PublicDashboardPage'
import SharedDashboardPage from './pages/SharedDashboardPage'
@@ -146,6 +147,9 @@ function App() {
/>
{/* Legacy /setup route — now the real onboarding wizard, not a dead end. */}
} />
+ {/* Public desktop-client download page — no login: it is reached from
+ the marketing site by people who do not have an account yet. */}
+ } />
} />
} />
diff --git a/src/hooks/useAuth.jsx b/src/hooks/useAuth.jsx
index fca85c5..799a875 100644
--- a/src/hooks/useAuth.jsx
+++ b/src/hooks/useAuth.jsx
@@ -14,7 +14,8 @@ import { PERMISSIONS, ROLES, ROLE_BASELINE_PERMISSIONS, normalizeRole, isAdminRo
const AuthContext = createContext(null)
-const AUTH_PUBLIC_PATHS = ['/login', '/signup', '/activate']
+// See the matching list in lib/api/client.js — '/download' is public.
+const AUTH_PUBLIC_PATHS = ['/login', '/signup', '/activate', '/download']
const isPublicAuthPath = (pathname) => AUTH_PUBLIC_PATHS.some((prefix) => pathname.startsWith(prefix))
diff --git a/src/lib/api/client.js b/src/lib/api/client.js
index 558d6bc..d98c513 100644
--- a/src/lib/api/client.js
+++ b/src/lib/api/client.js
@@ -13,7 +13,11 @@ const getApiBaseUrl = () => {
export const API_BASE_URL = getApiBaseUrl();
export const AUTH_CHANGE_EVENT = "deepsql-auth-change";
-const AUTH_PUBLIC_PATHS = ["/login", "/signup", "/activate"];
+// "/download" is reachable with no session on purpose: it is the public
+// desktop-client download page, linked from the marketing site by people who
+// do not have an account yet. Without it here, a logged-out visitor is bounced
+// to /login and never sees the installers.
+const AUTH_PUBLIC_PATHS = ["/login", "/signup", "/activate", "/download"];
const isPublicAuthPath = (pathname = "") =>
AUTH_PUBLIC_PATHS.some((prefix) => pathname.startsWith(prefix));
diff --git a/src/pages/Download.jsx b/src/pages/Download.jsx
new file mode 100644
index 0000000..ca8469b
--- /dev/null
+++ b/src/pages/Download.jsx
@@ -0,0 +1,286 @@
+import { useEffect, useMemo, useState } from 'react'
+import {
+ AlertTriangle,
+ Apple,
+ Download as DownloadIcon,
+ Loader2,
+ Monitor,
+ Package,
+ Terminal,
+} from 'lucide-react'
+
+/**
+ * Public download page for the DeepSQL desktop client.
+ *
+ * Asset list comes straight from the GitHub Releases API. This is the one place
+ * that deliberately does NOT go through lib/api/client.js: that module is the
+ * DeepSQL backend's axios layer (auth headers, refresh, error envelope), and
+ * none of it applies to a third-party public API. A plain fetch keeps the page
+ * working before a user has logged in — or on a box whose backend is down.
+ *
+ * The repo publishes two unrelated release series from the same tags list:
+ * `v1.3.0` (the DeepSQL app) and `desktop-v*` (this client). /releases/latest
+ * returns the newest of *either*, so it hands back the app release and would
+ * point every download button at the wrong artifact. Filter by tag prefix.
+ */
+
+const REPO = 'DeepSQLAI/deepsql'
+const TAG_PREFIX = 'desktop-v'
+
+const PLATFORMS = {
+ mac: { label: 'macOS', icon: Apple },
+ windows: { label: 'Windows', icon: Monitor },
+ linux: { label: 'Linux', icon: Terminal },
+}
+
+/** Classify a release asset by filename, not by position in the list. */
+function classify(asset) {
+ const name = asset.name.toLowerCase()
+ const arch = name.includes('arm64')
+ ? 'Apple Silicon'
+ : name.includes('x64') || name.includes('amd64') || name.includes('x86_64')
+ ? 'Intel / AMD64'
+ : null
+
+ if (name.endsWith('.dmg')) return { platform: 'mac', kind: 'Disk image', arch }
+ if (name.endsWith('.zip')) return { platform: 'mac', kind: 'Zip archive', arch }
+ if (name.endsWith('.exe'))
+ return {
+ platform: 'windows',
+ kind: name.includes('setup') ? 'Installer' : 'Portable',
+ arch,
+ }
+ if (name.endsWith('.appimage')) return { platform: 'linux', kind: 'AppImage', arch }
+ if (name.endsWith('.deb')) return { platform: 'linux', kind: 'Debian package', arch }
+ if (name.endsWith('.rpm')) return { platform: 'linux', kind: 'RPM package', arch }
+ return null
+}
+
+/** Best-effort guess so the primary button matches the visitor's machine. */
+function detectPlatform() {
+ const ua = navigator.userAgent || ''
+ if (/Mac|iPhone|iPad/i.test(ua)) return 'mac'
+ if (/Win/i.test(ua)) return 'windows'
+ if (/Linux|X11/i.test(ua)) return 'linux'
+ return null
+}
+
+function formatSize(bytes) {
+ if (!bytes) return ''
+ const mb = bytes / (1024 * 1024)
+ return `${mb.toFixed(1)} MB`
+}
+
+export default function Download() {
+ const [state, setState] = useState({ status: 'loading' })
+ const detected = useMemo(() => detectPlatform(), [])
+
+ useEffect(() => {
+ let cancelled = false
+
+ fetch(`https://api.github.com/repos/${REPO}/releases?per_page=30`, {
+ headers: { Accept: 'application/vnd.github+json' },
+ })
+ .then((res) => {
+ if (!res.ok) throw new Error(`GitHub returned ${res.status}`)
+ return res.json()
+ })
+ .then((releases) => {
+ if (cancelled) return
+ const release = releases.find(
+ (r) => r.tag_name?.startsWith(TAG_PREFIX) && !r.draft,
+ )
+ // No desktop release yet is a *different* answer from "we could not
+ // check", and the page must not blur the two into one empty state.
+ if (!release) return setState({ status: 'none' })
+ setState({ status: 'ready', release })
+ })
+ .catch((err) => {
+ if (cancelled) return
+ setState({ status: 'error', message: err.message })
+ })
+
+ return () => {
+ cancelled = true
+ }
+ }, [])
+
+ const grouped = useMemo(() => {
+ if (state.status !== 'ready') return {}
+ const out = { mac: [], windows: [], linux: [] }
+ for (const asset of state.release.assets || []) {
+ const meta = classify(asset)
+ if (meta) out[meta.platform].push({ ...asset, ...meta })
+ }
+ return out
+ }, [state])
+
+ return (
+
+
+
+
+ {state.status === 'loading' && (
+
+
+ Looking up the latest release…
+
+ )}
+
+ {state.status === 'error' && (
+
+ )}
+
+ {state.status === 'none' && (
+
+ )}
+
+ {state.status === 'ready' && (
+ <>
+
+
+ {state.release.tag_name.replace(TAG_PREFIX, 'Version ')}
+
+
+ released {new Date(state.release.published_at).toLocaleDateString()}
+
+
+
+ {Object.entries(PLATFORMS).map(([key, meta]) => {
+ const assets = grouped[key] || []
+ if (!assets.length) return null
+ return (
+
+ )
+ })}
+
+
+ Source and build instructions live in{' '}
+
+ desktop/README.md
+
+ .
+
+ >
+ )}
+
+
+ )
+}
+
+function PlatformSection({ platform, assets, highlight, showMacNote }) {
+ const Icon = platform.icon
+ return (
+
+
+
+
+ {platform.label}
+
+ {highlight && (
+
+ Detected
+
+ )}
+
+
+
+
+ {showMacNote && (
+
+ Builds are unsigned unless signing credentials are configured, so the first
+ launch needs right-click → Open (or{' '}
+ xattr -dr com.apple.quarantine /Applications/DeepSQL.app
+ ).
+
+ )}
+
+ )
+}
+
+function Notice({ tone, title, body, action }) {
+ return (
+
+ )
+}