import { ipcMain, BrowserWindow, app, shell } from 'electron'; import * as os from 'os'; import * as path from 'path'; import * as fs from 'fs'; import { exec, spawn } from 'child_process'; import axios from 'axios'; import { expandPath } from './file_utils'; // Helper to execute shell commands const runExec = (cmd: string): Promise<{ success: boolean; stdout?: string; stderr?: string; error?: string }> => { return new Promise((resolve) => { exec(cmd, (error, stdout, stderr) => { resolve({ success: !error, stdout: stdout.trim(), stderr: stderr.trim(), error: error ? error.message : undefined }); }); }); }; let recordingProcess: any = null; export function registerSystemHandlers() { // 1. Window Control ipcMain.handle('native:window-control', async (event, { action, value }) => { const win = BrowserWindow.fromWebContents(event.sender); if (!win) return { success: false, error: 'No window found' }; switch (action) { case 'maximize': win.maximize(); break; case 'unmaximize': win.unmaximize(); break; case 'minimize': win.minimize(); break; case 'restore': win.restore(); break; case 'close': win.close(); break; case 'devtools': if (value) win.webContents.openDevTools(); else win.webContents.closeDevTools(); break; case 'kiosk': win.setKiosk(!!value); break; case 'fullscreen': win.setFullScreen(!!value); break; case 'reload': win.reload(); break; default: return { success: false, error: `Unknown action: ${action}` }; } return { success: true }; }); // 2. Set Wallpaper ipcMain.handle('native:set-wallpaper', async (event, { path: imagePath }) => { const cleanPath = expandPath(imagePath); if (!fs.existsSync(cleanPath)) return { success: false, error: 'Image file not found' }; if (os.platform() === 'darwin') { const script = `tell application "System Events" to set picture of every desktop to "${cleanPath}"`; return await runExec(`osascript -e '${script}'`); } else if (os.platform() === 'linux') { // Gnome/Ubuntu default return await runExec(`gsettings set org.gnome.desktop.background picture-uri "file://${cleanPath}"`); } return { success: false, error: 'Platform not supported' }; }); // 3. Power Control ipcMain.handle('native:power-control', async (event, { action }) => { let cmd = ''; const isMac = os.platform() === 'darwin'; const isLinux = os.platform() === 'linux'; if (action === 'shutdown') { if (isMac) cmd = 'shutdown -h now'; // Requires sudo/admin usually if (isLinux) cmd = 'shutdown -h now'; } else if (action === 'reboot') { if (isMac) cmd = 'shutdown -r now'; if (isLinux) cmd = 'reboot'; } else if (action === 'sleep') { if (isMac) cmd = 'pmset sleepnow'; if (isLinux) cmd = 'systemctl suspend'; } if (!cmd) return { success: false, error: 'Action not supported' }; // NOTE: These commands often require root. // For a kiosk, you might configure sudoers to allow this specific command without password. return await runExec(cmd); }); // 4. Open External (Browser) ipcMain.handle('native:open-external', async (event, { url, app: appName }) => { if (appName === 'chrome') { if (os.platform() === 'darwin') { return await runExec(`open -a "Google Chrome" "${url}"`); } else if (os.platform() === 'linux') { return await runExec(`google-chrome "${url}"`); } } else if (appName === 'firefox') { if (os.platform() === 'darwin') { return await runExec(`open -a "Firefox" "${url}"`); } else if (os.platform() === 'linux') { return await runExec(`firefox "${url}"`); } } // Default system handler await shell.openExternal(url); return { success: true }; }); // 5. Manage Recording (Aperture Wrapper) ipcMain.handle('native:manage-recording', async (event, { action, options }) => { if (os.platform() !== 'darwin') return { success: false, error: 'Recording only supported on macOS' }; // Path to bundled aperture binary // In dev: ./resources/bin/aperture // In prod: process.resourcesPath/bin/aperture const binPath = app.isPackaged ? path.join(process.resourcesPath, 'bin', 'aperture') : path.join(__dirname, '../../resources/bin/aperture'); // Adjust based on structure if (action === 'start') { if (recordingProcess) return { success: false, error: 'Recording already in progress' }; const { fps = 30, audioDeviceId, output } = options || {}; const cleanOutput = expandPath(output || '~/tmp/recording.mp4'); const args = ['run', '--fps', fps, '--output', cleanOutput]; if (audioDeviceId) args.push('--audio-device-id', audioDeviceId); // Spawn process // Note: aperture is a CLI tool. We might need 'aperture' node package or the binary. // Assuming binary usage here. try { console.log(`Starting recording: ${binPath} ${args.join(' ')}`); recordingProcess = spawn(binPath, args); recordingProcess.on('error', (err: any) => { console.error('Recording error:', err); recordingProcess = null; }); recordingProcess.on('exit', (code: any) => { console.log(`Recording exited with code ${code}`); recordingProcess = null; }); return { success: true, pid: recordingProcess.pid }; } catch (e: any) { return { success: false, error: e.message }; } } else if (action === 'stop') { if (!recordingProcess) return { success: false, error: 'No recording in progress' }; recordingProcess.kill('SIGINT'); // Send interrupt to stop cleanly recordingProcess = null; return { success: true }; } else if (action === 'status') { return { success: true, isRecording: !!recordingProcess }; } return { success: false, error: 'Unknown action' }; }); // 6. Set Display Layout (Displayplacer) ipcMain.handle('native:set-display-layout', async (event, { mode, configStr }) => { if (os.platform() !== 'darwin') return { success: false, error: 'Display control only supported on macOS' }; const binPath = app.isPackaged ? path.join(process.resourcesPath, 'bin', 'displayplacer') : path.join(__dirname, '../../resources/bin/displayplacer'); let cmd = ''; if (mode === 'mirror') { // This usually requires querying current IDs, which is complex. // If configStr is provided (output of 'displayplacer list'), use it. if (configStr) { cmd = `"${binPath}" ${configStr}`; } else { return { success: false, error: 'Config string required for now' }; } } else if (mode === 'extend') { if (configStr) { cmd = `"${binPath}" ${configStr}`; } } if (cmd) { return await runExec(cmd); } return { success: false, error: 'Invalid mode or missing config' }; }); // 7. Update App ipcMain.handle('native:update-app', async (event, { source, url, path: localPath }) => { // 1. Determine Source File let updateFile = ''; const tempDir = os.tmpdir(); const destName = 'update_package.zip'; // Or .app, .AppImage const destPath = path.join(tempDir, destName); if (source === 'url' && url) { // Download try { const response = await axios({ method: 'get', url: url, responseType: 'stream' }); const writer = fs.createWriteStream(destPath); response.data.pipe(writer); await new Promise((resolve, reject) => { writer.on('finish', () => resolve(true)); writer.on('error', reject); }); updateFile = destPath; } catch (e: any) { return { success: false, error: `Download failed: ${e.message}` }; } } else if (source === 'file' && localPath) { const cleanLocal = expandPath(localPath); if (fs.existsSync(cleanLocal)) { updateFile = cleanLocal; } else { return { success: false, error: 'Local update file not found' }; } } if (!updateFile) return { success: false, error: 'No update source provided' }; // 2. Install Logic (Stub) // Real implementation depends on OS and packaging format. // macOS: Mount DMG, copy .app to /Applications? Or Unzip .app? // Linux: chmod +x AppImage and move? console.log(`Ready to install update from: ${updateFile}`); // For now, just return success so the UI knows we "downloaded" it. return { success: true, message: 'Update downloaded/located. Installation logic requires packaging specifics.', downloadedPath: updateFile }; }); }