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;