Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ interface Window {
}>;
onStopRecordingFromTray: (callback: () => void) => () => void;
openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>;
getAppVersion: () => Promise<string>;
saveExportedVideo: (
videoData: ArrayBuffer,
fileName: string,
Expand Down Expand Up @@ -128,6 +129,7 @@ interface Window {
onMenuLoadProject: (callback: () => void) => () => void;
onMenuSaveProject: (callback: () => void) => () => void;
onMenuSaveProjectAs: (callback: () => void) => () => void;
onMenuCheckForUpdates: (callback: () => void) => () => void;
getPlatform: () => Promise<string>;
revealInFolder: (
filePath: string,
Expand Down
2 changes: 2 additions & 0 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:"];
Expand Down
24 changes: 24 additions & 0 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
Expand Down Expand Up @@ -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");
},
Expand Down
35 changes: 35 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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") || "",
Expand All @@ -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":
Expand Down
181 changes: 181 additions & 0 deletions src/lib/checkForUpdate.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {};
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");
});
});
Loading
Loading