Files
Recordly/electron/main.ts
webadderall e8aa5ac5ad fix: add setDevicePermissionHandler for Windows 11 webcam access
Chromium (Electron 17+) requires setDevicePermissionHandler in addition
to setPermissionCheckHandler/setPermissionRequestHandler. Without it,
device-level access is silently denied on Windows 11 even though the
permission check passes, causing getUserMedia to throw NotAllowedError.
2026-03-30 15:41:01 +11:00

712 lines
19 KiB
TypeScript

import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
app,
BrowserWindow,
desktopCapturer,
dialog,
ipcMain,
Menu,
nativeImage,
Notification,
session,
systemPreferences,
Tray,
} from "electron";
import { RECORDINGS_DIR } from "./appPaths";
import { showCursor } from "./cursorHider";
import {
getSelectedSourceId,
killWindowsCaptureProcess,
registerIpcHandlers,
} from "./ipc/handlers";
import {
checkForAppUpdates,
dismissUpdateToast,
downloadAvailableUpdate,
deferUpdateReminder,
getCurrentUpdateToastPayload,
getUpdaterLogPath,
getUpdateStatusSummary,
installDownloadedUpdateNow,
previewUpdateToast,
skipAvailableUpdateVersion,
setupAutoUpdates,
} from "./updater";
import type { UpdateToastPayload } from "./updater";
import {
createEditorWindow,
createHudOverlayWindow,
createSourceSelectorWindow,
getUpdateToastWindow,
getHudOverlayWindow,
hideUpdateToastWindow,
showUpdateToastWindow,
} from "./windows";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
if (process.platform === "darwin") {
app.commandLine.appendSwitch("disable-features", "MacCatapLoopbackAudioForScreenShare");
}
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(__dirname, "..");
// 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");
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
? path.join(process.env.APP_ROOT, "public")
: RENDERER_DIST;
// Window references
let mainWindow: BrowserWindow | null = null;
let sourceSelectorWindow: BrowserWindow | null = null;
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) {
app.quit();
}
function closeEditorWindowBypassingUnsavedPrompt(window: BrowserWindow | null) {
if (!window || window.isDestroyed()) {
return;
}
isForceClosing = true;
editorHasUnsavedChanges = false;
window.close();
}
// 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 getDefaultTrayIcon() {
if (!defaultTrayIcon) {
defaultTrayIcon = getTrayIcon("app-icons/recordly-32.png");
}
return defaultTrayIcon;
}
function getRecordingTrayIcon() {
if (!recordingTrayIcon) {
recordingTrayIcon = getTrayIcon("rec-button.png");
}
return recordingTrayIcon;
}
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;
}
mainWindow = createHudOverlayWindow();
}
function focusOrCreateMainWindow() {
if (!app.isReady()) {
void app.whenReady().then(() => {
focusOrCreateMainWindow();
});
return;
}
if (BrowserWindow.getAllWindows().length === 0) {
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;
}
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 createTray() {
tray = new Tray(getDefaultTrayIcon());
tray.on("click", () => focusOrCreateMainWindow());
}
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("app-icons/recordly-512.png");
if (!dockIcon.isEmpty()) {
app.dock.setIcon(dockIcon);
}
}
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) {
return false;
}
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", () => {
return downloadAvailableUpdate(sendUpdateToastToWindows);
});
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("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: "Stop Recording",
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("stop-recording-from-tray");
}
},
},
]
: [
{
label: "Open",
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
mainWindow.moveTop();
} else {
createWindow();
}
},
},
{
label: "Quit",
click: () => {
app.quit();
},
},
];
tray.setImage(trayIcon);
tray.setToolTip(trayToolTip);
tray.setContextMenu(Menu.buildFromTemplate(menuTemplate));
}
function createEditorWindowWrapper() {
if (mainWindow) {
closeEditorWindowBypassingUnsavedPrompt(mainWindow);
mainWindow = null;
}
mainWindow = createEditorWindow();
editorHasUnsavedChanges = false;
mainWindow.on("closed", () => {
if (mainWindow?.isDestroyed()) {
mainWindow = null;
}
isForceClosing = false;
editorHasUnsavedChanges = false;
});
mainWindow.on("close", (event) => {
if (isForceClosing || !editorHasUnsavedChanges) {
return;
}
event.preventDefault();
const choice = dialog.showMessageBoxSync(mainWindow!, {
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) {
mainWindow!.webContents.send("request-save-before-close");
ipcMain.once("save-before-close-done", (_event, saved: boolean) => {
if (saved) {
closeEditorWindowBypassingUnsavedPrompt(mainWindow);
}
});
} else if (choice === 1) {
closeEditorWindowBypassingUnsavedPrompt(mainWindow);
}
});
}
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();
});
app.on("window-all-closed", () => {
if (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) => {
const allowed = ["media", "audioCapture", "microphone", "camera", "videoCapture"];
return allowed.includes(permission);
});
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
const allowed = ["media", "audioCapture", "microphone", "camera", "videoCapture"];
callback(allowed.includes(permission));
});
session.defaultSession.setDevicePermissionHandler((_details) => true);
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", () => {
app.quit();
});
syncDockIcon();
createTray();
updateTrayMenu();
setupApplicationMenu();
// Ensure recordings directory exists
await ensureRecordingsDir();
registerIpcHandlers(
createEditorWindowWrapper,
createSourceSelectorWindowWrapper,
() => mainWindow,
() => sourceSelectorWindow,
(recording: boolean, sourceName: string) => {
selectedSourceName = sourceName;
if (!tray) createTray();
updateTrayMenu(recording);
if (!recording) {
if (mainWindow) mainWindow.restore();
}
},
);
createWindow();
setupAutoUpdates(getUpdateDialogWindow, sendUpdateToastToWindows);
// 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 sources = await desktopCapturer.getSources({ types: ["screen", "window"] });
const sourceId = getSelectedSourceId();
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);
}
});