Files
OSIT-AE-App-Native-Electron/src/main/system_handlers.ts
2026-05-13 18:57:27 -04:00

402 lines
15 KiB
TypeScript

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
// Supports local path OR URL download. URL images are saved to a stable cache dir
// so macOS can reference them persistently after reboot.
// display: 'all' (default) | 'primary' (built-in) | 'external' (projector/second screen)
// url_external: optional second URL for the external display only.
ipcMain.handle('native:set-wallpaper', async (event, {
path: imagePath,
url,
url_external,
display = 'all',
api_key,
account_id
}: {
path?: string;
url?: string;
url_external?: string;
display?: 'all' | 'primary' | 'external';
api_key?: string;
account_id?: string;
}) => {
// Cache dir: ~/Library/Caches/OSIT/wallpaper on macOS, ~/.cache/osit/wallpaper on Linux.
// Using a stable path means macOS keeps the reference across reboots.
const wallpaper_cache_dir = os.platform() === 'darwin'
? path.join(os.homedir(), 'Library', 'Caches', 'OSIT', 'wallpaper')
: path.join(os.homedir(), '.cache', 'osit', 'wallpaper');
async function download_wallpaper_image(image_url: string, basename: string): Promise<{ success: boolean; path?: string; error?: string }> {
if (!fs.existsSync(wallpaper_cache_dir)) {
fs.mkdirSync(wallpaper_cache_dir, { recursive: true });
}
// Infer extension from URL path, fall back to .jpg
let ext = '.jpg';
try {
const url_path = new URL(image_url).pathname;
const inferred = path.extname(url_path).toLowerCase();
if (['.jpg', '.jpeg', '.png', '.webp'].includes(inferred)) {
ext = inferred === '.jpeg' ? '.jpg' : inferred;
}
} catch {}
const dest_path = path.join(wallpaper_cache_dir, basename + ext);
const headers: Record<string, string> = {};
if (api_key) headers['x-aether-api-key'] = api_key;
if (account_id) headers['x-account-id'] = account_id;
try {
const response = await axios({ method: 'get', url: image_url, responseType: 'stream', headers });
const content_type = (response.headers['content-type'] ?? '') as string;
if (!content_type.startsWith('image/')) {
response.data.destroy();
return { success: false, error: `URL did not return an image (Content-Type: ${content_type})` };
}
const writer = fs.createWriteStream(dest_path);
response.data.pipe(writer);
await new Promise<void>((resolve, reject) => {
writer.on('finish', resolve);
writer.on('error', reject);
});
const file_size = fs.statSync(dest_path).size;
if (file_size === 0) {
try { fs.unlinkSync(dest_path); } catch {}
return { success: false, error: 'Wallpaper download incomplete (0 bytes)' };
}
return { success: true, path: dest_path };
} catch (e: any) {
return { success: false, error: `Wallpaper download failed: ${e.message}` };
}
}
// HARDENED: write AppleScript to a temp file, same pattern as native:run-osascript.
// The old osascript -e approach breaks on paths with spaces or special characters.
async function apply_mac_wallpaper(img_path: string, display_target: 'all' | 'primary' | 'external'): Promise<{ success: boolean; stdout?: string; stderr?: string; error?: string }> {
const escaped = img_path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
let script: string;
if (display_target === 'primary') {
script = `tell application "System Events"\n\ttell desktop 1\n\t\tset picture to "${escaped}"\n\tend tell\nend tell`;
} else if (display_target === 'external') {
script = `tell application "System Events"\n\ttell desktop 2\n\t\tset picture to "${escaped}"\n\tend tell\nend tell`;
} else {
script = `tell application "System Events"\n\ttell every desktop\n\t\tset picture to "${escaped}"\n\tend tell\nend tell`;
}
const script_path = path.join(os.tmpdir(), `ae_wallpaper_${Date.now()}.scpt`);
fs.writeFileSync(script_path, script, 'utf-8');
try {
return await runExec(`osascript "${script_path}"`);
} finally {
try { fs.unlinkSync(script_path); } catch {}
}
}
if (os.platform() === 'darwin') {
// Resolve primary image path
let primary_path: string | null = null;
if (imagePath) {
const clean = expandPath(imagePath);
if (!fs.existsSync(clean)) return { success: false, error: 'Image file not found' };
primary_path = clean;
} else if (url) {
const result = await download_wallpaper_image(url, 'wallpaper_primary');
if (!result.success || !result.path) return { success: false, error: result.error };
primary_path = result.path;
}
if (!primary_path && url_external && display === 'external') {
const ext_result = await download_wallpaper_image(url_external, 'wallpaper_external');
if (!ext_result.success || !ext_result.path) return { success: false, error: ext_result.error };
return await apply_mac_wallpaper(ext_result.path, 'external');
}
if (!primary_path) return { success: false, error: 'No image source provided' };
if (url_external) {
// Different images for each display: set primary display first, then external
const primary_result = await apply_mac_wallpaper(primary_path, 'primary');
if (!primary_result.success) return primary_result;
const ext_result = await download_wallpaper_image(url_external, 'wallpaper_external');
if (!ext_result.success || !ext_result.path) return { success: false, error: ext_result.error };
return await apply_mac_wallpaper(ext_result.path, 'external');
} else {
return await apply_mac_wallpaper(primary_path, display);
}
}
if (os.platform() === 'linux') {
// Dev test mode: never touch the desktop on Linux. Running gsettings during
// development would reset the dev workstation monitors on every test cycle.
// Return what would have run so the Svelte side can show a debug popup.
const would_run: string[] = [];
const cache_dir = wallpaper_cache_dir;
if (!imagePath && !url && !url_external) {
return { success: false, error: 'No image source provided' };
}
if (imagePath) {
const clean = expandPath(imagePath);
if (!fs.existsSync(clean)) return { success: false, error: 'Image file not found' };
}
if (url) would_run.push(`download: ${url}\n → ${path.join(cache_dir, 'wallpaper_primary.jpg')}`);
if (url_external) would_run.push(`download: ${url_external}\n → ${path.join(cache_dir, 'wallpaper_external.jpg')}`);
if (imagePath) {
would_run.push(`gsettings set org.gnome.desktop.background picture-uri "file://${expandPath(imagePath)}"`);
} else if (url) {
would_run.push(`gsettings set org.gnome.desktop.background picture-uri "file://${path.join(cache_dir, 'wallpaper_primary.jpg')}"`);
}
if (url_external) {
would_run.push(`(external display: gsettings has no per-display wallpaper support)`);
}
return {
success: true,
linux_test_mode: true,
platform: 'linux',
display,
url: url ?? null,
url_external: url_external ?? null,
would_run
};
}
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 };
});
}