Skip to content
Open
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
60 changes: 60 additions & 0 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import { fileURLToPath } from "node:url";
import {
app,
BrowserWindow,
dialog,
ipcMain,
Menu,
nativeImage,
net,
session,
shell,
systemPreferences,
Tray,
} from "electron";
Expand All @@ -24,6 +27,7 @@ import { mainT, setMainLocale } from "./i18n";
import { getSelectedDesktopSource, registerIpcHandlers } from "./ipc/handlers";
import { installMainProcessErrorGuards } from "./main-process-errors";
import { registerSttIpc } from "./stt";
import { checkLatestRelease } from "./update-checker";
import {
createCountdownOverlayWindow,
createEditorWindow,
Expand Down Expand Up @@ -340,6 +344,55 @@ function getTrayIcon(filename: string, size: number) {
});
}

let updateCheckInFlight = false;

async function checkForUpdates() {
if (updateCheckInFlight) return;
updateCheckInFlight = true;
try {
const result = await checkLatestRelease({
currentVersion: app.getVersion(),
fetchLatest: (url, init) => net.fetch(url, init),
signal: AbortSignal.timeout(10_000),
});
if (result.kind === "current") {
await dialog.showMessageBox({
type: "info",
title: app.name,
message: mainT("common", "updates.current", {
currentVersion: result.currentVersion,
}),
});
return;
}

const choice = await dialog.showMessageBox({
type: "info",
title: app.name,
message: mainT("common", "updates.available", {
currentVersion: result.currentVersion,
latestVersion: result.latestVersion,
}),
buttons: [
mainT("common", "actions.viewRelease") || "View Release",
mainT("common", "actions.cancel") || "Cancel",
],
defaultId: 0,
cancelId: 1,
});
if (choice.response === 0) await shell.openExternal(result.releaseUrl);
} catch (error) {
await dialog.showMessageBox({
type: "error",
title: app.name,
message: mainT("common", "updates.failed"),
detail: error instanceof Error ? error.message : String(error),
});
} finally {
updateCheckInFlight = false;
}
}

function updateTrayMenu(recording: boolean = false) {
if (!tray) return;
const trayIcon = recording ? recordingTrayIcon : defaultTrayIcon;
Expand All @@ -366,6 +419,13 @@ function updateTrayMenu(recording: boolean = false) {
showMainWindow();
},
},
{
label: mainT("common", "actions.checkForUpdates") || "Check for Updates",
click: () => {
void checkForUpdates();
},
},
{ type: "separator" as const },
{
label: mainT("common", "actions.quit") || "Quit",
click: () => {
Expand Down
168 changes: 168 additions & 0 deletions electron/update-checker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { describe, expect, it, vi } from "vitest";
import { checkLatestRelease, compareVersions } from "./update-checker";

function releaseResponse(payload: unknown, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
json: vi.fn().mockResolvedValue(payload),
};
}

describe("compareVersions", () => {
it("implements semantic version ordering for stable and prerelease builds", () => {
expect(compareVersions("v2.0.0", "1.9.9")).toBeGreaterThan(0);
expect(compareVersions("1.9.0", "1.9.0-rc.2")).toBeGreaterThan(0);
expect(compareVersions("1.9.0-rc.10", "1.9.0-rc.2")).toBeGreaterThan(0);
expect(compareVersions("1.9.0+build.2", "v1.9.0+build.1")).toBe(0);
});

it("preserves precision for oversized core identifiers", () => {
expect(compareVersions("9007199254740993.0.0", "9007199254740992.0.0")).toBeGreaterThan(0);
});

it.each([
"01.0.0",
"1.02.0",
"1.0.03",
"1.0.0-rc.01",
])("rejects leading-zero numeric identifiers in %s", (version) => {
expect(() => compareVersions(version, "1.0.0")).toThrow("invalid semantic version");
});
});

describe("checkLatestRelease", () => {
it("reports a newer official stable release", async () => {
const fetchLatest = vi.fn().mockResolvedValue(
releaseResponse({
tag_name: "v1.10.0",
html_url: "https://github.com/getopenscreen/openscreen/releases/tag/v1.10.0",
draft: false,
prerelease: false,
}),
);

await expect(checkLatestRelease({ currentVersion: "1.9.0", fetchLatest })).resolves.toEqual({
kind: "available",
currentVersion: "1.9.0",
latestVersion: "1.10.0",
releaseUrl: "https://github.com/getopenscreen/openscreen/releases/tag/v1.10.0",
});
expect(fetchLatest).toHaveBeenCalledWith(
"https://api.github.com/repos/getopenscreen/openscreen/releases/latest",
expect.objectContaining({
headers: expect.objectContaining({ Accept: "application/vnd.github+json" }),
}),
);
});

it("reports current when the installed version is equal or newer", async () => {
const fetchLatest = vi.fn().mockResolvedValue(
releaseResponse({
tag_name: "v1.9.0",
html_url: "https://github.com/getopenscreen/openscreen/releases/tag/v1.9.0",
draft: false,
prerelease: false,
}),
);

await expect(checkLatestRelease({ currentVersion: "1.9.1", fetchLatest })).resolves.toEqual({
kind: "current",
currentVersion: "1.9.1",
latestVersion: "1.9.0",
});
await expect(checkLatestRelease({ currentVersion: "1.9.0", fetchLatest })).resolves.toEqual({
kind: "current",
currentVersion: "1.9.0",
latestVersion: "1.9.0",
});
});

it("forwards the cancellation signal", async () => {
const fetchLatest = vi.fn().mockResolvedValue(
releaseResponse({
tag_name: "v1.9.0",
html_url: "https://github.com/getopenscreen/openscreen/releases/tag/v1.9.0",
draft: false,
prerelease: false,
}),
);
const controller = new AbortController();

await checkLatestRelease({
currentVersion: "1.9.0",
fetchLatest,
signal: controller.signal,
});

expect(fetchLatest).toHaveBeenCalledWith(
"https://api.github.com/repos/getopenscreen/openscreen/releases/latest",
expect.objectContaining({ signal: controller.signal }),
);
});

it.each([
{ draft: true, prerelease: false },
{ draft: false, prerelease: true },
])("rejects draft and prerelease payloads: %o", async ({ draft, prerelease }) => {
const fetchLatest = vi.fn().mockResolvedValue(
releaseResponse({
tag_name: "v2.0.0",
html_url: "https://github.com/getopenscreen/openscreen/releases/tag/v2.0.0",
draft,
prerelease,
}),
);

await expect(checkLatestRelease({ currentVersion: "1.9.0", fetchLatest })).rejects.toThrow(
"invalid GitHub release response",
);
});

it("rejects a release URL outside the official repository", async () => {
const fetchLatest = vi.fn().mockResolvedValue(
releaseResponse({
tag_name: "v9.9.9",
html_url: "https://example.com/openscreen-9.9.9.exe",
draft: false,
prerelease: false,
}),
);

await expect(checkLatestRelease({ currentVersion: "1.9.0", fetchLatest })).rejects.toThrow(
"untrusted release URL",
);
});

it.each([
"https://github.com/getopenscreen/openscreen/releases/download/v9.9.9/app.zip",
"https://github.com/getopenscreen/openscreen/releases/tag/v9.9.8",
"https://github.com/getopenscreen/openscreen/releases/tag/v9.9.9?download=1",
"https://github.com/getopenscreen/openscreen/releases/tag/v9.9.9#notes",
])("rejects an invalid official-repository URL: %s", async (htmlUrl) => {
const fetchLatest = vi.fn().mockResolvedValue(
releaseResponse({
tag_name: "v9.9.9",
html_url: htmlUrl,
draft: false,
prerelease: false,
}),
);

await expect(checkLatestRelease({ currentVersion: "1.9.0", fetchLatest })).rejects.toThrow(
"untrusted release URL",
);
});

it("rejects unsuccessful or malformed GitHub responses", async () => {
const unavailable = vi.fn().mockResolvedValue(releaseResponse({}, 503));
await expect(
checkLatestRelease({ currentVersion: "1.9.0", fetchLatest: unavailable }),
).rejects.toThrow("GitHub release check failed (503)");

const malformed = vi.fn().mockResolvedValue(releaseResponse({ tag_name: "v2.0.0" }));
await expect(
checkLatestRelease({ currentVersion: "1.9.0", fetchLatest: malformed }),
).rejects.toThrow("invalid GitHub release response");
});
Comment thread
arhxam marked this conversation as resolved.
});
Loading
Loading