UI changes to update and support auto updating (#6075)

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
Anthony Stirling
2026-06-02 23:11:37 +01:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 919f0ade99
commit 256d1a86d2
31 changed files with 3602 additions and 488 deletions
@@ -30,6 +30,7 @@ import { useScarfTracking } from "@app/hooks/useScarfTracking";
import { useAppInitialization } from "@app/hooks/useAppInitialization";
import { useLogoAssets } from "@app/hooks/useLogoAssets";
import AppConfigLoader from "@app/components/shared/AppConfigLoader";
import { UpdateStartupPopup } from "@app/components/shared/UpdateStartupPopup";
import { RedactionProvider } from "@app/contexts/RedactionContext";
import { FormFillProvider } from "@app/tools/formFill/FormFillContext";
import { FolderProvider } from "@app/contexts/FolderContext";
@@ -122,6 +123,9 @@ export function AppProviders({
<ScarfTrackingInitializer />
<AppConfigLoader />
<ServerDefaultsSync />
{/* Auto-popup on startup when a newer Stirling-PDF release is available.
No-ops inside Tauri — the desktop popup handles that flow. */}
<UpdateStartupPopup />
<FileContextProvider
enableUrlSync={true}
enablePersistence={true}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,136 @@
import { useEffect, useRef, useState } from "react";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useFrontendVersionInfo } from "@app/hooks/useFrontendVersionInfo";
import { updateService, type UpdateSummary } from "@app/services/updateService";
import UpdateModal from "@app/components/shared/UpdateModal";
/**
* How long to wait after mount before checking for an update. Matches the
* desktop popup delay (15s) so the first-launch experience feels the same
* in both environments and the check doesn't race with initial app config
* load / auth handshake on slow networks.
*/
const STARTUP_DELAY_MS = 15_000;
/**
* localStorage key used by the "Remind me later" button on the UpdateModal.
* Shared with the desktop popup so snoozing in either context suppresses
* both popups for the same 24h window.
*/
const SNOOZE_KEY = "stirling-pdf-updater:snoozedUntil";
const SNOOZE_DURATION_MS = 24 * 60 * 60 * 1000;
/**
* Best-effort Tauri detection without importing `@tauri-apps/api` into the
* core bundle (which must remain runnable on plain web). Tauri v2 injects
* `__TAURI_INTERNALS__` before any user code runs.
*/
function isRunningInTauri(): boolean {
if (typeof window === "undefined") return false;
return (
typeof (window as unknown as { __TAURI_INTERNALS__?: unknown })
.__TAURI_INTERNALS__ !== "undefined"
);
}
/**
* Web/server-side auto-popup that shows the UpdateModal on startup when a
* newer Stirling-PDF version is available. Previously this check only ran
* from the Settings → General "Check for Updates" button, so non-desktop
* users could sit on stale versions indefinitely without any prompt.
*
* On desktop (Tauri) this component is a no-op — `useDesktopUpdatePopup`
* drives the desktop flow because it also has to honour the headless
* `updateMode` provisioning flag and wire up the silent/auto installer.
* Running both would double-popup.
*/
export function UpdateStartupPopup() {
const { config } = useAppConfig();
const { appVersion } = useFrontendVersionInfo(config?.appVersion);
// The version to compare against the latest. Prefer the frontend version
// (which is always known) so we don't wait for the backend handshake in
// offline / self-hosted-down scenarios.
const currentVersion = appVersion ?? config?.appVersion ?? null;
const [updateSummary, setUpdateSummary] = useState<UpdateSummary | null>(
null,
);
const [showModal, setShowModal] = useState(false);
const hasChecked = useRef(false);
useEffect(() => {
// Skip on desktop — the Tauri popup owns that flow end-to-end.
if (isRunningInTauri()) return;
if (hasChecked.current) return;
if (!currentVersion) return;
// Don't even schedule the timer until we have a version to compare.
hasChecked.current = true;
const timer = setTimeout(async () => {
// Respect the 24h snooze set by the "Remind me later" button.
const snoozedUntil = localStorage.getItem(SNOOZE_KEY);
if (snoozedUntil && Date.now() < parseInt(snoozedUntil, 10)) return;
try {
const machineInfo = {
machineType: config?.machineType ?? "unknown",
activeSecurity: config?.activeSecurity ?? false,
licenseType: config?.license ?? "NORMAL",
};
const summary = await updateService.getUpdateSummary(
currentVersion,
machineInfo,
);
if (
summary?.latest_version &&
updateService.compareVersions(
summary.latest_version,
currentVersion,
) > 0
) {
setUpdateSummary(summary);
setShowModal(true);
}
} catch (err) {
// Surface as a console warning — a silent failure here is preferable
// to a noisy error banner on every offline startup.
console.warn("[UpdateStartupPopup] startup update check failed:", err);
}
}, STARTUP_DELAY_MS);
return () => clearTimeout(timer);
}, [
currentVersion,
config?.machineType,
config?.activeSecurity,
config?.license,
]);
if (!updateSummary || !currentVersion) return null;
const machineInfo = {
machineType: config?.machineType ?? "unknown",
activeSecurity: config?.activeSecurity ?? false,
licenseType: config?.license ?? "NORMAL",
};
return (
<UpdateModal
opened={showModal}
onClose={() => setShowModal(false)}
onRemindLater={() => {
localStorage.setItem(
SNOOZE_KEY,
String(Date.now() + SNOOZE_DURATION_MS),
);
setShowModal(false);
}}
currentVersion={currentVersion}
updateSummary={updateSummary}
machineInfo={machineInfo}
/>
);
}
export default UpdateStartupPopup;
@@ -30,21 +30,57 @@ import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import LocalIcon from "@app/components/shared/LocalIcon";
import { updateService, UpdateSummary } from "@app/services/updateService";
import UpdateModal from "@app/components/shared/UpdateModal";
import type {
DesktopInstallState,
DesktopInstallProgress,
DesktopInstallActions,
DesktopInstallCanInstall,
} from "@app/components/shared/UpdateModal";
import { useFrontendVersionInfo } from "@app/hooks/useFrontendVersionInfo";
const DEFAULT_AUTO_UNZIP_FILE_LIMIT = 4;
const BANNER_DISMISSED_KEY = "stirlingpdf_features_banner_dismissed";
/**
* Desktop-only: user-facing update policy control, rendered inside the
* Software Updates section alongside the version info. Passed from the
* desktop GeneralSection override so this core component doesn't have to
* import any Tauri APIs directly.
*/
export interface DesktopUpdateModeControl {
/** Current mode. */
mode: "prompt" | "auto" | "disabled";
/** `true` when the mode was written by a provisioning file — disables the control. */
locked: boolean;
/** Called when the user picks a new mode. Async: surface errors via toast. */
onChange: (mode: "prompt" | "auto" | "disabled") => Promise<void> | void;
}
interface GeneralSectionProps {
hideTitle?: boolean;
hideUpdateSection?: boolean;
hideAdminBanner?: boolean;
/** Desktop-only: Tauri updater install state, passed from the desktop override. */
desktopInstall?: {
state: DesktopInstallState;
progress: DesktopInstallProgress | null;
errorMessage: string | null;
tauriInstallReady: boolean;
/** Result of the `can_install_updates` probe, used to show an inline
* warning when msiexec would need UAC elevation this user doesn't have. */
canInstall?: DesktopInstallCanInstall | null;
actions: DesktopInstallActions;
};
/** Desktop-only: update-mode toggle (prompt/auto/disabled). */
desktopUpdateMode?: DesktopUpdateModeControl;
}
const GeneralSection: React.FC<GeneralSectionProps> = ({
hideTitle = false,
hideUpdateSection = false,
hideAdminBanner = false,
desktopInstall,
desktopUpdateMode,
}) => {
const { t } = useTranslation();
const { preferences, updatePreference } = usePreferences();
@@ -72,48 +108,56 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
setFileLimitInput(preferences.autoUnzipFileLimit);
}, [preferences.autoUnzipFileLimit]);
// The version to use for update checks — on desktop use the Tauri app version,
// falling back to the backend version
const currentVersion = appVersion ?? config?.appVersion ?? null;
// Check for updates on mount
useEffect(() => {
if (config?.appVersion && config?.machineType) {
if (currentVersion) {
checkForUpdate();
}
}, [config?.appVersion, config?.machineType]);
}, [currentVersion, config?.machineType]);
const checkForUpdate = async () => {
if (!config?.appVersion || !config?.machineType) {
return;
}
if (!currentVersion) return;
setCheckingUpdate(true);
const machineInfo = {
machineType: config.machineType,
activeSecurity: config.activeSecurity ?? false,
licenseType: config.license ?? "NORMAL",
machineType: config?.machineType ?? "unknown",
activeSecurity: config?.activeSecurity ?? false,
licenseType: config?.license ?? "NORMAL",
};
const summary = await updateService.getUpdateSummary(
config.appVersion,
currentVersion,
machineInfo,
);
if (summary && summary.latest_version) {
const isNewerVersion =
updateService.compareVersions(
summary.latest_version,
config.appVersion,
) > 0;
if (isNewerVersion) {
setUpdateSummary(summary);
} else {
// Clear any existing update summary if user is on latest version
setUpdateSummary(null);
}
if (
summary?.latest_version &&
updateService.compareVersions(summary.latest_version, currentVersion) > 0
) {
setUpdateSummary(summary);
} else {
// No update available (latest_version is null) - clear any existing update summary
setUpdateSummary(null);
}
setCheckingUpdate(false);
};
// Build desktop install props for the UpdateModal (only when provided by desktop override)
const desktopInstallProps = desktopInstall?.tauriInstallReady
? {
state: desktopInstall.state,
progress: desktopInstall.progress,
errorMessage: desktopInstall.errorMessage,
canInstall: desktopInstall.canInstall,
actions: desktopInstall.actions,
}
: undefined;
// Check if login is disabled
const loginDisabled = !config?.enableLogin;
@@ -215,8 +259,8 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
</Paper>
)}
{/* Update Check Section */}
{!hideUpdateSection && config?.appVersion && (
{/* Update Check Section — show when backend version is known OR in desktop mode (Tauri version is always available) */}
{!hideUpdateSection && (config?.appVersion || !!desktopInstall) && (
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
@@ -272,16 +316,18 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
)}
<Group justify="space-between" align="center">
<div>
<Text size="sm" c="dimmed">
{t(
"settings.general.updates.currentBackendVersion",
"Current Backend Version",
)}
:{" "}
<Text component="span" fw={500}>
{config.appVersion}
{config?.appVersion && (
<Text size="sm" c="dimmed">
{t(
"settings.general.updates.currentBackendVersion",
"Current Backend Version",
)}
:{" "}
<Text component="span" fw={500}>
{config.appVersion}
</Text>
</Text>
</Text>
)}
{updateSummary && (
<Text size="sm" c="dimmed" mt={4}>
{t(
@@ -301,6 +347,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
variant="default"
onClick={checkForUpdate}
loading={checkingUpdate}
disabled={!currentVersion}
leftSection={
<LocalIcon
icon="refresh-rounded"
@@ -335,6 +382,79 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
</Group>
</Group>
{/* Desktop-only: update behaviour selector (prompt / auto / disabled).
Rendered disabled with a "Managed by administrator" hint when the
mode was pinned by a provisioning file. */}
{desktopUpdateMode && (
<Stack gap="xs">
<Group gap="xs" align="center">
<Text fw={600} size="sm">
{t(
"settings.general.updates.updateBehavior",
"Update behavior",
)}
</Text>
{desktopUpdateMode.locked && (
// `color="gray" variant="light"` rendered as near-invisible
// light-on-dark in dark mode. `blue light` has enough
// contrast in both themes to read clearly without being
// shouty.
<Badge color="blue" variant="light" size="sm" radius="sm">
{t(
"settings.general.updates.managedByAdmin",
"Managed by administrator",
)}
</Badge>
)}
</Group>
<Text size="xs" c="dimmed">
{desktopUpdateMode.locked
? t(
"settings.general.updates.updateBehaviorLockedDescription",
"Your administrator has configured how Stirling-PDF handles updates on this machine. Contact them to change this.",
)
: t(
"settings.general.updates.updateBehaviorDescription",
"Choose whether to prompt before installing updates, install them automatically, or skip update checks entirely.",
)}
</Text>
<Select
disabled={desktopUpdateMode.locked}
value={desktopUpdateMode.mode}
onChange={(value) => {
if (!value) return;
void desktopUpdateMode.onChange(
value as "prompt" | "auto" | "disabled",
);
}}
data={[
{
value: "prompt",
label: t(
"settings.general.updates.modePrompt",
"Ask me before installing updates",
),
},
{
value: "auto",
label: t(
"settings.general.updates.modeAuto",
"Install updates automatically",
),
},
{
value: "disabled",
label: t(
"settings.general.updates.modeDisabled",
"Don't check for updates",
),
},
]}
maw={360}
/>
</Stack>
)}
{updateSummary?.any_breaking && (
<Alert
color="orange"
@@ -713,17 +833,24 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
</Paper>
{/* Update Modal */}
{updateSummary && config?.appVersion && config?.machineType && (
{updateSummary && (config?.appVersion || !!desktopInstall) && (
<UpdateModal
opened={updateModalOpened}
onClose={() => setUpdateModalOpened(false)}
currentVersion={config.appVersion}
onRemindLater={() => {
localStorage.setItem(
"stirling-pdf-updater:snoozedUntil",
String(Date.now() + 24 * 60 * 60 * 1000),
);
}}
currentVersion={appVersion ?? config?.appVersion ?? ""}
updateSummary={updateSummary}
machineInfo={{
machineType: config.machineType,
activeSecurity: config.activeSecurity ?? false,
licenseType: config.license ?? "NORMAL",
machineType: config?.machineType ?? "unknown",
activeSecurity: config?.activeSecurity ?? false,
licenseType: config?.license ?? "NORMAL",
}}
desktopInstall={desktopInstallProps}
/>
)}
</Stack>
@@ -25,6 +25,8 @@ import { SaaSTeamProvider } from "@app/contexts/SaaSTeamContext";
import { SaasBillingProvider } from "@app/contexts/SaasBillingContext";
import { SaaSCheckoutProvider } from "@app/contexts/SaaSCheckoutContext";
import { CreditModalBootstrap } from "@app/components/shared/modals/CreditModalBootstrap";
import UpdateModal from "@core/components/shared/UpdateModal";
import { useDesktopUpdatePopup } from "@app/hooks/useDesktopUpdatePopup";
// Common tool endpoints to preload for faster first-use
const COMMON_TOOL_ENDPOINTS = [
@@ -48,6 +50,7 @@ const COMMON_TOOL_ENDPOINTS = [
*/
export function AppProviders({ children }: { children: ReactNode }) {
const { isFirstLaunch, setupComplete } = useFirstLaunchCheck();
const updatePopup = useDesktopUpdatePopup();
const [connectionMode, setConnectionMode] = useState<
"saas" | "selfhosted" | "local" | null
>(null);
@@ -264,6 +267,38 @@ export function AppProviders({ children }: { children: ReactNode }) {
.catch(() => {});
}, [authChecked]);
// Desktop auto-update popup (shown on startup if update available)
const { state: popupState, actions: popupActions } = updatePopup;
const updatePopupModal = popupState.updateSummary && (
<UpdateModal
opened={popupState.showModal}
onClose={popupActions.dismissModal}
onRemindLater={popupActions.remindLater}
currentVersion={popupState.currentVersion}
updateSummary={popupState.updateSummary}
machineInfo={{
machineType: navigator.platform?.toLowerCase().includes("mac")
? "Client-mac"
: navigator.platform?.toLowerCase().includes("linux")
? "Client-unix"
: "Client-win",
activeSecurity: false,
licenseType: "NORMAL",
}}
desktopInstall={
popupState.tauriInstallReady
? {
state: popupState.state,
progress: popupState.progress,
errorMessage: popupState.errorMessage,
canInstall: popupState.canInstall,
actions: popupActions,
}
: undefined
}
/>
);
if (!authChecked) {
return (
<ProprietaryAppProviders
@@ -278,6 +313,7 @@ export function AppProviders({ children }: { children: ReactNode }) {
}}
>
<div style={{ minHeight: "100vh" }} />
{updatePopupModal}
</ProprietaryAppProviders>
);
}
@@ -313,6 +349,8 @@ export function AppProviders({ children }: { children: ReactNode }) {
<DesktopOnboardingModal />
{/* Global sign-in modal, opened via stirling:open-sign-in event */}
<SignInModal />
{/* Desktop auto-update popup */}
{updatePopupModal}
</SaaSCheckoutProvider>
</SaasBillingProvider>
</SaaSTeamProvider>
@@ -1,16 +1,114 @@
import React from "react";
import { Stack } from "@mantine/core";
import React, { useCallback, useEffect, useState } from "react";
import { Stack, Alert } from "@mantine/core";
import { useTranslation } from "react-i18next";
import CoreGeneralSection from "@core/components/shared/config/configSections/GeneralSection";
import { DefaultAppSettings } from "@app/components/shared/config/configSections/DefaultAppSettings";
import { useDesktopInstall } from "@app/hooks/useDesktopInstall";
import {
desktopUpdateService,
type UpdateMode,
type UpdateModeInfo,
} from "@app/services/desktopUpdateService";
/**
* Desktop extension of GeneralSection that adds default PDF editor settings
* Desktop extension of GeneralSection.
*
* Adds default PDF editor settings, wires up the Tauri auto-updater install
* flow, and exposes the user-facing update-mode control (prompt / auto /
* disabled). When the mode is locked by a provisioning file the control is
* still rendered but disabled, with a "Managed by administrator" hint, so
* managed-deployment users can see what policy is in effect.
*/
const GeneralSection: React.FC = () => {
const { t } = useTranslation();
const install = useDesktopInstall();
const [updateModeInfo, setUpdateModeInfo] = useState<UpdateModeInfo>({
mode: "prompt",
locked: false,
});
const [updateModeError, setUpdateModeError] = useState<string | null>(null);
// Check for Tauri updater availability on mount
useEffect(() => {
void install.checkTauriUpdate();
}, [install.checkTauriUpdate]);
// Load the current update mode + lock status on mount. We intentionally
// re-fetch on every mount so that a provisioning file dropped while the
// app is running (admin re-pushes config via MDM) is reflected the next
// time the user opens Settings — the Rust side re-reads the store on
// every call, so this is essentially a fresh read.
useEffect(() => {
let cancelled = false;
desktopUpdateService.getUpdateModeInfo().then((info) => {
if (!cancelled) setUpdateModeInfo(info);
});
return () => {
cancelled = true;
};
}, []);
const handleUpdateModeChange = useCallback(
async (mode: UpdateMode) => {
setUpdateModeError(null);
try {
await desktopUpdateService.setUpdateMode(mode);
// Refresh rather than optimistically updating — the Rust command
// can refuse the change (locked) and we want the UI to reflect
// the authoritative stored value.
const fresh = await desktopUpdateService.getUpdateModeInfo();
setUpdateModeInfo(fresh);
} catch (err) {
console.error("[GeneralSection] setUpdateMode failed:", err);
const msg =
err instanceof Error
? err.message
: typeof err === "string"
? err
: t(
"settings.general.updates.updateBehaviorErrorLocked",
"This setting is locked by your administrator.",
);
setUpdateModeError(msg);
}
},
[t],
);
return (
<Stack gap="lg">
<DefaultAppSettings />
<CoreGeneralSection />
{updateModeError && (
<Alert
color="red"
title={t(
"settings.general.updates.updateBehaviorError",
"Could not change update behavior",
)}
withCloseButton
onClose={() => setUpdateModeError(null)}
>
{updateModeError}
</Alert>
)}
<CoreGeneralSection
hideUpdateSection={
updateModeInfo.mode === "disabled" && updateModeInfo.locked
}
desktopInstall={{
state: install.state,
progress: install.progress,
errorMessage: install.errorMessage,
tauriInstallReady: install.tauriInstallReady,
canInstall: install.canInstall,
actions: install.actions,
}}
desktopUpdateMode={{
mode: updateModeInfo.mode,
locked: updateModeInfo.locked,
onChange: handleUpdateModeChange,
}}
/>
</Stack>
);
};
@@ -0,0 +1,131 @@
import { useState, useEffect, useCallback } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import type {
DesktopInstallState,
DesktopInstallProgress,
DesktopInstallActions,
} from "@core/components/shared/UpdateModal";
import {
desktopUpdateService,
type CanInstallResult,
} from "@app/services/desktopUpdateService";
/**
* Desktop-only hook managing the Tauri updater install state.
* Provides state + actions that get passed to the core UpdateModal
* via the desktop GeneralSection override.
*/
export function useDesktopInstall() {
const [state, setState] = useState<DesktopInstallState>("idle");
const [progress, setProgress] = useState<DesktopInstallProgress | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [tauriInstallReady, setTauriInstallReady] = useState(false);
/**
* Result of the `can_install_updates` probe. Populated when
* [`checkTauriUpdate`] finds an update, because that's when it becomes
* relevant — no point worrying about install permissions until we know
* there's actually something to install.
*
* `null` means "haven't probed yet". When the probe runs and returns
* `canInstall: false` the UpdateModal shows an inline blocked warning
* with a link to the installation docs, and disables the Install Now
* button so users can't click into a UAC prompt they can't satisfy.
*/
const [canInstall, setCanInstall] = useState<CanInstallResult | null>(null);
// Listen for the ready-to-restart event from Rust
useEffect(() => {
let unlisten: UnlistenFn | undefined;
listen<void>("update-ready-to-restart", () => {
setState("ready-to-restart");
}).then((fn) => {
unlisten = fn;
});
return () => unlisten?.();
}, []);
/**
* Check whether the Tauri updater has a downloadable build for the
* current platform, and if so probe whether we can actually install it
* without needing UAC elevation.
*/
/**
* Check whether the Tauri updater has a downloadable build for the
* current platform, and if so probe whether we can actually install it
* without needing UAC elevation.
*
* Returns `true` when an in-app install is available — callers MUST use
* the return value instead of reading `tauriInstallReady` from the hook's
* state, because React state updates are async and won't be visible
* until the next render.
*/
const checkTauriUpdate = useCallback(async (): Promise<boolean> => {
try {
const result = await invoke<{ version: string } | null>(
"check_for_update",
);
const ready = !!result;
setTauriInstallReady(ready);
if (result) {
const ci = await desktopUpdateService.canInstallUpdates();
setCanInstall(ci);
} else {
setCanInstall(null);
}
return ready;
} catch {
setTauriInstallReady(false);
setCanInstall(null);
return false;
}
}, []);
const startInstall: DesktopInstallActions["startInstall"] =
useCallback(async () => {
setState("downloading");
setProgress(null);
setErrorMessage(null);
let progressUnlisten: UnlistenFn | undefined;
let finishUnlisten: UnlistenFn | undefined;
try {
progressUnlisten = await listen<DesktopInstallProgress>(
"update-download-progress",
(event) => {
setProgress(event.payload);
if (event.payload.percent >= 100) setState("installing");
},
);
finishUnlisten = await listen<void>("update-download-finished", () => {
setState("installing");
});
await invoke<void>("download_and_install_update");
return true;
} catch (err) {
console.error("[useDesktopInstall] Install failed:", err);
setErrorMessage(err instanceof Error ? err.message : String(err));
setState("error");
return false;
} finally {
progressUnlisten?.();
finishUnlisten?.();
}
}, []);
const restartApp: DesktopInstallActions["restartApp"] =
useCallback(async () => {
await invoke<void>("restart_app");
}, []);
return {
state,
progress,
errorMessage,
tauriInstallReady,
canInstall,
checkTauriUpdate,
actions: { startInstall, restartApp },
};
}
@@ -0,0 +1,179 @@
import { renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ── Mocks (hoisted by vi.mock) ──────────────────────────────────────────────
const invokeMock = vi.fn();
const listenMock = vi.fn();
const getVersionMock = vi.fn();
const getUpdateModeMock = vi.fn();
const canInstallUpdatesMock = vi.fn();
const getUpdateSummaryMock = vi.fn();
vi.mock("@tauri-apps/api/core", () => ({
invoke: (cmd: string, args?: unknown) => invokeMock(cmd, args),
}));
vi.mock("@tauri-apps/api/event", () => ({
listen: (event: string, cb: unknown) => listenMock(event, cb),
}));
vi.mock("@tauri-apps/api/app", () => ({
getVersion: () => getVersionMock(),
}));
vi.mock("@app/services/updateService", () => ({
updateService: {
getUpdateSummary: (...args: unknown[]) => getUpdateSummaryMock(...args),
compareVersions: (a: string, b: string) => (a === b ? 0 : a > b ? 1 : -1),
},
}));
vi.mock("@app/services/desktopUpdateService", () => ({
desktopUpdateService: {
getUpdateMode: () => getUpdateModeMock(),
canInstallUpdates: () => canInstallUpdatesMock(),
},
}));
import { useDesktopUpdatePopup } from "@app/hooks/useDesktopUpdatePopup";
/** Flush pending microtasks so awaited promises settle. */
async function flushMicrotasks() {
for (let i = 0; i < 10; i++) {
await Promise.resolve();
}
}
const AUTO_FAILURE_KEY = "stirling-pdf-updater:autoFailedAt";
/** Run the hook through its startup timer + async chain. */
async function runStartup() {
renderHook(() => useDesktopUpdatePopup());
await vi.advanceTimersByTimeAsync(16_000);
await flushMicrotasks();
await vi.advanceTimersByTimeAsync(0);
await flushMicrotasks();
}
describe("useDesktopUpdatePopup — auto mode", () => {
beforeEach(() => {
vi.useFakeTimers();
window.localStorage.clear();
invokeMock.mockReset();
listenMock.mockReset();
getVersionMock.mockReset();
getUpdateModeMock.mockReset();
canInstallUpdatesMock.mockReset();
getUpdateSummaryMock.mockReset();
// Defaults: auto mode, update available, install permitted.
getUpdateModeMock.mockResolvedValue("auto");
getVersionMock.mockResolvedValue("1.0.0");
getUpdateSummaryMock.mockResolvedValue({ latest_version: "2.0.0" });
canInstallUpdatesMock.mockResolvedValue({
canInstall: true,
reason: null,
installDir: "C:/Program Files/Stirling-PDF",
});
listenMock.mockResolvedValue(() => undefined);
});
afterEach(() => {
vi.useRealTimers();
});
it("does NOT call restart_app when download_and_install_update fails", async () => {
invokeMock.mockImplementation((cmd: string) => {
if (cmd === "check_for_update") {
return Promise.resolve({
version: "2.0.0",
currentVersion: "1.0.0",
releaseNotes: null,
});
}
if (cmd === "download_and_install_update") {
return Promise.reject(new Error("signature mismatch"));
}
return Promise.resolve();
});
await runStartup();
const invocations = invokeMock.mock.calls.map((c) => c[0]);
expect(invocations).toContain("download_and_install_update");
expect(invocations).not.toContain("restart_app");
// Failure must be persisted so the next launch backs off instead of
// re-attempting a known-broken install.
expect(window.localStorage.getItem(AUTO_FAILURE_KEY)).not.toBeNull();
});
it("calls restart_app when download_and_install_update succeeds", async () => {
invokeMock.mockImplementation((cmd: string) => {
if (cmd === "check_for_update") {
return Promise.resolve({
version: "2.0.0",
currentVersion: "1.0.0",
releaseNotes: null,
});
}
// download_and_install_update + restart_app both resolve.
return Promise.resolve();
});
// Pre-seed a stale failure timestamp — a successful install must clear it.
window.localStorage.setItem(AUTO_FAILURE_KEY, "1");
await runStartup();
const invocations = invokeMock.mock.calls.map((c) => c[0]);
expect(invocations).toContain("download_and_install_update");
expect(invocations).toContain("restart_app");
expect(window.localStorage.getItem(AUTO_FAILURE_KEY)).toBeNull();
});
it("skips the install entirely when a recent failure is within the backoff window", async () => {
// Recorded 1 hour ago — well inside the 6-hour backoff.
const oneHourAgo = Date.now() - 60 * 60 * 1000;
window.localStorage.setItem(AUTO_FAILURE_KEY, String(oneHourAgo));
invokeMock.mockImplementation((cmd: string) => {
if (cmd === "check_for_update") {
return Promise.resolve({
version: "2.0.0",
currentVersion: "1.0.0",
releaseNotes: null,
});
}
return Promise.resolve();
});
await runStartup();
const invocations = invokeMock.mock.calls.map((c) => c[0]);
expect(invocations).not.toContain("download_and_install_update");
expect(invocations).not.toContain("restart_app");
});
it("attempts the install again once the backoff has elapsed", async () => {
// Recorded 7 hours ago — past the 6-hour backoff.
const sevenHoursAgo = Date.now() - 7 * 60 * 60 * 1000;
window.localStorage.setItem(AUTO_FAILURE_KEY, String(sevenHoursAgo));
invokeMock.mockImplementation((cmd: string) => {
if (cmd === "check_for_update") {
return Promise.resolve({
version: "2.0.0",
currentVersion: "1.0.0",
releaseNotes: null,
});
}
return Promise.resolve();
});
await runStartup();
const invocations = invokeMock.mock.calls.map((c) => c[0]);
expect(invocations).toContain("download_and_install_update");
expect(invocations).toContain("restart_app");
});
});
@@ -0,0 +1,237 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getVersion } from "@tauri-apps/api/app";
import { updateService, UpdateSummary } from "@app/services/updateService";
import { useDesktopInstall } from "@app/hooks/useDesktopInstall";
import {
desktopUpdateService,
type CanInstallResult,
} from "@app/services/desktopUpdateService";
const SNOOZE_KEY = "stirling-pdf-updater:snoozedUntil";
const STARTUP_DELAY_MS = 15_000;
/**
* When the headless auto-update path fails (bad signature, corrupt download,
* disk full, network drop mid-install) we record the timestamp here and skip
* the auto attempt for [`AUTO_FAILURE_BACKOFF_MS`] before trying again. Without
* this, a persistent failure restart-loops the app on every launch.
*
* The interactive flow still works during the backoff window — the user can
* open Settings → Software Updates and click Install Now manually, which
* clears the backoff on success.
*/
const AUTO_FAILURE_KEY = "stirling-pdf-updater:autoFailedAt";
const AUTO_FAILURE_BACKOFF_MS = 6 * 60 * 60 * 1000;
/**
* Desktop-only hook that checks for updates on startup and handles the
* three update modes configured via the tauri store (or the MDM
* provisioning file):
*
* * `disabled` — no check is performed, no UI is shown, no network call.
* * `auto` — FULLY HEADLESS. We download + install + restart in the
* background with no modal and no countdown. The whole
* point of `auto` is that the admin has already decided
* for the user; adding a UI on top would defeat that.
* If the Rust `can_install_updates` probe reports the
* current process can't write to the install directory
* (non-admin on a per-machine install, typical MDM case),
* we silently skip — no annoying popup on every launch.
* * `prompt` — default interactive flow. Shows the UpdateModal so the
* user can decide. If the probe reports we can't install,
* the modal renders an inline "administrator permissions
* required" alert and disables the Install Now button —
* the user learns about the restriction at the moment
* they try to act on it.
*
* The `Later` snooze is only honoured in interactive mode. Auto mode
* ignores the snooze so enterprise installs never drift onto old versions
* just because a previous user clicked Later once.
*/
export function useDesktopUpdatePopup() {
const [showModal, setShowModal] = useState(false);
const [currentVersion, setCurrentVersion] = useState("");
const [updateSummary, setUpdateSummary] = useState<UpdateSummary | null>(
null,
);
// Popup-local canInstall — kept separate from install.canInstall so the
// modal can be guaranteed to receive the probe result on the same render
// that opens the modal. Setting setCanInstall + setShowModal in the same
// microtask lets React batch them into a single render.
const [canInstall, setCanInstall] = useState<CanInstallResult | null>(null);
const install = useDesktopInstall();
const hasChecked = useRef(false);
// Keep a ref to install so the startup effect can call into it without
// re-firing on every re-render (install.actions is a fresh object each time).
const installRef = useRef(install);
installRef.current = install;
// Startup check
useEffect(() => {
if (hasChecked.current) return;
hasChecked.current = true;
const timer = setTimeout(async () => {
let mode: Awaited<ReturnType<typeof desktopUpdateService.getUpdateMode>> =
"prompt";
try {
mode = await desktopUpdateService.getUpdateMode();
} catch {
/* fall through with default */
}
if (mode === "disabled") {
// Managed deployment opted out of updates entirely — nothing to do.
return;
}
// Honour the user's "remind me later" snooze, but only in the interactive
// flow. `auto` mode always applies updates so enterprise installs never
// drift onto old versions just because a previous user pressed Later.
if (mode === "prompt") {
const snoozedUntil = localStorage.getItem(SNOOZE_KEY);
if (snoozedUntil && Date.now() < parseInt(snoozedUntil, 10)) return;
}
try {
const version = await getVersion();
setCurrentVersion(version);
const platform = navigator.platform?.toLowerCase() ?? "";
const machineType = platform.includes("mac")
? "Client-mac"
: platform.includes("linux")
? "Client-unix"
: "Client-win";
const machineInfo = {
machineType,
activeSecurity: false,
licenseType: "NORMAL",
};
const summary = await updateService.getUpdateSummary(
version,
machineInfo,
);
if (
!summary?.latest_version ||
updateService.compareVersions(summary.latest_version, version) <= 0
)
return;
// Ask the Tauri updater whether it can provide an in-app install.
// This may fail (placeholder pubkey, 404 on latest.json, signature
// mismatch, …) — that's fine, it just means we show "Download
// Latest" instead of "Install Now". The Supabase summary above is
// the source of truth for "is there a newer version at all"; the
// Tauri endpoint is only about "can we install it in-process".
//
// IMPORTANT: use the RETURN VALUE, not installRef.current.tauriInstallReady.
// React state updates are async — the ref would still hold the
// stale pre-check value at this point in the microtask.
const tauriReady = await installRef.current.checkTauriUpdate();
if (mode === "auto") {
// Auto mode requires a working Tauri updater — we can't headless-
// install via an external download link. If the tauri endpoint
// is broken, or the user can't install, silently skip. The
// interactive flow will still show the "Download Latest" fallback
// if they ever open the popup manually.
if (!tauriReady) {
console.warn(
"[DesktopUpdatePopup] auto-update skipped: tauri updater not available (pubkey/endpoint/signature issue?)",
);
return;
}
// Skip the headless attempt if a previous one failed recently.
// hasChecked is in-memory only, so without this guard a persistent
// failure (bad signature, corrupt download) restart-loops the app.
const failedAt = localStorage.getItem(AUTO_FAILURE_KEY);
if (
failedAt &&
Date.now() - parseInt(failedAt, 10) < AUTO_FAILURE_BACKOFF_MS
) {
console.warn(
"[DesktopUpdatePopup] auto-update skipped: recent failure within backoff window",
);
return;
}
const ci: CanInstallResult =
await desktopUpdateService.canInstallUpdates();
if (!ci.canInstall) {
console.warn(
"[DesktopUpdatePopup] auto-update silently skipped: install dir not writable",
);
return;
}
// Fully headless flow: download + install + restart with no UI.
// restart_app MUST be gated on install success — otherwise a failed
// install (which startInstall catches internally) cascades into a
// restart, and the in-memory `hasChecked` ref resets, so the next
// launch retries the same broken update. Restart loop.
let installed = false;
try {
installed = await installRef.current.actions.startInstall();
} catch (err) {
console.error("[DesktopUpdatePopup] auto-update failed:", err);
}
if (installed) {
localStorage.removeItem(AUTO_FAILURE_KEY);
try {
await installRef.current.actions.restartApp();
} catch (err) {
console.error(
"[DesktopUpdatePopup] restart after auto-update failed:",
err,
);
}
} else {
localStorage.setItem(AUTO_FAILURE_KEY, String(Date.now()));
}
return;
}
// Interactive mode: always show the modal. The footer renders
// "Install Now" when tauriInstallReady is true, or falls back
// to "Download Latest" (external link) when the Tauri updater
// is unavailable — so the user always has a path forward even
// when latest.json is missing, the pubkey is wrong, or the
// signature doesn't match.
const ci: CanInstallResult =
await desktopUpdateService.canInstallUpdates();
setUpdateSummary(summary);
setCanInstall(ci);
setShowModal(true);
} catch (err) {
console.error("[DesktopUpdatePopup] Startup check failed:", err);
}
}, STARTUP_DELAY_MS);
// Intentionally empty deps — this is a one-shot startup effect guarded
// by `hasChecked.current`. Adding `install` to the deps would re-fire
// the timer every time the install state changes, which is wrong.
return () => clearTimeout(timer);
}, []);
const dismissModal = useCallback(() => setShowModal(false), []);
const remindLater = useCallback(() => {
localStorage.setItem(SNOOZE_KEY, String(Date.now() + 24 * 60 * 60 * 1000));
setShowModal(false);
}, []);
return {
state: {
showModal,
currentVersion,
updateSummary,
...install,
// Override the install hook's canInstall with the popup-local copy
// so consumers see the value captured at the moment the modal was
// opened. Prevents a race where install.canInstall is still null
// when the modal first renders.
canInstall,
},
actions: { dismissModal, remindLater, ...install.actions },
};
}
@@ -0,0 +1,291 @@
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
export interface UpdateInfo {
/** New version string, e.g. "2.8.0" */
version: string;
/** Currently installed version string */
currentVersion: string;
/** Release notes from the update endpoint, if any */
releaseNotes: string | null;
}
export interface UpdateProgress {
/** Total bytes downloaded so far */
downloaded: number;
/** Total bytes to download (null if unknown) */
total: number | null;
/** Download percentage 0100 */
percent: number;
}
/**
* Headless-install update policy, set via the `updateMode` field of a
* `stirling-provisioning.json` file dropped by MDM / Intune tooling — or
* from the Settings → Software Updates panel by the user.
*
* * `prompt` default. Show the update popup and let the user decide.
* * `auto` silently download, install, and restart on startup.
* * `disabled` never check for updates or surface update UI.
*/
export type UpdateMode = "prompt" | "auto" | "disabled";
/** Current [`UpdateMode`] plus whether the UI can change it. */
export interface UpdateModeInfo {
mode: UpdateMode;
/**
* `true` when the mode was written by a provisioning file. The Settings
* control should render disabled with a "Managed by your administrator"
* hint; attempts to call [`setUpdateMode`] are rejected by the Rust
* command with an error so the UI can't quietly fall out of sync.
*/
locked: boolean;
}
/** Shape returned by the `can_install_updates` Tauri command. */
export interface CanInstallResult {
/** `true` when the current process can write to the install directory. */
canInstall: boolean;
/** Machine-readable reason when `canInstall` is `false`. */
reason: string | null;
/** The directory that was probed, for display in error messages. */
installDir: string | null;
}
// ─── localStorage keys ────────────────────────────────────────────────────────
const KEY_PREFIX = "stirling-pdf-updater:";
const KEY_LAST_CHECKED = `${KEY_PREFIX}lastChecked`;
const KEY_CHECK_INTERVAL_HOURS = `${KEY_PREFIX}checkIntervalHours`;
const KEY_AUTO_DOWNLOAD = `${KEY_PREFIX}autoDownload`;
const KEY_SNOOZED_UNTIL = `${KEY_PREFIX}snoozedUntil`;
const DEFAULT_CHECK_INTERVAL_HOURS = 24;
// ─── Service ─────────────────────────────────────────────────────────────────
class DesktopUpdateService {
private periodicCheckTimer: ReturnType<typeof setInterval> | null = null;
private progressUnlisten: UnlistenFn | null = null;
private finishUnlisten: UnlistenFn | null = null;
// ── Tauri command wrappers ─────────────────────────────────────────────────
/**
* Ask the Rust updater to check the configured endpoint.
* Returns `null` when up-to-date or when the check fails silently
* (e.g. no network, bad pubkey configuration).
*/
async checkForUpdate(): Promise<UpdateInfo | null> {
try {
return await invoke<UpdateInfo | null>("check_for_update");
} catch (error) {
console.error("[DesktopUpdateService] check_for_update failed:", error);
return null;
}
}
/**
* Download and install the available update.
*
* `onProgress` is called for each received chunk.
* `onFinish` is called when the download is complete and the installer
* has been written to disk (install is underway).
*
* Throws if the download or install fails — callers should catch and fall
* back to opening the download page.
*/
async downloadAndInstall(
onProgress: (progress: UpdateProgress) => void,
onFinish: () => void,
): Promise<void> {
this.cleanupEventListeners();
this.progressUnlisten = await listen<UpdateProgress>(
"update-download-progress",
(event) => onProgress(event.payload),
);
this.finishUnlisten = await listen<void>("update-download-finished", () =>
onFinish(),
);
try {
await invoke<void>("download_and_install_update");
} finally {
this.cleanupEventListeners();
}
}
/**
* Restart the app to apply an already-installed update.
* The process will be replaced — this call never returns normally.
*/
async restartApp(): Promise<void> {
await invoke<void>("restart_app");
}
/** Return the currently running app version string. */
async getAppVersion(): Promise<string> {
return invoke<string>("get_app_version");
}
/**
* Return the configured update mode plus whether it's locked by provisioning.
*
* Falls back to `{mode: 'prompt', locked: false}` if the Tauri command is
* unavailable (e.g. running in a browser dev environment) so non-managed
* installs always get the default interactive flow.
*/
async getUpdateModeInfo(): Promise<UpdateModeInfo> {
try {
return await invoke<UpdateModeInfo>("get_update_mode");
} catch (error) {
console.warn(
"[DesktopUpdateService] get_update_mode failed, defaulting to prompt/unlocked:",
error,
);
return { mode: "prompt", locked: false };
}
}
/** Convenience wrapper when callers only need the mode itself. */
async getUpdateMode(): Promise<UpdateMode> {
return (await this.getUpdateModeInfo()).mode;
}
/**
* Persist a user-chosen update mode. Throws when the mode is locked by
* provisioning — callers should not swallow that error; it should be
* surfaced as a UI toast or equivalent so the user knows why nothing
* changed.
*/
async setUpdateMode(mode: UpdateMode): Promise<void> {
await invoke<void>("set_update_mode", { mode });
}
/**
* Check whether the Tauri updater is likely to be able to install a new
* version silently on this machine. See the Rust docstring on
* `can_install_updates` for the underlying write-probe heuristic.
*
* Returns an optimistic `{canInstall: true, ...}` if the Tauri command is
* unavailable so we don't block updates in non-Tauri dev environments.
*/
async canInstallUpdates(): Promise<CanInstallResult> {
try {
return await invoke<CanInstallResult>("can_install_updates");
} catch (error) {
console.warn(
"[DesktopUpdateService] can_install_updates failed, assuming allowed:",
error,
);
return { canInstall: true, reason: null, installDir: null };
}
}
// ── Periodic checking ──────────────────────────────────────────────────────
/**
* Start a recurring timer that checks for updates at the configured
* interval. `onUpdateAvailable` is called when a newer version is found.
*
* The timer respects the snooze setting — if the user has snoozed
* notifications, the callback is suppressed until the snooze expires.
*/
startPeriodicChecks(
onUpdateAvailable: (update: UpdateInfo) => void,
intervalHours?: number,
): void {
this.stopPeriodicChecks();
const hours = intervalHours ?? this.getCheckIntervalHours();
const intervalMs = hours * 60 * 60 * 1000;
this.periodicCheckTimer = setInterval(async () => {
if (!this.shouldCheckNow()) return;
const update = await this.checkForUpdate();
this.setLastChecked();
if (update) onUpdateAvailable(update);
}, intervalMs);
}
stopPeriodicChecks(): void {
if (this.periodicCheckTimer !== null) {
clearInterval(this.periodicCheckTimer);
this.periodicCheckTimer = null;
}
}
// ── Settings (persisted to localStorage) ──────────────────────────────────
/**
* Returns `true` when enough time has elapsed since the last check
* AND the user has not snoozed update notifications.
*/
shouldCheckNow(): boolean {
const snoozedUntil = this.getSnoozedUntil();
if (snoozedUntil !== null && Date.now() < snoozedUntil) return false;
const lastChecked = this.getLastChecked();
if (lastChecked === null) return true;
const intervalMs = this.getCheckIntervalHours() * 60 * 60 * 1000;
return Date.now() - lastChecked >= intervalMs;
}
getCheckIntervalHours(): number {
const stored = localStorage.getItem(KEY_CHECK_INTERVAL_HOURS);
return stored !== null
? parseInt(stored, 10)
: DEFAULT_CHECK_INTERVAL_HOURS;
}
setCheckIntervalHours(hours: number): void {
localStorage.setItem(KEY_CHECK_INTERVAL_HOURS, String(hours));
}
isAutoDownloadEnabled(): boolean {
return localStorage.getItem(KEY_AUTO_DOWNLOAD) === "true";
}
setAutoDownload(value: boolean): void {
localStorage.setItem(KEY_AUTO_DOWNLOAD, String(value));
}
getLastChecked(): number | null {
const stored = localStorage.getItem(KEY_LAST_CHECKED);
return stored !== null ? parseInt(stored, 10) : null;
}
setLastChecked(): void {
localStorage.setItem(KEY_LAST_CHECKED, String(Date.now()));
}
getSnoozedUntil(): number | null {
const stored = localStorage.getItem(KEY_SNOOZED_UNTIL);
return stored !== null ? parseInt(stored, 10) : null;
}
/** Snooze update notifications for the given number of hours. */
snoozeFor(hours: number): void {
localStorage.setItem(
KEY_SNOOZED_UNTIL,
String(Date.now() + hours * 60 * 60 * 1000),
);
}
clearSnooze(): void {
localStorage.removeItem(KEY_SNOOZED_UNTIL);
}
// ── Private helpers ────────────────────────────────────────────────────────
private cleanupEventListeners(): void {
this.progressUnlisten?.();
this.progressUnlisten = null;
this.finishUnlisten?.();
this.finishUnlisten = null;
}
}
export const desktopUpdateService = new DesktopUpdateService();