From 641d3d271f994d846a8ad5958d6558bc0ae6a72e Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sat, 28 Mar 2026 21:06:41 +1100 Subject: [PATCH] feat(updater): add explicit download toast flow --- electron/electron-env.d.ts | 14 + electron/main.ts | 72 +++-- electron/preload.ts | 38 +++ electron/updater.ts | 281 +++++++++++++++++--- electron/windows.ts | 117 ++++++++ src/App.tsx | 94 +------ src/components/launch/UpdateToastWindow.tsx | 180 +++++++++++++ 7 files changed, 656 insertions(+), 140 deletions(-) create mode 100644 src/components/launch/UpdateToastWindow.tsx diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 4cab9719..3d976ff4 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -43,6 +43,15 @@ interface NativeCaptureDiagnostics { error?: string; } +interface UpdateToastState { + version: string; + detail: string; + phase: "available" | "downloading" | "ready" | "error"; + delayMs: number; + isPreview?: boolean; + progressPercent?: number; +} + interface Window { electronAPI: { hudOverlayHide: () => void; @@ -280,11 +289,16 @@ interface Window { error?: string; }>; installDownloadedUpdate: () => Promise<{ success: boolean }>; + downloadAvailableUpdate: () => Promise<{ success: boolean; message?: string }>; deferDownloadedUpdate: (delayMs?: number) => Promise<{ success: boolean; message?: string; }>; + dismissUpdateToast: () => Promise<{ success: boolean }>; + skipUpdateVersion: () => Promise<{ success: boolean; message?: string }>; + getCurrentUpdateToastPayload: () => Promise; previewUpdateToast: () => Promise<{ success: boolean }>; + onUpdateToastStateChanged: (callback: (payload: UpdateToastState | null) => void) => () => void; onUpdateReadyToast: (callback: (payload: { version: string; detail: string; diff --git a/electron/main.ts b/electron/main.ts index 905e8d63..90fed640 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -21,16 +21,23 @@ import { } from "./ipc/handlers"; import { checkForAppUpdates, - deferDownloadedUpdateReminder, + dismissUpdateToast, + downloadAvailableUpdate, + deferUpdateReminder, + getCurrentUpdateToastPayload, installDownloadedUpdateNow, previewUpdateToast, + skipAvailableUpdateVersion, setupAutoUpdates, } from "./updater"; import { createEditorWindow, createHudOverlayWindow, createSourceSelectorWindow, + getUpdateToastWindow, getHudOverlayWindow, + hideUpdateToastWindow, + showUpdateToastWindow, } from "./windows"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -296,29 +303,28 @@ function syncDockIcon() { } } -function getUpdateWindowTargets() { - const targets: BrowserWindow[] = []; - const hudOverlayWindow = getHudOverlayWindow(); +function sendUpdateToastToWindows(channel: "update-toast-state", payload: unknown) { + if (!payload) { + const existingWindow = getUpdateToastWindow(); + if (!existingWindow) { + return false; + } - if (hudOverlayWindow) { - targets.push(hudOverlayWindow); + existingWindow.webContents.send(channel, null); + hideUpdateToastWindow(); + return true; } - if (mainWindow && !mainWindow.isDestroyed() && mainWindow !== hudOverlayWindow) { - targets.push(mainWindow); - } + const toastWindow = showUpdateToastWindow(); + const sendPayload = () => { + toastWindow.webContents.send(channel, payload); + showUpdateToastWindow(); + }; - return targets; -} - -function sendUpdateToastToWindows(channel: "update-ready-toast", payload: unknown) { - const targets = getUpdateWindowTargets(); - if (targets.length === 0) { - return false; - } - - for (const target of targets) { - target.webContents.send(channel, payload); + if (toastWindow.webContents.isLoadingMainFrame()) { + toastWindow.webContents.once("did-finish-load", sendPayload); + } else { + sendPayload(); } return true; @@ -338,12 +344,28 @@ function getUpdateDialogWindow() { } ipcMain.handle("install-downloaded-update", () => { - installDownloadedUpdateNow(); + installDownloadedUpdateNow(sendUpdateToastToWindows); return { success: true }; }); +ipcMain.handle("download-available-update", () => { + return downloadAvailableUpdate(sendUpdateToastToWindows); +}); + ipcMain.handle("defer-downloaded-update", (_event, delayMs?: number) => { - return deferDownloadedUpdateReminder(getUpdateDialogWindow, sendUpdateToastToWindows, delayMs); + return deferUpdateReminder(getUpdateDialogWindow, sendUpdateToastToWindows, delayMs); +}); + +ipcMain.handle("dismiss-update-toast", () => { + return dismissUpdateToast(sendUpdateToastToWindows); +}); + +ipcMain.handle("skip-update-version", () => { + return skipAvailableUpdateVersion(sendUpdateToastToWindows); +}); + +ipcMain.handle("get-current-update-toast-payload", () => { + return getCurrentUpdateToastPayload(); }); ipcMain.handle("preview-update-toast", () => { @@ -512,6 +534,7 @@ app.whenReady().then(async () => { }, ); + createWindow(); setupAutoUpdates(getUpdateDialogWindow, sendUpdateToastToWindows); // Register the display media handler so that renderer's getDisplayMedia() @@ -540,5 +563,8 @@ app.whenReady().then(async () => { } }); - createWindow(); + const currentToastPayload = getCurrentUpdateToastPayload(); + if (currentToastPayload) { + sendUpdateToastToWindows("update-toast-state", currentToastPayload); + } }); diff --git a/electron/preload.ts b/electron/preload.ts index edb85fa8..0a07bd97 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -247,12 +247,50 @@ contextBridge.exposeInMainWorld("electronAPI", { installDownloadedUpdate: () => { return ipcRenderer.invoke("install-downloaded-update"); }, + downloadAvailableUpdate: () => { + return ipcRenderer.invoke("download-available-update"); + }, deferDownloadedUpdate: (delayMs?: number) => { return ipcRenderer.invoke("defer-downloaded-update", delayMs); }, + dismissUpdateToast: () => { + return ipcRenderer.invoke("dismiss-update-toast"); + }, + skipUpdateVersion: () => { + return ipcRenderer.invoke("skip-update-version"); + }, + getCurrentUpdateToastPayload: () => { + return ipcRenderer.invoke("get-current-update-toast-payload"); + }, previewUpdateToast: () => { return ipcRenderer.invoke("preview-update-toast"); }, + onUpdateToastStateChanged: ( + callback: (payload: { + version: string; + detail: string; + phase: "available" | "downloading" | "ready" | "error"; + delayMs: number; + isPreview?: boolean; + progressPercent?: number; + } | null) => void, + ) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: + | { + version: string; + detail: string; + phase: "available" | "downloading" | "ready" | "error"; + delayMs: number; + isPreview?: boolean; + progressPercent?: number; + } + | null, + ) => callback(payload); + ipcRenderer.on("update-toast-state", listener); + return () => ipcRenderer.removeListener("update-toast-state", listener); + }, onUpdateReadyToast: ( callback: (payload: { version: string; diff --git a/electron/updater.ts b/electron/updater.ts index 5387d46c..b8072488 100644 --- a/electron/updater.ts +++ b/electron/updater.ts @@ -7,14 +7,21 @@ export const UPDATE_REMINDER_DELAY_MS = 3 * 60 * 60 * 1000; const AUTO_UPDATES_DISABLED = process.env.RECORDLY_DISABLE_AUTO_UPDATES === "1"; const DEV_UPDATE_PREVIEW_INTERVAL_MS = 10 * 1000; +export type UpdateToastPhase = "available" | "downloading" | "ready" | "error"; + export interface UpdateToastPayload { version: string; detail: string; + phase: UpdateToastPhase; delayMs: number; isPreview?: boolean; + progressPercent?: number; } -type UpdateToastSender = (channel: "update-ready-toast", payload: UpdateToastPayload) => boolean; +type UpdateToastSender = ( + channel: "update-toast-state", + payload: UpdateToastPayload | null, +) => boolean; let updaterInitialized = false; let updateCheckInProgress = false; @@ -22,7 +29,11 @@ let manualCheckRequested = false; let periodicCheckTimer: NodeJS.Timeout | null = null; let deferredReminderTimer: NodeJS.Timeout | null = null; let devPreviewTimer: NodeJS.Timeout | null = null; +let currentToastPayload: UpdateToastPayload | null = null; +let availableVersion: string | null = null; let pendingDownloadedVersion: string | null = null; +let downloadInProgress = false; +let downloadToastDismissed = false; let skippedVersion: string | null = null; function canUseAutoUpdates() { @@ -64,18 +75,82 @@ function clearDevPreviewTimer() { } } -function sendUpdateToast(sendToRenderer: UpdateToastSender, payload: UpdateToastPayload) { - return sendToRenderer("update-ready-toast", payload); +function emitUpdateToastState( + sendToRenderer: UpdateToastSender | undefined, + payload: UpdateToastPayload | null, +) { + currentToastPayload = payload; + if (!sendToRenderer) { + return false; + } + + return sendToRenderer("update-toast-state", payload); +} + +function createAvailableUpdateToastPayload(version: string): UpdateToastPayload { + return { + version, + 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, + }; +} + +function createDownloadingUpdateToastPayload( + version: string, + progressPercent = 0, +): UpdateToastPayload { + const normalizedProgress = Math.max(0, Math.min(100, progressPercent)); + return { + version, + phase: "downloading", + detail: + normalizedProgress >= 100 + ? "Finishing the update download. You can keep using Recordly while this completes." + : `Downloading the update in the foreground: ${normalizedProgress.toFixed(0)}% complete.`, + delayMs: UPDATE_REMINDER_DELAY_MS, + progressPercent: normalizedProgress, + }; } function createDownloadedUpdateToastPayload(version: string): UpdateToastPayload { return { version, - detail: "Restart now to install the update, or wait and we will remind you again in 3 hours.", + 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, }; } +function createUpdateErrorToastPayload(version: string, error: unknown): UpdateToastPayload { + return { + version, + phase: "error", + detail: `The update download failed. ${String(error)}`, + delayMs: UPDATE_REMINDER_DELAY_MS, + }; +} + +function getReminderPayload(): UpdateToastPayload | null { + if (pendingDownloadedVersion) { + return createDownloadedUpdateToastPayload(pendingDownloadedVersion); + } + + if (availableVersion && !downloadInProgress) { + return createAvailableUpdateToastPayload(availableVersion); + } + + return null; +} + +function clearVisibleUpdateToast(sendToRenderer?: UpdateToastSender) { + emitUpdateToastState(sendToRenderer, null); +} + +export function getCurrentUpdateToastPayload() { + return currentToastPayload; +} + async function showNoUpdatesDialog(getMainWindow: () => BrowserWindow | null) { await showMessageBox(getMainWindow, { type: "info", @@ -102,47 +177,148 @@ function scheduleDevUpdatePreview(sendToRenderer: UpdateToastSender) { }, DEV_UPDATE_PREVIEW_INTERVAL_MS); } -export function installDownloadedUpdateNow() { +export function dismissUpdateToast(sendToRenderer?: UpdateToastSender) { + if (downloadInProgress) { + downloadToastDismissed = true; + } + + clearVisibleUpdateToast(sendToRenderer); + return { success: true }; +} + +export function installDownloadedUpdateNow(sendToRenderer?: UpdateToastSender) { clearDeferredReminderTimer(); clearDevPreviewTimer(); + downloadToastDismissed = false; + clearVisibleUpdateToast(sendToRenderer); autoUpdater.quitAndInstall(); } -export function deferDownloadedUpdateReminder( +export async function downloadAvailableUpdate(sendToRenderer?: UpdateToastSender) { + if (!availableVersion) { + return { success: false, message: "No update is ready to download." }; + } + + if (pendingDownloadedVersion === availableVersion) { + return { success: false, message: "This update has already been downloaded." }; + } + + if (downloadInProgress) { + return { success: false, message: "This update is already downloading." }; + } + + clearDeferredReminderTimer(); + downloadInProgress = true; + downloadToastDismissed = false; + emitUpdateToastState(sendToRenderer, createDownloadingUpdateToastPayload(availableVersion, 0)); + + try { + await autoUpdater.downloadUpdate(); + return { success: true }; + } catch (error) { + downloadInProgress = false; + emitUpdateToastState( + sendToRenderer, + createUpdateErrorToastPayload(availableVersion, error), + ); + return { success: false, message: String(error) }; + } +} + +export function deferUpdateReminder( getMainWindow: () => BrowserWindow | null, sendToRenderer?: UpdateToastSender, delayMs = UPDATE_REMINDER_DELAY_MS, ) { - if (!pendingDownloadedVersion) { - return { success: false, message: "No downloaded update is ready yet." }; + const payload = getReminderPayload(); + if (!payload) { + return { success: false, message: "No update reminder is ready yet." }; } clearDeferredReminderTimer(); + clearVisibleUpdateToast(sendToRenderer); deferredReminderTimer = setTimeout(() => { - if (pendingDownloadedVersion) { - if (sendToRenderer) { - const payload = createDownloadedUpdateToastPayload(pendingDownloadedVersion); - if (sendUpdateToast(sendToRenderer, payload)) { - return; - } - } - - void showDownloadedUpdateDialog(getMainWindow, pendingDownloadedVersion); + const nextPayload = getReminderPayload(); + if (!nextPayload) { + return; } + + if (sendToRenderer && emitUpdateToastState(sendToRenderer, nextPayload)) { + return; + } + + if (nextPayload.phase === "ready") { + void showDownloadedUpdateDialog(getMainWindow, nextPayload.version); + return; + } + + void showAvailableUpdateDialog(getMainWindow, nextPayload.version, sendToRenderer); }, delayMs); return { success: true }; } +export function skipAvailableUpdateVersion(sendToRenderer?: UpdateToastSender) { + const versionToSkip = pendingDownloadedVersion ?? availableVersion; + if (!versionToSkip) { + return { success: false, message: "No update is available to skip." }; + } + + skippedVersion = versionToSkip; + if (pendingDownloadedVersion === versionToSkip) { + pendingDownloadedVersion = null; + } + if (availableVersion === versionToSkip) { + availableVersion = null; + } + downloadInProgress = false; + downloadToastDismissed = false; + clearDeferredReminderTimer(); + clearVisibleUpdateToast(sendToRenderer); + + return { success: true }; +} + export function previewUpdateToast(sendToRenderer: UpdateToastSender) { - return sendUpdateToast(sendToRenderer, { + downloadToastDismissed = false; + return emitUpdateToastState(sendToRenderer, { version: "9.9.9", + phase: "available", detail: "This is a development preview of the in-app update toast.", delayMs: UPDATE_REMINDER_DELAY_MS, isPreview: true, }); } +async function showAvailableUpdateDialog( + getMainWindow: () => BrowserWindow | null, + version: string, + sendToRenderer?: UpdateToastSender, +) { + const result = await showMessageBox(getMainWindow, { + type: "info", + title: "Update Available", + message: `Recordly ${version} is available.`, + detail: "Download now, remind me in 3 hours, or skip this version.", + buttons: ["Download Update", "Remind Me in 3 Hours", "Skip This Version"], + defaultId: 0, + cancelId: 1, + noLink: true, + }); + + if (result.response === 0) { + await downloadAvailableUpdate(sendToRenderer); + return; + } + + if (result.response === 1) { + deferUpdateReminder(getMainWindow, sendToRenderer, UPDATE_REMINDER_DELAY_MS); + return; + } + + skipAvailableUpdateVersion(sendToRenderer); +} + async function showDownloadedUpdateDialog( getMainWindow: () => BrowserWindow | null, version: string, @@ -158,7 +334,7 @@ async function showDownloadedUpdateDialog( detail: isPreview ? "Development preview of the native update prompt. No real update will be installed." : "Install now, remind me in 3 hours, or skip this version.", - buttons: ["Update Now", "Remind Me in 3 Hours", "Skip This Version"], + buttons: ["Install Update", "Remind Me in 3 Hours", "Skip This Version"], defaultId: 0, cancelId: 1, noLink: true, @@ -187,7 +363,7 @@ async function showDownloadedUpdateDialog( return; } - deferDownloadedUpdateReminder(getMainWindow, undefined, UPDATE_REMINDER_DELAY_MS); + deferUpdateReminder(getMainWindow, undefined, UPDATE_REMINDER_DELAY_MS); return; } @@ -195,9 +371,7 @@ async function showDownloadedUpdateDialog( return; } - skippedVersion = version; - pendingDownloadedVersion = null; - clearDeferredReminderTimer(); + skipAvailableUpdateVersion(); } export async function checkForAppUpdates( @@ -273,25 +447,39 @@ export function setupAutoUpdates( } updaterInitialized = true; - autoUpdater.autoDownload = true; - autoUpdater.autoInstallOnAppQuit = true; + autoUpdater.autoDownload = false; + autoUpdater.autoInstallOnAppQuit = false; autoUpdater.on("update-available", (info) => { updateCheckInProgress = false; - if (!manualCheckRequested) { + availableVersion = info.version; + pendingDownloadedVersion = null; + downloadInProgress = false; + downloadToastDismissed = false; + if (skippedVersion === info.version) { + manualCheckRequested = false; return; } - void showMessageBox(getMainWindow, { - type: "info", - title: "Update Available", - message: `Recordly ${info.version} is available.`, - detail: "The update is downloading in the background and you will see a native update prompt when it is ready.", - }); + const payload = createAvailableUpdateToastPayload(info.version); + if (emitUpdateToastState(sendToRenderer, payload)) { + manualCheckRequested = false; + return; + } + + if (manualCheckRequested) { + void showAvailableUpdateDialog(getMainWindow, info.version, sendToRenderer); + manualCheckRequested = false; + } }); autoUpdater.on("update-not-available", () => { updateCheckInProgress = false; + availableVersion = null; + pendingDownloadedVersion = null; + downloadInProgress = false; + downloadToastDismissed = false; + clearVisibleUpdateToast(sendToRenderer); const shouldReport = manualCheckRequested; manualCheckRequested = false; if (shouldReport) { @@ -299,11 +487,35 @@ export function setupAutoUpdates( } }); + autoUpdater.on("download-progress", (progress) => { + if (!availableVersion) { + return; + } + + downloadInProgress = true; + if (downloadToastDismissed) { + return; + } + + emitUpdateToastState( + sendToRenderer, + createDownloadingUpdateToastPayload(availableVersion, progress.percent), + ); + }); + autoUpdater.on("error", (error) => { updateCheckInProgress = false; const shouldReport = manualCheckRequested; manualCheckRequested = false; console.error("Auto-updater error:", error); + if (downloadInProgress && availableVersion) { + downloadInProgress = false; + downloadToastDismissed = false; + emitUpdateToastState( + sendToRenderer, + createUpdateErrorToastPayload(availableVersion, error), + ); + } if (shouldReport) { void showUpdateErrorDialog(getMainWindow, error); } @@ -312,13 +524,16 @@ export function setupAutoUpdates( autoUpdater.on("update-downloaded", (info) => { updateCheckInProgress = false; manualCheckRequested = false; + downloadInProgress = false; + downloadToastDismissed = false; if (skippedVersion === info.version) { return; } + availableVersion = info.version; pendingDownloadedVersion = info.version; clearDeferredReminderTimer(); - if (sendUpdateToast(sendToRenderer, createDownloadedUpdateToastPayload(info.version))) { + if (emitUpdateToastState(sendToRenderer, createDownloadedUpdateToastPayload(info.version))) { return; } diff --git a/electron/windows.ts b/electron/windows.ts index ec390b93..f5729f25 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -20,6 +20,7 @@ let hudOverlayWindow: BrowserWindow | null = null; let hudOverlayHiddenFromCapture = true; let hudOverlayCaptureProtectionLoaded = false; let countdownWindow: BrowserWindow | null = null; +let updateToastWindow: BrowserWindow | null = null; const HUD_OVERLAY_SETTINGS_FILE = path.join(app.getPath("userData"), "hud-overlay-settings.json"); const HUD_BOTTOM_CLEARANCE_CM = 3.5; @@ -30,6 +31,9 @@ const HUD_SHADOW_BLEED_DIP = 36; const HUD_MIN_WINDOW_WIDTH = 560; const HUD_COMPACT_HEIGHT = 96; const HUD_MIN_EXPANDED_HEIGHT = 520 + HUD_SHADOW_BLEED_DIP; +const UPDATE_TOAST_WIDTH = 420; +const UPDATE_TOAST_HEIGHT = 212; +const UPDATE_TOAST_GAP_DIP = 18; let hudOverlayExpanded = false; let hudOverlayCompactWidth = HUD_MIN_WINDOW_WIDTH; @@ -119,12 +123,51 @@ function applyHudOverlayBounds(expanded: boolean) { hudOverlayExpanded = expanded; hudOverlayWindow.setBounds(getHudOverlayBounds(expanded), false); + positionUpdateToastWindow(); if (!hudOverlayWindow.isVisible()) { return; } hudOverlayWindow.moveTop(); } +function getUpdateToastBounds() { + const hudWindow = getHudOverlayWindow(); + if (hudWindow) { + const hudBounds = hudWindow.getBounds(); + const display = getScreen().getDisplayMatching(hudBounds); + const x = Math.round(hudBounds.x + (hudBounds.width - UPDATE_TOAST_WIDTH) / 2); + const y = Math.max( + display.workArea.y + HUD_EDGE_MARGIN_DIP, + hudBounds.y - UPDATE_TOAST_HEIGHT - UPDATE_TOAST_GAP_DIP, + ); + + return { + x, + y, + width: UPDATE_TOAST_WIDTH, + height: UPDATE_TOAST_HEIGHT, + }; + } + + const primaryDisplay = getScreen().getPrimaryDisplay(); + const { workArea } = primaryDisplay; + return { + x: Math.round(workArea.x + (workArea.width - UPDATE_TOAST_WIDTH) / 2), + y: workArea.y + HUD_EDGE_MARGIN_DIP, + width: UPDATE_TOAST_WIDTH, + height: UPDATE_TOAST_HEIGHT, + }; +} + +function positionUpdateToastWindow() { + if (!updateToastWindow || updateToastWindow.isDestroyed()) { + return; + } + + updateToastWindow.setBounds(getUpdateToastBounds(), false); + updateToastWindow.moveTop(); +} + ipcMain.on("hud-overlay-hide", () => { if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) { hudOverlayWindow.minimize(); @@ -277,6 +320,80 @@ export function getHudOverlayWindow(): BrowserWindow | null { return hudOverlayWindow && !hudOverlayWindow.isDestroyed() ? hudOverlayWindow : null; } +export function createUpdateToastWindow(): BrowserWindow { + const initialBounds = getUpdateToastBounds(); + + const win = new BrowserWindow({ + width: initialBounds.width, + height: initialBounds.height, + x: initialBounds.x, + y: initialBounds.y, + frame: false, + transparent: true, + resizable: false, + alwaysOnTop: true, + skipTaskbar: true, + hasShadow: false, + show: false, + focusable: true, + ...(hudOverlayWindow && !hudOverlayWindow.isDestroyed() ? { parent: hudOverlayWindow } : {}), + backgroundColor: "#00000000", + webPreferences: { + preload: path.join(__dirname, "preload.mjs"), + nodeIntegration: false, + contextIsolation: true, + backgroundThrottling: false, + }, + }); + + if (process.platform === "darwin") { + win.setAlwaysOnTop(true, "status"); + } + + win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + updateToastWindow = win; + + win.on("closed", () => { + if (updateToastWindow === win) { + updateToastWindow = null; + } + }); + + if (VITE_DEV_SERVER_URL) { + win.loadURL(VITE_DEV_SERVER_URL + "?windowType=update-toast"); + } else { + win.loadFile(path.join(RENDERER_DIST, "index.html"), { + query: { windowType: "update-toast" }, + }); + } + + return win; +} + +export function getUpdateToastWindow(): BrowserWindow | null { + return updateToastWindow && !updateToastWindow.isDestroyed() ? updateToastWindow : null; +} + +export function showUpdateToastWindow(): BrowserWindow { + const win = getUpdateToastWindow() ?? createUpdateToastWindow(); + positionUpdateToastWindow(); + if (!win.isVisible()) { + win.showInactive(); + } else { + win.moveTop(); + } + + return win; +} + +export function hideUpdateToastWindow(): void { + if (!updateToastWindow || updateToastWindow.isDestroyed()) { + return; + } + + updateToastWindow.hide(); +} + export function createEditorWindow(): BrowserWindow { const isMac = process.platform === "darwin"; const { width, height } = getScreen().getPrimaryDisplay().workAreaSize; diff --git a/src/App.tsx b/src/App.tsx index bcaf4fc2..94a68ef4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,8 @@ -import { Gift } from "lucide-react"; import { useEffect, useState } from "react"; -import { toast } from "sonner"; import { CountdownOverlay } from "./components/countdown/CountdownOverlay"; import { LaunchWindow } from "./components/launch/LaunchWindow"; import { SourceSelector } from "./components/launch/SourceSelector"; +import { UpdateToastWindow } from "./components/launch/UpdateToastWindow"; import { Toaster } from "./components/ui/sonner"; import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog"; import VideoEditor from "./components/video-editor/VideoEditor"; @@ -11,13 +10,6 @@ import { useI18n } from "./contexts/I18nContext"; import { ShortcutsProvider } from "./contexts/ShortcutsContext"; import { loadAllCustomFonts } from "./lib/customFonts"; -const UPDATE_TOAST_ID = "recordly-update-ready"; - -function formatDelayHours(delayMs: number) { - const hours = Math.max(1, Math.round(delayMs / (60 * 60 * 1000))); - return `${hours}h`; -} - export default function App() { const [windowType, setWindowType] = useState(""); const { locale, t } = useI18n(); @@ -27,13 +19,18 @@ export default function App() { const type = params.get("windowType") || ""; setWindowType(type); - if (type === "hud-overlay" || type === "source-selector" || type === "countdown") { + if ( + type === "hud-overlay" || + type === "source-selector" || + type === "countdown" || + type === "update-toast" + ) { document.body.style.background = "transparent"; document.documentElement.style.background = "transparent"; document.getElementById("root")?.style.setProperty("background", "transparent"); } - if (type === "hud-overlay") { + if (type === "hud-overlay" || type === "update-toast") { document.documentElement.style.overflow = "visible"; document.body.style.overflow = "visible"; document.getElementById("root")?.style.setProperty("overflow", "visible"); @@ -49,79 +46,6 @@ export default function App() { windowType === "editor" ? t("app.editorTitle", "Recordly Editor") : t("app.name", "Recordly"); }, [windowType, locale, t]); - useEffect(() => { - if ( - windowType === "countdown" || - windowType === "source-selector" || - typeof window.electronAPI?.onUpdateReadyToast !== "function" - ) { - return; - } - - return window.electronAPI.onUpdateReadyToast((payload) => { - toast.custom( - (toastInstance) => ( -
-
- -
-
-
-

- {payload.isPreview ? "Update Toast Preview" : `Recordly ${payload.version} is ready`} -

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

{payload.detail}

-
- - -
-
-
- ), - { id: UPDATE_TOAST_ID, duration: Number.POSITIVE_INFINITY }, - ); - }); - }, [windowType]); - switch (windowType) { case "hud-overlay": return ( @@ -134,6 +58,8 @@ export default function App() { return ; case "countdown": return ; + case "update-toast": + return ; case "editor": return ( diff --git a/src/components/launch/UpdateToastWindow.tsx b/src/components/launch/UpdateToastWindow.tsx new file mode 100644 index 00000000..7fa89630 --- /dev/null +++ b/src/components/launch/UpdateToastWindow.tsx @@ -0,0 +1,180 @@ +import { AlertCircle, Download, LoaderCircle, Rocket } from "lucide-react"; +import { useEffect, useState } from "react"; + +type UpdateToastPayload = { + version: string; + detail: string; + phase: "available" | "downloading" | "ready" | "error"; + delayMs: number; + isPreview?: boolean; + progressPercent?: number; +}; + +function formatDelayHours(delayMs: number) { + const hours = Math.max(1, Math.round(delayMs / (60 * 60 * 1000))); + return `${hours}h`; +} + +function getToastTitle(payload: UpdateToastPayload) { + if (payload.isPreview) { + return "Update Toast Preview"; + } + + 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 getIcon(payload: UpdateToastPayload) { + switch (payload.phase) { + case "available": + return ; + case "downloading": + return ; + case "ready": + return ; + case "error": + return ; + } +} + +export function UpdateToastWindow() { + const [payload, setPayload] = useState(null); + + useEffect(() => { + let mounted = true; + + void window.electronAPI.getCurrentUpdateToastPayload().then((nextPayload) => { + if (mounted) { + setPayload(nextPayload); + } + }); + + const dispose = window.electronAPI.onUpdateToastStateChanged((nextPayload) => { + setPayload(nextPayload); + }); + + return () => { + mounted = false; + dispose(); + }; + }, []); + + if (!payload) { + return
; + } + + const normalizedProgress = Math.max(0, Math.min(100, Math.round(payload.progressPercent ?? 0))); + + return ( +
+
+
+ {getIcon(payload)} +
+
+
+

{getToastTitle(payload)}

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

{payload.detail}

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

{normalizedProgress}% downloaded

+
+ ) : null} + +
+ {payload.phase === "available" || payload.phase === "error" ? ( + + ) : null} + + {payload.phase === "ready" ? ( + + ) : null} + + {payload.phase === "downloading" ? ( + + ) : null} + + {payload.phase !== "downloading" ? ( + + ) : null} + + {!payload.isPreview && payload.phase !== "downloading" ? ( + + ) : null} +
+
+
+
+ ); +} \ No newline at end of file