From fb521e97acf86f66ae825224b8e19a9778acb344 Mon Sep 17 00:00:00 2001 From: Arham Wani Date: Sun, 9 Aug 2026 00:10:41 +0530 Subject: [PATCH] feat(update): add manual release checking --- electron/main.ts | 60 +++++++++++ electron/update-checker.test.ts | 89 +++++++++++++++++ electron/update-checker.ts | 153 +++++++++++++++++++++++++++++ src/i18n/locales/ar/common.json | 7 ++ src/i18n/locales/en/common.json | 7 ++ src/i18n/locales/es/common.json | 7 ++ src/i18n/locales/fr/common.json | 7 ++ src/i18n/locales/it/common.json | 7 ++ src/i18n/locales/ja-JP/common.json | 7 ++ src/i18n/locales/ko-KR/common.json | 7 ++ src/i18n/locales/pt-BR/common.json | 7 ++ src/i18n/locales/ru/common.json | 7 ++ src/i18n/locales/tr/common.json | 7 ++ src/i18n/locales/vi/common.json | 7 ++ src/i18n/locales/zh-CN/common.json | 7 ++ src/i18n/locales/zh-TW/common.json | 7 ++ 16 files changed, 393 insertions(+) create mode 100644 electron/update-checker.test.ts create mode 100644 electron/update-checker.ts diff --git a/electron/main.ts b/electron/main.ts index 5c1388407..f719ed4dd 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -4,10 +4,13 @@ import { fileURLToPath } from "node:url"; import { app, BrowserWindow, + dialog, ipcMain, Menu, nativeImage, + net, session, + shell, systemPreferences, Tray, } from "electron"; @@ -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, @@ -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; @@ -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: () => { diff --git a/electron/update-checker.test.ts b/electron/update-checker.test.ts new file mode 100644 index 000000000..ddb41e671 --- /dev/null +++ b/electron/update-checker.test.ts @@ -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"); + }); +}); diff --git a/electron/update-checker.ts b/electron/update-checker.ts new file mode 100644 index 000000000..66cc2d339 --- /dev/null +++ b/electron/update-checker.ts @@ -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; +} + +type FetchLatestRelease = ( + url: string, + init: { + headers: Record; + signal?: AbortSignal; + }, +) => Promise; + +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 { + 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).tag_name !== "string" || + typeof (payload as Record).html_url !== "string" || + (payload as Record).draft !== false || + (payload as Record).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), + }; +} diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index aead94336..baf728d84 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -7,6 +7,8 @@ "share": "مشاركة", "done": "تم", "open": "فتح", + "checkForUpdates": "التحقق من وجود تحديثات", + "viewRelease": "عرض الإصدار", "upload": "رفع", "export": "تصدير", "showInFolder": "عرض في المجلد", @@ -37,6 +39,11 @@ "hideOthers": "إخفاء الآخرين", "unhide": "إظهار الكل" }, + "updates": { + "available": "يتوفر OpenScreen {{latestVersion}}. الإصدار المثبت هو {{currentVersion}}.", + "current": "OpenScreen محدّث ({{currentVersion}}).", + "failed": "تعذّر التحقق من وجود تحديثات." + }, "playback": { "play": "تشغيل", "pause": "ايقاف مؤقت", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 8dd5e6c86..23fec2451 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -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", @@ -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", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 0260dae04..6486fd545 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -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", @@ -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", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 14a6ef89a..130048349 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -7,6 +7,8 @@ "share": "Partager", "done": "Terminer", "open": "Ouvrir", + "checkForUpdates": "Rechercher des mises à jour", + "viewRelease": "Voir la version", "upload": "Téléverser", "export": "Exporter", "showInFolder": "Afficher dans le dossier", @@ -37,6 +39,11 @@ "hideOthers": "Masquer les autres", "unhide": "Tout afficher" }, + "updates": { + "available": "OpenScreen {{latestVersion}} est disponible. Vous utilisez la version {{currentVersion}}.", + "current": "OpenScreen est à jour ({{currentVersion}}).", + "failed": "Impossible de rechercher les mises à jour." + }, "playback": { "play": "Lecture", "pause": "Pause", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index a7e52d874..7265a1dbe 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -7,6 +7,8 @@ "share": "Condividi", "done": "Fatto", "open": "Apri", + "checkForUpdates": "Controlla aggiornamenti", + "viewRelease": "Visualizza versione", "upload": "Carica", "export": "Esporta", "showInFolder": "Mostra nella cartella", @@ -37,6 +39,11 @@ "hideOthers": "Nascondi gli altri", "unhide": "Mostra tutto" }, + "updates": { + "available": "OpenScreen {{latestVersion}} è disponibile. Stai usando la versione {{currentVersion}}.", + "current": "OpenScreen è aggiornato ({{currentVersion}}).", + "failed": "Impossibile controllare gli aggiornamenti." + }, "playback": { "play": "Riproduci", "pause": "Pausa", diff --git a/src/i18n/locales/ja-JP/common.json b/src/i18n/locales/ja-JP/common.json index f2a063f51..66d23371b 100644 --- a/src/i18n/locales/ja-JP/common.json +++ b/src/i18n/locales/ja-JP/common.json @@ -7,6 +7,8 @@ "share": "共有", "done": "完了", "open": "開く", + "checkForUpdates": "アップデートを確認", + "viewRelease": "リリースを表示", "upload": "読み込む", "export": "エクスポート", "showInFolder": "フォルダに表示", @@ -37,6 +39,11 @@ "hideOthers": "ほかを隠す", "unhide": "すべて表示" }, + "updates": { + "available": "OpenScreen {{latestVersion}} を利用できます。現在のバージョンは {{currentVersion}} です。", + "current": "OpenScreen は最新です({{currentVersion}})。", + "failed": "アップデートを確認できませんでした。" + }, "playback": { "play": "再生", "pause": "一時停止", diff --git a/src/i18n/locales/ko-KR/common.json b/src/i18n/locales/ko-KR/common.json index 7c1e5e8c3..a3e409bf3 100644 --- a/src/i18n/locales/ko-KR/common.json +++ b/src/i18n/locales/ko-KR/common.json @@ -7,6 +7,8 @@ "share": "공유", "done": "완료", "open": "열기", + "checkForUpdates": "업데이트 확인", + "viewRelease": "릴리스 보기", "upload": "업로드", "export": "내보내기", "showInFolder": "폴더에 표시", @@ -37,6 +39,11 @@ "hideOthers": "다른 항목 숨기기", "unhide": "모두 보기" }, + "updates": { + "available": "OpenScreen {{latestVersion}} 버전을 사용할 수 있습니다. 현재 버전은 {{currentVersion}}입니다.", + "current": "OpenScreen이 최신 버전입니다({{currentVersion}}).", + "failed": "업데이트를 확인할 수 없습니다." + }, "playback": { "play": "재생", "pause": "일시정지", diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index 477b7699e..950683e65 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -7,6 +7,8 @@ "share": "Compartilhar", "done": "Concluir", "open": "Abrir", + "checkForUpdates": "Verificar atualizações", + "viewRelease": "Ver versão", "upload": "Upload", "export": "Exportar", "showInFolder": "Mostrar na Pasta", @@ -37,6 +39,11 @@ "hideOthers": "Ocultar Outros", "unhide": "Mostrar Todos" }, + "updates": { + "available": "O OpenScreen {{latestVersion}} está disponível. Você está usando a versão {{currentVersion}}.", + "current": "O OpenScreen está atualizado ({{currentVersion}}).", + "failed": "Não foi possível verificar atualizações." + }, "playback": { "play": "Play", "pause": "Pause", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 4838c0989..6388998af 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -7,6 +7,8 @@ "share": "Поделиться", "done": "Готово", "open": "Открыть", + "checkForUpdates": "Проверить обновления", + "viewRelease": "Открыть выпуск", "upload": "Загрузить", "export": "Экспорт", "showInFolder": "Показать в папке", @@ -37,6 +39,11 @@ "hideOthers": "Скрыть остальные", "unhide": "Показать все" }, + "updates": { + "available": "Доступен OpenScreen {{latestVersion}}. Установлена версия {{currentVersion}}.", + "current": "Установлена последняя версия OpenScreen ({{currentVersion}}).", + "failed": "Не удалось проверить наличие обновлений." + }, "playback": { "play": "Воспроизвести", "pause": "Пауза", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 0282bf0e5..a489bbdda 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -7,6 +7,8 @@ "share": "Paylaş", "done": "Tamam", "open": "Aç", + "checkForUpdates": "Güncellemeleri denetle", + "viewRelease": "Sürümü görüntüle", "upload": "Yükle", "export": "Dışa Aktar", "showInFolder": "Klasörde Göster", @@ -37,6 +39,11 @@ "hideOthers": "Diğerlerini Gizle", "unhide": "Tümünü Göster" }, + "updates": { + "available": "OpenScreen {{latestVersion}} kullanılabilir. Mevcut sürümünüz {{currentVersion}}.", + "current": "OpenScreen güncel ({{currentVersion}}).", + "failed": "Güncellemeler denetlenemedi." + }, "playback": { "play": "Oynat", "pause": "Duraklat", diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index 2ea8e3258..872527309 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -7,6 +7,8 @@ "share": "Chia sẻ", "done": "Hoàn tất", "open": "Mở", + "checkForUpdates": "Kiểm tra bản cập nhật", + "viewRelease": "Xem bản phát hành", "upload": "Tải lên", "export": "Xuất", "showInFolder": "Hiển thị trong thư mục", @@ -37,6 +39,11 @@ "hideOthers": "Ẩn ứng dụng khác", "unhide": "Hiển thị tất cả" }, + "updates": { + "available": "Đã có OpenScreen {{latestVersion}}. Bạn đang dùng phiên bản {{currentVersion}}.", + "current": "OpenScreen đã được cập nhật ({{currentVersion}}).", + "failed": "Không thể kiểm tra bản cập nhật." + }, "playback": { "play": "Phát", "pause": "Tạm dừng", diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 4bf91ce33..16d7d7df4 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -7,6 +7,8 @@ "share": "分享", "done": "完成", "open": "打开", + "checkForUpdates": "检查更新", + "viewRelease": "查看版本", "upload": "上传", "export": "导出", "showInFolder": "在文件夹中显示", @@ -37,6 +39,11 @@ "hideOthers": "隐藏其他", "unhide": "显示全部" }, + "updates": { + "available": "OpenScreen {{latestVersion}} 已发布。当前版本为 {{currentVersion}}。", + "current": "OpenScreen 已是最新版本({{currentVersion}})。", + "failed": "无法检查更新。" + }, "playback": { "play": "播放", "pause": "暂停", diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index 6d8f827f6..9644d8170 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -7,6 +7,8 @@ "share": "分享", "done": "完成", "open": "開啟", + "checkForUpdates": "檢查更新", + "viewRelease": "檢視版本", "upload": "上傳", "export": "匯出", "showInFolder": "在資料夾中顯示", @@ -37,6 +39,11 @@ "hideOthers": "隱藏其他", "unhide": "全部顯示" }, + "updates": { + "available": "OpenScreen {{latestVersion}} 已推出。目前版本為 {{currentVersion}}。", + "current": "OpenScreen 已是最新版本({{currentVersion}})。", + "failed": "無法檢查更新。" + }, "playback": { "play": "播放", "pause": "暫停",