feat: implement Phase 5 system handlers (automation, power, recording)
- Implement window control, wallpaper reset, and power management. - Add Aperture recording wrapper and displayplacer layout control. - Add self-update logic stub for local/remote sources. - Register and expose handlers via context bridge.
This commit is contained in:
@@ -5,6 +5,7 @@ import { loadSeedConfig } from './config_loader';
|
||||
import { fetchFullConfig } from './api_client';
|
||||
import { registerShellHandlers } from './shell_handlers';
|
||||
import { registerFileHandlers } from './file_handlers';
|
||||
import { registerSystemHandlers } from './system_handlers';
|
||||
import { SeedConfig } from '../shared/types';
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
@@ -48,6 +49,8 @@ async function createWindow() {
|
||||
|
||||
registerShellHandlers();
|
||||
registerFileHandlers();
|
||||
registerSystemHandlers();
|
||||
|
||||
app.on('ready', createWindow);
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
|
||||
246
src/main/system_handlers.ts
Normal file
246
src/main/system_handlers.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
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 };
|
||||
});
|
||||
}
|
||||
@@ -19,4 +19,13 @@ contextBridge.exposeInMainWorld('aetherNative', {
|
||||
launch_presentation: (args: any) => ipcRenderer.invoke('native:launch-presentation', args),
|
||||
control_presentation: (args: any) => ipcRenderer.invoke('native:control-presentation', args),
|
||||
list_tools: () => ipcRenderer.invoke('native:list-tools'),
|
||||
|
||||
// System Handlers (V5)
|
||||
set_wallpaper: (args: any) => ipcRenderer.invoke('native:set-wallpaper', args),
|
||||
update_app: (args: any) => ipcRenderer.invoke('native:update-app', args),
|
||||
window_control: (args: any) => ipcRenderer.invoke('native:window-control', args),
|
||||
manage_recording: (args: any) => ipcRenderer.invoke('native:manage-recording', args),
|
||||
set_display_layout: (args: any) => ipcRenderer.invoke('native:set-display-layout', args),
|
||||
power_control: (args: any) => ipcRenderer.invoke('native:power-control', args),
|
||||
open_external: (args: any) => ipcRenderer.invoke('native:open-external', args),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user