Compare commits

...
9 changed files with 343 additions and 39 deletions
@@ -27,12 +27,14 @@ import DownloadIcon from "@mui/icons-material/Download";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import ExpandLessIcon from "@mui/icons-material/ExpandLess";
interface UpdateModalProps {
export interface UpdateModalProps {
opened: boolean;
onClose: () => void;
currentVersion: string;
updateSummary: UpdateSummary;
machineInfo: MachineInfo;
isDesktop?: boolean;
desktopVersion?: string | null;
}
const UpdateModal: React.FC<UpdateModalProps> = ({
@@ -41,6 +43,8 @@ const UpdateModal: React.FC<UpdateModalProps> = ({
currentVersion,
updateSummary,
machineInfo,
isDesktop = false,
desktopVersion,
}) => {
const { t } = useTranslation();
const [fullUpdateInfo, setFullUpdateInfo] = useState<FullUpdateInfo | null>(
@@ -96,7 +100,19 @@ const UpdateModal: React.FC<UpdateModalProps> = ({
return t(`update.priority.${key}`, priority || "Normal");
};
const downloadUrl = updateService.getDownloadUrl(machineInfo);
const desktopNeedsUpdate =
isDesktop && desktopVersion
? updateService.compareVersions(
updateSummary.latest_version || "0",
desktopVersion,
) > 0
: false;
const downloadUrl = isDesktop
? desktopNeedsUpdate
? updateService.getDownloadUrl(machineInfo, true)
: null
: updateService.getDownloadUrl(machineInfo);
return (
<Modal
@@ -504,7 +520,7 @@ const UpdateModal: React.FC<UpdateModalProps> = ({
component="a"
href={downloadUrl}
target="_blank"
color="green"
variant="filled"
leftSection={<DownloadIcon style={{ fontSize: 16 }} />}
>
{t("update.downloadLatest", "Download Latest")}
@@ -39,12 +39,16 @@ interface GeneralSectionProps {
hideTitle?: boolean;
hideUpdateSection?: boolean;
hideAdminBanner?: boolean;
desktopVersion?: string | null;
isDesktop?: boolean;
}
const GeneralSection: React.FC<GeneralSectionProps> = ({
hideTitle = false,
hideUpdateSection = false,
hideAdminBanner = false,
desktopVersion,
isDesktop = false,
}) => {
const { t } = useTranslation();
const { preferences, updatePreference } = usePreferences();
@@ -272,11 +276,25 @@ 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",
)}
{desktopVersion && (
<Text size="sm" c="dimmed">
{t(
"settings.general.versionInfo.desktop",
"Desktop Version",
)}
:{" "}
<Text component="span" fw={500}>
{desktopVersion}
</Text>
</Text>
)}
<Text size="sm" c="dimmed" mt={desktopVersion ? 4 : 0}>
{desktopVersion
? t("settings.general.versionInfo.server", "Server Version")
: t(
"settings.general.updates.currentBackendVersion",
"Current Backend Version",
)}
:{" "}
<Text component="span" fw={500}>
{config.appVersion}
@@ -294,6 +312,33 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
</Text>
</Text>
)}
{isDesktop &&
desktopVersion &&
updateSummary &&
(() => {
const desktopNeedsUpdate =
updateService.compareVersions(
updateSummary.latest_version || "0",
desktopVersion,
) > 0;
const serverNeedsUpdate =
updateService.compareVersions(
updateSummary.latest_version || "0",
config.appVersion || "0",
) > 0;
if (!desktopNeedsUpdate && serverNeedsUpdate) {
return (
<Text size="sm" c="orange" mt={4} fw={500}>
{t(
"settings.general.updates.serverNeedsUpdate",
"Server needs to be updated by administrator",
)}
</Text>
);
}
return null;
})()}
</div>
<Group gap="sm">
<Button
@@ -315,22 +360,68 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
)}
</Button>
{updateSummary && (
<Button
size="sm"
color={
updateSummary.max_priority === "urgent" ? "red" : "blue"
}
onClick={() => setUpdateModalOpened(true)}
leftSection={
<LocalIcon
icon="system-update-alt-rounded"
width="1rem"
height="1rem"
/>
}
>
{t("settings.general.updates.viewDetails", "View Details")}
</Button>
<>
<Button
size="sm"
color={
updateSummary.max_priority === "urgent" ? "red" : "blue"
}
onClick={() => setUpdateModalOpened(true)}
leftSection={
<LocalIcon
icon="system-update-alt-rounded"
width="1rem"
height="1rem"
/>
}
>
{t(
"settings.general.updates.viewDetails",
"View Details",
)}
</Button>
{isDesktop &&
desktopVersion &&
(() => {
const desktopNeedsUpdate =
updateService.compareVersions(
updateSummary.latest_version || "0",
desktopVersion,
) > 0;
if (!desktopNeedsUpdate) {
return null;
}
const downloadUrl = updateService.getDownloadUrl(
{
machineType: config.machineType || "Unknown",
activeSecurity: config.activeSecurity ?? false,
licenseType: config.license ?? "NORMAL",
},
true,
);
return downloadUrl ? (
<Button
size="sm"
variant="filled"
component="a"
href={downloadUrl}
target="_blank"
leftSection={
<LocalIcon
icon="download-rounded"
width="1rem"
height="1rem"
/>
}
>
{t("update.downloadLatest", "Download Latest")}
</Button>
) : null;
})()}
</>
)}
</Group>
</Group>
@@ -1,8 +1,18 @@
// Centralized download URLs for Stirling PDF desktop installers
export const DOWNLOAD_URLS = {
WINDOWS: "https://files.stirlingpdf.com/win-installer.exe",
MAC: "https://files.stirlingpdf.com/mac-installer.dmg",
LINUX_DOCS: "https://docs.stirlingpdf.com/Installation/Unix%20Installation/",
// Centralized download URLs for Stirling PDF desktop installers.
// File names match the GitHub release artifacts so files.stirlingpdf.com can
// alias them 1:1.
export const DOWNLOAD_BASE_URL = "https://files.stirlingpdf.com/";
export const DESKTOP_INSTALLER_FILES = {
// Universal Mac binary - works on both Intel and Apple silicon, so no arch
// detection is needed on the client.
MAC: "Stirling-PDF-macos-universal.dmg",
WINDOWS: "Stirling-PDF-windows-x86_64.msi",
LINUX_DEB: "Stirling-PDF-linux-x86_64.deb",
} as const;
export const DOWNLOAD_BASE_URL = "https://files.stirlingpdf.com/";
export const DOWNLOAD_URLS = {
WINDOWS: DOWNLOAD_BASE_URL + DESKTOP_INSTALLER_FILES.WINDOWS,
MAC: DOWNLOAD_BASE_URL + DESKTOP_INSTALLER_FILES.MAC,
LINUX_DOCS: "https://docs.stirlingpdf.com/Installation/Unix%20Installation/",
} as const;
@@ -0,0 +1,76 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { updateService } from "@app/services/updateService";
import { DOWNLOAD_URLS } from "@app/constants/downloads";
type MachineInfo = Parameters<typeof updateService.getDownloadUrl>[0];
const stubMachineInfo: MachineInfo = {
machineType: "Server-jar",
activeSecurity: false,
licenseType: "FREE",
};
const setUserAgent = (value: string) => {
Object.defineProperty(window.navigator, "userAgent", {
value,
configurable: true,
});
};
describe("updateService.getDownloadUrl (desktop)", () => {
const original = window.navigator.userAgent;
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
setUserAgent(original);
});
it("returns the universal Mac installer for Apple-silicon Macs", () => {
setUserAgent(
"Mozilla/5.0 (Macintosh; ARM Mac OS X 14_0) AppleWebKit/605.1.15",
);
expect(updateService.getDownloadUrl(stubMachineInfo, true)).toBe(
DOWNLOAD_URLS.MAC,
);
});
it("returns the universal Mac installer for Intel Macs", () => {
setUserAgent(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15",
);
expect(updateService.getDownloadUrl(stubMachineInfo, true)).toBe(
DOWNLOAD_URLS.MAC,
);
});
it("returns the universal Mac URL even when the UA misreports Apple-silicon as Intel", () => {
setUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)");
const url = updateService.getDownloadUrl(stubMachineInfo, true);
expect(url).toBe(DOWNLOAD_URLS.MAC);
expect(url).toContain("Stirling-PDF-macos-universal.dmg");
});
it("returns the Windows installer for Windows UAs", () => {
setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64)");
expect(updateService.getDownloadUrl(stubMachineInfo, true)).toBe(
DOWNLOAD_URLS.WINDOWS,
);
});
it("returns a .deb URL for Linux UAs", () => {
setUserAgent("Mozilla/5.0 (X11; Linux x86_64)");
expect(updateService.getDownloadUrl(stubMachineInfo, true)).toContain(
"Stirling-PDF-linux-x86_64.deb",
);
});
it("falls back to the GitHub releases page for unknown platforms", () => {
setUserAgent("Mozilla/5.0 (Unknown OS)");
expect(updateService.getDownloadUrl(stubMachineInfo, true)).toBe(
"https://github.com/Stirling-Tools/Stirling-PDF/releases/latest",
);
});
});
@@ -1,4 +1,8 @@
import { DOWNLOAD_BASE_URL } from "@app/constants/downloads";
import {
DESKTOP_INSTALLER_FILES,
DOWNLOAD_BASE_URL,
DOWNLOAD_URLS,
} from "@app/constants/downloads";
export interface UpdateSummary {
latest_version: string | null;
@@ -67,8 +71,27 @@ export class UpdateService {
/**
* Get download URL based on machine type and security settings
* If isDesktop is true, returns desktop installer URLs instead
*/
getDownloadUrl(machineInfo: MachineInfo): string | null {
getDownloadUrl(
machineInfo: MachineInfo,
isDesktop: boolean = false,
): string | null {
if (isDesktop) {
const userAgent = navigator.userAgent.toLowerCase();
if (userAgent.includes("win")) {
return DOWNLOAD_URLS.WINDOWS;
} else if (userAgent.includes("mac")) {
// Universal Mac binary covers both Intel and Apple silicon, so we
// don't need to detect arch (which is unreliable on the web anyway -
// Apple-silicon Macs often report Intel in the UA).
return DOWNLOAD_URLS.MAC;
} else if (userAgent.includes("linux")) {
return DOWNLOAD_BASE_URL + DESKTOP_INSTALLER_FILES.LINUX_DEB;
}
return "https://github.com/Stirling-Tools/Stirling-PDF/releases/latest";
}
// Only show download for non-Docker installations
if (
machineInfo.machineType === "Docker" ||
@@ -1,7 +1,8 @@
import React, { useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Box, Tooltip, useMantineTheme, rem } from "@mantine/core";
import { Box, Tooltip, useMantineTheme, rem, Stack, Text } from "@mantine/core";
import { useBackendHealth } from "@app/hooks/useBackendHealth";
import { useVersionInfo } from "@app/hooks/useVersionInfo";
interface BackendHealthIndicatorProps {
className?: string;
@@ -13,8 +14,9 @@ export const BackendHealthIndicator: React.FC<BackendHealthIndicatorProps> = ({
const { t } = useTranslation();
const theme = useMantineTheme();
const { status, isOnline, checkHealth } = useBackendHealth();
const { desktopVersion, serverVersion } = useVersionInfo();
const label = useMemo(() => {
const statusText = useMemo(() => {
if (status === "starting") {
return t("backendHealth.checking", "Checking backend status...");
}
@@ -26,6 +28,38 @@ export const BackendHealthIndicator: React.FC<BackendHealthIndicatorProps> = ({
return t("backendHealth.offline", "Backend Offline");
}, [status, isOnline, t]);
const versionLines = useMemo(() => {
const lines: string[] = [];
if (desktopVersion) {
lines.push(`Desktop: ${desktopVersion}`);
}
if (serverVersion) {
lines.push(`Server: ${serverVersion}`);
}
return lines;
}, [desktopVersion, serverVersion]);
const ariaLabel = useMemo(() => {
if (versionLines.length === 0) {
return statusText;
}
return `${statusText} (${versionLines.join(", ")})`;
}, [statusText, versionLines]);
const label =
versionLines.length > 0 ? (
<Stack gap={4}>
<Text size="sm">{statusText}</Text>
{versionLines.map((line, idx) => (
<Text key={idx} size="xs" c="dimmed">
{line}
</Text>
))}
</Stack>
) : (
statusText
);
const dotColor = useMemo(() => {
if (status === "starting") {
return theme.colors.yellow?.[5] ?? "#fcc419";
@@ -59,7 +93,7 @@ export const BackendHealthIndicator: React.FC<BackendHealthIndicatorProps> = ({
className={className ? `${className}` : undefined}
role="status"
aria-live="polite"
aria-label={label}
aria-label={ariaLabel}
tabIndex={0}
onClick={checkHealth}
onKeyDown={handleKeyDown}
@@ -0,0 +1,13 @@
import React from "react";
import CoreUpdateModal from "@core/components/shared/UpdateModal";
import type { UpdateModalProps } from "@core/components/shared/UpdateModal";
import { useVersionInfo } from "@app/hooks/useVersionInfo";
const UpdateModal: React.FC<UpdateModalProps> = (props) => {
const { desktopVersion } = useVersionInfo();
return (
<CoreUpdateModal {...props} isDesktop desktopVersion={desktopVersion} />
);
};
export default UpdateModal;
@@ -2,15 +2,15 @@ import React from "react";
import { Stack } from "@mantine/core";
import CoreGeneralSection from "@core/components/shared/config/configSections/GeneralSection";
import { DefaultAppSettings } from "@app/components/shared/config/configSections/DefaultAppSettings";
import { useVersionInfo } from "@app/hooks/useVersionInfo";
/**
* Desktop extension of GeneralSection that adds default PDF editor settings
*/
const GeneralSection: React.FC = () => {
const { desktopVersion } = useVersionInfo();
return (
<Stack gap="lg">
<DefaultAppSettings />
<CoreGeneralSection />
<CoreGeneralSection hideTitle desktopVersion={desktopVersion} isDesktop />
</Stack>
);
};
@@ -0,0 +1,41 @@
import { useState, useEffect } from "react";
import { getVersion } from "@tauri-apps/api/app";
import { useAppConfig } from "@app/contexts/AppConfigContext";
export function useVersionInfo() {
const { config } = useAppConfig();
const [desktopVersion, setDesktopVersion] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
const fetchDesktopVersion = async () => {
try {
const version = await getVersion();
if (!cancelled) {
setDesktopVersion(version);
}
} catch (error) {
console.error(
"[useVersionInfo] Failed to fetch desktop version:",
error,
);
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
void fetchDesktopVersion();
return () => {
cancelled = true;
};
}, []);
return {
desktopVersion,
serverVersion: config?.appVersion ?? null,
loading: loading || !config,
};
}