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
@@ -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();