From 2d13b26fcc17b8a4e8139cbe038c5f19c9e5a663 Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Tue, 14 Jul 2026 15:45:39 +0800 Subject: [PATCH] feat(desktop): add automatic update preference (#5380) --- .../src/main/updater-preferences.test.ts | 51 +++++++ apps/desktop/src/main/updater-preferences.ts | 44 ++++++ apps/desktop/src/main/updater.test.ts | 73 ++++++++++ apps/desktop/src/main/updater.ts | 81 +++++++++--- apps/desktop/src/preload/index.d.ts | 11 +- apps/desktop/src/preload/index.ts | 14 +- .../components/updates-settings-tab.test.tsx | 101 ++++++++++++++ .../src/components/updates-settings-tab.tsx | 125 +++++++++++++----- apps/desktop/src/shared/updater-types.ts | 12 ++ packages/views/locales/en/settings.json | 7 +- packages/views/locales/ja/settings.json | 7 +- packages/views/locales/ko/settings.json | 7 +- packages/views/locales/zh-Hans/settings.json | 7 +- 13 files changed, 475 insertions(+), 65 deletions(-) create mode 100644 apps/desktop/src/main/updater-preferences.test.ts create mode 100644 apps/desktop/src/main/updater-preferences.ts create mode 100644 apps/desktop/src/renderer/src/components/updates-settings-tab.test.tsx create mode 100644 apps/desktop/src/shared/updater-types.ts diff --git a/apps/desktop/src/main/updater-preferences.test.ts b/apps/desktop/src/main/updater-preferences.test.ts new file mode 100644 index 000000000..cc5106f2b --- /dev/null +++ b/apps/desktop/src/main/updater-preferences.test.ts @@ -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 { + 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, + }); + }); +}); diff --git a/apps/desktop/src/main/updater-preferences.ts b/apps/desktop/src/main/updater-preferences.ts new file mode 100644 index 000000000..f4977ac38 --- /dev/null +++ b/apps/desktop/src/main/updater-preferences.ts @@ -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 { + try { + return parseUpdaterPreferences(JSON.parse(await readFile(filePath, "utf-8"))); + } catch { + return { ...DEFAULT_UPDATER_PREFERENCES }; + } +} + +export async function saveUpdaterPreferences( + filePath: string, + preferences: UpdaterPreferences, +): Promise { + await mkdir(dirname(filePath), { recursive: true }); + const temporaryPath = `${filePath}.tmp`; + await writeFile(temporaryPath, JSON.stringify(preferences, null, 2), "utf-8"); + await rename(temporaryPath, filePath); +} diff --git a/apps/desktop/src/main/updater.test.ts b/apps/desktop/src/main/updater.test.ts index 314e5b5cf..e62ee9a09 100644 --- a/apps/desktop/src/main/updater.test.ts +++ b/apps/desktop/src/main/updater.test.ts @@ -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(), + ipcHandlers: new Map(), 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", () => { diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts index d622a6af3..2d9caa0f5 100644 --- a/apps/desktop/src/main/updater.ts +++ b/apps/desktop/src/main/updater.ts @@ -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 { } 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 => { + await preferencesReady; + return { automaticUpdates: automaticUpdatesEnabled }; + }, + ); + + ipcMain.handle( + "updater:set-automatic-updates", + async (_event, enabled: unknown): Promise => { + 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 => { 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); } diff --git a/apps/desktop/src/preload/index.d.ts b/apps/desktop/src/preload/index.d.ts index cf0efac86..a5c6253cb 100644 --- a/apps/desktop/src/preload/index.d.ts +++ b/apps/desktop/src/preload/index.d.ts @@ -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; installUpdate: () => Promise; - checkForUpdates: () => Promise< - | { ok: true; currentVersion: string; latestVersion: string; available: boolean } - | { ok: false; error: string } - >; + getPreferences: () => Promise; + setAutomaticUpdates: (enabled: boolean) => Promise; + checkForUpdates: () => Promise; } declare global { diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 23c7e3bd5..4e8ecf874 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -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 => + ipcRenderer.invoke("updater:get-preferences"), + setAutomaticUpdates: (enabled: boolean): Promise => + ipcRenderer.invoke("updater:set-automatic-updates", enabled), + checkForUpdates: (): Promise => + ipcRenderer.invoke("updater:check"), }; if (process.contextIsolated) { diff --git a/apps/desktop/src/renderer/src/components/updates-settings-tab.test.tsx b/apps/desktop/src/renderer/src/components/updates-settings-tab.test.tsx new file mode 100644 index 000000000..630f72026 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/updates-settings-tab.test.tsx @@ -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, + ) => { + 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(); + + 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", + }); + }); +}); diff --git a/apps/desktop/src/renderer/src/components/updates-settings-tab.tsx b/apps/desktop/src/renderer/src/components/updates-settings-tab.tsx index 5175813e4..da606d9f3 100644 --- a/apps/desktop/src/renderer/src/components/updates-settings-tab.tsx +++ b/apps/desktop/src/renderer/src/components/updates-settings-tab.tsx @@ -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({ 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() { + $.desktop.updates.automatic_updates_title)} + description={t(($) => $.desktop.updates.automatic_updates_description)} + > + $.desktop.updates.automatic_updates_title)} + /> + + $.desktop.updates.check_section_title)} align="start" description={ <>

{t(($) => $.desktop.updates.check_section_description)}

- {state.status === "up-to-date" && ( -

- - {t(($) => $.desktop.updates.up_to_date)} -

- )} - {state.status === "available" && ( -

- - {t(($) => $.desktop.updates.downloading, { version: state.latestVersion })} -

- )} - {state.status === "error" && ( -

- - {state.message} -

- )} + {state.status === "up-to-date" && ( +

+ + {t(($) => $.desktop.updates.up_to_date)} +

+ )} + {state.status === "available" && ( +

+ + {t(($) => $.desktop.updates.downloading, { + version: state.latestVersion, + })} +

+ )} + {state.status === "error" && ( +

+ + {state.message} +

+ )} } > - +
diff --git a/apps/desktop/src/shared/updater-types.ts b/apps/desktop/src/shared/updater-types.ts new file mode 100644 index 000000000..3c6d28b93 --- /dev/null +++ b/apps/desktop/src/shared/updater-types.ts @@ -0,0 +1,12 @@ +export interface UpdaterPreferences { + automaticUpdates: boolean; +} + +export type ManualUpdateCheckResult = + | { + ok: true; + currentVersion: string; + latestVersion: string; + available: boolean; + } + | { ok: false; error: string }; diff --git a/packages/views/locales/en/settings.json b/packages/views/locales/en/settings.json index 85dba20fb..8f922bb9a 100644 --- a/packages/views/locales/en/settings.json +++ b/packages/views/locales/en/settings.json @@ -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", diff --git a/packages/views/locales/ja/settings.json b/packages/views/locales/ja/settings.json index 4d31b2c5b..c8565ed21 100644 --- a/packages/views/locales/ja/settings.json +++ b/packages/views/locales/ja/settings.json @@ -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": "今すぐ確認", diff --git a/packages/views/locales/ko/settings.json b/packages/views/locales/ko/settings.json index 94b7e50f4..76ba30ec4 100644 --- a/packages/views/locales/ko/settings.json +++ b/packages/views/locales/ko/settings.json @@ -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": "지금 확인", diff --git a/packages/views/locales/zh-Hans/settings.json b/packages/views/locales/zh-Hans/settings.json index cd5ee1a1e..e59b3866e 100644 --- a/packages/views/locales/zh-Hans/settings.json +++ b/packages/views/locales/zh-Hans/settings.json @@ -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": "立即检查",