|
| 1 | +import { useCallback, useEffect, useMemo, useState } from 'react' |
| 2 | +import { Download, RotateCw, X } from 'lucide-react' |
| 3 | +import { useTranslation } from 'react-i18next' |
| 4 | + |
| 5 | +import type { AppUpdateStatusJson } from '@/shared/rpc-schema' |
| 6 | +import { invoke, listen } from '@/mainview/lib/native' |
| 7 | +import { cn } from '@/mainview/lib/utils' |
| 8 | +import { Button } from '@/mainview/components/ui/button' |
| 9 | +import { useToast } from '@/mainview/components/ToastProvider' |
| 10 | + |
| 11 | +const DISMISSED_UPDATE_VERSION_KEY = 'skiller.dismissed_update_version' |
| 12 | + |
| 13 | +function readDismissedVersion(): string | null { |
| 14 | + try { |
| 15 | + return localStorage.getItem(DISMISSED_UPDATE_VERSION_KEY) |
| 16 | + } catch { |
| 17 | + return null |
| 18 | + } |
| 19 | +} |
| 20 | + |
| 21 | +function writeDismissedVersion(version: string | null): void { |
| 22 | + try { |
| 23 | + if (version) localStorage.setItem(DISMISSED_UPDATE_VERSION_KEY, version) |
| 24 | + } catch { |
| 25 | + // Ignore storage errors in private/locked environments. |
| 26 | + } |
| 27 | +} |
| 28 | + |
| 29 | +export default function AppUpdateBanner() { |
| 30 | + const { t } = useTranslation() |
| 31 | + const { toast } = useToast() |
| 32 | + const [status, setStatus] = useState<AppUpdateStatusJson | null>(null) |
| 33 | + const [busy, setBusy] = useState<'idle' | 'downloading' | 'applying'>('idle') |
| 34 | + const [dismissedVersion, setDismissedVersion] = useState(() => |
| 35 | + readDismissedVersion(), |
| 36 | + ) |
| 37 | + |
| 38 | + useEffect(() => { |
| 39 | + let cancelled = false |
| 40 | + let unlisten: (() => void) | undefined |
| 41 | + |
| 42 | + invoke('app_update_status') |
| 43 | + .then((snapshot) => { |
| 44 | + if (!cancelled) setStatus(snapshot) |
| 45 | + }) |
| 46 | + .catch(() => {}) |
| 47 | + |
| 48 | + void listen<AppUpdateStatusJson>( |
| 49 | + 'app_update_status_changed', |
| 50 | + ({ payload }) => { |
| 51 | + setStatus(payload) |
| 52 | + if ( |
| 53 | + payload.remoteVersion && |
| 54 | + payload.remoteVersion !== dismissedVersion && |
| 55 | + (payload.state === 'available' || |
| 56 | + payload.state === 'downloading' || |
| 57 | + payload.state === 'ready') |
| 58 | + ) { |
| 59 | + setDismissedVersion(readDismissedVersion()) |
| 60 | + } |
| 61 | + }, |
| 62 | + ).then((cleanup) => { |
| 63 | + if (cancelled) cleanup() |
| 64 | + else unlisten = cleanup |
| 65 | + }) |
| 66 | + |
| 67 | + return () => { |
| 68 | + cancelled = true |
| 69 | + unlisten?.() |
| 70 | + } |
| 71 | + }, [dismissedVersion]) |
| 72 | + |
| 73 | + const visible = useMemo(() => { |
| 74 | + if (!status?.remoteVersion) return false |
| 75 | + if (dismissedVersion === status.remoteVersion) return false |
| 76 | + return ( |
| 77 | + status.state === 'available' || |
| 78 | + status.state === 'downloading' || |
| 79 | + status.state === 'ready' |
| 80 | + ) |
| 81 | + }, [dismissedVersion, status]) |
| 82 | + |
| 83 | + const handleDismiss = useCallback(() => { |
| 84 | + const version = status?.remoteVersion ?? null |
| 85 | + writeDismissedVersion(version) |
| 86 | + setDismissedVersion(version) |
| 87 | + }, [status?.remoteVersion]) |
| 88 | + |
| 89 | + const handlePrimaryAction = useCallback(async () => { |
| 90 | + if (!status) return |
| 91 | + if (status.state === 'ready') { |
| 92 | + setBusy('applying') |
| 93 | + try { |
| 94 | + await invoke('app_update_apply') |
| 95 | + } catch (e) { |
| 96 | + toast( |
| 97 | + `${t('settings.updateStateError')}: ${ |
| 98 | + e instanceof Error ? e.message : String(e) |
| 99 | + }`, |
| 100 | + 'destructive', |
| 101 | + ) |
| 102 | + setBusy('idle') |
| 103 | + } |
| 104 | + return |
| 105 | + } |
| 106 | + |
| 107 | + setBusy('downloading') |
| 108 | + try { |
| 109 | + const snapshot = await invoke('app_update_download') |
| 110 | + if (snapshot) setStatus(snapshot) |
| 111 | + if (snapshot?.state === 'error' && snapshot.error) { |
| 112 | + toast(snapshot.error, 'destructive') |
| 113 | + } |
| 114 | + } catch (e) { |
| 115 | + toast( |
| 116 | + `${t('settings.updateStateError')}: ${ |
| 117 | + e instanceof Error ? e.message : String(e) |
| 118 | + }`, |
| 119 | + 'destructive', |
| 120 | + ) |
| 121 | + } finally { |
| 122 | + setBusy('idle') |
| 123 | + } |
| 124 | + }, [status, t, toast]) |
| 125 | + |
| 126 | + if (!visible || !status) return null |
| 127 | + |
| 128 | + const current = status.localVersion |
| 129 | + const latest = status.remoteVersion ?? '' |
| 130 | + const progress = typeof status.progress === 'number' ? status.progress : null |
| 131 | + const downloading = status.state === 'downloading' || busy === 'downloading' |
| 132 | + const ready = status.state === 'ready' |
| 133 | + |
| 134 | + return ( |
| 135 | + <div className="pointer-events-none fixed left-1/2 top-4 z-[120] w-[min(calc(100vw-2rem),38rem)] -translate-x-1/2"> |
| 136 | + <section |
| 137 | + role="status" |
| 138 | + aria-live="polite" |
| 139 | + className="skiller-update-banner pointer-events-auto animate-update-banner-in overflow-hidden rounded-xl border text-card-foreground backdrop-blur-xl" |
| 140 | + > |
| 141 | + <div className="flex items-center gap-3 px-3 py-2.5"> |
| 142 | + <div |
| 143 | + className={cn( |
| 144 | + 'skiller-update-mark flex size-8 shrink-0 items-center justify-center rounded-lg text-primary', |
| 145 | + ready && 'skiller-update-mark-ready', |
| 146 | + )} |
| 147 | + > |
| 148 | + {ready ? ( |
| 149 | + <RotateCw className="size-4" aria-hidden="true" /> |
| 150 | + ) : ( |
| 151 | + <Download className="size-4" aria-hidden="true" /> |
| 152 | + )} |
| 153 | + </div> |
| 154 | + |
| 155 | + <div className="min-w-0 flex-1"> |
| 156 | + <p className="truncate text-[13px] font-semibold leading-5"> |
| 157 | + {ready |
| 158 | + ? t('settings.updateBannerReadyTitle') |
| 159 | + : t('settings.updateBannerTitle', { version: latest })} |
| 160 | + </p> |
| 161 | + <p className="truncate text-xs leading-4 text-muted-foreground"> |
| 162 | + {downloading |
| 163 | + ? progress == null |
| 164 | + ? t('settings.updateStateDownloading') |
| 165 | + : t('settings.updateBannerDownloading', { |
| 166 | + progress, |
| 167 | + }) |
| 168 | + : t('settings.updateBannerDescription', { |
| 169 | + current, |
| 170 | + latest, |
| 171 | + })} |
| 172 | + </p> |
| 173 | + </div> |
| 174 | + |
| 175 | + <Button |
| 176 | + size="sm" |
| 177 | + variant="outline" |
| 178 | + className="skiller-update-action shrink-0" |
| 179 | + onClick={handlePrimaryAction} |
| 180 | + disabled={downloading || busy === 'applying'} |
| 181 | + > |
| 182 | + {ready ? ( |
| 183 | + <RotateCw className="size-3.5" aria-hidden="true" /> |
| 184 | + ) : ( |
| 185 | + <Download className="size-3.5" aria-hidden="true" /> |
| 186 | + )} |
| 187 | + {ready |
| 188 | + ? t('settings.updateRestart') |
| 189 | + : downloading |
| 190 | + ? t('settings.updateBannerUpdating') |
| 191 | + : t('settings.updateBannerAction')} |
| 192 | + </Button> |
| 193 | + |
| 194 | + <Button |
| 195 | + type="button" |
| 196 | + variant="ghost" |
| 197 | + size="icon-sm" |
| 198 | + className="shrink-0 text-muted-foreground hover:text-foreground" |
| 199 | + onClick={handleDismiss} |
| 200 | + aria-label={t('settings.updateBannerDismiss')} |
| 201 | + > |
| 202 | + <X className="size-4" aria-hidden="true" /> |
| 203 | + </Button> |
| 204 | + </div> |
| 205 | + |
| 206 | + {downloading && ( |
| 207 | + <div className="h-1 bg-muted"> |
| 208 | + <div |
| 209 | + className={cn( |
| 210 | + 'h-full bg-primary transition-[width] duration-300', |
| 211 | + progress == null && 'w-1/3 animate-pulse', |
| 212 | + )} |
| 213 | + style={progress == null ? undefined : { width: `${progress}%` }} |
| 214 | + /> |
| 215 | + </div> |
| 216 | + )} |
| 217 | + </section> |
| 218 | + </div> |
| 219 | + ) |
| 220 | +} |
0 commit comments