mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 06:46:09 +00:00
1082 lines
29 KiB
TypeScript
1082 lines
29 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import {
|
|
app,
|
|
BrowserWindow,
|
|
desktopCapturer,
|
|
dialog,
|
|
webContents as electronWebContents,
|
|
ipcMain,
|
|
Menu,
|
|
nativeImage,
|
|
session,
|
|
shell,
|
|
systemPreferences,
|
|
Tray,
|
|
} from "electron";
|
|
import { RECORDINGS_DIR } from "./appPaths";
|
|
import { showCursor } from "./cursorHider";
|
|
import { getGpuSwitches } from "./gpuSwitches";
|
|
import {
|
|
cleanupAllExportStreams,
|
|
cleanupNativeVideoExportSessions,
|
|
getSelectedSourceId,
|
|
killWindowsCaptureProcess,
|
|
registerIpcHandlers,
|
|
} from "./ipc/handlers";
|
|
import { ensureMediaServer } from "./mediaServer";
|
|
import { hardenWebContentsNavigation, shouldHardenWebContentsType } from "./navigationPolicy";
|
|
import { shouldGrantDisplayCapture, shouldGrantMediaPermission } from "./permissionPolicy";
|
|
import { ensurePackagedRendererServer, getPackagedRendererBaseUrl } from "./rendererServer";
|
|
import {
|
|
checkForAppUpdates,
|
|
deferUpdateReminder,
|
|
dismissUpdateToast,
|
|
downloadAvailableUpdate,
|
|
getCurrentUpdateToastPayload,
|
|
getExperimentalUpdatesEnabled,
|
|
getUpdaterLogPath,
|
|
getUpdateStatusSummary,
|
|
installDownloadedUpdateNow,
|
|
previewUpdateToast,
|
|
setupAutoUpdates,
|
|
setExperimentalUpdatesEnabled,
|
|
skipAvailableUpdateVersion,
|
|
} from "./updater";
|
|
import {
|
|
createEditorWindow,
|
|
createHudOverlayWindow,
|
|
createSourceSelectorWindow,
|
|
getHudOverlayWindow,
|
|
getUpdateToastWindow,
|
|
hideUpdateToastWindow,
|
|
isHudOverlayMousePassthroughSupported,
|
|
reassertHudOverlayMousePassthrough as reassertHudOverlayMouseState,
|
|
setHudOverlayRecordingActive,
|
|
showUpdateToastWindow,
|
|
} from "./windows";
|
|
|
|
const electronMainDir = path.dirname(fileURLToPath(import.meta.url));
|
|
const IS_SMOKE_EXPORT = process.env.RECORDLY_SMOKE_EXPORT === "1";
|
|
|
|
function ignoreBrokenConsolePipe(stream: NodeJS.WritableStream | undefined) {
|
|
stream?.on("error", (error: NodeJS.ErrnoException) => {
|
|
if (error.code === "EPIPE") {
|
|
return;
|
|
}
|
|
throw error;
|
|
});
|
|
}
|
|
|
|
ignoreBrokenConsolePipe(process.stdout);
|
|
ignoreBrokenConsolePipe(process.stderr);
|
|
|
|
app.commandLine.appendSwitch("ignore-gpu-blocklist");
|
|
app.commandLine.appendSwitch("enable-unsafe-webgpu");
|
|
app.commandLine.appendSwitch("enable-gpu-rasterization");
|
|
|
|
app.on("web-contents-created", (_event, contents) => {
|
|
if (!shouldHardenWebContentsType(contents.getType())) {
|
|
return;
|
|
}
|
|
|
|
hardenWebContentsNavigation(contents, (url) => shell.openExternal(url));
|
|
});
|
|
|
|
function configureGpuAccelerationSwitches() {
|
|
const { useAngle, useGl, disableFeatures } = getGpuSwitches(process.platform, process.env);
|
|
if (useAngle) {
|
|
app.commandLine.appendSwitch("use-angle", useAngle);
|
|
}
|
|
if (useGl) {
|
|
app.commandLine.appendSwitch("use-gl", useGl);
|
|
}
|
|
if (disableFeatures && disableFeatures.length > 0) {
|
|
app.commandLine.appendSwitch("disable-features", disableFeatures.join(","));
|
|
}
|
|
}
|
|
|
|
async function logSmokeExportGpuDiagnostics() {
|
|
if (!IS_SMOKE_EXPORT) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
console.log("[smoke-export] GPU feature status", JSON.stringify(app.getGPUFeatureStatus()));
|
|
console.log("[smoke-export] GPU info", JSON.stringify(await app.getGPUInfo("basic")));
|
|
} catch (error) {
|
|
console.warn("[smoke-export] Failed to read GPU diagnostics:", error);
|
|
}
|
|
}
|
|
|
|
configureGpuAccelerationSwitches();
|
|
|
|
async function ensureRecordingsDir() {
|
|
try {
|
|
await fs.mkdir(RECORDINGS_DIR, { recursive: true });
|
|
console.log("RECORDINGS_DIR:", RECORDINGS_DIR);
|
|
console.log("User Data Path:", app.getPath("userData"));
|
|
} catch (error) {
|
|
console.error("Failed to create recordings directory:", error);
|
|
}
|
|
}
|
|
|
|
// The built directory structure
|
|
//
|
|
// ├─┬─┬ dist
|
|
// │ │ └── index.html
|
|
// │ │
|
|
// │ ├─┬ dist-electron
|
|
// │ │ ├── main.js
|
|
// │ │ └── preload.mjs
|
|
// │
|
|
process.env.APP_ROOT = path.join(electronMainDir, "..");
|
|
|
|
// Use ['ENV_NAME'] avoid vite:define plugin - Vite@2.x
|
|
export const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"];
|
|
export const MAIN_DIST = path.join(process.env.APP_ROOT, "dist-electron");
|
|
export const RENDERER_DIST = path.join(process.env.APP_ROOT, "dist");
|
|
const IS_DEV = Boolean(VITE_DEV_SERVER_URL);
|
|
|
|
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
|
|
? path.join(process.env.APP_ROOT, "public")
|
|
: RENDERER_DIST;
|
|
|
|
function getTrustedCaptureDocumentBaseUrls(): string[] {
|
|
const trustedUrls = [pathToFileURL(path.join(RENDERER_DIST, "index.html")).href];
|
|
|
|
if (VITE_DEV_SERVER_URL) {
|
|
trustedUrls.push(VITE_DEV_SERVER_URL);
|
|
}
|
|
|
|
const packagedRendererBaseUrl = getPackagedRendererBaseUrl();
|
|
if (packagedRendererBaseUrl) {
|
|
trustedUrls.push(new URL("/", packagedRendererBaseUrl).href);
|
|
}
|
|
|
|
return trustedUrls;
|
|
}
|
|
|
|
function isHudWebContents(webContents: Electron.WebContents | null): boolean {
|
|
if (!webContents || webContents.isDestroyed()) {
|
|
return false;
|
|
}
|
|
|
|
const hudWindow = getHudOverlayWindow();
|
|
return Boolean(hudWindow && hudWindow.webContents === webContents);
|
|
}
|
|
|
|
// Window references
|
|
let mainWindow: BrowserWindow | null = null;
|
|
let sourceSelectorWindow: BrowserWindow | null = null;
|
|
let tray: Tray | null = null;
|
|
let trayContextMenu: Menu | null = null;
|
|
let selectedSourceName = "";
|
|
let editorHasUnsavedChanges = false;
|
|
let isForceClosing = false;
|
|
let isCreatingMainWindow = false;
|
|
let isCreatingEditorWindow = false;
|
|
const shouldEnforceSingleInstanceLock = !IS_DEV;
|
|
const hasSingleInstanceLock = shouldEnforceSingleInstanceLock
|
|
? app.requestSingleInstanceLock()
|
|
: true;
|
|
|
|
if (!hasSingleInstanceLock) {
|
|
app.quit();
|
|
}
|
|
|
|
function closeEditorWindowBypassingUnsavedPrompt(window: BrowserWindow | null) {
|
|
if (!window || window.isDestroyed()) {
|
|
return;
|
|
}
|
|
|
|
if (isEditorWindow(window)) {
|
|
isForceClosing = true;
|
|
editorHasUnsavedChanges = false;
|
|
}
|
|
window.close();
|
|
}
|
|
|
|
function restoreWindowSafely(window: BrowserWindow | null) {
|
|
if (!window || window.isDestroyed()) {
|
|
return;
|
|
}
|
|
|
|
if (!isEditorWindow(window) && process.platform === "win32") {
|
|
showHudOverlayFromTray();
|
|
return;
|
|
}
|
|
|
|
if (window.isMinimized()) {
|
|
window.restore();
|
|
}
|
|
|
|
if (!window.isVisible()) {
|
|
window.show();
|
|
}
|
|
|
|
window.moveTop();
|
|
window.focus();
|
|
}
|
|
|
|
function getExistingEditorWindow(): BrowserWindow | null {
|
|
return (
|
|
BrowserWindow.getAllWindows().find(
|
|
(window) => !window.isDestroyed() && isEditorWindow(window),
|
|
) ?? null
|
|
);
|
|
}
|
|
|
|
// Tray Icons (lazily created after app is ready to avoid accessing Electron APIs too early)
|
|
let defaultTrayIcon: ReturnType<typeof getTrayIcon> | null = null;
|
|
let recordingTrayIcon: ReturnType<typeof getTrayIcon> | null = null;
|
|
|
|
function getPlatformAppIconFilename(size: 32 | 128 | 512) {
|
|
const baseName = process.platform === "darwin" ? "recordlymac" : "recordly";
|
|
return `app-icons/${baseName}-${size}.png`;
|
|
}
|
|
|
|
function getDefaultTrayIcon() {
|
|
if (!defaultTrayIcon) {
|
|
defaultTrayIcon = getTrayIcon(getPlatformAppIconFilename(32));
|
|
}
|
|
return defaultTrayIcon;
|
|
}
|
|
|
|
function getRecordingTrayIcon() {
|
|
if (!recordingTrayIcon) {
|
|
recordingTrayIcon = getTrayIcon("rec-button.png");
|
|
}
|
|
return recordingTrayIcon;
|
|
}
|
|
|
|
function showHudOverlayFromTray() {
|
|
const updateToast = getUpdateToastWindow();
|
|
if (updateToast?.isVisible()) {
|
|
updateToast.show();
|
|
updateToast.moveTop();
|
|
updateToast.focus();
|
|
return true;
|
|
}
|
|
|
|
const hud = getHudOverlayWindow();
|
|
if (!hud) {
|
|
return false;
|
|
}
|
|
|
|
if (hud.isMinimized()) {
|
|
hud.restore();
|
|
}
|
|
|
|
if (process.platform === "win32" && isHudOverlayMousePassthroughSupported()) {
|
|
hud.showInactive();
|
|
hud.moveTop();
|
|
reassertHudOverlayMouseState();
|
|
return true;
|
|
}
|
|
|
|
hud.show();
|
|
hud.moveTop();
|
|
hud.focus();
|
|
return true;
|
|
}
|
|
|
|
ipcMain.on("set-has-unsaved-changes", (_event, hasChanges: boolean) => {
|
|
editorHasUnsavedChanges = hasChanges;
|
|
});
|
|
|
|
function createWindow() {
|
|
if (!app.isReady()) {
|
|
void app.whenReady().then(() => {
|
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
|
createWindow();
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (isCreatingMainWindow) {
|
|
return;
|
|
}
|
|
|
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
restoreWindowSafely(mainWindow);
|
|
return;
|
|
}
|
|
|
|
const existingHudWindow = getHudOverlayWindow();
|
|
if (existingHudWindow) {
|
|
mainWindow = existingHudWindow;
|
|
restoreWindowSafely(existingHudWindow);
|
|
return;
|
|
}
|
|
|
|
isCreatingMainWindow = true;
|
|
const createdHudWindow = createHudOverlayWindow();
|
|
mainWindow = createdHudWindow;
|
|
createdHudWindow.once("closed", () => {
|
|
if (mainWindow === createdHudWindow) {
|
|
mainWindow = null;
|
|
}
|
|
});
|
|
isCreatingMainWindow = false;
|
|
}
|
|
|
|
function focusOrCreateMainWindow() {
|
|
if (!app.isReady()) {
|
|
void app.whenReady().then(() => {
|
|
focusOrCreateMainWindow();
|
|
});
|
|
return;
|
|
}
|
|
|
|
const updateToast = getUpdateToastWindow();
|
|
if (updateToast?.isVisible()) {
|
|
updateToast.show();
|
|
updateToast.moveTop();
|
|
updateToast.focus();
|
|
return;
|
|
}
|
|
|
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
|
const existingHud = getHudOverlayWindow();
|
|
if (existingHud && !existingHud.isDestroyed()) {
|
|
mainWindow = existingHud;
|
|
} else {
|
|
createWindow();
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
// On Linux/Wayland, focus() often doesn't take effect (compositor ignores it). Apps like Telegram
|
|
// work because they receive an XDG activation token via StatusNotifierItem.ProvideXdgActivationToken;
|
|
// Electron's tray doesn't handle that yet. Workaround: destroy and recreate the HUD so the new
|
|
// window gets focus (creation path works). Only for HUD, not editor.
|
|
if (
|
|
process.platform === "linux" &&
|
|
!mainWindow.isFocused() &&
|
|
!isEditorWindow(mainWindow)
|
|
) {
|
|
const win = mainWindow;
|
|
mainWindow = null;
|
|
win.once("closed", () => createWindow());
|
|
win.destroy();
|
|
return;
|
|
}
|
|
|
|
// On Win32 with mouse passthrough enabled (Win11+), calling
|
|
// show/moveTop/focus on the transparent HUD overlay permanently corrupts
|
|
// setIgnoreMouseEvents forwarding, making it click-through. Only focus
|
|
// the editor window; the HUD is alwaysOnTop so it doesn't need explicit
|
|
// focus. On Win10 (passthrough disabled), the HUD is always interactive
|
|
// and can be safely shown/restored.
|
|
if (
|
|
process.platform === "win32" &&
|
|
!isEditorWindow(mainWindow) &&
|
|
isHudOverlayMousePassthroughSupported()
|
|
) {
|
|
showHudOverlayFromTray();
|
|
return;
|
|
}
|
|
|
|
mainWindow.show();
|
|
if (mainWindow.isMinimized()) mainWindow.restore();
|
|
mainWindow.moveTop();
|
|
mainWindow.focus();
|
|
}
|
|
}
|
|
|
|
function isEditorWindow(window: BrowserWindow) {
|
|
return window.webContents.getURL().includes("windowType=editor");
|
|
}
|
|
|
|
function sendEditorMenuAction(
|
|
channel: "menu-load-project" | "menu-save-project" | "menu-save-project-as",
|
|
) {
|
|
let targetWindow = BrowserWindow.getFocusedWindow() ?? mainWindow;
|
|
|
|
if (!targetWindow || targetWindow.isDestroyed() || !isEditorWindow(targetWindow)) {
|
|
createEditorWindowWrapper();
|
|
targetWindow = mainWindow;
|
|
if (!targetWindow || targetWindow.isDestroyed()) return;
|
|
|
|
targetWindow.webContents.once("did-finish-load", () => {
|
|
if (!targetWindow || targetWindow.isDestroyed()) return;
|
|
targetWindow.webContents.send(channel);
|
|
});
|
|
return;
|
|
}
|
|
|
|
targetWindow.webContents.send(channel);
|
|
}
|
|
|
|
function setupApplicationMenu() {
|
|
const isMac = process.platform === "darwin";
|
|
if (!isMac) {
|
|
Menu.setApplicationMenu(null);
|
|
return;
|
|
}
|
|
|
|
const template: Electron.MenuItemConstructorOptions[] = [];
|
|
template.push({
|
|
label: app.name,
|
|
submenu: [
|
|
{ role: "about" },
|
|
{ type: "separator" },
|
|
{ role: "services" },
|
|
{ type: "separator" },
|
|
{ role: "hide" },
|
|
{ role: "hideOthers" },
|
|
{ role: "unhide" },
|
|
{ type: "separator" },
|
|
{ role: "quit" },
|
|
],
|
|
});
|
|
|
|
template.push(
|
|
{
|
|
label: "File",
|
|
submenu: [
|
|
{
|
|
label: "Open Projects…",
|
|
accelerator: "CmdOrCtrl+O",
|
|
click: () => sendEditorMenuAction("menu-load-project"),
|
|
},
|
|
{
|
|
label: "Save Project…",
|
|
accelerator: "CmdOrCtrl+S",
|
|
click: () => sendEditorMenuAction("menu-save-project"),
|
|
},
|
|
{
|
|
label: "Save Project As…",
|
|
accelerator: "CmdOrCtrl+Shift+S",
|
|
click: () => sendEditorMenuAction("menu-save-project-as"),
|
|
},
|
|
...(isMac ? [] : [{ type: "separator" as const }, { role: "quit" as const }]),
|
|
],
|
|
},
|
|
{
|
|
label: "Edit",
|
|
submenu: [
|
|
{ role: "undo" },
|
|
{ role: "redo" },
|
|
{ type: "separator" },
|
|
{ role: "cut" },
|
|
{ role: "copy" },
|
|
{ role: "paste" },
|
|
{ role: "selectAll" },
|
|
],
|
|
},
|
|
{
|
|
label: "View",
|
|
submenu: [
|
|
{ role: "reload" },
|
|
{ role: "forceReload" },
|
|
{ role: "toggleDevTools" },
|
|
{ type: "separator" },
|
|
{ role: "resetZoom" },
|
|
{ role: "zoomIn" },
|
|
{ role: "zoomOut" },
|
|
{ type: "separator" },
|
|
{ role: "togglefullscreen" },
|
|
],
|
|
},
|
|
{
|
|
label: "Window",
|
|
submenu: isMac
|
|
? [{ role: "minimize" }, { role: "zoom" }, { type: "separator" }, { role: "front" }]
|
|
: [{ role: "minimize" }, { role: "close" }],
|
|
},
|
|
{
|
|
label: "Help",
|
|
submenu: [
|
|
{
|
|
label: "Check for Updates…",
|
|
click: () => {
|
|
void checkForAppUpdates(getUpdateDialogWindow, { manual: true });
|
|
},
|
|
},
|
|
],
|
|
},
|
|
);
|
|
|
|
const menu = Menu.buildFromTemplate(template);
|
|
Menu.setApplicationMenu(menu);
|
|
}
|
|
|
|
function isPrimaryTrayClick(event: unknown) {
|
|
const button =
|
|
event && typeof event === "object" && "button" in event
|
|
? (event as { button?: number | string }).button
|
|
: undefined;
|
|
return button === undefined || button === 0 || button === "left";
|
|
}
|
|
|
|
function createTray() {
|
|
tray = new Tray(getDefaultTrayIcon());
|
|
tray.on("click", (event) => {
|
|
if (process.platform === "win32" && !isPrimaryTrayClick(event)) {
|
|
return;
|
|
}
|
|
|
|
focusOrCreateMainWindow();
|
|
});
|
|
|
|
if (process.platform === "win32") {
|
|
tray.on("right-click", () => {
|
|
if (!tray || !trayContextMenu) {
|
|
return;
|
|
}
|
|
|
|
tray.popUpContextMenu(trayContextMenu);
|
|
});
|
|
return;
|
|
}
|
|
|
|
tray.on("double-click", () => focusOrCreateMainWindow());
|
|
}
|
|
|
|
function shouldUseTray() {
|
|
// macOS and Windows expose Recordly through their Dock/taskbar. Keep the
|
|
// tray entry only on Linux, where it remains the primary app entry point.
|
|
return process.platform === "linux";
|
|
}
|
|
|
|
function getPublicAssetPath(filename: string) {
|
|
return path.join(process.env.VITE_PUBLIC || RENDERER_DIST, filename);
|
|
}
|
|
|
|
function getAppImage(filename: string) {
|
|
return nativeImage.createFromPath(getPublicAssetPath(filename));
|
|
}
|
|
|
|
function getTrayIcon(filename: string) {
|
|
return getAppImage(filename).resize({
|
|
width: 24,
|
|
height: 24,
|
|
quality: "best",
|
|
});
|
|
}
|
|
|
|
function syncDockIcon() {
|
|
if (process.platform !== "darwin" || !app.dock) {
|
|
return;
|
|
}
|
|
|
|
const dockIcon = getAppImage(getPlatformAppIconFilename(512));
|
|
if (!dockIcon.isEmpty()) {
|
|
app.dock.setIcon(dockIcon);
|
|
}
|
|
}
|
|
|
|
function sendUpdateToastToWindows(channel: "update-toast-state", payload: unknown) {
|
|
if (!payload) {
|
|
const existingWindow = getUpdateToastWindow();
|
|
if (existingWindow) {
|
|
existingWindow.webContents.send(channel, null);
|
|
}
|
|
hideUpdateToastWindow();
|
|
return true;
|
|
}
|
|
|
|
const toastWindow = showUpdateToastWindow();
|
|
const sendPayload = () => {
|
|
toastWindow.webContents.send(channel, payload);
|
|
showUpdateToastWindow();
|
|
};
|
|
|
|
if (toastWindow.webContents.isLoadingMainFrame()) {
|
|
toastWindow.webContents.once("did-finish-load", sendPayload);
|
|
} else {
|
|
sendPayload();
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function getUpdateDialogWindow() {
|
|
const focusedWindow = BrowserWindow.getFocusedWindow();
|
|
if (focusedWindow && !focusedWindow.isDestroyed()) {
|
|
return focusedWindow;
|
|
}
|
|
|
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
return mainWindow;
|
|
}
|
|
|
|
return getHudOverlayWindow();
|
|
}
|
|
|
|
ipcMain.handle("install-downloaded-update", () => {
|
|
installDownloadedUpdateNow(sendUpdateToastToWindows);
|
|
return { success: true };
|
|
});
|
|
|
|
ipcMain.handle("download-available-update", (_event, installAfterDownload?: boolean) => {
|
|
return downloadAvailableUpdate(sendUpdateToastToWindows, {
|
|
installAfterDownload: Boolean(installAfterDownload),
|
|
});
|
|
});
|
|
|
|
ipcMain.handle("defer-downloaded-update", (_event, delayMs?: number) => {
|
|
return deferUpdateReminder(getUpdateDialogWindow, sendUpdateToastToWindows, delayMs);
|
|
});
|
|
|
|
ipcMain.handle("dismiss-update-toast", () => {
|
|
return dismissUpdateToast(getUpdateDialogWindow, sendUpdateToastToWindows);
|
|
});
|
|
|
|
ipcMain.handle("skip-update-version", () => {
|
|
return skipAvailableUpdateVersion(sendUpdateToastToWindows);
|
|
});
|
|
|
|
ipcMain.handle("get-current-update-toast-payload", () => {
|
|
return getCurrentUpdateToastPayload();
|
|
});
|
|
|
|
ipcMain.handle("get-update-status-summary", () => {
|
|
return getUpdateStatusSummary();
|
|
});
|
|
|
|
ipcMain.handle("get-experimental-updates-enabled", () => {
|
|
return getExperimentalUpdatesEnabled();
|
|
});
|
|
|
|
ipcMain.handle("set-experimental-updates-enabled", async (_event, enabled: unknown) => {
|
|
if (typeof enabled !== "boolean") {
|
|
return { success: false, enabled: getExperimentalUpdatesEnabled() };
|
|
}
|
|
|
|
try {
|
|
const savedValue = setExperimentalUpdatesEnabled(enabled);
|
|
await checkForAppUpdates(getUpdateDialogWindow);
|
|
return { success: true, enabled: savedValue };
|
|
} catch (error) {
|
|
console.error("Failed to update experimental updates preference:", error);
|
|
return {
|
|
success: false,
|
|
enabled: getExperimentalUpdatesEnabled(),
|
|
error: String(error),
|
|
};
|
|
}
|
|
});
|
|
|
|
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();
|
|
const trayToolTip = recording ? `Recording: ${selectedSourceName}` : "Recordly";
|
|
const menuTemplate = recording
|
|
? [
|
|
{
|
|
label: "Show Controls",
|
|
click: () => {
|
|
if (!showHudOverlayFromTray()) {
|
|
focusOrCreateMainWindow();
|
|
}
|
|
},
|
|
},
|
|
{
|
|
label: "Stop Recording",
|
|
click: () => {
|
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
mainWindow.webContents.send("stop-recording-from-tray");
|
|
}
|
|
},
|
|
},
|
|
]
|
|
: [
|
|
{
|
|
label: "Open",
|
|
click: () => {
|
|
if (!showHudOverlayFromTray()) {
|
|
focusOrCreateMainWindow();
|
|
}
|
|
},
|
|
},
|
|
{
|
|
label: "Quit",
|
|
click: () => {
|
|
app.quit();
|
|
},
|
|
},
|
|
];
|
|
const menu = Menu.buildFromTemplate(menuTemplate);
|
|
trayContextMenu = menu;
|
|
tray.setImage(trayIcon);
|
|
tray.setToolTip(trayToolTip);
|
|
if (process.platform !== "win32") {
|
|
tray.setContextMenu(menu);
|
|
}
|
|
}
|
|
|
|
function createEditorWindowWrapper() {
|
|
const existingEditorWindow = getExistingEditorWindow();
|
|
if (existingEditorWindow) {
|
|
mainWindow = existingEditorWindow;
|
|
restoreWindowSafely(existingEditorWindow);
|
|
return existingEditorWindow;
|
|
}
|
|
|
|
if (isCreatingEditorWindow) {
|
|
const currentWindow = mainWindow;
|
|
if (currentWindow && !currentWindow.isDestroyed()) {
|
|
return currentWindow;
|
|
}
|
|
|
|
const currentEditorWindow = getExistingEditorWindow();
|
|
if (currentEditorWindow) {
|
|
mainWindow = currentEditorWindow;
|
|
return currentEditorWindow;
|
|
}
|
|
}
|
|
|
|
isCreatingEditorWindow = true;
|
|
const previousWindow = mainWindow;
|
|
if (previousWindow && !previousWindow.isDestroyed()) {
|
|
const closingEditorWindow = isEditorWindow(previousWindow);
|
|
|
|
if (closingEditorWindow) {
|
|
closeEditorWindowBypassingUnsavedPrompt(previousWindow);
|
|
} else {
|
|
// It's the HUD or another window. Hide it instead of closing so background
|
|
// tasks (like webcam finalizing) can finish in its renderer process.
|
|
previousWindow.hide();
|
|
}
|
|
|
|
if (!closingEditorWindow) {
|
|
isForceClosing = false;
|
|
}
|
|
if (mainWindow === previousWindow) {
|
|
mainWindow = null;
|
|
}
|
|
}
|
|
const editorWindow = createEditorWindow();
|
|
mainWindow = editorWindow;
|
|
editorHasUnsavedChanges = false;
|
|
|
|
editorWindow.on("closed", () => {
|
|
if (mainWindow === editorWindow) {
|
|
mainWindow = null;
|
|
}
|
|
isCreatingEditorWindow = false;
|
|
isForceClosing = false;
|
|
editorHasUnsavedChanges = false;
|
|
});
|
|
|
|
editorWindow.on("close", (event) => {
|
|
if (isForceClosing || !editorHasUnsavedChanges) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
|
|
const choice = dialog.showMessageBoxSync(editorWindow, {
|
|
type: "warning",
|
|
buttons: ["Save & Close", "Discard & Close", "Cancel"],
|
|
defaultId: 0,
|
|
cancelId: 2,
|
|
title: "Unsaved Changes",
|
|
message: "You have unsaved changes.",
|
|
detail: "Do you want to save your project before closing?",
|
|
});
|
|
|
|
if (choice === 0) {
|
|
editorWindow.webContents.send("request-save-before-close");
|
|
ipcMain.once("save-before-close-done", (_event, saved: boolean) => {
|
|
if (saved) {
|
|
closeEditorWindowBypassingUnsavedPrompt(editorWindow);
|
|
}
|
|
});
|
|
} else if (choice === 1) {
|
|
closeEditorWindowBypassingUnsavedPrompt(editorWindow);
|
|
}
|
|
});
|
|
|
|
return editorWindow;
|
|
}
|
|
|
|
function createSourceSelectorWindowWrapper() {
|
|
sourceSelectorWindow = createSourceSelectorWindow();
|
|
sourceSelectorWindow.on("closed", () => {
|
|
sourceSelectorWindow = null;
|
|
});
|
|
return sourceSelectorWindow;
|
|
}
|
|
|
|
// On macOS, applications and their menu bar stay active until the user quits
|
|
// explicitly with Cmd + Q.
|
|
app.on("before-quit", () => {
|
|
killWindowsCaptureProcess();
|
|
showCursor();
|
|
cleanupNativeVideoExportSessions();
|
|
void cleanupAllExportStreams();
|
|
});
|
|
|
|
app.on("window-all-closed", () => {
|
|
if (IS_SMOKE_EXPORT || process.platform !== "darwin") {
|
|
app.quit();
|
|
}
|
|
});
|
|
|
|
app.on("activate", () => {
|
|
// On OS X it's common to re-create a window in the app when the
|
|
// dock icon is clicked and there are no other windows open.
|
|
focusOrCreateMainWindow();
|
|
});
|
|
|
|
app.on("second-instance", () => {
|
|
focusOrCreateMainWindow();
|
|
});
|
|
|
|
// 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, requestingOrigin, details) => {
|
|
return shouldGrantMediaPermission(
|
|
{
|
|
permission,
|
|
isTrustedCaptureWindow: isHudWebContents(webContents),
|
|
isMainFrame: details.isMainFrame,
|
|
currentDocumentUrl: webContents?.getURL() ?? "",
|
|
// Electron 39 may supply the last committed document URL, including its
|
|
// query, in the requestingOrigin argument for media checks.
|
|
requestingUrl: details.requestingUrl ?? requestingOrigin,
|
|
securityOrigins:
|
|
details.securityOrigin === undefined ? [] : [details.securityOrigin],
|
|
},
|
|
getTrustedCaptureDocumentBaseUrls(),
|
|
);
|
|
},
|
|
);
|
|
|
|
session.defaultSession.setPermissionRequestHandler(
|
|
(webContents, permission, callback, details) => {
|
|
const securityOrigin = "securityOrigin" in details ? details.securityOrigin : undefined;
|
|
|
|
callback(
|
|
shouldGrantMediaPermission(
|
|
{
|
|
permission,
|
|
isTrustedCaptureWindow: isHudWebContents(webContents),
|
|
isMainFrame: details.isMainFrame,
|
|
currentDocumentUrl: webContents.getURL(),
|
|
requestingUrl: details.requestingUrl,
|
|
securityOrigins: securityOrigin === undefined ? [] : [securityOrigin],
|
|
},
|
|
getTrustedCaptureDocumentBaseUrls(),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
|
|
// Recordly does not use WebHID, Web Serial, or WebUSB. Do not grant devices by default.
|
|
session.defaultSession.setDevicePermissionHandler(() => false);
|
|
|
|
if (process.platform === "darwin") {
|
|
const cameraStatus = systemPreferences.getMediaAccessStatus("camera");
|
|
if (cameraStatus !== "granted") {
|
|
await systemPreferences.askForMediaAccess("camera");
|
|
}
|
|
|
|
const micStatus = systemPreferences.getMediaAccessStatus("microphone");
|
|
if (micStatus !== "granted") {
|
|
await systemPreferences.askForMediaAccess("microphone");
|
|
}
|
|
} else if (process.platform === "win32") {
|
|
const cameraStatus = systemPreferences.getMediaAccessStatus("camera");
|
|
const micStatus = systemPreferences.getMediaAccessStatus("microphone");
|
|
if (cameraStatus !== "granted") {
|
|
console.warn(
|
|
`[permissions] Camera access is "${cameraStatus}" — webcam may not work. Check Windows Settings > Privacy > Camera.`,
|
|
);
|
|
}
|
|
if (micStatus !== "granted") {
|
|
console.warn(
|
|
`[permissions] Microphone access is "${micStatus}" — mic recording may not work. Check Windows Settings > Privacy > Microphone.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
ipcMain.on("hud-overlay-close", () => {
|
|
const hud = getHudOverlayWindow();
|
|
if (hud) {
|
|
console.log("[main] Closing HUD window via hud-overlay-close");
|
|
hud.close();
|
|
}
|
|
|
|
// If this was the last window (or we are in a state where we should quit), do it.
|
|
// We use a small delay to allow window.close() to propagate.
|
|
setTimeout(() => {
|
|
const windows = BrowserWindow.getAllWindows().filter((w) => !w.isDestroyed());
|
|
if (windows.length === 0) {
|
|
console.log("[main] No windows left, quitting app");
|
|
app.quit();
|
|
}
|
|
}, 100);
|
|
});
|
|
if (process.platform === "darwin" && app.dock) {
|
|
await app.dock.show();
|
|
}
|
|
syncDockIcon();
|
|
if (shouldUseTray()) {
|
|
createTray();
|
|
updateTrayMenu();
|
|
}
|
|
setupApplicationMenu();
|
|
// Ensure recordings directory exists
|
|
await ensureRecordingsDir();
|
|
|
|
if (!VITE_DEV_SERVER_URL) {
|
|
try {
|
|
await ensurePackagedRendererServer(RENDERER_DIST);
|
|
} catch (error) {
|
|
console.warn("[renderer-server] Failed to start packaged renderer server:", error);
|
|
}
|
|
}
|
|
|
|
try {
|
|
await ensureMediaServer();
|
|
} catch (error) {
|
|
console.warn("[media-server] Failed to start media server:", error);
|
|
}
|
|
|
|
registerIpcHandlers(
|
|
createEditorWindowWrapper,
|
|
createSourceSelectorWindowWrapper,
|
|
() => mainWindow,
|
|
() => sourceSelectorWindow,
|
|
(recording: boolean, sourceName: string) => {
|
|
selectedSourceName = sourceName;
|
|
setHudOverlayRecordingActive(recording);
|
|
if (shouldUseTray()) {
|
|
if (!tray) createTray();
|
|
updateTrayMenu(recording);
|
|
}
|
|
if (recording) {
|
|
reassertHudOverlayMouseState();
|
|
}
|
|
if (!recording) {
|
|
restoreWindowSafely(mainWindow);
|
|
}
|
|
},
|
|
);
|
|
|
|
if (IS_SMOKE_EXPORT || process.env.RECORDLY_DEV_OPEN_RECORDING_INPUT) {
|
|
await logSmokeExportGpuDiagnostics();
|
|
if (IS_SMOKE_EXPORT) {
|
|
const smokeSource =
|
|
process.env.RECORDLY_SMOKE_EXPORT_PROJECT ??
|
|
process.env.RECORDLY_SMOKE_EXPORT_INPUT ??
|
|
"<missing input>";
|
|
console.log(`[smoke-export] Starting editor smoke export for ${smokeSource}`);
|
|
} else {
|
|
console.log(
|
|
`[dev-open-recording] Starting editor for ${process.env.RECORDLY_DEV_OPEN_RECORDING_INPUT}`,
|
|
);
|
|
}
|
|
createEditorWindowWrapper();
|
|
return;
|
|
}
|
|
|
|
createWindow();
|
|
setupAutoUpdates(getUpdateDialogWindow, sendUpdateToastToWindows);
|
|
if (IS_DEV && process.env.RECORDLY_DEV_PREVIEW_UPDATE === "1") {
|
|
setTimeout(() => {
|
|
previewUpdateToast(sendUpdateToastToWindows);
|
|
}, 750);
|
|
}
|
|
|
|
// Register the display media handler so that renderer's getDisplayMedia()
|
|
// calls land on the pre-selected source without showing a system picker.
|
|
//
|
|
// IMPORTANT: The callback must receive a plain { id, name } Video object.
|
|
// Passing the full DesktopCapturerSource (with thumbnail, appIcon, etc.)
|
|
// via an unsafe cast breaks Electron's internal cursor-constraint
|
|
// propagation and causes cursor: 'never' from the renderer to be silently
|
|
// ignored by the native capture pipeline.
|
|
session.defaultSession.setDisplayMediaRequestHandler(async (request, callback) => {
|
|
try {
|
|
const frame = request.frame;
|
|
const isLiveFrame = Boolean(frame && !frame.isDestroyed());
|
|
const requestingWebContents =
|
|
isLiveFrame && frame ? electronWebContents.fromFrame(frame) : undefined;
|
|
const isHudMainFrame = Boolean(
|
|
isLiveFrame &&
|
|
requestingWebContents &&
|
|
isHudWebContents(requestingWebContents) &&
|
|
frame === requestingWebContents.mainFrame,
|
|
);
|
|
|
|
if (
|
|
!shouldGrantDisplayCapture(
|
|
{
|
|
isTrustedCaptureWindow: isHudMainFrame,
|
|
isMainFrame: Boolean(isLiveFrame && frame?.parent === null),
|
|
currentDocumentUrl: isLiveFrame ? (frame?.url ?? "") : "",
|
|
securityOrigin: request.securityOrigin,
|
|
videoRequested: request.videoRequested,
|
|
},
|
|
getTrustedCaptureDocumentBaseUrls(),
|
|
)
|
|
) {
|
|
callback({});
|
|
return;
|
|
}
|
|
|
|
const sourceId = getSelectedSourceId();
|
|
// On Linux/Wayland, calling desktopCapturer.getSources() itself
|
|
// invokes the xdg-desktop-portal picker. If we then return one of
|
|
// those sources, Chromium triggers a SECOND portal because the
|
|
// pre-enumerated source IDs are stale on Wayland. To collapse this
|
|
// into a single portal invocation, when the Linux portal sentinel
|
|
// is set we skip getSources entirely and hand back a synthetic
|
|
// source id; Chromium then opens the portal once to actually
|
|
// resolve the capture.
|
|
// Default to the sentinel on Linux when no source has been
|
|
// pre-selected (e.g. fresh session where the renderer skipped the
|
|
// source picker entirely). This avoids calling getSources() which
|
|
// would itself trigger an extra portal dialog.
|
|
const isLinuxPortalSentinel =
|
|
process.platform === "linux" && (sourceId === "screen:linux-portal" || !sourceId);
|
|
if (isLinuxPortalSentinel) {
|
|
callback({ video: { id: "screen:0:0", name: "Entire screen" } });
|
|
return;
|
|
}
|
|
const sources = await desktopCapturer.getSources({ types: ["screen", "window"] });
|
|
const source = sourceId
|
|
? (sources.find((s) => s.id === sourceId) ?? sources[0])
|
|
: sources[0];
|
|
if (source) {
|
|
callback({
|
|
video: { id: source.id, name: source.name },
|
|
});
|
|
} else {
|
|
callback({});
|
|
}
|
|
} catch (error) {
|
|
console.error("setDisplayMediaRequestHandler error:", error);
|
|
callback({});
|
|
}
|
|
});
|
|
|
|
const currentToastPayload = getCurrentUpdateToastPayload();
|
|
if (currentToastPayload) {
|
|
sendUpdateToastToWindows("update-toast-state", currentToastPayload);
|
|
}
|
|
});
|