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

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