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
This commit is contained in:
webadderall
2026-03-29 19:30:02 +11:00
parent f5c92964a4
commit 53c7aa7e77
5 changed files with 296 additions and 53 deletions
+111
View File
@@ -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);
+14 -4
View File
@@ -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();
}
+2 -1
View File
@@ -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";
+161 -48
View File
@@ -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 <Download className="h-5 w-5" />;
case "downloading":
return <LoaderCircle className="h-5 w-5 animate-spin" />;
case "ready":
return <Rocket className="h-5 w-5" />;
case "error":
return <AlertCircle className="h-5 w-5" />;
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<typeof setInterval> | 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 <div className="h-full w-full bg-transparent" />;
return (
<div style={wrapperStyle}>
<div style={{ ...cardStyle, alignItems: "center" }}>
<div style={iconBoxStyle}>
<LoaderCircle size={20} />
</div>
<div>
<p style={titleStyle}>Checking for updates</p>
<p style={secondaryTextStyle}>
Waiting for updater state from the main process.
</p>
</div>
</div>
</div>
);
}
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 (
<div className="flex h-full w-full items-center justify-center bg-transparent p-2">
<div style={wrapperStyle}>
<div
className="pointer-events-auto flex w-full max-w-[404px] items-start gap-3 rounded-[24px] border border-sky-300/20 bg-[#0d1117]/95 p-4 text-white shadow-2xl shadow-black/45 backdrop-blur-xl transition-transform duration-150 ease-out select-none"
className="pointer-events-auto select-none"
style={{
...cardStyle,
transform: `translateX(${dragOffsetX}px) rotate(${dragOffsetX / 30}deg)`,
opacity: Math.max(0.35, 1 - Math.min(1, Math.abs(dragOffsetX) / 180)),
}}
@@ -160,54 +285,42 @@ export function UpdateToastWindow() {
setDragOffsetX(0);
}}
>
<div className="mt-0.5 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-2xl bg-sky-400/15 text-sky-300">
{getIcon(payload)}
<div style={iconBoxStyle}>
{payload.phase === "available" ? <Download size={20} /> : null}
{payload.phase === "downloading" ? <LoaderCircle size={20} className="animate-spin" /> : null}
{payload.phase === "ready" ? <Rocket size={20} /> : null}
{payload.phase === "error" ? <AlertCircle size={20} /> : null}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold tracking-tight">{getToastTitle(payload)}</p>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<p style={titleStyle}>{getToastTitle(payload)}</p>
{payload.isPreview ? (
<span className="rounded-full border border-sky-300/20 bg-sky-400/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.18em] text-sky-200">
<span style={{ borderRadius: 999, border: "1px solid rgba(125, 211, 252, 0.2)", background: "rgba(125, 211, 252, 0.1)", padding: "2px 8px", fontSize: 10, fontWeight: 700, letterSpacing: "0.18em", textTransform: "uppercase", color: "#bae6fd" }}>
Dev
</span>
) : null}
</div>
<p className="mt-1 text-sm leading-5 text-white/70">{payload.detail}</p>
<p style={secondaryTextStyle}>{payload.detail}</p>
{payload.phase === "downloading" ? (
<div className="mt-3">
<div className="h-2 overflow-hidden rounded-full bg-white/10">
<div style={{ marginTop: 12 }}>
<div style={{ height: 8, overflow: "hidden", borderRadius: 999, background: "rgba(255, 255, 255, 0.1)" }}>
<div
className="h-full rounded-full bg-sky-300 transition-[width] duration-300"
style={{ width: `${normalizedProgress}%` }}
style={{ height: "100%", borderRadius: 999, background: "#7dd3fc", width: `${normalizedProgress}%` }}
/>
</div>
<p className="mt-2 text-xs font-medium text-sky-100/85">{normalizedProgress}% downloaded</p>
<p style={{ margin: "8px 0 0 0", color: "rgba(224, 242, 254, 0.9)", fontSize: 12, fontWeight: 600 }}>{normalizedProgress}% downloaded</p>
</div>
) : null}
<div className="mt-3 flex flex-wrap items-center gap-2">
{payload.phase === "available" || payload.phase === "error" ? (
<div style={rowStyle}>
{primaryActionLabel ? (
<button
type="button"
onClick={async () => {
await window.electronAPI.downloadAvailableUpdate();
}}
className="rounded-xl bg-sky-400 px-3 py-2 text-xs font-semibold text-[#031a2c] transition-colors hover:bg-sky-300"
onClick={handlePrimaryAction}
style={primaryButtonStyle}
>
Download Update
</button>
) : null}
{payload.phase === "ready" ? (
<button
type="button"
onClick={async () => {
await window.electronAPI.installDownloadedUpdate();
}}
className="rounded-xl bg-sky-400 px-3 py-2 text-xs font-semibold text-[#031a2c] transition-colors hover:bg-sky-300"
>
Install Update
{primaryActionLabel}
</button>
) : 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
</button>
@@ -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)})
</button>
@@ -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)
</button>
@@ -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
</button>
+8
View File
@@ -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;