import { app, clipboard, desktopCapturer, dialog, ipcMain, shell } from 'electron'; import * as fs from 'fs'; import * as fsp from 'fs/promises'; import * as path from 'path'; import { fileURLToPath } from 'url'; import { getDesktopSettingsSnapshot, updateDesktopSettings, type DesktopSettings } from '../desktop-settings'; import { activateLinuxScreenShareAudioRouting, deactivateLinuxScreenShareAudioRouting, prepareLinuxScreenShareAudioRouting, startLinuxScreenShareMonitorCapture, stopLinuxScreenShareMonitorCapture } from '../audio/linux-screen-share-routing'; import { checkForDesktopUpdates, configureDesktopUpdaterContext, getDesktopUpdateState, handleDesktopSettingsChanged, restartToApplyUpdate, readDesktopUpdateServerHealth, type DesktopUpdateServerContext } from '../update/desktop-updater'; import { consumePendingDeepLink } from '../app/deep-links'; import { synchronizeAutoStartSetting } from '../app/auto-start'; const DEFAULT_MIME_TYPE = 'application/octet-stream'; const FILE_CLIPBOARD_FORMATS = [ 'x-special/gnome-copied-files', 'text/uri-list', 'public.file-url', 'FileNameW' ] as const; const MIME_TYPES_BY_EXTENSION: Record = { '.7z': 'application/x-7z-compressed', '.aac': 'audio/aac', '.avi': 'video/x-msvideo', '.bmp': 'image/bmp', '.csv': 'text/csv', '.doc': 'application/msword', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', '.gif': 'image/gif', '.gz': 'application/gzip', '.heic': 'image/heic', '.jpeg': 'image/jpeg', '.jpg': 'image/jpeg', '.json': 'application/json', '.m4a': 'audio/mp4', '.md': 'text/markdown', '.mov': 'video/quicktime', '.mp3': 'audio/mpeg', '.mp4': 'video/mp4', '.ogg': 'audio/ogg', '.pdf': 'application/pdf', '.png': 'image/png', '.ppt': 'application/vnd.ms-powerpoint', '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', '.rar': 'application/vnd.rar', '.svg': 'image/svg+xml', '.tar': 'application/x-tar', '.txt': 'text/plain', '.wav': 'audio/wav', '.webm': 'video/webm', '.webp': 'image/webp', '.xls': 'application/vnd.ms-excel', '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', '.xml': 'application/xml', '.zip': 'application/zip' }; interface ClipboardFilePayload { data: string; lastModified: number; mime: string; name: string; path?: string; } function resolveLinuxDisplayServer(): string { if (process.platform !== 'linux') { return 'N/A'; } const ozonePlatform = app.commandLine.getSwitchValue('ozone-platform') .trim() .toLowerCase(); if (ozonePlatform === 'wayland') { return 'Wayland'; } if (ozonePlatform === 'x11') { return 'X11'; } const ozonePlatformHint = app.commandLine.getSwitchValue('ozone-platform-hint') .trim() .toLowerCase(); if (ozonePlatformHint === 'wayland') { return 'Wayland'; } if (ozonePlatformHint === 'x11') { return 'X11'; } const sessionType = String(process.env['XDG_SESSION_TYPE'] || '').trim() .toLowerCase(); if (sessionType === 'wayland') { return 'Wayland'; } if (sessionType === 'x11') { return 'X11'; } if (String(process.env['WAYLAND_DISPLAY'] || '').trim().length > 0) { return 'Wayland'; } if (String(process.env['DISPLAY'] || '').trim().length > 0) { return 'X11'; } return 'Unknown (Linux)'; } function isSupportedClipboardFileFormat(format: string): boolean { return FILE_CLIPBOARD_FORMATS.some( (supportedFormat) => supportedFormat.toLowerCase() === format.toLowerCase() ); } function extractClipboardFilePaths(buffer: Buffer, format: string): string[] { const textVariants = new Set(); const utf8Text = buffer.toString('utf8').replace(/\0/g, '') .trim(); if (utf8Text) { textVariants.add(utf8Text); } if (format.toLowerCase() === 'filenamew') { const utf16Text = buffer.toString('utf16le').replace(/\0/g, '\n') .trim(); if (utf16Text) { textVariants.add(utf16Text); } } const filePaths = new Set(); for (const text of textVariants) { for (const filePath of parseClipboardFilePathText(text)) { filePaths.add(filePath); } } return [...filePaths]; } function parseClipboardFilePathText(text: string): string[] { return text .split(/\r?\n/) .map((line) => line.trim()) .filter( (line) => !!line && !line.startsWith('#') && line.toLowerCase() !== 'copy' && line.toLowerCase() !== 'cut' ) .flatMap((line) => { if (line.startsWith('file://')) { try { return [fileURLToPath(line)]; } catch { return []; } } return path.isAbsolute(line) ? [line] : []; }); } function inferMimeType(filePath: string): string { return MIME_TYPES_BY_EXTENSION[path.extname(filePath).toLowerCase()] ?? DEFAULT_MIME_TYPE; } async function readClipboardFiles(): Promise { const formats = clipboard.availableFormats(); const filePaths = new Set(); for (const format of formats) { if (!isSupportedClipboardFileFormat(format)) { continue; } try { const buffer = clipboard.readBuffer(format); for (const filePath of extractClipboardFilePaths(buffer, format)) { filePaths.add(filePath); } } catch { /* ignore unreadable clipboard formats */ } } const files: ClipboardFilePayload[] = []; for (const filePath of filePaths) { try { const stats = await fsp.stat(filePath); if (!stats.isFile()) { continue; } const data = await fsp.readFile(filePath); files.push({ data: data.toString('base64'), lastModified: Math.round(stats.mtimeMs), mime: inferMimeType(filePath), name: path.basename(filePath), path: filePath }); } catch { /* ignore files that are no longer readable */ } } return files; } export function setupSystemHandlers(): void { ipcMain.on('get-linux-display-server', (event) => { event.returnValue = resolveLinuxDisplayServer(); }); ipcMain.handle('open-external', async (_event, url: string) => { if (typeof url === 'string' && (url.startsWith('http://') || url.startsWith('https://'))) { await shell.openExternal(url); return true; } return false; }); ipcMain.handle('consume-pending-deep-link', () => consumePendingDeepLink()); ipcMain.handle('get-sources', async () => { try { const thumbnailSize = { width: 240, height: 150 }; const [screenSources, windowSources] = await Promise.all([ desktopCapturer.getSources({ types: ['screen'], thumbnailSize }), desktopCapturer.getSources({ types: ['window'], thumbnailSize, fetchWindowIcons: true }) ]); const sources = [...screenSources, ...windowSources]; const uniqueSources = new Map(sources.map((source) => [source.id, source])); return [...uniqueSources.values()].map((source) => ({ id: source.id, name: source.name, thumbnail: source.thumbnail.toDataURL() })); } catch { // desktopCapturer.getSources fails on Wayland; return empty so the // renderer falls through to getDisplayMedia with the system picker. return []; } }); ipcMain.handle('prepare-linux-screen-share-audio-routing', async () => { return await prepareLinuxScreenShareAudioRouting(); }); ipcMain.handle('activate-linux-screen-share-audio-routing', async () => { return await activateLinuxScreenShareAudioRouting(); }); ipcMain.handle('deactivate-linux-screen-share-audio-routing', async () => { return await deactivateLinuxScreenShareAudioRouting(); }); ipcMain.handle('start-linux-screen-share-monitor-capture', async (event) => { return await startLinuxScreenShareMonitorCapture(event.sender); }); ipcMain.handle('stop-linux-screen-share-monitor-capture', async (_event, captureId?: string) => { return await stopLinuxScreenShareMonitorCapture(captureId); }); ipcMain.handle('get-app-data-path', () => app.getPath('userData')); ipcMain.handle('get-desktop-settings', () => getDesktopSettingsSnapshot()); ipcMain.handle('get-auto-update-state', () => getDesktopUpdateState()); ipcMain.handle('get-auto-update-server-health', async (_event, serverUrl: string) => { return await readDesktopUpdateServerHealth(serverUrl); }); ipcMain.handle('configure-auto-update-context', async (_event, context: Partial) => { return await configureDesktopUpdaterContext(context); }); ipcMain.handle('check-for-app-updates', async () => { return await checkForDesktopUpdates(); }); ipcMain.handle('restart-to-apply-update', () => restartToApplyUpdate()); ipcMain.handle('set-desktop-settings', async (_event, patch: Partial) => { const snapshot = updateDesktopSettings(patch); await synchronizeAutoStartSetting(snapshot.autoStart); await handleDesktopSettingsChanged(); return snapshot; }); ipcMain.handle('relaunch-app', () => { app.relaunch(); app.exit(0); return true; }); ipcMain.handle('file-exists', async (_event, filePath: string) => { try { await fsp.access(filePath, fs.constants.F_OK); return true; } catch { return false; } }); ipcMain.handle('read-file', async (_event, filePath: string) => { const data = await fsp.readFile(filePath); return data.toString('base64'); }); ipcMain.handle('read-clipboard-files', async () => { return await readClipboardFiles(); }); ipcMain.handle('write-file', async (_event, filePath: string, base64Data: string) => { const buffer = Buffer.from(base64Data, 'base64'); await fsp.writeFile(filePath, buffer); return true; }); ipcMain.handle('delete-file', async (_event, filePath: string) => { try { await fsp.unlink(filePath); return true; } catch (error) { if ((error as { code?: string }).code === 'ENOENT') { return true; } throw error; } }); ipcMain.handle('save-file-as', async (_event, defaultFileName: string, base64Data: string) => { const result = await dialog.showSaveDialog({ defaultPath: defaultFileName }); if (result.canceled || !result.filePath) { return { saved: false, cancelled: true }; } const buffer = Buffer.from(base64Data, 'base64'); await fsp.writeFile(result.filePath, buffer); return { saved: true, cancelled: false }; }); ipcMain.handle('ensure-dir', async (_event, dirPath: string) => { await fsp.mkdir(dirPath, { recursive: true }); return true; }); }