From a235a0c50be2fc84c88feadf1e9a77647812ab26 Mon Sep 17 00:00:00 2001 From: huanld Date: Fri, 5 Jun 2026 10:54:22 +0700 Subject: [PATCH] Add automatic update checks --- electron-builder.json5 | 16 ++-- electron/electron-env.d.ts | 8 ++ electron/main.ts | 2 + electron/preload.ts | 21 +++++ electron/updater.ts | 177 +++++++++++++++++++++++++++++++++++++ package-lock.json | 91 +++++++++++++++++-- package.json | 1 + src/App.tsx | 71 ++++++++++++++- src/lib/updateStatus.ts | 29 ++++++ 9 files changed, 402 insertions(+), 14 deletions(-) create mode 100644 electron/updater.ts create mode 100644 src/lib/updateStatus.ts diff --git a/electron-builder.json5 b/electron-builder.json5 index df38f5a..075e53b 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -13,11 +13,17 @@ }, "npmRebuild": true, "buildDependenciesFromSource": true, - "compression": "normal", - "directories": { - "output": "release/${version}" - }, - "files": [ + "compression": "normal", + "directories": { + "output": "release/${version}" + }, + "publish": [ + { + "provider": "generic", + "url": "https://gittea.softs.business/huanld/openscreen/raw/branch/release-assets%2Flatest" + } + ], + "files": [ "dist", "dist-electron", "!*.png", diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index e55f18d..c1eb6b4 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -24,6 +24,14 @@ declare namespace NodeJS { // Used in Renderer process, expose in `preload.ts` interface Window { electronAPI: { + updates: { + getStatus: () => Promise; + check: () => Promise; + install: () => Promise; + onStatus: ( + callback: (status: import("../src/lib/updateStatus").UpdateStatus) => void, + ) => () => void; + }; invokeNativeBridge: ( request: import("../src/native/contracts").NativeBridgeRequest, ) => Promise>; diff --git a/electron/main.ts b/electron/main.ts index 3e2258f..4349080 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -13,6 +13,7 @@ import { } from "electron"; import { mainT, setMainLocale } from "./i18n"; import { getSelectedDesktopSource, registerIpcHandlers } from "./ipc/handlers"; +import { initializeAutoUpdates } from "./updater"; import { createCountdownOverlayWindow, createEditorWindow, @@ -515,6 +516,7 @@ app.whenReady().then(async () => { createTray(); updateTrayMenu(); setupApplicationMenu(); + initializeAutoUpdates(); // Ensure recordings directory exists await ensureRecordingsDir(); diff --git a/electron/preload.ts b/electron/preload.ts index ef435b7..d23cb23 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -26,6 +26,27 @@ const assetBaseUrl = assetBaseUrlArg ? assetBaseUrlArg.slice(ASSET_BASE_URL_ARG_ contextBridge.exposeInMainWorld("electronAPI", { assetBaseUrl, + updates: { + getStatus: () => { + return ipcRenderer.invoke("updates:get-status"); + }, + check: () => { + return ipcRenderer.invoke("updates:check"); + }, + install: () => { + return ipcRenderer.invoke("updates:install"); + }, + onStatus: (callback: (status: import("../src/lib/updateStatus").UpdateStatus) => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + status: import("../src/lib/updateStatus").UpdateStatus, + ) => { + callback(status); + }; + ipcRenderer.on("updates:status", listener); + return () => ipcRenderer.removeListener("updates:status", listener); + }, + }, invokeNativeBridge: (request: NativeBridgeRequest) => { return ipcRenderer.invoke(NATIVE_BRIDGE_CHANNEL, request) as Promise; }, diff --git a/electron/updater.ts b/electron/updater.ts new file mode 100644 index 0000000..b1b8189 --- /dev/null +++ b/electron/updater.ts @@ -0,0 +1,177 @@ +import { app, BrowserWindow, ipcMain } from "electron"; +import { autoUpdater, type ProgressInfo, type UpdateInfo } from "electron-updater"; +import type { UpdateCheckResult, UpdateStatus } from "../src/lib/updateStatus"; + +const DEFAULT_UPDATE_FEED_URL = + "https://gittea.softs.business/huanld/openscreen/raw/branch/release-assets%2Flatest"; +const AUTO_CHECK_DELAY_MS = 10_000; +const AUTO_CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; + +let status: UpdateStatus = createStatus("idle"); +let handlersRegistered = false; +let initialized = false; +let checkInFlight: Promise | null = null; + +function createStatus( + phase: UpdateStatus["phase"], + patch: Partial = {}, +): UpdateStatus { + return { + phase, + currentVersion: app.getVersion(), + updatedAt: new Date().toISOString(), + ...patch, + }; +} + +function normalizeReleaseNotes(releaseNotes: UpdateInfo["releaseNotes"]): string | undefined { + if (typeof releaseNotes === "string") { + return releaseNotes; + } + if (Array.isArray(releaseNotes)) { + return releaseNotes + .map((note) => note.note) + .filter(Boolean) + .join("\n\n"); + } + return undefined; +} + +function updateStatus(next: UpdateStatus) { + status = next; + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send("updates:status", status); + } + } +} + +function statusFromInfo(phase: UpdateStatus["phase"], info: UpdateInfo): UpdateStatus { + return createStatus(phase, { + version: info.version, + releaseName: info.releaseName ?? undefined, + releaseNotes: normalizeReleaseNotes(info.releaseNotes), + }); +} + +async function checkForUpdates(): Promise { + if (!initialized) { + updateStatus( + createStatus("unsupported", { + error: "Update service is not initialized.", + }), + ); + return { success: false, status, error: status.error }; + } + + if (!app.isPackaged && process.env.OPENSCREEN_ALLOW_DEV_UPDATE_CHECK !== "1") { + updateStatus( + createStatus("unsupported", { + error: "Update checks only run in packaged builds.", + }), + ); + return { success: false, status, error: status.error }; + } + + if (checkInFlight) { + return checkInFlight; + } + + updateStatus(createStatus("checking")); + checkInFlight = autoUpdater + .checkForUpdates() + .then(() => ({ success: true, status })) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error); + updateStatus(createStatus("error", { error: message })); + return { success: false, status, error: message }; + }) + .finally(() => { + checkInFlight = null; + }); + + return checkInFlight; +} + +function registerUpdateIpcHandlers() { + if (handlersRegistered) { + return; + } + + handlersRegistered = true; + ipcMain.handle("updates:get-status", () => status); + ipcMain.handle("updates:check", () => checkForUpdates()); + ipcMain.handle("updates:install", () => { + if (status.phase !== "downloaded") { + return { + success: false, + status, + error: "No downloaded update is ready to install.", + }; + } + setImmediate(() => autoUpdater.quitAndInstall(false, true)); + return { success: true, status }; + }); +} + +export function initializeAutoUpdates() { + registerUpdateIpcHandlers(); + + if (initialized) { + return; + } + + initialized = true; + autoUpdater.autoDownload = true; + autoUpdater.autoInstallOnAppQuit = true; + autoUpdater.logger = console; + + const feedUrl = process.env.OPENSCREEN_UPDATE_FEED_URL?.trim() || DEFAULT_UPDATE_FEED_URL; + const updateToken = process.env.OPENSCREEN_UPDATE_TOKEN?.trim(); + if (updateToken) { + autoUpdater.requestHeaders = { + Authorization: `token ${updateToken}`, + }; + } + autoUpdater.setFeedURL({ + provider: "generic", + url: feedUrl, + }); + + autoUpdater.on("checking-for-update", () => { + updateStatus(createStatus("checking")); + }); + autoUpdater.on("update-available", (info) => { + updateStatus(statusFromInfo("available", info)); + }); + autoUpdater.on("update-not-available", (info) => { + updateStatus(statusFromInfo("not-available", info)); + }); + autoUpdater.on("download-progress", (progress: ProgressInfo) => { + updateStatus( + createStatus("downloading", { + version: status.version, + releaseName: status.releaseName, + releaseNotes: status.releaseNotes, + percent: progress.percent, + bytesPerSecond: progress.bytesPerSecond, + transferred: progress.transferred, + total: progress.total, + }), + ); + }); + autoUpdater.on("update-downloaded", (info) => { + updateStatus(statusFromInfo("downloaded", info)); + }); + autoUpdater.on("error", (error) => { + const message = error instanceof Error ? error.message : String(error); + updateStatus(createStatus("error", { error: message })); + }); + + setTimeout(() => { + void checkForUpdates(); + }, AUTO_CHECK_DELAY_MS); + setInterval(() => { + void checkForUpdates(); + }, AUTO_CHECK_INTERVAL_MS).unref(); +} diff --git a/package-lock.json b/package-lock.json index e24c5f1..0eb79d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dnd-timeline": "^2.4.0", + "electron-updater": "^6.8.3", "emoji-picker-react": "^4.18.0", "fix-webm-duration": "^1.0.6", "gif.js": "^0.2.0", @@ -4625,7 +4626,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-hidden": { @@ -4959,7 +4959,6 @@ "version": "9.5.1", "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz", "integrity": "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==", - "dev": true, "license": "MIT", "dependencies": { "debug": "^4.3.4", @@ -5504,7 +5503,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -6015,6 +6013,69 @@ "dev": true, "license": "ISC" }, + "node_modules/electron-updater": { + "version": "6.8.3", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.3.tgz", + "integrity": "sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.5.1", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "~7.7.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-updater/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-updater/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-updater/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/electron-updater/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/electron-winstaller": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", @@ -6874,7 +6935,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/gsap": { @@ -7287,7 +7347,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -7419,7 +7478,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", - "dev": true, "license": "MIT" }, "node_modules/lilconfig": { @@ -7586,6 +7644,19 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/log-update": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", @@ -7991,7 +8062,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -9394,7 +9464,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" @@ -10099,6 +10168,12 @@ "node": ">=12" } }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/package.json b/package.json index bc1a9f3..c0ab2b3 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dnd-timeline": "^2.4.0", + "electron-updater": "^6.8.3", "emoji-picker-react": "^4.18.0", "fix-webm-duration": "^1.0.6", "gif.js": "^0.2.0", diff --git a/src/App.tsx b/src/App.tsx index e9ef3bf..5051ddb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,5 @@ -import { lazy, Suspense, useEffect, useState } from "react"; +import { lazy, Suspense, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; import { CountdownOverlay } from "./components/launch/CountdownOverlay.tsx"; import { LaunchWindow } from "./components/launch/LaunchWindow"; import { SourceSelector } from "./components/launch/SourceSelector"; @@ -6,6 +7,7 @@ import { Toaster } from "./components/ui/sonner"; import { TooltipProvider } from "./components/ui/tooltip"; import { ShortcutsProvider } from "./contexts/ShortcutsContext"; import { loadAllCustomFonts } from "./lib/customFonts"; +import type { UpdateStatus } from "./lib/updateStatus"; const VideoEditor = lazy(() => import("./components/video-editor/VideoEditor")); const ShortcutsConfigDialog = lazy(() => @@ -79,11 +81,78 @@ export default function App() { return ( {content} + ); } +function UpdateNotifier({ enabled }: { enabled: boolean }) { + const lastPhaseRef = useRef("idle"); + + useEffect(() => { + if (!enabled || !window.electronAPI?.updates) { + return; + } + + const applyStatus = (status: UpdateStatus) => { + const version = status.version ? ` ${status.version}` : ""; + if (status.phase === "available") { + toast.loading(`Downloading OpenScreen${version} update...`, { + id: "openscreen-update", + duration: Number.POSITIVE_INFINITY, + }); + } else if (status.phase === "downloading") { + const percent = typeof status.percent === "number" ? ` ${Math.round(status.percent)}%` : ""; + toast.loading(`Downloading OpenScreen${version} update${percent}...`, { + id: "openscreen-update", + duration: Number.POSITIVE_INFINITY, + }); + } else if (status.phase === "downloaded") { + toast.success(`OpenScreen${version} is ready to install.`, { + id: "openscreen-update", + duration: Number.POSITIVE_INFINITY, + action: { + label: "Restart", + onClick: () => { + void window.electronAPI.updates.install(); + }, + }, + }); + } else if ( + status.phase === "error" && + (lastPhaseRef.current === "available" || + lastPhaseRef.current === "downloading" || + lastPhaseRef.current === "downloaded") + ) { + toast.error(status.error || "OpenScreen update failed.", { + id: "openscreen-update", + }); + } else if (status.phase === "not-available" || status.phase === "unsupported") { + toast.dismiss("openscreen-update"); + } + lastPhaseRef.current = status.phase; + }; + + const unsubscribe = window.electronAPI.updates.onStatus(applyStatus); + void window.electronAPI.updates + .getStatus() + .then(applyStatus) + .catch(() => undefined); + + return unsubscribe; + }, [enabled]); + + return null; +} + function BrowserDevFallback() { return (
diff --git a/src/lib/updateStatus.ts b/src/lib/updateStatus.ts new file mode 100644 index 0000000..ab22a4a --- /dev/null +++ b/src/lib/updateStatus.ts @@ -0,0 +1,29 @@ +export type UpdateStatusPhase = + | "idle" + | "checking" + | "available" + | "not-available" + | "downloading" + | "downloaded" + | "error" + | "unsupported"; + +export interface UpdateStatus { + phase: UpdateStatusPhase; + currentVersion: string; + version?: string; + releaseName?: string; + releaseNotes?: string; + percent?: number; + bytesPerSecond?: number; + transferred?: number; + total?: number; + error?: string; + updatedAt: string; +} + +export interface UpdateCheckResult { + success: boolean; + status: UpdateStatus; + error?: string; +}