diff --git a/src/scaffold/AppUpdater/component-app-updater-1226.md b/src/scaffold/AppUpdater/component-app-updater-1226.md index faf367c2b..c691dcac5 100644 --- a/src/scaffold/AppUpdater/component-app-updater-1226.md +++ b/src/scaffold/AppUpdater/component-app-updater-1226.md @@ -1,7 +1,7 @@ # AppUpdater **Location:** `src/scaffold/AppUpdater/` -**Last updated:** July 13, 2026 +**Last updated:** July 31, 2026 ## Overview @@ -29,8 +29,12 @@ relaunch remain explicit user actions. without showing progress toasts or forcing a restart, then show one confirmation dialog. Installation only starts after the user confirms. - **Dialog actions:** users can skip the detected version, postpone the - decision while keeping the package ready, or install and restart. Skipped - versions remain suppressed across app launches. + decision for 24 hours while keeping the package ready, or install and + restart. The reminder cooldown is persisted per version and applies to every + automatic trigger, including startup, interval, foreground, online, and + retry checks. A different version is never suppressed by an older cooldown. + Explicit install actions bypass the cooldown. Skipped versions remain + suppressed across app launches. Installing is never automatic because the Tauri updater installer can terminate the running process on Windows. Users can postpone installation and @@ -65,8 +69,11 @@ available → downloading → downloaded available | downloaded → installing → relaunching ``` -`appUpdaterCoordinator.ts` owns this lifecycle. Jotai atoms in `index.tsx` are -read-only UI projections and are not independent sources of truth. +`appUpdaterCoordinator.ts` owns the check/download/install lifecycle. Jotai +atoms in `index.tsx` project coordinator state plus transient dialog +visibility. Local storage owns the durable user decisions: the skipped version +and the per-version reminder deadline. Expired or malformed reminder state is +removed before the next automatic prompt decision. ## Entry points diff --git a/src/scaffold/AppUpdater/index.test.ts b/src/scaffold/AppUpdater/index.test.ts index 457995a49..51c998f70 100644 --- a/src/scaffold/AppUpdater/index.test.ts +++ b/src/scaffold/AppUpdater/index.test.ts @@ -1,7 +1,7 @@ import type { DownloadEvent, Update } from "@tauri-apps/plugin-updater"; import { type ReactElement, type ReactNode, createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AppUpdateDownloadNoticeContent, @@ -11,6 +11,7 @@ import { import { AppUpdater, checkForUpdatesManually, + executeAutomaticUpdateForTests, installAvailableAppUpdate, resetAppUpdaterForTests, } from "./index"; @@ -149,6 +150,17 @@ function createUpdate(overrides: Partial = {}): Update { } as unknown as Update; } +function installMemoryLocalStorage() { + const values = new Map(); + const localStorage = { + getItem: vi.fn((key: string) => values.get(key) ?? null), + removeItem: vi.fn((key: string) => values.delete(key)), + setItem: vi.fn((key: string, value: string) => values.set(key, value)), + }; + vi.stubGlobal("window", { localStorage }); + return { localStorage, values }; +} + describe("AppUpdater", () => { beforeEach(() => { vi.clearAllMocks(); @@ -165,6 +177,11 @@ describe("AppUpdater", () => { resetAppUpdaterForTests(); }); + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + function renderPreparedUpdate(update: Update): string { mocks.useAtom.mockReturnValueOnce([true, mocks.setInstallPromptVisible]); mocks.useAtomValue @@ -347,14 +364,110 @@ describe("AppUpdater", () => { expect(mocks.relaunch).not.toHaveBeenCalled(); }); + it.each(["startup", "interval", "foreground", "online", "retry"] as const)( + "defers the %s reminder for 24 hours after Later", + async (automaticReason) => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-30T00:00:00.000Z")); + const { values } = installMemoryLocalStorage(); + const update = createUpdate(); + mocks.check.mockResolvedValue(update); + await installAvailableAppUpdate(); + renderPreparedUpdate(update); + + capturedButton("Later").onClick?.(); + + expect( + JSON.parse(values.get("orgii:updater:deferred-update-reminder") ?? "{}") + ).toEqual({ + version: "1.1.22", + remindAfter: Date.now() + 24 * 60 * 60_000, + }); + mocks.storeSet.mockClear(); + + await executeAutomaticUpdateForTests(automaticReason); + + expect(update.download).toHaveBeenCalledOnce(); + expect(mocks.storeSet).not.toHaveBeenCalledWith(expect.anything(), true); + } + ); + + it("shows the deferred reminder again after its 24-hour cooldown", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-30T00:00:00.000Z")); + const { localStorage } = installMemoryLocalStorage(); + const update = createUpdate(); + mocks.check.mockResolvedValue(update); + await installAvailableAppUpdate(); + renderPreparedUpdate(update); + capturedButton("Later").onClick?.(); + mocks.storeSet.mockClear(); + vi.advanceTimersByTime(24 * 60 * 60_000); + + await executeAutomaticUpdateForTests("foreground"); + + expect(localStorage.removeItem).toHaveBeenCalledWith( + "orgii:updater:deferred-update-reminder" + ); + expect(mocks.storeSet).toHaveBeenCalledWith(expect.anything(), true); + }); + + it("does not let a deferred version suppress a newer update", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-30T00:00:00.000Z")); + installMemoryLocalStorage(); + const deferredUpdate = createUpdate(); + const newerUpdate = createUpdate({ version: "1.1.23" }); + mocks.check + .mockResolvedValueOnce(deferredUpdate) + .mockResolvedValueOnce(newerUpdate); + await installAvailableAppUpdate(); + renderPreparedUpdate(deferredUpdate); + capturedButton("Later").onClick?.(); + mocks.storeSet.mockClear(); + + await executeAutomaticUpdateForTests("interval"); + + expect(deferredUpdate.close).toHaveBeenCalledOnce(); + expect(newerUpdate.download).toHaveBeenCalledOnce(); + expect(mocks.storeSet).toHaveBeenCalledWith(expect.anything(), true); + }); + + it("discards malformed reminder state instead of suppressing an update", async () => { + const { localStorage, values } = installMemoryLocalStorage(); + values.set("orgii:updater:deferred-update-reminder", "{not-json"); + const update = createUpdate(); + mocks.check.mockResolvedValue(update); + + await executeAutomaticUpdateForTests("startup"); + + expect(localStorage.removeItem).toHaveBeenCalledWith( + "orgii:updater:deferred-update-reminder" + ); + expect(mocks.storeSet).toHaveBeenCalledWith(expect.anything(), true); + }); + + it("lets an explicit install action override an active reminder cooldown", async () => { + const { values } = installMemoryLocalStorage(); + const update = createUpdate(); + mocks.check.mockResolvedValue(update); + await installAvailableAppUpdate(); + renderPreparedUpdate(update); + capturedButton("Later").onClick?.(); + expect(values.has("orgii:updater:deferred-update-reminder")).toBe(true); + mocks.storeSet.mockClear(); + + await installAvailableAppUpdate(); + + expect(mocks.storeSet).toHaveBeenCalledWith(expect.anything(), true); + }); + it("persists a skipped version and closes its update handle", async () => { - const values = new Map(); - const localStorage = { - getItem: vi.fn((key: string) => values.get(key) ?? null), - removeItem: vi.fn((key: string) => values.delete(key)), - setItem: vi.fn((key: string, value: string) => values.set(key, value)), - }; - vi.stubGlobal("window", { localStorage }); + const { localStorage, values } = installMemoryLocalStorage(); + values.set( + "orgii:updater:deferred-update-reminder", + JSON.stringify({ version: "1.1.22", remindAfter: Date.now() + 60_000 }) + ); const update = createUpdate(); mocks.check.mockResolvedValue(update); await installAvailableAppUpdate(); @@ -366,14 +479,21 @@ describe("AppUpdater", () => { "orgii:updater:skipped-update-version", "1.1.22" ); + expect(localStorage.removeItem).toHaveBeenCalledWith( + "orgii:updater:deferred-update-reminder" + ); expect(update.close).toHaveBeenCalledOnce(); expect(mocks.setInstallPromptVisible).toHaveBeenCalledWith(false); expect(update.install).not.toHaveBeenCalled(); expect(mocks.relaunch).not.toHaveBeenCalled(); - vi.unstubAllGlobals(); }); it("installs from the dialog only after its primary action is clicked", async () => { + const { localStorage, values } = installMemoryLocalStorage(); + values.set( + "orgii:updater:deferred-update-reminder", + JSON.stringify({ version: "1.1.22", remindAfter: Date.now() + 60_000 }) + ); const update = createUpdate(); mocks.check.mockResolvedValue(update); await installAvailableAppUpdate(); @@ -383,6 +503,9 @@ describe("AppUpdater", () => { expect(update.install).toHaveBeenCalledOnce(); expect(mocks.relaunch).toHaveBeenCalledOnce(); + expect(localStorage.removeItem).toHaveBeenCalledWith( + "orgii:updater:deferred-update-reminder" + ); expect(mocks.setInstallPromptVisible).toHaveBeenCalledWith(false); }); @@ -416,6 +539,5 @@ describe("AppUpdater", () => { expect(progressMessages[3]?.[0].title).toBe("Downloading update… 100%"); expect(mocks.messageRemove).toHaveBeenCalledWith("app-update-progress"); expect(mocks.relaunch).not.toHaveBeenCalled(); - vi.useRealTimers(); }); }); diff --git a/src/scaffold/AppUpdater/index.tsx b/src/scaffold/AppUpdater/index.tsx index a47da5a58..4729ef0d1 100644 --- a/src/scaffold/AppUpdater/index.tsx +++ b/src/scaffold/AppUpdater/index.tsx @@ -48,6 +48,14 @@ const CHECK_TOAST_ID = "app-update-check"; const INSTALL_TOAST_ID = "app-update-progress"; const SKIPPED_UPDATE_VERSION_STORAGE_KEY = "orgii:updater:skipped-update-version"; +const DEFERRED_UPDATE_REMINDER_STORAGE_KEY = + "orgii:updater:deferred-update-reminder"; +const UPDATE_REMINDER_DEFER_DURATION_MS = 24 * 60 * 60_000; + +interface DeferredUpdateReminder { + version: string; + remindAfter: number; +} export interface CheckForAppUpdatesOptions { notify?: boolean; @@ -133,6 +141,60 @@ function clearSkippedUpdateVersion(version: string): void { } } +function getDeferredUpdateReminder(): DeferredUpdateReminder | null { + if (typeof window === "undefined") return null; + + const stored = window.localStorage.getItem( + DEFERRED_UPDATE_REMINDER_STORAGE_KEY + ); + if (!stored) return null; + + try { + const parsed = JSON.parse(stored) as Partial; + if ( + typeof parsed.version === "string" && + typeof parsed.remindAfter === "number" && + Number.isFinite(parsed.remindAfter) + ) { + return { version: parsed.version, remindAfter: parsed.remindAfter }; + } + } catch { + // Invalid persisted state should never suppress an update reminder. + } + + window.localStorage.removeItem(DEFERRED_UPDATE_REMINDER_STORAGE_KEY); + return null; +} + +function deferUpdateReminder(version: string): void { + if (typeof window === "undefined") return; + const reminder: DeferredUpdateReminder = { + version, + remindAfter: Date.now() + UPDATE_REMINDER_DEFER_DURATION_MS, + }; + window.localStorage.setItem( + DEFERRED_UPDATE_REMINDER_STORAGE_KEY, + JSON.stringify(reminder) + ); +} + +function clearDeferredUpdateReminder(version: string): void { + if (typeof window === "undefined") return; + const reminder = getDeferredUpdateReminder(); + if (reminder?.version === version) { + window.localStorage.removeItem(DEFERRED_UPDATE_REMINDER_STORAGE_KEY); + } +} + +function isUpdateReminderDeferred(version: string): boolean { + const reminder = getDeferredUpdateReminder(); + if (!reminder || reminder.version !== version) return false; + if (reminder.remindAfter > Date.now()) return true; + + clearDeferredUpdateReminder(version); + return false; +} + function createCoordinator(): AppUpdaterCoordinator { return new AppUpdaterCoordinator({ check: () => checkAppUpdateOnChannel(UPDATE_CHECK_TIMEOUT_MS), @@ -278,11 +340,28 @@ export interface InstallAvailableAppUpdateOptions { silentDownload?: boolean; } +interface PrepareAvailableAppUpdateOptions { + silentDownload: boolean; + promptPolicy: "always" | "respect-reminder"; +} + +function shouldShowUpdatePrompt( + update: Update, + policy: PrepareAvailableAppUpdateOptions["promptPolicy"] +): boolean { + switch (policy) { + case "always": + return true; + case "respect-reminder": + return !isUpdateReminderDeferred(update.version); + } +} + async function prepareAvailableAppUpdate( update: Update, - silentDownload: boolean + options: PrepareAvailableAppUpdateOptions ): Promise { - const progressReporter = silentDownload + const progressReporter = options.silentDownload ? undefined : createProgressReporter(); clearSkippedUpdateVersion(update.version); @@ -291,7 +370,9 @@ async function prepareAvailableAppUpdate( try { await coordinator.downloadAvailableUpdate(progressReporter); endDownloadProgress(); - store().set(appUpdateInstallPromptAtom, true); + if (shouldShowUpdatePrompt(update, options.promptPolicy)) { + store().set(appUpdateInstallPromptAtom, true); + } } catch (error) { if (progressReporter) endDownloadProgress(); throw error; @@ -328,7 +409,10 @@ export async function installAvailableAppUpdate( if (!confirmed) { try { - await prepareAvailableAppUpdate(update, silentDownload); + await prepareAvailableAppUpdate(update, { + silentDownload, + promptPolicy: "always", + }); activeAutomaticScheduler?.resetRetry(); } catch (error) { showDownloadFailure(error, { @@ -371,7 +455,7 @@ export async function installAvailableAppUpdate( async function executeAutomaticUpdate( reason: AutomaticUpdateReason, - scheduler: AppUpdaterScheduler + retryAutomaticUpdate: () => void ): Promise { let update: Update | null; try { @@ -403,11 +487,14 @@ async function executeAutomaticUpdate( try { // Installing can terminate the app on Windows. Every automatic path only // prepares the package and asks the user before installing or relaunching. - await prepareAvailableAppUpdate(update, true); + await prepareAvailableAppUpdate(update, { + silentDownload: true, + promptPolicy: "respect-reminder", + }); } catch (error) { showDownloadFailure(error, { automatic: true, - retry: () => scheduler.retryNow(), + retry: retryAutomaticUpdate, }); log.warn( `Automatic update download (${reason}) failed`, @@ -435,19 +522,24 @@ export const AppUpdater: React.FC = () => { const settingsLoaded = useAtomValue(settingsLoadedAtom); const handleInstallLater = useCallback(() => { + if (availableUpdate) deferUpdateReminder(availableUpdate.version); setInstallPromptVisible(false); - }, [setInstallPromptVisible]); + }, [availableUpdate, setInstallPromptVisible]); const handleSkipVersion = useCallback(() => { - if (availableUpdate) setSkippedUpdateVersion(availableUpdate.version); + if (availableUpdate) { + setSkippedUpdateVersion(availableUpdate.version); + clearDeferredUpdateReminder(availableUpdate.version); + } coordinator.clearAvailableUpdate(); setInstallPromptVisible(false); }, [availableUpdate, setInstallPromptVisible]); const handleInstallConfirm = useCallback(async () => { + if (availableUpdate) clearDeferredUpdateReminder(availableUpdate.version); await installAvailableAppUpdate({ confirmed: true }); setInstallPromptVisible(false); - }, [setInstallPromptVisible]); + }, [availableUpdate, setInstallPromptVisible]); useEffect(() => { if (!settingsLoaded) return; @@ -461,7 +553,9 @@ export const AppUpdater: React.FC = () => { retryJitterRatio: UPDATE_RETRY_JITTER_RATIO, }); activeAutomaticScheduler = scheduler; - scheduler.start((reason) => executeAutomaticUpdate(reason, scheduler)); + scheduler.start((reason) => + executeAutomaticUpdate(reason, () => scheduler.retryNow()) + ); return () => { scheduler.stop(); @@ -539,6 +633,12 @@ export const AppUpdater: React.FC = () => { ); }; +export async function executeAutomaticUpdateForTests( + reason: AutomaticUpdateReason +): Promise { + await executeAutomaticUpdate(reason, () => undefined); +} + /** Test-only reset for the module singleton. */ export function resetAppUpdaterForTests(): void { activeAutomaticScheduler?.stop();