feat(launcher): thin primitive architecture + run_osascript hardening
- 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
This commit is contained in:
98
README.md
98
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`.
|
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 |
|
| Method | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `list_tools()` | Returns a JSON manifest of all available native functions. |
|
| `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. |
|
||||||
| `launch_presentation({path, app})` | Launches a presentation with auto-focus and slideshow start. |
|
| `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. |
|
||||||
| `control_presentation({app, action})` | Sends `next`, `prev`, `start`, or `stop` to active decks. |
|
| `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. |
|
||||||
| `open_folder(path)` | Opens a local directory in the OS file explorer. |
|
| `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`. |
|
||||||
| `get_device_info()` | Returns hardware metadata (RAM, IPs, Hostname). |
|
|
||||||
|
### 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
|
```typescript
|
||||||
import * as native from '$lib/electron/electron_relay';
|
import * as native from '$lib/electron/electron_relay';
|
||||||
|
|
||||||
// Launch a file from local cache
|
// Step 1: copy the cached file to temp and get the resolved path
|
||||||
await native.launch_presentation({
|
const copy = await native.copy_from_cache_to_temp({
|
||||||
path: '[tmp]/my_deck.pptx',
|
cache_root: $ae_loc.local_file_cache_path,
|
||||||
app: 'powerpoint'
|
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
|
// Step 2: run whatever script/command you want with that path
|
||||||
await native.control_presentation({
|
// Option A — AppleScript (macOS):
|
||||||
app: 'powerpoint',
|
await native.run_osascript(`
|
||||||
action: 'next'
|
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
|
## 🛠️ Development
|
||||||
|
|
||||||
- **Preload:** Logic defined in `src/preload/index.ts`.
|
- **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`.
|
- **Types:** Shared TypeScript interfaces in `src/shared/types.ts`.
|
||||||
|
|||||||
@@ -9,10 +9,10 @@
|
|||||||
# num ip event_device_id notes
|
# num ip event_device_id notes
|
||||||
01 192.168.32.101 tFLL1fLQfnk
|
01 192.168.32.101 tFLL1fLQfnk
|
||||||
02 192.168.32.102 rpbfunVPEzw
|
02 192.168.32.102 rpbfunVPEzw
|
||||||
# 03 192.168.32.103 1EPfPX8kfw8
|
03 192.168.192.203 1EPfPX8kfw8
|
||||||
# 04 192.168.32.104 zvgyLM5yieU
|
04 192.168.192.204 zvgyLM5yieU
|
||||||
# 05 192.168.32.105 QOc046GoeSc
|
05 192.168.192.205 QOc046GoeSc
|
||||||
# 06 192.168.32.106 2o8j6eb0L6s
|
06 192.168.192.206 2o8j6eb0L6s
|
||||||
07 192.168.32.107 Oa1tlxPEVSQ
|
07 192.168.32.107 Oa1tlxPEVSQ
|
||||||
08 192.168.32.108 fY4yznpUZ48
|
08 192.168.32.108 fY4yznpUZ48
|
||||||
09 192.168.32.109 YlgGCyjo9bY
|
09 192.168.32.109 YlgGCyjo9bY
|
||||||
|
|||||||
@@ -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 {
|
try {
|
||||||
const source = get_organized_hashed_path(cache_root, hash, hash_prefix_length);
|
const source = get_organized_hashed_path(cache_root, hash, hash_prefix_length);
|
||||||
const expanded_temp = expandPath(temp_root);
|
const expanded_temp = expandPath(temp_root);
|
||||||
@@ -114,11 +114,45 @@ export function registerFileHandlers() {
|
|||||||
// 1. Copy the file to temp folder with original name
|
// 1. Copy the file to temp folder with original name
|
||||||
fs.copyFileSync(source, target);
|
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:<cmd> {{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 ext = path.extname(filename).toLowerCase().replace('.', '');
|
||||||
const is_pres = ['pptx', 'ppt', 'key', 'pdf', 'odp'].includes(ext);
|
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 (is_pres) {
|
||||||
if (os.platform() === 'linux') {
|
if (os.platform() === 'linux') {
|
||||||
console.log(`Native: Launching LibreOffice (--impress) for ${target}`);
|
console.log(`Native: Launching LibreOffice (--impress) for ${target}`);
|
||||||
@@ -183,4 +217,33 @@ export function registerFileHandlers() {
|
|||||||
return { success: false, error: error.message };
|
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 };
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ipcMain, shell } from 'electron';
|
import { ipcMain, shell } from 'electron';
|
||||||
import { exec, execSync } from 'child_process';
|
import { exec, execSync } from 'child_process';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import { expandPath } from './file_utils';
|
import { expandPath } from './file_utils';
|
||||||
|
|
||||||
@@ -32,10 +33,27 @@ export function registerShellHandlers() {
|
|||||||
|
|
||||||
ipcMain.handle('native:run-osascript', async (event, script: string) => {
|
ipcMain.handle('native:run-osascript', async (event, script: string) => {
|
||||||
if (os.platform() !== 'darwin') return { success: false, error: 'AppleScript is only available on macOS' };
|
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) => {
|
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 });
|
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);
|
console.log(`Native: Killing processes -> `, process_name_li);
|
||||||
const results = [];
|
const results = [];
|
||||||
for (const name of process_name_li) {
|
for (const name of process_name_li) {
|
||||||
const cmd = os.platform() === 'win32'
|
const cmd = os.platform() === 'win32'
|
||||||
? `taskkill /F /IM ${name} /T`
|
? `taskkill /F /IM ${name} /T`
|
||||||
: `pkill -f ${name}`;
|
: `pkill -f ${name}`;
|
||||||
try {
|
try {
|
||||||
execSync(cmd);
|
execSync(cmd);
|
||||||
@@ -117,7 +135,7 @@ export function registerShellHandlers() {
|
|||||||
|
|
||||||
ipcMain.handle('native:control-presentation', async (event, { app, action }) => {
|
ipcMain.handle('native:control-presentation', async (event, { app, action }) => {
|
||||||
if (os.platform() !== 'darwin') return { success: false, error: 'Presentation control is only available on macOS' };
|
if (os.platform() !== 'darwin') return { success: false, error: 'Presentation control is only available on macOS' };
|
||||||
|
|
||||||
let script = '';
|
let script = '';
|
||||||
if (app === 'powerpoint') {
|
if (app === 'powerpoint') {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
|
|||||||
Reference in New Issue
Block a user