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