mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 04:25:46 +02:00
feat(desktop): add automatic update preference (#5380)
This commit is contained in:
51
apps/desktop/src/main/updater-preferences.test.ts
Normal file
51
apps/desktop/src/main/updater-preferences.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
loadUpdaterPreferences,
|
||||
saveUpdaterPreferences,
|
||||
updaterPreferencesPath,
|
||||
} from "./updater-preferences";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function makePreferencesPath(): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), "multica-updater-preferences-"));
|
||||
tempDirs.push(dir);
|
||||
return updaterPreferencesPath(dir);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe("updater preferences", () => {
|
||||
it("defaults automatic updates to enabled when the file is missing or invalid", async () => {
|
||||
const missingPath = await makePreferencesPath();
|
||||
const invalidPath = await makePreferencesPath();
|
||||
await writeFile(invalidPath, JSON.stringify({ automaticUpdates: "false" }));
|
||||
|
||||
await expect(loadUpdaterPreferences(missingPath)).resolves.toEqual({
|
||||
automaticUpdates: true,
|
||||
});
|
||||
await expect(loadUpdaterPreferences(invalidPath)).resolves.toEqual({
|
||||
automaticUpdates: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips a disabled automatic update preference", async () => {
|
||||
const filePath = await makePreferencesPath();
|
||||
|
||||
await saveUpdaterPreferences(filePath, { automaticUpdates: false });
|
||||
|
||||
await expect(loadUpdaterPreferences(filePath)).resolves.toEqual({
|
||||
automaticUpdates: false,
|
||||
});
|
||||
expect(JSON.parse(await readFile(filePath, "utf-8"))).toEqual({
|
||||
automaticUpdates: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
44
apps/desktop/src/main/updater-preferences.ts
Normal file
44
apps/desktop/src/main/updater-preferences.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import type { UpdaterPreferences } from "../shared/updater-types";
|
||||
|
||||
export const DEFAULT_UPDATER_PREFERENCES: UpdaterPreferences = {
|
||||
automaticUpdates: true,
|
||||
};
|
||||
|
||||
export function updaterPreferencesPath(userDataPath: string): string {
|
||||
return join(userDataPath, "updater-preferences.json");
|
||||
}
|
||||
|
||||
function parseUpdaterPreferences(value: unknown): UpdaterPreferences {
|
||||
const candidate = value as { automaticUpdates?: unknown } | null;
|
||||
if (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
typeof candidate?.automaticUpdates === "boolean"
|
||||
) {
|
||||
return { automaticUpdates: candidate.automaticUpdates };
|
||||
}
|
||||
|
||||
return { ...DEFAULT_UPDATER_PREFERENCES };
|
||||
}
|
||||
|
||||
export async function loadUpdaterPreferences(
|
||||
filePath: string,
|
||||
): Promise<UpdaterPreferences> {
|
||||
try {
|
||||
return parseUpdaterPreferences(JSON.parse(await readFile(filePath, "utf-8")));
|
||||
} catch {
|
||||
return { ...DEFAULT_UPDATER_PREFERENCES };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveUpdaterPreferences(
|
||||
filePath: string,
|
||||
preferences: UpdaterPreferences,
|
||||
): Promise<void> {
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
const temporaryPath = `${filePath}.tmp`;
|
||||
await writeFile(temporaryPath, JSON.stringify(preferences, null, 2), "utf-8");
|
||||
await rename(temporaryPath, filePath);
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { BrowserWindow, WebContents } from "electron";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
type Handler = (...args: unknown[]) => void;
|
||||
type IpcHandler = (...args: unknown[]) => unknown;
|
||||
|
||||
const ctx = vi.hoisted(() => ({
|
||||
handlers: new Map<string, Handler[]>(),
|
||||
ipcHandlers: new Map<string, IpcHandler>(),
|
||||
ipcHandle: vi.fn(),
|
||||
checkForUpdates: vi.fn(async () => ({
|
||||
updateInfo: { version: "0.3.18" },
|
||||
@@ -13,6 +18,7 @@ const ctx = vi.hoisted(() => ({
|
||||
downloadUpdate: vi.fn(),
|
||||
quitAndInstall: vi.fn(),
|
||||
getVersion: vi.fn(() => "0.3.17"),
|
||||
userDataPath: "",
|
||||
}));
|
||||
|
||||
vi.mock("electron-updater", () => {
|
||||
@@ -36,6 +42,7 @@ vi.mock("electron-updater", () => {
|
||||
vi.mock("electron", () => ({
|
||||
app: {
|
||||
getVersion: ctx.getVersion,
|
||||
getPath: vi.fn(() => ctx.userDataPath),
|
||||
},
|
||||
BrowserWindow: class BrowserWindow {},
|
||||
ipcMain: {
|
||||
@@ -44,6 +51,7 @@ vi.mock("electron", () => ({
|
||||
}));
|
||||
|
||||
import { setupAutoUpdater } from "./updater";
|
||||
import { updaterPreferencesPath } from "./updater-preferences";
|
||||
|
||||
function emitUpdater(event: string, ...args: unknown[]) {
|
||||
for (const handler of ctx.handlers.get(event) ?? []) {
|
||||
@@ -51,6 +59,12 @@ function emitUpdater(event: string, ...args: unknown[]) {
|
||||
}
|
||||
}
|
||||
|
||||
async function invokeIpc(channel: string, ...args: unknown[]) {
|
||||
const handler = ctx.ipcHandlers.get(channel);
|
||||
if (!handler) throw new Error(`Missing IPC handler: ${channel}`);
|
||||
return handler({}, ...args);
|
||||
}
|
||||
|
||||
function makeWindow() {
|
||||
const send = vi.fn();
|
||||
return {
|
||||
@@ -109,8 +123,13 @@ function makeWindowWithThrowingSend(error: Error) {
|
||||
describe("setupAutoUpdater", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
ctx.userDataPath = mkdtempSync(join(tmpdir(), "multica-updater-test-"));
|
||||
ctx.handlers.clear();
|
||||
ctx.ipcHandlers.clear();
|
||||
ctx.ipcHandle.mockClear();
|
||||
ctx.ipcHandle.mockImplementation((channel: string, handler: IpcHandler) => {
|
||||
ctx.ipcHandlers.set(channel, handler);
|
||||
});
|
||||
ctx.checkForUpdates.mockClear();
|
||||
ctx.downloadUpdate.mockClear();
|
||||
ctx.quitAndInstall.mockClear();
|
||||
@@ -120,6 +139,60 @@ describe("setupAutoUpdater", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
rmSync(ctx.userDataPath, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("enables automatic background updates by default", async () => {
|
||||
setupAutoUpdater(() => null);
|
||||
|
||||
await expect(invokeIpc("updater:get-preferences")).resolves.toEqual({
|
||||
automaticUpdates: true,
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(ctx.checkForUpdates).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips startup and periodic checks when automatic updates are disabled", async () => {
|
||||
writeFileSync(
|
||||
updaterPreferencesPath(ctx.userDataPath),
|
||||
JSON.stringify({ automaticUpdates: false }),
|
||||
);
|
||||
setupAutoUpdater(() => null);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60 * 60 * 1000 + 5_000);
|
||||
|
||||
expect(ctx.checkForUpdates).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists the automatic update preference and stops future background checks", async () => {
|
||||
setupAutoUpdater(() => null);
|
||||
|
||||
await expect(
|
||||
invokeIpc("updater:set-automatic-updates", false),
|
||||
).resolves.toEqual({ automaticUpdates: false });
|
||||
expect(
|
||||
JSON.parse(
|
||||
readFileSync(updaterPreferencesPath(ctx.userDataPath), "utf-8"),
|
||||
),
|
||||
).toEqual({ automaticUpdates: false });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60 * 60 * 1000 + 5_000);
|
||||
expect(ctx.checkForUpdates).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still allows an explicit manual check when automatic updates are disabled", async () => {
|
||||
writeFileSync(
|
||||
updaterPreferencesPath(ctx.userDataPath),
|
||||
JSON.stringify({ automaticUpdates: false }),
|
||||
);
|
||||
setupAutoUpdater(() => null);
|
||||
|
||||
await expect(invokeIpc("updater:check")).resolves.toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
|
||||
expect(ctx.checkForUpdates).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("forwards update progress to a live renderer", () => {
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { autoUpdater, type UpdateDownloadedEvent } from "electron-updater";
|
||||
import { app, type BrowserWindow, ipcMain } from "electron";
|
||||
import type {
|
||||
ManualUpdateCheckResult,
|
||||
UpdaterPreferences,
|
||||
} from "../shared/updater-types";
|
||||
import {
|
||||
DEFAULT_UPDATER_PREFERENCES,
|
||||
loadUpdaterPreferences,
|
||||
saveUpdaterPreferences,
|
||||
updaterPreferencesPath,
|
||||
} from "./updater-preferences";
|
||||
|
||||
// Silent background updates: electron-updater downloads on its own as soon
|
||||
// as `update-available` fires; we only surface UI when the package is fully
|
||||
@@ -20,15 +30,6 @@ if (process.platform === "win32" && process.arch === "arm64") {
|
||||
const STARTUP_CHECK_DELAY_MS = 5_000;
|
||||
const PERIODIC_CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
export type ManualUpdateCheckResult =
|
||||
| {
|
||||
ok: true;
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
available: boolean;
|
||||
}
|
||||
| { ok: false; error: string };
|
||||
|
||||
type RendererChannel =
|
||||
| "updater:update-available"
|
||||
| "updater:download-progress"
|
||||
@@ -85,6 +86,28 @@ function checkForUpdatesOnce(): Promise<unknown> {
|
||||
}
|
||||
|
||||
export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): void {
|
||||
const preferencesFilePath = updaterPreferencesPath(app.getPath("userData"));
|
||||
let automaticUpdatesEnabled =
|
||||
DEFAULT_UPDATER_PREFERENCES.automaticUpdates;
|
||||
let startupCheckElapsed = false;
|
||||
const preferencesReady = loadUpdaterPreferences(preferencesFilePath).then(
|
||||
(preferences) => {
|
||||
automaticUpdatesEnabled = preferences.automaticUpdates;
|
||||
return preferences;
|
||||
},
|
||||
);
|
||||
|
||||
const runAutomaticCheck = (errorMessage: string): void => {
|
||||
void preferencesReady
|
||||
.then(() => {
|
||||
if (!automaticUpdatesEnabled) return;
|
||||
return checkForUpdatesOnce();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(errorMessage, err);
|
||||
});
|
||||
};
|
||||
|
||||
autoUpdater.on("update-available", (info) => {
|
||||
// Forwarded for renderer-side state tracking only; the notification UI
|
||||
// does not render an "available" affordance with autoDownload=true.
|
||||
@@ -121,6 +144,37 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
"updater:get-preferences",
|
||||
async (): Promise<UpdaterPreferences> => {
|
||||
await preferencesReady;
|
||||
return { automaticUpdates: automaticUpdatesEnabled };
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
"updater:set-automatic-updates",
|
||||
async (_event, enabled: unknown): Promise<UpdaterPreferences> => {
|
||||
if (typeof enabled !== "boolean") {
|
||||
throw new TypeError("automaticUpdates must be a boolean");
|
||||
}
|
||||
|
||||
await preferencesReady;
|
||||
const wasEnabled = automaticUpdatesEnabled;
|
||||
const preferences = { automaticUpdates: enabled };
|
||||
await saveUpdaterPreferences(preferencesFilePath, preferences);
|
||||
automaticUpdatesEnabled = enabled;
|
||||
|
||||
// If the startup check has already passed while the preference was off,
|
||||
// enabling it should take effect now instead of waiting up to one hour.
|
||||
if (enabled && !wasEnabled && startupCheckElapsed) {
|
||||
runAutomaticCheck("Failed to check for updates:");
|
||||
}
|
||||
|
||||
return preferences;
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle("updater:check", async (): Promise<ManualUpdateCheckResult> => {
|
||||
try {
|
||||
const result = (await checkForUpdatesOnce()) as
|
||||
@@ -149,16 +203,13 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
|
||||
|
||||
// Initial check shortly after startup so we don't block boot.
|
||||
setTimeout(() => {
|
||||
checkForUpdatesOnce().catch((err) => {
|
||||
console.error("Failed to check for updates:", err);
|
||||
});
|
||||
startupCheckElapsed = true;
|
||||
runAutomaticCheck("Failed to check for updates:");
|
||||
}, STARTUP_CHECK_DELAY_MS);
|
||||
|
||||
// Background poll so long-running sessions still pick up new releases
|
||||
// without requiring the user to restart the app.
|
||||
setInterval(() => {
|
||||
checkForUpdatesOnce().catch((err) => {
|
||||
console.error("Periodic update check failed:", err);
|
||||
});
|
||||
runAutomaticCheck("Periodic update check failed:");
|
||||
}, PERIODIC_CHECK_INTERVAL_MS);
|
||||
}
|
||||
|
||||
11
apps/desktop/src/preload/index.d.ts
vendored
11
apps/desktop/src/preload/index.d.ts
vendored
@@ -3,6 +3,10 @@ import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
import type { NavigationGesture } from "../shared/navigation-gestures";
|
||||
import type { RendererRouteContextInput } from "../shared/renderer-route-context";
|
||||
import type { FreezeBreadcrumb } from "../shared/freeze-breadcrumb";
|
||||
import type {
|
||||
ManualUpdateCheckResult,
|
||||
UpdaterPreferences,
|
||||
} from "../shared/updater-types";
|
||||
|
||||
interface DesktopAPI {
|
||||
/** App version + normalized OS, captured synchronously at preload time. */
|
||||
@@ -147,10 +151,9 @@ interface UpdaterAPI {
|
||||
) => () => void;
|
||||
downloadUpdate: () => Promise<void>;
|
||||
installUpdate: () => Promise<void>;
|
||||
checkForUpdates: () => Promise<
|
||||
| { ok: true; currentVersion: string; latestVersion: string; available: boolean }
|
||||
| { ok: false; error: string }
|
||||
>;
|
||||
getPreferences: () => Promise<UpdaterPreferences>;
|
||||
setAutomaticUpdates: (enabled: boolean) => Promise<UpdaterPreferences>;
|
||||
checkForUpdates: () => Promise<ManualUpdateCheckResult>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -2,6 +2,10 @@ import { contextBridge, ipcRenderer } from "electron";
|
||||
import { electronAPI } from "@electron-toolkit/preload";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
import type { FreezeBreadcrumb } from "../shared/freeze-breadcrumb";
|
||||
import type {
|
||||
ManualUpdateCheckResult,
|
||||
UpdaterPreferences,
|
||||
} from "../shared/updater-types";
|
||||
import {
|
||||
RENDERER_ROUTE_CONTEXT_CHANNEL,
|
||||
type RendererRouteContextInput,
|
||||
@@ -287,10 +291,12 @@ const updaterAPI = {
|
||||
},
|
||||
downloadUpdate: () => ipcRenderer.invoke("updater:download"),
|
||||
installUpdate: () => ipcRenderer.invoke("updater:install"),
|
||||
checkForUpdates: (): Promise<
|
||||
| { ok: true; currentVersion: string; latestVersion: string; available: boolean }
|
||||
| { ok: false; error: string }
|
||||
> => ipcRenderer.invoke("updater:check"),
|
||||
getPreferences: (): Promise<UpdaterPreferences> =>
|
||||
ipcRenderer.invoke("updater:get-preferences"),
|
||||
setAutomaticUpdates: (enabled: boolean): Promise<UpdaterPreferences> =>
|
||||
ipcRenderer.invoke("updater:set-automatic-updates", enabled),
|
||||
checkForUpdates: (): Promise<ManualUpdateCheckResult> =>
|
||||
ipcRenderer.invoke("updater:check"),
|
||||
};
|
||||
|
||||
if (process.contextIsolated) {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getPreferences: vi.fn(),
|
||||
setAutomaticUpdates: vi.fn(),
|
||||
checkForUpdates: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
}));
|
||||
|
||||
const translations = {
|
||||
auto_save: { toast_saved: "Settings saved" },
|
||||
desktop: {
|
||||
updates: {
|
||||
title: "Updates",
|
||||
description: "Update preferences",
|
||||
current_version: "Current version",
|
||||
automatic_updates_title: "Automatic background updates",
|
||||
automatic_updates_description: "Download updates in the background",
|
||||
automatic_updates_save_failed: "Failed to save update settings",
|
||||
check_section_title: "Check for updates",
|
||||
check_section_description: "Check manually",
|
||||
up_to_date: "Up to date",
|
||||
downloading: "Downloading v{{version}}",
|
||||
check_now: "Check now",
|
||||
checking: "Checking",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("@multica/views/i18n", () => ({
|
||||
useT: () => ({
|
||||
t: (
|
||||
selector: (resources: typeof translations) => string,
|
||||
values?: Record<string, string>,
|
||||
) => {
|
||||
const template = selector(translations);
|
||||
return Object.entries(values ?? {}).reduce(
|
||||
(result, [key, value]) => result.replace(`{{${key}}}`, value),
|
||||
template,
|
||||
);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
success: mocks.toastSuccess,
|
||||
error: mocks.toastError,
|
||||
},
|
||||
}));
|
||||
|
||||
import { UpdatesSettingsTab } from "./updates-settings-tab";
|
||||
|
||||
describe("UpdatesSettingsTab", () => {
|
||||
beforeEach(() => {
|
||||
mocks.getPreferences.mockReset().mockResolvedValue({
|
||||
automaticUpdates: true,
|
||||
});
|
||||
mocks.setAutomaticUpdates.mockReset();
|
||||
mocks.checkForUpdates.mockReset();
|
||||
mocks.toastSuccess.mockReset();
|
||||
mocks.toastError.mockReset();
|
||||
|
||||
Object.defineProperty(window, "desktopAPI", {
|
||||
configurable: true,
|
||||
value: { appInfo: { version: "1.2.3" } },
|
||||
});
|
||||
Object.defineProperty(window, "updater", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getPreferences: mocks.getPreferences,
|
||||
setAutomaticUpdates: mocks.setAutomaticUpdates,
|
||||
checkForUpdates: mocks.checkForUpdates,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("loads the persisted preference and saves changes from the switch", async () => {
|
||||
mocks.getPreferences.mockResolvedValue({ automaticUpdates: false });
|
||||
mocks.setAutomaticUpdates.mockResolvedValue({ automaticUpdates: true });
|
||||
render(<UpdatesSettingsTab />);
|
||||
|
||||
const toggle = screen.getByRole("switch", {
|
||||
name: "Automatic background updates",
|
||||
});
|
||||
await waitFor(() => expect(toggle).toBeEnabled());
|
||||
expect(toggle).not.toBeChecked();
|
||||
|
||||
fireEvent.click(toggle);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.setAutomaticUpdates).toHaveBeenCalledWith(true);
|
||||
expect(toggle).toBeChecked();
|
||||
});
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith("Settings saved", {
|
||||
id: "settings-auto-save",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { AlertCircle, ArrowDownToLine, Check, Loader2 } from "lucide-react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Switch } from "@multica/ui/components/ui/switch";
|
||||
import { useT } from "@multica/views/i18n";
|
||||
import { SettingsCard, SettingsRow, SettingsTab } from "@multica/views/settings";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type CheckState =
|
||||
| { status: "idle" }
|
||||
@@ -14,8 +16,49 @@ type CheckState =
|
||||
export function UpdatesSettingsTab() {
|
||||
const { t } = useT("settings");
|
||||
const [state, setState] = useState<CheckState>({ status: "idle" });
|
||||
const [automaticUpdates, setAutomaticUpdates] = useState(true);
|
||||
const [preferencesReady, setPreferencesReady] = useState(false);
|
||||
const [savingPreference, setSavingPreference] = useState(false);
|
||||
const currentVersion = window.desktopAPI.appInfo.version;
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
void window.updater
|
||||
.getPreferences()
|
||||
.then((preferences) => {
|
||||
if (mounted) setAutomaticUpdates(preferences.automaticUpdates);
|
||||
})
|
||||
.catch(() => {
|
||||
// The main process falls back to enabled when preferences cannot be
|
||||
// read. Keep the same safe default if IPC itself becomes unavailable.
|
||||
})
|
||||
.finally(() => {
|
||||
if (mounted) setPreferencesReady(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleAutomaticUpdatesChange = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
setSavingPreference(true);
|
||||
try {
|
||||
const preferences = await window.updater.setAutomaticUpdates(enabled);
|
||||
setAutomaticUpdates(preferences.automaticUpdates);
|
||||
toast.success(t(($) => $.auto_save.toast_saved), {
|
||||
id: "settings-auto-save",
|
||||
});
|
||||
} catch {
|
||||
toast.error(t(($) => $.desktop.updates.automatic_updates_save_failed));
|
||||
} finally {
|
||||
setSavingPreference(false);
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleCheck = useCallback(async () => {
|
||||
setState({ status: "checking" });
|
||||
const result = await window.updater.checkForUpdates();
|
||||
@@ -42,48 +85,62 @@ export function UpdatesSettingsTab() {
|
||||
</span>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
label={t(($) => $.desktop.updates.automatic_updates_title)}
|
||||
description={t(($) => $.desktop.updates.automatic_updates_description)}
|
||||
>
|
||||
<Switch
|
||||
checked={automaticUpdates}
|
||||
onCheckedChange={handleAutomaticUpdatesChange}
|
||||
disabled={!preferencesReady || savingPreference}
|
||||
aria-label={t(($) => $.desktop.updates.automatic_updates_title)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
label={t(($) => $.desktop.updates.check_section_title)}
|
||||
align="start"
|
||||
description={
|
||||
<>
|
||||
<p>{t(($) => $.desktop.updates.check_section_description)}</p>
|
||||
{state.status === "up-to-date" && (
|
||||
<p className="mt-2 inline-flex items-center gap-1.5">
|
||||
<Check className="size-3.5 text-success" />
|
||||
{t(($) => $.desktop.updates.up_to_date)}
|
||||
</p>
|
||||
)}
|
||||
{state.status === "available" && (
|
||||
<p className="mt-2 inline-flex items-center gap-1.5">
|
||||
<ArrowDownToLine className="size-3.5 text-primary" />
|
||||
{t(($) => $.desktop.updates.downloading, { version: state.latestVersion })}
|
||||
</p>
|
||||
)}
|
||||
{state.status === "error" && (
|
||||
<p className="mt-2 inline-flex items-center gap-1.5 text-destructive">
|
||||
<AlertCircle className="size-3.5" />
|
||||
{state.message}
|
||||
</p>
|
||||
)}
|
||||
{state.status === "up-to-date" && (
|
||||
<p className="mt-2 inline-flex items-center gap-1.5">
|
||||
<Check className="size-3.5 text-success" />
|
||||
{t(($) => $.desktop.updates.up_to_date)}
|
||||
</p>
|
||||
)}
|
||||
{state.status === "available" && (
|
||||
<p className="mt-2 inline-flex items-center gap-1.5">
|
||||
<ArrowDownToLine className="size-3.5 text-primary" />
|
||||
{t(($) => $.desktop.updates.downloading, {
|
||||
version: state.latestVersion,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{state.status === "error" && (
|
||||
<p className="mt-2 inline-flex items-center gap-1.5 text-destructive">
|
||||
<AlertCircle className="size-3.5" />
|
||||
{state.message}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCheck}
|
||||
disabled={state.status === "checking"}
|
||||
>
|
||||
{state.status === "checking" ? (
|
||||
<>
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
{t(($) => $.desktop.updates.checking)}
|
||||
</>
|
||||
) : (
|
||||
t(($) => $.desktop.updates.check_now)
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCheck}
|
||||
disabled={state.status === "checking"}
|
||||
>
|
||||
{state.status === "checking" ? (
|
||||
<>
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
{t(($) => $.desktop.updates.checking)}
|
||||
</>
|
||||
) : (
|
||||
t(($) => $.desktop.updates.check_now)
|
||||
)}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
</SettingsCard>
|
||||
</SettingsTab>
|
||||
|
||||
12
apps/desktop/src/shared/updater-types.ts
Normal file
12
apps/desktop/src/shared/updater-types.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export interface UpdaterPreferences {
|
||||
automaticUpdates: boolean;
|
||||
}
|
||||
|
||||
export type ManualUpdateCheckResult =
|
||||
| {
|
||||
ok: true;
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
available: boolean;
|
||||
}
|
||||
| { ok: false; error: string };
|
||||
@@ -536,10 +536,13 @@
|
||||
},
|
||||
"updates": {
|
||||
"title": "Updates",
|
||||
"description": "The desktop app checks for new versions automatically once an hour and shortly after launch, downloading them in the background. You'll be prompted to restart once an update is ready.",
|
||||
"description": "Control automatic background updates or check for a new version manually.",
|
||||
"current_version": "Current version",
|
||||
"automatic_updates_title": "Automatic background updates",
|
||||
"automatic_updates_description": "Check shortly after launch and every hour, then download available updates in the background.",
|
||||
"automatic_updates_save_failed": "Failed to save automatic update settings",
|
||||
"check_section_title": "Check for updates",
|
||||
"check_section_description": "Trigger a check now instead of waiting for the next automatic poll. Available updates download in the background and show a restart prompt when ready.",
|
||||
"check_section_description": "Check now regardless of your automatic update preference. Available updates download in the background and show a restart prompt when ready.",
|
||||
"up_to_date": "You're on the latest version.",
|
||||
"downloading": "v{{version}} is downloading in the background — you'll be notified when it's ready to install.",
|
||||
"check_now": "Check now",
|
||||
|
||||
@@ -529,10 +529,13 @@
|
||||
},
|
||||
"updates": {
|
||||
"title": "アップデート",
|
||||
"description": "デスクトップアプリは 1 時間に 1 回、および起動直後に新しいバージョンを自動で確認し、バックグラウンドでダウンロードします。アップデートの準備が整うと、再起動を促すメッセージが表示されます。",
|
||||
"description": "バックグラウンドでの自動アップデートを設定するか、新しいバージョンを手動で確認します。",
|
||||
"current_version": "現在のバージョン",
|
||||
"automatic_updates_title": "バックグラウンドで自動アップデート",
|
||||
"automatic_updates_description": "有効にすると、起動直後と 1 時間ごとにアップデートを確認し、利用可能なバージョンをバックグラウンドでダウンロードします。",
|
||||
"automatic_updates_save_failed": "自動アップデート設定を保存できませんでした",
|
||||
"check_section_title": "アップデートを確認",
|
||||
"check_section_description": "次回の自動確認を待たずに、今すぐ確認します。利用可能なアップデートはバックグラウンドでダウンロードされ、準備が整うと再起動を促すメッセージが表示されます。",
|
||||
"check_section_description": "自動アップデートの設定にかかわらず、今すぐ確認します。利用可能なアップデートはバックグラウンドでダウンロードされ、準備が整うと再起動を促すメッセージが表示されます。",
|
||||
"up_to_date": "最新バージョンを使用しています。",
|
||||
"downloading": "v{{version}} をバックグラウンドでダウンロード中です。インストールの準備が整うとお知らせします。",
|
||||
"check_now": "今すぐ確認",
|
||||
|
||||
@@ -381,10 +381,13 @@
|
||||
},
|
||||
"updates": {
|
||||
"title": "업데이트",
|
||||
"description": "데스크톱 앱은 한 시간에 한 번, 그리고 실행 직후 새 버전을 자동으로 확인하고 백그라운드에서 다운로드합니다. 업데이트가 준비되면 다시 시작하라는 안내가 표시됩니다.",
|
||||
"description": "자동 백그라운드 업데이트를 설정하거나 새 버전을 직접 확인합니다.",
|
||||
"current_version": "현재 버전",
|
||||
"automatic_updates_title": "자동 백그라운드 업데이트",
|
||||
"automatic_updates_description": "사용하면 앱 실행 직후와 한 시간마다 업데이트를 확인하고 사용 가능한 버전을 백그라운드에서 다운로드합니다.",
|
||||
"automatic_updates_save_failed": "자동 업데이트 설정을 저장하지 못했습니다",
|
||||
"check_section_title": "업데이트 확인",
|
||||
"check_section_description": "다음 자동 확인을 기다리지 않고 지금 확인합니다. 사용 가능한 업데이트는 백그라운드에서 다운로드되며 준비되면 다시 시작 안내가 표시됩니다.",
|
||||
"check_section_description": "자동 업데이트 설정과 관계없이 지금 확인합니다. 사용 가능한 업데이트는 백그라운드에서 다운로드되며 준비되면 다시 시작 안내가 표시됩니다.",
|
||||
"up_to_date": "최신 버전입니다.",
|
||||
"downloading": "v{{version}}을(를) 백그라운드에서 다운로드 중입니다. 설치 준비가 끝나면 알림을 드립니다.",
|
||||
"check_now": "지금 확인",
|
||||
|
||||
@@ -536,10 +536,13 @@
|
||||
},
|
||||
"updates": {
|
||||
"title": "更新",
|
||||
"description": "桌面端每小时以及启动后不久会自动检查新版本,并在后台下载。更新就绪后会提示你重启。",
|
||||
"description": "控制后台自动更新,或手动检查新版本。",
|
||||
"current_version": "当前版本",
|
||||
"automatic_updates_title": "后台自动更新",
|
||||
"automatic_updates_description": "开启后,桌面端会在启动后和每小时检查更新,并在后台下载可用版本。",
|
||||
"automatic_updates_save_failed": "保存自动更新设置失败",
|
||||
"check_section_title": "检查更新",
|
||||
"check_section_description": "立即检查更新,而不是等待下一次自动轮询。可用的更新会在后台下载,就绪时弹出重启提示。",
|
||||
"check_section_description": "无论是否开启自动更新,都立即检查新版本。可用的更新会在后台下载,就绪时弹出重启提示。",
|
||||
"up_to_date": "已是最新版本。",
|
||||
"downloading": "v{{version}} 正在后台下载——准备好安装时会通知你。",
|
||||
"check_now": "立即检查",
|
||||
|
||||
Reference in New Issue
Block a user