Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/apps/desktop/src/api/system_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,14 @@ pub async fn check_for_updates(
request: CheckForUpdatesRequest,
) -> Result<CheckForUpdatesResponse, String> {
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 {
Expand Down
5 changes: 5 additions & 0 deletions src/apps/desktop/src/api/update_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ fn platform() -> String {
}

fn cache_dir(app: &AppHandle) -> Result<std::path::PathBuf, String> {
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"))
Expand Down
16 changes: 13 additions & 3 deletions src/apps/desktop/src/appearance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
109 changes: 106 additions & 3 deletions src/web-ui/src/infrastructure/update/DailyAppUpdateGate.test.tsx
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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(<DailyAppUpdateGate />));
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(<><Dialog open aria-label="Another dialog">Busy</Dialog><DailyAppUpdateGate /></>));
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(<DailyAppUpdateGate />));
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(<DailyAppUpdateGate />));
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(<DailyAppUpdateGate />));
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()); }
});
111 changes: 88 additions & 23 deletions src/web-ui/src/infrastructure/update/DailyAppUpdateGate.tsx
Original file line number Diff line number Diff line change
@@ -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'));
Expand All @@ -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);
Expand All @@ -37,44 +45,101 @@ export function DailyAppUpdateGate(): ReactElement | null {
if (!canAutoCheckForAppUpdates()) return;
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
let interval: ReturnType<typeof setInterval> | 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<typeof setTimeout> | 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<typeof setTimeout> | 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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ export const UpdateInstallProgressModal: React.FC<UpdateInstallProgressModalProp
closeOnPointerOutside={false}
closeOnEscape={!installing}
size="sm"
data-testid="app-update-install-dialog"
>
<DialogHeader>
<DialogHeading>
Expand Down Expand Up @@ -182,10 +183,10 @@ export const UpdateInstallProgressModal: React.FC<UpdateInstallProgressModalProp
{t('update.downloadAgain')}
</Button>
) : null}
<Button variant="fill" size="md" disabled={installing} onClick={onCloseInstalled}>
<Button data-testid="app-update-install-later" variant="fill" size="md" disabled={installing} onClick={onCloseInstalled}>
{t('update.restartLater')}
</Button>
<Button variant="primary" size="md" disabled={installing} loading={installing} onClick={onRestart}>
<Button data-testid="app-update-install-confirm" variant="primary" size="md" disabled={installing} loading={installing} onClick={onRestart}>
{t(installing ? 'update.installing' : 'update.installAndRestart')}
</Button>
</div>
Expand Down
Loading
Loading