mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 19:10: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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+102
-4
@@ -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 0–100 */
|
||||
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();
|
||||
Reference in New Issue
Block a user