From 9282f166cd75a239864c84dd145da63d93180caa Mon Sep 17 00:00:00 2001 From: hazre Date: Thu, 23 Jul 2026 06:20:44 +0200 Subject: [PATCH 1/2] fix: move updater and global banner to appshell Signed-off-by: hazre --- src/app/components/app-shell/AppShell.tsx | 6 ++++++ src/app/pages/client/ClientNonUIFeatures.tsx | 7 +------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/app/components/app-shell/AppShell.tsx b/src/app/components/app-shell/AppShell.tsx index d8405df23..25b264a10 100644 --- a/src/app/components/app-shell/AppShell.tsx +++ b/src/app/components/app-shell/AppShell.tsx @@ -9,6 +9,9 @@ import { type as osType } from '@tauri-apps/plugin-os'; import { TauriFrontendReady } from '$components/tauri/TauriFrontendReady'; import { DesktopTitleBar } from '$components/tauri/DesktopTitleBar'; import { MacTitleBar } from '$components/tauri/MacTitleBar'; +import { DesktopUpdater } from '$pages/client/DesktopUpdater'; +import { WebUpdater } from '$pages/client/WebUpdater'; +import { GlobalBannerRenderer } from '$components/global-banner/GlobalBannerRenderer'; import { Toast } from '$components/toast/Toast'; import type { ScreenSize } from '$hooks/useScreenSize'; import { ScreenSizeProvider } from '$hooks/useScreenSize'; @@ -88,6 +91,9 @@ function AppShellFrame({ children, portalContainer, onPortalContainerChange }: A > {titlebarKind === 'desktop' && } {titlebarKind === 'mac' && } + + +
- - + - From b4ee6a66691a7f6454dfa70d7da34373474b3f50 Mon Sep 17 00:00:00 2001 From: hazre Date: Thu, 23 Jul 2026 06:46:42 +0200 Subject: [PATCH 2/2] feat: add desktop auto-update pill with download progress and manual check Signed-off-by: hazre fix: typescript issues Signed-off-by: hazre fix: oxlint warnings Signed-off-by: hazre chore: add changeset Signed-off-by: hazre --- .changeset/desktop-update-pill.md | 5 + src/app/components/SyncConnectionStatus.tsx | 36 +++- src/app/components/icons/phosphor.tsx | 2 + src/app/components/tauri/DesktopTitleBar.tsx | 4 + .../components/tauri/DesktopUpdatePill.tsx | 36 ++++ src/app/components/tauri/MacTitleBar.tsx | 10 +- src/app/features/settings/about/About.tsx | 93 +++++++++- .../TauriNotificationsApiClient.ts | 8 +- src/app/features/settings/settingsLink.ts | 1 + src/app/generated/tauri/commands.ts | 2 +- src/app/generated/tauri/events.ts | 4 +- src/app/generated/tauri/index.ts | 2 +- src/app/generated/tauri/types.ts | 3 +- .../pages/client/BackgroundNotifications.tsx | 8 +- src/app/pages/client/DesktopUpdater.tsx | 164 ++++++++++++++---- src/app/state/desktopUpdate.ts | 30 ++++ src/app/styles/overrides/TauriDesktop.css | 17 +- 17 files changed, 372 insertions(+), 53 deletions(-) create mode 100644 .changeset/desktop-update-pill.md create mode 100644 src/app/components/tauri/DesktopUpdatePill.tsx create mode 100644 src/app/state/desktopUpdate.ts diff --git a/.changeset/desktop-update-pill.md b/.changeset/desktop-update-pill.md new file mode 100644 index 000000000..5b9472cc4 --- /dev/null +++ b/.changeset/desktop-update-pill.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Desktop app now periodically checks for updates, shows an update pill in the titlebar when one is available, and lets you manually check for updates in Settings > About. diff --git a/src/app/components/SyncConnectionStatus.tsx b/src/app/components/SyncConnectionStatus.tsx index 438307936..8e8d1f1a3 100644 --- a/src/app/components/SyncConnectionStatus.tsx +++ b/src/app/components/SyncConnectionStatus.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import classNames from 'classnames'; import { Box, config, Text } from 'folds'; import { AnimatePresence, motion, useReducedMotion, type Variants } from 'framer-motion'; @@ -10,6 +11,8 @@ const TITLEBAR_EASE_OUT_SOFT: [number, number, number, number] = [0.24, 0.72, 0. type SyncConnectionStatusProps = { status: TitlebarStatusView | null; + onClick?: () => void; + icon?: React.ReactNode; }; export function getSyncConnectionStatusView( @@ -157,7 +160,7 @@ export function SyncConnectionStatusBanner({ status }: SyncConnectionStatusProps ); } -export function SyncConnectionStatusTitlebar({ status }: SyncConnectionStatusProps) { +export function SyncConnectionStatusTitlebar({ status, onClick, icon }: SyncConnectionStatusProps) { const shouldReduceMotion = useReducedMotion(); const progress = status?.progress; const pillVariants = shouldReduceMotion @@ -233,10 +236,12 @@ export function SyncConnectionStatusTitlebar({ status }: SyncConnectionStatusPro }; return ( - + {status && ( { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick(); + } + } + : undefined + } style={{ transformOrigin: 'center top', position: 'relative', overflow: 'hidden', willChange: shouldReduceMotion ? 'opacity' : 'transform, opacity, clip-path', + cursor: onClick ? 'pointer' : undefined, }} variants={pillVariants} initial="hidden" @@ -256,11 +273,20 @@ export function SyncConnectionStatusTitlebar({ status }: SyncConnectionStatusPro exit="exit" > - {status.text} + {icon && ( + + {icon} + + )} + {status.text} {progress !== undefined && (
+
+ +
+ } + /> + + )} Promise | void; }; @@ -65,10 +65,6 @@ export async function ensureTauriNotificationPermission(): Promise { return permissionPromise; } -// Desktop webviews can't show web notifications (WKWebView lacks the API; the -// Linux CEF runtime never grants it), so desktop routes through the native plugin. -const DESKTOP_TAURI_OS = new Set(['linux', 'macos', 'windows']); -export const isDesktopTauri = (): boolean => isTauri() && DESKTOP_TAURI_OS.has(osType()); export const isIosTauri = (): boolean => isTauri() && osType() === 'ios'; // Platforms where OS notifications go through the native plugin instead of web APIs. export const isNativeNotificationTauri = (): boolean => isDesktopTauri() || isIosTauri(); @@ -104,3 +100,5 @@ export async function sendNativeTauriNotification({ extra, }); } + +export { isDesktopTauri }; diff --git a/src/app/features/settings/settingsLink.ts b/src/app/features/settings/settingsLink.ts index e2ed6d0b8..fb7857f31 100644 --- a/src/app/features/settings/settingsLink.ts +++ b/src/app/features/settings/settingsLink.ts @@ -218,6 +218,7 @@ const settingsLinkFocusIdsBySection: Record isTauri() && DESKTOP_TAURI_OS.has(osType()); - let desktopNotificationSeq = 1; const nextDesktopNotificationId = (): number => { const id = desktopNotificationSeq; diff --git a/src/app/pages/client/DesktopUpdater.tsx b/src/app/pages/client/DesktopUpdater.tsx index 35e54decd..0b4406ccd 100644 --- a/src/app/pages/client/DesktopUpdater.tsx +++ b/src/app/pages/client/DesktopUpdater.tsx @@ -1,61 +1,162 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useAtomValue } from 'jotai'; -import { isTauri } from '@tauri-apps/api/core'; -import { type as osType } from '@tauri-apps/plugin-os'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import type { Update } from '@tauri-apps/plugin-updater'; +import { isDesktopTauri } from '$utils/platform'; import { autoUpdateCheckAtom } from '$state/autoUpdateCheck'; import { createLogger } from '$utils/debug'; +import { hasCustomDesktopTitlebar } from '$utils/tauriTitlebar'; +import { useDesktopSetting } from '$state/hooks/desktopSettings'; import { ArrowUp } from '$components/icons/phosphor'; import { useRegisterGlobalBanner, type GlobalBanner } from '$state/globalBanners'; +import { + updatePhaseAtom, + updateBannerVisibleAtom, + triggerUpdateCheckAtom, + desktopUpdateLastCheckedAtom, + fakeDesktopUpdate, +} from '$state/desktopUpdate'; const log = createLogger('DesktopUpdater'); - -const DESKTOP_TAURI_OS = new Set(['linux', 'macos', 'windows']); -const isDesktopTauri = (): boolean => isTauri() && DESKTOP_TAURI_OS.has(osType()); +const UPDATE_POLL_INTERVAL_MS = 300_000; // 5 minutes export function DesktopUpdater() { const autoUpdateCheck = useAtomValue(autoUpdateCheckAtom); + const triggerCount = useAtomValue(triggerUpdateCheckAtom); + const setPhase = useSetAtom(updatePhaseAtom); + const [bannerVisible, setBannerVisible] = useAtom(updateBannerVisibleAtom); + const setLastChecked = useSetAtom(desktopUpdateLastCheckedAtom); const [updateInfo, setUpdateInfo] = useState(null); - const [isInstalling, setIsInstalling] = useState(false); const [isDownloaded, setIsDownloaded] = useState(false); + const [isInstalled, setIsInstalled] = useState(false); + const [isInstalling, setIsInstalling] = useState(false); const [dismissed, setDismissed] = useState(false); + const [useCustomTitleBar] = useDesktopSetting('useCustomTitleBar'); + const hasUpdateRef = useRef(false); useEffect(() => { if (!isDesktopTauri()) return undefined; - if (!autoUpdateCheck) return undefined; + if (triggerCount === 0 && !autoUpdateCheck && !fakeDesktopUpdate()) return undefined; + + let mounted = true; - let isMounted = true; - async function checkUpdate() { + setPhase({ type: 'checking' }); + + async function run() { try { + if (fakeDesktopUpdate()) { + log.log('Fake update: simulating download'); + setUpdateInfo({ version: '9.9.9', body: 'Fake changelog for testing.' } as Update); + setPhase({ type: 'downloading', progress: 0 }); + for (let pct = 0; pct <= 100; pct += 2) { + // eslint-disable-next-line no-await-in-loop + await new Promise((r) => setTimeout(r, 40)); + if (!mounted) return; + setPhase({ type: 'downloading', progress: pct }); + } + if (!mounted) return; + setIsDownloaded(true); + setPhase({ type: 'ready', version: '9.9.9' }); + setLastChecked(new Date().toISOString()); + if (!hasCustomDesktopTitlebar(useCustomTitleBar)) setBannerVisible(true); + return; + } + const { check } = await import('@tauri-apps/plugin-updater'); const update = await check(); - if (update && isMounted) { - log.log(`Desktop update ${update.version} available`); - setUpdateInfo(update); + if (!mounted) return; + + if (!update) { + if (!hasUpdateRef.current) { + setPhase({ type: 'idle' }); + } + setLastChecked(new Date().toISOString()); + return; } + + log.log(`Desktop update ${update.version} available, downloading...`); + setUpdateInfo(update); + setIsInstalled(false); + setDismissed(false); + hasUpdateRef.current = true; + setPhase({ type: 'downloading', progress: 0 }); + + let downloadedBytes = 0; + let contentLength = 0; + await update.download((event) => { + if (event.event === 'Started') { + contentLength = event.data.contentLength ?? 0; + } else if (event.event === 'Progress') { + downloadedBytes += event.data.chunkLength; + const pct = contentLength > 0 ? Math.round((downloadedBytes / contentLength) * 100) : 0; + setPhase({ type: 'downloading', progress: Math.min(pct, 100) }); + } + }); + + if (!mounted) return; + log.log(`Update ${update.version} downloaded`); + setIsDownloaded(true); + setPhase({ type: 'ready', version: update.version }); + setLastChecked(new Date().toISOString()); + if (!hasCustomDesktopTitlebar(useCustomTitleBar)) setBannerVisible(true); } catch (err) { log.error('Desktop update check failed', err); + if (mounted) { + if (!hasUpdateRef.current) { + setPhase({ type: 'idle' }); + } + setLastChecked(new Date().toISOString()); + } } } - checkUpdate(); + run(); + + if (autoUpdateCheck) { + const interval = setInterval(run, UPDATE_POLL_INTERVAL_MS); + return () => { + mounted = false; + clearInterval(interval); + }; + } + return () => { - isMounted = false; + mounted = false; }; - }, [autoUpdateCheck]); + }, [ + autoUpdateCheck, + triggerCount, + setPhase, + setBannerVisible, + setLastChecked, + useCustomTitleBar, + ]); + + useEffect(() => { + if (bannerVisible && dismissed) { + setDismissed(false); + } + }, [bannerVisible, dismissed]); const handleInstall = useCallback(async () => { if (!updateInfo) return; try { setIsInstalling(true); - await updateInfo.downloadAndInstall(); + setPhase({ type: 'installing' }); + + if (fakeDesktopUpdate()) { + await new Promise((r) => setTimeout(r, 1500)); + } else { + await updateInfo.install(); + } + + setPhase({ type: 'ready', version: updateInfo.version }); setIsInstalling(false); - setIsDownloaded(true); + setIsInstalled(true); } catch (err) { - log.error('Failed to download and install update', err); + log.error('Failed to install update', err); setIsInstalling(false); } - }, [updateInfo]); + }, [updateInfo, setPhase]); const handleRestart = useCallback(async () => { try { @@ -68,18 +169,19 @@ export function DesktopUpdater() { const handleDismiss = useCallback(() => { setDismissed(true); - }, []); + setBannerVisible(false); + }, [setBannerVisible]); const bannerData = useMemo(() => { - if (!updateInfo || dismissed) return null; + if (!bannerVisible || !updateInfo || dismissed) return null; - if (isDownloaded) { + if (isInstalled) { return { id: 'desktop-update-restart', priority: 200, icon: ArrowUp, - title: 'Update Ready', - description: `Sable ${updateInfo.version} has been downloaded. Restart the app to finish updating.`, + title: 'Update Installed', + description: `Sable ${updateInfo.version} has been installed. Restart the app to finish updating.`, primaryAction: { label: 'Restart Now', variant: 'Primary', @@ -93,12 +195,14 @@ export function DesktopUpdater() { }; } + if (!isDownloaded) return null; + return { - id: 'desktop-update-available', + id: 'desktop-update-ready', priority: 200, icon: ArrowUp, - title: 'Desktop Update Available', - description: `Sable ${updateInfo.version} is available for installation.`, + title: 'Update Available', + description: `Sable ${updateInfo.version} is ready to install.${updateInfo.body ? `\n${updateInfo.body}` : ''}`, primaryAction: { label: isInstalling ? 'Installing...' : 'Install & Update', variant: 'Primary', @@ -111,9 +215,11 @@ export function DesktopUpdater() { }, }; }, [ + bannerVisible, updateInfo, dismissed, isDownloaded, + isInstalled, isInstalling, handleInstall, handleRestart, diff --git a/src/app/state/desktopUpdate.ts b/src/app/state/desktopUpdate.ts new file mode 100644 index 000000000..f7ee98e21 --- /dev/null +++ b/src/app/state/desktopUpdate.ts @@ -0,0 +1,30 @@ +import { atom } from 'jotai'; +import { + atomWithLocalStorage, + getLocalStorageItem, + setLocalStorageItem, +} from './utils/atomWithLocalStorage'; + +export type UpdatePhase = + | { type: 'idle' } + | { type: 'checking' } + | { type: 'downloading'; progress: number } + | { type: 'ready'; version: string } + | { type: 'installing' }; + +export const updatePhaseAtom = atom({ type: 'idle' }); + +export const updateBannerVisibleAtom = atom(false); + +export const triggerUpdateCheckAtom = atom(0); + +const DESKTOP_UPDATE_LAST_CHECKED_KEY = 'desktopUpdateLastChecked'; + +export const desktopUpdateLastCheckedAtom = atomWithLocalStorage( + DESKTOP_UPDATE_LAST_CHECKED_KEY, + (key) => getLocalStorageItem(key, null), + (key, value) => setLocalStorageItem(key, value) +); + +export const fakeDesktopUpdate = (): boolean => + localStorage.getItem('sable_fake_desktop_update') === '1'; diff --git a/src/app/styles/overrides/TauriDesktop.css b/src/app/styles/overrides/TauriDesktop.css index 695a8cf8f..5863fde17 100644 --- a/src/app/styles/overrides/TauriDesktop.css +++ b/src/app/styles/overrides/TauriDesktop.css @@ -6,7 +6,7 @@ .tauri-titlebar { height: var(--tauri-titlebar-height); display: grid; - grid-template-columns: 1fr auto; + grid-template-columns: 1fr auto auto; align-items: stretch; position: relative; background: var(--sable-bg-container); @@ -44,6 +44,21 @@ letter-spacing: 0.02em; } +.tauri-titlebar__update { + display: flex; + align-items: center; + padding-inline: 4px; + app-region: no-drag; + -webkit-app-region: no-drag; +} + +.tauri-titlebar--mac .tauri-titlebar__update { + position: absolute; + right: 8px; + top: 0; + height: 100%; +} + .tauri-titlebar__controls { display: flex; app-region: no-drag;