From be8dccb036315674629671c5b0cac5b5ffab36d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E7=BA=A7?= =?UTF-8?q?=E5=96=9C=E6=AC=A2=E4=BD=A0=E7=9A=84=E8=BE=BE=E5=A6=AE=E5=A8=85?= <176143450+My-Denia@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:48:22 +0800 Subject: [PATCH 1/2] feat(update): add update settings and a background check --- electron/background-update.test.ts | 78 +++++++++++++ electron/background-update.ts | 74 +++++++++++++ electron/main.ts | 169 ++++++++++++++++++++++++++--- electron/update-settings.test.ts | 44 ++++++++ electron/update-settings.ts | 26 +++++ src/i18n/locales/ar/common.json | 5 + src/i18n/locales/en/common.json | 5 + src/i18n/locales/es/common.json | 5 + src/i18n/locales/fr/common.json | 5 + src/i18n/locales/it/common.json | 5 + src/i18n/locales/ja-JP/common.json | 5 + src/i18n/locales/ko-KR/common.json | 5 + src/i18n/locales/pt-BR/common.json | 5 + src/i18n/locales/ru/common.json | 5 + src/i18n/locales/tr/common.json | 5 + src/i18n/locales/vi/common.json | 5 + src/i18n/locales/zh-CN/common.json | 5 + src/i18n/locales/zh-TW/common.json | 5 + 18 files changed, 438 insertions(+), 18 deletions(-) create mode 100644 electron/background-update.test.ts create mode 100644 electron/background-update.ts create mode 100644 electron/update-settings.test.ts create mode 100644 electron/update-settings.ts diff --git a/electron/background-update.test.ts b/electron/background-update.test.ts new file mode 100644 index 000000000..5a86aa99d --- /dev/null +++ b/electron/background-update.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from "vitest"; +import { + planBackgroundUpdate, + runUnblockedDownloadAndInstall, + shouldQuitAndInstallAfterRestartPrompt, + shouldStartBackgroundUpdateTimer, +} from "./background-update"; + +describe("background update policy", () => { + it("starts no timer on a Store / non-owning channel", () => { + expect(shouldStartBackgroundUpdateTimer({ isPackaged: true, ownsItsUpdates: false })).toBe( + false, + ); + expect(shouldStartBackgroundUpdateTimer({ isPackaged: false, ownsItsUpdates: true })).toBe( + false, + ); + expect(shouldStartBackgroundUpdateTimer({ isPackaged: true, ownsItsUpdates: true })).toBe(true); + }); + + it("does not plan a current-version dialog on the background path", () => { + expect(planBackgroundUpdate({ outcome: { kind: "current" }, mode: "notify" })).toEqual({ + action: "none", + }); + expect( + planBackgroundUpdate({ outcome: { kind: "unsupported" }, mode: "download-and-install" }), + ).toEqual({ action: "none" }); + }); + + it("plans notify / download / download-and-install from an available update", () => { + const outcome = { kind: "downloaded" as const, version: "1.10.0" }; + expect(planBackgroundUpdate({ outcome, mode: "notify" })).toEqual({ + action: "notify-available", + version: "1.10.0", + }); + expect(planBackgroundUpdate({ outcome, mode: "download" })).toEqual({ + action: "download", + version: "1.10.0", + }); + expect(planBackgroundUpdate({ outcome, mode: "download-and-install" })).toEqual({ + action: "download-and-install", + version: "1.10.0", + }); + }); + + it("does not call quitAndInstall until Restart Now returns 0", async () => { + const install = vi.fn(); + const cancelled = await runUnblockedDownloadAndInstall({ + download: async () => ({ kind: "downloaded", version: "1.10.0" }), + blocked: () => null, + confirmRestart: async () => 1, + install, + }); + expect(cancelled).toEqual({ status: "cancelled" }); + expect(install).not.toHaveBeenCalled(); + expect(shouldQuitAndInstallAfterRestartPrompt(1)).toBe(false); + + const installed = await runUnblockedDownloadAndInstall({ + download: async () => ({ kind: "downloaded", version: "1.10.0" }), + blocked: () => null, + confirmRestart: async () => 0, + install, + }); + expect(installed).toEqual({ status: "installed" }); + expect(install).toHaveBeenCalledTimes(1); + expect(shouldQuitAndInstallAfterRestartPrompt(0)).toBe(true); + }); + + it("hands the download error back so the dialog can show its message", async () => { + const error = new Error("ECONNRESET mid-download"); + const failed = await runUnblockedDownloadAndInstall({ + download: async () => ({ kind: "failed", error }), + blocked: () => null, + confirmRestart: async () => 0, + install: vi.fn(), + }); + expect(failed).toEqual({ status: "failed", error }); + }); +}); diff --git a/electron/background-update.ts b/electron/background-update.ts new file mode 100644 index 000000000..b04119da9 --- /dev/null +++ b/electron/background-update.ts @@ -0,0 +1,74 @@ +import type { UpdateOutcome } from "./auto-updater"; + +/** How far a background-discovered update may go on its own. No mode ever + * flips `autoInstallOnAppQuit`: install is always an explicit + * `quitAndInstall` behind a restart prompt, because `window-all-closed` + * quits this app and the HUD is a window — install-on-quit would fire a + * ~243 MB installer when the user merely closed the HUD. */ +export type UpdateMode = "notify" | "download" | "download-and-install"; + +export const DEFAULT_UPDATE_MODE: UpdateMode = "notify"; +export const BACKGROUND_UPDATE_INTERVAL_MS = 24 * 60 * 60 * 1000; + +export function parseUpdateMode(raw: unknown): UpdateMode { + if (raw === "notify" || raw === "download" || raw === "download-and-install") return raw; + return DEFAULT_UPDATE_MODE; +} + +export function shouldStartBackgroundUpdateTimer(input: { + isPackaged: boolean; + ownsItsUpdates: boolean; +}): boolean { + return input.isPackaged && input.ownsItsUpdates; +} + +export type BackgroundUpdatePlan = + | { action: "none" } + | { action: "notify-available"; version: string } + | { action: "download"; version: string } + | { action: "download-and-install"; version: string }; + +/** Background path: never plan a "you are current" dialog. */ +export function planBackgroundUpdate(input: { + outcome: UpdateOutcome; + mode: UpdateMode; +}): BackgroundUpdatePlan { + if (input.outcome.kind !== "downloaded") return { action: "none" }; + switch (input.mode) { + case "notify": + return { action: "notify-available", version: input.outcome.version }; + case "download": + return { action: "download", version: input.outcome.version }; + case "download-and-install": + return { action: "download-and-install", version: input.outcome.version }; + } +} + +export function shouldQuitAndInstallAfterRestartPrompt(response: number): boolean { + return response === 0; +} + +/** The failure carries the download error: the dialog that reports it shows + * `error.message` as detail, and collapsing the outcome to a bare string + * here is exactly how that detail once got lost between the helper and the + * caller. */ +export type DownloadAndInstallResult = + | { status: "failed"; error: Error } + | { status: "blocked" } + | { status: "cancelled" } + | { status: "installed" }; + +export async function runUnblockedDownloadAndInstall(deps: { + download: () => Promise; + blocked: () => string | null; + confirmRestart: () => Promise; + install: () => Promise; +}): Promise { + const downloaded = await deps.download(); + if (downloaded.kind === "failed") return { status: "failed", error: downloaded.error }; + if (deps.blocked()) return { status: "blocked" }; + const choice = await deps.confirmRestart(); + if (!shouldQuitAndInstallAfterRestartPrompt(choice)) return { status: "cancelled" }; + await deps.install(); + return { status: "installed" }; +} diff --git a/electron/main.ts b/electron/main.ts index a85629bf3..ec961aead 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -30,6 +30,13 @@ import { installSelfUpdate, type UpdateOutcome, } from "./auto-updater"; +import { + BACKGROUND_UPDATE_INTERVAL_MS, + planBackgroundUpdate, + runUnblockedDownloadAndInstall, + shouldStartBackgroundUpdateTimer, + type UpdateMode, +} from "./background-update"; import { parseCliArgs } from "./cli/args"; import { runCli } from "./cli/cliMain"; import { isDiagnosticModeEnabled, mainLogBuffer } from "./diagnostics/main-log-buffer"; @@ -40,7 +47,12 @@ import { unregisterAllGlobalShortcuts, } from "./globalShortcut"; import { mainT, setMainLocale } from "./i18n"; -import { getInstallChannel, offersUpdateCheck, platformOwnsUpdates } from "./install-channel"; +import { + getInstallChannel, + offersUpdateCheck, + ownsItsUpdates, + platformOwnsUpdates, +} from "./install-channel"; import { exportDiagnosticFile, getSelectedDesktopSource, @@ -49,6 +61,7 @@ import { import { installMainProcessErrorGuards } from "./main-process-errors"; import { registerSttIpc, shutdownStt } from "./stt"; import { checkLatestRelease } from "./update-checker"; +import { loadUpdateMode, saveUpdateMode } from "./update-settings"; import { createCountdownOverlayWindow, createEditorWindow, @@ -578,29 +591,63 @@ function runSaveDiagnostics() { * and on Windows it physically cannot, because the capture helpers spawn from inside the * install directory and NSIS cannot overwrite a running .exe. */ let isRecording = false; +let currentUpdateMode: UpdateMode = "notify"; +let backgroundUpdateTimer: ReturnType | null = null; + +function showUpdateSettingsMenu(): boolean { + return app.isPackaged && ownsItsUpdates(getInstallChannel()); +} + +function persistUpdateMode(mode: UpdateMode) { + currentUpdateMode = mode; + saveUpdateMode(app.getPath("userData"), mode); + updateTrayMenu(isRecording); +} async function downloadAndInstall(latestVersion: string) { - const downloaded = await downloadSelfUpdate(); - if (downloaded.kind === "failed") { + const result = await runUnblockedDownloadAndInstall({ + download: downloadSelfUpdate, + blocked: () => + blockedFromInstalling({ + recording: isRecording, + inApplicationsFolder: + process.platform === "darwin" ? (app.isInApplicationsFolder?.() ?? true) : true, + platform: process.platform, + }), + confirmRestart: async () => { + const restart = await showMessageBox({ + type: "info", + title: PRODUCT_NAME, + message: mainT("common", "updates.readyToInstall", { latestVersion }), + buttons: [ + mainT("common", "actions.restartNow") || "Restart Now", + mainT("common", "actions.cancel") || "Cancel", + ], + defaultId: 0, + cancelId: 1, + }); + return restart.response; + }, + install: installSelfUpdate, + }); + if (result.status === "failed") { await showMessageBox({ type: "error", title: PRODUCT_NAME, // Not `updates.failed`: the CHECK succeeded — that is how we got here — and telling // the user we could not check for updates sends them looking in the wrong place. message: mainT("common", "updates.downloadFailed"), - detail: downloaded.error.message, + detail: result.error.message, }); return; } - - const blocked = blockedFromInstalling({ - recording: isRecording, - // macOS-only API; absent elsewhere, and irrelevant there. - inApplicationsFolder: - process.platform === "darwin" ? (app.isInApplicationsFolder?.() ?? true) : true, - platform: process.platform, - }); - if (blocked) { + if (result.status === "blocked") { + const blocked = blockedFromInstalling({ + recording: isRecording, + inApplicationsFolder: + process.platform === "darwin" ? (app.isInApplicationsFolder?.() ?? true) : true, + platform: process.platform, + }); await showMessageBox({ type: "info", title: PRODUCT_NAME, @@ -609,21 +656,78 @@ async function downloadAndInstall(latestVersion: string) { blocked === "recording" ? "updates.blockedRecording" : "updates.blockedLocation", ), }); - return; } +} - const restart = await showMessageBox({ +async function presentAvailableUpdate(latestVersion: string) { + const choice = await showMessageBox({ type: "info", title: PRODUCT_NAME, - message: mainT("common", "updates.readyToInstall", { latestVersion }), + message: mainT("common", "updates.available", { + currentVersion: app.getVersion(), + latestVersion, + }), buttons: [ - mainT("common", "actions.restartNow") || "Restart Now", + mainT("common", "actions.downloadUpdate") || "Download Update", mainT("common", "actions.cancel") || "Cancel", ], defaultId: 0, cancelId: 1, }); - if (restart.response === 0) await installSelfUpdate(); + if (choice.response === 0) await downloadAndInstall(latestVersion); +} + +async function runBackgroundUpdateCheck() { + if (updateCheckInFlight || !canOfferUpdateCheck()) return; + updateCheckInFlight = true; + try { + const outcome = await probeSelfUpdate(); + const plan = planBackgroundUpdate({ outcome, mode: currentUpdateMode }); + if (plan.action === "none") return; + if (plan.action === "notify-available") { + await presentAvailableUpdate(plan.version); + return; + } + if (plan.action === "download") { + const downloaded = await downloadSelfUpdate(); + if (downloaded.kind === "failed") { + await showMessageBox({ + type: "error", + title: PRODUCT_NAME, + message: mainT("common", "updates.downloadFailed"), + detail: downloaded.error.message, + }); + return; + } + await showMessageBox({ + type: "info", + title: PRODUCT_NAME, + message: mainT("common", "updates.downloaded", { latestVersion: plan.version }), + }); + return; + } + await downloadAndInstall(plan.version); + } catch (error) { + console.error("[updates] background check failed", error); + } finally { + updateCheckInFlight = false; + } +} + +function startBackgroundUpdateTimer() { + if (backgroundUpdateTimer) return; + if ( + !shouldStartBackgroundUpdateTimer({ + isPackaged: app.isPackaged, + ownsItsUpdates: ownsItsUpdates(getInstallChannel()), + }) + ) { + return; + } + backgroundUpdateTimer = setInterval(() => { + void runBackgroundUpdateCheck(); + }, BACKGROUND_UPDATE_INTERVAL_MS); + backgroundUpdateTimer.unref?.(); } /** `onVerdict` fires as soon as we know whether an update exists — before any of the dialogs @@ -771,6 +875,29 @@ function updateTrayMenu(recording: boolean = false) { }, ] : []), + ...(showUpdateSettingsMenu() + ? [ + { + label: mainT("common", "actions.updateSettings") || "Update Settings", + submenu: ( + [ + ["notify", "updateModeNotify", "Notify when an update is available"], + ["download", "updateModeDownload", "Download updates automatically"], + [ + "download-and-install", + "updateModeDownloadAndInstall", + "Download and install updates automatically", + ], + ] as const + ).map(([mode, key, fallback]) => ({ + label: mainT("common", `actions.${key}`) || fallback, + type: "radio" as const, + checked: currentUpdateMode === mode, + click: () => persistUpdateMode(mode), + })), + }, + ] + : []), // The About box's other homes are menu-bar items, and no window this app creates // shows a menu bar: the HUD is frameless (electron/windows.ts), and the editor and // notes windows call setAutoHideMenuBar(true) on Windows and Linux. Without this @@ -1136,8 +1263,14 @@ appReady?.then(async () => { }); }); + // Deliberately no updater touch here: importing electron-updater costs + // startup time and the channels that cannot use it must not pay for it at + // all (see auto-updater.ts getUpdater) — every real update path applies + // its settings lazily on first use. + currentUpdateMode = loadUpdateMode(app.getPath("userData")); createTray(); updateTrayMenu(); + startBackgroundUpdateTimer(); configureAboutPanel(); setupApplicationMenu(); await ensureRecordingsDir(); diff --git a/electron/update-settings.test.ts b/electron/update-settings.test.ts new file mode 100644 index 000000000..4050987c6 --- /dev/null +++ b/electron/update-settings.test.ts @@ -0,0 +1,44 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { parseUpdateMode } from "./background-update"; +import { loadUpdateMode, saveUpdateMode, updateSettingsPath } from "./update-settings"; + +const temps: string[] = []; + +afterEach(() => { + for (const dir of temps) rmSync(dir, { recursive: true, force: true }); + temps.length = 0; +}); + +function tmp(): string { + const dir = mkdtempSync(path.join(os.tmpdir(), "os-update-settings-")); + temps.push(dir); + return dir; +} + +describe("update settings", () => { + it("round-trips a saved mode through load", () => { + const dir = tmp(); + saveUpdateMode(dir, "download-and-install"); + expect(loadUpdateMode(dir)).toBe("download-and-install"); + }); + + it("defaults to notify when nothing was ever saved", () => { + expect(loadUpdateMode(tmp())).toBe("notify"); + }); + + it("falls back to notify on a corrupt settings file instead of throwing", () => { + const dir = tmp(); + writeFileSync(updateSettingsPath(dir), '{"mode": not-even-json !!}'); + expect(loadUpdateMode(dir)).toBe("notify"); + }); + + it("refuses garbage mode values rather than trusting the file", () => { + for (const garbage of ["install-silently", "", 42, null, { mode: "download" }]) { + expect(parseUpdateMode(garbage)).toBe("notify"); + } + expect(parseUpdateMode("download")).toBe("download"); + }); +}); diff --git a/electron/update-settings.ts b/electron/update-settings.ts new file mode 100644 index 000000000..9e78b2c30 --- /dev/null +++ b/electron/update-settings.ts @@ -0,0 +1,26 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { DEFAULT_UPDATE_MODE, parseUpdateMode, type UpdateMode } from "./background-update"; + +export function updateSettingsPath(userData: string): string { + return path.join(userData, "update-settings.json"); +} + +export function loadUpdateMode(userData: string): UpdateMode { + const file = updateSettingsPath(userData); + if (!existsSync(file)) return DEFAULT_UPDATE_MODE; + try { + const raw = JSON.parse(readFileSync(file, "utf8")) as { mode?: unknown }; + return parseUpdateMode(raw.mode); + } catch { + return DEFAULT_UPDATE_MODE; + } +} + +export function saveUpdateMode(userData: string, mode: UpdateMode): void { + try { + writeFileSync(updateSettingsPath(userData), `${JSON.stringify({ mode })}\n`, "utf8"); + } catch { + // Best-effort; a failed write must not block the tray click. + } +} diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index b63cd0265..a36724acb 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -8,6 +8,10 @@ "done": "تم", "open": "فتح", "checkForUpdates": "التحقق من وجود تحديثات", + "updateSettings": "إعدادات التحديث", + "updateModeNotify": "إشعار عند توفر تحديث", + "updateModeDownload": "تنزيل التحديثات تلقائيًا", + "updateModeDownloadAndInstall": "تنزيل التحديثات وتثبيتها تلقائيًا", "viewRelease": "عرض الإصدار", "downloadUpdate": "تنزيل التحديث", "restartNow": "إعادة التشغيل الآن", @@ -47,6 +51,7 @@ "available": "يتوفر OpenScreen {{latestVersion}}. الإصدار المثبت هو {{currentVersion}}.", "current": "OpenScreen محدّث ({{currentVersion}}).", "readyToInstall": "تم تنزيل OpenScreen {{latestVersion}}. أعد التشغيل لإكمال التثبيت.", + "downloaded": "تم تنزيل OpenScreen {{latestVersion}}.", "blockedRecording": "أنهِ التسجيل قبل تثبيت التحديث.", "blockedLocation": "انقل OpenScreen إلى مجلد التطبيقات قبل التحديث.", "failed": "تعذّر التحقق من وجود تحديثات.", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 6eac95830..e065c2a53 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -8,6 +8,10 @@ "done": "Done", "open": "Open", "checkForUpdates": "Check for Updates", + "updateSettings": "Update Settings", + "updateModeNotify": "Notify when an update is available", + "updateModeDownload": "Download updates automatically", + "updateModeDownloadAndInstall": "Download and install updates automatically", "viewRelease": "View Release", "downloadUpdate": "Download Update", "restartNow": "Restart Now", @@ -47,6 +51,7 @@ "available": "OpenScreen {{latestVersion}} is available. You are using {{currentVersion}}.", "current": "OpenScreen is up to date ({{currentVersion}}).", "readyToInstall": "OpenScreen {{latestVersion}} has been downloaded. Restart to finish installing.", + "downloaded": "OpenScreen {{latestVersion}} has been downloaded.", "blockedRecording": "Finish your recording before installing the update.", "blockedLocation": "Move OpenScreen to your Applications folder before updating.", "failed": "Could not check for updates.", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 29a364265..9f1d4ec1f 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -8,6 +8,10 @@ "done": "Listo", "open": "Abrir", "checkForUpdates": "Buscar actualizaciones", + "updateSettings": "Ajustes de actualización", + "updateModeNotify": "Avisar cuando haya una actualización", + "updateModeDownload": "Descargar actualizaciones automáticamente", + "updateModeDownloadAndInstall": "Descargar e instalar actualizaciones automáticamente", "viewRelease": "Ver versión", "downloadUpdate": "Descargar actualización", "restartNow": "Reiniciar ahora", @@ -47,6 +51,7 @@ "available": "OpenScreen {{latestVersion}} está disponible. Estás usando {{currentVersion}}.", "current": "OpenScreen está actualizado ({{currentVersion}}).", "readyToInstall": "Se ha descargado OpenScreen {{latestVersion}}. Reinicia para completar la instalación.", + "downloaded": "Se ha descargado OpenScreen {{latestVersion}}.", "blockedRecording": "Termina la grabación antes de instalar la actualización.", "blockedLocation": "Mueve OpenScreen a la carpeta Aplicaciones antes de actualizar.", "failed": "No se pudieron buscar actualizaciones.", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index c74ef66b8..ca6f0c2a8 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -8,6 +8,10 @@ "done": "Terminer", "open": "Ouvrir", "checkForUpdates": "Rechercher des mises à jour", + "updateSettings": "Paramètres de mise à jour", + "updateModeNotify": "Notifier lorsqu'une mise à jour est disponible", + "updateModeDownload": "Télécharger les mises à jour automatiquement", + "updateModeDownloadAndInstall": "Télécharger et installer les mises à jour automatiquement", "viewRelease": "Voir la version", "downloadUpdate": "Télécharger la mise à jour", "restartNow": "Redémarrer maintenant", @@ -47,6 +51,7 @@ "available": "OpenScreen {{latestVersion}} est disponible. Vous utilisez la version {{currentVersion}}.", "current": "OpenScreen est à jour ({{currentVersion}}).", "readyToInstall": "OpenScreen {{latestVersion}} a été téléchargé. Redémarrez pour terminer l'installation.", + "downloaded": "OpenScreen {{latestVersion}} a été téléchargé.", "blockedRecording": "Terminez votre enregistrement avant d'installer la mise à jour.", "blockedLocation": "Déplacez OpenScreen dans le dossier Applications avant de le mettre à jour.", "failed": "Impossible de rechercher les mises à jour.", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 24f10fc61..22f13468c 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -8,6 +8,10 @@ "done": "Fatto", "open": "Apri", "checkForUpdates": "Controlla aggiornamenti", + "updateSettings": "Impostazioni aggiornamento", + "updateModeNotify": "Notifica quando è disponibile un aggiornamento", + "updateModeDownload": "Scarica gli aggiornamenti automaticamente", + "updateModeDownloadAndInstall": "Scarica e installa gli aggiornamenti automaticamente", "viewRelease": "Visualizza versione", "downloadUpdate": "Scarica aggiornamento", "restartNow": "Riavvia ora", @@ -47,6 +51,7 @@ "available": "OpenScreen {{latestVersion}} è disponibile. Stai usando la versione {{currentVersion}}.", "current": "OpenScreen è aggiornato ({{currentVersion}}).", "readyToInstall": "OpenScreen {{latestVersion}} è stato scaricato. Riavvia per completare l'installazione.", + "downloaded": "OpenScreen {{latestVersion}} è stato scaricato.", "blockedRecording": "Termina la registrazione prima di installare l'aggiornamento.", "blockedLocation": "Sposta OpenScreen nella cartella Applicazioni prima di aggiornare.", "failed": "Impossibile controllare gli aggiornamenti.", diff --git a/src/i18n/locales/ja-JP/common.json b/src/i18n/locales/ja-JP/common.json index 0d3ca001b..74b7986eb 100644 --- a/src/i18n/locales/ja-JP/common.json +++ b/src/i18n/locales/ja-JP/common.json @@ -8,6 +8,10 @@ "done": "完了", "open": "開く", "checkForUpdates": "アップデートを確認", + "updateSettings": "アップデート設定", + "updateModeNotify": "アップデートが利用可能になったら通知", + "updateModeDownload": "アップデートを自動でダウンロード", + "updateModeDownloadAndInstall": "アップデートを自動でダウンロードしてインストール", "viewRelease": "リリースを表示", "downloadUpdate": "アップデートをダウンロード", "restartNow": "今すぐ再起動", @@ -47,6 +51,7 @@ "available": "OpenScreen {{latestVersion}} を利用できます。現在のバージョンは {{currentVersion}} です。", "current": "OpenScreen は最新です({{currentVersion}})。", "readyToInstall": "OpenScreen {{latestVersion}} をダウンロードしました。再起動してインストールを完了してください。", + "downloaded": "OpenScreen {{latestVersion}} をダウンロードしました。", "blockedRecording": "アップデートをインストールする前に録画を終了してください。", "blockedLocation": "アップデートする前に OpenScreen をアプリケーションフォルダに移動してください。", "failed": "アップデートを確認できませんでした。", diff --git a/src/i18n/locales/ko-KR/common.json b/src/i18n/locales/ko-KR/common.json index 98d025d9a..352c4db37 100644 --- a/src/i18n/locales/ko-KR/common.json +++ b/src/i18n/locales/ko-KR/common.json @@ -8,6 +8,10 @@ "done": "완료", "open": "열기", "checkForUpdates": "업데이트 확인", + "updateSettings": "업데이트 설정", + "updateModeNotify": "업데이트가 있으면 알림", + "updateModeDownload": "업데이트 자동 다운로드", + "updateModeDownloadAndInstall": "업데이트 자동 다운로드 및 설치", "viewRelease": "릴리스 보기", "downloadUpdate": "업데이트 다운로드", "restartNow": "지금 다시 시작", @@ -47,6 +51,7 @@ "available": "OpenScreen {{latestVersion}} 버전을 사용할 수 있습니다. 현재 버전은 {{currentVersion}}입니다.", "current": "OpenScreen이 최신 버전입니다({{currentVersion}}).", "readyToInstall": "OpenScreen {{latestVersion}}을(를) 다운로드했습니다. 다시 시작하여 설치를 완료하세요.", + "downloaded": "OpenScreen {{latestVersion}}이(가) 다운로드되었습니다.", "blockedRecording": "업데이트를 설치하기 전에 녹화를 마치세요.", "blockedLocation": "업데이트하기 전에 OpenScreen을 응용 프로그램 폴더로 옮기세요.", "failed": "업데이트를 확인할 수 없습니다.", diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index d74e5b215..2dac0f59b 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -8,6 +8,10 @@ "done": "Concluir", "open": "Abrir", "checkForUpdates": "Verificar atualizações", + "updateSettings": "Configurações de atualização", + "updateModeNotify": "Avisar quando houver uma atualização", + "updateModeDownload": "Baixar atualizações automaticamente", + "updateModeDownloadAndInstall": "Baixar e instalar atualizações automaticamente", "viewRelease": "Ver versão", "downloadUpdate": "Baixar atualização", "restartNow": "Reiniciar agora", @@ -47,6 +51,7 @@ "available": "O OpenScreen {{latestVersion}} está disponível. Você está usando a versão {{currentVersion}}.", "current": "O OpenScreen está atualizado ({{currentVersion}}).", "readyToInstall": "O OpenScreen {{latestVersion}} foi baixado. Reinicie para concluir a instalação.", + "downloaded": "O OpenScreen {{latestVersion}} foi baixado.", "blockedRecording": "Conclua a gravação antes de instalar a atualização.", "blockedLocation": "Mova o OpenScreen para a pasta Aplicativos antes de atualizar.", "failed": "Não foi possível verificar atualizações.", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 141e8b984..ec4f2802f 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -8,6 +8,10 @@ "done": "Готово", "open": "Открыть", "checkForUpdates": "Проверить обновления", + "updateSettings": "Настройки обновления", + "updateModeNotify": "Уведомлять, когда доступно обновление", + "updateModeDownload": "Загружать обновления автоматически", + "updateModeDownloadAndInstall": "Загружать и устанавливать обновления автоматически", "viewRelease": "Открыть выпуск", "downloadUpdate": "Загрузить обновление", "restartNow": "Перезапустить", @@ -47,6 +51,7 @@ "available": "Доступен OpenScreen {{latestVersion}}. Установлена версия {{currentVersion}}.", "current": "Установлена последняя версия OpenScreen ({{currentVersion}}).", "readyToInstall": "OpenScreen {{latestVersion}} загружен. Перезапустите, чтобы завершить установку.", + "downloaded": "OpenScreen {{latestVersion}} загружен.", "blockedRecording": "Завершите запись перед установкой обновления.", "blockedLocation": "Переместите OpenScreen в папку «Программы» перед обновлением.", "failed": "Не удалось проверить наличие обновлений.", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 923079b1e..50d577f6c 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -8,6 +8,10 @@ "done": "Tamam", "open": "Aç", "checkForUpdates": "Güncellemeleri denetle", + "updateSettings": "Güncelleme ayarları", + "updateModeNotify": "Güncelleme olduğunda bildir", + "updateModeDownload": "Güncellemeleri otomatik indir", + "updateModeDownloadAndInstall": "Güncellemeleri otomatik indir ve yükle", "viewRelease": "Sürümü görüntüle", "downloadUpdate": "Güncellemeyi indir", "restartNow": "Şimdi yeniden başlat", @@ -47,6 +51,7 @@ "available": "OpenScreen {{latestVersion}} kullanılabilir. Mevcut sürümünüz {{currentVersion}}.", "current": "OpenScreen güncel ({{currentVersion}}).", "readyToInstall": "OpenScreen {{latestVersion}} indirildi. Kurulumu tamamlamak için yeniden başlatın.", + "downloaded": "OpenScreen {{latestVersion}} indirildi.", "blockedRecording": "Güncellemeyi yüklemeden önce kaydınızı tamamlayın.", "blockedLocation": "Güncellemeden önce OpenScreen'i Uygulamalar klasörüne taşıyın.", "failed": "Güncellemeler denetlenemedi.", diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index 089cf992a..625ff8d43 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -8,6 +8,10 @@ "done": "Hoàn tất", "open": "Mở", "checkForUpdates": "Kiểm tra bản cập nhật", + "updateSettings": "Cài đặt cập nhật", + "updateModeNotify": "Thông báo khi có bản cập nhật", + "updateModeDownload": "Tự động tải bản cập nhật", + "updateModeDownloadAndInstall": "Tự động tải và cài đặt bản cập nhật", "viewRelease": "Xem bản phát hành", "downloadUpdate": "Tải bản cập nhật", "restartNow": "Khởi động lại ngay", @@ -47,6 +51,7 @@ "available": "Đã có OpenScreen {{latestVersion}}. Bạn đang dùng phiên bản {{currentVersion}}.", "current": "OpenScreen đang ở phiên bản mới nhất ({{currentVersion}}).", "readyToInstall": "Đã tải xuống OpenScreen {{latestVersion}}. Khởi động lại để hoàn tất cài đặt.", + "downloaded": "Đã tải OpenScreen {{latestVersion}}.", "blockedRecording": "Hãy kết thúc bản ghi trước khi cài đặt bản cập nhật.", "blockedLocation": "Hãy chuyển OpenScreen vào thư mục Applications trước khi cập nhật.", "failed": "Không thể kiểm tra bản cập nhật.", diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 0a7b2bd9b..51b457f1a 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -8,6 +8,10 @@ "done": "完成", "open": "打开", "checkForUpdates": "检查更新", + "updateSettings": "更新设置", + "updateModeNotify": "有更新时通知", + "updateModeDownload": "自动下载更新", + "updateModeDownloadAndInstall": "自动下载并安装更新", "viewRelease": "查看版本", "downloadUpdate": "下载更新", "restartNow": "立即重启", @@ -47,6 +51,7 @@ "available": "OpenScreen {{latestVersion}} 已发布。当前版本为 {{currentVersion}}。", "current": "OpenScreen 已是最新版本({{currentVersion}})。", "readyToInstall": "OpenScreen {{latestVersion}} 已下载完成。重启以完成安装。", + "downloaded": "已下载 OpenScreen {{latestVersion}}。", "blockedRecording": "请先结束录制,再安装更新。", "blockedLocation": "更新前请将 OpenScreen 移动到「应用程序」文件夹。", "failed": "无法检查更新。", diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index c54dc17ed..dbdcb73da 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -8,6 +8,10 @@ "done": "完成", "open": "開啟", "checkForUpdates": "檢查更新", + "updateSettings": "更新設定", + "updateModeNotify": "有更新時通知", + "updateModeDownload": "自動下載更新", + "updateModeDownloadAndInstall": "自動下載並安裝更新", "viewRelease": "檢視版本", "downloadUpdate": "下載更新", "restartNow": "立即重新啟動", @@ -47,6 +51,7 @@ "available": "OpenScreen {{latestVersion}} 已推出。目前版本為 {{currentVersion}}。", "current": "OpenScreen 已是最新版本({{currentVersion}})。", "readyToInstall": "OpenScreen {{latestVersion}} 已下載完成。請重新啟動以完成安裝。", + "downloaded": "已下載 OpenScreen {{latestVersion}}。", "blockedRecording": "請先結束錄影,再安裝更新。", "blockedLocation": "更新前請將 OpenScreen 移至「應用程式」資料夾。", "failed": "無法檢查更新。", From 9c84fd3efb59ce0bf73309477023122a8010f9fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E7=BA=A7?= =?UTF-8?q?=E5=96=9C=E6=AC=A2=E4=BD=A0=E7=9A=84=E8=BE=BE=E5=A6=AE=E5=A8=85?= <176143450+My-Denia@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:42:34 +0800 Subject: [PATCH 2/2] fix(update): stop non-downloaded outcomes short of the restart prompt --- electron/background-update.test.ts | 18 ++++++++++++++++++ electron/background-update.ts | 5 +++++ 2 files changed, 23 insertions(+) diff --git a/electron/background-update.test.ts b/electron/background-update.test.ts index 5a86aa99d..53f92b0c5 100644 --- a/electron/background-update.test.ts +++ b/electron/background-update.test.ts @@ -75,4 +75,22 @@ describe("background update policy", () => { }); expect(failed).toEqual({ status: "failed", error }); }); + + it("never reaches the restart prompt without a downloaded update", async () => { + // downloadSelfUpdate cannot return these today, but the type admits + // them; a current/unsupported outcome must not prompt or install. + for (const kind of ["current", "unsupported"] as const) { + const confirmRestart = vi.fn(async () => 0); + const install = vi.fn(); + const result = await runUnblockedDownloadAndInstall({ + download: async () => ({ kind }), + blocked: () => null, + confirmRestart, + install, + }); + expect(result).toEqual({ status: "unavailable" }); + expect(confirmRestart).not.toHaveBeenCalled(); + expect(install).not.toHaveBeenCalled(); + } + }); }); diff --git a/electron/background-update.ts b/electron/background-update.ts index b04119da9..ec17e3431 100644 --- a/electron/background-update.ts +++ b/electron/background-update.ts @@ -54,6 +54,7 @@ export function shouldQuitAndInstallAfterRestartPrompt(response: number): boolea * caller. */ export type DownloadAndInstallResult = | { status: "failed"; error: Error } + | { status: "unavailable" } | { status: "blocked" } | { status: "cancelled" } | { status: "installed" }; @@ -66,6 +67,10 @@ export async function runUnblockedDownloadAndInstall(deps: { }): Promise { const downloaded = await deps.download(); if (downloaded.kind === "failed") return { status: "failed", error: downloaded.error }; + // `downloadSelfUpdate` only ever reports downloaded|failed today, but the + // UpdateOutcome type admits current|unsupported — neither of which may + // reach the restart prompt, let alone quitAndInstall. + if (downloaded.kind !== "downloaded") return { status: "unavailable" }; if (deps.blocked()) return { status: "blocked" }; const choice = await deps.confirmRestart(); if (!shouldQuitAndInstallAfterRestartPrompt(choice)) return { status: "cancelled" };