Skip to content

Commit 804e713

Browse files
feat(web): add a public /download page for the desktop client (#87)
Adds a public `/download` page listing the DeepSQL Desktop installers, read live from the GitHub Releases API and grouped by platform with architecture detection. ## Why the tag-prefix filter matters The repo publishes **two unrelated release series from one tags list**: `v1.3.0` (the DeepSQL app) and `desktop-v*` (the desktop client). `/releases/latest` returns the newest of *either*, so today it returns `v1.3.0` — a naive download page would point every button at the wrong artifact. The page filters on the `desktop-v` prefix instead. ## Public route `/download` is added to `AUTH_PUBLIC_PATHS` in **both** `src/lib/api/client.js` and `src/hooks/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 this page exists for. The page uses `fetch` rather than `lib/api/client.js` deliberately: that module is the 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. ## Honest empty states "No desktop build published yet" is kept distinct from "could not reach GitHub", so a failed fetch can never render as an empty catalogue. ## Verification - Rendered logged-out in a browser: correctly shows the *no build published* state, since no `desktop-v*` release exists yet. - With a stubbed release payload: correctly skips `v1.3.0`, selects `desktop-v1.0.0`, groups mac/windows/linux, labels Apple Silicon vs Intel/AMD64, highlights the detected platform, and shows the unsigned-macOS first-launch note. - `npm run build` passes; eslint reports 0 errors on changed files. ## Not included There are currently **no downloadable assets** — the `desktop-v1.0.0` tag failed to build on all three platforms. The fix for that is a separate commit that cannot be pushed with the current token (`.github/workflows` needs the `workflow` OAuth scope); it will follow in its own PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 88f2ea9 commit 804e713

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)