Skip to content

Commit 8c10681

Browse files
feat(web): add a public /download page for the desktop client
Lists the desktop installers straight from the GitHub Releases API, grouped by platform with architecture detection and the visitor's own platform highlighted. Filters releases on the desktop-v tag prefix rather than using /releases/latest. The repo publishes two unrelated series from one tags list — v1.3.0 (the app) and desktop-v* (this client) — and /releases/latest returns the newest of either, so it hands back the app release and would point every download button at the wrong artifact. "No desktop build published yet" is kept distinct from "could not reach GitHub", so a failed fetch can never render as an empty catalogue. Adds /download to AUTH_PUBLIC_PATHS in both client.js and useAuth.jsx. Without it, useAuth's mount effect redirects any non-allowlisted path to /login when there is no session — bouncing exactly the logged-out visitors the page exists for. The page uses fetch rather than lib/api/client.js on purpose: that module is the DeepSQL backend's axios layer (auth headers, refresh, error envelope), none of which applies to a third-party public API, and the page must work with no session and a down backend. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 88f2ea9 commit 8c10681

4 files changed

Lines changed: 297 additions & 2 deletions

File tree

src/App.jsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Login from './pages/Login'
55
import Signup from './pages/Signup'
66
import ActivateInvite from './pages/ActivateInvite'
77
import CliAuthorize from './pages/CliAuthorize'
8+
import Download from './pages/Download'
89
import Onboarding from './pages/Onboarding'
910
import PublicDashboardPage from './pages/PublicDashboardPage'
1011
import SharedDashboardPage from './pages/SharedDashboardPage'
@@ -146,6 +147,9 @@ function App() {
146147
/>
147148
{/* Legacy /setup route — now the real onboarding wizard, not a dead end. */}
148149
<Route path="/setup" element={<Navigate to="/onboarding" replace />} />
150+
{/* Public desktop-client download page — no login: it is reached from
151+
the marketing site by people who do not have an account yet. */}
152+
<Route path="/download" element={<Download />} />
149153
<Route path="/cli-authorize" element={<CliAuthorize />} />
150154
<Route path="/cli-authorize/device" element={<CliAuthorize />} />
151155
</Routes>

src/hooks/useAuth.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ import { PERMISSIONS, ROLES, ROLE_BASELINE_PERMISSIONS, normalizeRole, isAdminRo
1414

1515
const AuthContext = createContext(null)
1616

17-
const AUTH_PUBLIC_PATHS = ['/login', '/signup', '/activate']
17+
// See the matching list in lib/api/client.js — '/download' is public.
18+
const AUTH_PUBLIC_PATHS = ['/login', '/signup', '/activate', '/download']
1819

1920
const isPublicAuthPath = (pathname) => AUTH_PUBLIC_PATHS.some((prefix) => pathname.startsWith(prefix))
2021

src/lib/api/client.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ const getApiBaseUrl = () => {
1313
export const API_BASE_URL = getApiBaseUrl();
1414
export const AUTH_CHANGE_EVENT = "deepsql-auth-change";
1515

16-
const AUTH_PUBLIC_PATHS = ["/login", "/signup", "/activate"];
16+
// "/download" is reachable with no session on purpose: it is the public
17+
// desktop-client download page, linked from the marketing site by people who
18+
// do not have an account yet. Without it here, a logged-out visitor is bounced
19+
// to /login and never sees the installers.
20+
const AUTH_PUBLIC_PATHS = ["/login", "/signup", "/activate", "/download"];
1721

1822
const isPublicAuthPath = (pathname = "") =>
1923
AUTH_PUBLIC_PATHS.some((prefix) => pathname.startsWith(prefix));

src/pages/Download.jsx

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
import { useEffect, useMemo, useState } from 'react'
2+
import {
3+
AlertTriangle,
4+
Apple,
5+
Download as DownloadIcon,
6+
Loader2,
7+
Monitor,
8+
Package,
9+
Terminal,
10+
} from 'lucide-react'
11+
12+
/**
13+
* Public download page for the DeepSQL desktop client.
14+
*
15+
* Asset list comes straight from the GitHub Releases API. This is the one place
16+
* that deliberately does NOT go through lib/api/client.js: that module is the
17+
* DeepSQL backend's axios layer (auth headers, refresh, error envelope), and
18+
* none of it applies to a third-party public API. A plain fetch keeps the page
19+
* working before a user has logged in — or on a box whose backend is down.
20+
*
21+
* The repo publishes two unrelated release series from the same tags list:
22+
* `v1.3.0` (the DeepSQL app) and `desktop-v*` (this client). /releases/latest
23+
* returns the newest of *either*, so it hands back the app release and would
24+
* point every download button at the wrong artifact. Filter by tag prefix.
25+
*/
26+
27+
const REPO = 'DeepSQLAI/deepsql'
28+
const TAG_PREFIX = 'desktop-v'
29+
30+
const PLATFORMS = {
31+
mac: { label: 'macOS', icon: Apple },
32+
windows: { label: 'Windows', icon: Monitor },
33+
linux: { label: 'Linux', icon: Terminal },
34+
}
35+
36+
/** Classify a release asset by filename, not by position in the list. */
37+
function classify(asset) {
38+
const name = asset.name.toLowerCase()
39+
const arch = name.includes('arm64')
40+
? 'Apple Silicon'
41+
: name.includes('x64') || name.includes('amd64') || name.includes('x86_64')
42+
? 'Intel / AMD64'
43+
: null
44+
45+
if (name.endsWith('.dmg')) return { platform: 'mac', kind: 'Disk image', arch }
46+
if (name.endsWith('.zip')) return { platform: 'mac', kind: 'Zip archive', arch }
47+
if (name.endsWith('.exe'))
48+
return {
49+
platform: 'windows',
50+
kind: name.includes('setup') ? 'Installer' : 'Portable',
51+
arch,
52+
}
53+
if (name.endsWith('.appimage')) return { platform: 'linux', kind: 'AppImage', arch }
54+
if (name.endsWith('.deb')) return { platform: 'linux', kind: 'Debian package', arch }
55+
if (name.endsWith('.rpm')) return { platform: 'linux', kind: 'RPM package', arch }
56+
return null
57+
}
58+
59+
/** Best-effort guess so the primary button matches the visitor's machine. */
60+
function detectPlatform() {
61+
const ua = navigator.userAgent || ''
62+
if (/Mac|iPhone|iPad/i.test(ua)) return 'mac'
63+
if (/Win/i.test(ua)) return 'windows'
64+
if (/Linux|X11/i.test(ua)) return 'linux'
65+
return null
66+
}
67+
68+
function formatSize(bytes) {
69+
if (!bytes) return ''
70+
const mb = bytes / (1024 * 1024)
71+
return `${mb.toFixed(1)} MB`
72+
}
73+
74+
export default function Download() {
75+
const [state, setState] = useState({ status: 'loading' })
76+
const detected = useMemo(() => detectPlatform(), [])
77+
78+
useEffect(() => {
79+
let cancelled = false
80+
81+
fetch(`https://api.github.com/repos/${REPO}/releases?per_page=30`, {
82+
headers: { Accept: 'application/vnd.github+json' },
83+
})
84+
.then((res) => {
85+
if (!res.ok) throw new Error(`GitHub returned ${res.status}`)
86+
return res.json()
87+
})
88+
.then((releases) => {
89+
if (cancelled) return
90+
const release = releases.find(
91+
(r) => r.tag_name?.startsWith(TAG_PREFIX) && !r.draft,
92+
)
93+
// No desktop release yet is a *different* answer from "we could not
94+
// check", and the page must not blur the two into one empty state.
95+
if (!release) return setState({ status: 'none' })
96+
setState({ status: 'ready', release })
97+
})
98+
.catch((err) => {
99+
if (cancelled) return
100+
setState({ status: 'error', message: err.message })
101+
})
102+
103+
return () => {
104+
cancelled = true
105+
}
106+
}, [])
107+
108+
const grouped = useMemo(() => {
109+
if (state.status !== 'ready') return {}
110+
const out = { mac: [], windows: [], linux: [] }
111+
for (const asset of state.release.assets || []) {
112+
const meta = classify(asset)
113+
if (meta) out[meta.platform].push({ ...asset, ...meta })
114+
}
115+
return out
116+
}, [state])
117+
118+
return (
119+
<div className="min-h-screen bg-white text-gray-900">
120+
<div className="max-w-3xl mx-auto px-6 py-16">
121+
<header className="mb-12">
122+
<div className="flex items-center gap-3 mb-4">
123+
<div className="h-10 w-10 rounded-xl bg-gray-900 flex items-center justify-center">
124+
<DownloadIcon className="h-5 w-5 text-white" />
125+
</div>
126+
<h1 className="text-3xl font-semibold tracking-tight">DeepSQL Desktop</h1>
127+
</div>
128+
<p className="text-gray-500 leading-relaxed">
129+
A native client for your self-hosted DeepSQL server. Connects directly over
130+
TLS or through an SSH tunnel, with connection health and transport status
131+
built into the window chrome.
132+
</p>
133+
</header>
134+
135+
{state.status === 'loading' && (
136+
<div className="flex items-center gap-3 text-gray-500 py-12">
137+
<Loader2 className="h-5 w-5 animate-spin" />
138+
<span>Looking up the latest release…</span>
139+
</div>
140+
)}
141+
142+
{state.status === 'error' && (
143+
<Notice
144+
tone="error"
145+
title="Could not reach GitHub"
146+
body={`The download list could not be loaded (${state.message}). This is a problem fetching the release list, not a sign that no build exists — you can browse releases directly on GitHub.`}
147+
action={{ href: `https://github.com/${REPO}/releases`, label: 'Open releases on GitHub' }}
148+
/>
149+
)}
150+
151+
{state.status === 'none' && (
152+
<Notice
153+
tone="info"
154+
title="No desktop build published yet"
155+
body="The release list loaded fine — there is simply no desktop-v* release with attached installers. Builds are produced by the desktop-release workflow when a desktop-v* tag is pushed."
156+
action={{ href: `https://github.com/${REPO}/releases`, label: 'Open releases on GitHub' }}
157+
/>
158+
)}
159+
160+
{state.status === 'ready' && (
161+
<>
162+
<div className="flex items-baseline gap-3 mb-8 pb-4 border-b border-gray-200">
163+
<span className="text-sm font-semibold text-gray-900">
164+
{state.release.tag_name.replace(TAG_PREFIX, 'Version ')}
165+
</span>
166+
<span className="text-sm text-gray-400">
167+
released {new Date(state.release.published_at).toLocaleDateString()}
168+
</span>
169+
</div>
170+
171+
{Object.entries(PLATFORMS).map(([key, meta]) => {
172+
const assets = grouped[key] || []
173+
if (!assets.length) return null
174+
return (
175+
<PlatformSection
176+
key={key}
177+
platform={meta}
178+
assets={assets}
179+
highlight={detected === key}
180+
showMacNote={key === 'mac'}
181+
/>
182+
)
183+
})}
184+
185+
<p className="mt-12 text-sm text-gray-400">
186+
Source and build instructions live in{' '}
187+
<a
188+
className="underline hover:text-gray-600 transition-colors"
189+
href={`https://github.com/${REPO}/blob/main/desktop/README.md`}
190+
>
191+
desktop/README.md
192+
</a>
193+
.
194+
</p>
195+
</>
196+
)}
197+
</div>
198+
</div>
199+
)
200+
}
201+
202+
function PlatformSection({ platform, assets, highlight, showMacNote }) {
203+
const Icon = platform.icon
204+
return (
205+
<section className="mb-10">
206+
<div className="flex items-center gap-2 mb-3">
207+
<Icon className="h-4 w-4 text-gray-500" />
208+
<h2 className="text-sm font-semibold uppercase tracking-wider text-gray-500">
209+
{platform.label}
210+
</h2>
211+
{highlight && (
212+
<span className="text-xs font-medium text-gray-900 bg-gray-100 px-2 py-0.5 rounded-full">
213+
Detected
214+
</span>
215+
)}
216+
</div>
217+
218+
<div className="space-y-2">
219+
{assets.map((asset) => (
220+
<a
221+
key={asset.id}
222+
href={asset.browser_download_url}
223+
className={`flex items-center justify-between gap-4 rounded-xl border px-4 py-3 transition-all ${
224+
highlight
225+
? 'border-gray-900 bg-gray-900 text-white hover:bg-gray-800'
226+
: 'border-gray-200 bg-white text-gray-900 hover:border-gray-400'
227+
}`}
228+
>
229+
<span className="flex items-center gap-3 min-w-0">
230+
<Package className={`h-4 w-4 shrink-0 ${highlight ? 'text-gray-300' : 'text-gray-400'}`} />
231+
<span className="min-w-0">
232+
<span className="block text-sm font-medium truncate">
233+
{asset.kind}
234+
{asset.arch ? ` · ${asset.arch}` : ''}
235+
</span>
236+
<span
237+
className={`block text-xs truncate ${highlight ? 'text-gray-400' : 'text-gray-400'}`}
238+
>
239+
{asset.name}
240+
</span>
241+
</span>
242+
</span>
243+
<span className={`text-xs shrink-0 ${highlight ? 'text-gray-300' : 'text-gray-400'}`}>
244+
{formatSize(asset.size)}
245+
</span>
246+
</a>
247+
))}
248+
</div>
249+
250+
{showMacNote && (
251+
<p className="mt-3 text-xs text-gray-400 leading-relaxed">
252+
Builds are unsigned unless signing credentials are configured, so the first
253+
launch needs <span className="text-gray-600">right-click → Open</span> (or{' '}
254+
<code className="text-gray-600">xattr -dr com.apple.quarantine /Applications/DeepSQL.app</code>
255+
).
256+
</p>
257+
)}
258+
</section>
259+
)
260+
}
261+
262+
function Notice({ tone, title, body, action }) {
263+
return (
264+
<div
265+
className={`rounded-xl border px-5 py-4 ${
266+
tone === 'error' ? 'border-gray-300 bg-gray-50' : 'border-gray-200 bg-gray-50'
267+
}`}
268+
>
269+
<div className="flex items-start gap-3">
270+
<AlertTriangle className="h-4 w-4 text-gray-500 mt-0.5 shrink-0" />
271+
<div>
272+
<p className="text-sm font-semibold text-gray-900 mb-1">{title}</p>
273+
<p className="text-sm text-gray-500 leading-relaxed">{body}</p>
274+
{action && (
275+
<a
276+
href={action.href}
277+
className="inline-block mt-3 text-sm font-medium text-gray-900 underline hover:text-gray-600 transition-colors"
278+
>
279+
{action.label}
280+
</a>
281+
)}
282+
</div>
283+
</div>
284+
</div>
285+
)
286+
}

0 commit comments

Comments
 (0)