From 36aed19169ace102406dee43fe8fe076f6ff7c0c Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Mon, 11 May 2026 13:40:05 -0400 Subject: [PATCH] feat(launcher): thin primitive architecture + run_osascript hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - file_handlers: add script_template param to native:launch-from-cache (AppleScript or shell: prefix; falls back to hardcoded defaults when null) - file_handlers: add native:copy-from-cache-to-temp as composable primitive (copies cached file to temp, returns path — caller handles launch logic) - shell_handlers: harden native:run-osascript with temp .scpt file approach (replaces -e flag; handles multi-line scripts and paths with special chars) - README: rewrite Native Bridge section — full method table, composable pattern example, configurable launch scripts note - deploy/devices.conf: update IPs for devices 03-06, uncomment entries --- README.md | 98 +++++++++++++++++++++++++++++++------- deploy/devices.conf | 8 ++-- src/main/file_handlers.ts | 69 +++++++++++++++++++++++++-- src/main/shell_handlers.ts | 30 +++++++++--- 4 files changed, 175 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index f7ba40d..87bdd10 100644 --- a/README.md +++ b/README.md @@ -194,35 +194,99 @@ explicitly coordinated across all devices. The bridge is exposed to the renderer via `contextBridge`. It can be accessed in the web UI via `window.aetherNative`. -### Core Methods +**Design principle:** The Electron app is a thin OS primitive layer. Business logic (which script +runs for which file type, how to sequence operations, etc.) belongs in the SvelteKit/Svelte side +where it can be changed without a rebuild and redeployment. Electron handlers should rarely need +to change. + +### File Cache | Method | Description | | --- | --- | -| `list_tools()` | Returns a JSON manifest of all available native functions. | -| `launch_presentation({path, app})` | Launches a presentation with auto-focus and slideshow start. | -| `control_presentation({app, action})` | Sends `next`, `prev`, `start`, or `stop` to active decks. | -| `open_folder(path)` | Opens a local directory in the OS file explorer. | -| `get_device_info()` | Returns hardware metadata (RAM, IPs, Hostname). | +| `check_cache({cache_root, hash, hash_prefix_length?, verify_hash?})` | Checks if a file exists in the hashed cache. `verify_hash: true` re-hashes the file to confirm integrity. | +| `download_to_cache({url, cache_root, hash, api_key, account_id, hash_prefix_length?})` | Streams a file from the API into the hashed cache. Verifies SHA-256 integrity before finalizing. | +| `copy_from_cache_to_temp({cache_root, hash, temp_root, filename, hash_prefix_length?})` | **Preferred primitive.** Copies cached file to temp dir with original filename. Returns `{ success, path }`. The Svelte caller decides what to do next. | +| `launch_from_cache({cache_root, hash, temp_root, filename, hash_prefix_length?, script_template?})` | Combines copy + launch. If `script_template` is provided, runs it (AppleScript or `shell:` prefixed command) instead of hardcoded extension logic. Falls back to built-in defaults when `null`. | + +### Shell & OS + +| Method | Description | +| --- | --- | +| `run_cmd({cmd, timeout?, return_stdout?})` | Async shell command execution. | +| `run_cmd_sync({cmd})` | Synchronous shell command execution. | +| `run_osascript(script)` | **Hardened.** Runs AppleScript via temp `.scpt` file — handles multi-line scripts and paths with spaces/special characters correctly. macOS only. | +| `open_folder(path)` | Opens a directory in Finder / system file manager. | +| `open_local_file_v2(path)` | Opens a file with its default OS application. | +| `open_external({url, app?})` | Opens a URL in Chrome, Firefox, or the default browser. | +| `kill_processes({process_name_li})` | Terminates processes by name. macOS/Linux: `pkill -f`. Windows: `taskkill /F`. | + +### Presentations (Phase 5 — legacy specialized handlers) + +| Method | Description | +| --- | --- | +| `launch_presentation({path, app?, os?})` | Platform-aware launcher. Resolves `[home]`/`[tmp]` placeholders. **Note:** uses legacy `-e` flag for AppleScript; prefer `copy_from_cache_to_temp` + `run_osascript` for new flows. | +| `control_presentation({app, action})` | Slide navigation (`next`/`prev`/`start`/`stop`) for PowerPoint or Keynote via AppleScript. macOS only. | + +### System Management (Phase 5) + +| Method | Description | +| --- | --- | +| `get_device_config()` | Returns hydrated device config injected at startup from `seed.json` + API. | +| `get_device_info()` | Returns OS metadata: platform, hostname, IPs, CPU count, free RAM, home/tmp paths. | +| `window_control({action, value?})` | Electron window: maximize, minimize, restore, close, fullscreen, kiosk, devtools, reload. | +| `set_wallpaper({path})` | Sets desktop wallpaper. macOS (AppleScript) + Linux (gsettings/Gnome). | +| `power_control({action})` | Shutdown, reboot, or sleep. macOS + Linux. Requires sudo for shutdown/reboot. | +| `set_display_layout({mode, configStr?})` | Mirror/extend displays via bundled `displayplacer` binary. macOS only. | +| `manage_recording({action, options?})` | Screen recording via bundled `aperture` binary. macOS only. | +| `update_app(args)` | **Stub.** Downloads update package but does not install. Not functional. | +| `list_tools()` | Returns a self-describing manifest of all available bridge functions. | + +### Example Usage (preferred composable pattern) -### Example Usage (UI Relay) ```typescript import * as native from '$lib/electron/electron_relay'; -// Launch a file from local cache -await native.launch_presentation({ - path: '[tmp]/my_deck.pptx', - app: 'powerpoint' +// Step 1: copy the cached file to temp and get the resolved path +const copy = await native.copy_from_cache_to_temp({ + cache_root: $ae_loc.local_file_cache_path, + hash: event_file_obj.hash_sha256, + temp_root: $ae_loc.host_file_temp_path, + filename: event_file_obj.filename }); +if (!copy.success) { /* handle error */ return; } -// Navigate slides -await native.control_presentation({ - app: 'powerpoint', - action: 'next' -}); +// Step 2: run whatever script/command you want with that path +// Option A — AppleScript (macOS): +await native.run_osascript(` + tell application "Microsoft PowerPoint" + activate + open (POSIX file "${copy.path}") + delay 3 + end tell +`); + +// Option B — shell command: +await native.run_cmd({ cmd: `open "${copy.path}"` }); + +// Option C — use a template from device config (data-driven, no rebuild needed): +const template = $ae_loc.native_device?.launch_scripts?.pptx; +if (template) { + const script = template.replace(/\{\{path\}\}/g, copy.path); + await native.run_osascript(script); +} ``` +### Configurable Launch Scripts (no rebuild needed) + +`launch_from_cache` and `launcher_file_cont.svelte` support per-extension script templates +stored in `event_device.data_json.launch_scripts`. Keys are lowercase extensions (`pptx`, `key`, +`pdf`, etc.); `default` is a catch-all. Templates use `{{path}}` as the file path placeholder. +AppleScript strings run via `run_osascript`; prefix with `shell:` for shell commands. + +See `documentation/PROJECT__AE_Events_Launcher_Native_integration.md` Section 8 for full details. + ## 🛠️ Development - **Preload:** Logic defined in `src/preload/index.ts`. -- **Handlers:** OS-level logic in `src/main/shell_handlers.ts` and `src/main/file_handlers.ts`. +- **Handlers:** OS-level logic in `src/main/shell_handlers.ts`, `src/main/file_handlers.ts`, and `src/main/system_handlers.ts`. - **Types:** Shared TypeScript interfaces in `src/shared/types.ts`. diff --git a/deploy/devices.conf b/deploy/devices.conf index 12e3888..5d5ed53 100644 --- a/deploy/devices.conf +++ b/deploy/devices.conf @@ -9,10 +9,10 @@ # num ip event_device_id notes 01 192.168.32.101 tFLL1fLQfnk 02 192.168.32.102 rpbfunVPEzw -# 03 192.168.32.103 1EPfPX8kfw8 -# 04 192.168.32.104 zvgyLM5yieU -# 05 192.168.32.105 QOc046GoeSc -# 06 192.168.32.106 2o8j6eb0L6s +03 192.168.192.203 1EPfPX8kfw8 +04 192.168.192.204 zvgyLM5yieU +05 192.168.192.205 QOc046GoeSc +06 192.168.192.206 2o8j6eb0L6s 07 192.168.32.107 Oa1tlxPEVSQ 08 192.168.32.108 fY4yznpUZ48 09 192.168.32.109 YlgGCyjo9bY diff --git a/src/main/file_handlers.ts b/src/main/file_handlers.ts index 0037373..1e4e071 100644 --- a/src/main/file_handlers.ts +++ b/src/main/file_handlers.ts @@ -101,7 +101,7 @@ export function registerFileHandlers() { } }); - ipcMain.handle('native:launch-from-cache', async (event, { cache_root, hash, temp_root, filename, hash_prefix_length = 2 }) => { + ipcMain.handle('native:launch-from-cache', async (event, { cache_root, hash, temp_root, filename, hash_prefix_length = 2, script_template = null }) => { try { const source = get_organized_hashed_path(cache_root, hash, hash_prefix_length); const expanded_temp = expandPath(temp_root); @@ -114,11 +114,45 @@ export function registerFileHandlers() { // 1. Copy the file to temp folder with original name fs.copyFileSync(source, target); - // 2. Determine file type + // 2a. Data-driven script override (no rebuild needed for script changes). + // Set via event_device.data_json.launch_scripts or $events_loc.launcher.launch_scripts. + // Format: AppleScript string with {{path}} placeholder, OR "shell: {{path}}" + if (script_template) { + const resolved = (script_template as string).replace(/\{\{path\}\}/g, target); + if (resolved.startsWith('shell:')) { + const cmd = resolved.slice(6).trim(); + console.log(`Native: Running custom shell script for ${filename}`); + return new Promise((resolve_fn) => { + exec(cmd, (err, stdout, stderr) => { + if (err) resolve_fn({ success: false, error: err.message, stderr: stderr.trim() }); + else resolve_fn({ success: true, stdout: stdout.trim() }); + }); + }); + } else { + // Treat as AppleScript — write to temp .scpt file (same hardened approach used below) + console.log(`Native: Running custom AppleScript for ${filename}`); + const tmp_script_path = path.join(os.tmpdir(), `ae_launch_${Date.now()}.scpt`); + return new Promise((resolve_fn) => { + try { + fs.writeFileSync(tmp_script_path, resolved.trim()); + } catch (e: any) { + resolve_fn({ success: false, error: `Failed to write AppleScript temp file: ${e.message}` }); + return; + } + exec(`osascript "${tmp_script_path}"`, (err) => { + try { fs.unlinkSync(tmp_script_path); } catch {} + if (err) resolve_fn({ success: false, error: err.message }); + else resolve_fn({ success: true }); + }); + }); + } + } + + // 2b. Determine file type (legacy hardcoded launch logic — used when no script_template provided) const ext = path.extname(filename).toLowerCase().replace('.', ''); const is_pres = ['pptx', 'ppt', 'key', 'pdf', 'odp'].includes(ext); - // 3. Optimized Launch (LibreOffice / AppleScript) + // 3. Hardcoded launch (legacy — still the default when no script_template is configured) if (is_pres) { if (os.platform() === 'linux') { console.log(`Native: Launching LibreOffice (--impress) for ${target}`); @@ -183,4 +217,33 @@ export function registerFileHandlers() { return { success: false, error: error.message }; } }); + + // Thin primitive: copy a cached file to the temp directory with its original filename, + // then return the resolved path. The caller (Svelte side) decides what to do next — + // run_osascript, run_cmd, open_local_file, etc. + // + // This is the preferred building block for custom launch flows. Use launch_from_cache + // when the built-in hardcoded logic is sufficient; use copy_from_cache_to_temp when + // you want full control over what happens after the file lands in temp. + ipcMain.handle('native:copy-from-cache-to-temp', async (event, { cache_root, hash, temp_root, filename, hash_prefix_length = 2 }) => { + try { + const source = get_organized_hashed_path(cache_root, hash, hash_prefix_length); + + if (!fs.existsSync(source)) { + return { success: false, error: `File not in cache: ${hash}` }; + } + + const expanded_temp = expandPath(temp_root); + const target = path.join(expanded_temp, filename); + + if (!fs.existsSync(expanded_temp)) fs.mkdirSync(expanded_temp, { recursive: true }); + + fs.copyFileSync(source, target); + console.log(`Native: Copied from cache to temp -> ${target}`); + + return { success: true, path: target }; + } catch (error: any) { + return { success: false, error: error.message }; + } + }); } diff --git a/src/main/shell_handlers.ts b/src/main/shell_handlers.ts index 67427f1..da90820 100644 --- a/src/main/shell_handlers.ts +++ b/src/main/shell_handlers.ts @@ -1,6 +1,7 @@ import { ipcMain, shell } from 'electron'; import { exec, execSync } from 'child_process'; import * as fs from 'fs'; +import * as path from 'path'; import * as os from 'os'; import { expandPath } from './file_utils'; @@ -32,10 +33,27 @@ export function registerShellHandlers() { ipcMain.handle('native:run-osascript', async (event, script: string) => { if (os.platform() !== 'darwin') return { success: false, error: 'AppleScript is only available on macOS' }; - const escapedScript = script.replace(/"/g, '\"'); - const cmd = `osascript -e "${escapedScript}"`; + + // HARDENED: Write script to a temp .scpt file rather than passing inline via -e. + // The old -e approach (`osascript -e "..."`) has two fatal flaws: + // 1. It breaks on multi-line scripts. + // 2. It breaks on paths containing spaces or special characters (quotes, parens, etc.) + // Writing to a file sidesteps both — no shell escaping needed at all. + // The .scpt file is deleted immediately after execution (success or failure). + // Worst case on crash: a stale .scpt in /tmp, cleared on next OS reboot. + // + // LEGACY (removed): const cmd = `osascript -e "${script.replace(/"/g, '\\"')}"`; + + const tmp_script_path = path.join(os.tmpdir(), `ae_osa_${Date.now()}.scpt`); return new Promise((resolve) => { - exec(cmd, (error, stdout, stderr) => { + try { + fs.writeFileSync(tmp_script_path, script.trim()); + } catch (e: any) { + resolve({ success: false, error: `Failed to write AppleScript temp file: ${e.message}` }); + return; + } + exec(`osascript "${tmp_script_path}"`, (error, stdout, stderr) => { + try { fs.unlinkSync(tmp_script_path); } catch {} resolve({ success: !error, stdout: stdout.trim(), stderr: stderr.trim(), error: error ? error.message : null }); }); }); @@ -45,8 +63,8 @@ export function registerShellHandlers() { console.log(`Native: Killing processes -> `, process_name_li); const results = []; for (const name of process_name_li) { - const cmd = os.platform() === 'win32' - ? `taskkill /F /IM ${name} /T` + const cmd = os.platform() === 'win32' + ? `taskkill /F /IM ${name} /T` : `pkill -f ${name}`; try { execSync(cmd); @@ -117,7 +135,7 @@ export function registerShellHandlers() { ipcMain.handle('native:control-presentation', async (event, { app, action }) => { if (os.platform() !== 'darwin') return { success: false, error: 'Presentation control is only available on macOS' }; - + let script = ''; if (app === 'powerpoint') { switch (action) {