feat(launcher): implement LaunchProfile system — MasterKey replacement

- Add ae_launcher__default_launch_profiles.ts with LaunchProfile interface,
  DEFAULT_LAUNCH_PROFILES constant, and resolve_launch_profile() helper.
  Covers pptx/ppt/key/odp/pdf, all VLC media formats, Windows/Parallels
  variants (pptxwin/pptwin/odpwin/pdfwin), and a catch-all 'default'.

- Replace get_launch_script_template() with get_launch_profile() in
  launcher_file_cont.svelte. Override priority: device API config >
  local persistent config > built-in defaults > 'default' catch-all.

- Rewrite handle_open_file() native branch with 9-step profile-driven flow:
  copy_from_cache_to_temp → resolve profile → set_display_layout (silent fail)
  → open (run_cmd or OS default) → sleep(post_delay_ms) → run post_script
  → fallback to OS default on open failure → surface status/error detail.

- Add open_file_error_detail state var; show error pre block in status
  alert for native error state, show fallback note for fallback state.

- Add display override toggle button in event_file_meta (visible when
  trusted_access + is_native): cycles null → extend → mirror → null,
  PATCHes event_file.cfg_json.display_override via V3 CRUD.
This commit is contained in:
Scott Idem
2026-05-12 12:17:43 -04:00
parent a3d229c803
commit 422c9c341c
2 changed files with 557 additions and 49 deletions

View File

@@ -0,0 +1,382 @@
/**
* ae_launcher__default_launch_profiles.ts
*
* Built-in launch profiles for the Aether Events Launcher — the Svelte-side
* replacement for the legacy OSIT MasterKey Swift app.
*
* These are the last-resort defaults. Override priority (high → low):
* 1. event_file.cfg_json.display_override — per-file, display_mode only
* 2. event_device.data_json.launch_profiles[ext] — full profile, per device (API)
* 3. $events_loc.launcher.launch_profiles[ext] — local persistent override
* 4. DEFAULT_LAUNCH_PROFILES[ext] — these built-ins
* 5. DEFAULT_LAUNCH_PROFILES['default'] — catch-all
*
* Keys are lowercase file extensions without the dot: "pptx", "key", "pdf", etc.
* The special key "default" catches any unrecognised extension.
*
* post_script formats:
* - Plain string → run as AppleScript via run_osascript() (macOS only)
* - "shell:..." prefix → run as shell command via run_cmd()
*
* Reserved for future use (not yet read anywhere):
* - speed_factor: number — delay multiplier for slower machines (1.0 = normal)
* - url: string — for URL-type presentations (e.g. Google Slides)
*/
export interface LaunchProfile {
/** Human-readable label for status messages */
app: string;
/** Display layout to set before opening. 'extend' only applied if external display found. */
display_mode: 'extend' | 'mirror' | 'none';
/**
* Shell command to open the file. {{path}} is replaced with the resolved temp path.
* If omitted, falls back to open_local_file_v2(path) — OS default handler.
*/
open_cmd?: string;
/**
* Script to run after the file opens and post_delay_ms has elapsed.
* Plain string → AppleScript (macOS). "shell:" prefix → shell command.
*/
post_script?: string;
/** Milliseconds to wait after open_cmd before running post_script. Default: 2000 */
post_delay_ms?: number;
// --- Reserved for future use — not yet implemented ---
// speed_factor?: number;
// url?: string;
}
export const DEFAULT_LAUNCH_PROFILES: Record<string, LaunchProfile> = {
// -------------------------------------------------------------------------
// macOS presentation formats
// -------------------------------------------------------------------------
pptx: {
app: 'Microsoft PowerPoint',
display_mode: 'extend',
open_cmd: 'open -a "Microsoft PowerPoint" "{{path}}"',
post_delay_ms: 2000,
post_script: `tell application "Microsoft PowerPoint"
activate
end tell
tell application "System Events"
tell process "Microsoft PowerPoint"
keystroke return using command down
end tell
end tell`
},
ppt: {
app: 'Microsoft PowerPoint',
display_mode: 'extend',
open_cmd: 'open -a "Microsoft PowerPoint" "{{path}}"',
post_delay_ms: 2000,
post_script: `tell application "Microsoft PowerPoint"
activate
end tell
tell application "System Events"
tell process "Microsoft PowerPoint"
keystroke return using command down
end tell
end tell`
},
key: {
app: 'Keynote',
display_mode: 'extend',
open_cmd: 'open -a "Keynote" "{{path}}"',
post_delay_ms: 2000,
post_script: `tell application "Keynote"
activate
start (front document)
end tell`
},
odp: {
app: 'LibreOffice',
display_mode: 'extend',
open_cmd: 'open -a "LibreOffice" "{{path}}"',
post_delay_ms: 2000,
post_script: `tell application "LibreOffice"
activate
end tell
tell application "System Events"
tell process "soffice"
key code 96
end tell
end tell`
},
pdf: {
app: 'Adobe Acrobat Reader DC',
display_mode: 'mirror',
open_cmd: 'open -a "Adobe Acrobat Reader DC" "{{path}}"',
post_delay_ms: 2000,
post_script: `tell application "Adobe Acrobat Reader DC"
activate
end tell
tell application "System Events"
tell process "AdobeReader"
keystroke "l" using command down
end tell
end tell`
},
// -------------------------------------------------------------------------
// Media (VLC) — mirror display
// -------------------------------------------------------------------------
mp4: {
app: 'VLC',
display_mode: 'mirror',
open_cmd: 'open -a "VLC" "{{path}}"',
post_delay_ms: 2000,
post_script: `tell application "VLC"
activate
end tell
tell application "System Events"
tell process "VLC"
keystroke "f" using command down
end tell
end tell`
},
mkv: {
app: 'VLC',
display_mode: 'mirror',
open_cmd: 'open -a "VLC" "{{path}}"',
post_delay_ms: 2000,
post_script: `tell application "VLC"
activate
end tell
tell application "System Events"
tell process "VLC"
keystroke "f" using command down
end tell
end tell`
},
mov: {
app: 'VLC',
display_mode: 'mirror',
open_cmd: 'open -a "VLC" "{{path}}"',
post_delay_ms: 2000,
post_script: `tell application "VLC"
activate
end tell
tell application "System Events"
tell process "VLC"
keystroke "f" using command down
end tell
end tell`
},
mpeg: {
app: 'VLC',
display_mode: 'mirror',
open_cmd: 'open -a "VLC" "{{path}}"',
post_delay_ms: 2000,
post_script: `tell application "VLC"
activate
end tell
tell application "System Events"
tell process "VLC"
keystroke "f" using command down
end tell
end tell`
},
avi: {
app: 'VLC',
display_mode: 'mirror',
open_cmd: 'open -a "VLC" "{{path}}"',
post_delay_ms: 2000,
post_script: `tell application "VLC"
activate
end tell
tell application "System Events"
tell process "VLC"
keystroke "f" using command down
end tell
end tell`
},
flv: {
app: 'VLC',
display_mode: 'mirror',
open_cmd: 'open -a "VLC" "{{path}}"',
post_delay_ms: 2000,
post_script: `tell application "VLC"
activate
end tell
tell application "System Events"
tell process "VLC"
keystroke "f" using command down
end tell
end tell`
},
ogg: {
app: 'VLC',
display_mode: 'mirror',
open_cmd: 'open -a "VLC" "{{path}}"',
post_delay_ms: 2000,
post_script: `tell application "VLC"
activate
end tell
tell application "System Events"
tell process "VLC"
keystroke "f" using command down
end tell
end tell`
},
ogv: {
app: 'VLC',
display_mode: 'mirror',
open_cmd: 'open -a "VLC" "{{path}}"',
post_delay_ms: 2000,
post_script: `tell application "VLC"
activate
end tell
tell application "System Events"
tell process "VLC"
keystroke "f" using command down
end tell
end tell`
},
mp3: {
app: 'VLC',
display_mode: 'mirror',
open_cmd: 'open -a "VLC" "{{path}}"',
post_delay_ms: 2000,
post_script: `tell application "VLC"
activate
end tell
tell application "System Events"
tell process "VLC"
keystroke "f" using command down
end tell
end tell`
},
wmv: {
app: 'VLC',
display_mode: 'mirror',
open_cmd: 'open -a "VLC" "{{path}}"',
post_delay_ms: 2000,
post_script: `tell application "VLC"
activate
end tell
tell application "System Events"
tell process "VLC"
keystroke "f" using command down
end tell
end tell`
},
// -------------------------------------------------------------------------
// Windows / Parallels variants — longer post_delay_ms (Parallels needs more time)
// -------------------------------------------------------------------------
pptxwin: {
app: 'Microsoft Office PowerPoint (Windows)',
display_mode: 'extend',
open_cmd: 'open -a "Microsoft Office PowerPoint" "{{path}}"',
post_delay_ms: 3000,
post_script: `tell application "Microsoft Office PowerPoint"
activate
end tell
tell application "System Events"
key code 96
end tell`
},
pptwin: {
app: 'Microsoft Office PowerPoint (Windows)',
display_mode: 'extend',
open_cmd: 'open -a "Microsoft Office PowerPoint" "{{path}}"',
post_delay_ms: 3000,
post_script: `tell application "Microsoft Office PowerPoint"
activate
end tell
tell application "System Events"
key code 96
end tell`
},
odpwin: {
app: 'LibreOffice (Windows)',
display_mode: 'extend',
open_cmd: 'open -a "LibreOffice" "{{path}}"',
post_delay_ms: 3000,
post_script: `tell application "LibreOffice"
activate
end tell
tell application "System Events"
tell process "soffice"
key code 96
end tell
end tell`
},
pdfwin: {
app: 'Acrobat Reader (Windows)',
display_mode: 'mirror',
open_cmd: 'open -a "Acrobat Reader Windows" "{{path}}"',
post_delay_ms: 3000,
post_script: `tell application "Acrobat Reader Windows"
activate
end tell
tell application "System Events"
key code 108 using control down
end tell`
},
// -------------------------------------------------------------------------
// Catch-all — OS default handler, no display change
// Works on macOS (open) and Linux (xdg-open via open_local_file_v2)
// -------------------------------------------------------------------------
default: {
app: 'OS Default',
display_mode: 'none'
// No open_cmd — execution falls through to open_local_file_v2(path)
// No post_script
}
};
/**
* Returns a shallow copy of the built-in profile for the given extension,
* with a display_override applied if provided.
*
* Falls back to 'default' if no specific profile exists.
*/
export function resolve_launch_profile(
extension: string,
display_override?: 'extend' | 'mirror' | 'none' | null,
device_profiles?: Record<string, LaunchProfile> | null,
local_profiles?: Record<string, LaunchProfile> | null
): LaunchProfile {
const ext = (extension || '').toLowerCase().replace(/^\./, '');
// Priority: device config → local config → built-ins → default
const source =
device_profiles?.[ext] ??
device_profiles?.['default'] ??
local_profiles?.[ext] ??
local_profiles?.['default'] ??
DEFAULT_LAUNCH_PROFILES[ext] ??
DEFAULT_LAUNCH_PROFILES['default'];
const profile = { ...source };
// Per-file display override wins over everything
if (display_override) {
profile.display_mode = display_override;
}
return profile;
}

View File

@@ -79,48 +79,40 @@ import AE_Comp_Hosted_Files_Download_Button from '$lib/ae_core/ae_comp__hosted_f
// Import the relay
import * as native from '$lib/electron/electron_relay';
import {
type LaunchProfile,
resolve_launch_profile
} from '$lib/ae_events/ae_launcher__default_launch_profiles';
let ae_promises: key_val = $state({});
let open_file_clicked: null | boolean = $state(null);
let open_file_status: null | string = $state(null);
let open_file_status_message: null | string = $state(null);
let open_file_error_detail: string | null = $state(null);
/** Simple promise-based delay for post-open script timing */
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
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}}\""
* Resolves the LaunchProfile for a given file extension and optional per-file
* display override. Checked in priority order:
* 1. event_device.data_json.launch_profiles (API-driven, per-device)
* 2. $events_loc.launcher.launch_profiles (local persistent override)
* 3. DEFAULT_LAUNCH_PROFILES[ext] (Svelte built-in defaults)
* 4. DEFAULT_LAUNCH_PROFILES['default'] (catch-all)
* Per-file display_override from event_file.cfg_json overrides display_mode only.
*/
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;
function get_launch_profile(
extension: string,
file_obj?: any
): LaunchProfile {
const device_profiles = ($ae_loc as any).native_device?.launch_profiles ?? null;
const local_profiles = ($events_loc as any).launcher?.launch_profiles ?? null;
const display_override = file_obj?.cfg_json?.display_override ?? null;
return resolve_launch_profile(extension, display_override, device_profiles, local_profiles);
}
onMount(() => {
@@ -178,29 +170,115 @@ async function handle_open_file() {
}
}
// --- Step 1: Copy cached file to a writable temp path ---
open_file_status = 'opening_file';
open_file_status_message = 'Opening Application';
open_file_status_message = 'Preparing file...';
open_file_error_detail = null;
// 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({
const copy_result = await native.copy_from_cache_to_temp({
cache_root,
hash: event_file_obj.hash_sha256,
temp_root,
filename: event_file_obj.filename,
script_template
filename: event_file_obj.filename
});
if (!launch_result.success) {
if (!copy_result.success || !copy_result.path) {
open_file_status = 'error';
open_file_status_message = `Failed to open: ${launch_result.error}`;
open_file_status_message = 'Failed to prepare file';
open_file_error_detail = copy_result.error ?? 'copy_from_cache_to_temp returned no path';
setTimeout(() => (open_file_clicked = false), 6000);
return false;
}
const resolved_path = copy_result.path;
// --- Step 2: Resolve launch profile ---
const profile = get_launch_profile(event_file_obj.extension, event_file_obj);
if (log_lvl) console.log('LaunchProfile:', profile);
// --- Step 3: Set display layout (skip silently on failure / no external display) ---
if (profile.display_mode !== 'none') {
open_file_status_message = `Setting display (${profile.display_mode})...`;
await native.set_display_layout({ mode: profile.display_mode }).catch(() => {
/* No external display or displayplacer unavailable — continue */
});
}
// --- Step 4: Open the file ---
open_file_status_message = `Opening ${profile.app}...`;
let open_ok = true;
let open_error: string | null = null;
if (profile.open_cmd) {
const cmd = profile.open_cmd.replaceAll('{{path}}', resolved_path);
const cmd_result = await native.run_cmd({ cmd });
if (!cmd_result.success) {
open_ok = false;
open_error = cmd_result.error ?? 'run_cmd failed';
}
} else {
// No open_cmd → OS default handler via shell.openPath
const os_result = await native.open_local_file_v2(resolved_path);
if (!os_result.success) {
open_ok = false;
open_error = os_result.error ?? 'open_local_file_v2 failed';
}
}
// --- Step 5: Wait for app to load before running post-script ---
if (open_ok) {
const delay = profile.post_delay_ms ?? 2000;
open_file_status_message = `Waiting for ${profile.app}...`;
await sleep(delay);
}
// --- Step 6: Run post-script (AppleScript or shell) ---
if (open_ok && profile.post_script) {
open_file_status_message = 'Running post-open automation...';
let script_ok = true;
let script_error: string | null = null;
if (profile.post_script.startsWith('shell:')) {
const shell_cmd = profile.post_script.slice('shell:'.length);
const sr = await native.run_cmd({ cmd: shell_cmd });
if (!sr.success) { script_ok = false; script_error = sr.error ?? 'run_cmd (post) failed'; }
} else {
const sr = await native.run_osascript(profile.post_script);
if (!sr.success) { script_ok = false; script_error = sr.error ?? 'run_osascript failed'; }
}
if (!script_ok) {
// Non-fatal: file is already open. Surface as warning, not error.
if (log_lvl) console.warn('post_script failed:', script_error);
open_file_status = 'fallback';
open_file_status_message = `Opened (post-script failed: ${script_error})`;
setTimeout(() => (open_file_clicked = false), 8000);
return true;
}
}
// --- Step 7: Fallback if open_cmd itself failed ---
if (!open_ok) {
if (log_lvl) console.warn('open_cmd failed, falling back to OS default:', open_error);
const fb_result = await native.open_local_file_v2(resolved_path);
if (!fb_result.success) {
open_file_status = 'error';
open_file_status_message = 'Failed to open file';
open_file_error_detail = `${profile.app} failed: ${open_error}; OS fallback: ${fb_result.error}`;
setTimeout(() => (open_file_clicked = false), 8000);
return false;
}
open_file_status = 'fallback';
open_file_status_message = '(opened with OS default)';
setTimeout(() => (open_file_clicked = false), 5000);
return true;
}
// --- Success ---
open_file_status = 'open';
open_file_status_message = `Opened in ${profile.app}`;
setTimeout(() => (open_file_clicked = false), 5000);
return launch_result.success;
return true;
}
// 2. ONSITE MODE (Browser with Modified Extensions)
else if ($events_loc.launcher.app_mode === 'onsite') {
@@ -289,12 +367,21 @@ function prevent_default<T extends Event>(fn: (event: T) => void) {
'Please wait while this file downloads...'} ***</strong>
</div>
{#if $ae_loc.is_native && $events_loc.launcher.app_mode === 'native'}
<p>Most files will automatically be opened full screen.</p>
<p>
PowerPoint or KeyNote will attempt to display in presenter
view.
</p>
<p>Please close the file when finished.</p>
{#if open_file_status === 'error'}
<p class="text-red-400">Failed to open file.</p>
{#if open_file_error_detail}
<pre class="mt-1 max-w-full overflow-x-auto whitespace-pre-wrap break-all rounded bg-black/30 px-2 py-1 font-mono text-xs text-red-300">{open_file_error_detail}</pre>
{/if}
{:else if open_file_status === 'fallback'}
<p class="text-yellow-400">(opened with OS default)</p>
{:else}
<p>Most files will automatically be opened full screen.</p>
<p>
PowerPoint or KeyNote will attempt to display in presenter
view.
</p>
<p>Please close the file when finished.</p>
{/if}
{/if}
</div>
{/if}
@@ -448,6 +535,45 @@ function prevent_default<T extends Event>(fn: (event: T) => void) {
{/if}
</button>
{#if $ae_loc.trusted_access && $ae_loc.is_native}
<!-- Display override: per-file display_mode override for this file only.
null = use profile default, 'extend' = force extend, 'mirror' = force mirror.
Stored in event_file.cfg_json.display_override. Cycles null → extend → mirror → null. -->
<button
type="button"
onclick={async () => {
const cur = event_file_obj?.cfg_json?.display_override ?? null;
let next: string | null;
if (!cur) next = 'extend';
else if (cur === 'extend') next = 'mirror';
else next = null;
const new_cfg = { ...(event_file_obj.cfg_json ?? {}), display_override: next };
await api.update_ae_obj({
api_cfg: $ae_api,
obj_type: 'event_file',
obj_id: event_file_id,
fields: { cfg_json: new_cfg }
});
events_func.load_ae_obj_id__event_file({
api_cfg: $ae_api,
event_file_id: event_file_obj?.event_file_id,
log_lvl
});
}}
class="btn btn-sm transition-all"
class:preset-tonal-primary={event_file_obj?.cfg_json?.display_override === 'extend'}
class:preset-tonal-warning={event_file_obj?.cfg_json?.display_override === 'mirror'}
title={`Display override: ${event_file_obj?.cfg_json?.display_override ?? 'default'}`}>
{#if event_file_obj?.cfg_json?.display_override === 'extend'}
Ext
{:else if event_file_obj?.cfg_json?.display_override === 'mirror'}
Mir
{:else}
<Monitor size="1em" class="m-1" />
{/if}
</button>
{/if}
<span
class="event_file_created_on preset-filled-surface-100-900 flex w-24 flex-row items-center justify-end gap-1 rounded px-1 py-0.5 text-center text-xs md:w-44"
class:hidden={hide_created_on}>