mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 11:00:47 +02:00
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:
co-authored by
Claude Opus 4.6
parent
919f0ade99
commit
256d1a86d2
@@ -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;
|
||||
+165
-38
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user