feat(desktop): add automatic update preference (#5380)

This commit is contained in:
Jiayuan Zhang
2026-07-14 15:45:39 +08:00
committed by GitHub
parent c27919a4d0
commit 2d13b26fcc
13 changed files with 475 additions and 65 deletions

View 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,
});
});
});

View 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);
}

View File

@@ -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", () => {

View File

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

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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",
});
});
});

View File

@@ -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>

View 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 };

View File

@@ -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",

View File

@@ -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": "今すぐ確認",

View File

@@ -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": "지금 확인",

View File

@@ -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": "立即检查",