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.
This commit is contained in:
Scott Idem
2026-01-26 15:12:03 -05:00
parent f0c4022675
commit b072857d68
6 changed files with 528 additions and 218 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -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) {

View File

@@ -140,85 +140,295 @@
</div>
</section>
<!-- Sync Loop Timers Section -->
<section
class:preset-outlined-warning-300-700={$events_loc.launcher.show_section__sync_timers}
class="sync_timers w-full preset-outlined-surface-300-700 transition-all mb-2"
>
<h3 class="text-center mb-2 text-sm font-semibold w-full">
<button
onclick={() => {
$events_loc.launcher.show_section__sync_timers =
!$events_loc.launcher.show_section__sync_timers;
}}
class="btn btn-sm w-full justify-between"
>
<span>
{#if $events_loc.launcher.show_section__sync_timers}
<span class="fas fa-chevron-down"></span>
{:else}
<span class="fas fa-chevron-right"></span>
{/if}
Native Sync Timers
</span>
</button>
</h3>
<!-- Sync Loop Timers Section -->
<section
class:preset-outlined-warning-300-700={$events_loc.launcher.show_section__sync_timers}
class="sync_timers w-full preset-outlined-surface-300-700 transition-all mb-2"
<div
class="flex flex-col gap-1 items-center justify-start w-full p-2"
class:hidden={!$events_loc.launcher.show_section__sync_timers}
>
{#if $ae_loc.native_device}
<label class="flex flex-row gap-1 items-center justify-start text-xs w-full">
<span class="grow opacity-70">Event Sync (ms):</span>
<input
type="number"
bind:value={$ae_loc.native_device.check_event_loop_period}
class="input input-sm w-24 text-right preset-tonal-surface"
/>
</label>
<label class="flex flex-row gap-1 items-center justify-start text-xs w-full">
<span class="grow opacity-70">Device Heartbeat (ms):</span>
<input
type="number"
bind:value={$ae_loc.native_device.check_event_device_loop_period}
class="input input-sm w-24 text-right preset-tonal-surface"
/>
</label>
<label class="flex flex-row gap-1 items-center justify-start text-xs w-full">
<span class="grow opacity-70">Location Refresh (ms):</span>
<input
type="number"
bind:value={$ae_loc.native_device.check_event_location_loop_period}
class="input input-sm w-24 text-right preset-tonal-surface"
/>
</label>
<label class="flex flex-row gap-1 items-center justify-start text-xs w-full">
<span class="grow opacity-70">Session Scan (ms):</span>
<input
type="number"
bind:value={$ae_loc.native_device.check_event_session_loop_period}
class="input input-sm w-24 text-right preset-tonal-surface"
/>
</label>
<label class="flex flex-row gap-1 items-center justify-start text-xs w-full border-t border-surface-500/20 pt-1 mt-1">
<span class="grow font-semibold text-primary-500">Hash Prefix Length:</span>
<input
type="number"
min="1"
max="8"
bind:value={$ae_loc.native_device.hash_prefix_length}
class="input input-sm w-24 text-right preset-tonal-surface font-bold"
/>
</label>
<div class="text-[9px] text-gray-500 mt-1 italic w-full text-right">
* Prefix: {($ae_loc.native_device.hash_prefix_length || 2)} chars. Reload required.
<h3 class="text-center mb-2 text-sm font-semibold w-full">
<button
onclick={() => {
$events_loc.launcher.show_section__sync_timers =
!$events_loc.launcher.show_section__sync_timers;
}}
class="btn btn-sm w-full justify-between"
>
<span>
{#if $events_loc.launcher.show_section__sync_timers}
<span class="fas fa-chevron-down"></span>
{:else}
<span class="fas fa-chevron-right"></span>
{/if}
Native Sync Timers
</span>
</button>
</h3>
<div
class="flex flex-col gap-1 items-center justify-start w-full p-2"
class:hidden={!$events_loc.launcher.show_section__sync_timers}
>
{#if $ae_loc.native_device}
<label class="flex flex-row gap-1 items-center justify-start text-xs w-full">
<span class="grow opacity-70">Event Sync (ms):</span>
<input
type="number"
bind:value={$ae_loc.native_device.check_event_loop_period}
class="input input-sm w-24 text-right preset-tonal-surface"
/>
</label>
<label class="flex flex-row gap-1 items-center justify-start text-xs w-full">
<span class="grow opacity-70">Device Heartbeat (ms):</span>
<input
type="number"
bind:value={$ae_loc.native_device.check_event_device_loop_period}
class="input input-sm w-24 text-right preset-tonal-surface"
/>
</label>
<label class="flex flex-row gap-1 items-center justify-start text-xs w-full">
<span class="grow opacity-70">Location Refresh (ms):</span>
<input
type="number"
bind:value={$ae_loc.native_device.check_event_location_loop_period}
class="input input-sm w-24 text-right preset-tonal-surface"
/>
</label>
<label class="flex flex-row gap-1 items-center justify-start text-xs w-full">
<span class="grow opacity-70">Session Scan (ms):</span>
<input
type="number"
bind:value={$ae_loc.native_device.check_event_session_loop_period}
class="input input-sm w-24 text-right preset-tonal-surface"
/>
</label>
<label class="flex flex-row gap-1 items-center justify-start text-xs w-full border-t border-surface-500/20 pt-1 mt-1">
<span class="grow font-semibold text-primary-500">Hash Prefix Length:</span>
<input
type="number"
min="1"
max="8"
bind:value={$ae_loc.native_device.hash_prefix_length}
class="input input-sm w-24 text-right preset-tonal-surface font-bold"
/>
</label>
<div class="text-[9px] text-gray-500 mt-1 italic w-full text-right">
* Prefix: {($ae_loc.native_device.hash_prefix_length || 2)} chars. Reload required.
</div>
{:else}
<div class="text-xs text-error-500 italic">No device config hydrated.</div>
{/if}
</div>
</section>
<!-- System Health Section -->
<section
class:preset-outlined-primary-300-700={$events_loc.launcher.show_section__health}
class="system_health w-full preset-outlined-surface-300-700 transition-all mb-2"
>
<h3 class="text-center mb-2 text-sm font-semibold w-full">
<button
onclick={() => {
$events_loc.launcher.show_section__health =
!$events_loc.launcher.show_section__health;
}}
class="btn btn-sm w-full justify-between"
>
<span>
{#if $events_loc.launcher.show_section__health}
<span class="fas fa-chevron-down"></span>
{:else}
<span class="fas fa-chevron-right"></span>
{/if}
System & Sync Health
</span>
<span class="flex gap-1 items-center">
{#if $events_sess.launcher.heartbeat_info.status === 'success'}
<span class="w-2 h-2 rounded-full bg-success-500 animate-pulse"></span>
{:else}
<span class="w-2 h-2 rounded-full bg-error-500"></span>
{/if}
</span>
</button>
</h3>
<div
class="flex flex-col gap-2 p-2 items-center justify-start w-full"
class:hidden={!$events_loc.launcher.show_section__health}
>
<!-- Heartbeat Info -->
<div class="grid grid-cols-2 gap-x-2 gap-y-1 w-full text-[10px] bg-surface-500/5 p-2 rounded border border-surface-500/10">
<span class="opacity-70">Last Heartbeat:</span>
<span class="text-right font-mono {$events_sess.launcher.heartbeat_info.status === 'success' ? 'text-success-500' : 'text-error-500'}">
{$events_sess.launcher.heartbeat_info.last_timestamp || 'Pending...'}
</span>
<span class="opacity-70">Room Sync Status:</span>
<span class="text-right font-mono">
{$events_sess.launcher.sync_stats.cached} / {$events_sess.launcher.sync_stats.total} Files
</span>
{#if $events_sess.launcher.sync_stats.currently_syncing}
<span class="col-span-2 text-center text-primary-500 animate-pulse mt-1 border-t border-primary-500/20 pt-1">
<span class="fas fa-sync fa-spin mr-1"></span>
Syncing: {$events_sess.launcher.sync_stats.currently_syncing}
</span>
{/if}
</div>
{:else}
<div class="text-xs text-error-500 italic">No device config hydrated.</div>
{/if}
</div>
</section>
<!-- Basic Native Info -->
{#if $ae_loc.is_native && $ae_loc.native_device}
<div class="w-full mt-1 flex flex-col gap-1 text-[10px] opacity-80 pl-1 italic">
<div>Host: {$ae_loc.native_device.info_hostname || 'Loading...'}</div>
<div class="truncate">IPs: {$ae_loc.native_device.info_ip_list || '...'}</div>
</div>
{/if}
</div>
</section>
{/if}
<!-- <hr class="w-full my-2 border-1 border-gray-200 dark:border-gray-800 " /> -->