mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-26 07:45:34 +00:00
feat(updater): add explicit download toast flow
This commit is contained in:
Vendored
+14
@@ -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<UpdateToastState | null>;
|
||||
previewUpdateToast: () => Promise<{ success: boolean }>;
|
||||
onUpdateToastStateChanged: (callback: (payload: UpdateToastState | null) => void) => () => void;
|
||||
onUpdateReadyToast: (callback: (payload: {
|
||||
version: string;
|
||||
detail: string;
|
||||
|
||||
+49
-23
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
+248
-33
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+10
-84
@@ -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) => (
|
||||
<div className="pointer-events-auto flex w-[390px] items-start gap-3 rounded-2xl border border-sky-300/20 bg-[#0d1117]/95 p-4 text-white shadow-2xl shadow-black/40 backdrop-blur-xl">
|
||||
<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">
|
||||
<Gift className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-semibold tracking-tight">
|
||||
{payload.isPreview ? "Update Toast Preview" : `Recordly ${payload.version} is ready`}
|
||||
</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">
|
||||
Dev
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 text-sm leading-5 text-white/70">{payload.detail}</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
toast.dismiss(toastInstance);
|
||||
if (payload.isPreview) {
|
||||
toast.success("Preview only. No real update was installed.");
|
||||
return;
|
||||
}
|
||||
|
||||
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"
|
||||
>
|
||||
Update Now
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
toast.dismiss(toastInstance);
|
||||
if (payload.isPreview) {
|
||||
toast.success(`Preview dismissed. We'll show it again in ${formatDelayHours(payload.delayMs)}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await window.electronAPI.deferDownloadedUpdate(payload.delayMs);
|
||||
if (result.success) {
|
||||
toast.success(`Okay, we'll remind you in ${formatDelayHours(payload.delayMs)}.`);
|
||||
} else if (result.message) {
|
||||
toast.error(result.message);
|
||||
}
|
||||
}}
|
||||
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"
|
||||
>
|
||||
Update Later ({formatDelayHours(payload.delayMs)})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{ id: UPDATE_TOAST_ID, duration: Number.POSITIVE_INFINITY },
|
||||
);
|
||||
});
|
||||
}, [windowType]);
|
||||
|
||||
switch (windowType) {
|
||||
case "hud-overlay":
|
||||
return (
|
||||
@@ -134,6 +58,8 @@ export default function App() {
|
||||
return <SourceSelector />;
|
||||
case "countdown":
|
||||
return <CountdownOverlay />;
|
||||
case "update-toast":
|
||||
return <UpdateToastWindow />;
|
||||
case "editor":
|
||||
return (
|
||||
<ShortcutsProvider>
|
||||
|
||||
@@ -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 <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" />;
|
||||
}
|
||||
}
|
||||
|
||||
export function UpdateToastWindow() {
|
||||
const [payload, setPayload] = useState<UpdateToastPayload | null>(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 <div className="h-full w-full bg-transparent" />;
|
||||
}
|
||||
|
||||
const normalizedProgress = Math.max(0, Math.min(100, Math.round(payload.progressPercent ?? 0)));
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-transparent p-2">
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
{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">
|
||||
Dev
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 text-sm leading-5 text-white/70">{payload.detail}</p>
|
||||
|
||||
{payload.phase === "downloading" ? (
|
||||
<div className="mt-3">
|
||||
<div className="h-2 overflow-hidden rounded-full bg-white/10">
|
||||
<div
|
||||
className="h-full rounded-full bg-sky-300 transition-[width] duration-300"
|
||||
style={{ width: `${normalizedProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs font-medium text-sky-100/85">{normalizedProgress}% downloaded</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{payload.phase === "available" || payload.phase === "error" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (payload.isPreview) {
|
||||
await window.electronAPI.dismissUpdateToast();
|
||||
return;
|
||||
}
|
||||
|
||||
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"
|
||||
>
|
||||
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
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{payload.phase === "downloading" ? (
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
Hide
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{payload.phase !== "downloading" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (payload.isPreview) {
|
||||
await window.electronAPI.dismissUpdateToast();
|
||||
return;
|
||||
}
|
||||
|
||||
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"
|
||||
>
|
||||
Later ({formatDelayHours(payload.delayMs)})
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{!payload.isPreview && payload.phase !== "downloading" ? (
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
Skip This Version
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user