From 121b7e125aa8a87778e921d9fcb6c7c31f17fe66 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Thu, 29 Oct 2026 00:24:09 +0100 Subject: [PATCH 1/8] feat(update): check in the background, show an update button, install on quit Builds that update themselves checked once per 24 hours of uptime, which most sessions never reach, and answered with a modal dialog. Now: - The first check runs a minute after launch, then every six hours. - A background find never opens a dialog. It becomes a blue update button in the HUD bar and the editor's top bar, and an "Update to " tray item. The click restarts into a downloaded update, or downloads one the "Notify" mode only found. - "Download and install updates automatically" is now the default, and it installs the downloaded update silently when the app quits, with no relaunch. Not where the install needs rights (per-machine NSIS, deb, rpm, pacman), and not while a CLI run is alive: the NSIS installer ends every process of this executable, an agent's `openscreen mcp` included. A click on the button is the user deciding, so it skips that veto. - If the self-updater fails, the release lookup still finds the version and the button opens the release page, so a broken updater cannot strand users on an old build. --- AGENTS.md | 2 +- electron/auto-updater.test.ts | 67 +++++- electron/auto-updater.ts | 46 ++++- electron/background-update.test.ts | 14 ++ electron/background-update.ts | 40 +++- electron/cli/sessionData.test.ts | 42 +++- electron/cli/sessionData.ts | 31 +++ electron/electron-env.d.ts | 5 + electron/main.ts | 194 ++++++++++++------ electron/preload.ts | 11 + electron/update-settings.test.ts | 20 +- .../ai-edition/v4/EditorShellV4.module.css | 26 ++- .../ai-edition/v4/EditorTopBar.test.tsx | 62 ++++++ src/components/ai-edition/v4/EditorTopBar.tsx | 25 +++ src/components/launch/HudControls.tsx | 30 ++- src/components/launch/LaunchWindow.tsx | 16 ++ src/hooks/useUpdateBadge.ts | 34 +++ src/i18n/locales/ar/common.json | 3 +- src/i18n/locales/cs/common.json | 3 +- src/i18n/locales/de/common.json | 3 +- src/i18n/locales/en/common.json | 3 +- src/i18n/locales/es/common.json | 3 +- src/i18n/locales/fr/common.json | 3 +- src/i18n/locales/it/common.json | 3 +- src/i18n/locales/ja-JP/common.json | 3 +- src/i18n/locales/ko-KR/common.json | 3 +- src/i18n/locales/pt-BR/common.json | 3 +- src/i18n/locales/ru/common.json | 3 +- src/i18n/locales/tr/common.json | 3 +- src/i18n/locales/vi/common.json | 3 +- src/i18n/locales/zh-CN/common.json | 3 +- src/i18n/locales/zh-TW/common.json | 3 +- src/native/browserShim.ts | 3 + src/styles/design-tokens.css | 8 + .../testing/manual-e2e-checklist.md | 5 +- website/docs/faq.md | 2 +- .../current/faq.md | 2 +- .../current/faq.md | 2 +- .../current/faq.md | 2 +- .../current/faq.md | 2 +- .../current/faq.md | 2 +- .../current/faq.md | 2 +- .../current/faq.md | 2 +- 43 files changed, 634 insertions(+), 108 deletions(-) create mode 100644 src/hooks/useUpdateBadge.ts diff --git a/AGENTS.md b/AGENTS.md index ceea69bb61..e9557c6b75 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,7 +132,7 @@ This section is the *mechanics*. **What to actually run is [`technical-documenta **The tray icon** (notification area on Windows, menu bar on macOS, the desktop's indicator area on Linux) - Because the HUD skips the taskbar and can be minimized/hidden, the **system-tray icon is the reliable way to refocus the app**: **a click or double-click brings back the app's window** (`showMainWindow`: the HUD, or the editor while it is open), and so does its menu's *Open*. Its icon swaps to a red dot while recording. -- **Right-click → context menu**: when idle, *Open*, *Check for Updates* (not on a Store, Flathub, Snap or Nix install), *Update Settings* (packaged builds that update themselves), *About OpenScreen*, *Save Diagnostics*, *Permissions…* (macOS), *Quit*; while recording, only ***Stop Recording*** (mirrors the HUD's stop). Tooltip shows `OpenScreen` or `Recording: `. Use this to stop a recording if the HUD isn't reachable. +- **Right-click → context menu**: when idle, *Open*, *Update to * (only while a background check has found one; the same click as the HUD's and the editor's blue update button), *Check for Updates* (not on a Store, Flathub, Snap or Nix install), *Update Settings* (packaged builds that update themselves), *About OpenScreen*, *Save Diagnostics*, *Permissions…* (macOS), *Quit*; while recording, only ***Stop Recording*** (mirrors the HUD's stop). Tooltip shows `OpenScreen` or `Recording: `. Use this to stop a recording if the HUD isn't reachable. **End-to-end flow (record → edit)** diff --git a/electron/auto-updater.test.ts b/electron/auto-updater.test.ts index 3f60a2ec2e..80fdb45706 100644 --- a/electron/auto-updater.test.ts +++ b/electron/auto-updater.test.ts @@ -1,10 +1,15 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, readdirSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { blockedFromInstalling, checkForSelfUpdate, downloadSelfUpdate, type InstallReadiness, + installNeedsElevation, installSelfUpdate, + setInstallOnQuit, } from "./auto-updater"; const mocks = vi.hoisted(() => ({ @@ -155,4 +160,64 @@ describe("self-update flow", () => { await installSelfUpdate(); expect(mocks.autoUpdater.quitAndInstall).toHaveBeenCalledWith(false, true); }); + + // electron-updater only registers its quit handler if the flag is on when the download ends, + // and only installs if it is still on at quit. Both halves have to follow the switch. + it("applies install-on-quit at the next call and flips it live once loaded", async () => { + mocks.autoUpdater.checkForUpdates.mockResolvedValue({ updateInfo: { version: "1.9.2" } }); + try { + setInstallOnQuit(true); + await checkForSelfUpdate("nsis"); + expect(mocks.autoUpdater.autoInstallOnAppQuit).toBe(true); + // From before-quit: synchronous, no updater call in between. + setInstallOnQuit(false); + expect(mocks.autoUpdater.autoInstallOnAppQuit).toBe(false); + } finally { + setInstallOnQuit(false); + } + }); +}); + +describe("installNeedsElevation", () => { + const dirs: string[] = []; + afterEach(() => { + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); + dirs.length = 0; + }); + function installDir(): string { + const dir = mkdtempSync(path.join(os.tmpdir(), "os-install-")); + dirs.push(dir); + return dir; + } + + it("always needs it for a package the system package manager installed", () => { + for (const channel of ["deb", "rpm", "pacman"] as const) { + expect(installNeedsElevation(channel, path.join(installDir(), "openscreen"))).toBe(true); + } + }); + + it("never asks on macOS, where Squirrel installs", () => { + expect(installNeedsElevation("dmg", "/nonexistent/Openscreen.app/Contents/MacOS/x")).toBe( + false, + ); + }); + + it("needs none where the install directory is writable, and leaves nothing behind", () => { + const dir = installDir(); + expect(installNeedsElevation("nsis", path.join(dir, "Openscreen.exe"))).toBe(false); + expect(readdirSync(dir)).toEqual([]); + }); + + it("needs it where the install directory cannot be written", () => { + const missing = path.join(installDir(), "gone", "Openscreen.exe"); + expect(installNeedsElevation("nsis", missing)).toBe(true); + }); + + it("probes the AppImage's own folder, not the mount it runs from", () => { + const dir = installDir(); + const mount = path.join(dir, "missing-mount", "openscreen"); + expect(installNeedsElevation("appimage", mount, path.join(dir, "Openscreen.AppImage"))).toBe( + false, + ); + }); }); diff --git a/electron/auto-updater.ts b/electron/auto-updater.ts index fdee80d2bb..36a03a0c89 100644 --- a/electron/auto-updater.ts +++ b/electron/auto-updater.ts @@ -8,7 +8,10 @@ // bundle and come along. What does NOT come along is anything under userData — recordings, // projects, and the ~500 MB STT model — which is exactly what we want. +import { mkdtempSync, rmdirSync } from "node:fs"; +import path from "node:path"; import { app } from "electron"; +import type { AppUpdater } from "electron-updater"; import type { InstallChannel } from "./install-channel"; import { ownsItsUpdates } from "./install-channel"; @@ -36,24 +39,59 @@ export function blockedFromInstalling(state: InstallReadiness): string | null { return null; } +/** Whether a downloaded update installs as the app quits. Off unless the user's update mode asks + * for it, and main turns it back off at quit when `shouldInstallOnQuit` says not now. */ +let installOnQuit = false; +/** Set once electron-updater has been imported, so `setInstallOnQuit` can reach it synchronously + * from `before-quit` without importing it there. */ +let loadedUpdater: AppUpdater | null = null; + +/** electron-updater reads the flag twice. At download, where only a `true` registers its quit + * handler (and, on macOS, hands the update to Squirrel, which then installs on any quit), and at + * quit, where the handler skips the install if it is `false` by then. */ +export function setInstallOnQuit(enabled: boolean): void { + installOnQuit = enabled; + if (loadedUpdater) loadedUpdater.autoInstallOnAppQuit = enabled; +} + /** Loaded lazily: importing electron-updater costs startup time on every launch, and the * channels that cannot use it must not pay for it at all. */ async function getUpdater() { const { autoUpdater } = await import("electron-updater"); - // Never surprise the user with a 240 MB download or an install they did not ask for. - // `window-all-closed` quits this app, and the HUD is a window — with the default - // `autoInstallOnAppQuit` simply closing the HUD would kick off an installer. + // Never download behind the user's back from here: every download is an explicit + // `downloadSelfUpdate`, made by the update mode or by a click. // // Reapplied on every call rather than set once behind a flag: these are three property // writes, so memoising them buys nothing measurable, and a "configured" flag would mean // anything that later reset them is never corrected — while making the guarantee hold only // for whichever caller happened to come first. autoUpdater.autoDownload = false; - autoUpdater.autoInstallOnAppQuit = false; + autoUpdater.autoInstallOnAppQuit = installOnQuit; autoUpdater.logger = null; + loadedUpdater = autoUpdater; return autoUpdater; } +/** Would replacing this install prompt for rights the user has to grant? deb, rpm and pacman always + * go through pkexec. For NSIS and AppImage the install directory is probed by writing into it, + * because on Windows `fs.access` ignores ACLs and calls Program Files writable. macOS is not + * asked: Squirrel installs there. */ +export function installNeedsElevation( + channel: InstallChannel, + execPath: string = process.execPath, + appImage: string | undefined = process.env.APPIMAGE, +): boolean { + if (channel === "deb" || channel === "rpm" || channel === "pacman") return true; + if (channel === "dmg") return false; + const dir = path.dirname(channel === "appimage" && appImage ? appImage : execPath); + try { + rmdirSync(mkdtempSync(path.join(dir, ".update-probe-"))); + return false; + } catch { + return true; + } +} + /** Is an update available, and can this install apply it itself? */ export async function checkForSelfUpdate(channel: InstallChannel): Promise { if (!ownsItsUpdates(channel) || !app.isPackaged) return { kind: "unsupported" }; diff --git a/electron/background-update.test.ts b/electron/background-update.test.ts index 53f92b0c5d..61d82a158e 100644 --- a/electron/background-update.test.ts +++ b/electron/background-update.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { planBackgroundUpdate, runUnblockedDownloadAndInstall, + shouldInstallOnQuit, shouldQuitAndInstallAfterRestartPrompt, shouldStartBackgroundUpdateTimer, } from "./background-update"; @@ -65,6 +66,19 @@ describe("background update policy", () => { expect(shouldQuitAndInstallAfterRestartPrompt(0)).toBe(true); }); + // The NSIS installer ends every process of this executable, so an install nobody clicked for + // must not cut an agent's `openscreen mcp` session short, nor raise a UAC or pkexec prompt on + // the way out. + it("installs on quit only in its mode, without elevation and with no CLI run alive", () => { + const clear = { needsElevation: false, cliSessionAlive: false }; + expect(shouldInstallOnQuit({ ...clear, mode: "download-and-install" })).toBe(true); + expect(shouldInstallOnQuit({ ...clear, mode: "download" })).toBe(false); + expect(shouldInstallOnQuit({ ...clear, mode: "notify" })).toBe(false); + const auto = { ...clear, mode: "download-and-install" as const }; + expect(shouldInstallOnQuit({ ...auto, needsElevation: true })).toBe(false); + expect(shouldInstallOnQuit({ ...auto, cliSessionAlive: true })).toBe(false); + }); + 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({ diff --git a/electron/background-update.ts b/electron/background-update.ts index ec17e34316..d1ce9ed36b 100644 --- a/electron/background-update.ts +++ b/electron/background-update.ts @@ -1,14 +1,40 @@ 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. */ +/** How far a background-discovered update may go on its own. None of them opens a dialog: a + * background find only ever becomes the update button (`UpdateBadge`). `download-and-install` + * also installs the downloaded update when the app quits, silently and without relaunching, + * unless `shouldInstallOnQuit` says not now. Closing the last window quits this app, so that + * quit is an ordinary one, and the installer is already on disk by then. */ 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; +/** Keeping users current is the point, so the default goes all the way. A mode the user picked in + * the tray is stored and wins. */ +export const DEFAULT_UPDATE_MODE: UpdateMode = "download-and-install"; +/** A minute after launch, then every six hours. Most sessions end long before 24 hours, so a check + * that only ran once a day of uptime almost never ran at all. */ +export const BACKGROUND_UPDATE_FIRST_CHECK_MS = 60 * 1000; +export const BACKGROUND_UPDATE_INTERVAL_MS = 6 * 60 * 60 * 1000; + +/** What a background find shows: a button in the HUD and the editor, and a tray item. */ +export interface UpdateBadge { + /** `available`: found, not downloaded, so the click downloads it (or opens the release page, + * when only the release lookup worked). `ready`: downloaded, so the click restarts into it. */ + state: "available" | "ready"; + version: string; +} + +/** May the downloaded update install while the app quits? Only in the mode that says so; only + * where it needs no rights the user would be prompted for on the way out (UAC for a per-machine + * NSIS install, pkexec for deb/rpm/pacman); and never while a CLI run is alive, because the NSIS + * installer ends every process of this executable, `openscreen mcp` driven by an agent included. + * The update button does not ask this: a click is the user deciding. */ +export function shouldInstallOnQuit(input: { + mode: UpdateMode; + needsElevation: boolean; + cliSessionAlive: boolean; +}): boolean { + return input.mode === "download-and-install" && !input.needsElevation && !input.cliSessionAlive; +} export function parseUpdateMode(raw: unknown): UpdateMode { if (raw === "notify" || raw === "download" || raw === "download-and-install") return raw; diff --git a/electron/cli/sessionData.test.ts b/electron/cli/sessionData.test.ts index d770b6a667..dffd842518 100644 --- a/electron/cli/sessionData.test.ts +++ b/electron/cli/sessionData.test.ts @@ -1,5 +1,8 @@ -import { describe, expect, it } from "vitest"; -import { isAbandonedSession, type SessionOwner } from "./sessionData"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { hasLiveCliSession, isAbandonedSession, type SessionOwner } from "./sessionData"; const DAY = 24 * 60 * 60 * 1000; const self: SessionOwner = { host: "box", pidNamespace: "pid:[1]", pid: 100 }; @@ -31,3 +34,38 @@ describe("isAbandonedSession", () => { expect(isAbandonedSession({ ...self, host: "other" }, 8 * DAY, self, alive)).toBe(true); }); }); + +describe("hasLiveCliSession", () => { + const dirs: string[] = []; + afterEach(() => { + for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true }); + dirs.length = 0; + }); + function userData(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "os-cli-sessions-")); + dirs.push(dir); + return dir; + } + function addRun(root: string, owner: SessionOwner | null) { + const run = path.join(root, "cli-sessions", "200-abc"); + fs.mkdirSync(run, { recursive: true }); + if (owner) fs.writeFileSync(path.join(run, "owner.json"), JSON.stringify(owner)); + } + + it("finds none when no CLI run ever started", () => { + expect(hasLiveCliSession(userData(), alive, self)).toBe(false); + }); + + it("sees an agent's `openscreen mcp` run while its pid lives, and not after", () => { + const root = userData(); + addRun(root, { ...self, pid: 200 }); + expect(hasLiveCliSession(root, alive, self)).toBe(true); + expect(hasLiveCliSession(root, dead, self)).toBe(false); + }); + + it("does not count a folder whose run never wrote its owner", () => { + const root = userData(); + addRun(root, null); + expect(hasLiveCliSession(root, alive, self)).toBe(false); + }); +}); diff --git a/electron/cli/sessionData.ts b/electron/cli/sessionData.ts index 0d2ade8721..2d3ed303d6 100644 --- a/electron/cli/sessionData.ts +++ b/electron/cli/sessionData.ts @@ -64,6 +64,37 @@ function isProcessAlive(pid: number): boolean { } } +/** + * Whether a CLI run is alive: `openscreen mcp` started by an agent, an export, anything that + * went through `isolateCliSessionData`. The app asks before installing an update on quit, since + * the NSIS installer ends every process of this executable. A folder with no readable owner + * counts as no run: it is one that died before writing it, or one a few milliseconds old. + */ +export function hasLiveCliSession( + userData: string, + isAlive: (pid: number) => boolean = isProcessAlive, + self: SessionOwner = currentOwner(), +): boolean { + const root = path.join(userData, CLI_SESSIONS_DIR); + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(root, { withFileTypes: true }); + } catch { + return false; + } + return entries.some((entry) => { + if (!entry.isDirectory()) return false; + const ownerPath = path.join(root, entry.name, OWNER_FILE); + try { + const owner = JSON.parse(fs.readFileSync(ownerPath, "utf8")) as SessionOwner; + const ageMs = Date.now() - fs.statSync(ownerPath).mtimeMs; + return !isAbandonedSession(owner, ageMs, self, isAlive); + } catch { + return false; + } + }); +} + async function readOwner(dir: string): Promise<{ owner: SessionOwner | null; ageMs: number }> { const ownerPath = path.join(dir, OWNER_FILE); try { diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index fc6b5f9118..b85d616ced 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -469,6 +469,11 @@ interface Window { checkForUpdates: () => Promise; showAbout: () => Promise; canCheckForUpdatesNow: () => Promise; + getUpdateBadge: () => Promise; + onUpdateBadgeChanged: ( + callback: (badge: import("./background-update").UpdateBadge | null) => void, + ) => () => void; + applyUpdate: () => Promise; revealInFolder: ( filePath: string, ) => Promise<{ success: boolean; error?: string; message?: string }>; diff --git a/electron/main.ts b/electron/main.ts index 43249d65ae..bb5ad40e96 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -26,18 +26,24 @@ import { blockedFromInstalling, checkForSelfUpdate, downloadSelfUpdate, + installNeedsElevation, installSelfUpdate, + setInstallOnQuit, type UpdateOutcome, } from "./auto-updater"; import { + BACKGROUND_UPDATE_FIRST_CHECK_MS, BACKGROUND_UPDATE_INTERVAL_MS, planBackgroundUpdate, runUnblockedDownloadAndInstall, + shouldInstallOnQuit, shouldStartBackgroundUpdateTimer, + type UpdateBadge, type UpdateMode, } from "./background-update"; import { parseCliArgs } from "./cli/args"; import { runCli } from "./cli/cliMain"; +import { hasLiveCliSession } from "./cli/sessionData"; import { disableHttpCacheForDevServer } from "./dev-http-cache"; import { mainLogBuffer } from "./diagnostics/main-log-buffer"; import { buildEditMenuSubmenu, type EditorUndoRedoChannel, routeEditorUndoRedo } from "./edit-menu"; @@ -610,6 +616,12 @@ function runUpdateCheck() { }); } +function runApplyUpdate() { + applyUpdate().catch((error) => { + console.error("[updates] update failed", error); + }); +} + /** * Menu and tray entry point for exporting a diagnostic bundle. The backend * (`exportDiagnosticFile`) and its "Save Diagnostics" label already existed — @@ -659,6 +671,13 @@ function runSaveDiagnostics() { let isRecording = false; let currentUpdateMode: UpdateMode = "notify"; let backgroundUpdateTimer: ReturnType | null = null; +/** The update button, shown by the HUD, the editor and the tray; null hides it. Only a background + * check and the downloads it leads to set it: the manual check keeps its own dialogs. */ +let updateBadge: UpdateBadge | null = null; +/** Set when the self-updater failed and only the release lookup found the version: the button + * then opens this page, the one way out a broken updater leaves. */ +let updateReleasePage: string | null = null; +let applyUpdateInFlight = false; function showUpdateSettingsMenu(): boolean { return app.isPackaged && ownsItsUpdates(getInstallChannel()); @@ -667,9 +686,39 @@ function showUpdateSettingsMenu(): boolean { function persistUpdateMode(mode: UpdateMode) { currentUpdateMode = mode; saveUpdateMode(app.getPath("userData"), mode); + setInstallOnQuit(mode === "download-and-install"); + updateTrayMenu(isRecording); +} + +function setUpdateBadge(badge: UpdateBadge | null, releasePage: string | null = null) { + updateBadge = badge; + updateReleasePage = releasePage; + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) window.webContents.send("update-badge-changed", badge); + } updateTrayMenu(isRecording); } +function installBlock(): string | null { + return blockedFromInstalling({ + recording: isRecording, + inApplicationsFolder: + process.platform === "darwin" ? (app.isInApplicationsFolder?.() ?? true) : true, + platform: process.platform, + }); +} + +async function showBlockedInstall(blocked: string) { + await showMessageBox({ + type: "info", + title: PRODUCT_NAME, + message: mainT( + "common", + blocked === "recording" ? "updates.blockedRecording" : "updates.blockedLocation", + ), + }); +} + let usagePingEnabled = true; /** Hidden where the ping never runs anyway: a dev build, or DO_NOT_TRACK set. */ @@ -696,13 +745,7 @@ function runUsagePing() { async function downloadAndInstall(latestVersion: string) { const result = await runUnblockedDownloadAndInstall({ download: downloadSelfUpdate, - blocked: () => - blockedFromInstalling({ - recording: isRecording, - inApplicationsFolder: - process.platform === "darwin" ? (app.isInApplicationsFolder?.() ?? true) : true, - platform: process.platform, - }), + blocked: installBlock, confirmRestart: async () => { const restart = await showMessageBox({ type: "info", @@ -730,72 +773,54 @@ async function downloadAndInstall(latestVersion: string) { }); return; } + // Downloaded, then left for later: the button now restarts straight into it. + if (result.status === "blocked" || result.status === "cancelled") { + setUpdateBadge({ state: "ready", version: latestVersion }); + } 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, - message: mainT( - "common", - blocked === "recording" ? "updates.blockedRecording" : "updates.blockedLocation", - ), - }); + const blocked = installBlock(); + if (blocked) await showBlockedInstall(blocked); } } -async function presentAvailableUpdate(latestVersion: string) { - const choice = await showMessageBox({ - type: "info", - title: PRODUCT_NAME, - message: mainT("common", "updates.available", { - currentVersion: app.getVersion(), - latestVersion, - }), - buttons: [ - mainT("common", "actions.downloadUpdate") || "Download Update", - mainT("common", "actions.cancel") || "Cancel", - ], - defaultId: 0, - cancelId: 1, - }); - if (choice.response === 0) await downloadAndInstall(latestVersion); -} - +/** Never a dialog: a background find becomes the update button, and a failure is only logged. */ async function runBackgroundUpdateCheck() { if (updateCheckInFlight || !canOfferUpdateCheck()) return; updateCheckInFlight = true; try { const outcome = await probeSelfUpdate(); - const plan = planBackgroundUpdate({ outcome, mode: currentUpdateMode }); + if (outcome.kind === "failed") { + // A broken updater or a missing feed. The release lookup does not go through + // electron-updater, so the user is still pointed at the release page instead of being + // left on this version for good. + console.warn("[updates] background self-update probe failed", outcome.error.message); + const release = await checkLatestRelease({ + currentVersion: app.getVersion(), + fetchLatest: (url, init) => net.fetch(url, init), + signal: AbortSignal.timeout(10_000), + }); + if (release.kind === "available") { + setUpdateBadge({ state: "available", version: release.latestVersion }, release.releaseUrl); + } + return; + } + // A translocated Mac cannot install: it gets the button alone, whose click says why. + const mode = installBlock() === "not-in-applications" ? "notify" : currentUpdateMode; + const plan = planBackgroundUpdate({ outcome, mode }); if (plan.action === "none") return; + if (updateBadge?.state === "ready" && updateBadge.version === plan.version) return; if (plan.action === "notify-available") { - await presentAvailableUpdate(plan.version); + setUpdateBadge({ state: "available", version: 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 }), - }); + const downloaded = await downloadSelfUpdate(); + if (downloaded.kind === "failed") { + console.warn("[updates] background download failed", downloaded.error.message); + // The click downloads it again, through the dialogs that can report a second failure. + setUpdateBadge({ state: "available", version: plan.version }); return; } - await downloadAndInstall(plan.version); + setUpdateBadge({ state: "ready", version: plan.version }); } catch (error) { console.error("[updates] background check failed", error); } finally { @@ -803,6 +828,27 @@ async function runBackgroundUpdateCheck() { } } +/** The update button. A click is the user deciding, so a live CLI run does not stop it, unlike + * the install on quit; a take or a translocated Mac still does, with the dialog that says why. */ +async function applyUpdate() { + const badge = updateBadge; + if (!badge || applyUpdateInFlight) return; + applyUpdateInFlight = true; + try { + if (updateReleasePage) { + await shell.openExternal(updateReleasePage); + } else if (badge.state === "available") { + await downloadAndInstall(badge.version); + } else { + const blocked = installBlock(); + if (blocked) await showBlockedInstall(blocked); + else await installSelfUpdate(); + } + } finally { + applyUpdateInFlight = false; + } +} + function startBackgroundUpdateTimer() { if (backgroundUpdateTimer) return; if ( @@ -813,6 +859,9 @@ function startBackgroundUpdateTimer() { ) { return; } + setTimeout(() => { + void runBackgroundUpdateCheck(); + }, BACKGROUND_UPDATE_FIRST_CHECK_MS).unref?.(); backgroundUpdateTimer = setInterval(() => { void runBackgroundUpdateCheck(); }, BACKGROUND_UPDATE_INTERVAL_MS); @@ -953,6 +1002,16 @@ function updateTrayMenu(recording: boolean = false) { showMainWindow(); }, }, + ...(updateBadge + ? [ + { + label: + mainT("common", "actions.updateTo", { version: updateBadge.version }) || + `Update to ${updateBadge.version}`, + click: runApplyUpdate, + }, + ] + : []), // Omitted entirely where a package manager owns the update (Microsoft Store, // Flathub, Snap, Nix): there the app is already kept current, and offering a // GitHub download walks the user into a second, parallel installation. @@ -1211,6 +1270,17 @@ app.on("before-quit", (event) => { // quit is deferred below: the user asked to leave, and a check they can re-run from the // tray is not worth holding the helper's teardown behind. updateCheckAbort?.abort(); + // Decided here rather than when the download finished: a CLI run can start or end, and the + // mode can change, while the update waits. The last before-quit is the one that counts. + if (updateBadge?.state === "ready") { + setInstallOnQuit( + shouldInstallOnQuit({ + mode: currentUpdateMode, + needsElevation: installNeedsElevation(getInstallChannel()), + cliSessionAlive: hasLiveCliSession(app.getPath("userData")), + }), + ); + } if (sttShutdownFinished) return; event.preventDefault(); if (sttShutdownPromise) return; @@ -1350,6 +1420,13 @@ appReady?.then(async () => { // then silently refuses. ipcMain.handle("can-check-for-updates-now", () => canOfferUpdateCheck()); + // The update button. Pushed as `update-badge-changed`; this is for a window that mounts later. + ipcMain.handle("get-update-badge", () => updateBadge); + // Returns at once: whatever follows the click is a native dialog or a quit, owned by this side. + ipcMain.handle("apply-update", () => { + runApplyUpdate(); + }); + // The one-time star ask. Decided here, not in the renderer, for the same reason the update // check is: the take flag and the install channel live in this file, and a rule enforced in // two places is a rule that gets asked two different ways. The renderer learns yes or no — @@ -1436,6 +1513,7 @@ appReady?.then(async () => { // all (see auto-updater.ts getUpdater) — every real update path applies // its settings lazily on first use. currentUpdateMode = loadUpdateMode(app.getPath("userData")); + setInstallOnQuit(currentUpdateMode === "download-and-install"); usagePingEnabled = loadUsagePingState(app.getPath("userData")).enabled; createTray(); updateTrayMenu(); diff --git a/electron/preload.ts b/electron/preload.ts index 23160bcaa8..4e96cdb76a 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -18,6 +18,7 @@ import { NATIVE_BRIDGE_CHANNEL, type NativeBridgeRequest, } from "../src/native/contracts"; +import type { UpdateBadge } from "./background-update"; import type { RecordingPrefs } from "./ipc/handlers"; import type { SttStatusEvent, @@ -491,6 +492,16 @@ contextBridge.exposeInMainWorld("electronAPI", { * permanent half; this one also answers "not mid-take", so it is only correct for a caller * that asks at the moment it shows the affordance. */ canCheckForUpdatesNow: () => ipcRenderer.invoke("can-check-for-updates-now") as Promise, + /** The update button's state: null hides it. Found by the main process's background check. */ + getUpdateBadge: () => ipcRenderer.invoke("get-update-badge") as Promise, + onUpdateBadgeChanged: (callback: (badge: UpdateBadge | null) => void) => { + const listener = (_event: unknown, badge: UpdateBadge | null) => callback(badge); + ipcRenderer.on("update-badge-changed", listener); + return () => ipcRenderer.removeListener("update-badge-changed", listener); + }, + /** The button's click. Resolves at once; the dialogs or the restart that follow are the main + * process's. */ + applyUpdate: () => ipcRenderer.invoke("apply-update") as Promise, revealInFolder: (filePath: string) => { return ipcRenderer.invoke("reveal-in-folder", filePath); }, diff --git a/electron/update-settings.test.ts b/electron/update-settings.test.ts index 4050987c6b..9e977e27ac 100644 --- a/electron/update-settings.test.ts +++ b/electron/update-settings.test.ts @@ -2,7 +2,7 @@ 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 { DEFAULT_UPDATE_MODE, parseUpdateMode } from "./background-update"; import { loadUpdateMode, saveUpdateMode, updateSettingsPath } from "./update-settings"; const temps: string[] = []; @@ -19,25 +19,29 @@ function tmp(): string { } describe("update settings", () => { + // Saved as something other than the default, or a load that ignored the file would pass too. it("round-trips a saved mode through load", () => { const dir = tmp(); - saveUpdateMode(dir, "download-and-install"); - expect(loadUpdateMode(dir)).toBe("download-and-install"); + saveUpdateMode(dir, "notify"); + expect(loadUpdateMode(dir)).toBe("notify"); }); - it("defaults to notify when nothing was ever saved", () => { - expect(loadUpdateMode(tmp())).toBe("notify"); + // Keeping users current is what the setting is for, so a user who never opened it gets the + // mode that downloads and installs on its own. + it("defaults to download-and-install when nothing was ever saved", () => { + expect(DEFAULT_UPDATE_MODE).toBe("download-and-install"); + expect(loadUpdateMode(tmp())).toBe("download-and-install"); }); - it("falls back to notify on a corrupt settings file instead of throwing", () => { + it("falls back to the default on a corrupt settings file instead of throwing", () => { const dir = tmp(); writeFileSync(updateSettingsPath(dir), '{"mode": not-even-json !!}'); - expect(loadUpdateMode(dir)).toBe("notify"); + expect(loadUpdateMode(dir)).toBe(DEFAULT_UPDATE_MODE); }); 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(garbage)).toBe(DEFAULT_UPDATE_MODE); } expect(parseUpdateMode("download")).toBe("download"); }); diff --git a/src/components/ai-edition/v4/EditorShellV4.module.css b/src/components/ai-edition/v4/EditorShellV4.module.css index 77bc77fd4e..2c43257d87 100644 --- a/src/components/ai-edition/v4/EditorShellV4.module.css +++ b/src/components/ai-edition/v4/EditorShellV4.module.css @@ -435,6 +435,26 @@ opacity: 0.5; cursor: not-allowed; } +/* Present only while an update waits, so it is quieter than Export: a tint, not a fill. */ +.updateBtn { + display: inline-flex; + align-items: center; + gap: 6px; + height: var(--topbar-icon); + padding: 0 12px; + border-radius: 10px; + background: var(--update-soft); + color: var(--update); + border: 1px solid transparent; + font-size: 13px; + font-weight: 600; + cursor: pointer; + flex-shrink: 0; + transition: border-color var(--motion-fast) var(--ease); +} +.updateBtn:hover { + border-color: var(--update); +} /* ─── Responsive topbar rules ─── */ @media (max-width: 1240px) { @@ -504,10 +524,12 @@ font-size: 11px; max-width: 70px; } - .exportLabel { + .exportLabel, + .updateLabel { display: none; } - .exportBtn { + .exportBtn, + .updateBtn { padding: 0; width: var(--topbar-icon); justify-content: center; diff --git a/src/components/ai-edition/v4/EditorTopBar.test.tsx b/src/components/ai-edition/v4/EditorTopBar.test.tsx index 9377d994cf..71d83d96b7 100644 --- a/src/components/ai-edition/v4/EditorTopBar.test.tsx +++ b/src/components/ai-edition/v4/EditorTopBar.test.tsx @@ -532,3 +532,65 @@ describe("top bar drag region while a menu is open (issue #1009)", () => { expect(bar.matches(condition)).toBe(false); }); }); + +describe("the update button", () => { + function stubUpdateBadge(initial: { state: "available" | "ready"; version: string } | null) { + let push: ((badge: typeof initial) => void) | null = null; + const applyUpdate = vi.fn(() => Promise.resolve()); + (window as unknown as { electronAPI?: unknown }).electronAPI = { + getAppInfo: () => Promise.resolve({ version: "2.1.2", canCheckForUpdates: true }), + canCheckForUpdatesNow: () => Promise.resolve(true), + getUpdateBadge: () => Promise.resolve(initial), + onUpdateBadgeChanged: (callback: (badge: typeof initial) => void) => { + push = callback; + return () => { + push = null; + }; + }, + applyUpdate, + }; + return { + applyUpdate, + push: (badge: typeof initial) => act(() => push?.(badge)), + restore: () => { + (window as unknown as { electronAPI?: unknown }).electronAPI = undefined; + }, + }; + } + + it("stays out of the bar while no update waits", async () => { + const stub = stubUpdateBadge(null); + try { + renderTopBar("Demo Project"); + await act(async () => {}); + expect(screen.queryByRole("button", { name: /actions\.updateTo/ })).not.toBeInTheDocument(); + } finally { + stub.restore(); + } + }); + + it("appears once the main process has one ready, and hands the click back to it", async () => { + const stub = stubUpdateBadge({ state: "ready", version: "2.2.0" }); + try { + renderTopBar("Demo Project"); + fireEvent.click(await screen.findByRole("button", { name: /actions\.updateTo/ })); + expect(stub.applyUpdate).toHaveBeenCalledTimes(1); + } finally { + stub.restore(); + } + }); + + it("follows a badge the background check pushes after the bar mounted", async () => { + const stub = stubUpdateBadge(null); + try { + renderTopBar("Demo Project"); + await act(async () => {}); + stub.push({ state: "available", version: "2.2.0" }); + expect(screen.getByRole("button", { name: /actions\.updateTo/ })).toBeInTheDocument(); + stub.push(null); + expect(screen.queryByRole("button", { name: /actions\.updateTo/ })).not.toBeInTheDocument(); + } finally { + stub.restore(); + } + }); +}); diff --git a/src/components/ai-edition/v4/EditorTopBar.tsx b/src/components/ai-edition/v4/EditorTopBar.tsx index 6968b36617..d85a4189e0 100644 --- a/src/components/ai-edition/v4/EditorTopBar.tsx +++ b/src/components/ai-edition/v4/EditorTopBar.tsx @@ -1,5 +1,6 @@ import { ChevronDown, + CircleArrowDown, Download, FolderOpen, FolderPlus, @@ -22,6 +23,7 @@ import { Tooltip } from "@/components/ui/tooltip"; import { useI18n, useScopedT } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; import { useTheme } from "@/hooks/useTheme"; +import { useUpdateBadge } from "@/hooks/useUpdateBadge"; import { getAvailableLocales, getLocaleName } from "@/i18n/loader"; import { moveMenuFocus } from "@/lib/menuKeyboard"; import { formatFirstFixedBinding } from "@/lib/shortcuts"; @@ -57,6 +59,14 @@ interface EditorTopBarProps { actions: TopBarActions; } +/** The update button's click. The main process answers at once; the dialogs or the restart that + * follow are its own. */ +function applyUpdate() { + void window.electronAPI?.applyUpdate?.().catch(() => { + // The main process logs and reports its own failures. + }); +} + const MODES: Array<{ id: EditorMode; labelKey: string }> = [ { id: "media", labelKey: "topbar.modes.media" }, { id: "edit", labelKey: "topbar.modes.edit" }, @@ -76,8 +86,10 @@ export function EditorTopBar({ }: EditorTopBarProps) { const t = useScopedT("editor"); const tShortcuts = useScopedT("shortcuts"); + const tCommon = useScopedT("common"); const { isMac } = useShortcuts(); const savedLabel = dirty ? t("topbar.unsaved") : t("topbar.saved"); + const updateBadge = useUpdateBadge(); // ponytail: the left side panel only renders in "edit" mode (see // NewEditorShell body), so its toggle is meaningless in Media/Rec — @@ -175,6 +187,19 @@ export function EditorTopBar({ + {updateBadge ? ( + + + + ) : null} + + ); +}); + export const HudRecordingControls = memo(function HudRecordingControls({ vertical, paused, diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index d4b402b841..0bad565d75 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -16,6 +16,7 @@ import { import { usePortalOwnsSource } from "../../hooks/usePortalOwnsSource"; import { useRememberedSourceName } from "../../hooks/useRememberedSourceName"; import { useScreenRecorder } from "../../hooks/useScreenRecorder"; +import { useUpdateBadge } from "../../hooks/useUpdateBadge"; import type { WebcamQualityId } from "../../hooks/webcamCaptureTarget"; import { requestCameraAccess } from "../../lib/requestCameraAccess"; import { Toaster } from "../ui/sonner"; @@ -37,6 +38,7 @@ import { HudStudioButton, HudSystemAudioButton, HudTrayLayoutButton, + HudUpdateButton, HudWindowControls, } from "./HudControls"; import { HudDeviceSettings, type HudDeviceSettingsLabels } from "./HudDeviceSettings"; @@ -88,6 +90,12 @@ export function LaunchWindow() { const tCommon = useScopedT("common"); // The microphone check's toast is worded where the camera check's is: in `editor`. const tEditor = useScopedT("editor"); + const updateBadge = useUpdateBadge(); + const applyUpdate = useCallback(() => { + void window.electronAPI?.applyUpdate?.().catch(() => { + // The main process logs and reports its own failures. + }); + }, []); const { locale, setLocale, @@ -1350,6 +1358,14 @@ export function LaunchWindow() {
+ {/* Hidden mid-take: an update cannot install during one anyway. */} + {updateBadge && !recording && ( + + )} + (null); + + useEffect(() => { + const api = window.electronAPI; + let pushed = false; + const unsubscribe = api?.onUpdateBadgeChanged?.((next) => { + pushed = true; + setBadge(next); + }); + void api + ?.getUpdateBadge?.() + .then((current) => { + if (!pushed) setBadge(current); + }) + .catch(() => { + // No badge is the safe answer: the button stays hidden. + }); + return () => { + pushed = true; + unsubscribe?.(); + }; + }, []); + + return badge; +} diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index 65a0171cac..8a66a7be7a 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -10,6 +10,8 @@ "open": "فتح", "checkForUpdates": "التحقق من وجود تحديثات", "updateSettings": "إعدادات التحديث", + "update": "تحديث", + "updateTo": "التحديث إلى {{version}}", "updateModeNotify": "إشعار عند توفر تحديث", "updateModeDownload": "تنزيل التحديثات تلقائيًا", "updateModeDownloadAndInstall": "تنزيل التحديثات وتثبيتها تلقائيًا", @@ -56,7 +58,6 @@ "available": "يتوفر OpenScreen {{latestVersion}}. الإصدار المثبت هو {{currentVersion}}.", "current": "OpenScreen محدّث ({{currentVersion}}).", "readyToInstall": "تم تنزيل OpenScreen {{latestVersion}}. أعد التشغيل لإكمال التثبيت.", - "downloaded": "تم تنزيل OpenScreen {{latestVersion}}.", "blockedRecording": "أنهِ التسجيل قبل تثبيت التحديث.", "blockedLocation": "انقل OpenScreen إلى مجلد التطبيقات قبل التحديث.", "failed": "تعذّر التحقق من وجود تحديثات.", diff --git a/src/i18n/locales/cs/common.json b/src/i18n/locales/cs/common.json index f1bb844221..d6e5104e9e 100644 --- a/src/i18n/locales/cs/common.json +++ b/src/i18n/locales/cs/common.json @@ -10,6 +10,8 @@ "open": "Otevřít", "checkForUpdates": "Zkontrolovat aktualizace", "updateSettings": "Nastavení aktualizací", + "update": "Aktualizovat", + "updateTo": "Aktualizovat na {{version}}", "updateModeNotify": "Upozornit na dostupnou aktualizaci", "updateModeDownload": "Stahovat aktualizace automaticky", "updateModeDownloadAndInstall": "Stahovat a instalovat aktualizace automaticky", @@ -56,7 +58,6 @@ "available": "OpenScreen {{latestVersion}} je k dispozici. Používáte verzi {{currentVersion}}.", "current": "OpenScreen je aktuální ({{currentVersion}}).", "readyToInstall": "OpenScreen {{latestVersion}} byl stažen. Restartujte aplikaci pro dokončení instalace.", - "downloaded": "OpenScreen {{latestVersion}} byl stažen.", "blockedRecording": "Před instalací aktualizace dokončete nahrávání.", "blockedLocation": "Před aktualizací přesuňte OpenScreen do složky Aplikace.", "failed": "Nepodařilo se zkontrolovat aktualizace.", diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 458bec8be1..7bfd52e37c 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -10,6 +10,8 @@ "open": "Öffnen", "checkForUpdates": "Nach Updates suchen", "updateSettings": "Update-Einstellungen", + "update": "Aktualisieren", + "updateTo": "Auf {{version}} aktualisieren", "updateModeNotify": "Benachrichtigen, wenn ein Update verfügbar ist", "updateModeDownload": "Updates automatisch herunterladen", "updateModeDownloadAndInstall": "Updates automatisch herunterladen und installieren", @@ -56,7 +58,6 @@ "available": "OpenScreen {{latestVersion}} ist verfügbar. Du verwendest {{currentVersion}}.", "current": "OpenScreen ist auf dem neuesten Stand ({{currentVersion}}).", "readyToInstall": "OpenScreen {{latestVersion}} wurde heruntergeladen. Starte neu, um die Installation abzuschließen.", - "downloaded": "OpenScreen {{latestVersion}} wurde heruntergeladen.", "blockedRecording": "Beende deine Aufnahme, bevor du das Update installierst.", "blockedLocation": "Verschiebe OpenScreen in deinen Programme-Ordner, bevor du aktualisierst.", "failed": "Nach Updates konnte nicht gesucht werden.", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index f04adbec75..41c89db28f 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -10,6 +10,8 @@ "open": "Open", "checkForUpdates": "Check for Updates", "updateSettings": "Update Settings", + "update": "Update", + "updateTo": "Update to {{version}}", "updateModeNotify": "Notify when an update is available", "updateModeDownload": "Download updates automatically", "updateModeDownloadAndInstall": "Download and install updates automatically", @@ -56,7 +58,6 @@ "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 cb5f145e77..2f05649a7c 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -10,6 +10,8 @@ "open": "Abrir", "checkForUpdates": "Buscar actualizaciones", "updateSettings": "Ajustes de actualización", + "update": "Actualizar", + "updateTo": "Actualizar a {{version}}", "updateModeNotify": "Avisar cuando haya una actualización", "updateModeDownload": "Descargar actualizaciones automáticamente", "updateModeDownloadAndInstall": "Descargar e instalar actualizaciones automáticamente", @@ -56,7 +58,6 @@ "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 1f04b15f0c..d1384e3570 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -10,6 +10,8 @@ "open": "Ouvrir", "checkForUpdates": "Rechercher des mises à jour", "updateSettings": "Paramètres de mise à jour", + "update": "Mettre à jour", + "updateTo": "Mettre à jour vers la version {{version}}", "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", @@ -56,7 +58,6 @@ "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 6554344e2c..5316e4c631 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -10,6 +10,8 @@ "open": "Apri", "checkForUpdates": "Controlla aggiornamenti", "updateSettings": "Impostazioni aggiornamento", + "update": "Aggiorna", + "updateTo": "Aggiorna alla versione {{version}}", "updateModeNotify": "Notifica quando è disponibile un aggiornamento", "updateModeDownload": "Scarica gli aggiornamenti automaticamente", "updateModeDownloadAndInstall": "Scarica e installa gli aggiornamenti automaticamente", @@ -56,7 +58,6 @@ "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 b36f2c5a43..068f0e5b09 100644 --- a/src/i18n/locales/ja-JP/common.json +++ b/src/i18n/locales/ja-JP/common.json @@ -10,6 +10,8 @@ "open": "開く", "checkForUpdates": "アップデートを確認", "updateSettings": "アップデート設定", + "update": "アップデート", + "updateTo": "{{version}} にアップデート", "updateModeNotify": "アップデートが利用可能になったら通知", "updateModeDownload": "アップデートを自動でダウンロード", "updateModeDownloadAndInstall": "アップデートを自動でダウンロードしてインストール", @@ -56,7 +58,6 @@ "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 2291c442a7..ac110e934a 100644 --- a/src/i18n/locales/ko-KR/common.json +++ b/src/i18n/locales/ko-KR/common.json @@ -10,6 +10,8 @@ "open": "열기", "checkForUpdates": "업데이트 확인", "updateSettings": "업데이트 설정", + "update": "업데이트", + "updateTo": "{{version}}(으)로 업데이트", "updateModeNotify": "업데이트가 있으면 알림", "updateModeDownload": "업데이트 자동 다운로드", "updateModeDownloadAndInstall": "업데이트 자동 다운로드 및 설치", @@ -56,7 +58,6 @@ "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 747e9ffa55..26c99168e1 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -10,6 +10,8 @@ "open": "Abrir", "checkForUpdates": "Verificar atualizações", "updateSettings": "Configurações de atualização", + "update": "Atualizar", + "updateTo": "Atualizar para {{version}}", "updateModeNotify": "Avisar quando houver uma atualização", "updateModeDownload": "Baixar atualizações automaticamente", "updateModeDownloadAndInstall": "Baixar e instalar atualizações automaticamente", @@ -56,7 +58,6 @@ "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 523743e308..47ec8ceefc 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -10,6 +10,8 @@ "open": "Открыть", "checkForUpdates": "Проверить обновления", "updateSettings": "Настройки обновления", + "update": "Обновить", + "updateTo": "Обновить до {{version}}", "updateModeNotify": "Уведомлять, когда доступно обновление", "updateModeDownload": "Загружать обновления автоматически", "updateModeDownloadAndInstall": "Загружать и устанавливать обновления автоматически", @@ -56,7 +58,6 @@ "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 86fd7321d5..56f92ce902 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -10,6 +10,8 @@ "open": "Aç", "checkForUpdates": "Güncellemeleri denetle", "updateSettings": "Güncelleme ayarları", + "update": "Güncelle", + "updateTo": "{{version}} sürümüne güncelle", "updateModeNotify": "Güncelleme olduğunda bildir", "updateModeDownload": "Güncellemeleri otomatik indir", "updateModeDownloadAndInstall": "Güncellemeleri otomatik indir ve yükle", @@ -56,7 +58,6 @@ "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 419188852a..8e2d6cf934 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -10,6 +10,8 @@ "open": "Mở", "checkForUpdates": "Kiểm tra bản cập nhật", "updateSettings": "Cài đặt cập nhật", + "update": "Cập nhật", + "updateTo": "Cập nhật lên {{version}}", "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", @@ -56,7 +58,6 @@ "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 607199bac2..4358dfc03d 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -10,6 +10,8 @@ "open": "打开", "checkForUpdates": "检查更新", "updateSettings": "更新设置", + "update": "更新", + "updateTo": "更新到 {{version}}", "updateModeNotify": "有更新时通知", "updateModeDownload": "自动下载更新", "updateModeDownloadAndInstall": "自动下载并安装更新", @@ -56,7 +58,6 @@ "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 ca72bbeeef..36b16d75e3 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -10,6 +10,8 @@ "open": "開啟", "checkForUpdates": "檢查更新", "updateSettings": "更新設定", + "update": "更新", + "updateTo": "更新至 {{version}}", "updateModeNotify": "有更新時通知", "updateModeDownload": "自動下載更新", "updateModeDownloadAndInstall": "自動下載並安裝更新", @@ -56,7 +58,6 @@ "available": "OpenScreen {{latestVersion}} 已推出。目前版本為 {{currentVersion}}。", "current": "OpenScreen 已是最新版本({{currentVersion}})。", "readyToInstall": "OpenScreen {{latestVersion}} 已下載完成。請重新啟動以完成安裝。", - "downloaded": "已下載 OpenScreen {{latestVersion}}。", "blockedRecording": "請先結束錄影,再安裝更新。", "blockedLocation": "更新前請將 OpenScreen 移至「應用程式」資料夾。", "failed": "無法檢查更新。", diff --git a/src/native/browserShim.ts b/src/native/browserShim.ts index d66dbf1abb..3b7b704d21 100644 --- a/src/native/browserShim.ts +++ b/src/native/browserShim.ts @@ -171,6 +171,9 @@ function createShimElectronAPI() { showAbout: () => Promise.resolve(), // No installer in browser mode, so nothing may ever be checked. canCheckForUpdatesNow: () => Promise.resolve(false), + getUpdateBadge: () => Promise.resolve(null), + onUpdateBadgeChanged: () => () => undefined, + applyUpdate: () => Promise.resolve(), getSources: () => Promise.resolve(SHIM_SOURCES), selectSource: (source: ShimDesktopSource, options?: { persist?: boolean }) => { shimSelectedSource = source; diff --git a/src/styles/design-tokens.css b/src/styles/design-tokens.css index 4b9c514346..5efa10250e 100644 --- a/src/styles/design-tokens.css +++ b/src/styles/design-tokens.css @@ -102,6 +102,11 @@ --success: #10b981; --success-soft: rgba(16, 185, 129, 0.10); + /* the update button: blue, apart from the mint accent, which already means "export" in the + same bar */ + --update: #2563eb; + --update-soft: rgba(37, 99, 235, 0.10); + /* macOS traffic lights */ --light-red: #ff5f57; --light-yellow: #febc2e; @@ -235,6 +240,9 @@ --success: #10b981; --success-soft: rgba(16, 185, 129, 0.14); + --update: #60a5fa; + --update-soft: rgba(96, 165, 250, 0.14); + --scene-sky-hi: #2e3440; --scene-sky-mid: #252a35; --scene-foliage: #14181f; diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 2bd38f8a9a..2d8dab3585 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -698,7 +698,10 @@ The inspector's rail holds five facets: Composition (background, format, frame, - [ ] **post-1.10.0** — Confirm the submenu is absent in a dev build and in a build on a channel that does not own its updates (`app.isPackaged && ownsItsUpdates`), rather than present and inert. - [ ] **post-1.10.0** — Confirm a background check that finds nothing shows no dialog at all — the background path never reports "you are current". - [ ] **post-1.10.0** — Confirm a failed or unavailable download stops short of the restart prompt rather than offering to restart into an installer that was never fetched. -- [ ] **post-1.10.0** — Confirm no mode installs on quit: closing the HUD must not fire the installer. +- [ ] **post-2.1.2** — Install the previous release, launch it and wait a minute. Confirm no dialog opens; a blue update button appears in the HUD bar and in the editor's top bar, and the tray gains *Update to *. In the two download modes it appears once the download has finished; in "Notify" at once. +- [ ] **post-2.1.2** — Click the button. In the two download modes the app restarts into the new version; in "Notify" it downloads, then asks to restart. Start a recording first and confirm the button is gone for the length of the take. +- [ ] **post-2.1.2** — In "Download and install updates automatically", quit with the button showing. Confirm no installer window and no relaunch, and that the next launch is the new version. On a per-machine Windows install, and on deb, rpm or pacman, confirm the quit installs nothing and raises no UAC or password prompt: there the button is the way. +- [ ] **post-2.1.2** — Same mode, with an agent connected through `openscreen mcp` (Claude Code or Codex). Quit OpenScreen and confirm nothing installs and the agent's session survives. Disconnect the agent, launch and quit again, and confirm the update installs. - [ ] **post-1.10.0** — Reach **Save Diagnostics** from the tray context menu while idle, and from the Help menu on Windows and Linux (Alt) or the app menu on macOS; confirm each writes a bundle. It is deliberately not in the wordmark menu. - [ ] With the wordmark menu open, walk it with the Down and Up arrows and confirm focus wraps at both ends. - [ ] Switch the app language and confirm the wordmark menu's labels follow — Keyboard Shortcuts and AI settings matching the dialogs they open, Check for Updates and About OpenScreen the wording the macOS app menu and the tray use. diff --git a/website/docs/faq.md b/website/docs/faq.md index 51bc639d3e..b9a200c8b4 100644 --- a/website/docs/faq.md +++ b/website/docs/faq.md @@ -34,7 +34,7 @@ OpenScreen is a free, MIT-licensed screen recorder and video editor for Windows, - **huggingface.co, once.** The first transcription downloads the Whisper model, about 264 MB, and a voice-detection model under 1 MB, each checked against a SHA-256 hash. After that, transcription needs no connection. - **github.com, once per language.** On a machine with a GPU, the first English or French transcription also downloads the model that re-times each word: about 110 MB for English, 350 MB for French. -- **github.com and api.github.com.** Builds that update themselves check for a new release every 24 hours, and when you ask. By default they only tell you one is available. +- **github.com and api.github.com.** Builds that update themselves check for a new release a minute after launch, then every six hours, and when you ask. By default they download it in the background and install it when you quit OpenScreen; **Update Settings** in the tray menu can make them only tell you instead. - **fonts.googleapis.com, only for a font added in 1.x.** A custom annotation font added in an earlier version is still fetched at launch, though 2.x no longer draws it. - **PostHog, once a day.** An anonymous count of active installs, sent to PostHog's EU servers. It holds the app version, the operating system, the processor architecture, how the app was installed, whether it was used directly or through its MCP server by an AI agent, and whether it is the first count of the week and of the month. That is all: no installation identifier (each count gets a new random ID), no IP address kept, nothing about your recordings. Untick **Send Anonymous Usage Count** in the tray menu to turn it off, or set `DO_NOT_TRACK=1`. - **Your AI provider, only if you connect one.** Chat editing sends your messages and the project data it reads, such as the timeline and transcript. Caption translation sends the caption text. Both stay off until you connect a provider. See [AI editing](./ai-editing.md). diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/faq.md b/website/i18n/de/docusaurus-plugin-content-docs/current/faq.md index d59ccc053c..b7c9af2dbd 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/faq.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/faq.md @@ -34,7 +34,7 @@ OpenScreen ist ein kostenloser Bildschirmrekorder und Videoeditor unter MIT-Lize - **huggingface.co, einmalig.** Die erste Transkription lädt das Whisper-Modell herunter, etwa 264 MB, und ein Modell zur Spracherkennung unter 1 MB, jedes gegen einen SHA-256-Hash geprüft. Danach braucht die Transkription keine Verbindung mehr. - **github.com, einmal pro Sprache.** Auf einem Rechner mit GPU lädt die erste englische oder französische Transkription zusätzlich das Modell herunter, das jedes Wort neu zeitlich ausrichtet: etwa 110 MB für Englisch, 350 MB für Französisch. -- **github.com und api.github.com.** Builds, die sich selbst aktualisieren, suchen alle 24 Stunden und auf deine Anfrage nach einer neuen Version. Standardmäßig melden sie nur, dass eine verfügbar ist. +- **github.com und api.github.com.** Builds, die sich selbst aktualisieren, suchen eine Minute nach dem Start, dann alle sechs Stunden und auf deine Anfrage nach einer neuen Version. Standardmäßig laden sie sie im Hintergrund herunter und installieren sie, wenn du OpenScreen beendest; mit **Update-Einstellungen** im Tray-Menü melden sie stattdessen nur, dass eine verfügbar ist. - **fonts.googleapis.com, nur für eine in 1.x hinzugefügte Schrift.** Eine eigene Schrift für Annotationen, die in einer früheren Version hinzugefügt wurde, wird beim Start weiterhin geladen, obwohl 2.x sie nicht mehr zeichnet. - **PostHog, einmal täglich.** Eine anonyme Zählung aktiver Installationen, gesendet an die EU-Server von PostHog. Sie enthält die App-Version, das Betriebssystem, die Prozessorarchitektur, die Art der Installation, ob die App direkt oder über ihren MCP-Server von einem KI-Agenten genutzt wurde, und ob es die erste Zählung der Woche und des Monats ist. Mehr nicht: keine Installationskennung (jede Zählung erhält eine neue Zufalls-ID), keine gespeicherte IP-Adresse, nichts über deine Aufnahmen. Entferne im Menü des Symbols im Infobereich das Häkchen bei **Anonyme Nutzungszählung senden**, um sie abzuschalten, oder setze `DO_NOT_TRACK=1`. - **Dein KI-Anbieter, nur wenn du einen verbindest.** Die Chat-Bearbeitung sendet deine Nachrichten und die Projektdaten, die sie liest, etwa Zeitleiste und Transkript. Die Untertitelübersetzung sendet den Untertiteltext. Beides bleibt aus, bis du einen Anbieter verbindest. Siehe [KI-Bearbeitung](./ai-editing.md). diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/faq.md b/website/i18n/es/docusaurus-plugin-content-docs/current/faq.md index 8f395734db..baa60908bb 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/faq.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/faq.md @@ -34,7 +34,7 @@ OpenScreen es un grabador de pantalla y editor de video gratis, con licencia MIT - **huggingface.co, una vez.** La primera transcripción descarga el modelo Whisper, de unos 264 MB, y un modelo de detección de voz de menos de 1 MB, cada uno verificado con un hash SHA-256. Después, la transcripción no necesita conexión. - **github.com, una vez por idioma.** En un equipo con GPU, la primera transcripción en inglés o en francés también descarga el modelo que vuelve a sincronizar cada palabra: unos 110 MB para el inglés, 350 MB para el francés. -- **github.com y api.github.com.** Las compilaciones que se actualizan solas buscan una nueva versión cada 24 horas, y cuando tú lo pides. De forma predeterminada, solo te avisan de que hay una disponible. +- **github.com y api.github.com.** Las compilaciones que se actualizan solas buscan una nueva versión un minuto después de abrirse, luego cada seis horas, y cuando tú lo pides. De forma predeterminada, la descargan en segundo plano y la instalan cuando cierras OpenScreen; con **Ajustes de actualización**, en el menú de la bandeja, puedes hacer que solo te avisen. - **fonts.googleapis.com, solo para una fuente añadida en 1.x.** Una fuente personalizada para anotaciones añadida en una versión anterior se sigue descargando al iniciar, aunque la 2.x ya no la dibuja. - **PostHog, una vez al día.** Un recuento anónimo de instalaciones activas, enviado a los servidores de PostHog en la UE. Contiene la versión de la app, el sistema operativo, la arquitectura del procesador, cómo se instaló la app, si se usó directamente o a través de su servidor MCP por un agente de IA, y si es el primer recuento de la semana y del mes. Nada más: ningún identificador de instalación (cada recuento recibe un ID aleatorio nuevo), ninguna dirección IP conservada, nada sobre tus grabaciones. Desmarca **Enviar recuento de uso anónimo** en el menú de la bandeja para desactivarlo, o define `DO_NOT_TRACK=1`. - **Tu proveedor de IA, solo si conectas uno.** La edición por chat envía tus mensajes y los datos del proyecto que lee, como la línea de tiempo y la transcripción. La traducción de subtítulos envía el texto de los subtítulos. Ambas permanecen desactivadas hasta que conectas un proveedor. Consulta [Edición con IA](./ai-editing.md). diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/faq.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/faq.md index db6bce75aa..99f0cb11e6 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/faq.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/faq.md @@ -34,7 +34,7 @@ OpenScreen est un enregistreur d'écran et éditeur vidéo gratuit, sous licence - **huggingface.co, une fois.** La première transcription télécharge le modèle Whisper, environ 264 Mo, et un modèle de détection de la voix de moins de 1 Mo, chacun vérifié à l'aide d'une empreinte SHA-256. Ensuite, la transcription n'a plus besoin de connexion. - **github.com, une fois par langue.** Sur une machine dotée d'un GPU, la première transcription en anglais ou en français télécharge aussi le modèle qui recale chaque mot dans le temps : environ 110 Mo pour l'anglais, 350 Mo pour le français. -- **github.com et api.github.com.** Les versions qui se mettent à jour d'elles-mêmes vérifient la présence d'une nouvelle version toutes les 24 heures, et quand vous le demandez. Par défaut, elles se contentent de vous signaler qu'une version est disponible. +- **github.com et api.github.com.** Les versions qui se mettent à jour d'elles-mêmes vérifient la présence d'une nouvelle version une minute après le lancement, puis toutes les six heures, et quand vous le demandez. Par défaut, elles la téléchargent en arrière-plan et l'installent quand vous quittez OpenScreen ; **Paramètres de mise à jour**, dans le menu de la barre système, permet de vous contenter d'un signalement. - **fonts.googleapis.com, seulement pour une police ajoutée en 1.x.** Une police d'annotation personnalisée ajoutée dans une version précédente est encore téléchargée au lancement, bien que la 2.x ne l'affiche plus. - **PostHog, une fois par jour.** Un comptage anonyme des installations actives, envoyé aux serveurs de PostHog dans l'UE. Il contient la version de l'application, le système d'exploitation, l'architecture du processeur, le mode d'installation de l'application, si elle a servi directement ou à un agent IA via son serveur MCP, et s'il s'agit du premier comptage de la semaine et du mois. C'est tout : aucun identifiant d'installation (chaque comptage reçoit un nouvel identifiant aléatoire), aucune adresse IP conservée, rien sur vos enregistrements. Décochez **Envoyer le comptage d'utilisation anonyme** dans le menu de l'icône de la zone de notification pour le désactiver, ou définissez `DO_NOT_TRACK=1`. - **Votre fournisseur d'IA, seulement si vous en connectez un.** Le montage par chat envoie vos messages et les données du projet qu'il lit, comme la timeline et la transcription. La traduction des sous-titres envoie le texte des sous-titres. Les deux restent désactivés tant que vous n'avez pas connecté de fournisseur. Voir [Montage par IA](./ai-editing.md). diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/faq.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/faq.md index d4ec9496ff..f018437831 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/faq.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/faq.md @@ -34,7 +34,7 @@ OpenScreen は、Windows、macOS、Linux に対応した、MIT ライセンス - **huggingface.co(一度だけ)。** 最初の文字起こしで約 264 MB の Whisper モデルと、1 MB 未満の音声検出モデルをダウンロードし、それぞれ SHA-256 ハッシュで検証します。それ以降、文字起こしに接続は必要ありません。 - **github.com(言語ごとに一度だけ)。** GPU を搭載したマシンでは、英語またはフランス語の最初の文字起こしで、各単語のタイミングを付け直すモデルもダウンロードします。英語は約 110 MB、フランス語は約 350 MB です。 -- **github.com と api.github.com。** 自動でアップデートするビルドは、24 時間ごとと、ユーザーが求めたときに、新しいリリースを確認します。既定では、新しいリリースがあることを知らせるだけです。 +- **github.com と api.github.com。** 自動でアップデートするビルドは、起動の 1 分後、その後は 6 時間ごと、そしてユーザーが求めたときに、新しいリリースを確認します。既定では、バックグラウンドでダウンロードし、OpenScreen を終了したときにインストールします。トレイメニューの **アップデート設定** で、知らせるだけにもできます。 - **fonts.googleapis.com(1.x で追加したフォントのみ)。** 以前のバージョンで追加したカスタムの注釈フォントは、2.x では描画されませんが、起動時に引き続き読み込まれます。 - **PostHog(1 日 1 回)。** アクティブなインストール数の匿名カウントで、PostHog の EU サーバーに送信されます。内容は、アプリのバージョン、OS、プロセッサのアーキテクチャ、アプリのインストール方法、アプリが直接使われたか AI エージェントが MCP サーバー経由で使ったか、そしてその週とその月で最初のカウントかどうかです。それ以外は何もありません。インストールを識別する ID はなく(カウントごとに新しいランダム ID が付きます)、IP アドレスは保持されず、録画に関する情報も一切含みません。無効にするには、トレイメニューの**匿名の利用カウントを送信**のチェックを外すか、`DO_NOT_TRACK=1` を設定します。 - **AI プロバイダー(接続した場合のみ)。** チャット編集では、あなたのメッセージと、エージェントが読み取るプロジェクトのデータ(タイムラインや文字起こしなど)が送信されます。字幕の翻訳では、字幕のテキストが送信されます。どちらも、プロバイダーを接続するまではオフです。[AI 編集](./ai-editing.md)を参照してください。 diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/faq.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/faq.md index 551df1e653..b38c45de57 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/faq.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/faq.md @@ -34,7 +34,7 @@ O OpenScreen é um gravador de tela e editor de vídeo gratuito, com licença MI - **huggingface.co, uma vez.** A primeira transcrição baixa o modelo Whisper, de cerca de 264 MB, e um modelo de detecção de voz com menos de 1 MB, cada um conferido com um hash SHA-256. Depois disso, a transcrição não precisa de conexão. - **github.com, uma vez por idioma.** Em uma máquina com GPU, a primeira transcrição em inglês ou em francês também baixa o modelo que reajusta o tempo de cada palavra: cerca de 110 MB para o inglês, 350 MB para o francês. -- **github.com e api.github.com.** Os builds que se atualizam sozinhos procuram uma nova versão a cada 24 horas e quando você pede. Por padrão, eles só avisam que há uma disponível. +- **github.com e api.github.com.** Os builds que se atualizam sozinhos procuram uma nova versão um minuto depois de abrir, depois a cada seis horas, e quando você pede. Por padrão, eles a baixam em segundo plano e a instalam quando você fecha o OpenScreen; em **Configurações de atualização**, no menu da bandeja, você pode fazer com que só avisem. - **fonts.googleapis.com, só para uma fonte adicionada na 1.x.** Uma fonte personalizada de anotação adicionada numa versão anterior ainda é baixada na inicialização, embora a 2.x não a desenhe mais. - **PostHog, uma vez por dia.** Uma contagem anônima de instalações ativas, enviada aos servidores do PostHog na UE. Ela contém a versão do app, o sistema operacional, a arquitetura do processador, como o app foi instalado, se foi usado diretamente ou pelo seu servidor MCP por um agente de IA, e se é a primeira contagem da semana e do mês. Só isso: nenhum identificador de instalação (cada contagem recebe um ID aleatório novo), nenhum endereço IP guardado, nada sobre suas gravações. Desmarque **Enviar contagem de uso anônima** no menu da bandeja para desativá-la, ou defina `DO_NOT_TRACK=1`. - **Seu provedor de IA, somente se você conectar um.** A edição por chat envia suas mensagens e os dados do projeto que ela lê, como a linha do tempo e a transcrição. A tradução de legendas envia o texto das legendas. As duas ficam desativadas até você conectar um provedor. Veja [Edição com IA](./ai-editing.md). diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/faq.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/faq.md index dd37c9bcbc..23015732a8 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/faq.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/faq.md @@ -34,7 +34,7 @@ OpenScreen 是一款免费的录屏与视频剪辑软件,采用 MIT 许可证 - **huggingface.co,仅一次**。第一次转录时会下载约 264 MB 的 Whisper 模型,以及一个不到 1 MB 的语音检测模型,每个都用 SHA-256 哈希值进行校验。此后转录无需联网。 - **github.com,每种语言一次**。在带 GPU 的电脑上,第一次进行英语或法语转录时,还会下载为每个词重新计时的模型:英语约 110 MB,法语约 350 MB。 -- **github.com 和 api.github.com**。能够自动更新的版本每 24 小时检查一次新版本,你手动检查时也会连接。默认情况下,它们只会通知你有新版本可用。 +- **github.com 和 api.github.com**。能够自动更新的版本在启动一分钟后检查一次新版本,之后每六小时检查一次,你手动检查时也会连接。默认情况下,它们会在后台下载新版本,并在你退出 OpenScreen 时安装;在托盘菜单的 **更新设置** 中,可以改为只通知你。 - **fonts.googleapis.com,仅限 1.x 中添加的字体**。在早期版本中添加的自定义标注字体仍会在启动时加载,尽管 2.x 已不再绘制它。 - **PostHog,每天一次**。活跃安装数的匿名计数,发送到 PostHog 位于欧盟的服务器。它包含应用版本、操作系统、处理器架构、应用的安装方式、应用是被直接使用还是由 AI 智能体通过其 MCP 服务器使用,以及这是否为当周和当月的首次计数。仅此而已:没有安装标识符(每次计数都使用新的随机 ID),不保留 IP 地址,不涉及你的录制内容。要关闭它,请在托盘菜单中取消勾选**发送匿名使用计数**,或设置 `DO_NOT_TRACK=1`。 - **你的 AI 提供方,仅在你连接之后**。聊天编辑会发送你的消息以及它读取的项目数据,例如时间轴和转录文本。字幕翻译会发送字幕文本。在你连接提供方之前,这两项功能都处于关闭状态。请参阅 [AI 编辑](./ai-editing.md)。 diff --git a/website/i18n/zh-TW/docusaurus-plugin-content-docs/current/faq.md b/website/i18n/zh-TW/docusaurus-plugin-content-docs/current/faq.md index b774f201c9..5bb6f93943 100644 --- a/website/i18n/zh-TW/docusaurus-plugin-content-docs/current/faq.md +++ b/website/i18n/zh-TW/docusaurus-plugin-content-docs/current/faq.md @@ -34,7 +34,7 @@ OpenScreen 是一款採用 MIT 授權的免費螢幕錄影與影片剪輯軟體 - **huggingface.co,只有一次。** 第一次轉錄時會下載 Whisper 模型(約 264 MB)與一個小於 1 MB 的語音偵測模型,每個都以 SHA-256 雜湊值驗證。之後轉錄就不再需要網路連線。 - **github.com,每種語言一次。** 在有 GPU 的電腦上,第一次進行英文或法文轉錄時,還會下載為每個字詞重新對時的模型:英文約 110 MB,法文約 350 MB。 -- **github.com 與 api.github.com。** 會自行更新的版本每 24 小時檢查一次新版本,你手動要求時也會檢查。預設只會通知你有新版本可用。 +- **github.com 與 api.github.com。** 會自行更新的版本在啟動一分鐘後檢查一次新版本,之後每六小時檢查一次,你手動要求時也會檢查。預設會在背景下載新版本,並在你結束 OpenScreen 時安裝;在系統匣選單的 **更新設定** 中,可以改為只通知你。 - **fonts.googleapis.com,僅限 1.x 中加入的字體。** 在先前版本中加入的自訂標註字體仍會在啟動時載入,儘管 2.x 已不再繪製它。 - **PostHog,每天一次。** 活躍安裝數的匿名計數,會送到 PostHog 位於歐盟的伺服器。內容包括應用程式版本、作業系統、處理器架構、應用程式的安裝方式、應用程式是直接使用還是由 AI 代理透過其 MCP 伺服器使用,以及這是否為當週與當月的第一次計數。僅此而已:沒有安裝識別碼(每次計數都使用新的隨機 ID),不保留 IP 位址,也不含任何與你錄影有關的資訊。若要關閉,請在系統匣選單中取消勾選**傳送匿名使用計數**,或設定 `DO_NOT_TRACK=1`。 - **你的 AI 提供者,僅在你連接時。** 聊天剪輯會送出你的訊息,以及它讀取的專案資料,例如時間軸與逐字稿。字幕翻譯會送出字幕文字。在你連接提供者之前,這兩項功能都保持關閉。請參閱 [AI 剪輯](./ai-editing.md)。 From a8adc59024364dda19d4fdad69970dd98a0dedd0 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Thu, 29 Oct 2026 00:29:19 +0100 Subject: [PATCH 2/8] feat(mcp): tell the agent when a newer OpenScreen is out A user who reaches OpenScreen only through an agent never opens the app, so never sees its update button and was never updated. On an install that updates itself, `openscreen mcp` now looks the latest release up on GitHub, at most every six hours, in the background and cached in userData. When the cache names a newer version, a line opens its instructions: tell the user once that opening the app installs it, and never update OpenScreen yourself. The process updates nothing: stdout is the protocol, and the installer would end it mid-session. --- electron/cli/cliMain.ts | 21 +++- electron/mcp/openscreen-mcp-server.ts | 8 ++ electron/mcp/stdio-server.ts | 3 + electron/mcp/update-notice.test.ts | 106 ++++++++++++++++++ electron/mcp/update-notice.ts | 64 +++++++++++ .../architecture/mcp-server.md | 1 + 6 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 electron/mcp/update-notice.test.ts create mode 100644 electron/mcp/update-notice.ts diff --git a/electron/cli/cliMain.ts b/electron/cli/cliMain.ts index 410ba97458..3c23e53233 100644 --- a/electron/cli/cliMain.ts +++ b/electron/cli/cliMain.ts @@ -5,14 +5,15 @@ import fs from "node:fs/promises"; import path from "node:path"; import readline from "node:readline"; -import { app, BrowserWindow, ipcMain, Menu, session, systemPreferences } from "electron"; +import { app, BrowserWindow, ipcMain, Menu, net, session, systemPreferences } from "electron"; import type { CliProgressEvent, CliSourcesResult } from "../../src/lib/cliContracts"; import { renameWithRetry } from "../atomic-rename"; import { isDiagnosticModeEnabled } from "../diagnostics/main-log-buffer"; -import { getInstallChannel } from "../install-channel"; +import { getInstallChannel, ownsItsUpdates } from "../install-channel"; import { getSelectedDesktopSource, registerIpcHandlers } from "../ipc/handlers"; import { openVideoInHiddenWindow } from "../mcp/hidden-video"; import { serveMcpOverStdio } from "../mcp/stdio-server"; +import { readReleaseLookup, refreshReleaseLookup, updateNotice } from "../mcp/update-notice"; import { registerSttIpc, shutdownStt } from "../stt"; import { sendUsagePing, USAGE_PING_INTERVAL_MS } from "../usage-ping"; import { createRunnerWindow } from "../windows"; @@ -402,15 +403,31 @@ export function runCli(command: CliCommand): void { }); if (command.kind === "mcp") { + // Only where the app updates itself: elsewhere a package manager already does, and + // opening the app would not change that. + const tellsAboutUpdates = app.isPackaged && ownsItsUpdates(getInstallChannel()); await serveMcpOverStdio({ ...agentServices, userDataDir, runners, openVideo: openVideoInHiddenWindow, + updateNotice: tellsAboutUpdates + ? (updateNotice(app.getVersion(), readReleaseLookup(userDataDir)) ?? undefined) + : undefined, // The Whisper helper is a process of its own: it must not outlive this one. onClose: () => void shutdownStt().finally(() => app.exit(0)), }); milestone("mcp server listening on stdio"); + if (tellsAboutUpdates) { + refreshReleaseLookup({ + userData: userDataDir, + currentVersion: app.getVersion(), + fetchLatest: (url, init) => net.fetch(url, init), + }).catch(() => { + // Offline or rate-limited: the next session looks again. Nothing to report, + // and stdout is the protocol. + }); + } // An install driven only by an agent is an active install too. Same ping, state // file and opt-outs as the GUI's, and it writes nothing: stdout is the protocol. // The other commands stay silent: they are scripts and CI more often than people. diff --git a/electron/mcp/openscreen-mcp-server.ts b/electron/mcp/openscreen-mcp-server.ts index c73378b6a5..774f3ccde0 100644 --- a/electron/mcp/openscreen-mcp-server.ts +++ b/electron/mcp/openscreen-mcp-server.ts @@ -177,6 +177,9 @@ export interface McpToolDeps extends ImportMediaDeps { /** The main process's on-device Whisper (`SttManager.transcribe`). Absent: no transcribe tool. */ transcribe?(request: SttTranscribeRequest): Promise; version: string; + /** `openscreen mcp` only: a newer OpenScreen to tell the user about, for the user who never + * opens the app and so never sees its update button. */ + updateNotice?: string; } export const LIST_PROJECTS_TOOL = "listProjects"; @@ -287,9 +290,13 @@ export function mcpInstructions(options: { media?: string; /** The transcribe tool is served. */ transcribe?: boolean; + /** A newer OpenScreen to tell the user about (update-notice.ts). First, so it is not lost + * under the editing guidance. */ + updateNotice?: string; }): string { const { transport, editsAllowed, media } = options; const preamble = [ + ...(options.updateNotice ? [options.updateNotice] : []), ...(media ? [media] : []), ...CORE_PREAMBLES[transport], CHECKPOINT_LIFETIMES[transport], @@ -860,6 +867,7 @@ export function createOpenScreenMcpServer( editsAllowed: deps.editsAllowed(), media: media?.preamble, transcribe: deps.transcribe !== undefined, + updateNotice: deps.updateNotice, }), }, ); diff --git a/electron/mcp/stdio-server.ts b/electron/mcp/stdio-server.ts index e38e66ece4..e0c245bf6f 100644 --- a/electron/mcp/stdio-server.ts +++ b/electron/mcp/stdio-server.ts @@ -32,6 +32,8 @@ export async function serveMcpOverStdio(options: { runners: MediaToolDeps["runners"]; openVideo: MediaToolDeps["openVideo"]; renderFrames?: MediaToolDeps["renderFrames"]; + /** A newer OpenScreen to tell the agent about (update-notice.ts). */ + updateNotice?: string; /** The client went away: nothing is left to serve. */ onClose(): void; }): Promise { @@ -39,6 +41,7 @@ export async function serveMcpOverStdio(options: { ...options.mcpToolDeps, transport: "stdio", editsAllowed: () => true, + updateNotice: options.updateNotice, }; // The media tools' own document calls (stopRecording's checkpoint, setPointerTrack) // take the same route. diff --git a/electron/mcp/update-notice.test.ts b/electron/mcp/update-notice.test.ts new file mode 100644 index 0000000000..b4ce6947bc --- /dev/null +++ b/electron/mcp/update-notice.test.ts @@ -0,0 +1,106 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mcpInstructions } from "./openscreen-mcp-server"; +import { + RELEASE_LOOKUP_INTERVAL_MS, + readReleaseLookup, + refreshReleaseLookup, + updateNotice, +} from "./update-notice"; + +const dirs: string[] = []; +afterEach(() => { + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); + dirs.length = 0; +}); +function userData(): string { + const dir = mkdtempSync(path.join(os.tmpdir(), "os-mcp-update-")); + dirs.push(dir); + return dir; +} + +function release(tag: string) { + return vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ + tag_name: tag, + html_url: `https://github.com/getopenscreen/openscreen/releases/tag/${tag}`, + draft: false, + prerelease: false, + }), + })); +} + +describe("updateNotice", () => { + it("says nothing without a lookup, or when the running version is the latest or newer", () => { + expect(updateNotice("2.1.2", null)).toBeNull(); + expect(updateNotice("2.1.2", { latestVersion: "2.1.2", checkedAt: 0 })).toBeNull(); + expect(updateNotice("2.2.0-rc.1", { latestVersion: "2.1.2", checkedAt: 0 })).toBeNull(); + }); + + it("says nothing rather than throw on a version it cannot read", () => { + expect(updateNotice("2.1.2", { latestVersion: "not-a-version", checkedAt: 0 })).toBeNull(); + }); + + // The user updates, not the agent: the line hands them the news and keeps the agent off it. + it("names both versions and leaves the update to the user", () => { + const notice = updateNotice("2.1.2", { latestVersion: "2.2.0", checkedAt: 0 }); + expect(notice).toContain("OpenScreen 2.2.0 is available; this is 2.1.2."); + expect(notice).toContain("opening the OpenScreen app"); + expect(notice).toContain("Never update OpenScreen yourself."); + }); + + it("opens the stdio instructions, ahead of the editing guidance", () => { + const instructions = mcpInstructions({ + transport: "stdio", + editsAllowed: true, + updateNotice: "OpenScreen 2.2.0 is available.", + }); + expect(instructions.startsWith("OpenScreen 2.2.0 is available.\n")).toBe(true); + expect(mcpInstructions({ transport: "stdio", editsAllowed: true })).not.toContain( + "is available", + ); + }); +}); + +describe("refreshReleaseLookup", () => { + it("stores the latest release for the next session to read", async () => { + const dir = userData(); + await refreshReleaseLookup({ + userData: dir, + currentVersion: "2.1.2", + fetchLatest: release("v2.2.0"), + now: 1000, + }); + expect(readReleaseLookup(dir)).toEqual({ latestVersion: "2.2.0", checkedAt: 1000 }); + }); + + it("asks GitHub at most once per interval", async () => { + const dir = userData(); + const fetchLatest = release("v2.2.0"); + await refreshReleaseLookup({ userData: dir, currentVersion: "2.1.2", fetchLatest, now: 0 }); + await refreshReleaseLookup({ + userData: dir, + currentVersion: "2.1.2", + fetchLatest, + now: RELEASE_LOOKUP_INTERVAL_MS - 1, + }); + expect(fetchLatest).toHaveBeenCalledTimes(1); + await refreshReleaseLookup({ + userData: dir, + currentVersion: "2.1.2", + fetchLatest, + now: RELEASE_LOOKUP_INTERVAL_MS, + }); + expect(fetchLatest).toHaveBeenCalledTimes(2); + }); + + it("reads a corrupt cache as no lookup at all", () => { + const dir = userData(); + writeFileSync(path.join(dir, "mcp-update-check.json"), "{not json"); + expect(readReleaseLookup(dir)).toBeNull(); + }); +}); diff --git a/electron/mcp/update-notice.ts b/electron/mcp/update-notice.ts new file mode 100644 index 0000000000..2d4d3c5a90 --- /dev/null +++ b/electron/mcp/update-notice.ts @@ -0,0 +1,64 @@ +// What `openscreen mcp` tells the agent about a newer OpenScreen, for the user who only ever +// reaches OpenScreen through an agent and so never sees the app's update button. +// +// This process updates nothing. Its stdout is the protocol, its lifetime is the client's, and an +// installer would end it mid-session. It only looks the latest release up, at most every six +// hours, and says so in its instructions; the agent passes it on, and the user updates. The +// lookup runs in the background and is cached, so the handshake never waits on the network: the +// session that finds a release says nothing, the next one does. + +import { readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { checkLatestRelease, compareVersions } from "../update-checker"; + +const CACHE_FILE = "mcp-update-check.json"; +export const RELEASE_LOOKUP_INTERVAL_MS = 6 * 60 * 60 * 1000; + +interface ReleaseLookup { + latestVersion: string; + checkedAt: number; +} + +type FetchLatest = Parameters[0]["fetchLatest"]; + +export function readReleaseLookup(userData: string): ReleaseLookup | null { + try { + const raw = JSON.parse(readFileSync(path.join(userData, CACHE_FILE), "utf8")) as unknown; + const { latestVersion, checkedAt } = (raw ?? {}) as Partial; + if (typeof latestVersion !== "string" || typeof checkedAt !== "number") return null; + return { latestVersion, checkedAt }; + } catch { + return null; + } +} + +/** The line for the instructions, or null when there is nothing newer to tell. */ +export function updateNotice(currentVersion: string, lookup: ReleaseLookup | null): string | null { + if (!lookup) return null; + try { + if (compareVersions(lookup.latestVersion, currentVersion) <= 0) return null; + } catch { + return null; + } + return `OpenScreen ${lookup.latestVersion} is available; this is ${currentVersion}. Tell the user once, when it fits, that they can install it by opening the OpenScreen app. Never update OpenScreen yourself.`; +} + +/** Looks the latest release up if the cached answer is older than the interval. Throws on a + * network or GitHub failure; the caller drops it, as the next session tries again. */ +export async function refreshReleaseLookup(options: { + userData: string; + currentVersion: string; + fetchLatest: FetchLatest; + now?: number; +}): Promise { + const now = options.now ?? Date.now(); + const cached = readReleaseLookup(options.userData); + if (cached && now - cached.checkedAt < RELEASE_LOOKUP_INTERVAL_MS) return; + const result = await checkLatestRelease({ + currentVersion: options.currentVersion, + fetchLatest: options.fetchLatest, + signal: AbortSignal.timeout(10_000), + }); + const lookup: ReleaseLookup = { latestVersion: result.latestVersion, checkedAt: now }; + writeFileSync(path.join(options.userData, CACHE_FILE), `${JSON.stringify(lookup)}\n`, "utf8"); +} diff --git a/technical-documentation/architecture/mcp-server.md b/technical-documentation/architecture/mcp-server.md index 446212da47..7165ec2b0f 100644 --- a/technical-documentation/architecture/mcp-server.md +++ b/technical-documentation/architecture/mcp-server.md @@ -64,6 +64,7 @@ The server's one resource, `openscreen://effects/contract` ([`effects-contract.t Every text a client reads must be true on the server that serves it, so the texts are composed rather than shared verbatim. `McpToolDeps.transport` (`"http"` or `"stdio"`) selects them: - **Instructions** (`mcpInstructions`): the media tools' capability block (`mediaPreamble`), then the projects-and-checkpoints rules of the transport, then how long checkpoints live there, then `buildSystemPrompt`. The checkpoint rule is in both. Over HTTP the media block says a take goes through the app's recording bar and opens in the editor, and the rules speak of the editor and its undo stack; while **MCP edits** is off, a line says `startRecording`, `exportVideo` and `createProject` are refused too. Over stdio the media block says the takes land in the app's project list, and the rules say that calls go through the app while it runs (the open project live, with `Ctrl/Cmd+Z`), and to the files when it does not. +- **A newer OpenScreen** (`update-notice.ts`), stdio only, and only on an install that updates itself (a Store, Flathub, Snap or Nix copy is kept current by its package manager). A user who reaches OpenScreen only through an agent never sees the app's update button, so `openscreen mcp` looks the latest release up on GitHub, at most every six hours, in the background and cached in `mcp-update-check.json`. When the cache names a newer version, a line opens the instructions: tell the user once that opening the app installs it, and never update OpenScreen yourself. The process itself updates nothing: its stdout is the protocol, its lifetime is the client's, and the NSIS installer would end it. The lookup never holds the handshake back, so the session that finds a release says nothing and the next one does. - **`buildSystemPrompt`** takes the transport's facts as options: `editsSetting` names the switch its consent block points to (the MCP server's **MCP edits**, not the in-app agent's **Agent edits**), and `canExport` drops "exporting" from its examples of a request with no tool, since both serve `exportVideo`. - **Tool descriptions**: `projectId` is "omit it for the open project" over HTTP and "omit it only for the open project, while the app runs" over stdio. `startRecording` and `stopRecording` say how each server makes a take ([below](#the-media-tools)), and the paths of `exportVideo` and `getFrames` must be absolute over HTTP: the app has no working directory of the client's. - **Messages**: no project to fall back on, a checkpoint required, an unknown checkpoint, edits turned off. Each has a version per transport, or exists only on the one that can produce it. The stdio versions are only met with no app answering, and say so. From 2abfc7bac911568b8d36f22403df0d5453add011 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Thu, 29 Oct 2026 00:40:35 +0100 Subject: [PATCH 3/8] docs(testing): an end-to-end pass for updating in place The update path is the one a release cannot fix afterwards: a build with a broken updater can only be left through it. The checklist gains a section that says when it is mandatory, why a dev build cannot run it, how to get a newer version to update to (the RC path on every platform, a local generic feed on Windows and Linux), how to tell an install happened, and the checks: button, click, install on quit, elevation, a live agent session, the release-page fallback and the agent-only notice. AGENTS.md points agents at it from the computer-use mechanics. --- AGENTS.md | 7 +++ .../testing/manual-e2e-checklist.md | 50 +++++++++++++++++-- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e9557c6b75..67b03a1b70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,6 +134,13 @@ This section is the *mechanics*. **What to actually run is [`technical-documenta - Because the HUD skips the taskbar and can be minimized/hidden, the **system-tray icon is the reliable way to refocus the app**: **a click or double-click brings back the app's window** (`showMainWindow`: the HUD, or the editor while it is open), and so does its menu's *Open*. Its icon swaps to a red dot while recording. - **Right-click → context menu**: when idle, *Open*, *Update to * (only while a background check has found one; the same click as the HUD's and the editor's blue update button), *Check for Updates* (not on a Store, Flathub, Snap or Nix install), *Update Settings* (packaged builds that update themselves), *About OpenScreen*, *Save Diagnostics*, *Permissions…* (macOS), *Quit*; while recording, only ***Stop Recording*** (mirrors the HUD's stop). Tooltip shows `OpenScreen` or `Recording: `. Use this to stop a recording if the HUD isn't reachable. +**Updates** + +- **Run the update pass on every release candidate and after any change to the update path.** A build with a broken updater can only be left through that updater, so a defect here strands every user who installs it. What to run, and how to get a newer version to update to: [Updating in place](technical-documentation/testing/manual-e2e-checklist.md#updating-in-place--post-212), in the checklist. +- **It needs an installed build.** The background check, the blue update button and the install on quit all require `app.isPackaged`: a dev build shows none of them, and that absence proves nothing. The Store package does not apply either; the Store updates it. +- **On macOS, only the RC path works:** install `rc.N`, publish `rc.N+1`. Editing `app-update.yml` inside a signed `.app`, which is how the local feed works on Windows and Linux, breaks the bundle's seal, and Squirrel then refuses the update. +- **The agent session check is the one that matters most to you.** In the default mode the app installs on quit, and the Windows installer ends every `Openscreen.exe`, an `openscreen mcp` session included. Keep one running while you quit OpenScreen and confirm it survives and nothing installs. + **End-to-end flow (record → edit)** 1. On the HUD: click the **camera** toggle (the gear picks which camera), then the **source** button → pick the *Screens*/*Windows* tab → select a thumbnail → **Select**. On macOS 15.2+ the source button opens Apple's picker instead; on Linux there is no source button, and the portal's picker appears once you press record. diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 2d8dab3585..e0677f047e 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -698,10 +698,6 @@ The inspector's rail holds five facets: Composition (background, format, frame, - [ ] **post-1.10.0** — Confirm the submenu is absent in a dev build and in a build on a channel that does not own its updates (`app.isPackaged && ownsItsUpdates`), rather than present and inert. - [ ] **post-1.10.0** — Confirm a background check that finds nothing shows no dialog at all — the background path never reports "you are current". - [ ] **post-1.10.0** — Confirm a failed or unavailable download stops short of the restart prompt rather than offering to restart into an installer that was never fetched. -- [ ] **post-2.1.2** — Install the previous release, launch it and wait a minute. Confirm no dialog opens; a blue update button appears in the HUD bar and in the editor's top bar, and the tray gains *Update to *. In the two download modes it appears once the download has finished; in "Notify" at once. -- [ ] **post-2.1.2** — Click the button. In the two download modes the app restarts into the new version; in "Notify" it downloads, then asks to restart. Start a recording first and confirm the button is gone for the length of the take. -- [ ] **post-2.1.2** — In "Download and install updates automatically", quit with the button showing. Confirm no installer window and no relaunch, and that the next launch is the new version. On a per-machine Windows install, and on deb, rpm or pacman, confirm the quit installs nothing and raises no UAC or password prompt: there the button is the way. -- [ ] **post-2.1.2** — Same mode, with an agent connected through `openscreen mcp` (Claude Code or Codex). Quit OpenScreen and confirm nothing installs and the agent's session survives. Disconnect the agent, launch and quit again, and confirm the update installs. - [ ] **post-1.10.0** — Reach **Save Diagnostics** from the tray context menu while idle, and from the Help menu on Windows and Linux (Alt) or the app menu on macOS; confirm each writes a bundle. It is deliberately not in the wordmark menu. - [ ] With the wordmark menu open, walk it with the Down and Up arrows and confirm focus wraps at both ends. - [ ] Switch the app language and confirm the wordmark menu's labels follow — Keyboard Shortcuts and AI settings matching the dialogs they open, Check for Updates and About OpenScreen the wording the macOS app menu and the tray use. @@ -715,6 +711,52 @@ The inspector's rail holds five facets: Composition (background, format, frame, - [ ] Open the HUD's device-settings panel, start a recording with it still open, and confirm the update button disappears while the version stays. Then stop the take, reopen the panel, and confirm the button is back — a HUD that mounts mid-take must not lose it permanently. - [ ] On a Microsoft Store, Flathub, Snap, or Nix install, confirm no update affordance appears in the menu, the tray, or the HUD panel, while the version still shows. +### Updating in place — post-2.1.2 + +**Why this section is never skipped on a candidate.** An update is the one path a release cannot fix after the fact: a build whose updater is broken can only be left through that broken updater, so everyone who installs it stays on it. Run this section for every release candidate, on every platform, and for any change to `auto-updater.ts`, `background-update.ts`, `install-channel.ts`, `update-checker.ts`, `mcp/update-notice.ts`, the `before-quit` handler in `main.ts`, the NSIS or publish config in `electron-builder.json5`, or the feed scripts (`scripts/mac-update-feed.mjs`). + +**A dev build cannot run it.** The background check, the button and the install on quit all require `app.isPackaged`, so `npm run dev` shows none of them. Test an installed build, on a channel that updates itself: NSIS, DMG, AppImage, deb, rpm or pacman. Never the Store package, which is kept current by the Store. + +**Getting a newer version to update to.** Two ways, both against an installed build of the code under test. + +- **The release candidate path, every platform.** Install `rc.N` from its GitHub pre-release, then publish `rc.N+1` (the normal RC flow, see `release-and-secrets.md`). An installed RC follows the newest `rc` pre-release on GitHub, not the stable release, and every RC publishes `latest.yml`, `latest-mac.yml` and `latest-linux.yml`, so `rc.N` finds `rc.N+1` on its own. This exercises the code of `rc.N`, which is the point. The only way on macOS: editing anything inside a signed `.app`, `app-update.yml` included, breaks its seal, and Squirrel then refuses the update. +- **A local feed, Windows and Linux, for a change before any RC exists.** Install the CI-built artifact of the branch (per user on Windows, so no UAC prompt gets in the way). Keep a copy of `resources/app-update.yml` in the install folder, then replace its content with: + + ```yaml + provider: generic + url: http://127.0.0.1:8765/ + updaterCacheDirName: openscreen-updater + ``` + + In an empty folder, put any working installer of the same platform and architecture: the previous release, or the same artifact again. Its file name must contain `x64` or `arm64` (the architecture electron-updater matches). Write `latest.yml` beside it (`latest-linux.yml` for an AppImage), naming a version above the installed one, for example `9.9.9`: + + ```yaml + version: 9.9.9 + files: + - url: Openscreen.Setup.2.1.2-x64.exe + sha512: + size: + path: Openscreen.Setup.2.1.2-x64.exe + sha512: + ``` + + Get the digest with `node -e "console.log(require('crypto').createHash('sha512').update(require('fs').readFileSync(process.argv[1])).digest('base64'))" `. It must be base64, not hex: a hex digest fails every download with a checksum mismatch. Serve the folder on that port from the folder itself, for example with `node -e "require('http').createServer((q,s)=>require('fs').createReadStream('.'+decodeURIComponent(q.url)).on('error',()=>{s.statusCode=404;s.end()}).pipe(s)).listen(8765,'127.0.0.1')"`. + + **How to tell an install happened.** The installed version may not move: a CI artifact carries the version in `package.json`, often the same as the last release. The tell is `app-update.yml`: the installer writes its own, so once an update went through, the file is back to `provider: github`. + +**Before each run, and to clean up.** Empty the updater's cache, or a download from the previous run is reused without being fetched: `%LOCALAPPDATA%\openscreen-updater`, `~/Library/Caches/openscreen-updater` or `~/.cache/openscreen-updater`. Delete `update-settings.json` in `userData` to start from the default mode. Afterwards, restore the saved `app-update.yml` or reinstall, delete `mcp-update-check.json` from `userData`, and stop the local server. + +**What to watch.** The first check runs a minute after launch, so wait at least that, plus the download. Nothing logs the updater itself; a failed check or download logs one `[updates]` line, which tray → *Save Diagnostics* keeps. + +- [ ] **post-2.1.2** — Launch the installed build and wait. Confirm no dialog opens at any point. A blue update button appears in the HUD bar, just before the language button, and in the editor's top bar, just before Export, and the tray gains *Update to *. In the two download modes it appears once the download has finished; in "Notify" at once. +- [ ] **post-2.1.2** — Start a recording and confirm the button is gone for the length of the take, then back. +- [ ] **post-2.1.2** — Click the button. In the two download modes the app restarts into the update; in "Notify" it downloads, then asks to restart. +- [ ] **post-2.1.2** — In "Download and install updates automatically", quit with the button showing (*Quit OpenScreen* on the HUD, or tray → *Quit*). Confirm no installer window and no relaunch, and that the update is installed when you next launch. +- [ ] **post-2.1.2** — Same mode, on a per-machine Windows install and on a deb, rpm or pacman install: quit with the button showing and confirm nothing installs and no UAC or password prompt appears. There the button is the only way, and clicking it does raise the prompt. +- [ ] **post-2.1.2** — Same mode, with an agent's session open: start `Openscreen mcp` in a terminal and leave it waiting on its input, or connect Claude Code or Codex to it. Quit OpenScreen and confirm nothing installs and the `mcp` process is still alive. Stop it, launch and quit again, and confirm the update installs. +- [ ] **post-2.1.2** — Stop the local server, or point `url` at a port nothing serves, and relaunch. Confirm the button still appears and opens the release page in the browser: when the self-updater fails, the release lookup on GitHub is the way out. This needs a GitHub release newer than the installed version, so run it on an RC whose stable successor is not out yet, or on any build older than the latest release. +- [ ] **post-2.1.2** — The agent-only user. On the installed build, write `{"latestVersion":"9.9.9","checkedAt":}` to `mcp-update-check.json` in `userData`, then start `Openscreen mcp` and send it an `initialize` request. Confirm the `instructions` in the answer open with "OpenScreen 9.9.9 is available" and tell the agent never to update OpenScreen itself. Delete the file, and confirm a Store install, or a dev build, says nothing even with it present. No computer-use is needed: a client that prints the `initialize` answer is enough, such as a short Node script that spawns the process, writes one JSON-RPC line and reads the first non-blank line back. On Windows the first line Electron writes is blank. + ### New effects and controls — v1.8.0 - [ ] Type a zoom level outside the preset row (1.25, say) in the *Custom zoom* field, commit it, and confirm the preview scale and the retained value both follow, with no preset pressed. From ab1b84c50636d7e61b3e6540f5f380d76746c6a8 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Thu, 29 Oct 2026 00:50:56 +0100 Subject: [PATCH 4/8] refactor(update): one update flow, installed only by a click Prunes the update path down to what Codex and VS Code do: check in the background, download in the background, show an update button, and install when the user clicks it. Removed: - the three update modes and their tray submenu (notify, download, download and install), with `update-settings.ts` and the four labels in every locale. Every install now downloads in the background. - the install on quit, with the elevation probe and the live CLI session check it needed. Nothing installs unless the button is clicked, so no update can end a take, an export or an agent's `openscreen mcp` session on its own, nor ask for an admin password on the way out. The FAQ, the e2e checklist, AGENTS.md and the MCP doc follow. --- AGENTS.md | 6 +- electron/auto-updater.test.ts | 67 +--------------- electron/auto-updater.ts | 46 +---------- electron/background-update.test.ts | 40 ---------- electron/background-update.ts | 58 ++------------ electron/cli/sessionData.test.ts | 42 +--------- electron/cli/sessionData.ts | 31 -------- electron/main.ts | 77 +++---------------- electron/update-settings.test.ts | 48 ------------ electron/update-settings.ts | 26 ------- .../ai-edition/v4/EditorTopBar.test.tsx | 4 +- src/i18n/locales/ar/common.json | 4 - src/i18n/locales/cs/common.json | 4 - src/i18n/locales/de/common.json | 4 - src/i18n/locales/en/common.json | 4 - src/i18n/locales/es/common.json | 4 - src/i18n/locales/fr/common.json | 4 - src/i18n/locales/it/common.json | 4 - src/i18n/locales/ja-JP/common.json | 4 - src/i18n/locales/ko-KR/common.json | 4 - src/i18n/locales/pt-BR/common.json | 4 - src/i18n/locales/ru/common.json | 4 - src/i18n/locales/tr/common.json | 4 - src/i18n/locales/vi/common.json | 4 - src/i18n/locales/zh-CN/common.json | 4 - src/i18n/locales/zh-TW/common.json | 4 - .../architecture/mcp-server.md | 2 +- .../testing/manual-e2e-checklist.md | 18 ++--- website/docs/faq.md | 2 +- .../current/faq.md | 2 +- .../current/faq.md | 2 +- .../current/faq.md | 2 +- .../current/faq.md | 2 +- .../current/faq.md | 2 +- .../current/faq.md | 2 +- .../current/faq.md | 2 +- 36 files changed, 46 insertions(+), 495 deletions(-) delete mode 100644 electron/update-settings.test.ts delete mode 100644 electron/update-settings.ts diff --git a/AGENTS.md b/AGENTS.md index 67b03a1b70..2a8faf3405 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,14 +132,14 @@ This section is the *mechanics*. **What to actually run is [`technical-documenta **The tray icon** (notification area on Windows, menu bar on macOS, the desktop's indicator area on Linux) - Because the HUD skips the taskbar and can be minimized/hidden, the **system-tray icon is the reliable way to refocus the app**: **a click or double-click brings back the app's window** (`showMainWindow`: the HUD, or the editor while it is open), and so does its menu's *Open*. Its icon swaps to a red dot while recording. -- **Right-click → context menu**: when idle, *Open*, *Update to * (only while a background check has found one; the same click as the HUD's and the editor's blue update button), *Check for Updates* (not on a Store, Flathub, Snap or Nix install), *Update Settings* (packaged builds that update themselves), *About OpenScreen*, *Save Diagnostics*, *Permissions…* (macOS), *Quit*; while recording, only ***Stop Recording*** (mirrors the HUD's stop). Tooltip shows `OpenScreen` or `Recording: `. Use this to stop a recording if the HUD isn't reachable. +- **Right-click → context menu**: when idle, *Open*, *Update to * (only while a background check has found one; the same click as the HUD's and the editor's blue update button), *Check for Updates* (not on a Store, Flathub, Snap or Nix install), *About OpenScreen*, *Save Diagnostics*, *Permissions…* (macOS), *Quit*; while recording, only ***Stop Recording*** (mirrors the HUD's stop). Tooltip shows `OpenScreen` or `Recording: `. Use this to stop a recording if the HUD isn't reachable. **Updates** - **Run the update pass on every release candidate and after any change to the update path.** A build with a broken updater can only be left through that updater, so a defect here strands every user who installs it. What to run, and how to get a newer version to update to: [Updating in place](technical-documentation/testing/manual-e2e-checklist.md#updating-in-place--post-212), in the checklist. -- **It needs an installed build.** The background check, the blue update button and the install on quit all require `app.isPackaged`: a dev build shows none of them, and that absence proves nothing. The Store package does not apply either; the Store updates it. +- **It needs an installed build.** The background check and the blue update button both require `app.isPackaged`: a dev build shows neither, and that absence proves nothing. The Store package does not apply either; the Store updates it. - **On macOS, only the RC path works:** install `rc.N`, publish `rc.N+1`. Editing `app-update.yml` inside a signed `.app`, which is how the local feed works on Windows and Linux, breaks the bundle's seal, and Squirrel then refuses the update. -- **The agent session check is the one that matters most to you.** In the default mode the app installs on quit, and the Windows installer ends every `Openscreen.exe`, an `openscreen mcp` session included. Keep one running while you quit OpenScreen and confirm it survives and nothing installs. +- **Only the click installs.** Quitting with the button showing must install nothing. If an update installs on its own, at quit or at any other moment, that is a defect, not a feature to note. **End-to-end flow (record → edit)** diff --git a/electron/auto-updater.test.ts b/electron/auto-updater.test.ts index 80fdb45706..3f60a2ec2e 100644 --- a/electron/auto-updater.test.ts +++ b/electron/auto-updater.test.ts @@ -1,15 +1,10 @@ -import { mkdtempSync, readdirSync, rmSync } from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { blockedFromInstalling, checkForSelfUpdate, downloadSelfUpdate, type InstallReadiness, - installNeedsElevation, installSelfUpdate, - setInstallOnQuit, } from "./auto-updater"; const mocks = vi.hoisted(() => ({ @@ -160,64 +155,4 @@ describe("self-update flow", () => { await installSelfUpdate(); expect(mocks.autoUpdater.quitAndInstall).toHaveBeenCalledWith(false, true); }); - - // electron-updater only registers its quit handler if the flag is on when the download ends, - // and only installs if it is still on at quit. Both halves have to follow the switch. - it("applies install-on-quit at the next call and flips it live once loaded", async () => { - mocks.autoUpdater.checkForUpdates.mockResolvedValue({ updateInfo: { version: "1.9.2" } }); - try { - setInstallOnQuit(true); - await checkForSelfUpdate("nsis"); - expect(mocks.autoUpdater.autoInstallOnAppQuit).toBe(true); - // From before-quit: synchronous, no updater call in between. - setInstallOnQuit(false); - expect(mocks.autoUpdater.autoInstallOnAppQuit).toBe(false); - } finally { - setInstallOnQuit(false); - } - }); -}); - -describe("installNeedsElevation", () => { - const dirs: string[] = []; - afterEach(() => { - for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); - dirs.length = 0; - }); - function installDir(): string { - const dir = mkdtempSync(path.join(os.tmpdir(), "os-install-")); - dirs.push(dir); - return dir; - } - - it("always needs it for a package the system package manager installed", () => { - for (const channel of ["deb", "rpm", "pacman"] as const) { - expect(installNeedsElevation(channel, path.join(installDir(), "openscreen"))).toBe(true); - } - }); - - it("never asks on macOS, where Squirrel installs", () => { - expect(installNeedsElevation("dmg", "/nonexistent/Openscreen.app/Contents/MacOS/x")).toBe( - false, - ); - }); - - it("needs none where the install directory is writable, and leaves nothing behind", () => { - const dir = installDir(); - expect(installNeedsElevation("nsis", path.join(dir, "Openscreen.exe"))).toBe(false); - expect(readdirSync(dir)).toEqual([]); - }); - - it("needs it where the install directory cannot be written", () => { - const missing = path.join(installDir(), "gone", "Openscreen.exe"); - expect(installNeedsElevation("nsis", missing)).toBe(true); - }); - - it("probes the AppImage's own folder, not the mount it runs from", () => { - const dir = installDir(); - const mount = path.join(dir, "missing-mount", "openscreen"); - expect(installNeedsElevation("appimage", mount, path.join(dir, "Openscreen.AppImage"))).toBe( - false, - ); - }); }); diff --git a/electron/auto-updater.ts b/electron/auto-updater.ts index 36a03a0c89..fdee80d2bb 100644 --- a/electron/auto-updater.ts +++ b/electron/auto-updater.ts @@ -8,10 +8,7 @@ // bundle and come along. What does NOT come along is anything under userData — recordings, // projects, and the ~500 MB STT model — which is exactly what we want. -import { mkdtempSync, rmdirSync } from "node:fs"; -import path from "node:path"; import { app } from "electron"; -import type { AppUpdater } from "electron-updater"; import type { InstallChannel } from "./install-channel"; import { ownsItsUpdates } from "./install-channel"; @@ -39,59 +36,24 @@ export function blockedFromInstalling(state: InstallReadiness): string | null { return null; } -/** Whether a downloaded update installs as the app quits. Off unless the user's update mode asks - * for it, and main turns it back off at quit when `shouldInstallOnQuit` says not now. */ -let installOnQuit = false; -/** Set once electron-updater has been imported, so `setInstallOnQuit` can reach it synchronously - * from `before-quit` without importing it there. */ -let loadedUpdater: AppUpdater | null = null; - -/** electron-updater reads the flag twice. At download, where only a `true` registers its quit - * handler (and, on macOS, hands the update to Squirrel, which then installs on any quit), and at - * quit, where the handler skips the install if it is `false` by then. */ -export function setInstallOnQuit(enabled: boolean): void { - installOnQuit = enabled; - if (loadedUpdater) loadedUpdater.autoInstallOnAppQuit = enabled; -} - /** Loaded lazily: importing electron-updater costs startup time on every launch, and the * channels that cannot use it must not pay for it at all. */ async function getUpdater() { const { autoUpdater } = await import("electron-updater"); - // Never download behind the user's back from here: every download is an explicit - // `downloadSelfUpdate`, made by the update mode or by a click. + // Never surprise the user with a 240 MB download or an install they did not ask for. + // `window-all-closed` quits this app, and the HUD is a window — with the default + // `autoInstallOnAppQuit` simply closing the HUD would kick off an installer. // // Reapplied on every call rather than set once behind a flag: these are three property // writes, so memoising them buys nothing measurable, and a "configured" flag would mean // anything that later reset them is never corrected — while making the guarantee hold only // for whichever caller happened to come first. autoUpdater.autoDownload = false; - autoUpdater.autoInstallOnAppQuit = installOnQuit; + autoUpdater.autoInstallOnAppQuit = false; autoUpdater.logger = null; - loadedUpdater = autoUpdater; return autoUpdater; } -/** Would replacing this install prompt for rights the user has to grant? deb, rpm and pacman always - * go through pkexec. For NSIS and AppImage the install directory is probed by writing into it, - * because on Windows `fs.access` ignores ACLs and calls Program Files writable. macOS is not - * asked: Squirrel installs there. */ -export function installNeedsElevation( - channel: InstallChannel, - execPath: string = process.execPath, - appImage: string | undefined = process.env.APPIMAGE, -): boolean { - if (channel === "deb" || channel === "rpm" || channel === "pacman") return true; - if (channel === "dmg") return false; - const dir = path.dirname(channel === "appimage" && appImage ? appImage : execPath); - try { - rmdirSync(mkdtempSync(path.join(dir, ".update-probe-"))); - return false; - } catch { - return true; - } -} - /** Is an update available, and can this install apply it itself? */ export async function checkForSelfUpdate(channel: InstallChannel): Promise { if (!ownsItsUpdates(channel) || !app.isPackaged) return { kind: "unsupported" }; diff --git a/electron/background-update.test.ts b/electron/background-update.test.ts index 61d82a158e..336b81410a 100644 --- a/electron/background-update.test.ts +++ b/electron/background-update.test.ts @@ -1,8 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { - planBackgroundUpdate, runUnblockedDownloadAndInstall, - shouldInstallOnQuit, shouldQuitAndInstallAfterRestartPrompt, shouldStartBackgroundUpdateTimer, } from "./background-update"; @@ -18,31 +16,6 @@ describe("background update policy", () => { 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({ @@ -66,19 +39,6 @@ describe("background update policy", () => { expect(shouldQuitAndInstallAfterRestartPrompt(0)).toBe(true); }); - // The NSIS installer ends every process of this executable, so an install nobody clicked for - // must not cut an agent's `openscreen mcp` session short, nor raise a UAC or pkexec prompt on - // the way out. - it("installs on quit only in its mode, without elevation and with no CLI run alive", () => { - const clear = { needsElevation: false, cliSessionAlive: false }; - expect(shouldInstallOnQuit({ ...clear, mode: "download-and-install" })).toBe(true); - expect(shouldInstallOnQuit({ ...clear, mode: "download" })).toBe(false); - expect(shouldInstallOnQuit({ ...clear, mode: "notify" })).toBe(false); - const auto = { ...clear, mode: "download-and-install" as const }; - expect(shouldInstallOnQuit({ ...auto, needsElevation: true })).toBe(false); - expect(shouldInstallOnQuit({ ...auto, cliSessionAlive: true })).toBe(false); - }); - 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({ diff --git a/electron/background-update.ts b/electron/background-update.ts index d1ce9ed36b..e078f8f8fb 100644 --- a/electron/background-update.ts +++ b/electron/background-update.ts @@ -1,15 +1,10 @@ import type { UpdateOutcome } from "./auto-updater"; -/** How far a background-discovered update may go on its own. None of them opens a dialog: a - * background find only ever becomes the update button (`UpdateBadge`). `download-and-install` - * also installs the downloaded update when the app quits, silently and without relaunching, - * unless `shouldInstallOnQuit` says not now. Closing the last window quits this app, so that - * quit is an ordinary one, and the installer is already on disk by then. */ -export type UpdateMode = "notify" | "download" | "download-and-install"; +// One behaviour, the one Codex and VS Code have: check in the background, download in the +// background, then show an update button. Nothing installs until the user clicks it, so no update +// ever interrupts a take, an export or an agent's `openscreen mcp` session on its own, and no +// install ever asks for an admin password on the way out. -/** Keeping users current is the point, so the default goes all the way. A mode the user picked in - * the tray is stored and wins. */ -export const DEFAULT_UPDATE_MODE: UpdateMode = "download-and-install"; /** A minute after launch, then every six hours. Most sessions end long before 24 hours, so a check * that only ran once a day of uptime almost never ran at all. */ export const BACKGROUND_UPDATE_FIRST_CHECK_MS = 60 * 1000; @@ -17,30 +12,13 @@ export const BACKGROUND_UPDATE_INTERVAL_MS = 6 * 60 * 60 * 1000; /** What a background find shows: a button in the HUD and the editor, and a tray item. */ export interface UpdateBadge { - /** `available`: found, not downloaded, so the click downloads it (or opens the release page, - * when only the release lookup worked). `ready`: downloaded, so the click restarts into it. */ + /** `ready`: downloaded, so the click restarts into it. `available`: the background download + * failed, so the click downloads it again; or the self-updater failed and only the release + * lookup found it, so the click opens the release page. */ state: "available" | "ready"; version: string; } -/** May the downloaded update install while the app quits? Only in the mode that says so; only - * where it needs no rights the user would be prompted for on the way out (UAC for a per-machine - * NSIS install, pkexec for deb/rpm/pacman); and never while a CLI run is alive, because the NSIS - * installer ends every process of this executable, `openscreen mcp` driven by an agent included. - * The update button does not ask this: a click is the user deciding. */ -export function shouldInstallOnQuit(input: { - mode: UpdateMode; - needsElevation: boolean; - cliSessionAlive: boolean; -}): boolean { - return input.mode === "download-and-install" && !input.needsElevation && !input.cliSessionAlive; -} - -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; @@ -48,28 +26,6 @@ export function shouldStartBackgroundUpdateTimer(input: { 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; } diff --git a/electron/cli/sessionData.test.ts b/electron/cli/sessionData.test.ts index dffd842518..d770b6a667 100644 --- a/electron/cli/sessionData.test.ts +++ b/electron/cli/sessionData.test.ts @@ -1,8 +1,5 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { hasLiveCliSession, isAbandonedSession, type SessionOwner } from "./sessionData"; +import { describe, expect, it } from "vitest"; +import { isAbandonedSession, type SessionOwner } from "./sessionData"; const DAY = 24 * 60 * 60 * 1000; const self: SessionOwner = { host: "box", pidNamespace: "pid:[1]", pid: 100 }; @@ -34,38 +31,3 @@ describe("isAbandonedSession", () => { expect(isAbandonedSession({ ...self, host: "other" }, 8 * DAY, self, alive)).toBe(true); }); }); - -describe("hasLiveCliSession", () => { - const dirs: string[] = []; - afterEach(() => { - for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true }); - dirs.length = 0; - }); - function userData(): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "os-cli-sessions-")); - dirs.push(dir); - return dir; - } - function addRun(root: string, owner: SessionOwner | null) { - const run = path.join(root, "cli-sessions", "200-abc"); - fs.mkdirSync(run, { recursive: true }); - if (owner) fs.writeFileSync(path.join(run, "owner.json"), JSON.stringify(owner)); - } - - it("finds none when no CLI run ever started", () => { - expect(hasLiveCliSession(userData(), alive, self)).toBe(false); - }); - - it("sees an agent's `openscreen mcp` run while its pid lives, and not after", () => { - const root = userData(); - addRun(root, { ...self, pid: 200 }); - expect(hasLiveCliSession(root, alive, self)).toBe(true); - expect(hasLiveCliSession(root, dead, self)).toBe(false); - }); - - it("does not count a folder whose run never wrote its owner", () => { - const root = userData(); - addRun(root, null); - expect(hasLiveCliSession(root, alive, self)).toBe(false); - }); -}); diff --git a/electron/cli/sessionData.ts b/electron/cli/sessionData.ts index 2d3ed303d6..0d2ade8721 100644 --- a/electron/cli/sessionData.ts +++ b/electron/cli/sessionData.ts @@ -64,37 +64,6 @@ function isProcessAlive(pid: number): boolean { } } -/** - * Whether a CLI run is alive: `openscreen mcp` started by an agent, an export, anything that - * went through `isolateCliSessionData`. The app asks before installing an update on quit, since - * the NSIS installer ends every process of this executable. A folder with no readable owner - * counts as no run: it is one that died before writing it, or one a few milliseconds old. - */ -export function hasLiveCliSession( - userData: string, - isAlive: (pid: number) => boolean = isProcessAlive, - self: SessionOwner = currentOwner(), -): boolean { - const root = path.join(userData, CLI_SESSIONS_DIR); - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(root, { withFileTypes: true }); - } catch { - return false; - } - return entries.some((entry) => { - if (!entry.isDirectory()) return false; - const ownerPath = path.join(root, entry.name, OWNER_FILE); - try { - const owner = JSON.parse(fs.readFileSync(ownerPath, "utf8")) as SessionOwner; - const ageMs = Date.now() - fs.statSync(ownerPath).mtimeMs; - return !isAbandonedSession(owner, ageMs, self, isAlive); - } catch { - return false; - } - }); -} - async function readOwner(dir: string): Promise<{ owner: SessionOwner | null; ageMs: number }> { const ownerPath = path.join(dir, OWNER_FILE); try { diff --git a/electron/main.ts b/electron/main.ts index bb5ad40e96..2f8965c657 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -26,24 +26,18 @@ import { blockedFromInstalling, checkForSelfUpdate, downloadSelfUpdate, - installNeedsElevation, installSelfUpdate, - setInstallOnQuit, type UpdateOutcome, } from "./auto-updater"; import { BACKGROUND_UPDATE_FIRST_CHECK_MS, BACKGROUND_UPDATE_INTERVAL_MS, - planBackgroundUpdate, runUnblockedDownloadAndInstall, - shouldInstallOnQuit, shouldStartBackgroundUpdateTimer, type UpdateBadge, - type UpdateMode, } from "./background-update"; import { parseCliArgs } from "./cli/args"; import { runCli } from "./cli/cliMain"; -import { hasLiveCliSession } from "./cli/sessionData"; import { disableHttpCacheForDevServer } from "./dev-http-cache"; import { mainLogBuffer } from "./diagnostics/main-log-buffer"; import { buildEditMenuSubmenu, type EditorUndoRedoChannel, routeEditorUndoRedo } from "./edit-menu"; @@ -79,7 +73,6 @@ import { RecordingsDirManager } from "./recording/recordingsDirManager"; import { offersStarPrompt, REPO_URL, storeReviewUrl } from "./star-prompt"; import { registerSttIpc, shutdownStt } from "./stt"; import { checkLatestRelease } from "./update-checker"; -import { loadUpdateMode, saveUpdateMode } from "./update-settings"; import { doNotTrack, loadUsagePingState, @@ -669,7 +662,6 @@ 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; /** The update button, shown by the HUD, the editor and the tray; null hides it. Only a background * check and the downloads it leads to set it: the manual check keeps its own dialogs. */ @@ -679,17 +671,6 @@ let updateBadge: UpdateBadge | null = null; let updateReleasePage: string | null = null; let applyUpdateInFlight = false; -function showUpdateSettingsMenu(): boolean { - return app.isPackaged && ownsItsUpdates(getInstallChannel()); -} - -function persistUpdateMode(mode: UpdateMode) { - currentUpdateMode = mode; - saveUpdateMode(app.getPath("userData"), mode); - setInstallOnQuit(mode === "download-and-install"); - updateTrayMenu(isRecording); -} - function setUpdateBadge(badge: UpdateBadge | null, releasePage: string | null = null) { updateBadge = badge; updateReleasePage = releasePage; @@ -804,23 +785,23 @@ async function runBackgroundUpdateCheck() { } return; } - // A translocated Mac cannot install: it gets the button alone, whose click says why. - const mode = installBlock() === "not-in-applications" ? "notify" : currentUpdateMode; - const plan = planBackgroundUpdate({ outcome, mode }); - if (plan.action === "none") return; - if (updateBadge?.state === "ready" && updateBadge.version === plan.version) return; - if (plan.action === "notify-available") { - setUpdateBadge({ state: "available", version: plan.version }); + if (outcome.kind !== "downloaded") return; + const { version } = outcome; + if (updateBadge?.state === "ready" && updateBadge.version === version) return; + // A translocated Mac cannot install, so there is no point fetching: it gets the button + // alone, whose click says to move the app. + if (installBlock() === "not-in-applications") { + setUpdateBadge({ state: "available", version }); return; } const downloaded = await downloadSelfUpdate(); if (downloaded.kind === "failed") { console.warn("[updates] background download failed", downloaded.error.message); // The click downloads it again, through the dialogs that can report a second failure. - setUpdateBadge({ state: "available", version: plan.version }); + setUpdateBadge({ state: "available", version }); return; } - setUpdateBadge({ state: "ready", version: plan.version }); + setUpdateBadge({ state: "ready", version }); } catch (error) { console.error("[updates] background check failed", error); } finally { @@ -828,8 +809,8 @@ async function runBackgroundUpdateCheck() { } } -/** The update button. A click is the user deciding, so a live CLI run does not stop it, unlike - * the install on quit; a take or a translocated Mac still does, with the dialog that says why. */ +/** The update button, the only thing that installs an update. A click is the user deciding, so a + * live CLI run does not stop it; a take or a translocated Mac does, with the dialog that says why. */ async function applyUpdate() { const badge = updateBadge; if (!badge || applyUpdateInFlight) return; @@ -1023,29 +1004,6 @@ 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), - })), - }, - ] - : []), ...(showUsagePingToggle() ? [ { @@ -1270,17 +1228,6 @@ app.on("before-quit", (event) => { // quit is deferred below: the user asked to leave, and a check they can re-run from the // tray is not worth holding the helper's teardown behind. updateCheckAbort?.abort(); - // Decided here rather than when the download finished: a CLI run can start or end, and the - // mode can change, while the update waits. The last before-quit is the one that counts. - if (updateBadge?.state === "ready") { - setInstallOnQuit( - shouldInstallOnQuit({ - mode: currentUpdateMode, - needsElevation: installNeedsElevation(getInstallChannel()), - cliSessionAlive: hasLiveCliSession(app.getPath("userData")), - }), - ); - } if (sttShutdownFinished) return; event.preventDefault(); if (sttShutdownPromise) return; @@ -1512,8 +1459,6 @@ appReady?.then(async () => { // 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")); - setInstallOnQuit(currentUpdateMode === "download-and-install"); usagePingEnabled = loadUsagePingState(app.getPath("userData")).enabled; createTray(); updateTrayMenu(); diff --git a/electron/update-settings.test.ts b/electron/update-settings.test.ts deleted file mode 100644 index 9e977e27ac..0000000000 --- a/electron/update-settings.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -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 { DEFAULT_UPDATE_MODE, 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", () => { - // Saved as something other than the default, or a load that ignored the file would pass too. - it("round-trips a saved mode through load", () => { - const dir = tmp(); - saveUpdateMode(dir, "notify"); - expect(loadUpdateMode(dir)).toBe("notify"); - }); - - // Keeping users current is what the setting is for, so a user who never opened it gets the - // mode that downloads and installs on its own. - it("defaults to download-and-install when nothing was ever saved", () => { - expect(DEFAULT_UPDATE_MODE).toBe("download-and-install"); - expect(loadUpdateMode(tmp())).toBe("download-and-install"); - }); - - it("falls back to the default on a corrupt settings file instead of throwing", () => { - const dir = tmp(); - writeFileSync(updateSettingsPath(dir), '{"mode": not-even-json !!}'); - expect(loadUpdateMode(dir)).toBe(DEFAULT_UPDATE_MODE); - }); - - it("refuses garbage mode values rather than trusting the file", () => { - for (const garbage of ["install-silently", "", 42, null, { mode: "download" }]) { - expect(parseUpdateMode(garbage)).toBe(DEFAULT_UPDATE_MODE); - } - expect(parseUpdateMode("download")).toBe("download"); - }); -}); diff --git a/electron/update-settings.ts b/electron/update-settings.ts deleted file mode 100644 index 9e78b2c305..0000000000 --- a/electron/update-settings.ts +++ /dev/null @@ -1,26 +0,0 @@ -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/components/ai-edition/v4/EditorTopBar.test.tsx b/src/components/ai-edition/v4/EditorTopBar.test.tsx index 71d83d96b7..b9d920cb52 100644 --- a/src/components/ai-edition/v4/EditorTopBar.test.tsx +++ b/src/components/ai-edition/v4/EditorTopBar.test.tsx @@ -562,7 +562,7 @@ describe("the update button", () => { const stub = stubUpdateBadge(null); try { renderTopBar("Demo Project"); - await act(async () => {}); + await act(() => Promise.resolve()); expect(screen.queryByRole("button", { name: /actions\.updateTo/ })).not.toBeInTheDocument(); } finally { stub.restore(); @@ -584,7 +584,7 @@ describe("the update button", () => { const stub = stubUpdateBadge(null); try { renderTopBar("Demo Project"); - await act(async () => {}); + await act(() => Promise.resolve()); stub.push({ state: "available", version: "2.2.0" }); expect(screen.getByRole("button", { name: /actions\.updateTo/ })).toBeInTheDocument(); stub.push(null); diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index 8a66a7be7a..9a96befdaf 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -9,12 +9,8 @@ "done": "تم", "open": "فتح", "checkForUpdates": "التحقق من وجود تحديثات", - "updateSettings": "إعدادات التحديث", "update": "تحديث", "updateTo": "التحديث إلى {{version}}", - "updateModeNotify": "إشعار عند توفر تحديث", - "updateModeDownload": "تنزيل التحديثات تلقائيًا", - "updateModeDownloadAndInstall": "تنزيل التحديثات وتثبيتها تلقائيًا", "usagePing": "إرسال إحصاء استخدام مجهول الهوية", "viewRelease": "عرض الإصدار", "downloadUpdate": "تنزيل التحديث", diff --git a/src/i18n/locales/cs/common.json b/src/i18n/locales/cs/common.json index d6e5104e9e..4326ddedb5 100644 --- a/src/i18n/locales/cs/common.json +++ b/src/i18n/locales/cs/common.json @@ -9,12 +9,8 @@ "done": "Hotovo", "open": "Otevřít", "checkForUpdates": "Zkontrolovat aktualizace", - "updateSettings": "Nastavení aktualizací", "update": "Aktualizovat", "updateTo": "Aktualizovat na {{version}}", - "updateModeNotify": "Upozornit na dostupnou aktualizaci", - "updateModeDownload": "Stahovat aktualizace automaticky", - "updateModeDownloadAndInstall": "Stahovat a instalovat aktualizace automaticky", "usagePing": "Odesílat anonymní počet použití", "viewRelease": "Zobrazit vydání", "downloadUpdate": "Stáhnout aktualizaci", diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 7bfd52e37c..c107c3a05c 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -9,12 +9,8 @@ "done": "Fertig", "open": "Öffnen", "checkForUpdates": "Nach Updates suchen", - "updateSettings": "Update-Einstellungen", "update": "Aktualisieren", "updateTo": "Auf {{version}} aktualisieren", - "updateModeNotify": "Benachrichtigen, wenn ein Update verfügbar ist", - "updateModeDownload": "Updates automatisch herunterladen", - "updateModeDownloadAndInstall": "Updates automatisch herunterladen und installieren", "usagePing": "Anonyme Nutzungszählung senden", "viewRelease": "Release ansehen", "downloadUpdate": "Update herunterladen", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 41c89db28f..b1c38c4650 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -9,12 +9,8 @@ "done": "Done", "open": "Open", "checkForUpdates": "Check for Updates", - "updateSettings": "Update Settings", "update": "Update", "updateTo": "Update to {{version}}", - "updateModeNotify": "Notify when an update is available", - "updateModeDownload": "Download updates automatically", - "updateModeDownloadAndInstall": "Download and install updates automatically", "usagePing": "Send Anonymous Usage Count", "viewRelease": "View Release", "downloadUpdate": "Download Update", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 2f05649a7c..feae8a7244 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -9,12 +9,8 @@ "done": "Listo", "open": "Abrir", "checkForUpdates": "Buscar actualizaciones", - "updateSettings": "Ajustes de actualización", "update": "Actualizar", "updateTo": "Actualizar a {{version}}", - "updateModeNotify": "Avisar cuando haya una actualización", - "updateModeDownload": "Descargar actualizaciones automáticamente", - "updateModeDownloadAndInstall": "Descargar e instalar actualizaciones automáticamente", "usagePing": "Enviar recuento de uso anónimo", "viewRelease": "Ver versión", "downloadUpdate": "Descargar actualización", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index d1384e3570..98ce3a1334 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -9,12 +9,8 @@ "done": "Terminer", "open": "Ouvrir", "checkForUpdates": "Rechercher des mises à jour", - "updateSettings": "Paramètres de mise à jour", "update": "Mettre à jour", "updateTo": "Mettre à jour vers la version {{version}}", - "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", "usagePing": "Envoyer le comptage d'utilisation anonyme", "viewRelease": "Voir la version", "downloadUpdate": "Télécharger la mise à jour", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 5316e4c631..07ee03eebc 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -9,12 +9,8 @@ "done": "Fatto", "open": "Apri", "checkForUpdates": "Controlla aggiornamenti", - "updateSettings": "Impostazioni aggiornamento", "update": "Aggiorna", "updateTo": "Aggiorna alla versione {{version}}", - "updateModeNotify": "Notifica quando è disponibile un aggiornamento", - "updateModeDownload": "Scarica gli aggiornamenti automaticamente", - "updateModeDownloadAndInstall": "Scarica e installa gli aggiornamenti automaticamente", "usagePing": "Invia conteggio di utilizzo anonimo", "viewRelease": "Visualizza versione", "downloadUpdate": "Scarica aggiornamento", diff --git a/src/i18n/locales/ja-JP/common.json b/src/i18n/locales/ja-JP/common.json index 068f0e5b09..6ae6dd1149 100644 --- a/src/i18n/locales/ja-JP/common.json +++ b/src/i18n/locales/ja-JP/common.json @@ -9,12 +9,8 @@ "done": "完了", "open": "開く", "checkForUpdates": "アップデートを確認", - "updateSettings": "アップデート設定", "update": "アップデート", "updateTo": "{{version}} にアップデート", - "updateModeNotify": "アップデートが利用可能になったら通知", - "updateModeDownload": "アップデートを自動でダウンロード", - "updateModeDownloadAndInstall": "アップデートを自動でダウンロードしてインストール", "usagePing": "匿名の利用カウントを送信", "viewRelease": "リリースを表示", "downloadUpdate": "アップデートをダウンロード", diff --git a/src/i18n/locales/ko-KR/common.json b/src/i18n/locales/ko-KR/common.json index ac110e934a..f337022438 100644 --- a/src/i18n/locales/ko-KR/common.json +++ b/src/i18n/locales/ko-KR/common.json @@ -9,12 +9,8 @@ "done": "완료", "open": "열기", "checkForUpdates": "업데이트 확인", - "updateSettings": "업데이트 설정", "update": "업데이트", "updateTo": "{{version}}(으)로 업데이트", - "updateModeNotify": "업데이트가 있으면 알림", - "updateModeDownload": "업데이트 자동 다운로드", - "updateModeDownloadAndInstall": "업데이트 자동 다운로드 및 설치", "usagePing": "익명 사용 집계 보내기", "viewRelease": "릴리스 보기", "downloadUpdate": "업데이트 다운로드", diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index 26c99168e1..571d8d7631 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -9,12 +9,8 @@ "done": "Concluir", "open": "Abrir", "checkForUpdates": "Verificar atualizações", - "updateSettings": "Configurações de atualização", "update": "Atualizar", "updateTo": "Atualizar para {{version}}", - "updateModeNotify": "Avisar quando houver uma atualização", - "updateModeDownload": "Baixar atualizações automaticamente", - "updateModeDownloadAndInstall": "Baixar e instalar atualizações automaticamente", "usagePing": "Enviar contagem de uso anônima", "viewRelease": "Ver versão", "downloadUpdate": "Baixar atualização", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 47ec8ceefc..aa43af4f3d 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -9,12 +9,8 @@ "done": "Готово", "open": "Открыть", "checkForUpdates": "Проверить обновления", - "updateSettings": "Настройки обновления", "update": "Обновить", "updateTo": "Обновить до {{version}}", - "updateModeNotify": "Уведомлять, когда доступно обновление", - "updateModeDownload": "Загружать обновления автоматически", - "updateModeDownloadAndInstall": "Загружать и устанавливать обновления автоматически", "usagePing": "Отправлять анонимный счётчик использования", "viewRelease": "Открыть выпуск", "downloadUpdate": "Загрузить обновление", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 56f92ce902..9e3d9dd9d6 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -9,12 +9,8 @@ "done": "Tamam", "open": "Aç", "checkForUpdates": "Güncellemeleri denetle", - "updateSettings": "Güncelleme ayarları", "update": "Güncelle", "updateTo": "{{version}} sürümüne güncelle", - "updateModeNotify": "Güncelleme olduğunda bildir", - "updateModeDownload": "Güncellemeleri otomatik indir", - "updateModeDownloadAndInstall": "Güncellemeleri otomatik indir ve yükle", "usagePing": "Anonim kullanım sayımı gönder", "viewRelease": "Sürümü görüntüle", "downloadUpdate": "Güncellemeyi indir", diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index 8e2d6cf934..cbdeedeffe 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -9,12 +9,8 @@ "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", "update": "Cập nhật", "updateTo": "Cập nhật lên {{version}}", - "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", "usagePing": "Gửi số liệu sử dụng ẩn danh", "viewRelease": "Xem bản phát hành", "downloadUpdate": "Tải 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 4358dfc03d..8e70555ab0 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -9,12 +9,8 @@ "done": "完成", "open": "打开", "checkForUpdates": "检查更新", - "updateSettings": "更新设置", "update": "更新", "updateTo": "更新到 {{version}}", - "updateModeNotify": "有更新时通知", - "updateModeDownload": "自动下载更新", - "updateModeDownloadAndInstall": "自动下载并安装更新", "usagePing": "发送匿名使用计数", "viewRelease": "查看版本", "downloadUpdate": "下载更新", diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index 36b16d75e3..15e29d4ae2 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -9,12 +9,8 @@ "done": "完成", "open": "開啟", "checkForUpdates": "檢查更新", - "updateSettings": "更新設定", "update": "更新", "updateTo": "更新至 {{version}}", - "updateModeNotify": "有更新時通知", - "updateModeDownload": "自動下載更新", - "updateModeDownloadAndInstall": "自動下載並安裝更新", "usagePing": "傳送匿名使用計數", "viewRelease": "檢視版本", "downloadUpdate": "下載更新", diff --git a/technical-documentation/architecture/mcp-server.md b/technical-documentation/architecture/mcp-server.md index 7165ec2b0f..7264d77346 100644 --- a/technical-documentation/architecture/mcp-server.md +++ b/technical-documentation/architecture/mcp-server.md @@ -64,7 +64,7 @@ The server's one resource, `openscreen://effects/contract` ([`effects-contract.t Every text a client reads must be true on the server that serves it, so the texts are composed rather than shared verbatim. `McpToolDeps.transport` (`"http"` or `"stdio"`) selects them: - **Instructions** (`mcpInstructions`): the media tools' capability block (`mediaPreamble`), then the projects-and-checkpoints rules of the transport, then how long checkpoints live there, then `buildSystemPrompt`. The checkpoint rule is in both. Over HTTP the media block says a take goes through the app's recording bar and opens in the editor, and the rules speak of the editor and its undo stack; while **MCP edits** is off, a line says `startRecording`, `exportVideo` and `createProject` are refused too. Over stdio the media block says the takes land in the app's project list, and the rules say that calls go through the app while it runs (the open project live, with `Ctrl/Cmd+Z`), and to the files when it does not. -- **A newer OpenScreen** (`update-notice.ts`), stdio only, and only on an install that updates itself (a Store, Flathub, Snap or Nix copy is kept current by its package manager). A user who reaches OpenScreen only through an agent never sees the app's update button, so `openscreen mcp` looks the latest release up on GitHub, at most every six hours, in the background and cached in `mcp-update-check.json`. When the cache names a newer version, a line opens the instructions: tell the user once that opening the app installs it, and never update OpenScreen yourself. The process itself updates nothing: its stdout is the protocol, its lifetime is the client's, and the NSIS installer would end it. The lookup never holds the handshake back, so the session that finds a release says nothing and the next one does. +- **A newer OpenScreen** (`update-notice.ts`), stdio only, and only on an install that updates itself (a Store, Flathub, Snap or Nix copy is kept current by its package manager). A user who reaches OpenScreen only through an agent never sees the app's update button, so `openscreen mcp` looks the latest release up on GitHub, at most every six hours, in the background and cached in `mcp-update-check.json`. When the cache names a newer version, a line opens the instructions: tell the user once that they can install it by opening the app, and never update OpenScreen yourself. The process itself updates nothing: its stdout is the protocol, its lifetime is the client's, and the NSIS installer would end it. The lookup never holds the handshake back, so the session that finds a release says nothing and the next one does. - **`buildSystemPrompt`** takes the transport's facts as options: `editsSetting` names the switch its consent block points to (the MCP server's **MCP edits**, not the in-app agent's **Agent edits**), and `canExport` drops "exporting" from its examples of a request with no tool, since both serve `exportVideo`. - **Tool descriptions**: `projectId` is "omit it for the open project" over HTTP and "omit it only for the open project, while the app runs" over stdio. `startRecording` and `stopRecording` say how each server makes a take ([below](#the-media-tools)), and the paths of `exportVideo` and `getFrames` must be absolute over HTTP: the app has no working directory of the client's. - **Messages**: no project to fall back on, a checkpoint required, an unknown checkpoint, edits turned off. Each has a version per transport, or exists only on the one that can produce it. The stdio versions are only met with no app answering, and say so. diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index e0677f047e..79ec493cb9 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -693,9 +693,6 @@ The inspector's rail holds five facets: Composition (background, format, frame, - [ ] Repeat that in Media mode, in Record mode, and in Edit mode with the chat panel collapsed — the three states in which the dialog had no owner before, and the reason the row must not be Edit-only. - [ ] Connect or disconnect a provider from the menu's dialog while the chat panel is open behind it, close the dialog, and confirm the composer and the model pill follow without reopening the panel. - [ ] Open the wordmark menu, then press Escape, click elsewhere in the top bar, and click the wordmark again — confirm each closes it and that the window does not start dragging instead of registering the click. -- [ ] **post-1.10.0** — In a **packaged** build on a channel that owns its updates, right-click the tray icon and confirm an **Update Settings** submenu offers "Notify when an update is available", "Download updates automatically", and "Download and install updates automatically". -- [ ] **post-1.10.0** — Pick a mode, restart, and confirm it is still selected; `update-settings.json` in `userData` carries it. -- [ ] **post-1.10.0** — Confirm the submenu is absent in a dev build and in a build on a channel that does not own its updates (`app.isPackaged && ownsItsUpdates`), rather than present and inert. - [ ] **post-1.10.0** — Confirm a background check that finds nothing shows no dialog at all — the background path never reports "you are current". - [ ] **post-1.10.0** — Confirm a failed or unavailable download stops short of the restart prompt rather than offering to restart into an installer that was never fetched. - [ ] **post-1.10.0** — Reach **Save Diagnostics** from the tray context menu while idle, and from the Help menu on Windows and Linux (Alt) or the app menu on macOS; confirm each writes a bundle. It is deliberately not in the wordmark menu. @@ -713,9 +710,9 @@ The inspector's rail holds five facets: Composition (background, format, frame, ### Updating in place — post-2.1.2 -**Why this section is never skipped on a candidate.** An update is the one path a release cannot fix after the fact: a build whose updater is broken can only be left through that broken updater, so everyone who installs it stays on it. Run this section for every release candidate, on every platform, and for any change to `auto-updater.ts`, `background-update.ts`, `install-channel.ts`, `update-checker.ts`, `mcp/update-notice.ts`, the `before-quit` handler in `main.ts`, the NSIS or publish config in `electron-builder.json5`, or the feed scripts (`scripts/mac-update-feed.mjs`). +**Why this section is never skipped on a candidate.** An update is the one path a release cannot fix after the fact: a build whose updater is broken can only be left through that broken updater, so everyone who installs it stays on it. Run this section for every release candidate, on every platform, and for any change to `auto-updater.ts`, `background-update.ts`, `install-channel.ts`, `update-checker.ts`, `mcp/update-notice.ts`, the update code in `main.ts`, the NSIS or publish config in `electron-builder.json5`, or the feed scripts (`scripts/mac-update-feed.mjs`). -**A dev build cannot run it.** The background check, the button and the install on quit all require `app.isPackaged`, so `npm run dev` shows none of them. Test an installed build, on a channel that updates itself: NSIS, DMG, AppImage, deb, rpm or pacman. Never the Store package, which is kept current by the Store. +**A dev build cannot run it.** The background check and the update button both require `app.isPackaged`, so `npm run dev` shows none of them. Test an installed build, on a channel that updates itself: NSIS, DMG, AppImage, deb, rpm or pacman. Never the Store package, which is kept current by the Store. **Getting a newer version to update to.** Two ways, both against an installed build of the code under test. @@ -744,16 +741,15 @@ The inspector's rail holds five facets: Composition (background, format, frame, **How to tell an install happened.** The installed version may not move: a CI artifact carries the version in `package.json`, often the same as the last release. The tell is `app-update.yml`: the installer writes its own, so once an update went through, the file is back to `provider: github`. -**Before each run, and to clean up.** Empty the updater's cache, or a download from the previous run is reused without being fetched: `%LOCALAPPDATA%\openscreen-updater`, `~/Library/Caches/openscreen-updater` or `~/.cache/openscreen-updater`. Delete `update-settings.json` in `userData` to start from the default mode. Afterwards, restore the saved `app-update.yml` or reinstall, delete `mcp-update-check.json` from `userData`, and stop the local server. +**Before each run, and to clean up.** Empty the updater's cache, or a download from the previous run is reused without being fetched: `%LOCALAPPDATA%\openscreen-updater`, `~/Library/Caches/openscreen-updater` or `~/.cache/openscreen-updater`. Afterwards, restore the saved `app-update.yml` or reinstall, delete `mcp-update-check.json` from `userData`, and stop the local server. **What to watch.** The first check runs a minute after launch, so wait at least that, plus the download. Nothing logs the updater itself; a failed check or download logs one `[updates]` line, which tray → *Save Diagnostics* keeps. -- [ ] **post-2.1.2** — Launch the installed build and wait. Confirm no dialog opens at any point. A blue update button appears in the HUD bar, just before the language button, and in the editor's top bar, just before Export, and the tray gains *Update to *. In the two download modes it appears once the download has finished; in "Notify" at once. +- [ ] **post-2.1.2** — Launch the installed build and wait. Confirm no dialog opens at any point. Once the download has finished, a blue update button appears in the HUD bar, just before the language button, and in the editor's top bar, just before Export, and the tray gains *Update to *. - [ ] **post-2.1.2** — Start a recording and confirm the button is gone for the length of the take, then back. -- [ ] **post-2.1.2** — Click the button. In the two download modes the app restarts into the update; in "Notify" it downloads, then asks to restart. -- [ ] **post-2.1.2** — In "Download and install updates automatically", quit with the button showing (*Quit OpenScreen* on the HUD, or tray → *Quit*). Confirm no installer window and no relaunch, and that the update is installed when you next launch. -- [ ] **post-2.1.2** — Same mode, on a per-machine Windows install and on a deb, rpm or pacman install: quit with the button showing and confirm nothing installs and no UAC or password prompt appears. There the button is the only way, and clicking it does raise the prompt. -- [ ] **post-2.1.2** — Same mode, with an agent's session open: start `Openscreen mcp` in a terminal and leave it waiting on its input, or connect Claude Code or Codex to it. Quit OpenScreen and confirm nothing installs and the `mcp` process is still alive. Stop it, launch and quit again, and confirm the update installs. +- [ ] **post-2.1.2** — Quit with the button showing (*Quit OpenScreen* on the HUD, or tray → *Quit*), then relaunch. Confirm nothing installed on the way out: no installer, no UAC or password prompt, `app-update.yml` unchanged. The button is back a minute later. Only a click installs. +- [ ] **post-2.1.2** — Click the button. Confirm the app restarts into the update. On a per-machine Windows install, and on deb, rpm or pacman, the system asks for an administrator first (UAC, or the password dialog), and the update installs once granted. +- [ ] **post-2.1.2** — With an agent's session open (`Openscreen mcp` started in a terminal and left waiting on its input, or Claude Code or Codex connected to it), click the button. On Windows the update ends that session, as the installer replaces every running copy. That is expected: the click is the user's decision. The agent's client gets the new version the next time it starts the server. - [ ] **post-2.1.2** — Stop the local server, or point `url` at a port nothing serves, and relaunch. Confirm the button still appears and opens the release page in the browser: when the self-updater fails, the release lookup on GitHub is the way out. This needs a GitHub release newer than the installed version, so run it on an RC whose stable successor is not out yet, or on any build older than the latest release. - [ ] **post-2.1.2** — The agent-only user. On the installed build, write `{"latestVersion":"9.9.9","checkedAt":}` to `mcp-update-check.json` in `userData`, then start `Openscreen mcp` and send it an `initialize` request. Confirm the `instructions` in the answer open with "OpenScreen 9.9.9 is available" and tell the agent never to update OpenScreen itself. Delete the file, and confirm a Store install, or a dev build, says nothing even with it present. No computer-use is needed: a client that prints the `initialize` answer is enough, such as a short Node script that spawns the process, writes one JSON-RPC line and reads the first non-blank line back. On Windows the first line Electron writes is blank. diff --git a/website/docs/faq.md b/website/docs/faq.md index b9a200c8b4..d5894ce8e4 100644 --- a/website/docs/faq.md +++ b/website/docs/faq.md @@ -34,7 +34,7 @@ OpenScreen is a free, MIT-licensed screen recorder and video editor for Windows, - **huggingface.co, once.** The first transcription downloads the Whisper model, about 264 MB, and a voice-detection model under 1 MB, each checked against a SHA-256 hash. After that, transcription needs no connection. - **github.com, once per language.** On a machine with a GPU, the first English or French transcription also downloads the model that re-times each word: about 110 MB for English, 350 MB for French. -- **github.com and api.github.com.** Builds that update themselves check for a new release a minute after launch, then every six hours, and when you ask. By default they download it in the background and install it when you quit OpenScreen; **Update Settings** in the tray menu can make them only tell you instead. +- **github.com and api.github.com.** Builds that update themselves check for a new release a minute after launch, then every six hours, and when you ask. They download it in the background and show an update button; nothing is installed until you click it. - **fonts.googleapis.com, only for a font added in 1.x.** A custom annotation font added in an earlier version is still fetched at launch, though 2.x no longer draws it. - **PostHog, once a day.** An anonymous count of active installs, sent to PostHog's EU servers. It holds the app version, the operating system, the processor architecture, how the app was installed, whether it was used directly or through its MCP server by an AI agent, and whether it is the first count of the week and of the month. That is all: no installation identifier (each count gets a new random ID), no IP address kept, nothing about your recordings. Untick **Send Anonymous Usage Count** in the tray menu to turn it off, or set `DO_NOT_TRACK=1`. - **Your AI provider, only if you connect one.** Chat editing sends your messages and the project data it reads, such as the timeline and transcript. Caption translation sends the caption text. Both stay off until you connect a provider. See [AI editing](./ai-editing.md). diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/faq.md b/website/i18n/de/docusaurus-plugin-content-docs/current/faq.md index b7c9af2dbd..8781c949a9 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/faq.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/faq.md @@ -34,7 +34,7 @@ OpenScreen ist ein kostenloser Bildschirmrekorder und Videoeditor unter MIT-Lize - **huggingface.co, einmalig.** Die erste Transkription lädt das Whisper-Modell herunter, etwa 264 MB, und ein Modell zur Spracherkennung unter 1 MB, jedes gegen einen SHA-256-Hash geprüft. Danach braucht die Transkription keine Verbindung mehr. - **github.com, einmal pro Sprache.** Auf einem Rechner mit GPU lädt die erste englische oder französische Transkription zusätzlich das Modell herunter, das jedes Wort neu zeitlich ausrichtet: etwa 110 MB für Englisch, 350 MB für Französisch. -- **github.com und api.github.com.** Builds, die sich selbst aktualisieren, suchen eine Minute nach dem Start, dann alle sechs Stunden und auf deine Anfrage nach einer neuen Version. Standardmäßig laden sie sie im Hintergrund herunter und installieren sie, wenn du OpenScreen beendest; mit **Update-Einstellungen** im Tray-Menü melden sie stattdessen nur, dass eine verfügbar ist. +- **github.com und api.github.com.** Builds, die sich selbst aktualisieren, suchen eine Minute nach dem Start, dann alle sechs Stunden und auf deine Anfrage nach einer neuen Version. Sie laden sie im Hintergrund herunter und zeigen eine Update-Schaltfläche; installiert wird erst, wenn du darauf klickst. - **fonts.googleapis.com, nur für eine in 1.x hinzugefügte Schrift.** Eine eigene Schrift für Annotationen, die in einer früheren Version hinzugefügt wurde, wird beim Start weiterhin geladen, obwohl 2.x sie nicht mehr zeichnet. - **PostHog, einmal täglich.** Eine anonyme Zählung aktiver Installationen, gesendet an die EU-Server von PostHog. Sie enthält die App-Version, das Betriebssystem, die Prozessorarchitektur, die Art der Installation, ob die App direkt oder über ihren MCP-Server von einem KI-Agenten genutzt wurde, und ob es die erste Zählung der Woche und des Monats ist. Mehr nicht: keine Installationskennung (jede Zählung erhält eine neue Zufalls-ID), keine gespeicherte IP-Adresse, nichts über deine Aufnahmen. Entferne im Menü des Symbols im Infobereich das Häkchen bei **Anonyme Nutzungszählung senden**, um sie abzuschalten, oder setze `DO_NOT_TRACK=1`. - **Dein KI-Anbieter, nur wenn du einen verbindest.** Die Chat-Bearbeitung sendet deine Nachrichten und die Projektdaten, die sie liest, etwa Zeitleiste und Transkript. Die Untertitelübersetzung sendet den Untertiteltext. Beides bleibt aus, bis du einen Anbieter verbindest. Siehe [KI-Bearbeitung](./ai-editing.md). diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/faq.md b/website/i18n/es/docusaurus-plugin-content-docs/current/faq.md index baa60908bb..b6301d94bb 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/faq.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/faq.md @@ -34,7 +34,7 @@ OpenScreen es un grabador de pantalla y editor de video gratis, con licencia MIT - **huggingface.co, una vez.** La primera transcripción descarga el modelo Whisper, de unos 264 MB, y un modelo de detección de voz de menos de 1 MB, cada uno verificado con un hash SHA-256. Después, la transcripción no necesita conexión. - **github.com, una vez por idioma.** En un equipo con GPU, la primera transcripción en inglés o en francés también descarga el modelo que vuelve a sincronizar cada palabra: unos 110 MB para el inglés, 350 MB para el francés. -- **github.com y api.github.com.** Las compilaciones que se actualizan solas buscan una nueva versión un minuto después de abrirse, luego cada seis horas, y cuando tú lo pides. De forma predeterminada, la descargan en segundo plano y la instalan cuando cierras OpenScreen; con **Ajustes de actualización**, en el menú de la bandeja, puedes hacer que solo te avisen. +- **github.com y api.github.com.** Las compilaciones que se actualizan solas buscan una nueva versión un minuto después de abrirse, luego cada seis horas, y cuando tú lo pides. La descargan en segundo plano y muestran un botón de actualización; no se instala nada hasta que haces clic en él. - **fonts.googleapis.com, solo para una fuente añadida en 1.x.** Una fuente personalizada para anotaciones añadida en una versión anterior se sigue descargando al iniciar, aunque la 2.x ya no la dibuja. - **PostHog, una vez al día.** Un recuento anónimo de instalaciones activas, enviado a los servidores de PostHog en la UE. Contiene la versión de la app, el sistema operativo, la arquitectura del procesador, cómo se instaló la app, si se usó directamente o a través de su servidor MCP por un agente de IA, y si es el primer recuento de la semana y del mes. Nada más: ningún identificador de instalación (cada recuento recibe un ID aleatorio nuevo), ninguna dirección IP conservada, nada sobre tus grabaciones. Desmarca **Enviar recuento de uso anónimo** en el menú de la bandeja para desactivarlo, o define `DO_NOT_TRACK=1`. - **Tu proveedor de IA, solo si conectas uno.** La edición por chat envía tus mensajes y los datos del proyecto que lee, como la línea de tiempo y la transcripción. La traducción de subtítulos envía el texto de los subtítulos. Ambas permanecen desactivadas hasta que conectas un proveedor. Consulta [Edición con IA](./ai-editing.md). diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/faq.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/faq.md index 99f0cb11e6..f08d54095b 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/faq.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/faq.md @@ -34,7 +34,7 @@ OpenScreen est un enregistreur d'écran et éditeur vidéo gratuit, sous licence - **huggingface.co, une fois.** La première transcription télécharge le modèle Whisper, environ 264 Mo, et un modèle de détection de la voix de moins de 1 Mo, chacun vérifié à l'aide d'une empreinte SHA-256. Ensuite, la transcription n'a plus besoin de connexion. - **github.com, une fois par langue.** Sur une machine dotée d'un GPU, la première transcription en anglais ou en français télécharge aussi le modèle qui recale chaque mot dans le temps : environ 110 Mo pour l'anglais, 350 Mo pour le français. -- **github.com et api.github.com.** Les versions qui se mettent à jour d'elles-mêmes vérifient la présence d'une nouvelle version une minute après le lancement, puis toutes les six heures, et quand vous le demandez. Par défaut, elles la téléchargent en arrière-plan et l'installent quand vous quittez OpenScreen ; **Paramètres de mise à jour**, dans le menu de la barre système, permet de vous contenter d'un signalement. +- **github.com et api.github.com.** Les versions qui se mettent à jour d'elles-mêmes vérifient la présence d'une nouvelle version une minute après le lancement, puis toutes les six heures, et quand vous le demandez. Elles la téléchargent en arrière-plan et affichent un bouton de mise à jour ; rien n'est installé tant que vous n'avez pas cliqué dessus. - **fonts.googleapis.com, seulement pour une police ajoutée en 1.x.** Une police d'annotation personnalisée ajoutée dans une version précédente est encore téléchargée au lancement, bien que la 2.x ne l'affiche plus. - **PostHog, une fois par jour.** Un comptage anonyme des installations actives, envoyé aux serveurs de PostHog dans l'UE. Il contient la version de l'application, le système d'exploitation, l'architecture du processeur, le mode d'installation de l'application, si elle a servi directement ou à un agent IA via son serveur MCP, et s'il s'agit du premier comptage de la semaine et du mois. C'est tout : aucun identifiant d'installation (chaque comptage reçoit un nouvel identifiant aléatoire), aucune adresse IP conservée, rien sur vos enregistrements. Décochez **Envoyer le comptage d'utilisation anonyme** dans le menu de l'icône de la zone de notification pour le désactiver, ou définissez `DO_NOT_TRACK=1`. - **Votre fournisseur d'IA, seulement si vous en connectez un.** Le montage par chat envoie vos messages et les données du projet qu'il lit, comme la timeline et la transcription. La traduction des sous-titres envoie le texte des sous-titres. Les deux restent désactivés tant que vous n'avez pas connecté de fournisseur. Voir [Montage par IA](./ai-editing.md). diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/faq.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/faq.md index f018437831..43a7dad22b 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/faq.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/faq.md @@ -34,7 +34,7 @@ OpenScreen は、Windows、macOS、Linux に対応した、MIT ライセンス - **huggingface.co(一度だけ)。** 最初の文字起こしで約 264 MB の Whisper モデルと、1 MB 未満の音声検出モデルをダウンロードし、それぞれ SHA-256 ハッシュで検証します。それ以降、文字起こしに接続は必要ありません。 - **github.com(言語ごとに一度だけ)。** GPU を搭載したマシンでは、英語またはフランス語の最初の文字起こしで、各単語のタイミングを付け直すモデルもダウンロードします。英語は約 110 MB、フランス語は約 350 MB です。 -- **github.com と api.github.com。** 自動でアップデートするビルドは、起動の 1 分後、その後は 6 時間ごと、そしてユーザーが求めたときに、新しいリリースを確認します。既定では、バックグラウンドでダウンロードし、OpenScreen を終了したときにインストールします。トレイメニューの **アップデート設定** で、知らせるだけにもできます。 +- **github.com と api.github.com。** 自動でアップデートするビルドは、起動の 1 分後、その後は 6 時間ごと、そしてユーザーが求めたときに、新しいリリースを確認します。バックグラウンドでダウンロードしてアップデートボタンを表示し、ボタンをクリックするまでは何もインストールしません。 - **fonts.googleapis.com(1.x で追加したフォントのみ)。** 以前のバージョンで追加したカスタムの注釈フォントは、2.x では描画されませんが、起動時に引き続き読み込まれます。 - **PostHog(1 日 1 回)。** アクティブなインストール数の匿名カウントで、PostHog の EU サーバーに送信されます。内容は、アプリのバージョン、OS、プロセッサのアーキテクチャ、アプリのインストール方法、アプリが直接使われたか AI エージェントが MCP サーバー経由で使ったか、そしてその週とその月で最初のカウントかどうかです。それ以外は何もありません。インストールを識別する ID はなく(カウントごとに新しいランダム ID が付きます)、IP アドレスは保持されず、録画に関する情報も一切含みません。無効にするには、トレイメニューの**匿名の利用カウントを送信**のチェックを外すか、`DO_NOT_TRACK=1` を設定します。 - **AI プロバイダー(接続した場合のみ)。** チャット編集では、あなたのメッセージと、エージェントが読み取るプロジェクトのデータ(タイムラインや文字起こしなど)が送信されます。字幕の翻訳では、字幕のテキストが送信されます。どちらも、プロバイダーを接続するまではオフです。[AI 編集](./ai-editing.md)を参照してください。 diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/faq.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/faq.md index b38c45de57..e092ad3362 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/faq.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/faq.md @@ -34,7 +34,7 @@ O OpenScreen é um gravador de tela e editor de vídeo gratuito, com licença MI - **huggingface.co, uma vez.** A primeira transcrição baixa o modelo Whisper, de cerca de 264 MB, e um modelo de detecção de voz com menos de 1 MB, cada um conferido com um hash SHA-256. Depois disso, a transcrição não precisa de conexão. - **github.com, uma vez por idioma.** Em uma máquina com GPU, a primeira transcrição em inglês ou em francês também baixa o modelo que reajusta o tempo de cada palavra: cerca de 110 MB para o inglês, 350 MB para o francês. -- **github.com e api.github.com.** Os builds que se atualizam sozinhos procuram uma nova versão um minuto depois de abrir, depois a cada seis horas, e quando você pede. Por padrão, eles a baixam em segundo plano e a instalam quando você fecha o OpenScreen; em **Configurações de atualização**, no menu da bandeja, você pode fazer com que só avisem. +- **github.com e api.github.com.** Os builds que se atualizam sozinhos procuram uma nova versão um minuto depois de abrir, depois a cada seis horas, e quando você pede. Eles a baixam em segundo plano e mostram um botão de atualização; nada é instalado até você clicar nele. - **fonts.googleapis.com, só para uma fonte adicionada na 1.x.** Uma fonte personalizada de anotação adicionada numa versão anterior ainda é baixada na inicialização, embora a 2.x não a desenhe mais. - **PostHog, uma vez por dia.** Uma contagem anônima de instalações ativas, enviada aos servidores do PostHog na UE. Ela contém a versão do app, o sistema operacional, a arquitetura do processador, como o app foi instalado, se foi usado diretamente ou pelo seu servidor MCP por um agente de IA, e se é a primeira contagem da semana e do mês. Só isso: nenhum identificador de instalação (cada contagem recebe um ID aleatório novo), nenhum endereço IP guardado, nada sobre suas gravações. Desmarque **Enviar contagem de uso anônima** no menu da bandeja para desativá-la, ou defina `DO_NOT_TRACK=1`. - **Seu provedor de IA, somente se você conectar um.** A edição por chat envia suas mensagens e os dados do projeto que ela lê, como a linha do tempo e a transcrição. A tradução de legendas envia o texto das legendas. As duas ficam desativadas até você conectar um provedor. Veja [Edição com IA](./ai-editing.md). diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/faq.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/faq.md index 23015732a8..e0ebd7aef4 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/faq.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/faq.md @@ -34,7 +34,7 @@ OpenScreen 是一款免费的录屏与视频剪辑软件,采用 MIT 许可证 - **huggingface.co,仅一次**。第一次转录时会下载约 264 MB 的 Whisper 模型,以及一个不到 1 MB 的语音检测模型,每个都用 SHA-256 哈希值进行校验。此后转录无需联网。 - **github.com,每种语言一次**。在带 GPU 的电脑上,第一次进行英语或法语转录时,还会下载为每个词重新计时的模型:英语约 110 MB,法语约 350 MB。 -- **github.com 和 api.github.com**。能够自动更新的版本在启动一分钟后检查一次新版本,之后每六小时检查一次,你手动检查时也会连接。默认情况下,它们会在后台下载新版本,并在你退出 OpenScreen 时安装;在托盘菜单的 **更新设置** 中,可以改为只通知你。 +- **github.com 和 api.github.com**。能够自动更新的版本在启动一分钟后检查一次新版本,之后每六小时检查一次,你手动检查时也会连接。它们会在后台下载新版本并显示更新按钮;在你点击之前不会安装任何内容。 - **fonts.googleapis.com,仅限 1.x 中添加的字体**。在早期版本中添加的自定义标注字体仍会在启动时加载,尽管 2.x 已不再绘制它。 - **PostHog,每天一次**。活跃安装数的匿名计数,发送到 PostHog 位于欧盟的服务器。它包含应用版本、操作系统、处理器架构、应用的安装方式、应用是被直接使用还是由 AI 智能体通过其 MCP 服务器使用,以及这是否为当周和当月的首次计数。仅此而已:没有安装标识符(每次计数都使用新的随机 ID),不保留 IP 地址,不涉及你的录制内容。要关闭它,请在托盘菜单中取消勾选**发送匿名使用计数**,或设置 `DO_NOT_TRACK=1`。 - **你的 AI 提供方,仅在你连接之后**。聊天编辑会发送你的消息以及它读取的项目数据,例如时间轴和转录文本。字幕翻译会发送字幕文本。在你连接提供方之前,这两项功能都处于关闭状态。请参阅 [AI 编辑](./ai-editing.md)。 diff --git a/website/i18n/zh-TW/docusaurus-plugin-content-docs/current/faq.md b/website/i18n/zh-TW/docusaurus-plugin-content-docs/current/faq.md index 5bb6f93943..7297b65d35 100644 --- a/website/i18n/zh-TW/docusaurus-plugin-content-docs/current/faq.md +++ b/website/i18n/zh-TW/docusaurus-plugin-content-docs/current/faq.md @@ -34,7 +34,7 @@ OpenScreen 是一款採用 MIT 授權的免費螢幕錄影與影片剪輯軟體 - **huggingface.co,只有一次。** 第一次轉錄時會下載 Whisper 模型(約 264 MB)與一個小於 1 MB 的語音偵測模型,每個都以 SHA-256 雜湊值驗證。之後轉錄就不再需要網路連線。 - **github.com,每種語言一次。** 在有 GPU 的電腦上,第一次進行英文或法文轉錄時,還會下載為每個字詞重新對時的模型:英文約 110 MB,法文約 350 MB。 -- **github.com 與 api.github.com。** 會自行更新的版本在啟動一分鐘後檢查一次新版本,之後每六小時檢查一次,你手動要求時也會檢查。預設會在背景下載新版本,並在你結束 OpenScreen 時安裝;在系統匣選單的 **更新設定** 中,可以改為只通知你。 +- **github.com 與 api.github.com。** 會自行更新的版本在啟動一分鐘後檢查一次新版本,之後每六小時檢查一次,你手動要求時也會檢查。它們會在背景下載新版本並顯示更新按鈕;在你點擊之前不會安裝任何內容。 - **fonts.googleapis.com,僅限 1.x 中加入的字體。** 在先前版本中加入的自訂標註字體仍會在啟動時載入,儘管 2.x 已不再繪製它。 - **PostHog,每天一次。** 活躍安裝數的匿名計數,會送到 PostHog 位於歐盟的伺服器。內容包括應用程式版本、作業系統、處理器架構、應用程式的安裝方式、應用程式是直接使用還是由 AI 代理透過其 MCP 伺服器使用,以及這是否為當週與當月的第一次計數。僅此而已:沒有安裝識別碼(每次計數都使用新的隨機 ID),不保留 IP 位址,也不含任何與你錄影有關的資訊。若要關閉,請在系統匣選單中取消勾選**傳送匿名使用計數**,或設定 `DO_NOT_TRACK=1`。 - **你的 AI 提供者,僅在你連接時。** 聊天剪輯會送出你的訊息,以及它讀取的專案資料,例如時間軸與逐字稿。字幕翻譯會送出字幕文字。在你連接提供者之前,這兩項功能都保持關閉。請參閱 [AI 剪輯](./ai-editing.md)。 From 95e6ad8e4eeb19d6cfac73a5658520bd9e4004ef Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Thu, 29 Oct 2026 01:02:13 +0100 Subject: [PATCH 5/8] fix(update): address review on the update button and the MCP notice - A background check that finds the running version current clears an 'available' badge, so the button never offers a release that was pulled. - The click asks the recording veto before downloading, not after. - A cached release lookup dated in the future no longer counts as fresh. - The MCP notice says the app shows an update button, not that opening it installs anything, and a test reads it through the server's initialize. --- electron/main.ts | 20 ++++++--- electron/mcp/update-notice.test.ts | 44 ++++++++++++++++++- electron/mcp/update-notice.ts | 7 ++- .../architecture/mcp-server.md | 2 +- 4 files changed, 61 insertions(+), 12 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 2f8965c657..b0a7d38c24 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -785,7 +785,12 @@ async function runBackgroundUpdateCheck() { } return; } - if (outcome.kind !== "downloaded") return; + if (outcome.kind !== "downloaded") { + // The release it advertised was pulled, or this copy got there another way. A ready + // badge stays: its installer is on disk and still installs. + if (outcome.kind === "current" && updateBadge?.state === "available") setUpdateBadge(null); + return; + } const { version } = outcome; if (updateBadge?.state === "ready" && updateBadge.version === version) return; // A translocated Mac cannot install, so there is no point fetching: it gets the button @@ -818,13 +823,14 @@ async function applyUpdate() { try { if (updateReleasePage) { await shell.openExternal(updateReleasePage); - } else if (badge.state === "available") { - await downloadAndInstall(badge.version); - } else { - const blocked = installBlock(); - if (blocked) await showBlockedInstall(blocked); - else await installSelfUpdate(); + return; } + // Asked before any download: one started mid-take would compete with the encoder, and + // only be refused once it had finished. + const blocked = installBlock(); + if (blocked) await showBlockedInstall(blocked); + else if (badge.state === "available") await downloadAndInstall(badge.version); + else await installSelfUpdate(); } finally { applyUpdateInFlight = false; } diff --git a/electron/mcp/update-notice.test.ts b/electron/mcp/update-notice.test.ts index b4ce6947bc..9ccbb2c943 100644 --- a/electron/mcp/update-notice.test.ts +++ b/electron/mcp/update-notice.test.ts @@ -1,8 +1,14 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { mcpInstructions } from "./openscreen-mcp-server"; +import { + createOpenScreenMcpServer, + type McpToolDeps, + mcpInstructions, +} from "./openscreen-mcp-server"; import { RELEASE_LOOKUP_INTERVAL_MS, readReleaseLookup, @@ -49,10 +55,32 @@ describe("updateNotice", () => { it("names both versions and leaves the update to the user", () => { const notice = updateNotice("2.1.2", { latestVersion: "2.2.0", checkedAt: 0 }); expect(notice).toContain("OpenScreen 2.2.0 is available; this is 2.1.2."); - expect(notice).toContain("opening the OpenScreen app"); + expect(notice).toContain("the OpenScreen app shows an update button"); expect(notice).toContain("Never update OpenScreen yourself."); }); + // Through the server itself, as a client receives it in the initialize answer: a dropped + // `updateNotice` between the deps and the instructions would pass the test below. + it("reaches the client in the server's instructions", async () => { + // Only what building the server reads; no tool is called. + const deps = { + transport: "stdio", + editsAllowed: () => true, + version: "2.1.2", + updateNotice: "OpenScreen 2.2.0 is available.", + } as McpToolDeps; + const server = createOpenScreenMcpServer(deps, vi.fn()); + const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); + await server.connect(serverSide); + const client = new Client({ name: "test", version: "1" }); + await client.connect(clientSide); + try { + expect(client.getInstructions()?.startsWith("OpenScreen 2.2.0 is available.\n")).toBe(true); + } finally { + await client.close(); + } + }); + it("opens the stdio instructions, ahead of the editing guidance", () => { const instructions = mcpInstructions({ transport: "stdio", @@ -98,6 +126,18 @@ describe("refreshReleaseLookup", () => { expect(fetchLatest).toHaveBeenCalledTimes(2); }); + it("looks again when the cached lookup is dated in the future", async () => { + const dir = userData(); + writeFileSync( + path.join(dir, "mcp-update-check.json"), + JSON.stringify({ latestVersion: "2.1.2", checkedAt: 10_000 }), + ); + const fetchLatest = release("v2.2.0"); + await refreshReleaseLookup({ userData: dir, currentVersion: "2.1.2", fetchLatest, now: 1000 }); + expect(fetchLatest).toHaveBeenCalledTimes(1); + expect(readReleaseLookup(dir)).toEqual({ latestVersion: "2.2.0", checkedAt: 1000 }); + }); + it("reads a corrupt cache as no lookup at all", () => { const dir = userData(); writeFileSync(path.join(dir, "mcp-update-check.json"), "{not json"); diff --git a/electron/mcp/update-notice.ts b/electron/mcp/update-notice.ts index 2d4d3c5a90..d3d7247908 100644 --- a/electron/mcp/update-notice.ts +++ b/electron/mcp/update-notice.ts @@ -40,7 +40,7 @@ export function updateNotice(currentVersion: string, lookup: ReleaseLookup | nul } catch { return null; } - return `OpenScreen ${lookup.latestVersion} is available; this is ${currentVersion}. Tell the user once, when it fits, that they can install it by opening the OpenScreen app. Never update OpenScreen yourself.`; + return `OpenScreen ${lookup.latestVersion} is available; this is ${currentVersion}. Tell the user once, when it fits, that the OpenScreen app shows an update button for it once they open it. Never update OpenScreen yourself.`; } /** Looks the latest release up if the cached answer is older than the interval. Throws on a @@ -53,7 +53,10 @@ export async function refreshReleaseLookup(options: { }): Promise { const now = options.now ?? Date.now(); const cached = readReleaseLookup(options.userData); - if (cached && now - cached.checkedAt < RELEASE_LOOKUP_INTERVAL_MS) return; + // A lookup dated in the future (a clock set back since) is not fresh: it would otherwise hold + // every lookup off until the clock caught up. + const age = cached ? now - cached.checkedAt : Number.POSITIVE_INFINITY; + if (age >= 0 && age < RELEASE_LOOKUP_INTERVAL_MS) return; const result = await checkLatestRelease({ currentVersion: options.currentVersion, fetchLatest: options.fetchLatest, diff --git a/technical-documentation/architecture/mcp-server.md b/technical-documentation/architecture/mcp-server.md index 7264d77346..b9b9f22866 100644 --- a/technical-documentation/architecture/mcp-server.md +++ b/technical-documentation/architecture/mcp-server.md @@ -64,7 +64,7 @@ The server's one resource, `openscreen://effects/contract` ([`effects-contract.t Every text a client reads must be true on the server that serves it, so the texts are composed rather than shared verbatim. `McpToolDeps.transport` (`"http"` or `"stdio"`) selects them: - **Instructions** (`mcpInstructions`): the media tools' capability block (`mediaPreamble`), then the projects-and-checkpoints rules of the transport, then how long checkpoints live there, then `buildSystemPrompt`. The checkpoint rule is in both. Over HTTP the media block says a take goes through the app's recording bar and opens in the editor, and the rules speak of the editor and its undo stack; while **MCP edits** is off, a line says `startRecording`, `exportVideo` and `createProject` are refused too. Over stdio the media block says the takes land in the app's project list, and the rules say that calls go through the app while it runs (the open project live, with `Ctrl/Cmd+Z`), and to the files when it does not. -- **A newer OpenScreen** (`update-notice.ts`), stdio only, and only on an install that updates itself (a Store, Flathub, Snap or Nix copy is kept current by its package manager). A user who reaches OpenScreen only through an agent never sees the app's update button, so `openscreen mcp` looks the latest release up on GitHub, at most every six hours, in the background and cached in `mcp-update-check.json`. When the cache names a newer version, a line opens the instructions: tell the user once that they can install it by opening the app, and never update OpenScreen yourself. The process itself updates nothing: its stdout is the protocol, its lifetime is the client's, and the NSIS installer would end it. The lookup never holds the handshake back, so the session that finds a release says nothing and the next one does. +- **A newer OpenScreen** (`update-notice.ts`), stdio only, and only on an install that updates itself (a Store, Flathub, Snap or Nix copy is kept current by its package manager). A user who reaches OpenScreen only through an agent never sees the app's update button, so `openscreen mcp` looks the latest release up on GitHub, at most every six hours, in the background and cached in `mcp-update-check.json`. When the cache names a newer version, a line opens the instructions: tell the user once that the app shows an update button for it once they open it, and never update OpenScreen yourself. The process itself updates nothing: its stdout is the protocol, its lifetime is the client's, and the NSIS installer would end it. The lookup never holds the handshake back, so the session that finds a release says nothing and the next one does. - **`buildSystemPrompt`** takes the transport's facts as options: `editsSetting` names the switch its consent block points to (the MCP server's **MCP edits**, not the in-app agent's **Agent edits**), and `canExport` drops "exporting" from its examples of a request with no tool, since both serve `exportVideo`. - **Tool descriptions**: `projectId` is "omit it for the open project" over HTTP and "omit it only for the open project, while the app runs" over stdio. `startRecording` and `stopRecording` say how each server makes a take ([below](#the-media-tools)), and the paths of `exportVideo` and `getFrames` must be absolute over HTTP: the app has no working directory of the client's. - **Messages**: no project to fall back on, a checkpoint required, an unknown checkpoint, edits turned off. Each has a version per transport, or exists only on the one that can produce it. The stdio versions are only met with no app answering, and say so. From 8b02e8b6ac94efff13290a96db959e03e95b328e Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Thu, 29 Oct 2026 01:17:07 +0100 Subject: [PATCH 6/8] test(mcp): check openscreen mcp forwards the update notice Also documents that a failed release lookup writes nothing, so the next session tries again rather than waiting six hours. --- electron/cli/cliMain.test.ts | 48 +++++++++++++++++-- .../architecture/mcp-server.md | 2 +- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/electron/cli/cliMain.test.ts b/electron/cli/cliMain.test.ts index 85ef7e3528..745108a2c8 100644 --- a/electron/cli/cliMain.test.ts +++ b/electron/cli/cliMain.test.ts @@ -2,17 +2,25 @@ // counts as active. runCli runs for real here; only Electron and the heavy services around // the MCP server are stubbed. -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { sendUsagePing, setUsagePingEnabled } from "../usage-ping"; import { runCli } from "./cliMain"; -const mocks = vi.hoisted(() => ({ userData: "", serve: vi.fn(async () => undefined) })); +const mocks = vi.hoisted(() => ({ + userData: "", + isPackaged: false, + serve: vi.fn(async (_options: { updateNotice?: string }) => undefined), + netFetch: vi.fn(), +})); vi.mock("electron", () => ({ app: { + get isPackaged() { + return mocks.isPackaged; + }, getPath: () => mocks.userData, getVersion: () => "2.1.0", exit: vi.fn(), @@ -23,6 +31,7 @@ vi.mock("electron", () => ({ BrowserWindow: class {}, ipcMain: { handle: vi.fn() }, Menu: { setApplicationMenu: vi.fn(), buildFromTemplate: vi.fn() }, + net: { fetch: mocks.netFetch }, session: { defaultSession: { setPermissionCheckHandler: vi.fn(), @@ -32,7 +41,10 @@ vi.mock("electron", () => ({ }, systemPreferences: {}, })); -vi.mock("../install-channel", () => ({ getInstallChannel: () => "nsis" })); +vi.mock("../install-channel", () => ({ + getInstallChannel: () => "nsis", + ownsItsUpdates: (channel: string) => channel === "nsis", +})); vi.mock("../ipc/handlers", () => ({ getSelectedDesktopSource: () => null, registerIpcHandlers: () => ({ agentServices: {} }), @@ -50,7 +62,9 @@ let stdoutWrite: ReturnType; beforeEach(() => { mocks.userData = mkdtempSync(path.join(os.tmpdir(), "cli-usage-ping-")); + mocks.isPackaged = false; mocks.serve.mockClear(); + mocks.netFetch.mockReset(); fetchMock.mockClear(); vi.stubGlobal("fetch", fetchMock); vi.stubEnv("DO_NOT_TRACK", ""); @@ -107,3 +121,31 @@ describe("openscreen mcp usage ping", () => { expect(fetchMock).not.toHaveBeenCalled(); }); }); + +// The user who only reaches OpenScreen through an agent: the notice the cache holds has to reach +// the server it starts, or it is never said. +describe("openscreen mcp update notice", () => { + function cacheLookup(latestVersion: string) { + writeFileSync( + path.join(mocks.userData, "mcp-update-check.json"), + JSON.stringify({ latestVersion, checkedAt: Date.now() }), + ); + } + + it("hands the cached newer version to the server on an install that updates itself", async () => { + mocks.isPackaged = true; + cacheLookup("9.9.9"); + await runMcp(); + expect(mocks.serve.mock.calls[0][0].updateNotice).toContain( + "OpenScreen 9.9.9 is available; this is 2.1.0.", + ); + // The cache is fresh, so the background lookup did not go out. + expect(mocks.netFetch).not.toHaveBeenCalled(); + }); + + it("says nothing from a dev build, which a package manager may own", async () => { + cacheLookup("9.9.9"); + await runMcp(); + expect(mocks.serve.mock.calls[0][0].updateNotice).toBeUndefined(); + }); +}); diff --git a/technical-documentation/architecture/mcp-server.md b/technical-documentation/architecture/mcp-server.md index b9b9f22866..cfd7a7660b 100644 --- a/technical-documentation/architecture/mcp-server.md +++ b/technical-documentation/architecture/mcp-server.md @@ -64,7 +64,7 @@ The server's one resource, `openscreen://effects/contract` ([`effects-contract.t Every text a client reads must be true on the server that serves it, so the texts are composed rather than shared verbatim. `McpToolDeps.transport` (`"http"` or `"stdio"`) selects them: - **Instructions** (`mcpInstructions`): the media tools' capability block (`mediaPreamble`), then the projects-and-checkpoints rules of the transport, then how long checkpoints live there, then `buildSystemPrompt`. The checkpoint rule is in both. Over HTTP the media block says a take goes through the app's recording bar and opens in the editor, and the rules speak of the editor and its undo stack; while **MCP edits** is off, a line says `startRecording`, `exportVideo` and `createProject` are refused too. Over stdio the media block says the takes land in the app's project list, and the rules say that calls go through the app while it runs (the open project live, with `Ctrl/Cmd+Z`), and to the files when it does not. -- **A newer OpenScreen** (`update-notice.ts`), stdio only, and only on an install that updates itself (a Store, Flathub, Snap or Nix copy is kept current by its package manager). A user who reaches OpenScreen only through an agent never sees the app's update button, so `openscreen mcp` looks the latest release up on GitHub, at most every six hours, in the background and cached in `mcp-update-check.json`. When the cache names a newer version, a line opens the instructions: tell the user once that the app shows an update button for it once they open it, and never update OpenScreen yourself. The process itself updates nothing: its stdout is the protocol, its lifetime is the client's, and the NSIS installer would end it. The lookup never holds the handshake back, so the session that finds a release says nothing and the next one does. +- **A newer OpenScreen** (`update-notice.ts`), stdio only, and only on an install that updates itself (a Store, Flathub, Snap or Nix copy is kept current by its package manager). A user who reaches OpenScreen only through an agent never sees the app's update button, so `openscreen mcp` looks the latest release up on GitHub in the background and caches the answer in `mcp-update-check.json`. A successful lookup holds the next one off for six hours; a failed one (offline, rate-limited) writes nothing, so the next session tries again. When the cache names a newer version, a line opens the instructions: tell the user once that the app shows an update button for it once they open it, and never update OpenScreen yourself. The process itself updates nothing: its stdout is the protocol, its lifetime is the client's, and the NSIS installer would end it. The lookup never holds the handshake back, so the session that finds a release says nothing and the next one does. - **`buildSystemPrompt`** takes the transport's facts as options: `editsSetting` names the switch its consent block points to (the MCP server's **MCP edits**, not the in-app agent's **Agent edits**), and `canExport` drops "exporting" from its examples of a request with no tool, since both serve `exportVideo`. - **Tool descriptions**: `projectId` is "omit it for the open project" over HTTP and "omit it only for the open project, while the app runs" over stdio. `startRecording` and `stopRecording` say how each server makes a take ([below](#the-media-tools)), and the paths of `exportVideo` and `getFrames` must be absolute over HTTP: the app has no working directory of the client's. - **Messages**: no project to fall back on, a checkpoint required, an unknown checkpoint, edits turned off. Each has a version per transport, or exists only on the one that can produce it. The stdio versions are only met with no app answering, and say so. From 853f058089077d87602fb6e2ae1ef56614b68b8f Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Thu, 29 Oct 2026 01:26:46 +0100 Subject: [PATCH 7/8] test(mcp): a Store install gets no update notice --- electron/cli/cliMain.test.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/electron/cli/cliMain.test.ts b/electron/cli/cliMain.test.ts index 745108a2c8..bd337c5471 100644 --- a/electron/cli/cliMain.test.ts +++ b/electron/cli/cliMain.test.ts @@ -12,6 +12,7 @@ import { runCli } from "./cliMain"; const mocks = vi.hoisted(() => ({ userData: "", isPackaged: false, + channel: "nsis", serve: vi.fn(async (_options: { updateNotice?: string }) => undefined), netFetch: vi.fn(), })); @@ -42,7 +43,7 @@ vi.mock("electron", () => ({ systemPreferences: {}, })); vi.mock("../install-channel", () => ({ - getInstallChannel: () => "nsis", + getInstallChannel: () => mocks.channel, ownsItsUpdates: (channel: string) => channel === "nsis", })); vi.mock("../ipc/handlers", () => ({ @@ -63,6 +64,7 @@ let stdoutWrite: ReturnType; beforeEach(() => { mocks.userData = mkdtempSync(path.join(os.tmpdir(), "cli-usage-ping-")); mocks.isPackaged = false; + mocks.channel = "nsis"; mocks.serve.mockClear(); mocks.netFetch.mockReset(); fetchMock.mockClear(); @@ -148,4 +150,15 @@ describe("openscreen mcp update notice", () => { await runMcp(); expect(mocks.serve.mock.calls[0][0].updateNotice).toBeUndefined(); }); + + // The Store updates its copy: telling the user to open the app would offer a button that is not + // there, and looking the release up would be wasted. + it("says nothing, and looks nothing up, on a Store install", async () => { + mocks.isPackaged = true; + mocks.channel = "store"; + cacheLookup("9.9.9"); + await runMcp(); + expect(mocks.serve.mock.calls[0][0].updateNotice).toBeUndefined(); + expect(mocks.netFetch).not.toHaveBeenCalled(); + }); }); From 46613f82892dca335ee68a71848fd5de48661819 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Thu, 29 Oct 2026 01:37:08 +0100 Subject: [PATCH 8/8] test(mcp): use the real channel rule, and a stale cache in the Store case --- electron/cli/cliMain.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/electron/cli/cliMain.test.ts b/electron/cli/cliMain.test.ts index bd337c5471..2e8157a16e 100644 --- a/electron/cli/cliMain.test.ts +++ b/electron/cli/cliMain.test.ts @@ -42,9 +42,10 @@ vi.mock("electron", () => ({ }, systemPreferences: {}, })); -vi.mock("../install-channel", () => ({ +// The real `ownsItsUpdates`, so the notice tests follow the channel rule rather than a copy of it. +vi.mock("../install-channel", async (importOriginal) => ({ + ...(await importOriginal()), getInstallChannel: () => mocks.channel, - ownsItsUpdates: (channel: string) => channel === "nsis", })); vi.mock("../ipc/handlers", () => ({ getSelectedDesktopSource: () => null, @@ -127,10 +128,10 @@ describe("openscreen mcp usage ping", () => { // The user who only reaches OpenScreen through an agent: the notice the cache holds has to reach // the server it starts, or it is never said. describe("openscreen mcp update notice", () => { - function cacheLookup(latestVersion: string) { + function cacheLookup(latestVersion: string, checkedAt = Date.now()) { writeFileSync( path.join(mocks.userData, "mcp-update-check.json"), - JSON.stringify({ latestVersion, checkedAt: Date.now() }), + JSON.stringify({ latestVersion, checkedAt }), ); } @@ -156,7 +157,8 @@ describe("openscreen mcp update notice", () => { it("says nothing, and looks nothing up, on a Store install", async () => { mocks.isPackaged = true; mocks.channel = "store"; - cacheLookup("9.9.9"); + // Stale, so a lookup started by mistake would reach the network and fail the assertion. + cacheLookup("9.9.9", 0); await runMcp(); expect(mocks.serve.mock.calls[0][0].updateNotice).toBeUndefined(); expect(mocks.netFetch).not.toHaveBeenCalled();