@@ -6,6 +6,7 @@ import { register as registerGlobalShortcut, unregisterAll as unregisterAllGloba
66import { isPermissionGranted , requestPermission , sendNotification } from "@tauri-apps/plugin-notification" ;
77import { open as openExternal } from "@tauri-apps/plugin-shell" ;
88import { check } from "@tauri-apps/plugin-updater" ;
9+ import type { Update , DownloadEvent } from "@tauri-apps/plugin-updater" ;
910import MainLayout from "./components/layout/MainLayout" ;
1011import Loading from "./components/Loading" ;
1112
@@ -86,15 +87,17 @@ export default function MainApp() {
8687 } = useStatsStore ( ) ;
8788 const {
8889 setMonitoringActive,
89- autoCheckUpdates ,
90+ updateMode ,
9091 notificationQuietHoursEnabled,
9192 notificationQuietStart,
9293 notificationQuietEnd,
9394 notificationCooldownMin,
9495 } = useSettingsStore ( ) ;
9596 const { t } = useTranslation ( [ "common" , "limits" , "browserUsage" ] ) ;
9697
97- const [ updateInfo , setUpdateInfo ] = useState < { version : string ; notes : string ; url : string } | null > ( null ) ;
98+ const [ updateInfo , setUpdateInfo ] = useState < { version : string ; notes : string ; url : string ; update : Update | null } | null > ( null ) ;
99+ const [ updatePhase , setUpdatePhase ] = useState < "available" | "downloading" | "downloaded" | "installing" > ( "available" ) ;
100+ const [ downloadProgress , setDownloadProgress ] = useState ( 0 ) ;
98101 const updateCloseButtonRef = useRef < HTMLButtonElement > ( null ) ;
99102
100103 useEffect ( ( ) => {
@@ -318,7 +321,7 @@ export default function MainApp() {
318321
319322 // Update check – once after 4 s
320323 useEffect ( ( ) => {
321- if ( ! autoCheckUpdates ) return ;
324+ if ( updateMode === "off" ) return ;
322325
323326 const timer = setTimeout ( async ( ) => {
324327 try {
@@ -329,7 +332,6 @@ export default function MainApp() {
329332 if ( ! ( latest && compareVersions ( latest , CURRENT_VERSION ) > 0 ) ) return ;
330333
331334 const channel = await api . getInstallChannelInfo ( ) ;
332- setUpdateInfo ( { version : latest , notes : data . body ?? "" , url : data . html_url ?? "" } ) ;
333335
334336 if ( ! channel . should_trigger_update ) {
335337 const storeUpdateUrl = channel . update_url ?? "ms-windows-store://downloadsandupdates" ;
@@ -347,32 +349,46 @@ export default function MainApp() {
347349 return ;
348350 }
349351
352+ if ( updateMode === "auto" ) {
353+ try {
354+ const update = await check ( ) ;
355+ if ( update ) {
356+ await update . downloadAndInstall ( ) ;
357+ await api . relaunchApp ( ) ;
358+ return ;
359+ }
360+ } catch {
361+ // fallback to release page
362+ }
363+ if ( data . html_url ) window . open ( data . html_url , "_blank" , "noopener,noreferrer" ) ;
364+ await notifyWithNavigate (
365+ t ( "common:updateAvailableTitle" ) ,
366+ t ( "common:updateAvailableBody" , { version : latest , current : CURRENT_VERSION } ) ,
367+ "#/settings"
368+ ) ;
369+ return ;
370+ }
371+
372+ // notify mode: show the manual download/install dialog
350373 try {
351374 const update = await check ( ) ;
352375 if ( update ) {
353- await update . downloadAndInstall ( ) ;
354- await notifyWithNavigate (
355- t ( "common:updateAvailableTitle" ) ,
356- t ( "common:updateInstallReady" , { version : latest } ) ,
357- "#/settings"
358- ) ;
376+ setUpdateInfo ( { version : latest , notes : data . body ?? "" , url : data . html_url ?? "" , update } ) ;
377+ setUpdatePhase ( "available" ) ;
378+ setDownloadProgress ( 0 ) ;
359379 return ;
360380 }
361381 } catch {
362- // fallback to release page
382+ // fallback to release page modal
363383 }
364384
365- if ( data . html_url ) window . open ( data . html_url , "_blank" , "noopener,noreferrer" ) ;
366-
367- await notifyWithNavigate (
368- t ( "common:updateAvailableTitle" ) ,
369- t ( "common:updateAvailableBody" , { version : latest , current : CURRENT_VERSION } ) ,
370- "#/settings"
371- ) ;
385+ setUpdateInfo ( { version : latest , notes : data . body ?? "" , url : data . html_url ?? "" , update : null } ) ;
386+ setUpdatePhase ( "available" ) ;
387+ setDownloadProgress ( 0 ) ;
372388 } catch { /* offline */ }
373389 } , 4000 ) ;
374390 return ( ) => clearTimeout ( timer ) ;
375- } , [ autoCheckUpdates , notifyWithNavigate , t ] ) ;
391+ } , [ updateMode , notifyWithNavigate , t ] ) ;
376392
377393 useEffect ( ( ) => {
378394 if ( updateInfo ) {
@@ -447,6 +463,54 @@ export default function MainApp() {
447463 } ;
448464 } , [ focusMainAndNavigate , setMonitorActive , setMonitoringActive , toggleWidgetsVisibility ] ) ;
449465
466+ const closeUpdateModal = useCallback ( ( ) => {
467+ if ( updateInfo ?. update && updatePhase === "available" ) {
468+ updateInfo . update . close ( ) . catch ( ( ) => { } ) ;
469+ }
470+ setUpdateInfo ( null ) ;
471+ setUpdatePhase ( "available" ) ;
472+ setDownloadProgress ( 0 ) ;
473+ } , [ updateInfo , updatePhase ] ) ;
474+
475+ const handleDownloadUpdate = useCallback ( async ( ) => {
476+ if ( ! updateInfo ?. update ) return ;
477+ setUpdatePhase ( "downloading" ) ;
478+ setDownloadProgress ( 0 ) ;
479+ let downloaded = 0 ;
480+ let contentLength = 0 ;
481+ try {
482+ await updateInfo . update . download ( ( event : DownloadEvent ) => {
483+ if ( event . event === "Started" ) {
484+ contentLength = event . data . contentLength ?? 0 ;
485+ } else if ( event . event === "Progress" ) {
486+ downloaded += event . data . chunkLength ;
487+ if ( contentLength > 0 ) {
488+ setDownloadProgress ( Math . min ( 100 , Math . round ( ( downloaded / contentLength ) * 100 ) ) ) ;
489+ }
490+ }
491+ } ) ;
492+ setUpdatePhase ( "downloaded" ) ;
493+ } catch ( err ) {
494+ setUpdatePhase ( "available" ) ;
495+ setDownloadProgress ( 0 ) ;
496+ const message = err instanceof Error ? err . message : "" ;
497+ await notifyWithNavigate ( t ( "common:downloadUpdateFailed" ) , message , "#/settings" ) ;
498+ }
499+ } , [ updateInfo , notifyWithNavigate , t ] ) ;
500+
501+ const handleInstallUpdate = useCallback ( async ( ) => {
502+ if ( ! updateInfo ?. update ) return ;
503+ setUpdatePhase ( "installing" ) ;
504+ try {
505+ await updateInfo . update . install ( ) ;
506+ await api . relaunchApp ( ) ;
507+ } catch ( err ) {
508+ setUpdatePhase ( "downloaded" ) ;
509+ const message = err instanceof Error ? err . message : "" ;
510+ await notifyWithNavigate ( t ( "common:error" ) , message , "#/settings" ) ;
511+ }
512+ } , [ updateInfo , notifyWithNavigate , t ] ) ;
513+
450514 return (
451515 < HashRouter future = { { v7_startTransition : true , v7_relativeSplatPath : true } } >
452516 < MainLayout >
@@ -486,27 +550,88 @@ export default function MainApp() {
486550 </ div >
487551 < button
488552 ref = { updateCloseButtonRef }
489- onClick = { ( ) => setUpdateInfo ( null ) }
553+ onClick = { closeUpdateModal }
490554 aria-label = { t ( "common:close" ) }
491555 title = { t ( "common:close" ) }
492556 className = "text-text-muted hover:text-text-primary flex-shrink-0"
493557 >
494558 ✕
495559 </ button >
496560 </ div >
497- { updateInfo . notes && (
561+
562+ { updateInfo . notes && updatePhase === "available" && (
498563 < div className = "bg-surface-light rounded-xl p-3 max-h-52 overflow-y-auto" >
499564 < p className = "text-xs font-semibold text-text-secondary mb-1" > { t ( "common:whatsNew" ) } </ p >
500565 < pre className = "text-xs text-text-muted whitespace-pre-wrap font-sans leading-relaxed" > { updateInfo . notes } </ pre >
501566 </ div >
502567 ) }
568+
569+ { updatePhase === "downloading" && (
570+ < div className = "space-y-2" >
571+ < div className = "w-full bg-surface-hover rounded-full h-2 overflow-hidden" >
572+ < div
573+ className = "bg-accent-blue h-full transition-all duration-150"
574+ style = { { width : `${ downloadProgress } %` } }
575+ />
576+ </ div >
577+ < p className = "text-xs text-text-muted" >
578+ { t ( "common:downloadingUpdate" ) }
579+ { downloadProgress > 0 ? ` ${ downloadProgress } %` : "" }
580+ </ p >
581+ </ div >
582+ ) }
583+
584+ { updatePhase === "downloaded" && (
585+ < p className = "text-sm text-text-secondary" > { t ( "common:updateDownloadReady" ) } </ p >
586+ ) }
587+
588+ { updatePhase === "installing" && (
589+ < p className = "text-sm text-text-secondary" > { t ( "common:installingUpdate" ) } </ p >
590+ ) }
591+
503592 < div className = "flex gap-3" >
504- < a href = { updateInfo . url } target = "_blank" rel = "noopener noreferrer" className = "flex-1 py-2.5 rounded-xl text-sm font-medium text-center bg-accent-blue/20 text-accent-blue hover:bg-accent-blue/30 transition-colors border border-accent-blue/30" >
505- { t ( "common:viewOnGitHub" ) }
506- </ a >
507- < button onClick = { ( ) => setUpdateInfo ( null ) } className = "flex-1 py-2.5 rounded-xl text-sm font-medium border border-surface-border text-text-muted hover:text-text-secondary hover:bg-surface-hover transition-colors" >
508- { t ( "common:remindLater" ) }
509- </ button >
593+ { updatePhase === "available" && updateInfo . update && (
594+ < button
595+ onClick = { handleDownloadUpdate }
596+ className = "flex-1 py-2.5 rounded-xl text-sm font-medium text-center bg-accent-blue/20 text-accent-blue hover:bg-accent-blue/30 transition-colors border border-accent-blue/30"
597+ >
598+ { t ( "common:downloadUpdate" ) }
599+ </ button >
600+ ) }
601+ { updatePhase === "available" && ! updateInfo . update && updateInfo . url && (
602+ < a
603+ href = { updateInfo . url }
604+ target = "_blank"
605+ rel = "noopener noreferrer"
606+ className = "flex-1 py-2.5 rounded-xl text-sm font-medium text-center bg-accent-blue/20 text-accent-blue hover:bg-accent-blue/30 transition-colors border border-accent-blue/30"
607+ >
608+ { t ( "common:viewOnGitHub" ) }
609+ </ a >
610+ ) }
611+ { updatePhase === "downloaded" && updateInfo . update && (
612+ < button
613+ onClick = { handleInstallUpdate }
614+ className = "flex-1 py-2.5 rounded-xl text-sm font-medium text-center bg-accent-green/20 text-accent-green hover:bg-accent-green/30 transition-colors border border-accent-green/30"
615+ >
616+ { t ( "common:installUpdate" ) }
617+ </ button >
618+ ) }
619+ { ( updatePhase === "available" || updatePhase === "downloaded" ) && (
620+ < button
621+ onClick = { closeUpdateModal }
622+ className = "flex-1 py-2.5 rounded-xl text-sm font-medium border border-surface-border text-text-muted hover:text-text-secondary hover:bg-surface-hover transition-colors"
623+ >
624+ { updatePhase === "downloaded" ? t ( "common:installLater" ) : t ( "common:remindLater" ) }
625+ </ button >
626+ ) }
627+ { ( updatePhase === "downloading" || updatePhase === "installing" ) && (
628+ < button
629+ disabled
630+ className = "flex-1 py-2.5 rounded-xl text-sm font-medium border border-surface-border text-text-muted opacity-50 cursor-not-allowed"
631+ >
632+ { updatePhase === "downloading" ? t ( "common:downloadingUpdate" ) : t ( "common:installingUpdate" ) }
633+ </ button >
634+ ) }
510635 </ div >
511636 </ div >
512637 </ div >
0 commit comments