diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 85d82946..92e17d0b 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -73,6 +73,7 @@ interface Window { }>; onStopRecordingFromTray: (callback: () => void) => () => void; openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>; + getAppVersion: () => Promise; saveExportedVideo: ( videoData: ArrayBuffer, fileName: string, @@ -128,6 +129,7 @@ interface Window { onMenuLoadProject: (callback: () => void) => () => void; onMenuSaveProject: (callback: () => void) => () => void; onMenuSaveProjectAs: (callback: () => void) => () => void; + onMenuCheckForUpdates: (callback: () => void) => () => void; getPlatform: () => Promise; revealInFolder: ( filePath: string, diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 95ed797e..fd9023f0 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -790,6 +790,8 @@ export function registerIpcHandlers( } }); + ipcMain.handle("get-app-version", () => app.getVersion()); + ipcMain.handle("open-external-url", async (_, url: string) => { try { const ALLOWED_SCHEMES = ["http:", "https:", "mailto:"]; diff --git a/electron/main.ts b/electron/main.ts index ad0a33fc..ec93501d 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -116,6 +116,21 @@ function sendEditorMenuAction( targetWindow.webContents.send(channel); } +function sendCheckForUpdates() { + const target = BrowserWindow.getFocusedWindow() ?? mainWindow; + if (target && !target.isDestroyed()) { + target.webContents.send("menu-check-for-updates"); + return; + } + showMainWindow(); + const dispatchTarget = mainWindow; + if (!dispatchTarget || dispatchTarget.isDestroyed()) return; + dispatchTarget.webContents.once("did-finish-load", () => { + if (dispatchTarget.isDestroyed()) return; + dispatchTarget.webContents.send("menu-check-for-updates"); + }); +} + function setupApplicationMenu() { const isMac = process.platform === "darwin"; const template: Electron.MenuItemConstructorOptions[] = []; @@ -191,6 +206,15 @@ function setupApplicationMenu() { ? [{ role: "minimize" }, { role: "zoom" }, { type: "separator" }, { role: "front" }] : [{ role: "minimize" }, { role: "close" }], }, + { + role: "help", + submenu: [ + { + label: mainT("common", "actions.checkForUpdates") || "Check for Updates…", + click: () => sendCheckForUpdates(), + }, + ], + }, ); const menu = Menu.buildFromTemplate(template); diff --git a/electron/preload.ts b/electron/preload.ts index 46e16f04..d4513c0c 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -68,6 +68,9 @@ contextBridge.exposeInMainWorld("electronAPI", { openExternalUrl: (url: string) => { return ipcRenderer.invoke("open-external-url", url); }, + getAppVersion: () => { + return ipcRenderer.invoke("get-app-version"); + }, saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => { return ipcRenderer.invoke("save-exported-video", videoData, fileName); }, @@ -116,6 +119,11 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.on("menu-save-project-as", listener); return () => ipcRenderer.removeListener("menu-save-project-as", listener); }, + onMenuCheckForUpdates: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("menu-check-for-updates", listener); + return () => ipcRenderer.removeListener("menu-check-for-updates", listener); + }, getPlatform: () => { return ipcRenderer.invoke("get-platform"); }, diff --git a/src/App.tsx b/src/App.tsx index 4045b5dc..f7613a40 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,8 +7,11 @@ import { TooltipProvider } from "./components/ui/tooltip"; import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog"; import VideoEditor from "./components/video-editor/VideoEditor"; import { ShortcutsProvider } from "./contexts/ShortcutsContext"; +import { forceCheckForUpdate, maybeShowUpdateToast } from "./lib/checkForUpdate"; import { loadAllCustomFonts } from "./lib/customFonts"; +const UPDATE_CHECK_DELAY_MS = 3000; + export default function App() { const [windowType, setWindowType] = useState( () => new URLSearchParams(window.location.search).get("windowType") || "", @@ -34,6 +37,38 @@ export default function App() { }); }, []); + useEffect(() => { + if (windowType !== "") return; + const id = setTimeout(async () => { + try { + const version = await window.electronAPI.getAppVersion(); + await maybeShowUpdateToast(version); + } catch (error) { + console.error("Error during on-start update check in App useEffect:", error); + } + }, UPDATE_CHECK_DELAY_MS); + return () => clearTimeout(id); + }, [windowType]); + + useEffect(() => { + if ( + windowType === "hud-overlay" || + windowType === "source-selector" || + windowType === "countdown-overlay" + ) { + return; + } + const unsubscribe = window.electronAPI.onMenuCheckForUpdates(async () => { + try { + const version = await window.electronAPI.getAppVersion(); + forceCheckForUpdate(version); + } catch (error) { + console.error("Error handling onMenuCheckForUpdates in App:", error); + } + }); + return unsubscribe; + }, [windowType]); + const content = (() => { switch (windowType) { case "hud-overlay": diff --git a/src/lib/checkForUpdate.test.ts b/src/lib/checkForUpdate.test.ts new file mode 100644 index 00000000..5a0484ab --- /dev/null +++ b/src/lib/checkForUpdate.test.ts @@ -0,0 +1,181 @@ +import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from "vitest"; + +vi.mock("sonner", () => { + const fn = vi.fn(); + const toast = Object.assign(fn, { + success: vi.fn(), + error: vi.fn(), + }); + return { toast }; +}); + +const localStorageStore: Record = {}; +Object.defineProperty(globalThis, "localStorage", { + value: { + getItem: (k: string) => localStorageStore[k] ?? null, + setItem: (k: string, v: string) => { + localStorageStore[k] = v; + }, + removeItem: (k: string) => { + delete localStorageStore[k]; + }, + clear: () => { + for (const k of Object.keys(localStorageStore)) delete localStorageStore[k]; + }, + }, + configurable: true, +}); + +const openExternalUrl = vi.fn().mockResolvedValue({ success: true }); + +Object.defineProperty(window, "electronAPI", { + value: { openExternalUrl }, + configurable: true, + writable: true, +}); + +import { toast } from "sonner"; +import { forceCheckForUpdate, isNewer, maybeShowUpdateToast } from "./checkForUpdate"; + +const toastMock = toast as unknown as Mock; +const toastSuccess = toast.success as Mock; +const toastError = toast.error as Mock; + +function mockReleaseResponse(tagName: string, htmlUrl = "https://example.com/release") { + (global.fetch as Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ tag_name: tagName, html_url: htmlUrl }), + }); +} + +function resetUpdateCheckMocks() { + localStorage.clear(); + global.fetch = vi.fn() as unknown as typeof fetch; + toastMock.mockClear(); + toastSuccess.mockClear(); + toastError.mockClear(); + openExternalUrl.mockClear(); +} + +describe("isNewer", () => { + it("detects a higher minor version", () => { + expect(isNewer("1.3.0", "1.2.0")).toBe(true); + }); + + it("compares numerically rather than lexicographically", () => { + expect(isNewer("1.10.0", "1.2.0")).toBe(true); + }); + + it("returns false for equal versions", () => { + expect(isNewer("1.2.0", "1.2.0")).toBe(false); + }); + + it("returns false when current is newer", () => { + expect(isNewer("1.2.0", "1.3.0")).toBe(false); + }); + + it("rejects pre-release tags", () => { + expect(isNewer("1.3.0-beta.1", "1.2.0")).toBe(false); + }); +}); + +describe("maybeShowUpdateToast", () => { + beforeEach(resetUpdateCheckMocks); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("fires the update toast when a newer release exists", async () => { + mockReleaseResponse("v1.3.0"); + await maybeShowUpdateToast("1.2.0"); + expect(toastMock).toHaveBeenCalledTimes(1); + const [title, options] = toastMock.mock.calls[0]; + expect(title).toBe("OpenScreen 1.3.0 is available"); + expect(options.action.label).toBe("Download"); + expect(options.cancel.label).toBe("Don't remind again"); + expect(options.closeButton).toBe(true); + }); + + it("skips entirely when checks are disabled", async () => { + localStorage.setItem("openscreen_update_checks_disabled", "true"); + await maybeShowUpdateToast("1.2.0"); + expect(global.fetch).not.toHaveBeenCalled(); + expect(toastMock).not.toHaveBeenCalled(); + }); + + it("does not show toast when this version was dismissed", async () => { + localStorage.setItem("openscreen_dismissed_update_version", "1.3.0"); + mockReleaseResponse("v1.3.0"); + await maybeShowUpdateToast("1.2.0"); + expect(global.fetch).toHaveBeenCalled(); + expect(toastMock).not.toHaveBeenCalled(); + }); + + it("stays silent on fetch failure", async () => { + (global.fetch as Mock).mockRejectedValueOnce(new Error("network down")); + await maybeShowUpdateToast("1.2.0"); + expect(toastMock).not.toHaveBeenCalled(); + expect(toastError).not.toHaveBeenCalled(); + }); + + it("download action opens the release URL", async () => { + mockReleaseResponse("v1.3.0", "https://example.com/release/1.3.0"); + await maybeShowUpdateToast("1.2.0"); + const [, options] = toastMock.mock.calls[0]; + await options.action.onClick(); + expect(openExternalUrl).toHaveBeenCalledWith("https://example.com/release/1.3.0"); + }); + + it("cancel action persists checks-disabled flag", async () => { + mockReleaseResponse("v1.3.0"); + await maybeShowUpdateToast("1.2.0"); + const [, options] = toastMock.mock.calls[0]; + options.cancel.onClick(); + expect(localStorage.getItem("openscreen_update_checks_disabled")).toBe("true"); + }); + + it("close button dismissal persists per-version cache", async () => { + mockReleaseResponse("v1.3.0"); + await maybeShowUpdateToast("1.2.0"); + const [, options] = toastMock.mock.calls[0]; + options.onDismiss(); + expect(localStorage.getItem("openscreen_dismissed_update_version")).toBe("1.3.0"); + }); +}); + +describe("forceCheckForUpdate", () => { + beforeEach(resetUpdateCheckMocks); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("bypasses checks-disabled and dismissed-version gates", async () => { + localStorage.setItem("openscreen_update_checks_disabled", "true"); + localStorage.setItem("openscreen_dismissed_update_version", "1.3.0"); + mockReleaseResponse("v1.3.0"); + await forceCheckForUpdate("1.2.0"); + expect(toastMock).toHaveBeenCalledTimes(1); + }); + + it("shows up-to-date success toast when current matches latest", async () => { + mockReleaseResponse("v1.2.0"); + await forceCheckForUpdate("1.2.0"); + expect(toastSuccess).toHaveBeenCalledTimes(1); + expect(toastSuccess.mock.calls[0][0]).toBe("OpenScreen is up to date"); + }); + + it("shows up-to-date success toast when current is newer than latest", async () => { + mockReleaseResponse("v1.2.0"); + await forceCheckForUpdate("1.3.0"); + expect(toastSuccess).toHaveBeenCalledTimes(1); + }); + + it("shows error toast on fetch failure", async () => { + (global.fetch as Mock).mockRejectedValueOnce(new Error("offline")); + await forceCheckForUpdate("1.2.0"); + expect(toastError).toHaveBeenCalledTimes(1); + expect(toastError.mock.calls[0][0]).toBe("Couldn't check for updates"); + }); +}); diff --git a/src/lib/checkForUpdate.ts b/src/lib/checkForUpdate.ts new file mode 100644 index 00000000..ae4f7021 --- /dev/null +++ b/src/lib/checkForUpdate.ts @@ -0,0 +1,92 @@ +import { toast } from "sonner"; +import { + getDismissedUpdateVersion, + getUpdateChecksDisabled, + saveDismissedUpdateVersion, + setUpdateChecksDisabled, +} from "./updatePreferences"; + +const RELEASES_API = "https://api.github.com/repos/siddharthvaddem/openscreen/releases/latest"; + +export interface LatestRelease { + version: string; + htmlUrl: string; +} + +export async function fetchLatestRelease(): Promise { + try { + const response = await fetch(RELEASES_API, { + headers: { Accept: "application/vnd.github+json" }, + }); + if (!response.ok) return null; + const data = (await response.json()) as { + tag_name?: unknown; + html_url?: unknown; + }; + if (typeof data.tag_name !== "string" || typeof data.html_url !== "string") return null; + const version = data.tag_name.replace(/^v/, ""); + if (version.includes("-")) return null; + return { version, htmlUrl: data.html_url }; + } catch (error) { + console.error("checkForUpdate failed:", error); + return null; + } +} + +export function isNewer(latest: string, current: string): boolean { + if (latest.includes("-") || current.includes("-")) return false; + const latestParts = latest.split(".").map((n) => Number.parseInt(n, 10)); + const currentParts = current.split(".").map((n) => Number.parseInt(n, 10)); + if (latestParts.some(Number.isNaN) || currentParts.some(Number.isNaN)) return false; + const length = Math.max(latestParts.length, currentParts.length); + for (let i = 0; i < length; i++) { + const a = latestParts[i] ?? 0; + const b = currentParts[i] ?? 0; + if (a > b) return true; + if (a < b) return false; + } + return false; +} + +function showUpdateAvailableToast(release: LatestRelease, currentVersion: string): void { + toast(`OpenScreen ${release.version} is available`, { + duration: Number.POSITIVE_INFINITY, + description: `You're on ${currentVersion}`, + closeButton: true, + onDismiss: () => saveDismissedUpdateVersion(release.version), + action: { + label: "Download", + onClick: () => window.electronAPI.openExternalUrl(release.htmlUrl), + }, + cancel: { + label: "Don't remind again", + onClick: () => setUpdateChecksDisabled(true), + }, + }); +} + +export async function maybeShowUpdateToast(currentVersion: string): Promise { + if (getUpdateChecksDisabled()) return; + const release = await fetchLatestRelease(); + if (!release) return; + if (!isNewer(release.version, currentVersion)) return; + if (getDismissedUpdateVersion() === release.version) return; + showUpdateAvailableToast(release, currentVersion); +} + +export async function forceCheckForUpdate(currentVersion: string): Promise { + const release = await fetchLatestRelease(); + if (!release) { + toast.error("Couldn't check for updates", { + description: "Check your connection and try again.", + }); + return; + } + if (!isNewer(release.version, currentVersion)) { + toast.success("OpenScreen is up to date", { + description: `You're on ${currentVersion}`, + }); + return; + } + showUpdateAvailableToast(release, currentVersion); +} diff --git a/src/lib/updatePreferences.ts b/src/lib/updatePreferences.ts new file mode 100644 index 00000000..d42860e2 --- /dev/null +++ b/src/lib/updatePreferences.ts @@ -0,0 +1,36 @@ +const DISMISSED_VERSION_KEY = "openscreen_dismissed_update_version"; +const CHECKS_DISABLED_KEY = "openscreen_update_checks_disabled"; + +export function getDismissedUpdateVersion(): string | null { + try { + return localStorage.getItem(DISMISSED_VERSION_KEY); + } catch (error) { + console.error("Failed to read dismissed update version from localStorage:", error); + return null; + } +} + +export function saveDismissedUpdateVersion(version: string): void { + try { + localStorage.setItem(DISMISSED_VERSION_KEY, version); + } catch (error) { + console.error("Failed to save dismissed update version to localStorage:", error); + } +} + +export function getUpdateChecksDisabled(): boolean { + try { + return localStorage.getItem(CHECKS_DISABLED_KEY) === "true"; + } catch (error) { + console.error("Failed to read update checks disabled state from localStorage:", error); + return false; + } +} + +export function setUpdateChecksDisabled(disabled: boolean): void { + try { + localStorage.setItem(CHECKS_DISABLED_KEY, disabled ? "true" : "false"); + } catch (error) { + console.error("Failed to save update checks disabled state to localStorage:", error); + } +}