Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 30 additions & 27 deletions electron-builder.json5
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,18 @@
"!CONTRIBUTING.md",
"!LICENSE"
],
"extraResources": [
{
"from": "public/wallpapers",
"to": "assets/wallpapers"
}
],
"publish": [{"provider": "github"}],

"mac": {
"extraResources": [
{
"from": "public/wallpapers",
"to": "assets/wallpapers"
}
],
"asarUnpack": [
"node_modules/gifsicle/**"
],
"publish": [{"provider": "github"}],

"mac": {
"hardenedRuntime": false,
"target": [
{
Expand All @@ -38,13 +41,13 @@
],
"icon": "icons/icons/mac/icon.icns",
"artifactName": "${productName}-Mac-${arch}-${version}-Installer.${ext}",
"extendInfo": {
"NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.",
"NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.",
"NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.",
"NSCameraUseContinuityCameraDeviceType": true,
"com.apple.security.device.audio-input": true
}
"extendInfo": {
"NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.",
"NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.",
"NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.",
"NSCameraUseContinuityCameraDeviceType": true,
"com.apple.security.device.audio-input": true
}
},
"linux": {
"target": [
Expand All @@ -54,14 +57,14 @@
"artifactName": "${productName}-Linux-${version}.${ext}",
"category": "AudioVideo"
},
"win": {
"target": [
"nsis"
],
"icon": "icons/icons/win/icon.ico"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true
}
}
"win": {
"target": [
"nsis"
],
"icon": "icons/icons/win/icon.ico"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true
}
}
12 changes: 12 additions & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,18 @@ interface Window {
hudOverlayClose: () => void;
setMicrophoneExpanded: (expanded: boolean) => void;
setHasUnsavedChanges: (hasChanges: boolean) => void;
setIsExporting: (isExporting: boolean) => void;
saveExportedVideoToPath: (
videoData: ArrayBuffer,
filePath: string,
) => Promise<{ success: boolean; path?: string; error?: string }>;
sendExportNotification: (
format: string,
filePath: string,
) => Promise<{ success: boolean; message?: string; error?: string }>;
onBackgroundExportReady: (callback: (downloadsDir: string) => void) => () => void;
onCancelExportAndClose: (callback: () => void) => () => void;
exportCancelledDone: () => void;
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => () => void;
setLocale: (locale: string) => Promise<void>;
};
Expand Down
111 changes: 110 additions & 1 deletion electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { promisify } from "node:util";
import {
app,
BrowserWindow,
desktopCapturer,
dialog,
ipcMain,
Notification,
screen,
shell,
systemPreferences,
Expand All @@ -20,6 +24,58 @@ import {
import { mainT } from "../i18n";
import { RECORDINGS_DIR } from "../main";

const execFileAsync = promisify(execFile);

/**
* Returns the path to the bundled gifsicle binary without importing the gifsicle
* package (which uses import.meta.url internally and breaks when bundled by Vite).
* In a packaged Electron app with asarUnpack the binary lives in app.asar.unpacked.
*/
function getGifsiclePath(): string {
const binaryName = process.platform === "win32" ? "gifsicle.exe" : "gifsicle";
const relPath = path.join("node_modules", "gifsicle", "vendor", binaryName);
if (app.isPackaged) {
// process.resourcesPath is e.g. /tmp/.mount_xxx/resources
return path.join(process.resourcesPath, "app.asar.unpacked", relPath);
}
return path.join(app.getAppPath(), relPath);
}

/**
* Run gifsicle -O3 on a GIF buffer and return the optimized buffer.
* Falls back to the original buffer if gifsicle fails.
*/
async function optimizeGifBuffer(inputBuffer: Buffer): Promise<Buffer> {
const tmpDir = os.tmpdir();
const tmpInput = path.join(tmpDir, `openscreen-gif-in-${Date.now()}.gif`);
const tmpOutput = path.join(tmpDir, `openscreen-gif-out-${Date.now()}.gif`);
try {
await fs.writeFile(tmpInput, inputBuffer);
const gifsiclePath = getGifsiclePath();
await execFileAsync(gifsiclePath, [
"-O3",
"--lossy=40",
"--colors",
"256",
tmpInput,
"-o",
tmpOutput,
]);
const optimized = await fs.readFile(tmpOutput);
return optimized;
} catch (err) {
console.warn("[optimize-gif] gifsicle failed, using original:", err);
return inputBuffer;
} finally {
await fs.unlink(tmpInput).catch(() => {
// ignore missing tmp file
});
await fs.unlink(tmpOutput).catch(() => {
// ignore missing tmp file
});
}
}

const PROJECT_FILE_EXTENSION = "openscreen";
const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json");
const RECORDING_SESSION_SUFFIX = ".session.json";
Expand Down Expand Up @@ -493,7 +549,11 @@ export function registerIpcHandlers(
};
}

await fs.writeFile(result.filePath, Buffer.from(videoData));
let dataToWrite: Buffer = Buffer.from(videoData as ArrayBuffer);
if (isGif) {
dataToWrite = await optimizeGifBuffer(dataToWrite);
}
await fs.writeFile(result.filePath, dataToWrite);

return {
success: true,
Expand All @@ -510,6 +570,55 @@ export function registerIpcHandlers(
}
});

ipcMain.handle(
"save-exported-video-to-path",
async (_, videoData: ArrayBuffer, filePath: string) => {
try {
// Restrict writes to the user's Downloads folder to prevent path traversal.
const downloadsRoot = path.resolve(app.getPath("downloads"));
const safeFileName = path.basename(filePath).replace(/[/\\]/g, "");
const destination = path.resolve(path.join(downloadsRoot, safeFileName));
if (!destination.startsWith(downloadsRoot + path.sep) && destination !== downloadsRoot) {
return { success: false, error: "Invalid path: destination is outside Downloads folder" };
}
const isGif = safeFileName.toLowerCase().endsWith(".gif");
let dataToWrite: Buffer = Buffer.from(videoData as ArrayBuffer);
if (isGif) {
dataToWrite = await optimizeGifBuffer(dataToWrite);
}
await fs.writeFile(destination, dataToWrite);
return { success: true, path: destination };
} catch (error) {
console.error("Failed to save exported video to path:", error);
return { success: false, error: String(error) };
}
},
);

ipcMain.handle("send-export-notification", async (_, format: string, filePath: string) => {
try {
if (!Notification.isSupported()) {
return { success: false, message: "Notifications not supported" };
}
const fileName = path.basename(filePath);
const notification = new Notification({
title: mainT("dialogs", "exportInBackground.completeNotificationTitle"),
body: mainT("dialogs", "exportInBackground.completeNotificationBody", {
format,
filename: fileName,
}),
});
notification.on("click", () => {
shell.showItemInFolder(filePath);
});
notification.show();
return { success: true };
} catch (error) {
console.error("Failed to send export notification:", error);
return { success: false, error: String(error) };
}
});

ipcMain.handle("open-video-file-picker", async () => {
try {
const result = await dialog.showOpenDialog({
Expand Down
56 changes: 55 additions & 1 deletion electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,12 +244,17 @@ function updateTrayMenu(recording: boolean = false) {
}

let editorHasUnsavedChanges = false;
let editorIsExporting = false;
let isForceClosing = false;

ipcMain.on("set-has-unsaved-changes", (_, hasChanges: boolean) => {
editorHasUnsavedChanges = hasChanges;
});

ipcMain.on("set-is-exporting", (_, isExporting: boolean) => {
editorIsExporting = isExporting;
});

function forceCloseEditorWindow(windowToClose: BrowserWindow | null) {
if (!windowToClose || windowToClose.isDestroyed()) return;

Expand All @@ -276,7 +281,56 @@ function createEditorWindowWrapper() {
editorHasUnsavedChanges = false;

mainWindow.on("close", (event) => {
if (isForceClosing || !editorHasUnsavedChanges) return;
if (isForceClosing) return;

// If an export is running, offer background export instead of closing
if (editorIsExporting) {
event.preventDefault();

const choice = dialog.showMessageBoxSync(mainWindow!, {
type: "info",
buttons: [
mainT("dialogs", "exportInBackground.continueInBackground"),
mainT("dialogs", "exportInBackground.cancelAndClose"),
mainT("common", "actions.cancel"),
],
defaultId: 0,
cancelId: 2,
title: mainT("dialogs", "exportInBackground.title"),
message: mainT("dialogs", "exportInBackground.message"),
detail: mainT("dialogs", "exportInBackground.detail"),
});

const win = mainWindow;
if (!win || win.isDestroyed()) return;

if (choice === 0) {
// Continue in Background: hide the window and let renderer know
win.hide();
win.webContents.send("background-export-ready", app.getPath("downloads"));
} else if (choice === 1) {
// Cancel Export & Close: ask the renderer to cancel, then re-trigger
// the normal close so the unsaved-changes guard can run.
win.webContents.send("cancel-export-and-close");
const onCancelled = () => {
clearTimeout(fallback);
// editorIsExporting is now false (renderer sent set-is-exporting false
// before ACKing), so the next close will fall through to the
// unsaved-changes check rather than looping back here.
win.close();
};
const fallback = setTimeout(() => {
ipcMain.removeListener("export-cancelled-done", onCancelled);
// Renderer never responded — force close as last resort.
forceCloseEditorWindow(win);
}, 5000);
ipcMain.once("export-cancelled-done", onCancelled);
}
// choice === 2: Keep Open — do nothing
return;
}

if (!editorHasUnsavedChanges) return;

event.preventDefault();

Expand Down
22 changes: 22 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,28 @@ contextBridge.exposeInMainWorld("electronAPI", {
setHasUnsavedChanges: (hasChanges: boolean) => {
ipcRenderer.send("set-has-unsaved-changes", hasChanges);
},
setIsExporting: (isExporting: boolean) => {
ipcRenderer.send("set-is-exporting", isExporting);
},
saveExportedVideoToPath: (videoData: ArrayBuffer, filePath: string) => {
return ipcRenderer.invoke("save-exported-video-to-path", videoData, filePath);
},
sendExportNotification: (format: string, filePath: string) => {
return ipcRenderer.invoke("send-export-notification", format, filePath);
},
onBackgroundExportReady: (callback: (downloadsDir: string) => void) => {
const listener = (_: Electron.IpcRendererEvent, downloadsDir: string) => callback(downloadsDir);
ipcRenderer.on("background-export-ready", listener);
return () => ipcRenderer.removeListener("background-export-ready", listener);
},
onCancelExportAndClose: (callback: () => void) => {
const listener = () => callback();
ipcRenderer.on("cancel-export-and-close", listener);
return () => ipcRenderer.removeListener("cancel-export-and-close", listener);
},
exportCancelledDone: () => {
ipcRenderer.send("export-cancelled-done");
},
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => {
const listener = async () => {
try {
Expand Down
Loading