From f5c92964a4e175918d4acb619c20f90b0029c2a8 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sun, 29 Mar 2026 19:20:53 +1100 Subject: [PATCH 1/4] feat(updater): add update status summary, logging, and new IPC channels - Add UpdateStatusSummary type and getUpdateStatusSummary() for exposing updater state to renderer processes - Add writeUpdaterLog() for persistent updater diagnostics - Add configureUpdateFeed() for feed URL configuration - Wire up get-update-status-summary and check-for-app-updates IPC handlers - Add primaryAction field to UpdateToastState for actionable toast buttons - Add checking-for-update event handler with status tracking - Surface automatic check errors via toast when appropriate --- electron/electron-env.d.ts | 10 ++ electron/preload.ts | 8 ++ electron/updater.ts | 181 ++++++++++++++++++++++++++++++++++++- 3 files changed, 198 insertions(+), 1 deletion(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index f9a9ed19..83fbe502 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -50,6 +50,14 @@ interface UpdateToastState { delayMs: number; isPreview?: boolean; progressPercent?: number; + primaryAction?: "download-update" | "install-update" | "retry-check"; +} + +interface UpdateStatusSummary { + status: "idle" | "checking" | "up-to-date" | "available" | "downloading" | "ready" | "error"; + currentVersion: string; + availableVersion: string | null; + detail?: string; } interface Window { @@ -303,7 +311,9 @@ interface Window { dismissUpdateToast: () => Promise<{ success: boolean }>; skipUpdateVersion: () => Promise<{ success: boolean; message?: string }>; getCurrentUpdateToastPayload: () => Promise; + getUpdateStatusSummary: () => Promise; previewUpdateToast: () => Promise<{ success: boolean }>; + checkForAppUpdates: () => Promise<{ success: boolean; logPath: string }>; onUpdateToastStateChanged: (callback: (payload: UpdateToastState | null) => void) => () => void; onUpdateReadyToast: (callback: (payload: { version: string; diff --git a/electron/preload.ts b/electron/preload.ts index 2f76a239..a052fb95 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -265,9 +265,15 @@ contextBridge.exposeInMainWorld("electronAPI", { getCurrentUpdateToastPayload: () => { return ipcRenderer.invoke("get-current-update-toast-payload"); }, + getUpdateStatusSummary: () => { + return ipcRenderer.invoke("get-update-status-summary"); + }, previewUpdateToast: () => { return ipcRenderer.invoke("preview-update-toast"); }, + checkForAppUpdates: () => { + return ipcRenderer.invoke("check-for-app-updates"); + }, onUpdateToastStateChanged: ( callback: (payload: { version: string; @@ -276,6 +282,7 @@ contextBridge.exposeInMainWorld("electronAPI", { delayMs: number; isPreview?: boolean; progressPercent?: number; + primaryAction?: "download-update" | "install-update" | "retry-check"; } | null) => void, ) => { const listener = ( @@ -288,6 +295,7 @@ contextBridge.exposeInMainWorld("electronAPI", { delayMs: number; isPreview?: boolean; progressPercent?: number; + primaryAction?: "download-update" | "install-update" | "retry-check"; } | null, ) => callback(payload); diff --git a/electron/updater.ts b/electron/updater.ts index 0f939f89..bfb932dd 100644 --- a/electron/updater.ts +++ b/electron/updater.ts @@ -1,17 +1,41 @@ +import fs from "node:fs"; +import path from "node:path"; import { app, BrowserWindow, dialog } from "electron"; import { autoUpdater } from "electron-updater"; import type { MessageBoxOptions, MessageBoxReturnValue } from "electron"; +import { USER_DATA_PATH } from "./appPaths"; const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; export const UPDATE_REMINDER_DELAY_MS = 3 * 60 * 60 * 1000; const DISMISSED_READY_REMINDER_DELAY_MS = 5 * 60 * 1000; const AUTO_UPDATES_DISABLED = process.env.RECORDLY_DISABLE_AUTO_UPDATES === "1"; +const AUTO_UPDATE_ERROR_TOASTS_DISABLED = + process.env.RECORDLY_DISABLE_AUTO_UPDATE_ERROR_TOASTS === "1"; +const UPDATE_FEED_URL_OVERRIDE = process.env.RECORDLY_UPDATE_FEED_URL?.trim() ?? ""; +const UPDATER_LOG_PATH = + process.env.RECORDLY_UPDATER_LOG_PATH?.trim() || path.join(USER_DATA_PATH, "updater.log"); const DEV_UPDATE_PREVIEW_VERSION = "9.9.9"; const DEV_UPDATE_PREVIEW_PROGRESS_STEP_MS = 300; const DEV_UPDATE_PREVIEW_PROGRESS_INCREMENT = 20; export type UpdateToastPhase = "available" | "downloading" | "ready" | "error"; +export type UpdateStatusKind = + | "idle" + | "checking" + | "up-to-date" + | "available" + | "downloading" + | "ready" + | "error"; + +export interface UpdateStatusSummary { + status: UpdateStatusKind; + currentVersion: string; + availableVersion: string | null; + detail?: string; +} + export interface UpdateToastPayload { version: string; detail: string; @@ -19,6 +43,7 @@ export interface UpdateToastPayload { delayMs: number; isPreview?: boolean; progressPercent?: number; + primaryAction?: "download-update" | "install-update" | "retry-check"; } type UpdateToastSender = ( @@ -38,6 +63,72 @@ let pendingDownloadedVersion: string | null = null; let downloadInProgress = false; let downloadToastDismissed = false; let skippedVersion: string | null = null; +let updateCheckErrorHandled = false; +let activeUpdateToastSender: UpdateToastSender | undefined; +let updateStatusSummary: UpdateStatusSummary = { + status: "idle", + currentVersion: app.getVersion(), + availableVersion: null, +}; + +function setUpdateStatusSummary(summary: Partial) { + updateStatusSummary = { + ...updateStatusSummary, + currentVersion: app.getVersion(), + ...summary, + }; +} + +function summarizeError(error: unknown) { + if (error instanceof Error) { + return error.stack || `${error.name}: ${error.message}`; + } + + return String(error); +} + +function writeUpdaterLog(message: string, detail?: unknown) { + try { + fs.mkdirSync(path.dirname(UPDATER_LOG_PATH), { recursive: true }); + const suffix = detail === undefined ? "" : ` ${summarizeError(detail)}`; + fs.appendFileSync( + UPDATER_LOG_PATH, + `${new Date().toISOString()} ${message}${suffix}\n`, + "utf8", + ); + } catch (logError) { + console.error("Failed to write updater log:", logError); + } +} + +function createAutoCheckErrorToastPayload(): UpdateToastPayload { + return { + version: app.getVersion(), + phase: "error", + detail: + "Recordly could not check for updates automatically. Retry now, or inspect updater.log in your user data folder.", + delayMs: UPDATE_REMINDER_DELAY_MS, + primaryAction: "retry-check", + }; +} + +function shouldSurfaceAutomaticCheckErrors() { + return !AUTO_UPDATE_ERROR_TOASTS_DISABLED; +} + +function configureUpdateFeed() { + if (!UPDATE_FEED_URL_OVERRIDE) { + writeUpdaterLog("Using published GitHub update feed."); + return; + } + + autoUpdater.setFeedURL({ + provider: "generic", + url: UPDATE_FEED_URL_OVERRIDE, + channel: "latest", + }); + writeUpdaterLog(`Using overridden update feed: ${UPDATE_FEED_URL_OVERRIDE}`); +} function canUseAutoUpdates() { return !AUTO_UPDATES_DISABLED && app.isPackaged && !process.mas; @@ -92,6 +183,7 @@ function createAvailableUpdateToastPayload(version: string): UpdateToastPayload phase: "available", detail: "A new version is available. Download it now, or wait and we will remind you again in 3 hours.", delayMs: UPDATE_REMINDER_DELAY_MS, + primaryAction: "download-update", }; } @@ -118,6 +210,7 @@ function createDownloadedUpdateToastPayload(version: string): UpdateToastPayload phase: "ready", detail: "Install now to restart into the new version, or wait and we will remind you again in 3 hours.", delayMs: UPDATE_REMINDER_DELAY_MS, + primaryAction: "install-update", }; } @@ -127,6 +220,7 @@ function createUpdateErrorToastPayload(version: string, error: unknown): UpdateT phase: "error", detail: `The update download failed. ${String(error)}`, delayMs: UPDATE_REMINDER_DELAY_MS, + primaryAction: "download-update", }; } @@ -150,6 +244,14 @@ export function getCurrentUpdateToastPayload() { return currentToastPayload; } +export function getUpdaterLogPath() { + return UPDATER_LOG_PATH; +} + +export function getUpdateStatusSummary() { + return updateStatusSummary; +} + async function showNoUpdatesDialog(getMainWindow: () => BrowserWindow | null) { await showMessageBox(getMainWindow, { type: "info", @@ -267,6 +369,8 @@ export function installDownloadedUpdateNow(sendToRenderer?: UpdateToastSender) { clearDeferredReminderTimer(); downloadToastDismissed = false; clearVisibleUpdateToast(sendToRenderer); + setUpdateStatusSummary({ status: "ready", availableVersion: pendingDownloadedVersion }); + writeUpdaterLog("Installing downloaded update."); autoUpdater.quitAndInstall(); } @@ -290,13 +394,26 @@ export async function downloadAvailableUpdate(sendToRenderer?: UpdateToastSender clearDeferredReminderTimer(); downloadInProgress = true; downloadToastDismissed = false; + setUpdateStatusSummary({ + status: "downloading", + availableVersion, + detail: `Downloading Recordly ${availableVersion}`, + }); emitUpdateToastState(sendToRenderer, createDownloadingUpdateToastPayload(availableVersion, 0)); + writeUpdaterLog(`Starting update download for ${availableVersion}.`); try { await autoUpdater.downloadUpdate(); + writeUpdaterLog(`Update download requested for ${availableVersion}.`); return { success: true }; } catch (error) { downloadInProgress = false; + setUpdateStatusSummary({ + status: "error", + availableVersion, + detail: String(error), + }); + writeUpdaterLog(`Update download failed for ${availableVersion}.`, error); emitUpdateToastState( sendToRenderer, createUpdateErrorToastPayload(availableVersion, error), @@ -464,6 +581,9 @@ export async function checkForAppUpdates( options?: { manual?: boolean }, ) { if (!canUseAutoUpdates()) { + writeUpdaterLog( + `Skipped update check because auto-updates are unavailable. packaged=${app.isPackaged} mas=${process.mas ? "yes" : "no"} disabled=${AUTO_UPDATES_DISABLED ? "yes" : "no"}`, + ); if (options?.manual) { await showMessageBox(getMainWindow, { type: "info", @@ -478,6 +598,7 @@ export async function checkForAppUpdates( } if (updateCheckInProgress) { + writeUpdaterLog("Skipped update check because a previous check is still running."); if (options?.manual) { await showMessageBox(getMainWindow, { type: "info", @@ -490,16 +611,28 @@ export async function checkForAppUpdates( manualCheckRequested = Boolean(options?.manual); updateCheckInProgress = true; + updateCheckErrorHandled = false; + setUpdateStatusSummary({ status: "checking", detail: "Checking for updates..." }); + writeUpdaterLog(`Starting ${manualCheckRequested ? "manual" : "automatic"} update check.`); try { await autoUpdater.checkForUpdates(); + writeUpdaterLog("Update check request completed."); } catch (error) { updateCheckInProgress = false; const shouldReport = manualCheckRequested; manualCheckRequested = false; + setUpdateStatusSummary({ + status: "error", + availableVersion, + detail: String(error), + }); + writeUpdaterLog("Update check failed.", error); console.error("Auto-update check failed:", error); - if (shouldReport) { + if (shouldReport && !updateCheckErrorHandled) { await showUpdateErrorDialog(getMainWindow, error); + } else if (!updateCheckErrorHandled && shouldSurfaceAutomaticCheckErrors()) { + emitUpdateToastState(activeUpdateToastSender, createAutoCheckErrorToastPayload()); } } } @@ -513,19 +646,34 @@ export function setupAutoUpdates( } if (!canUseAutoUpdates()) { + setUpdateStatusSummary({ status: "idle", availableVersion: null, detail: undefined }); return; } updaterInitialized = true; + activeUpdateToastSender = sendToRenderer; + configureUpdateFeed(); autoUpdater.autoDownload = false; autoUpdater.autoInstallOnAppQuit = false; + writeUpdaterLog(`Updater initialized. logPath=${UPDATER_LOG_PATH}`); + + autoUpdater.on("checking-for-update", () => { + setUpdateStatusSummary({ status: "checking", availableVersion: null, detail: "Checking for updates..." }); + writeUpdaterLog("electron-updater emitted checking-for-update."); + }); autoUpdater.on("update-available", (info) => { + writeUpdaterLog(`Update available: version=${info.version}`); updateCheckInProgress = false; availableVersion = info.version; pendingDownloadedVersion = null; downloadInProgress = false; downloadToastDismissed = false; + setUpdateStatusSummary({ + status: "available", + availableVersion: info.version, + detail: `Recordly ${info.version} is available.`, + }); if (skippedVersion === info.version) { manualCheckRequested = false; return; @@ -544,11 +692,17 @@ export function setupAutoUpdates( }); autoUpdater.on("update-not-available", () => { + writeUpdaterLog("No update available."); updateCheckInProgress = false; availableVersion = null; pendingDownloadedVersion = null; downloadInProgress = false; downloadToastDismissed = false; + setUpdateStatusSummary({ + status: "up-to-date", + availableVersion: null, + detail: `Recordly ${app.getVersion()} is up to date.`, + }); clearVisibleUpdateToast(sendToRenderer); const shouldReport = manualCheckRequested; manualCheckRequested = false; @@ -563,6 +717,14 @@ export function setupAutoUpdates( } downloadInProgress = true; + setUpdateStatusSummary({ + status: "downloading", + availableVersion, + detail: `Downloading Recordly ${availableVersion}`, + }); + writeUpdaterLog( + `Download progress for ${availableVersion}: ${progress.percent.toFixed(1)}%`, + ); if (downloadToastDismissed) { return; } @@ -577,6 +739,15 @@ export function setupAutoUpdates( updateCheckInProgress = false; const shouldReport = manualCheckRequested; manualCheckRequested = false; + if (!downloadInProgress) { + updateCheckErrorHandled = true; + } + setUpdateStatusSummary({ + status: "error", + availableVersion, + detail: String(error), + }); + writeUpdaterLog("electron-updater emitted error.", error); console.error("Auto-updater error:", error); if (downloadInProgress && availableVersion) { downloadInProgress = false; @@ -588,10 +759,13 @@ export function setupAutoUpdates( } if (shouldReport) { void showUpdateErrorDialog(getMainWindow, error); + } else if (shouldSurfaceAutomaticCheckErrors()) { + emitUpdateToastState(sendToRenderer, createAutoCheckErrorToastPayload()); } }); autoUpdater.on("update-downloaded", (info) => { + writeUpdaterLog(`Update downloaded: version=${info.version}`); updateCheckInProgress = false; manualCheckRequested = false; downloadInProgress = false; @@ -601,6 +775,11 @@ export function setupAutoUpdates( } availableVersion = info.version; pendingDownloadedVersion = info.version; + setUpdateStatusSummary({ + status: "ready", + availableVersion: info.version, + detail: `Recordly ${info.version} is ready to install.`, + }); clearDeferredReminderTimer(); if (emitUpdateToastState(sendToRenderer, createDownloadedUpdateToastPayload(info.version))) { From 53c7aa7e770dd8f4e5f24d5a3137130d2a791dd0 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sun, 29 Mar 2026 19:21:17 +1100 Subject: [PATCH 2/4] feat(updater): Windows native notifications and toast window fixes - Use native OS Notification on Windows instead of transparent overlay toast window (transparent BrowserWindows are unreliable on Windows) - Set app user model ID on Windows for proper notification grouping - Make toast window opaque with solid background on Windows - Only set transparent body background on macOS for update-toast window - Refactor UpdateToastWindow to inline styles for Windows compatibility - Add primaryAction-based button rendering in toast UI - Add polling fallback for toast payload in UpdateToastWindow - Add global CSS variables for brand accent color --- electron/main.ts | 111 +++++++++++ electron/windows.ts | 18 +- src/App.tsx | 3 +- src/components/launch/UpdateToastWindow.tsx | 209 +++++++++++++++----- src/index.css | 8 + 5 files changed, 296 insertions(+), 53 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 0909a18f..bf93b96e 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -9,6 +9,7 @@ import { ipcMain, Menu, nativeImage, + Notification, session, systemPreferences, Tray, @@ -26,11 +27,14 @@ import { downloadAvailableUpdate, deferUpdateReminder, getCurrentUpdateToastPayload, + getUpdaterLogPath, + getUpdateStatusSummary, installDownloadedUpdateNow, previewUpdateToast, skipAvailableUpdateVersion, setupAutoUpdates, } from "./updater"; +import type { UpdateToastPayload } from "./updater"; import { createEditorWindow, createHudOverlayWindow, @@ -84,6 +88,8 @@ let tray: Tray | null = null; let selectedSourceName = ""; let editorHasUnsavedChanges = false; let isForceClosing = false; +let activeUpdateNotification: Notification | null = null; +let activeUpdateNotificationKey: string | null = null; const hasSingleInstanceLock = app.requestSingleInstanceLock(); if (!hasSingleInstanceLock) { @@ -317,7 +323,99 @@ function syncDockIcon() { } } +function getUpdateNotificationTitle(payload: UpdateToastPayload) { + switch (payload.phase) { + case "available": + return `Recordly ${payload.version} is available`; + case "downloading": + return `Downloading Recordly ${payload.version}`; + case "ready": + return `Recordly ${payload.version} is ready`; + case "error": + return `Recordly ${payload.version} needs attention`; + } +} + +function getUpdateNotificationBody(payload: UpdateToastPayload) { + switch (payload.phase) { + case "available": + return "Click to download the update."; + case "downloading": + return "Recordly is downloading the update in the foreground."; + case "ready": + return "Click to install the downloaded update."; + case "error": + return "Click to retry checking for updates."; + } +} + +function clearActiveUpdateNotification() { + if (activeUpdateNotification) { + activeUpdateNotification.close(); + activeUpdateNotification = null; + } + activeUpdateNotificationKey = null; +} + function sendUpdateToastToWindows(channel: "update-toast-state", payload: unknown) { + if (process.platform !== "darwin") { + if (!payload) { + clearActiveUpdateNotification(); + return true; + } + + const updatePayload = payload as UpdateToastPayload; + if (updatePayload.phase === "downloading") { + return true; + } + + if (!Notification.isSupported()) { + return false; + } + + const notificationKey = [updatePayload.phase, updatePayload.version, updatePayload.detail].join(":"); + if (activeUpdateNotificationKey === notificationKey) { + return true; + } + + clearActiveUpdateNotification(); + const notification = new Notification({ + title: getUpdateNotificationTitle(updatePayload), + body: getUpdateNotificationBody(updatePayload), + icon: getAppImage("app-icons/recordly-128.png"), + silent: false, + }); + + notification.on("click", () => { + focusOrCreateMainWindow(); + switch (updatePayload.phase) { + case "available": + void downloadAvailableUpdate(sendUpdateToastToWindows); + break; + case "ready": + installDownloadedUpdateNow(sendUpdateToastToWindows); + break; + case "error": + void checkForAppUpdates(getUpdateDialogWindow, { manual: true }); + break; + default: + break; + } + }); + + notification.on("close", () => { + if (activeUpdateNotification === notification) { + activeUpdateNotification = null; + activeUpdateNotificationKey = null; + } + }); + + notification.show(); + activeUpdateNotification = notification; + activeUpdateNotificationKey = notificationKey; + return true; + } + if (!payload) { const existingWindow = getUpdateToastWindow(); if (!existingWindow) { @@ -382,10 +480,19 @@ ipcMain.handle("get-current-update-toast-payload", () => { return getCurrentUpdateToastPayload(); }); +ipcMain.handle("get-update-status-summary", () => { + return getUpdateStatusSummary(); +}); + ipcMain.handle("preview-update-toast", () => { return { success: previewUpdateToast(sendUpdateToastToWindows) }; }); +ipcMain.handle("check-for-app-updates", async () => { + await checkForAppUpdates(getUpdateDialogWindow, { manual: true }); + return { success: true, logPath: getUpdaterLogPath() }; +}); + function updateTrayMenu(recording: boolean = false) { if (!tray) return; const trayIcon = recording ? getRecordingTrayIcon() : getDefaultTrayIcon(); @@ -506,6 +613,10 @@ app.on("second-instance", () => { // Register all IPC handlers when app is ready app.whenReady().then(async () => { + if (process.platform === "win32") { + app.setAppUserModelId("dev.recordly.app"); + } + session.defaultSession.setPermissionCheckHandler((_webContents, permission) => { const allowed = ["media", "audioCapture", "microphone", "camera", "videoCapture"]; return allowed.includes(permission); diff --git a/electron/windows.ts b/electron/windows.ts index b0717266..80c0d10c 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -326,6 +326,11 @@ export function getHudOverlayWindow(): BrowserWindow | null { export function createUpdateToastWindow(): BrowserWindow { const initialBounds = getUpdateToastBounds(); + const parentWindow = + process.platform === "darwin" && hudOverlayWindow && !hudOverlayWindow.isDestroyed() + ? hudOverlayWindow + : undefined; + const useTransparentToastWindow = process.platform !== "win32"; const win = new BrowserWindow({ width: initialBounds.width, @@ -333,15 +338,15 @@ export function createUpdateToastWindow(): BrowserWindow { x: initialBounds.x, y: initialBounds.y, frame: false, - transparent: true, + transparent: useTransparentToastWindow, resizable: false, alwaysOnTop: true, skipTaskbar: true, hasShadow: false, show: false, focusable: true, - ...(hudOverlayWindow && !hudOverlayWindow.isDestroyed() ? { parent: hudOverlayWindow } : {}), - backgroundColor: "#00000000", + ...(parentWindow ? { parent: parentWindow } : {}), + backgroundColor: useTransparentToastWindow ? "#00000000" : "#101418", webPreferences: { preload: path.join(__dirname, "preload.mjs"), nodeIntegration: false, @@ -382,7 +387,12 @@ export function showUpdateToastWindow(): BrowserWindow { const win = getUpdateToastWindow() ?? createUpdateToastWindow(); positionUpdateToastWindow(); if (!win.isVisible()) { - win.showInactive(); + if (process.platform === "win32") { + win.show(); + win.moveTop(); + } else { + win.showInactive(); + } } else { win.moveTop(); } diff --git a/src/App.tsx b/src/App.tsx index 94a68ef4..4622bdda 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,13 +17,14 @@ export default function App() { useEffect(() => { const params = new URLSearchParams(window.location.search); const type = params.get("windowType") || ""; + const isMacOS = /mac/i.test(navigator.platform); setWindowType(type); if ( type === "hud-overlay" || type === "source-selector" || type === "countdown" || - type === "update-toast" + (type === "update-toast" && isMacOS) ) { document.body.style.background = "transparent"; document.documentElement.style.background = "transparent"; diff --git a/src/components/launch/UpdateToastWindow.tsx b/src/components/launch/UpdateToastWindow.tsx index 3ea71b91..100a2339 100644 --- a/src/components/launch/UpdateToastWindow.tsx +++ b/src/components/launch/UpdateToastWindow.tsx @@ -8,6 +8,7 @@ type UpdateToastPayload = { delayMs: number; isPreview?: boolean; progressPercent?: number; + primaryAction?: "download-update" | "install-update" | "retry-check"; }; const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000; @@ -34,16 +35,16 @@ function getToastTitle(payload: UpdateToastPayload) { } } -function getIcon(payload: UpdateToastPayload) { - switch (payload.phase) { - case "available": - return ; - case "downloading": - return ; - case "ready": - return ; - case "error": - return ; +function getPrimaryActionLabel(payload: UpdateToastPayload) { + switch (payload.primaryAction) { + case "download-update": + return "Download Update"; + case "install-update": + return "Install Update"; + case "retry-check": + return "Retry Check"; + default: + return null; } } @@ -62,6 +63,7 @@ export function UpdateToastWindow() { useEffect(() => { let mounted = true; + let pollTimer: ReturnType | null = null; void window.electronAPI.getCurrentUpdateToastPayload().then((nextPayload) => { if (mounted) { @@ -69,12 +71,25 @@ export function UpdateToastWindow() { } }); + pollTimer = setInterval(() => { + void window.electronAPI.getCurrentUpdateToastPayload().then((nextPayload) => { + if (!mounted || !nextPayload) { + return; + } + + setPayload((currentPayload) => currentPayload ?? nextPayload); + }); + }, 750); + const dispose = window.electronAPI.onUpdateToastStateChanged((nextPayload) => { setPayload(nextPayload); }); return () => { mounted = false; + if (pollTimer) { + clearInterval(pollTimer); + } dispose(); }; }, []); @@ -88,11 +103,104 @@ export function UpdateToastWindow() { }; }, [payload?.phase, payload?.version, payload?.progressPercent]); + const cardStyle = { + background: "#0d1117", + border: "1px solid rgba(125, 211, 252, 0.22)", + boxShadow: "0 24px 48px rgba(0, 0, 0, 0.45)", + borderRadius: 24, + padding: 16, + color: "#ffffff", + width: "100%", + maxWidth: 404, + display: "flex", + gap: 12, + alignItems: "flex-start", + fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif', + } as const; + const wrapperStyle = { + display: "flex", + alignItems: "center", + justifyContent: "center", + width: "100%", + height: "100%", + padding: 8, + boxSizing: "border-box", + background: "transparent", + } as const; + const secondaryTextStyle = { + color: "rgba(255, 255, 255, 0.74)", + fontSize: 14, + lineHeight: 1.45, + margin: "4px 0 0 0", + } as const; + const titleStyle = { + fontSize: 14, + fontWeight: 700, + lineHeight: 1.2, + margin: 0, + color: "#ffffff", + } as const; + const iconBoxStyle = { + width: 40, + height: 40, + minWidth: 40, + borderRadius: 16, + background: "rgba(125, 211, 252, 0.15)", + color: "#7dd3fc", + display: "flex", + alignItems: "center", + justifyContent: "center", + marginTop: 2, + } as const; + const rowStyle = { + display: "flex", + flexWrap: "wrap" as const, + gap: 8, + marginTop: 12, + } as const; + const subtleButtonStyle = { + border: "1px solid rgba(255, 255, 255, 0.1)", + background: "rgba(255, 255, 255, 0.05)", + color: "rgba(255, 255, 255, 0.92)", + borderRadius: 12, + padding: "8px 12px", + fontSize: 12, + fontWeight: 600, + cursor: "pointer", + } as const; + const primaryButtonStyle = { + ...subtleButtonStyle, + background: "#7dd3fc", + color: "#031a2c", + border: "none", + } as const; + const ghostButtonStyle = { + ...subtleButtonStyle, + background: "transparent", + color: "rgba(255, 255, 255, 0.72)", + border: "1px solid rgba(125, 211, 252, 0.16)", + } as const; + if (!payload) { - return
; + return ( +
+
+
+ +
+
+

Checking for updates

+

+ Waiting for updater state from the main process. +

+
+
+
+ ); } const normalizedProgress = Math.max(0, Math.min(100, Math.round(payload.progressPercent ?? 0))); + const primaryActionLabel = getPrimaryActionLabel(payload); const swipeThreshold = 96; const handleSwipeDismiss = async () => { setDragOffsetX(0); @@ -104,11 +212,28 @@ export function UpdateToastWindow() { await window.electronAPI.dismissUpdateToast(); }; + const handlePrimaryAction = async () => { + switch (payload.primaryAction) { + case "download-update": + await window.electronAPI.downloadAvailableUpdate(); + return; + case "install-update": + await window.electronAPI.installDownloadedUpdate(); + return; + case "retry-check": + await window.electronAPI.checkForAppUpdates(); + return; + default: + return; + } + }; + return ( -
+
-
- {getIcon(payload)} +
+ {payload.phase === "available" ? : null} + {payload.phase === "downloading" ? : null} + {payload.phase === "ready" ? : null} + {payload.phase === "error" ? : null}
-
-
-

{getToastTitle(payload)}

+
+
+

{getToastTitle(payload)}

{payload.isPreview ? ( - + Dev ) : null}
-

{payload.detail}

+

{payload.detail}

{payload.phase === "downloading" ? ( -
-
+
+
-

{normalizedProgress}% downloaded

+

{normalizedProgress}% downloaded

) : null} -
- {payload.phase === "available" || payload.phase === "error" ? ( +
+ {primaryActionLabel ? ( - ) : null} - - {payload.phase === "ready" ? ( - ) : null} @@ -217,7 +330,7 @@ export function UpdateToastWindow() { onClick={async () => { await window.electronAPI.dismissUpdateToast(); }} - className="rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-xs font-medium text-white/85 transition-colors hover:bg-white/10" + style={subtleButtonStyle} > Hide @@ -234,7 +347,7 @@ export function UpdateToastWindow() { await window.electronAPI.deferDownloadedUpdate(payload.delayMs); }} - className="rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-xs font-medium text-white/85 transition-colors hover:bg-white/10" + style={subtleButtonStyle} > Later ({formatDelayHours(payload.delayMs)}) @@ -251,7 +364,7 @@ export function UpdateToastWindow() { await window.electronAPI.deferDownloadedUpdate(THREE_DAYS_MS); }} - className="rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-xs font-medium text-white/85 transition-colors hover:bg-white/10" + style={subtleButtonStyle} > Later (3 days) @@ -263,7 +376,7 @@ export function UpdateToastWindow() { onClick={async () => { await window.electronAPI.skipUpdateVersion(); }} - className="rounded-xl border border-sky-300/15 bg-transparent px-3 py-2 text-xs font-medium text-white/65 transition-colors hover:bg-white/5 hover:text-white" + style={ghostButtonStyle} > Skip This Version diff --git a/src/index.css b/src/index.css index 4a863e92..680e5297 100644 --- a/src/index.css +++ b/src/index.css @@ -3,6 +3,14 @@ @tailwind utilities; @layer base { + html, + body, + #root { + width: 100%; + height: 100%; + margin: 0; + } + :root { --brand-accent: #2563eb; --brand-accent-rgb: 37, 99, 235; From 199f35d9b4bab99f2ad18c30c26ba654810b0669 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sun, 29 Mar 2026 19:21:32 +1100 Subject: [PATCH 3/4] feat(launch): add update status badge to launch window toolbar - Add update badge button that shows current updater state (idle, checking, up-to-date, available, downloading, ready, error) - Badge triggers check/download/install based on current state - Poll update status summary every 2.5s for live state reflection - Add CSS module styles for badge variants (quiet, hot, spin) - Add i18n strings for badge labels and tooltips (en, es, zh-CN) --- src/components/launch/LaunchWindow.module.css | 60 +++++++++ src/components/launch/LaunchWindow.tsx | 120 ++++++++++++++++++ src/i18n/locales/en/launch.json | 13 +- src/i18n/locales/es/launch.json | 13 +- src/i18n/locales/zh-CN/launch.json | 13 +- 5 files changed, 216 insertions(+), 3 deletions(-) diff --git a/src/components/launch/LaunchWindow.module.css b/src/components/launch/LaunchWindow.module.css index bfa9de88..9bd61c6f 100644 --- a/src/components/launch/LaunchWindow.module.css +++ b/src/components/launch/LaunchWindow.module.css @@ -35,6 +35,66 @@ padding-right: 2px; } +.updateBadge { + display: inline-flex; + align-items: center; + gap: 7px; + height: 34px; + padding: 0 12px; + border-radius: 11px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.03); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.01em; + transition: all 0.15s ease; + cursor: pointer; + flex-shrink: 0; +} + +.updateBadge:disabled { + opacity: 0.72; + cursor: default; +} + +.updateBadgeQuiet { + color: #a5b4c7; + border-color: rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.035); +} + +.updateBadgeQuiet:hover:not(:disabled) { + color: #d7dee8; + background: rgba(255, 255, 255, 0.06); +} + +.updateBadgeHot { + color: #f8fbff; + border-color: rgba(125, 211, 252, 0.24); + background: linear-gradient(180deg, rgba(125, 211, 252, 0.12), rgba(125, 211, 252, 0.04)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); +} + +.updateBadgeHot:hover:not(:disabled) { + color: #ffffff; + border-color: rgba(125, 211, 252, 0.36); + background: linear-gradient(180deg, rgba(125, 211, 252, 0.17), rgba(125, 211, 252, 0.07)); + transform: translateY(-1px); +} + +.updateBadgeSpin { + animation: updateBadgeSpin 0.9s linear infinite; +} + +@keyframes updateBadgeSpin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + .barState { display: flex; align-items: center; diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index f9ba3fe5..8f227105 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,6 +1,8 @@ import { AppWindow, + ArrowUpCircle, ChevronUp, + CheckCircle2, Eye, EyeOff, FolderOpen, @@ -12,6 +14,7 @@ import { MoreVertical, Pause, Play, + RefreshCw, Square, Timer, Video, @@ -185,6 +188,17 @@ export function LaunchWindow() { const [hideHudFromCapture, setHideHudFromCapture] = useState(true); const [platform, setPlatform] = useState(null); const [appVersion, setAppVersion] = useState(null); + const [updateStatus, setUpdateStatus] = useState<{ + status: "idle" | "checking" | "up-to-date" | "available" | "downloading" | "ready" | "error"; + currentVersion: string; + availableVersion: string | null; + detail?: string; + }>({ + status: "idle", + currentVersion: "", + availableVersion: null, + }); + const [updateActionPending, setUpdateActionPending] = useState(false); const dropdownRef = useRef(null); const hudContentRef = useRef(null); const hudBarRef = useRef(null); @@ -372,6 +386,31 @@ export function LaunchWindow() { }; }, []); + useEffect(() => { + let mounted = true; + + const refreshUpdateStatus = async () => { + try { + const summary = await window.electronAPI.getUpdateStatusSummary(); + if (mounted) { + setUpdateStatus(summary); + } + } catch (error) { + console.error("Failed to load update status summary:", error); + } + }; + + void refreshUpdateStatus(); + const pollTimer = window.setInterval(() => { + void refreshUpdateStatus(); + }, 2500); + + return () => { + mounted = false; + window.clearInterval(pollTimer); + }; + }, []); + useEffect(() => { let cancelled = false; const loadVersion = async () => { @@ -619,6 +658,74 @@ export function LaunchWindow() { toggleDropdown("webcam"); }; + const updateButtonLabel = + updateStatus.status === "up-to-date" + ? t("recording.update.updated") + : t("recording.update.update"); + const updateButtonTitle = (() => { + switch (updateStatus.status) { + case "up-to-date": + return t("recording.update.upToDateTitle", "Recordly {{version}} is up to date.", { + version: updateStatus.currentVersion, + }); + case "available": + case "ready": + return updateStatus.availableVersion + ? t("recording.update.availableTitle", "Recordly {{version}} is available.", { + version: updateStatus.availableVersion, + }) + : t("recording.update.availableGenericTitle"); + case "downloading": + return updateStatus.detail ?? t("recording.update.downloadingTitle"); + case "checking": + return t("recording.update.checkingTitle"); + case "error": + return updateStatus.detail ?? t("recording.update.errorTitle"); + default: + return t("recording.update.idleTitle"); + } + })(); + const updateButtonClassName = `${styles.updateBadge} ${updateStatus.status === "up-to-date" ? styles.updateBadgeQuiet : styles.updateBadgeHot} ${styles.electronNoDrag}`; + const updateButtonIcon = (() => { + switch (updateStatus.status) { + case "up-to-date": + return ; + case "checking": + case "downloading": + return ; + default: + return ; + } + })(); + + const handleUpdateButtonClick = async () => { + if (updateActionPending || updateStatus.status === "downloading") { + return; + } + + setUpdateActionPending(true); + try { + switch (updateStatus.status) { + case "available": + await window.electronAPI.downloadAvailableUpdate(); + break; + case "ready": + await window.electronAPI.installDownloadedUpdate(); + break; + default: + await window.electronAPI.checkForAppUpdates(); + break; + } + + const summary = await window.electronAPI.getUpdateStatusSummary(); + setUpdateStatus(summary); + } catch (error) { + console.error("Failed to handle update button action:", error); + } finally { + setUpdateActionPending(false); + } + }; + const recordingControls = ( <>
@@ -1054,6 +1161,19 @@ export function LaunchWindow() {
+ +
Date: Sun, 29 Mar 2026 19:21:46 +1100 Subject: [PATCH 4/4] chore(native): rebuild win32-x64 native helper binaries - Rebuild wgc-capture.exe and cursor-monitor.exe from unchanged source - Update helpers-manifest.json with new binary hashes and timestamps --- .../native/bin/win32-x64/cursor-monitor.exe | Bin 26112 -> 26112 bytes .../bin/win32-x64/helpers-manifest.json | 8 ++++---- electron/native/bin/win32-x64/wgc-capture.exe | Bin 92160 -> 92160 bytes 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/electron/native/bin/win32-x64/cursor-monitor.exe b/electron/native/bin/win32-x64/cursor-monitor.exe index 68f2e54f49fc6cb11315445d03cde1cf8afeac9a..cd3e76114aa1f14b829e2ff38c7d4b33ee233e61 100644 GIT binary patch delta 27 fcmZoT!`N_!aRUbiC83UB}b delta 32 lcmZp;z}j$ub%OvS^SV*$Rz3myOf