diff --git a/documentation/MODULE__AE_Events_PressMgmt_Launcher.md b/documentation/MODULE__AE_Events_PressMgmt_Launcher.md index 7044a163..39e70e90 100644 --- a/documentation/MODULE__AE_Events_PressMgmt_Launcher.md +++ b/documentation/MODULE__AE_Events_PressMgmt_Launcher.md @@ -311,6 +311,13 @@ The Electron app zero-configs itself: 3. Rename to original filename (e.g., `Abstract_101.pptx`) 4. OS opens the file (Keynote, PowerPoint, Preview, etc.) +**Configurable launch behavior:** The open/launch command in step 4 can be overridden +per file extension via `event_device.data_json.launch_scripts` (device-level config) or +`event.launcher.launch_scripts` (event-level fallback). Templates use `{{path}}` as the +file path placeholder; AppleScript or `shell:` prefixed commands are both supported. No +Electron rebuild required to change how files open — edit config in Aether and it applies +immediately. See `PROJECT__AE_Events_Launcher_Native_integration.md` Section 8. + Versioning is handled automatically: when a presenter uploads an updated file, the new hash is cached separately and the old one remains intact. diff --git a/documentation/PROJECT__AE_Events_Launcher_Native_integration.md b/documentation/PROJECT__AE_Events_Launcher_Native_integration.md index 0cbb0d6b..0c937496 100644 --- a/documentation/PROJECT__AE_Events_Launcher_Native_integration.md +++ b/documentation/PROJECT__AE_Events_Launcher_Native_integration.md @@ -144,7 +144,8 @@ no-op when `window.aetherNative` is not present (i.e., in browser/non-native mod ### File Cache - `check_hash_file_cache({cache_root, hash, hash_prefix_length?})` — Verifies a file exists in the local hashed cache. - `download_to_cache({url, cache_root, hash, api_key, account_id, hash_prefix_length?})` — Streams a file download to the hashed cache with SHA-256 integrity check. -- `launch_from_cache({cache_root, hash, temp_root, filename, hash_prefix_length?})` — Atomic "Safe Handover": copy from cache → tmp → rename → execute. +- `copy_from_cache_to_temp({cache_root, hash, temp_root, filename, hash_prefix_length?})` — **Preferred primitive.** Copies a cached file to temp and returns `{ success, path }`. The Svelte caller decides what to do next (run a script, open it, etc.). +- `launch_from_cache({cache_root, hash, temp_root, filename, hash_prefix_length?, script_template?})` — Combines copy + launch in one call. Uses `script_template` if provided, otherwise falls back to hardcoded extension logic. See **Configurable Launch Scripts** below. - `cleanup_tmp_files({cache_root, max_age_minutes?})` — Removes stale `*.tmp` download artifacts. Default: 1440 min (24h). Called at launcher startup. > `hash_prefix_length` defaults to `2` throughout. Do not change without coordinating all devices — mismatched values create orphaned cache subdirectories. @@ -153,8 +154,8 @@ no-op when `window.aetherNative` is not present (i.e., in browser/non-native mod - `open_folder(path)` — Opens a path in the OS file manager. - `run_cmd({cmd, timeout?, return_stdout?})` — Async shell command execution. - `run_cmd_sync({cmd, return_stdout?})` — Synchronous shell command execution. -- `run_osascript(script)` — Executes an AppleScript string. macOS only. -- `kill_processes({process_name_li})` — Gracefully terminates processes by name. +- `run_osascript(script)` — Executes an AppleScript string. macOS only. **Hardened (2026-05-11):** writes script to a temp `.scpt` file; multi-line scripts and paths with special characters now work correctly. No shell escaping needed in the passed string. +- `kill_processes({process_name_li})` — Terminates processes by name. macOS/Linux: `pkill -f`. Windows: `taskkill /F`. - `open_local_file_v2(path)` — Opens a file with its default OS application. ### Presentations (Phase 5) @@ -176,5 +177,64 @@ All paths passed to native handlers should use tokens rather than hardcoded OS p - `[home]` — Resolved to the user's home directory by the native bridge. - `[tmp]` — Resolved to the system temporary directory. +--- + +## 8. Configurable Launch Scripts (No-Rebuild File Handling) + +To avoid requiring a full Electron rebuild for changes to how files are opened, `launch_from_cache` +supports an optional `script_template` parameter. When provided, Electron runs the template +instead of its built-in hardcoded logic. The hardcoded logic remains intact as the fallback +when no template is configured. + +### Template Formats + +| Format | Example | +| :--- | :--- | +| **AppleScript** (macOS) | Multi-line AppleScript string with `{{path}}` placeholder | +| **Shell command** | String prefixed with `shell:` — e.g. `shell:open "{{path}}"` | + +The placeholder `{{path}}` is replaced with the full resolved path to the file in the +temp directory (after the atomic copy from cache). + +### Where to Configure + +Templates are resolved in priority order by `get_launch_script_template()` in +`launcher_file_cont.svelte`: + +1. **`event_device.data_json.launch_scripts`** — API-driven, per-device. Highest priority. + Set via the `event_device` record (Pres Mgmt → Device Management or direct DB edit). +2. **`$events_loc.launcher.launch_scripts`** — Local persistent config. Editable via the + Launcher config UI (planned) or direct `localStorage` manipulation. + +If neither is set, `script_template` is `null` and Electron uses its built-in hardcoded defaults. + +### Key Format + +Keys are lowercase file extensions without the dot. A `"default"` key catches all +unrecognised extensions. + +```json +// event_device.data_json.launch_scripts example +{ + "launch_scripts": { + "pptx": "tell application \"Microsoft PowerPoint\"\n activate\n open (POSIX file \"{{path}}\")\n delay 3\nend tell\ntell application \"System Events\"\n keystroke return using command down\nend tell", + "key": "tell application \"Keynote\"\n activate\n open (POSIX file \"{{path}}\")\n delay 1\n start (front document)\nend tell", + "pdf": "shell:open \"{{path}}\"", + "default": "shell:open \"{{path}}\"" + } +} +``` + +### Known Issue: `launch_presentation` vs `launch_from_cache` Inconsistency + +`shell_handlers.ts` `native:launch-presentation` still uses the old `osascript -e ""` approach +for its AppleScript execution. `file_handlers.ts` `native:launch-from-cache` uses the hardened +temp-`.scpt`-file approach. These two handlers behave differently for identical file types. + +- **`launch_from_cache`** (used by the "Open" button in the Launcher file list) — hardened, correct. +- **`launch_presentation`** (used by `electron_relay.launch_presentation`) — legacy `-e` flag, fragile on paths with spaces or special characters. + +**Recommendation:** `launch_presentation` should be updated to use the temp-`.scpt` approach in a future Electron build. It is not used in the primary file-open flow today, so this is not blocking. + ### Not Exposed via Relay (intentional) - `get_seed_config` / `get_jwt` — Exposed in the preload but not relayed to the UI. The JWT and seed are injected into the environment at startup; components should not call these directly. diff --git a/src/lib/electron/electron_relay.ts b/src/lib/electron/electron_relay.ts index 89625242..f8c2c73e 100644 --- a/src/lib/electron/electron_relay.ts +++ b/src/lib/electron/electron_relay.ts @@ -64,11 +64,68 @@ export async function launch_from_cache({ hash, temp_root, filename, - hash_prefix_length = 2 -}: any) { + hash_prefix_length = 2, + script_template = null +}: { + cache_root: string; + hash: string; + temp_root: string; + filename: string; + hash_prefix_length?: number; + /** + * Optional data-driven launch script. If provided, Electron runs this instead of + * its hardcoded extension-based logic — no app rebuild needed for script changes. + * + * Two formats: + * - AppleScript: multi-line string with {{path}} placeholder (macOS only) + * - Shell command: prefix with "shell:" → e.g. "shell:open \"{{path}}\"" + * + * Configure via event_device.data_json.launch_scripts or $events_loc.launcher.launch_scripts. + * If null, Electron falls through to its built-in hardcoded defaults. + */ + script_template?: string | null; +}) { if (!native) return { success: false, error: 'Native bridge not available' }; return await native.launch_from_cache({ + cache_root, + hash, + temp_root, + filename, + hash_prefix_length, + script_template + }); +} + +/** + * Thin cache primitive — copies a cached file to the temp directory and returns + * the resolved path. The caller decides what happens next. + * + * Preferred building block for composable launch flows on the Svelte side: + * 1. copy_from_cache_to_temp(...) → { path } + * 2. run_osascript(template.replace('{{path}}', path)) + * OR run_cmd(`open "${path}"`) + * OR whatever you need + * + * Use launch_from_cache when the built-in hardcoded logic is sufficient. + * Use this when you want full control over what happens after the file lands in temp. + */ +export async function copy_from_cache_to_temp({ + cache_root, + hash, + temp_root, + filename, + hash_prefix_length = 2 +}: { + cache_root: string; + hash: string; + temp_root: string; + filename: string; + hash_prefix_length?: number; +}): Promise<{ success: boolean; path?: string; error?: string }> { + if (!native) + return { success: false, error: 'Native bridge not available' }; + return await native.copy_from_cache_to_temp({ cache_root, hash, temp_root, @@ -129,6 +186,18 @@ export async function cleanup_tmp_files({ return await native.run_cmd({ cmd, timeout: 30000, return_stdout: false }); } +/** + * Executes an AppleScript string. macOS only. + * + * HARDENED (2026-05-11): The Electron handler now writes the script to a temp .scpt + * file and runs `osascript "/path/to/file.scpt"` rather than passing it inline via + * the -e flag. This means: + * - Multi-line scripts work correctly + * - Paths with spaces or special characters work correctly + * - No shell escaping required in the script string you pass here + * + * The .scpt file is deleted immediately after execution. + */ export async function run_osascript(script: string) { if (!native) return { success: false, error: 'Native bridge not available' }; diff --git a/src/routes/events/[event_id]/(launcher)/launcher_file_cont.svelte b/src/routes/events/[event_id]/(launcher)/launcher_file_cont.svelte index dee6bfde..71b5e3e5 100644 --- a/src/routes/events/[event_id]/(launcher)/launcher_file_cont.svelte +++ b/src/routes/events/[event_id]/(launcher)/launcher_file_cont.svelte @@ -88,6 +88,41 @@ let open_file_status_message: null | string = $state(null); let screen_saver_exts = ['jpg', 'png', 'PNG', 'webp']; +/** + * Resolves a data-driven launch script template for a given file extension. + * Checked in priority order: + * 1. event_device.data_json.launch_scripts (API-driven, per-device, most specific) + * 2. $events_loc.launcher.launch_scripts (local persistent override) + * Keys are lowercase extensions without the dot ("pptx", "key", "pdf", etc.). + * A "default" key acts as a catch-all for unrecognised extensions. + * + * Returns null when no config is found → Electron falls back to its hardcoded defaults. + * + * Template formats: + * - AppleScript (macOS): plain string with {{path}} placeholder + * - Shell command: prefix with "shell:" → "shell:open \"{{path}}\"" + */ +function get_launch_script_template(extension: string): string | null { + const ext = (extension || '').toLowerCase().replace('.', ''); + + // 1. Device-level config (from API, per device — highest priority) + const device_scripts = ($ae_loc as any).native_device?.launch_scripts; + if (device_scripts) { + if (device_scripts[ext]) return device_scripts[ext]; + if (device_scripts['default']) return device_scripts['default']; + } + + // 2. Launcher local config override (set manually via Launcher config UI) + const local_scripts = ($events_loc as any).launcher?.launch_scripts; + if (local_scripts) { + if (local_scripts[ext]) return local_scripts[ext]; + if (local_scripts['default']) return local_scripts['default']; + } + + // 3. No override — let Electron use its built-in hardcoded defaults + return null; +} + onMount(() => { if (screen_saver_exts.includes(event_file_obj.extension)) { if (!$events_loc.launcher.screen_saver_img_kv) @@ -149,11 +184,14 @@ async function handle_open_file() { // Phase 2/5: Use the atomic copy-and-launch operation. // The main process handler (file_handlers.ts) now handles the // specialized LibreOffice/AppleScript logic internally after copying. + // script_template is null when no device/local config exists → Electron uses hardcoded defaults. + const script_template = get_launch_script_template(event_file_obj.extension); const launch_result = await native.launch_from_cache({ cache_root, hash: event_file_obj.hash_sha256, temp_root, - filename: event_file_obj.filename + filename: event_file_obj.filename, + script_template }); if (!launch_result.success) {