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
89 changes: 89 additions & 0 deletions electron/update-checker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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);
});
});

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",
});
});

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("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");
});
});
153 changes: 153 additions & 0 deletions electron/update-checker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
const LATEST_RELEASE_API = "https://api.github.com/repos/getopenscreen/openscreen/releases/latest";
const OFFICIAL_RELEASE_PREFIX = "/getopenscreen/openscreen/releases/tag/";

interface ReleaseResponse {
ok: boolean;
status: number;
json(): Promise<unknown>;
}

type FetchLatestRelease = (
url: string,
init: {
headers: Record<string, string>;
signal?: AbortSignal;
},
) => Promise<ReleaseResponse>;

interface ParsedVersion {
major: number;
minor: number;
patch: number;
prerelease: string[];
normalized: string;
}

function parseVersion(value: string): ParsedVersion {
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(
value.trim(),
);
if (!match) throw new Error(`invalid semantic version: ${value}`);
const prerelease = match[4]?.split(".") ?? [];
const core = `${Number(match[1])}.${Number(match[2])}.${Number(match[3])}`;
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
prerelease,
normalized: prerelease.length > 0 ? `${core}-${prerelease.join(".")}` : core,
};
}

function comparePrerelease(left: string[], right: string[]): number {
if (left.length === 0 || right.length === 0) {
return left.length === right.length ? 0 : left.length === 0 ? 1 : -1;
}
for (let i = 0; i < Math.max(left.length, right.length); i++) {
const a = left[i];
const b = right[i];
if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1;
if (a === b) continue;
const aNumeric = /^\d+$/.test(a);
const bNumeric = /^\d+$/.test(b);
if (aNumeric && bNumeric) return BigInt(a) > BigInt(b) ? 1 : -1;
if (aNumeric !== bNumeric) return aNumeric ? -1 : 1;
return a > b ? 1 : -1;
}
return 0;
}

export function compareVersions(left: string, right: string): number {
const a = parseVersion(left);
const b = parseVersion(right);
for (const key of ["major", "minor", "patch"] as const) {
if (a[key] !== b[key]) return a[key] > b[key] ? 1 : -1;
}
return comparePrerelease(a.prerelease, b.prerelease);
}

function officialReleaseUrl(value: string, tag: string): string {
let url: URL;
try {
url = new URL(value);
} catch {
throw new Error("untrusted release URL");
}
const encodedTag = url.pathname.slice(OFFICIAL_RELEASE_PREFIX.length);
let decodedTag = "";
try {
decodedTag = decodeURIComponent(encodedTag);
} catch {
throw new Error("untrusted release URL");
}
if (
url.protocol !== "https:" ||
url.hostname !== "github.com" ||
url.port !== "" ||
!url.pathname.startsWith(OFFICIAL_RELEASE_PREFIX) ||
decodedTag !== tag ||
url.search !== "" ||
url.hash !== ""
) {
throw new Error("untrusted release URL");
}
return url.toString();
}

export type UpdateCheckResult =
| {
kind: "available";
currentVersion: string;
latestVersion: string;
releaseUrl: string;
}
| {
kind: "current";
currentVersion: string;
latestVersion: string;
};

export async function checkLatestRelease(options: {
currentVersion: string;
fetchLatest: FetchLatestRelease;
signal?: AbortSignal;
}): Promise<UpdateCheckResult> {
const response = await options.fetchLatest(LATEST_RELEASE_API, {
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
...(options.signal ? { signal: options.signal } : {}),
});
if (!response.ok) throw new Error(`GitHub release check failed (${response.status})`);

const payload = await response.json();
if (
typeof payload !== "object" ||
payload === null ||
typeof (payload as Record<string, unknown>).tag_name !== "string" ||
typeof (payload as Record<string, unknown>).html_url !== "string" ||
(payload as Record<string, unknown>).draft !== false ||
(payload as Record<string, unknown>).prerelease !== false
) {
throw new Error("invalid GitHub release response");
}
const release = payload as { tag_name: string; html_url: string };
const current = parseVersion(options.currentVersion);
const latest = parseVersion(release.tag_name);
const comparison = compareVersions(latest.normalized, current.normalized);
if (comparison <= 0) {
return {
kind: "current",
currentVersion: current.normalized,
latestVersion: latest.normalized,
};
}

return {
kind: "available",
currentVersion: current.normalized,
latestVersion: latest.normalized,
releaseUrl: officialReleaseUrl(release.html_url, release.tag_name),
};
}
7 changes: 7 additions & 0 deletions src/i18n/locales/ar/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
"share": "مشاركة",
"done": "تم",
"open": "فتح",
"checkForUpdates": "التحقق من وجود تحديثات",
"viewRelease": "عرض الإصدار",
"upload": "رفع",
"export": "تصدير",
"showInFolder": "عرض في المجلد",
Expand Down Expand Up @@ -37,6 +39,11 @@
"hideOthers": "إخفاء الآخرين",
"unhide": "إظهار الكل"
},
"updates": {
"available": "يتوفر OpenScreen {{latestVersion}}. الإصدار المثبت هو {{currentVersion}}.",
"current": "OpenScreen محدّث ({{currentVersion}}).",
"failed": "تعذّر التحقق من وجود تحديثات."
},
"playback": {
"play": "تشغيل",
"pause": "ايقاف مؤقت",
Expand Down
7 changes: 7 additions & 0 deletions src/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
"share": "Share",
"done": "Done",
"open": "Open",
"checkForUpdates": "Check for Updates",
"viewRelease": "View Release",
"upload": "Upload",
"export": "Export",
"showInFolder": "Show in Folder",
Expand Down Expand Up @@ -37,6 +39,11 @@
"hideOthers": "Hide Others",
"unhide": "Show All"
},
"updates": {
"available": "OpenScreen {{latestVersion}} is available. You are using {{currentVersion}}.",
"current": "OpenScreen is up to date ({{currentVersion}}).",
"failed": "Could not check for updates."
},
"playback": {
"play": "Play",
"pause": "Pause",
Expand Down
7 changes: 7 additions & 0 deletions src/i18n/locales/es/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
"share": "Compartir",
"done": "Listo",
"open": "Abrir",
"checkForUpdates": "Buscar actualizaciones",
"viewRelease": "Ver versión",
"upload": "Subir",
"export": "Exportar",
"showInFolder": "Mostrar en carpeta",
Expand Down Expand Up @@ -37,6 +39,11 @@
"hideOthers": "Ocultar otros",
"unhide": "Mostrar todo"
},
"updates": {
"available": "OpenScreen {{latestVersion}} está disponible. Estás usando {{currentVersion}}.",
"current": "OpenScreen está actualizado ({{currentVersion}}).",
"failed": "No se pudieron buscar actualizaciones."
},
"playback": {
"play": "Reproducir",
"pause": "Pausar",
Expand Down
Loading
Loading