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 ( +
+
+
+
+
+ +
+

DeepSQL Desktop

+
+

+ A native client for your self-hosted DeepSQL server. Connects directly over + TLS or through an SSH tunnel, with connection health and transport status + built into the window chrome. +

+
+ + {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 ( +
+
+ +
+

{title}

+

{body}

+ {action && ( + + {action.label} + + )} +
+
+
+ ) +}