1073b0c214
CI / Lint (push) Has been cancelled
CI / Type Check (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
Bump Nix package on release / bump (release) Has been cancelled
Update Homebrew Cask / update-cask (release) Has been cancelled
550 lines
15 KiB
TypeScript
550 lines
15 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
app,
|
|
BrowserWindow,
|
|
ipcMain,
|
|
Menu,
|
|
nativeImage,
|
|
session,
|
|
systemPreferences,
|
|
Tray,
|
|
} from "electron";
|
|
import { mainT, setMainLocale } from "./i18n";
|
|
import { getSelectedDesktopSource, registerIpcHandlers } from "./ipc/handlers";
|
|
import {
|
|
createCountdownOverlayWindow,
|
|
createEditorWindow,
|
|
createHudOverlayWindow,
|
|
createSourceSelectorWindow,
|
|
} from "./windows";
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
// Use Screen & System Audio Recording permissions instead of CoreAudio Tap API on macOS.
|
|
// CoreAudio Tap requires NSAudioCaptureUsageDescription in the parent app's Info.plist,
|
|
// which doesn't work when running from a terminal/IDE during development, makes my life easier
|
|
if (process.platform === "darwin") {
|
|
app.commandLine.appendSwitch("disable-features", "MacCatapLoopbackAudioForScreenShare");
|
|
}
|
|
|
|
// Enable Wayland support for proper screen capture and window management
|
|
// on Wayland compositors (Hyprland, GNOME, KDE, etc.)
|
|
if (process.platform === "linux") {
|
|
const isWayland =
|
|
process.env.XDG_SESSION_TYPE === "wayland" || process.env.WAYLAND_DISPLAY !== undefined;
|
|
if (isWayland) {
|
|
app.commandLine.appendSwitch("ozone-platform", "wayland");
|
|
// Enable WebRTCPipeWireCapturer for screen capture on Wayland
|
|
app.commandLine.appendSwitch("enable-features", "WaylandWindowDrag,WebRTCPipeWireCapturer");
|
|
}
|
|
}
|
|
|
|
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 countdownOverlayWindow: BrowserWindow | null = null;
|
|
let tray: Tray | null = null;
|
|
let selectedSourceName = "";
|
|
const isMac = process.platform === "darwin";
|
|
const trayIconSize = isMac ? 16 : 24;
|
|
|
|
// Tray Icons
|
|
const defaultTrayIcon = getTrayIcon("openscreen.png", trayIconSize);
|
|
const recordingTrayIcon = getTrayIcon("rec-button.png", trayIconSize);
|
|
|
|
function createWindow() {
|
|
mainWindow = createHudOverlayWindow();
|
|
}
|
|
|
|
function showMainWindow() {
|
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
if (mainWindow.isMinimized()) {
|
|
mainWindow.restore();
|
|
}
|
|
mainWindow.show();
|
|
mainWindow.focus();
|
|
return;
|
|
}
|
|
|
|
createWindow();
|
|
}
|
|
|
|
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",
|
|
label: mainT("common", "actions.about") || "About OpenScreen",
|
|
},
|
|
{ type: "separator" },
|
|
{
|
|
role: "services",
|
|
label: mainT("common", "actions.services") || "Services",
|
|
},
|
|
{ type: "separator" },
|
|
{
|
|
role: "hide",
|
|
label: mainT("common", "actions.hide") || "Hide OpenScreen",
|
|
},
|
|
{
|
|
role: "hideOthers",
|
|
label: mainT("common", "actions.hideOthers") || "Hide Others",
|
|
},
|
|
{
|
|
role: "unhide",
|
|
label: mainT("common", "actions.unhide") || "Show All",
|
|
},
|
|
{ type: "separator" },
|
|
{ role: "quit", label: mainT("common", "actions.quit") || "Quit" },
|
|
],
|
|
});
|
|
}
|
|
|
|
template.push(
|
|
{
|
|
label: mainT("common", "actions.file") || "File",
|
|
submenu: [
|
|
{
|
|
label: mainT("dialogs", "unsavedChanges.loadProject") || "Load Project…",
|
|
accelerator: "CmdOrCtrl+O",
|
|
click: () => sendEditorMenuAction("menu-load-project"),
|
|
},
|
|
{
|
|
label: mainT("dialogs", "unsavedChanges.saveProject") || "Save Project…",
|
|
accelerator: "CmdOrCtrl+S",
|
|
click: () => sendEditorMenuAction("menu-save-project"),
|
|
},
|
|
{
|
|
label: mainT("dialogs", "unsavedChanges.saveProjectAs") || "Save Project As…",
|
|
accelerator: "CmdOrCtrl+Shift+S",
|
|
click: () => sendEditorMenuAction("menu-save-project-as"),
|
|
},
|
|
...(isMac
|
|
? []
|
|
: [
|
|
{ type: "separator" as const },
|
|
{
|
|
role: "quit" as const,
|
|
label: mainT("common", "actions.quit") || "Quit",
|
|
},
|
|
]),
|
|
],
|
|
},
|
|
{
|
|
label: mainT("common", "actions.edit") || "Edit",
|
|
submenu: [
|
|
{ role: "undo", label: mainT("common", "actions.undo") || "Undo" },
|
|
{ role: "redo", label: mainT("common", "actions.redo") || "Redo" },
|
|
{ type: "separator" },
|
|
{ role: "cut", label: mainT("common", "actions.cut") || "Cut" },
|
|
{ role: "copy", label: mainT("common", "actions.copy") || "Copy" },
|
|
{ role: "paste", label: mainT("common", "actions.paste") || "Paste" },
|
|
{
|
|
role: "selectAll",
|
|
label: mainT("common", "actions.selectAll") || "Select All",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
label: mainT("common", "actions.view") || "View",
|
|
submenu: [
|
|
{
|
|
role: "reload",
|
|
label: mainT("common", "actions.reload") || "Reload",
|
|
},
|
|
{
|
|
role: "forceReload",
|
|
label: mainT("common", "actions.forceReload") || "Force Reload",
|
|
},
|
|
{
|
|
role: "toggleDevTools",
|
|
label: mainT("common", "actions.toggleDevTools") || "Toggle Developer Tools",
|
|
},
|
|
{ type: "separator" },
|
|
{
|
|
role: "resetZoom",
|
|
label: mainT("common", "actions.actualSize") || "Actual Size",
|
|
},
|
|
{
|
|
role: "zoomIn",
|
|
label: mainT("common", "actions.zoomIn") || "Zoom In",
|
|
},
|
|
{
|
|
role: "zoomOut",
|
|
label: mainT("common", "actions.zoomOut") || "Zoom Out",
|
|
},
|
|
{ type: "separator" },
|
|
{
|
|
role: "togglefullscreen",
|
|
label: mainT("common", "actions.toggleFullScreen") || "Toggle Full Screen",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
label: mainT("common", "actions.window") || "Window",
|
|
submenu: isMac
|
|
? [
|
|
{
|
|
role: "minimize",
|
|
label: mainT("common", "actions.minimize") || "Minimize",
|
|
},
|
|
{ role: "zoom" },
|
|
{ type: "separator" },
|
|
{ role: "front" },
|
|
]
|
|
: [
|
|
{
|
|
role: "minimize",
|
|
label: mainT("common", "actions.minimize") || "Minimize",
|
|
},
|
|
{
|
|
role: "close",
|
|
label: mainT("common", "actions.close") || "Close",
|
|
},
|
|
],
|
|
},
|
|
);
|
|
|
|
const menu = Menu.buildFromTemplate(template);
|
|
Menu.setApplicationMenu(menu);
|
|
}
|
|
|
|
function createTray() {
|
|
tray = new Tray(defaultTrayIcon);
|
|
tray.on("click", () => {
|
|
showMainWindow();
|
|
});
|
|
tray.on("double-click", () => {
|
|
showMainWindow();
|
|
});
|
|
}
|
|
|
|
function getTrayIcon(filename: string, size: number) {
|
|
return nativeImage
|
|
.createFromPath(path.join(process.env.VITE_PUBLIC || RENDERER_DIST, filename))
|
|
.resize({
|
|
width: size,
|
|
height: size,
|
|
quality: "best",
|
|
});
|
|
}
|
|
|
|
function updateTrayMenu(recording: boolean = false) {
|
|
if (!tray) return;
|
|
const trayIcon = recording ? recordingTrayIcon : defaultTrayIcon;
|
|
const trayToolTip = recording
|
|
? mainT("common", "actions.recordingStatus", {
|
|
source: selectedSourceName,
|
|
}) || `Recording: ${selectedSourceName}`
|
|
: "OpenScreen";
|
|
const menuTemplate = recording
|
|
? [
|
|
{
|
|
label: mainT("common", "actions.stopRecording") || "Stop Recording",
|
|
click: () => {
|
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
mainWindow.webContents.send("stop-recording-from-tray");
|
|
}
|
|
},
|
|
},
|
|
]
|
|
: [
|
|
{
|
|
label: mainT("common", "actions.open") || "Open",
|
|
click: () => {
|
|
showMainWindow();
|
|
},
|
|
},
|
|
{
|
|
label: mainT("common", "actions.quit") || "Quit",
|
|
click: () => {
|
|
app.quit();
|
|
},
|
|
},
|
|
];
|
|
tray.setImage(trayIcon);
|
|
tray.setToolTip(trayToolTip);
|
|
tray.setContextMenu(Menu.buildFromTemplate(menuTemplate));
|
|
}
|
|
|
|
let editorHasUnsavedChanges = false;
|
|
let isForceClosing = false;
|
|
let isCloseConfirmInFlight = false;
|
|
|
|
ipcMain.on("set-has-unsaved-changes", (_, hasChanges: boolean) => {
|
|
editorHasUnsavedChanges = hasChanges;
|
|
});
|
|
|
|
function forceCloseEditorWindow(windowToClose: BrowserWindow | null) {
|
|
if (!windowToClose || windowToClose.isDestroyed()) return;
|
|
|
|
isForceClosing = true;
|
|
setImmediate(() => {
|
|
try {
|
|
if (!windowToClose.isDestroyed()) {
|
|
windowToClose.close();
|
|
}
|
|
} finally {
|
|
isForceClosing = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
function createEditorWindowWrapper() {
|
|
if (mainWindow) {
|
|
isForceClosing = true;
|
|
mainWindow.close();
|
|
isForceClosing = false;
|
|
mainWindow = null;
|
|
}
|
|
mainWindow = createEditorWindow();
|
|
editorHasUnsavedChanges = false;
|
|
|
|
mainWindow.on("close", (event) => {
|
|
if (isForceClosing || !editorHasUnsavedChanges || isCloseConfirmInFlight) return;
|
|
|
|
event.preventDefault();
|
|
isCloseConfirmInFlight = true;
|
|
|
|
const windowToClose = mainWindow;
|
|
if (!windowToClose || windowToClose.isDestroyed()) return;
|
|
|
|
// Ask renderer to show the custom in-app dialog
|
|
windowToClose.webContents.send("request-close-confirm");
|
|
|
|
ipcMain.once("close-confirm-response", (event, choice: "save" | "discard" | "cancel") => {
|
|
if (event.sender.id !== windowToClose?.webContents.id) return;
|
|
isCloseConfirmInFlight = false;
|
|
if (!windowToClose || windowToClose.isDestroyed()) return;
|
|
|
|
if (choice === "save") {
|
|
// Tell renderer to save the project, then close when done
|
|
windowToClose.webContents.send("request-save-before-close");
|
|
ipcMain.once("save-before-close-done", (event, shouldClose: boolean) => {
|
|
if (event.sender.id !== windowToClose?.webContents.id) return;
|
|
if (!shouldClose) return;
|
|
forceCloseEditorWindow(windowToClose);
|
|
});
|
|
} else if (choice === "discard") {
|
|
forceCloseEditorWindow(windowToClose);
|
|
}
|
|
// "cancel": flag reset, window stays open
|
|
});
|
|
});
|
|
}
|
|
|
|
function createSourceSelectorWindowWrapper() {
|
|
sourceSelectorWindow = createSourceSelectorWindow();
|
|
sourceSelectorWindow.on("closed", () => {
|
|
sourceSelectorWindow = null;
|
|
});
|
|
return sourceSelectorWindow;
|
|
}
|
|
|
|
function createCountdownOverlayWindowWrapper() {
|
|
if (countdownOverlayWindow && !countdownOverlayWindow.isDestroyed()) {
|
|
return countdownOverlayWindow;
|
|
}
|
|
|
|
countdownOverlayWindow = createCountdownOverlayWindow();
|
|
countdownOverlayWindow.on("closed", () => {
|
|
countdownOverlayWindow = null;
|
|
});
|
|
return countdownOverlayWindow;
|
|
}
|
|
|
|
// Closing every window quits the app entirely (tray icon goes too).
|
|
// The in-app "Return to Recorder" button covers the editor → HUD round-trip,
|
|
// so closing the last window is an explicit "I'm done" signal.
|
|
app.on("window-all-closed", () => {
|
|
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.
|
|
const hasVisibleWindow = BrowserWindow.getAllWindows().some((window) => {
|
|
if (window.isDestroyed() || !window.isVisible()) {
|
|
return false;
|
|
}
|
|
|
|
const url = window.webContents.getURL();
|
|
const isCountdownOverlayWindow = url.includes("windowType=countdown-overlay");
|
|
return !isCountdownOverlayWindow;
|
|
});
|
|
if (!hasVisibleWindow) {
|
|
showMainWindow();
|
|
}
|
|
});
|
|
|
|
// Register all IPC handlers when app is ready
|
|
app.whenReady().then(async () => {
|
|
// Force the app into "regular" activation policy so the Dock icon appears.
|
|
// The HUD overlay (transparent + frameless + skipTaskbar) is the first
|
|
// window we open, and AppKit otherwise classifies us as an accessory app.
|
|
if (process.platform === "darwin") {
|
|
app.dock?.show();
|
|
}
|
|
|
|
// Allow microphone/media/screen permission checks
|
|
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
|
|
const allowed = [
|
|
"media",
|
|
"audioCapture",
|
|
"microphone",
|
|
"videoCapture",
|
|
"camera",
|
|
"screen",
|
|
"display-capture",
|
|
];
|
|
return allowed.includes(permission);
|
|
});
|
|
|
|
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
|
|
const allowed = [
|
|
"media",
|
|
"audioCapture",
|
|
"microphone",
|
|
"videoCapture",
|
|
"camera",
|
|
"screen",
|
|
"display-capture",
|
|
];
|
|
callback(allowed.includes(permission));
|
|
});
|
|
|
|
session.defaultSession.setDisplayMediaRequestHandler(
|
|
(request, callback) => {
|
|
const source = getSelectedDesktopSource();
|
|
if (!request.videoRequested || !source) {
|
|
callback({});
|
|
return;
|
|
}
|
|
|
|
callback({
|
|
video: source,
|
|
...(request.audioRequested && process.platform === "win32" ? { audio: "loopback" } : {}),
|
|
});
|
|
},
|
|
{ useSystemPicker: false },
|
|
);
|
|
|
|
// Request microphone permission from macOS. Screen Recording is requested
|
|
// lazily from the source-picker action so the system prompt is not hidden
|
|
// behind OpenScreen's source selector window.
|
|
if (process.platform === "darwin") {
|
|
const micStatus = systemPreferences.getMediaAccessStatus("microphone");
|
|
if (micStatus !== "granted") {
|
|
await systemPreferences.askForMediaAccess("microphone");
|
|
}
|
|
}
|
|
|
|
// Listen for HUD overlay quit event (macOS only)
|
|
ipcMain.on("hud-overlay-close", () => {
|
|
app.quit();
|
|
});
|
|
ipcMain.handle("set-locale", (_, locale: string) => {
|
|
setMainLocale(locale);
|
|
setupApplicationMenu();
|
|
updateTrayMenu();
|
|
});
|
|
|
|
createTray();
|
|
updateTrayMenu();
|
|
setupApplicationMenu();
|
|
// Ensure recordings directory exists
|
|
await ensureRecordingsDir();
|
|
|
|
function switchToHudWrapper() {
|
|
if (mainWindow) {
|
|
isForceClosing = true;
|
|
mainWindow.close();
|
|
isForceClosing = false;
|
|
mainWindow = null;
|
|
}
|
|
showMainWindow();
|
|
}
|
|
|
|
registerIpcHandlers(
|
|
createEditorWindowWrapper,
|
|
createSourceSelectorWindowWrapper,
|
|
createCountdownOverlayWindowWrapper,
|
|
() => mainWindow,
|
|
() => sourceSelectorWindow,
|
|
() => countdownOverlayWindow,
|
|
(recording: boolean, sourceName: string) => {
|
|
selectedSourceName = sourceName;
|
|
if (!tray) createTray();
|
|
updateTrayMenu(recording);
|
|
if (!recording) {
|
|
showMainWindow();
|
|
}
|
|
},
|
|
switchToHudWrapper,
|
|
);
|
|
createWindow();
|
|
});
|