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>