11import { useEffect , useMemo , useState } from 'react'
2+ import { Link } from 'react-router-dom'
23import {
34 AlertTriangle ,
45 Apple ,
6+ ArrowLeft ,
57 Download as DownloadIcon ,
68 Loader2 ,
79 Monitor ,
@@ -42,17 +44,18 @@ function classify(asset) {
4244 ? 'Intel / AMD64'
4345 : null
4446
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 ( '.dmg' ) ) return { platform : 'mac' , kind : 'Disk image' , arch, rank : 0 }
48+ if ( name . endsWith ( '.zip' ) ) return { platform : 'mac' , kind : 'Zip archive' , arch, rank : 1 }
4749 if ( name . endsWith ( '.exe' ) )
4850 return {
4951 platform : 'windows' ,
5052 kind : name . includes ( 'setup' ) ? 'Installer' : 'Portable' ,
5153 arch,
54+ rank : name . includes ( 'setup' ) ? 0 : 1 ,
5255 }
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+ if ( name . endsWith ( '.appimage' ) ) return { platform : 'linux' , kind : 'AppImage' , arch, rank : 1 }
57+ if ( name . endsWith ( '.deb' ) ) return { platform : 'linux' , kind : 'Debian package' , arch, rank : 0 }
58+ if ( name . endsWith ( '.rpm' ) ) return { platform : 'linux' , kind : 'RPM package' , arch, rank : 2 }
5659 return null
5760}
5861
@@ -65,12 +68,44 @@ function detectPlatform() {
6568 return null
6669}
6770
71+ /** Prefer Apple Silicon builds on arm64 Macs when both exist. */
72+ function detectMacArchPreference ( ) {
73+ const ua = navigator . userAgent || ''
74+ if ( / a r m 6 4 | a a r c h 6 4 / i. test ( ua ) ) return 'Apple Silicon'
75+ return 'Intel / AMD64'
76+ }
77+
6878function formatSize ( bytes ) {
6979 if ( ! bytes ) return ''
7080 const mb = bytes / ( 1024 * 1024 )
7181 return `${ mb . toFixed ( 1 ) } MB`
7282}
7383
84+ function pickPrimaryAsset ( grouped , platform ) {
85+ const assets = grouped [ platform ] || [ ]
86+ if ( ! assets . length ) return null
87+
88+ const archPref = platform === 'mac' ? detectMacArchPreference ( ) : null
89+ const sorted = [ ...assets ] . sort ( ( a , b ) => {
90+ if ( archPref ) {
91+ const aMatch = a . arch === archPref ? 0 : 1
92+ const bMatch = b . arch === archPref ? 0 : 1
93+ if ( aMatch !== bMatch ) return aMatch - bMatch
94+ }
95+ return ( a . rank ?? 9 ) - ( b . rank ?? 9 )
96+ } )
97+ return sorted [ 0 ]
98+ }
99+
100+ function latestDesktopRelease ( releases ) {
101+ return releases
102+ . filter ( ( r ) => r . tag_name ?. startsWith ( TAG_PREFIX ) && ! r . draft )
103+ . sort (
104+ ( a , b ) =>
105+ new Date ( b . published_at ) . getTime ( ) - new Date ( a . published_at ) . getTime ( ) ,
106+ ) [ 0 ]
107+ }
108+
74109export default function Download ( ) {
75110 const [ state , setState ] = useState ( { status : 'loading' } )
76111 const detected = useMemo ( ( ) => detectPlatform ( ) , [ ] )
@@ -87,11 +122,7 @@ export default function Download() {
87122 } )
88123 . then ( ( releases ) => {
89124 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.
125+ const release = latestDesktopRelease ( releases )
95126 if ( ! release ) return setState ( { status : 'none' } )
96127 setState ( { status : 'ready' , release } )
97128 } )
@@ -112,12 +143,33 @@ export default function Download() {
112143 const meta = classify ( asset )
113144 if ( meta ) out [ meta . platform ] . push ( { ...asset , ...meta } )
114145 }
146+ for ( const key of Object . keys ( out ) ) {
147+ out [ key ] . sort ( ( a , b ) => ( a . rank ?? 9 ) - ( b . rank ?? 9 ) )
148+ }
115149 return out
116150 } , [ state ] )
117151
152+ const primary = useMemo (
153+ ( ) => ( detected ? pickPrimaryAsset ( grouped , detected ) : null ) ,
154+ [ grouped , detected ] ,
155+ )
156+
157+ const versionLabel =
158+ state . status === 'ready'
159+ ? state . release . tag_name . replace ( TAG_PREFIX , 'Version ' )
160+ : null
161+
118162 return (
119163 < div className = "min-h-screen bg-white text-gray-900" >
120164 < div className = "max-w-3xl mx-auto px-6 py-16" >
165+ < Link
166+ to = "/login"
167+ className = "inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-800 transition-colors mb-8"
168+ >
169+ < ArrowLeft className = "h-4 w-4" />
170+ Back to sign in
171+ </ Link >
172+
121173 < header className = "mb-12" >
122174 < div className = "flex items-center gap-3 mb-4" >
123175 < div className = "h-10 w-10 rounded-xl bg-gray-900 flex items-center justify-center" >
@@ -144,7 +196,10 @@ export default function Download() {
144196 tone = "error"
145197 title = "Could not reach GitHub"
146198 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' } }
199+ action = { {
200+ href : `https://github.com/${ REPO } /releases` ,
201+ label : 'Open releases on GitHub' ,
202+ } }
148203 />
149204 ) }
150205
@@ -153,21 +208,40 @@ export default function Download() {
153208 tone = "info"
154209 title = "No desktop build published yet"
155210 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' } }
211+ action = { {
212+ href : `https://github.com/${ REPO } /releases` ,
213+ label : 'Open releases on GitHub' ,
214+ } }
157215 />
158216 ) }
159217
160218 { state . status === 'ready' && (
161219 < >
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 >
220+ < div className = "flex flex-wrap items-baseline gap-3 mb-6 pb-4 border-b border-gray-200" >
221+ < span className = "text-sm font-semibold text-gray-900" > { versionLabel } </ span >
166222 < span className = "text-sm text-gray-400" >
167223 released { new Date ( state . release . published_at ) . toLocaleDateString ( ) }
168224 </ span >
169225 </ div >
170226
227+ { primary && (
228+ < div className = "mb-10" >
229+ < a
230+ href = { primary . browser_download_url }
231+ className = "inline-flex w-full sm:w-auto items-center justify-center gap-2 rounded-full bg-gray-900 px-6 py-3.5 text-sm font-semibold text-white shadow-lg transition-all hover:bg-gray-800 active:scale-[0.98]"
232+ >
233+ < DownloadIcon className = "h-4 w-4" />
234+ Download for { PLATFORMS [ detected ] . label }
235+ { primary . arch ? ` (${ primary . arch } )` : '' }
236+ </ a >
237+ < p className = "mt-2 text-xs text-gray-400" >
238+ { primary . kind } · { primary . name } · { formatSize ( primary . size ) }
239+ </ p >
240+ </ div >
241+ ) }
242+
243+ { detected === 'mac' && < MacGatekeeperNotice /> }
244+
171245 { Object . entries ( PLATFORMS ) . map ( ( [ key , meta ] ) => {
172246 const assets = grouped [ key ] || [ ]
173247 if ( ! assets . length ) return null
@@ -190,6 +264,13 @@ export default function Download() {
190264 >
191265 desktop/README.md
192266 </ a >
267+ . All installers for this release are also on{ ' ' }
268+ < a
269+ className = "underline hover:text-gray-600 transition-colors"
270+ href = { state . release . html_url }
271+ >
272+ GitHub
273+ </ a >
193274 .
194275 </ p >
195276 </ >
@@ -227,7 +308,9 @@ function PlatformSection({ platform, assets, highlight, showMacNote }) {
227308 } `}
228309 >
229310 < 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' } ` } />
311+ < Package
312+ className = { `h-4 w-4 shrink-0 ${ highlight ? 'text-gray-300' : 'text-gray-400' } ` }
313+ />
231314 < span className = "min-w-0" >
232315 < span className = "block text-sm font-medium truncate" >
233316 { asset . kind }
@@ -247,18 +330,52 @@ function PlatformSection({ platform, assets, highlight, showMacNote }) {
247330 ) ) }
248331 </ div >
249332
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- ) }
333+ { showMacNote && < MacGatekeeperNotice compact /> }
258334 </ section >
259335 )
260336}
261337
338+ /** macOS builds ship unsigned unless signing secrets are configured in CI. */
339+ function MacGatekeeperNotice ( { compact = false } ) {
340+ return (
341+ < div
342+ className = { `rounded-xl border border-amber-200 bg-amber-50 text-amber-950 ${
343+ compact ? 'mt-3 px-4 py-3' : 'mb-10 px-5 py-4'
344+ } `}
345+ >
346+ < div className = "flex items-start gap-3" >
347+ < AlertTriangle className = "h-4 w-4 text-amber-700 mt-0.5 shrink-0" />
348+ < div className = "text-sm leading-relaxed" >
349+ < p className = { `font-semibold text-amber-950 ${ compact ? 'mb-1' : 'mb-2' } ` } >
350+ macOS may block the first launch
351+ </ p >
352+ < p className = "text-amber-900/90 mb-3" >
353+ Installers are not Apple-notarized yet, so double-clicking can show
354+ “DeepSQL” Not Opened with only < strong > Done</ strong > and{ ' ' }
355+ < strong > Move to Trash</ strong > . That is expected — use one of these instead:
356+ </ p >
357+ < ol className = "list-decimal pl-5 space-y-2 text-amber-900/90" >
358+ < li >
359+ In Finder, < strong > right-click DeepSQL.app → Open</ strong > , then click{ ' ' }
360+ < strong > Open</ strong > in the dialog. You only need to do this once.
361+ </ li >
362+ < li >
363+ Or open < strong > System Settings → Privacy & Security</ strong > , scroll
364+ down after the block, and click < strong > Open Anyway</ strong > .
365+ </ li >
366+ < li >
367+ Or run in Terminal:{ ' ' }
368+ < code className = "text-xs bg-amber-100/80 px-1.5 py-0.5 rounded" >
369+ xattr -dr com.apple.quarantine /Applications/DeepSQL.app
370+ </ code >
371+ </ li >
372+ </ ol >
373+ </ div >
374+ </ div >
375+ </ div >
376+ )
377+ }
378+
262379function Notice ( { tone, title, body, action } ) {
263380 return (
264381 < div
0 commit comments