merge: Windows update UX fixes (#windows-fixes)

This commit is contained in:
webadderall
2026-03-29 19:45:51 +11:00
16 changed files with 714 additions and 61 deletions
+10
View File
@@ -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<UpdateToastState | null>;
getUpdateStatusSummary: () => Promise<UpdateStatusSummary>;
previewUpdateToast: () => Promise<{ success: boolean }>;
checkForAppUpdates: () => Promise<{ success: boolean; logPath: string }>;
onUpdateToastStateChanged: (callback: (payload: UpdateToastState | null) => void) => () => void;
onUpdateReadyToast: (callback: (payload: {
version: string;
+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);
Binary file not shown.
@@ -5,17 +5,17 @@
"helpers": {
"wgc-capture": {
"binaryName": "wgc-capture.exe",
"binarySha256": "5f364b07b016c597288f90a9b61c581e6559d6d32b79ffadceb37859bbea8dd2",
"binarySha256": "013164c0a1391d334e5aa2fe0ff2e47b507407e36c7e44e7333a70a84b944643",
"sourceDir": "electron/native/wgc-capture",
"sourceFingerprint": "9e9bce082266ca5968cf5f0b535b469d47c4ec3a775a93726171c0dfdbcdaa44",
"updatedAt": "2026-03-28T05:48:46.587Z"
"updatedAt": "2026-03-29T02:15:34.516Z"
},
"cursor-monitor": {
"binaryName": "cursor-monitor.exe",
"binarySha256": "916ed064c12a3b861d7721df8b9209d10ec0fdb8078564a2ba3ea8cf4f4354c4",
"binarySha256": "b0732abc06998a40c3e95078465ad750a6169901944571c39cfd7996effe39c0",
"sourceDir": "electron/native/cursor-monitor",
"sourceFingerprint": "6ad1b8b50bb336f2a48937b06f5ec56d90b6ab4a3e56a4bca278cf67a5d3e52e",
"updatedAt": "2026-03-28T05:17:17.697Z"
"updatedAt": "2026-03-29T02:15:38.286Z"
}
}
}
Binary file not shown.
+8
View File
@@ -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);
+180 -1
View File
@@ -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 = {
...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))) {
+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";
@@ -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;
+120
View File
@@ -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<string | null>(null);
const [appVersion, setAppVersion] = useState<string | null>(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<HTMLDivElement>(null);
const hudContentRef = useRef<HTMLDivElement>(null);
const hudBarRef = useRef<HTMLDivElement>(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 <CheckCircle2 size={14} />;
case "checking":
case "downloading":
return <RefreshCw size={14} className={styles.updateBadgeSpin} />;
default:
return <ArrowUpCircle size={14} />;
}
})();
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 = (
<>
<div className="flex items-center gap-[5px]">
@@ -1054,6 +1161,19 @@ export function LaunchWindow() {
<RxDragHandleDots2 size={14} className="text-[#6b6b78]" />
</div>
<button
type="button"
onClick={() => {
void handleUpdateButtonClick();
}}
className={updateButtonClassName}
title={updateButtonTitle}
disabled={updateActionPending}
>
{updateButtonIcon}
<span>{updateButtonLabel}</span>
</button>
<div className={styles.barStateViewport}>
<AnimatePresence initial={false} mode="wait">
<motion.div
+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>
+12 -1
View File
@@ -37,7 +37,18 @@
"pause": "Pause",
"stop": "Stop",
"cancel": "Cancel",
"more": "More"
"more": "More",
"update": {
"update": "Update",
"updated": "Updated",
"idleTitle": "Check for updates.",
"checkingTitle": "Checking for updates...",
"downloadingTitle": "Downloading update...",
"errorTitle": "Update check failed. Click to retry.",
"upToDateTitle": "Recordly {{version}} is up to date.",
"availableTitle": "Recordly {{version}} is available.",
"availableGenericTitle": "An update is available."
}
},
"sourceSelector": {
"loadingSources": "Loading sources...",
+12 -1
View File
@@ -37,7 +37,18 @@
"pause": "Pausa",
"stop": "Detener",
"cancel": "Cancelar",
"more": "Más"
"more": "Más",
"update": {
"update": "Actualizar",
"updated": "Actualizado",
"idleTitle": "Buscar actualizaciones.",
"checkingTitle": "Buscando actualizaciones...",
"downloadingTitle": "Descargando actualización...",
"errorTitle": "La comprobación de actualizaciones falló. Haz clic para reintentar.",
"upToDateTitle": "Recordly {{version}} está actualizado.",
"availableTitle": "Recordly {{version}} está disponible.",
"availableGenericTitle": "Hay una actualización disponible."
}
},
"sourceSelector": {
"loadingSources": "Cargando fuentes...",
+12 -1
View File
@@ -37,7 +37,18 @@
"pause": "暂停",
"stop": "停止",
"cancel": "取消",
"more": "更多"
"more": "更多",
"update": {
"update": "更新",
"updated": "已是最新",
"idleTitle": "检查更新。",
"checkingTitle": "正在检查更新...",
"downloadingTitle": "正在下载更新...",
"errorTitle": "检查更新失败。点击重试。",
"upToDateTitle": "Recordly {{version}} 已是最新版本。",
"availableTitle": "Recordly {{version}} 可更新。",
"availableGenericTitle": "有可用更新。"
}
},
"sourceSelector": {
"loadingSources": "正在加载源...",
+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;