diff --git a/src/apps/desktop/src/api/system_api.rs b/src/apps/desktop/src/api/system_api.rs index db1aff1c59..d7aa901ddc 100644 --- a/src/apps/desktop/src/api/system_api.rs +++ b/src/apps/desktop/src/api/system_api.rs @@ -310,7 +310,14 @@ pub async fn check_for_updates( request: CheckForUpdatesRequest, ) -> Result { let _ = request; - let updater = ranked_updater(&app).await?; + // Discovery reads only the manifests. Package throughput probes belong to download. + let updater = app + .updater_builder() + .endpoints(default_endpoints()) + .map_err(|e| e.to_string())? + .timeout(std::time::Duration::from_secs(20)) + .build() + .map_err(|e| e.to_string())?; let update = updater.check().await.map_err(|e| e.to_string())?; match update { Some(u) => Ok(CheckForUpdatesResponse { diff --git a/src/apps/desktop/src/api/update_api.rs b/src/apps/desktop/src/api/update_api.rs index cc5163b69e..ad3ece25e0 100644 --- a/src/apps/desktop/src/api/update_api.rs +++ b/src/apps/desktop/src/api/update_api.rs @@ -83,6 +83,11 @@ fn platform() -> String { } fn cache_dir(app: &AppHandle) -> Result { + if crate::e2e_storage_guard_enabled() { + return Ok(openbitfun_core::infrastructure::get_path_manager_arc() + .user_data_dir() + .join("app-updates")); + } app.path() .app_cache_dir() .map(|p| p.join("app-updates")) diff --git a/src/apps/desktop/src/appearance.rs b/src/apps/desktop/src/appearance.rs index 443c2e2d7d..dbe220fb53 100644 --- a/src/apps/desktop/src/appearance.rs +++ b/src/apps/desktop/src/appearance.rs @@ -637,9 +637,19 @@ pub fn create_main_window( #[cfg(debug_assertions)] if !use_development_frontend() { // Product-path isolation alone does not isolate WKWebView storage. - // This guarded test window keeps a private store across document - // reloads and never opens the daily client's browser data store. - builder = builder.incognito(true); + // Default to a private store. Windows persistence tests explicitly opt + // into a directory under the already validated isolated product root. + let persistent_test_store = cfg!(target_os = "windows") + && std::env::var("OPENBITFUN_E2E_PERSISTENT_WEBVIEW").as_deref() == Ok("1"); + #[cfg(target_os = "windows")] + if persistent_test_store { + builder = builder.data_directory( + openbitfun_core::infrastructure::get_path_manager_arc() + .user_data_dir() + .join("e2e-webview"), + ); + } + builder = builder.incognito(!persistent_test_store); } // On Windows, Tauri's native file-drop handler replaces WebView2's OLE drop diff --git a/src/web-ui/src/infrastructure/update/DailyAppUpdateGate.test.tsx b/src/web-ui/src/infrastructure/update/DailyAppUpdateGate.test.tsx index 02b18cc8d4..45462f8cfd 100644 --- a/src/web-ui/src/infrastructure/update/DailyAppUpdateGate.test.tsx +++ b/src/web-ui/src/infrastructure/update/DailyAppUpdateGate.test.tsx @@ -1,25 +1,45 @@ // @vitest-environment jsdom import { act } from 'react'; import { createRoot } from 'react-dom/client'; -import { afterEach, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { Dialog } from '@openbitfun/ui'; import { DailyAppUpdateGate } from './DailyAppUpdateGate'; import { useUpdateInstallStore } from './updateInstallStore'; +import { writeAppUpdateSnapshot } from './appUpdateStorage'; -const mocks = vi.hoisted(() => ({ check: vi.fn() })); -vi.mock('@/infrastructure/api/service-api/SystemAPI', () => ({ systemAPI: { checkForUpdates: mocks.check } })); +const mocks = vi.hoisted(() => ({ check: vi.fn(), foreground: true })); +vi.mock('@/infrastructure/api/service-api/SystemAPI', () => ({ systemAPI: { + checkForUpdates: mocks.check, getLocalAppVersion: async () => '1.0.0', getPendingUpdate: async () => null, + getAutoUpdateEnabled: async () => true, onAutoUpdateEnabledChange: () => () => {}, +} })); +vi.mock('@/shared/utils/startupTaskScheduling', () => ({ scheduleAfterStartupSignal: (start: () => void) => { start(); return () => {}; } })); vi.mock('./installUpdateWithProgress', () => ({ installUpdateWithProgress: vi.fn() })); vi.mock('./UpdateInstallProgressModal', () => ({ UpdateInstallProgressModal: () => null })); globalThis.IS_REACT_ACT_ENVIRONMENT = true; +beforeEach(() => { + vi.useFakeTimers(); + vi.stubEnv('DEV', false); + vi.stubGlobal('__TAURI__', {}); + mocks.foreground = true; + vi.spyOn(document, 'hasFocus').mockImplementation(() => mocks.foreground); + vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible'); + mocks.check.mockReset().mockResolvedValue({ updateAvailable: false, currentVersion: '1.0.0', latestVersion: null, releaseNotes: null, releaseDate: null }); + useUpdateInstallStore.setState(useUpdateInstallStore.getInitialState()); +}); + afterEach(() => { vi.unstubAllEnvs(); vi.unstubAllGlobals(); localStorage.clear(); + vi.restoreAllMocks(); + vi.useRealTimers(); }); it('synchronizes skips in development without starting discovery or dismissing another version', async () => { vi.stubEnv('DEV', true); + mocks.foreground = false; vi.stubGlobal('__TAURI__', {}); localStorage.clear(); useUpdateInstallStore.setState({ @@ -48,3 +68,86 @@ it('synchronizes skips in development without starting discovery or dismissing a await act(async () => root.unmount()); } }); + +it('checks startup after five seconds, then uses a six-hour deadline across focus changes', async () => { + writeAppUpdateSnapshot({ result: await mocks.check(), checkedAt: Date.now() - 3 * 60 * 60 * 1000 }); + mocks.check.mockClear(); + const root = createRoot(document.createElement('div')); + try { + await act(async () => root.render()); + await act(async () => vi.advanceTimersByTimeAsync(4999)); + expect(mocks.check).not.toHaveBeenCalled(); + await act(async () => vi.advanceTimersByTimeAsync(1)); + expect(mocks.check).toHaveBeenCalledOnce(); + mocks.foreground = false; + act(() => window.dispatchEvent(new Event('blur'))); + await act(async () => vi.advanceTimersByTimeAsync(6 * 60 * 60 * 1000)); + expect(mocks.check).toHaveBeenCalledOnce(); + mocks.foreground = true; + act(() => window.dispatchEvent(new Event('focus'))); + await act(async () => vi.advanceTimersByTimeAsync(1)); + expect(mocks.check).toHaveBeenCalledTimes(2); + act(() => { window.dispatchEvent(new Event('focus')); window.dispatchEvent(new Event('online')); }); + await act(async () => vi.advanceTimersByTimeAsync(1000)); + expect(mocks.check).toHaveBeenCalledTimes(2); + } finally { await act(async () => root.unmount()); } +}); + +it('holds a ready installation behind background and modal barriers, then presents it without installing', async () => { + vi.stubEnv('DEV', true); + mocks.foreground = false; + const root = createRoot(document.createElement('div')); + useUpdateInstallStore.setState({ initialized: true, currentVersion: '1.0.0', status: 'ready', version: '2.0.0' }); + try { + await act(async () => root.render(<>Busy)); + expect(useUpdateInstallStore.getState().promptOpen).toBe(false); + mocks.foreground = true; + act(() => window.dispatchEvent(new Event('focus'))); + expect(useUpdateInstallStore.getState().promptOpen).toBe(false); + await act(async () => root.render()); + expect(useUpdateInstallStore.getState().promptOpen).toBe(true); + act(() => useUpdateInstallStore.getState().deferInstall()); + expect(useUpdateInstallStore.getState().promptOpen).toBe(false); + act(() => window.dispatchEvent(new Event('focus'))); + expect(useUpdateInstallStore.getState().promptOpen).toBe(false); + await act(async () => vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000)); + expect(useUpdateInstallStore.getState().promptOpen).toBe(true); + } finally { await act(async () => root.unmount()); } +}); + +it('retries a failed check once after thirty minutes, then waits six hours', async () => { + mocks.check.mockRejectedValueOnce(new Error('offline')).mockRejectedValueOnce(new Error('still offline')); + const root = createRoot(document.createElement('div')); + try { + await act(async () => root.render()); + await act(async () => vi.advanceTimersByTimeAsync(5000)); + expect(mocks.check).toHaveBeenCalledOnce(); + await act(async () => vi.advanceTimersByTimeAsync(30 * 60 * 1000)); + expect(mocks.check).toHaveBeenCalledTimes(2); + act(() => window.dispatchEvent(new Event('online'))); + await act(async () => vi.advanceTimersByTimeAsync(5 * 60 * 60 * 1000)); + expect(mocks.check).toHaveBeenCalledTimes(2); + await act(async () => vi.advanceTimersByTimeAsync(60 * 60 * 1000)); + expect(mocks.check).toHaveBeenCalledTimes(3); + expect(useUpdateInstallStore.getState().consecutiveCheckFailures).toBe(0); + } finally { await act(async () => root.unmount()); } +}); + +it('consumes startup freshness once when a hidden launch first gains focus', async () => { + mocks.foreground = false; + writeAppUpdateSnapshot({ result: await mocks.check(), checkedAt: Date.now() - 60 * 60 * 1000 }); + mocks.check.mockClear(); + const root = createRoot(document.createElement('div')); + try { + await act(async () => root.render()); + await act(async () => vi.advanceTimersByTimeAsync(5000)); + mocks.foreground = true; + act(() => window.dispatchEvent(new Event('focus'))); + await act(async () => vi.advanceTimersByTimeAsync(1)); + expect(mocks.check).not.toHaveBeenCalled(); + await act(async () => vi.advanceTimersByTimeAsync(2 * 60 * 60 * 1000)); + expect(mocks.check).not.toHaveBeenCalled(); + await act(async () => vi.advanceTimersByTimeAsync(3 * 60 * 60 * 1000)); + expect(mocks.check).toHaveBeenCalledOnce(); + } finally { await act(async () => root.unmount()); } +}); diff --git a/src/web-ui/src/infrastructure/update/DailyAppUpdateGate.tsx b/src/web-ui/src/infrastructure/update/DailyAppUpdateGate.tsx index b832e56712..69dfeb4cb1 100644 --- a/src/web-ui/src/infrastructure/update/DailyAppUpdateGate.tsx +++ b/src/web-ui/src/infrastructure/update/DailyAppUpdateGate.tsx @@ -1,12 +1,14 @@ import { lazyWithRecovery } from '@/shared/utils/lazyWithRecovery'; -import { Suspense, useEffect, type ReactElement } from 'react'; +import { Suspense, useEffect, useRef, useState, type ReactElement } from 'react'; +import { useHasModalOverlay } from '@openbitfun/ui'; import { systemAPI } from '@/infrastructure/api/service-api/SystemAPI'; import { createLogger } from '@/shared/utils/logger'; import { scheduleAfterStartupSignal } from '@/shared/utils/startupTaskScheduling'; import { canAutoCheckForAppUpdates, canCheckForAppUpdates } from './tauriEnv'; -import { getSkippedVersion } from './appUpdateStorage'; +import { getSkippedVersion, getAppUpdateReminderAt, recordAppUpdatePresented, shouldShowDailyUpdatePrompt } from './appUpdateStorage'; import { UpdateInstallProgressModal } from './UpdateInstallProgressModal'; -import { APP_UPDATE_CHECK_INTERVAL, useUpdateInstallStore } from './updateInstallStore'; +import { useUpdateInstallStore } from './updateInstallStore'; +import { APP_UPDATE_CHECK_INTERVAL, getNextAppUpdateCheckAt } from './appUpdateSchedule'; import { RetainedMountBoundary } from '@/shared/presence'; const AppUpdateDetailsDialog = lazyWithRecovery(() => import('./AppUpdateDetailsDialog')); @@ -16,17 +18,23 @@ const log = createLogger('DailyAppUpdate'); /** One shell-owned scheduler; discovery, notices and installation have separate state. */ export function DailyAppUpdateGate(): ReactElement | null { const state = useUpdateInstallStore(); + const modalOpen = useHasModalOverlay(); + const [started, setStarted] = useState(false); + const startupCheckPending = useRef(true); useEffect(() => { if (!canCheckForAppUpdates()) return; const onStorage = (event: StorageEvent) => { - if (event.key !== 'openbitfun:update:skippedVersion' && event.key !== null) return; + if (event.key !== null && !event.key.startsWith('openbitfun:update:')) return; const skippedVersion = getSkippedVersion(); const current = useUpdateInstallStore.getState(); useUpdateInstallStore.setState({ skippedVersion, - ...(current.notice === 'available' && skippedVersion === current.availableUpdate?.latestVersion + reminderRevision: current.reminderRevision + 1, + ...(current.notice === 'available' && current.availableUpdate?.latestVersion && !shouldShowDailyUpdatePrompt(current.availableUpdate.latestVersion) ? { notice: null } : {}), + ...(current.status === 'ready' && current.version && (getAppUpdateReminderAt('ready', current.version) ?? 0) > Date.now() + ? { promptOpen: false } : {}), }); }; window.addEventListener('storage', onStorage); @@ -37,44 +45,101 @@ export function DailyAppUpdateGate(): ReactElement | null { if (!canAutoCheckForAppUpdates()) return; let cancelled = false; let timer: ReturnType | undefined; - let interval: ReturnType | undefined; - let started = false; - const check = (force = false) => { - if (!cancelled && started && document.visibilityState !== 'hidden' && navigator.onLine !== false) { - void useUpdateInstallStore.getState().checkForUpdates('automatic', force); - } - }; const cancelStartupSchedule = scheduleAfterStartupSignal(() => { timer = setTimeout(() => { - started = true; - void useUpdateInstallStore.getState().initialize().then(() => check()); - interval = setInterval(() => check(), APP_UPDATE_CHECK_INTERVAL); - }, 900); + void useUpdateInstallStore.getState().initialize().then(() => { + if (!cancelled) setStarted(true); + }); + }, 5000); }, { signalName: 'openbitfun:interactive-shell-ready', fallbackTimeoutMs: 10000, frameCount: 1, onError: error => log.warn('Failed to schedule update checks after startup', error), }); - const onResume = () => check(); + return () => { + cancelled = true; + cancelStartupSchedule(); + clearTimeout(timer); + }; + }, []); + + useEffect(() => { + if (!started) return; + let cancelled = false; + let timer: ReturnType | undefined; + const foreground = () => document.visibilityState !== 'hidden' && document.hasFocus(); + const schedule = (afterAttempt = false) => { + clearTimeout(timer); + if (cancelled || !foreground() || navigator.onLine === false) return; + const current = useUpdateInstallStore.getState(); + if (current.checkStatus === 'checking' || current.status === 'downloading' || current.status === 'installing') return; + const source = startupCheckPending.current ? 'startup' : 'automatic'; + const delay = startupCheckPending.current ? 0 : Math.max(0, getNextAppUpdateCheckAt(current, source) - Date.now()); + // A disabled/unavailable preference must not create a zero-delay retry loop. + timer = setTimeout(() => { + if (cancelled || !foreground() || navigator.onLine === false) return; + startupCheckPending.current = false; + void useUpdateInstallStore.getState().checkForUpdates(source).finally(() => schedule(true)); + }, afterAttempt && delay === 0 ? APP_UPDATE_CHECK_INTERVAL : delay); + }; + // Startup freshness is checked once, even when its cached result is reused. + if (startupCheckPending.current && foreground() && navigator.onLine !== false) { + startupCheckPending.current = false; + void useUpdateInstallStore.getState().checkForUpdates('startup').finally(() => schedule(true)); + } else schedule(); + const onResume = () => schedule(); const unsubscribe = systemAPI.onAutoUpdateEnabledChange(enabled => { - if (enabled) check(true); - else if (useUpdateInstallStore.getState().notice === 'available') { - useUpdateInstallStore.getState().dismissNotice(); + if (enabled && foreground() && navigator.onLine !== false) { + void useUpdateInstallStore.getState().checkForUpdates('automatic', true).finally(() => schedule(true)); + } else if (!enabled && useUpdateInstallStore.getState().notice === 'available') { + useUpdateInstallStore.setState({ notice: null }); } }); window.addEventListener('online', onResume); + window.addEventListener('offline', onResume); + window.addEventListener('focus', onResume); + window.addEventListener('blur', onResume); document.addEventListener('visibilitychange', onResume); return () => { cancelled = true; - cancelStartupSchedule(); clearTimeout(timer); - clearInterval(interval); unsubscribe(); window.removeEventListener('online', onResume); + window.removeEventListener('offline', onResume); + window.removeEventListener('focus', onResume); + window.removeEventListener('blur', onResume); document.removeEventListener('visibilitychange', onResume); }; - }, []); + }, [started, state.checkStatus, state.lastCheckedAt, state.lastCheckAttemptAt, state.consecutiveCheckFailures, state.status]); + + useEffect(() => { + if (!canCheckForAppUpdates() || !state.initialized) return; + let timer: ReturnType | undefined; + const present = () => { + clearTimeout(timer); + if (document.visibilityState === 'hidden' || !document.hasFocus()) return; + const current = useUpdateInstallStore.getState(); + if (current.promptOpen && current.version) recordAppUpdatePresented('ready', current.version); + if (modalOpen || current.detailsOpen || current.promptOpen || current.status === 'downloading' || current.status === 'installing') return; + current.presentPendingInstall(); + void useUpdateInstallStore.getState().refreshAvailableReminder(); + const next = useUpdateInstallStore.getState(); + const deadlines = [ + next.version && next.getReminderAt('ready', next.version), + next.availableUpdate?.latestVersion && next.getReminderAt('available', next.availableUpdate.latestVersion), + ].filter((at): at is number => typeof at === 'number' && at > Date.now()); + if (deadlines.length) timer = setTimeout(present, Math.min(...deadlines) - Date.now()); + }; + present(); + window.addEventListener('focus', present); + document.addEventListener('visibilitychange', present); + return () => { + clearTimeout(timer); + window.removeEventListener('focus', present); + document.removeEventListener('visibilitychange', present); + }; + }, [modalOpen, state.initialized, state.status, state.version, state.availableUpdate, state.detailsOpen, state.promptOpen, state.reminderRevision]); if (!canCheckForAppUpdates()) return null; diff --git a/src/web-ui/src/infrastructure/update/UpdateInstallProgressModal.tsx b/src/web-ui/src/infrastructure/update/UpdateInstallProgressModal.tsx index 0c252b75cc..5f7a0e68e2 100644 --- a/src/web-ui/src/infrastructure/update/UpdateInstallProgressModal.tsx +++ b/src/web-ui/src/infrastructure/update/UpdateInstallProgressModal.tsx @@ -153,6 +153,7 @@ export const UpdateInstallProgressModal: React.FC @@ -182,10 +183,10 @@ export const UpdateInstallProgressModal: React.FC ) : null} - - diff --git a/src/web-ui/src/infrastructure/update/UpdateNotificationCard.tsx b/src/web-ui/src/infrastructure/update/UpdateNotificationCard.tsx index 707a3e2b6f..970d450f6f 100644 --- a/src/web-ui/src/infrastructure/update/UpdateNotificationCard.tsx +++ b/src/web-ui/src/infrastructure/update/UpdateNotificationCard.tsx @@ -131,11 +131,14 @@ function UpdateNoticeCard({ notice }: { notice: UpdateNotice }) {
{notice === 'available' && version && ( - )} - {notice === 'available' && } + {notice === 'available' && } {notice === 'error' &&