mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 14:55:37 +00:00
463 lines
12 KiB
TypeScript
463 lines
12 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,
|
|
session,
|
|
systemPreferences,
|
|
Tray,
|
|
} from "electron";
|
|
import { showCursor } from "./cursorHider";
|
|
import {
|
|
getSelectedSourceId,
|
|
killWindowsCaptureProcess,
|
|
registerIpcHandlers,
|
|
} from "./ipc/handlers";
|
|
import { checkForAppUpdates, setupAutoUpdates } from "./updater";
|
|
import { createEditorWindow, createHudOverlayWindow, createSourceSelectorWindow } from "./windows";
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
if (process.platform === "darwin") {
|
|
app.commandLine.appendSwitch("disable-features", "MacCatapLoopbackAudioForScreenShare");
|
|
}
|
|
|
|
export const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings");
|
|
|
|
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;
|
|
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
|
|
const defaultTrayIcon = getTrayIcon("app-icons/recordly-32.png");
|
|
const recordingTrayIcon = getTrayIcon("rec-button.png");
|
|
|
|
ipcMain.on("set-has-unsaved-changes", (_event, hasChanges: boolean) => {
|
|
editorHasUnsavedChanges = hasChanges;
|
|
});
|
|
|
|
function createWindow() {
|
|
mainWindow = createHudOverlayWindow();
|
|
}
|
|
|
|
function focusOrCreateMainWindow() {
|
|
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";
|
|
const template: Electron.MenuItemConstructorOptions[] = [];
|
|
|
|
if (isMac) {
|
|
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(() => mainWindow, { manual: true });
|
|
},
|
|
},
|
|
],
|
|
},
|
|
);
|
|
|
|
const menu = Menu.buildFromTemplate(template);
|
|
Menu.setApplicationMenu(menu);
|
|
}
|
|
|
|
function createTray() {
|
|
tray = new Tray(defaultTrayIcon);
|
|
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 updateTrayMenu(recording: boolean = false) {
|
|
if (!tray) return;
|
|
const trayIcon = recording ? recordingTrayIcon : defaultTrayIcon;
|
|
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 () => {
|
|
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
|
|
const allowed = ["media", "audioCapture", "microphone"];
|
|
return allowed.includes(permission);
|
|
});
|
|
|
|
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
|
|
const allowed = ["media", "audioCapture", "microphone"];
|
|
callback(allowed.includes(permission));
|
|
});
|
|
|
|
if (process.platform === "darwin") {
|
|
const micStatus = systemPreferences.getMediaAccessStatus("microphone");
|
|
if (micStatus !== "granted") {
|
|
await systemPreferences.askForMediaAccess("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();
|
|
}
|
|
},
|
|
);
|
|
|
|
setupAutoUpdates(() => mainWindow);
|
|
|
|
// 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({});
|
|
}
|
|
});
|
|
|
|
createWindow();
|
|
});
|