From b072857d685e2b3c10b3d6352b18444833fe7427 Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Mon, 26 Jan 2026 15:12:03 -0500 Subject: [PATCH] feat(native): harden launcher bridge and implement presentation-aware handover - Upgraded LauncherBackgroundSync to force-hydrate OS metadata (home/tmp) on mount. - Hardened electron_relay.ts with robust placeholder resolution and global regex. - Restored safe handover by making native.launch_from_cache presentation-aware. - Integrated heartbeat and sync status into the formal Launcher Config UI. - Added comprehensive technical documentation for the 2026 native architecture. --- .../AETHER_NATIVE_APP_ELECTRON_NEW_2026.md | 70 ++++ src/lib/electron/electron_native.js | 183 +++++++-- src/lib/electron/electron_relay.ts | 113 +----- src/lib/stores/ae_events_stores.ts | 12 + .../launcher_background_sync.svelte | 6 + .../[event_id]/(launcher)/launcher_cfg.svelte | 362 ++++++++++++++---- 6 files changed, 528 insertions(+), 218 deletions(-) create mode 100644 documentation/AETHER_NATIVE_APP_ELECTRON_NEW_2026.md diff --git a/documentation/AETHER_NATIVE_APP_ELECTRON_NEW_2026.md b/documentation/AETHER_NATIVE_APP_ELECTRON_NEW_2026.md new file mode 100644 index 00000000..cd7f43b2 --- /dev/null +++ b/documentation/AETHER_NATIVE_APP_ELECTRON_NEW_2026.md @@ -0,0 +1,70 @@ +# Aether Native Electron App - V3 Architecture (2026) + +## 1. Overview +The Aether Native App serves as the OS-level bridge for the SvelteKit frontend. It enables functionality that is normally restricted by browser sandboxing, such as local filesystem management, hardware telemetry, and direct control of third-party presentation software (PowerPoint, Keynote, LibreOffice). + +--- + +## 2. The Three-Layer Architecture + +### 2.1 The Engine (`electron_native.js`) +* **Process:** Main Process (Node.js) +* **Role:** Performs the actual OS-level operations. +* **Key Responsibilities:** + * Executing shell commands via `child_process`. + * Managing the permanent Hashed Cache (`file_cache/`). + * Performing atomic "Safe Handover" copies to the operational `temp/` directory. + * Resolving global path placeholders like `[home]` and `[tmp]`. + +### 2.2 The Bridge (`preload.js`) +* **Process:** Preload Script +* **Role:** The security gatekeeper. +* **Key Responsibilities:** + * Uses Electron's `contextBridge` to securely expose specific Main Process functions to the UI. + * Exposes the `window.aetherNative` object. + * Ensures that only whitelisted IPC channels can be triggered by the renderer. + +### 2.3 The Messenger (`electron_relay.ts`) +* **Process:** Renderer Process (SvelteKit) +* **Role:** The TypeScript API used by Svelte components. +* **Key Responsibilities:** + * Detecting if the app is running in "Native Mode". + * Providing a clean, typed interface for native calls. + * Implementing "Smart Fallbacks" (e.g., resolving placeholders UI-side if the bridge is outdated). + +--- + +## 3. Core Lifecycle + +1. **Seed Phase:** The Electron shell reads `~/seed.json` to identify the device. +2. **Hydration Phase:** The shell authenticates with the Aether V3 API to pull device-specific settings (room assignment, sync timers). +3. **Injection Phase:** The shell injects the **JWT (Auth Token)** and **Native Config** into the SvelteKit environment. +4. **Observation Phase:** `LauncherBackgroundSync.svelte` force-hydrates absolute OS paths (Home/Tmp) into the global `ae_loc` store for immediate use. + +--- + +## 4. Native Tool Library (`window.aetherNative`) + +### File & Cache Management +- `check_cache({hash})`: Verify if a file exists in the hidden hashed storage. +- `download_to_cache({url, hash})`: Secure background download using the native API key. +- `launch_from_cache({hash, filename})`: **Safe Handover** — copies from cache to temp with the original name and triggers the launcher. + +### OS Execution (The "Actuators") +- `launch_presentation({path, app})`: Intelligent application selection. + - **Linux:** Triggers `libreoffice --impress`. + - **macOS:** Triggers AppleScript for Keynote/PowerPoint. +- `run_cmd({cmd})`: Executes raw shell commands with automatic `[home]` and `[tmp]` resolution. +- `kill_processes([names])`: Force-closes presentation apps to clean the podium between sessions. + +### System Metadata +- `get_device_info()`: Provides OS version, CPU/RAM stats, and absolute system paths. + +--- + +## 5. Path Placeholder System +To maintain cross-platform compatibility, the system uses a placeholder syntax for all paths: +- `[home]`: Resolves to the user's home directory (e.g., `/home/scott` or `/Users/scott`). +- `[tmp]`: Resolves to the system temporary directory. + +**Resolution Logic:** All native handlers utilize a global regex (`/\[home\]/g`) to ensure these are converted to absolute paths before any disk or shell operation occurs. diff --git a/src/lib/electron/electron_native.js b/src/lib/electron/electron_native.js index ab56dfc1..1df22bfc 100644 --- a/src/lib/electron/electron_native.js +++ b/src/lib/electron/electron_native.js @@ -1333,7 +1333,7 @@ exports.run_osascript = async function ({ }; // Run raw command -// Updated 2022-05-07 +// Updated 2026-01-26 exports.run_cmd = async function ({ cmd = null, return_stdout = null, @@ -1342,12 +1342,18 @@ exports.run_cmd = async function ({ }) { console.log('*** Electron framework export: run_cmd() ***'); - console.log(`Command String: ${cmd}`); + // Resolve placeholders in the command string + let cleaned_cmd = cmd; + if (cmd && typeof cmd === 'string') { + cleaned_cmd = cmd.replace(/\[home\]/g, home_directory).replace(/\[tmp\]/g, tmp_directory); + } + + console.log(`Command String: ${cleaned_cmd}`); let result; if (!sync) { - result = child_process.exec(cmd, (err, stdout, stdin) => { + result = child_process.exec(cleaned_cmd, (err, stdout, stdin) => { // if (err) throw err; if (err) { console.log('Error:', err); @@ -1366,7 +1372,7 @@ exports.run_cmd = async function ({ } }); } else { - result = child_process.execSync(cmd, (err, stdout, stdin) => { + result = child_process.execSync(cleaned_cmd, (err, stdout, stdin) => { // if (err) throw err; if (err) { console.log('Error:', err); @@ -1391,16 +1397,22 @@ exports.run_cmd = async function ({ }; // Run raw command sync -// Updated 2022-05-07 +// Updated 2026-01-26 exports.run_cmd_sync = function ({ cmd = null, return_stdout = null, return_stdin = null }) { - console.log('*** Electron framework export: run_cmd() ***'); + console.log('*** Electron framework export: run_cmd_sync() ***'); - console.log(`Command String: ${cmd}`); + // Resolve placeholders in the command string + let cleaned_cmd = cmd; + if (cmd && typeof cmd === 'string') { + cleaned_cmd = cmd.replace(/\[home\]/g, home_directory).replace(/\[tmp\]/g, tmp_directory); + } + + console.log(`Command String: ${cleaned_cmd}`); let stdout; try { - stdout = child_process.execSync(cmd, { encoding: 'utf8' }); + stdout = child_process.execSync(cleaned_cmd, { encoding: 'utf8' }); console.log('Std Out:', stdout); } catch (err) { console.error('Error:', err); @@ -1414,40 +1426,10 @@ exports.run_cmd_sync = function ({ cmd = null, return_stdout = null, return_stdi console.log('Finished and returning true'); return true; } - - // let result; - // let stdout; - // let stderr; - // try { - // let { stdout, stderr } = child_process.execSync(cmd, (err, stdout, stdin) => { - // // if (err) throw err; - // if (err) { - // console.log('Error:', err); - // return false; - // }; - - // console.log('stdout:', stdout); - // // console.log('stdin:', stdin); - - // if (return_stdout) { - // console.log('Finished and returning stdout'); - // return stdout; - // } else { - // console.log('Finished and returning true'); - // return true; - // } - // }); - // } catch (err) { - // console.error(err); - // return false; - // } - - // console.log('Result:', result); - // return result; }; // Run raw command -// Updated 2022-05-25 +// Updated 2026-01-26 exports.get_device_info = async function () { console.log('*** Electron framework export: get_device_info() ***'); @@ -1465,11 +1447,134 @@ exports.get_device_info = async function () { data['release'] = os.release(); data['uptime'] = os.uptime(); data['version'] = os.version(); + + // Add directory info for placeholder resolution in UI + data['home_directory'] = home_directory; + data['tmp_directory'] = tmp_directory; console.log(data); return data; }; +/** + * Atomic Copy-and-Launch (Phase 2/5) + * Moves a file from the hashed cache to the operational temp directory + * and triggers the system launcher. + */ +exports.launch_from_cache = async function ({ + cache_root, + hash, + temp_root, + filename, + hash_prefix_length = 2 +}) { + console.log('*** Aether App Native export: launch_from_cache() ***'); + + // 1. Resolve Path Placeholders (using global regex) + const clean_cache_root = cache_root.replace(/\[home\]/g, home_directory).replace(/\[tmp\]/g, tmp_directory); + const clean_temp_root = temp_root.replace(/\[home\]/g, home_directory).replace(/\[tmp\]/g, tmp_directory); + + const hash_filename = `${hash}.file`; + const prefix = hash.substring(0, hash_prefix_length); + const source_path = path.join(clean_cache_root, prefix, hash_filename); + const dest_path = path.join(clean_temp_root, filename); + + console.log(`Source: ${source_path}`); + console.log(`Dest: ${dest_path}`); + + try { + // 2. Ensure temp directory exists + if (!fs.existsSync(clean_temp_root)) { + fs.mkdirSync(clean_temp_root, { recursive: true }); + } + + // 3. Verify Source + if (!fs.existsSync(source_path)) { + throw new Error(`Source file not found in cache: ${source_path}`); + } + + // 4. Perform atomic copy + fs.copyFileSync(source_path, dest_path); + console.log('File copied to temp successfully.'); + + // 5. Trigger Specialized Launcher (if presentation) + const ext = path.extname(filename).toLowerCase().replace('.', ''); + const is_pres = ['pptx', 'ppt', 'key', 'pdf', 'odp'].includes(ext); + + if (is_pres) { + return await exports.launch_presentation({ + path: dest_path, + app: ext === 'key' ? 'keynote' : 'default' + }); + } + + // 6. Default Fallback + return await ipcRenderer.invoke('open_local_file', '', dest_path); + + } catch (err) { + console.error('Launch Error:', err); + return { success: false, error: err.message }; + } +}; + +/** + * Specialized Presentation Launcher (Phase 5) + * Handles platform-specific application selection (LibreOffice on Linux, + * PowerPoint/Keynote on macOS). + * Updated 2026-01-26 + */ +exports.launch_presentation = async function ({ + path: raw_path, + app = 'default', + os_platform = 'auto' +}) { + console.log('*** Aether App Native export: launch_presentation() ***'); + + // Resolve placeholders if they exist in the incoming path (using global regex) + let cleaned_path = raw_path + .replace(/\[home\]/g, home_directory) + .replace(/\[tmp\]/g, tmp_directory); + + console.log(`Raw Path: ${raw_path}; Cleaned Path: ${cleaned_path}; App: ${app}; OS: ${os_platform}`); + + // 1. Detect OS + let platform = os_platform; + if (platform === 'auto') { + platform = os.platform(); + } + + // 2. Handle Linux (LibreOffice Testing) + if (platform === 'linux') { + console.log(`Native: Launching LibreOffice on Linux for path: ${cleaned_path}`); + const cmd = `libreoffice --impress "${cleaned_path}"`; + + return new Promise((resolve) => { + child_process.exec(cmd, (err, stdout, stderr) => { + if (err) { + console.error('LibreOffice Launch Error:', err); + resolve({ success: false, error: err.message }); + } else { + resolve({ success: true, stdout, stderr }); + } + }); + }); + } + + // 3. Handle macOS (Production) + if (platform === 'darwin') { + if (app === 'keynote') { + const script = `tell application "Keynote" to open POSIX file "${cleaned_path}"`; + return exports.run_osascript({ cmd: script }); + } + + // Default to shell open + return ipcRenderer.invoke('open_local_file', '', cleaned_path); + } + + // 4. Default Fallback (Windows/Others) + return ipcRenderer.invoke('open_local_file', '', cleaned_path); +}; + // For loading JS file function loadJS() { // Gives -1 when the given input is not in the string diff --git a/src/lib/electron/electron_relay.ts b/src/lib/electron/electron_relay.ts index b04b5626..7260aa7b 100644 --- a/src/lib/electron/electron_relay.ts +++ b/src/lib/electron/electron_relay.ts @@ -70,165 +70,72 @@ export async function open_local_file_v2(path: string) { } /** - * Specialized Presentation Launcher (Phase 5) - * Handles platform-specific application selection (LibreOffice on Linux, - * PowerPoint/Keynote on macOS). - */ - export async function launch_presentation({ - path, - app = 'default', - os = 'auto', - log_lvl = 0 - }: { - path: string, - app?: 'default' | 'powerpoint' | 'keynote' | 'libreoffice', - os?: 'auto' | 'linux' | 'darwin' | 'win32', - log_lvl?: number - }) { - if (!native) return { success: false, error: 'Native bridge not available' }; - - // 1. Detect OS if set to auto - let platform = os; - if (platform === 'auto') { - const info = await get_device_info(); - platform = info?.platform || 'linux'; - } - - // 2. Prefer the Native Bridge implementation (Atomic Copy-and-Launch) - // This delegates to the hardened logic in electron_native.js - if (native.launch_presentation) { - if (log_lvl) console.log('Relay: Using native.launch_presentation'); - return await native.launch_presentation({ path, app, os_platform: platform }); - } - - // 3. Relay-side Fallback (Mock/Legacy) - // Manually resolve placeholders using all available context - const info = await get_device_info(); - const loc = get(ae_loc); - - - // Attempt to find home/tmp from bridge info or local hydrated store - + // Attempt to find home/tmp from bridge info OR local hydrated store + const home = info?.home_directory || loc.home_directory || loc.native_device?.home_directory || ''; + const tmp = info?.tmp_directory || loc.tmp_directory || loc.native_device?.tmp_directory || ''; + if (log_lvl) console.log('Relay Debug:', { home, tmp, raw_path: path }); - const home = info?.home_directory || loc.home_directory || loc.native_device?.home_directory || ''; - - - - const tmp = info?.tmp_directory || loc.tmp_directory || loc.native_device?.tmp_directory || ''; - - - - - - - - if (log_lvl) console.log('Relay Debug:', { home, tmp, raw_path: path }); - - - - - - - - // CRITICAL: Resolve all instances of placeholders using global regex (fixed pattern) - - - - let cleaned_path = path; - - - - if (home) cleaned_path = cleaned_path.replace(/\[home\]/g, home); - - - - if (tmp) cleaned_path = cleaned_path.replace(/\[tmp\]/g, tmp); - - - - - - - - if (log_lvl) console.log(`Relay Fallback: Resolving ${path} -> ${cleaned_path}`); - + // CRITICAL: Resolve all instances of placeholders using global regex + let cleaned_path = path; + if (home) cleaned_path = cleaned_path.replace(/\[home\]/g, home); + if (tmp) cleaned_path = cleaned_path.replace(/\[tmp\]/g, tmp); + if (log_lvl) console.log(`Relay Fallback: Resolving ${path} -> ${cleaned_path}`); // If path still contains [home] or [tmp], it means we failed to resolve it. - - // Stop here to prevent sending a broken path like /temp/... to the OS. - if (cleaned_path.includes('[home]') || cleaned_path.includes('[tmp]')) { - console.error('Relay Error: Could not resolve path placeholders. Home or Tmp directory unknown.', { home, tmp }); - return { success: false, error: 'Could not resolve path placeholders' }; - } - - if (platform === 'linux') { - if (log_lvl) console.log(`Relay: Launching LibreOffice on Linux for path: ${cleaned_path}`); - return await run_cmd({ cmd: `libreoffice --impress "${cleaned_path}"` }); - } - - if (platform === 'darwin') { - if (app === 'keynote') { - return await run_osascript(`tell application "Keynote" to open POSIX file "${cleaned_path}"`); - } - return await open_local_file_v2(cleaned_path); - } - - return await open_local_file_v2(cleaned_path); - -} \ No newline at end of file +} diff --git a/src/lib/stores/ae_events_stores.ts b/src/lib/stores/ae_events_stores.ts index 54249a15..6e64d67d 100644 --- a/src/lib/stores/ae_events_stores.ts +++ b/src/lib/stores/ae_events_stores.ts @@ -400,6 +400,18 @@ const events_session_data_struct: key_val = { event_file_open: {}, // This is from the older Launcher. native: {}, + // Shared observability for Sync Monitor and Config Drawer + sync_stats: { + total: 0, + cached: 0, + missing: 0, + currently_syncing: null + }, + heartbeat_info: { + last_timestamp: null, + status: 'pending' + }, + modal__title: '', modal__open_event_file_id: false, modal__event_file_obj: null, diff --git a/src/routes/events/[event_id]/(launcher)/launcher_background_sync.svelte b/src/routes/events/[event_id]/(launcher)/launcher_background_sync.svelte index 9519edf6..e6aa958f 100644 --- a/src/routes/events/[event_id]/(launcher)/launcher_background_sync.svelte +++ b/src/routes/events/[event_id]/(launcher)/launcher_background_sync.svelte @@ -43,6 +43,12 @@ if (info) { $ae_loc.home_directory = info.home_directory; $ae_loc.tmp_directory = info.tmp_directory; + + // Also sync into native_device for redundancy + if (!$ae_loc.native_device) $ae_loc.native_device = {}; + $ae_loc.native_device.home_directory = info.home_directory; + $ae_loc.native_device.tmp_directory = info.tmp_directory; + if (log_lvl) console.log('Sync: Native OS metadata hydrated.', { home: info.home_directory }); } } catch (err) { diff --git a/src/routes/events/[event_id]/(launcher)/launcher_cfg.svelte b/src/routes/events/[event_id]/(launcher)/launcher_cfg.svelte index 4d48423c..bb5278f5 100644 --- a/src/routes/events/[event_id]/(launcher)/launcher_cfg.svelte +++ b/src/routes/events/[event_id]/(launcher)/launcher_cfg.svelte @@ -140,85 +140,295 @@ - -
-

- -

+ + +
- {#if $ae_loc.native_device} - - - - - -
- * Prefix: {($ae_loc.native_device.hash_prefix_length || 2)} chars. Reload required. + +

+ + + +

+ + + +
+ + {#if $ae_loc.native_device} + + + + + + + + + + + +
+ + * Prefix: {($ae_loc.native_device.hash_prefix_length || 2)} chars. Reload required. + +
+ + {:else} + +
No device config hydrated.
+ + {/if} + +
+ +
+ + + + + +
+ +

+ + + +

+ + + +
+ + + +
+ + Last Heartbeat: + + + + {$events_sess.launcher.heartbeat_info.last_timestamp || 'Pending...'} + + + + + + Room Sync Status: + + + + {$events_sess.launcher.sync_stats.cached} / {$events_sess.launcher.sync_stats.total} Files + + + + + + {#if $events_sess.launcher.sync_stats.currently_syncing} + + + + + + Syncing: {$events_sess.launcher.sync_stats.currently_syncing} + + + + {/if} +
- {:else} -
No device config hydrated.
- {/if} -
-
+ + + + + + {#if $ae_loc.is_native && $ae_loc.native_device} + +
+ +
Host: {$ae_loc.native_device.info_hostname || 'Loading...'}
+ +
IPs: {$ae_loc.native_device.info_ip_list || '...'}
+ +
+ + {/if} + + + +
+ + {/if}