25 Commits

Author SHA1 Message Date
Scott Idem
530b53aa6d Fix IDAA recovery meetings auto search 2026-05-13 17:00:36 -04:00
Scott Idem
cc990084fb Updated version number... 2026-05-13 16:06:40 -04:00
Scott Idem
5fdb0d1d87 Add service worker self heal on version mismatch 2026-05-13 15:46:26 -04:00
Scott Idem
44cc538ce0 Disable service worker on localhost 2026-05-13 15:30:08 -04:00
Scott Idem
978a9a6960 Clarify service worker cross-origin guard 2026-05-13 15:13:27 -04:00
Scott Idem
82430649db Fix iframe reload and service worker caching 2026-05-13 15:08:17 -04:00
Scott Idem
cdcec259f7 fix(launcher): bypass downloads for url files 2026-05-13 13:59:43 -04:00
Scott Idem
e5883cd53c fix(launcher): skip url files in sync 2026-05-13 13:36:57 -04:00
Scott Idem
8d5c5e39c9 fix(launcher): guard health device metadata 2026-05-13 12:50:25 -04:00
Scott Idem
39749c608a fix(launcher): use per-profile timing overrides 2026-05-13 12:48:43 -04:00
Scott Idem
4923099cfb feat(launcher): add device launch timing override 2026-05-13 12:34:36 -04:00
Scott Idem
36bd32f172 docs(launcher): document post delay override 2026-05-13 12:22:11 -04:00
Scott Idem
1374f0728e refactor(launcher): canonicalize default profiles 2026-05-13 12:15:13 -04:00
Scott Idem
c79ae92be0 docs(launcher): align launch profile terminology 2026-05-13 11:50:13 -04:00
Scott Idem
49c6a2351e refactor(launcher): use launch_profiles only\n\nRemove the temporary launch_scripts compatibility alias and keep the launcher
configuration surface focused on launch_profiles everywhere in the Svelte app and
docs.
2026-05-13 10:30:10 -04:00
Scott Idem
b697126495 refactor(launcher): prefer launch_profiles naming\n\nRename the public launcher override concept to launch_profiles across the task list\nand docs, while keeping launch_scripts as a compatibility alias for older device
records. Update the Svelte resolver to read both keys so per-device tweaks remain
backward compatible during the transition.
2026-05-13 10:26:01 -04:00
Scott Idem
8dd22912c3 fix(launcher): keep native open state off download spinner\n\nAdd an opt-out on the shared hosted-file button so promise-returning native opens\ncan use Launcher status text without being treated like active downloads. Apply it\nto the Electron launcher open path so cache hits no longer show 'Downloading...'\nwhen the row status already reports the correct cache/open state. 2026-05-12 13:49:55 -04:00
Scott Idem
f8fe4ac5a2 fix(launcher): button state hygiene across all 3 modes
Fix 1 (stale error_detail):
  open_file_error_detail is now cleared at the start of every mode's
  branch (native, onsite, default) alongside open_file_clicked=true.
  Previously it was cleared mid-way through the native flow (Step 1),
  so a stale error from a previous failed run could flash briefly.

Fix 2 (stuck 'Opening...' during post_script sleep):
  After the Step 4 open call returns (run_cmd or open_local_file_v2),
  update the status message immediately to 'App opened — running setup...'
  so the button doesn't appear frozen for the full post_delay_ms wait.

Fix 3 (safety valve for hanging native calls):
  A 60s setTimeout is registered at the start of the native branch.
  If any native IPC call hangs indefinitely and the existing per-path
  reset timeouts never fire, this force-resets open_file_clicked after
  60s. All normal paths complete within ~8s so this only triggers on
  true hangs. ERR_NETWORK_CHANGED cannot re-enter the download path
  because the open_file_clicked guard blocks re-entry.
2026-05-12 13:37:11 -04:00
Scott Idem
2c1e9d294e fix(launcher): swallow orphaned IPC reply on open_local_file_v2
shell.openPath always resolves on the Electron side, but if the Svelte
renderer navigates before the IPC reply arrives, the promise rejects
with 'reply was never sent'. The file is already open at that point.

Catch the rejection and treat it as success on both call sites (Step 4
primary path and Step 7 fallback). This eliminates the unhandled
promise error without masking real failures.
2026-05-12 13:18:53 -04:00
Scott Idem
768fdbfb21 feat(launcher): URL-type event_file support + displayplacer Electron task
URL files: event_file.filename = 'https://...' is now a first-class
file type in the launcher.

- is_url derived rune detects https/http filename prefix
- URL branch in handle_open_file() runs before cache/native branches
  (no download, no temp copy, no hash)
- Offline guard: warns and blocks click if navigator.onLine is false;
  online/offline listeners registered only for URL rows (no-op on files)
- Native mode: opens via native.open_external({ url, app: 'chrome' })
  with silent fallback to default browser
- Browser mode: window.open() with noopener
- display_mode default: 'mirror' (URLs typically just need the screen
  mirrored, not extended presenter view)
- Button badge shows Link2 icon + WifiOff warning when offline
- Button text uses event_file.title as label (falls back to URL)
- Test mode popup: skips Steps 1-2 (N/A), shows open_external call

DEFAULT_LAUNCH_PROFILES: add 'url' key (display_mode: 'mirror')

Electron TODO: added set_display_layout / displayplacer per-device
config task to aether_app_native_electron/documentation/TODO_AGENTS.md
with full contract details and resources
2026-05-12 13:14:58 -04:00
Scott Idem
e74dc7a388 fix(launcher): skip post_delay sleep when no post_script
The sleep step was running unconditionally, meaning files that open
with the 'default' catch-all profile (zip, unknown ext, etc.) would
wait 2 seconds for no reason — there's no AppleScript to prepare for.

Gate the sleep inside the post_script check so it only delays when
there's actually automation to run after app launch.

Also update the test mode popup to show '0ms — skipped (no post_script)'
and display post_delay_ms as '(default: 2000ms)' when unset.
2026-05-12 12:49:50 -04:00
Scott Idem
a3f2f17480 Allow npx svelte check 2026-05-12 12:41:14 -04:00
Scott Idem
6c73812187 fix(launcher): improve test mode popup contrast for light/dark mode
- Warning text in cfg_app_modes: replace text-yellow-400/70 with
  preset-tonal-warning badge (theme-aware, readable in both modes).
- Popup dialog: bg-surface-900 → bg-surface-50/95 dark:bg-surface-900/95
  so the card adapts to light/dark instead of being locked dark.
- Code blocks: bg-black/30 → bg-surface-500/10 (neutral opacity).
- Header title: text-warning-400 → text-warning-600 dark:text-warning-400.
- Semantic text colors: all -300 variants → 500-level equivalents:
  text-green-300/400 → text-success-600 dark:text-success-400
  text-primary-300 → text-primary-500
  text-yellow-300/400 → text-warning-500
  text-purple-300 → text-purple-500
2026-05-12 12:40:39 -04:00
Scott Idem
ff824ebbe5 feat(launcher): add Native Test Mode for profile/command preview
Enables testing the LaunchProfile system from any device (no Mac/Electron
needed). When active, the Open button simulates the full native flow and
shows a debug popup with everything that WOULD be sent to Electron.

- ae_events_stores__launcher_defaults.ts: add native_test_mode boolean
  (persisted, default false) to LauncherLocState and launcher_loc_defaults.

- launcher_cfg_app_modes.svelte: add Native Test Mode checkbox toggle in
  the Advanced Toggles (Edit Mode Only) section with active-state warning.

- launcher_file_cont.svelte:
  - Add test_mode_popup_open/test_mode_popup_data state vars.
  - Add branch 0 in handle_open_file(): when native_test_mode + app_mode=native,
    skip all Electron calls; resolve the real LaunchProfile, build a data
    snapshot, open the debug popup.
  - Debug popup shows: file info, simulated temp path, cache/copy pass,
    resolved LaunchProfile fields, set_display_layout call, open command
    (run_cmd or open_local_file_v2 fallback), sleep delay, post-script
    (AppleScript or shell: prefix). Click backdrop or Close to dismiss.
2026-05-12 12:33:24 -04:00
Scott Idem
422c9c341c 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.
2026-05-12 12:17:43 -04:00
24 changed files with 1505 additions and 151 deletions

View File

@@ -15,6 +15,9 @@
"onsave"
],
"git.autofetch": true,
"editor.defaultFormatter": "svelte.svelte-vscode"
"editor.defaultFormatter": "svelte.svelte-vscode",
"chat.tools.terminal.autoApprove": {
"npx svelte-check": true
}
}
}

View File

@@ -149,6 +149,12 @@ subscribes to the **entire store**. This means unrelated writes to `$ae_loc`
what you read from these stores inside `$effect` blocks. See `PROJECT__Stores_Svelte5_Migration.md`
for the long-term fix plan.
For search pages specifically, this usually means:
- keep true user preferences in persisted local state
- keep transient triggers, loading flags, and last-executed search keys in session state when possible
- let the page effect schedule the search, but put the duplicate-execution guard inside the search executor so page-load auto-search still runs after hydration
- if the search text or filters are mirrored from localStorage on mount, expect that mount-time writes can re-trigger the effect unless the executor has its own guard
### `{#await}` blocks
```svelte
{#await somePromise}

View File

@@ -89,7 +89,7 @@ $effect(() => {
- When you have chains (presentations depend on session; presenters depend on presentation.person_id), make the dependent liveQuery explicitly wait for the upstream ID and log inside each query to verify the order — adding a small `await Promise.resolve()` or `await 0` inside the `liveQuery` is sometimes useful during debugging to ensure the JS microtask queue has a chance to settle after DB writes.
## Practical Patterns from Aether (Journals & Events)
## Practical Patterns from Aether (Journals & Events & IDAA Recovery Meetings)
- Journals: The journaling pages use SWR-style background refreshes but reliably render because either (a) the page `+page.ts` blocks to populate DB for critical views, or (b) components accept `data.initial_*` fallback values until `liveQuery` emits. This hybrid approach avoids the "refresh twice" problem while keeping navigation snappy.
- Journals broad views: if text search is empty, let the local IDB result set drive the visible list. The API can revalidate the cache in the background, but it should not replace a broad "All" view with a limited slice that hides valid rows.
@@ -98,6 +98,12 @@ $effect(() => {
- Provide `initial_session_obj` from `+page.ts` as a first-draw fallback to child components.
- Use `$derived.by(() => liveQuery(...))` for presentation lists so the observable instance is stable across renders and recreated only when `event_session_id` or `search` changes.
- Search pages with persisted filters or saved query text should keep the auto-search trigger in a page-level `$effect`, but the duplicate guard should live inside the actual search executor. That preserves the first page-load search while blocking repeated identical reruns from localStorage-backed rerenders. In practice:
- derive a single `qry_key` from the search inputs
- debounce in the `$effect`
- compare `qry_key` against a `last_executed_key` inside `handle_search_refresh()`
- keep transient loading flags and trigger counters in session state when the value is only used to force a refresh, not as a persisted preference
Example (presentation list pattern):
```typescript
let lq__event_presentation_obj_li = $derived(
@@ -114,6 +120,7 @@ let lq__event_presentation_obj_li = $derived(
- Add a small `console.log` inside each `liveQuery` closure to confirm when it runs and what `id` it sees.
- Verify that `+page.ts` either `await`s critical loads or returns `initial_*` payloads for first-render hydration.
- Confirm that dependent store values (selected IDs) are assigned before components subscribe — use `untrack` to prevent extra reactive cycles.
- If a search page stops auto-loading after a localStorage change, check whether the duplicate guard was placed in the `$effect` instead of the executor. Guarding too early can suppress the initial search; guard at execution time instead.
- If a broad Dexie-backed list shows fewer rows than a narrower filter, look for a limit or revalidation step overwriting the local IDB result set. Broad views should stay unbounded unless the user is actually narrowing by text.
- Ensure your `liveQuery` closures return quickly and do not throw; any exception inside the query can stop updates.
- If a dependent query appears stale, temporarily add `await 0` in the upstream query or an explicit `Promise.resolve()` after the IDB write to force the microtask queue to flush during debugging.

View File

@@ -37,7 +37,7 @@ platform is flexible enough to handle the full range.
### Object Hierarchy
```
```text
Event
├── Event File (walk-in/out, hold slides for the whole event)
├── Location (physical room — assigned to Sessions, not the other way around)
@@ -311,13 +311,36 @@ 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
**Configurable launch behavior:** The file-open behavior is driven by a Launch Profile, not
just a command string. Profiles are stored per file extension in
`event_device.data_json.launch_profiles` (device-level config) or
`event.launcher.launch_profiles` (event-level fallback). The built-in Svelte defaults are the
final fallback and are documented below. A profile can choose the app, display mode, open
command, and post-open automation. The resolved native template uses `{{path}}` as the file
path placeholder; AppleScript or `shell:` prefixed commands are both supported. No Electron
rebuild is required to change how files open — edit config in Aether and it applies
immediately. See `PROJECT__AE_Events_Launcher_Native_integration.md` Section 8.
### Built-In Default Launch Profiles
These are the initial built-in defaults shipped with the Launcher. They are the Svelte-side
fallbacks used when neither device config nor event config defines a profile for the file
extension. Each canonical profile can have multiple extension aliases. `post_delay_ms` is part
of the profile object, so a device-specific `launch_profiles[profile].post_delay_ms` override can
tune it later without changing the profile table.
| Profile name | Extension aliases | Default app | Display mode | Post delay | Notes |
|---|---|---|---|---|---|
| `powerpoint_mac_extend` | `pptx`, `ppt` | Microsoft PowerPoint for macOS | `extend` | `2000ms` | Open in the presentation app and extend to an external display if one is present. |
| `keynote_mac_extend` | `key` | Keynote | `extend` | `2000ms` | Keynote slideshow on the external display if available. |
| `libreoffice_mac_extend` | `odp` | LibreOffice for macOS | `extend` | `2000ms` | LibreOffice Impress for OpenDocument presentations. |
| `acrobat_mac_mirror` | `pdf` | Adobe Acrobat for macOS | `mirror` | `2000ms` | PDF handout / deck view uses mirrored display. |
| `vlc_mirror` | `mp4`, `mkv`, `mp3`, `m4v`, `m4a`, `webm`, `wav`, `aac`, `flac`, `mov`, `mpeg`, `avi`, `flv`, `ogg`, `ogv`, `wmv` | VLC for macOS | `mirror` | `2000ms` | Media playback is mirrored so the room sees the same output as the operator. |
| `powerpoint_win_extend` | `pptxwin`, `pptwin` | PowerPoint for Windows (Parallels) | `extend` | `3000ms` | Windows PowerPoint profile for Parallels-based rooms. |
| `libreoffice_win_extend` | `odpwin` | LibreOffice for Windows | `extend` | `3000ms` | Windows LibreOffice profile for Parallels-based rooms. |
| `acrobat_win_mirror` | `pdfwin` | Adobe Acrobat for Windows (Parallels) | `mirror` | `3000ms` | Windows PDF profile for mirrored display rooms. |
| `url_web` | `url` | Browser / Event File web presentation | `extend` | `n/a` | Web-based presentations are handled as Event File URLs rather than cached local files. |
Versioning is handled automatically: when a presenter uploads an updated file, the new
hash is cached separately and the old one remains intact.

View File

@@ -40,8 +40,13 @@ The integration is built on a decoupled three-layer communication model to ensur
- **File:** `src/lib/electron/electron_relay.ts`
- **Role:** Provides a clean, typed API for Svelte components.
- **Responsibilities:**
- Mapping `camelCase` UI triggers to `snake_case` IPC calls.
- Implementing "Smart Fallbacks" (e.g., resolving `[home]` placeholders if the bridge is partially hydrated).
- Mapping `camelCase` UI triggers to `snake_case` IPC calls.
- Resolving an extension alias to a canonical Launch Profile, then to a single
`native_template` string before crossing IPC.
The reason for this split is simple: Launch Profiles are policy, while Native Templates are
executable strings. Keeping that distinction explicit prevents the bridge from mixing config
objects with runtime commands.
---
@@ -147,7 +152,7 @@ no-op when `window.aetherNative` is not present (i.e., in browser/non-native mod
- `check_cache({cache_root, hash, hash_prefix_length?, verify_hash?})` — Verifies a file exists in the local hashed cache. `verify_hash: true` re-hashes to confirm integrity.
- `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. Stale `.tmp` files (older than 5 min) from crashed downloads are cleaned up automatically on each call.
- `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.
- `launch_from_cache({cache_root, hash, temp_root, filename, hash_prefix_length?, native_template?})` — Combines copy + launch in one call. Executes the provided `native_template` string after the file is copied to temp. If no template is supplied, treat it as an error and do not rely on Electron-side defaults.
> `hash_prefix_length` defaults to `2` throughout. Do not change without coordinating all devices — mismatched values create orphaned cache subdirectories.
@@ -180,44 +185,66 @@ All paths passed to native handlers should use tokens rather than hardcoded OS p
---
## 8. Configurable Launch Scripts (No-Rebuild File Handling)
## 8. Launch Profiles and Native Templates (No-Rebuild File Handling)
This launcher uses two related concepts:
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.
- **Launch Profile**: the Svelte-side config object keyed by file extension. A profile decides
which app to use, whether to extend or mirror displays, whether to use an explicit open
command, whether to run post-open automation, and how long to wait before running it.
- **Native Template**: the single AppleScript or shell command string handed to Electron after
Svelte resolves the profile. This is what Electron actually executes.
### Template Formats
The Svelte launcher resolves a profile and then passes a native template string to
`launch_from_cache`. Electron only executes the template it receives. If Svelte has not
resolved a template yet, it should stop before IPC and surface a missing-profile error.
This keeps all fallback logic in Svelte, where it can be edited without rebuilding Electron.
The native layer should not invent or guess a default launch path.
The built-in defaults are organized as canonical profile names plus extension aliases. That
lets multiple file types share one profile without repeating the same app/script details.
The profile object also carries `post_delay_ms`, and a device-specific per-profile
`launch_profiles[profile].post_delay_ms` override can tune the delay without changing the bridge
contract. URL-based presentations remain a special pseudo-extension handled separately from
the cache open flow.
### Native 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).
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
Launch profiles are resolved in priority order by `get_launch_profile()` in
`launcher_file_cont.svelte`:
1. **`event_device.data_json.launch_scripts`** — API-driven, per-device. Highest priority.
1. **`event_device.data_json.launch_profiles`** — 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
2. **`$events_loc.launcher.launch_profiles`** — 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.
If neither is set, the resolved native template is `null` and the launcher should not call
Electron until an explicit template is available.
Why: this avoids a second hidden source of truth. The profile map can evolve independently of
the executable string, and Electron stays a thin executor rather than a policy engine.
### Key Format
Keys are lowercase file extensions without the dot. A `"default"` key catches all
unrecognised extensions.
The JSON below illustrates the `native_template` emitted after profile resolution, not the
full Launch Profile object schema.
```json
// event_device.data_json.launch_scripts example
// event_device.data_json.launch_profiles example
{
"launch_scripts": {
"launch_profiles": {
"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}}\"",

View File

@@ -6,23 +6,33 @@
## 🔴 CMSC Charlotte — May 27 (Presentation Management)
**Drive down:** May 25 | **Setup:** May 26 morning | **Show:** May 27+
**[Electron/Launcher] Clean up presentation file launch scripts** — BGH show revealed issues
with the scripts used to open/launch presentation files. Architecture decision: move launch
**[Electron/Launcher] Clean up presentation file launch profiles** — BGH show revealed issues
with the profiles used to open/launch presentation files. Architecture decision: move launch
logic to the Svelte side so it can be changed without an Electron rebuild. Electron becomes a
thin OS primitive layer; all business logic lives in Svelte and device config.
Why this matters: the profile map is policy, while the native template is the exact runtime
command. Keeping those separate avoids a second hidden source of truth and keeps Electron from
guessing defaults.
**Electron groundwork (2026-05-11) — DONE:**
- [x] `run_osascript` hardened — temp `.scpt` file approach; handles multi-line + special chars
- [x] `native:copy-from-cache-to-temp` primitive added — copy to tmp, caller decides launch
- [x] `native:launch-from-cache` accepts optional `script_template` — AppleScript or `shell:` prefix; falls back to hardcoded defaults when null
- [x] `get_launch_script_template()` in `launcher_file_cont.svelte` reads from device config then event config; passes result to `launch_from_cache`
- [x] `native:launch-from-cache` executes a provided `native_template` string — AppleScript or `shell:` prefix; no Electron-side fallback
- [x] `get_launch_profile()` in `launcher_file_cont.svelte` reads from device config then event config; resolves to a `native_template` string and passes it to `launch_from_cache`
- [x] Built-in Launcher defaults refactored into canonical profile names plus extension aliases
- [x] Device-level Launch Timing section added under Launcher Configuration → Device, with per-profile `launch_profiles[profile].post_delay_ms` overrides
**Svelte-side migration — remaining before May 26:**
- [ ] **[Launcher] Built-in Svelte default templates** — move the "known good" pptx/key/pdf
AppleScript strings out of Electron hardcode and into a Svelte constants file (e.g.
`ae_launcher__default_launch_scripts.ts`). Priority: `get_launch_script_template()` already
checks device config and event config; add a 3rd fallback to these Svelte defaults before
returning `null`. This means Electron's hardcoded defaults become the last-resort only.
- [ ] **[Launcher] Built-in Svelte default profiles** — move the built-in presentation/media
policy objects into a Svelte constants file (e.g. `ae_launcher__default_launch_profiles.ts`).
Use canonical profile names plus extension aliases so the media family does not repeat the
same VLC config for every file type. Cover the core macOS set (`pptx`, `ppt`, `key`, `odp`,
`pdf`), the media set (`mp4`, `mkv`, `mp3`, and related media types), the Windows /
Parallels variants (`pptxwin`, `pptwin`, `odpwin`, `pdfwin`), and the URL/web-based
presentation path. Priority: `get_launch_profile()` already checks device config and event
config; add a 3rd fallback to these Svelte defaults before returning `null`. Keep the
fallback in Svelte, not in Electron.
- [ ] **[Launcher] Composable open flow** — refactor `handle_open_file()` to use
`copy_from_cache_to_temp` + `run_osascript` / `run_cmd` directly instead of the all-in-one
`launch_from_cache`. Finer error handling at each step (verify copy succeeded before
@@ -37,9 +47,9 @@ thin OS primitive layer; all business logic lives in Svelte and device config.
- [ ] **[Launcher] `kill_processes` target list in config** — process names to kill on cleanup
are currently caller-hardcoded. Allow device config to specify the process name list per
file type / app, so adding a new presentation app doesn't require a Svelte code change.
- [ ] **[Launcher] Launcher config UI — launch_scripts editor** — add a Technical Mode panel
in the Launcher config (tabbed settings) to view and edit `launch_scripts` entries on the
active device record. PATCH via `event_device` V3 CRUD. Lets OSIT staff tune scripts onsite
- [ ] **[Launcher] Launcher config UI — launch_profiles editor** — add a Technical Mode panel
in the Launcher config (tabbed settings) to view and edit `launch_profiles` entries on the
active device record. PATCH via `event_device` V3 CRUD. Lets OSIT staff tune launch behavior onsite
without needing phpMyAdmin or a code deploy.
- [ ] **[Launcher] End-to-end test on macOS** — test pptx and key opens on a real podium Mac
before May 26 setup day. Verify: file copies to tmp correctly, script fires, app opens in

View File

@@ -3,6 +3,51 @@
<head>
<meta charset="utf-8" />
<script>
(() => {
const hostname = window.location.hostname;
const is_local_dev =
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '[::1]' ||
hostname.endsWith('.localhost');
if (!is_local_dev || !('serviceWorker' in navigator)) return;
// Prevent the app bootstrap from re-registering a worker on localhost.
// The browser can otherwise keep routing fetches through a stale SW
// during iterative iframe testing, which is exactly the noise we want to avoid.
try {
Object.defineProperty(navigator.serviceWorker, 'register', {
configurable: true,
value: async () => ({})
});
} catch {
// If the property is not writable in this browser, the unregister
// pass below still removes any existing worker registration.
}
// Local iframe testing should not keep an older worker alive, because
// Chromium can continue to route fetches through the stale worker until
// it is explicitly unregistered.
navigator.serviceWorker.getRegistrations().then((registrations) => {
for (const registration of registrations) {
registration.unregister().catch(() => {});
}
}).catch(() => {});
// Clear any stale runtime caches as well; local testing should always
// rebuild from the current source rather than reusing old worker output.
if ('caches' in window) {
caches.keys().then((cache_keys) => {
for (const cache_key of cache_keys) {
caches.delete(cache_key).catch(() => {});
}
}).catch(() => {});
}
})();
</script>
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="manifest" href="/manifest.json" />

View File

@@ -35,6 +35,7 @@ interface Props {
require_auth?: boolean;
classes?: string;
click?: () => void | Promise<any>;
track_click_promise?: boolean;
label?: import('svelte').Snippet;
}
@@ -57,6 +58,7 @@ let {
require_auth = true,
classes = '',
click,
track_click_promise = true,
label
}: Props = $props();
@@ -172,6 +174,16 @@ let shortened_filename = $derived(
})
);
let is_url_file = $derived.by(() => {
const raw_filename = (hosted_file_obj?.filename ?? '').toLowerCase();
const extension = (hosted_file_obj?.extension ?? '').toLowerCase();
return (
raw_filename.startsWith('http://') ||
raw_filename.startsWith('https://') ||
extension === 'url'
);
});
let direct_download_url = $derived.by(() => {
if (!show_direct_download || !hosted_file_obj) return '';
// IMPORTANT: For Direct Link Mode, we MUST use the V3 Action endpoint to support Random String IDs.
@@ -188,13 +200,27 @@ let direct_download_url = $derived.by(() => {
async function handle_click() {
const file_id = hosted_file_obj?.hosted_file_id ?? hosted_file_id;
// URL-backed records are intentionally not downloaded. Callers are expected
// to provide a custom click handler that opens the URL directly.
if (is_url_file) {
if (click) {
const result = click();
if (track_click_promise && result instanceof Promise) {
ae_promises[file_id] = result;
}
}
return;
}
download_complete = undefined;
download_status_msg = 'Downloading...';
if (click) {
const result = click();
// If the override returns a promise, track it so the UI shows progress
if (result instanceof Promise) {
// If the override returns a promise, track it so the UI shows progress.
// Launcher open flows can opt out so native status messages stay authoritative.
if (track_click_promise && result instanceof Promise) {
ae_promises[file_id] = result;
}
return;

View File

@@ -0,0 +1,303 @@
/**
* 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[profile] — per-profile override, per device (API)
* 3. $events_loc.launcher.launch_profiles[profile] — local persistent override
* 4. DEFAULT_LAUNCH_PROFILES[profile/alias] — canonical built-ins + aliases
* 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:
* - speed_factor: number — delay multiplier for slower machines (1.0 = normal)
*
* Special pseudo-extension:
* - url — web-based presentations. Handled by the launcher URL branch rather
* than a cache-to-temp open flow.
*/
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. Can be overridden per profile via launch_profiles[profile].post_delay_ms.
*/
post_delay_ms?: number;
// --- Reserved for future use — not yet implemented ---
// speed_factor?: number;
// url?: string;
}
function make_vlc_mirror_profile(): LaunchProfile {
return {
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`
};
}
const POWERPOINT_MAC_EXTEND_PROFILE: LaunchProfile = {
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`
};
const KEYNOTE_MAC_EXTEND_PROFILE: LaunchProfile = {
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`
};
const LIBREOFFICE_MAC_EXTEND_PROFILE: LaunchProfile = {
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`
};
const ACROBAT_MAC_MIRROR_PROFILE: LaunchProfile = {
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`
};
const VLC_MIRROR_PROFILE: LaunchProfile = make_vlc_mirror_profile();
const POWERPOINT_WIN_EXTEND_PROFILE: LaunchProfile = {
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`
};
const LIBREOFFICE_WIN_EXTEND_PROFILE: LaunchProfile = {
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`
};
const ACROBAT_WIN_MIRROR_PROFILE: LaunchProfile = {
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`
};
const URL_WEB_PROFILE: LaunchProfile = {
app: 'Chrome',
display_mode: 'extend',
// No open_cmd or post_script — URL branch in handle_open_file() handles this
};
const DEFAULT_OS_PROFILE: LaunchProfile = {
app: 'OS Default',
display_mode: 'none',
// No open_cmd — execution falls through to open_local_file_v2(path)
// No post_script
};
type DefaultLaunchProfileDefinition = {
name: string;
aliases: string[];
profile: LaunchProfile;
};
export const DEFAULT_LAUNCH_PROFILE_DEFS: DefaultLaunchProfileDefinition[] = [
{
name: 'powerpoint_mac_extend',
aliases: ['pptx', 'ppt'],
profile: POWERPOINT_MAC_EXTEND_PROFILE
},
{
name: 'keynote_mac_extend',
aliases: ['key'],
profile: KEYNOTE_MAC_EXTEND_PROFILE
},
{
name: 'libreoffice_mac_extend',
aliases: ['odp'],
profile: LIBREOFFICE_MAC_EXTEND_PROFILE
},
{
name: 'acrobat_mac_mirror',
aliases: ['pdf'],
profile: ACROBAT_MAC_MIRROR_PROFILE
},
{
name: 'vlc_mirror',
aliases: ['mp4', 'mkv', 'mov', 'mpeg', 'avi', 'flv', 'ogg', 'ogv', 'mp3', 'm4v', 'm4a', 'webm', 'wmv', 'wav', 'aac', 'flac'],
profile: VLC_MIRROR_PROFILE
},
{
name: 'powerpoint_win_extend',
aliases: ['pptxwin', 'pptwin'],
profile: POWERPOINT_WIN_EXTEND_PROFILE
},
{
name: 'libreoffice_win_extend',
aliases: ['odpwin'],
profile: LIBREOFFICE_WIN_EXTEND_PROFILE
},
{
name: 'acrobat_win_mirror',
aliases: ['pdfwin'],
profile: ACROBAT_WIN_MIRROR_PROFILE
},
{
name: 'url_web',
aliases: ['url'],
profile: URL_WEB_PROFILE
},
{
name: 'os_default',
aliases: ['default'],
profile: DEFAULT_OS_PROFILE
}
];
export const DEFAULT_LAUNCH_PROFILE_LIBRARY: Record<string, LaunchProfile> = Object.fromEntries(
DEFAULT_LAUNCH_PROFILE_DEFS.map(({ name, profile }) => [name, profile])
);
export const DEFAULT_LAUNCH_PROFILE_ALIASES: Record<string, string> = Object.fromEntries(
DEFAULT_LAUNCH_PROFILE_DEFS.flatMap(({ name, aliases }) =>
aliases.map((alias) => [alias, name])
)
);
export const DEFAULT_LAUNCH_PROFILES: Record<string, LaunchProfile> = Object.fromEntries(
DEFAULT_LAUNCH_PROFILE_DEFS.flatMap(({ name, aliases, profile }) => [
[name, { ...profile }],
...aliases.map((alias) => [alias, { ...profile }])
])
);
/**
* 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, Partial<LaunchProfile>> | null,
local_profiles?: Record<string, Partial<LaunchProfile>> | null
): LaunchProfile {
const ext = (extension || '').toLowerCase().replace(/^\./, '');
const canonical_profile_name = DEFAULT_LAUNCH_PROFILE_ALIASES[ext] ?? ext;
const built_in_profile =
DEFAULT_LAUNCH_PROFILE_LIBRARY[canonical_profile_name] ??
DEFAULT_LAUNCH_PROFILE_LIBRARY.os_default;
const local_profile =
local_profiles?.[canonical_profile_name] ??
local_profiles?.[ext] ??
local_profiles?.['default'] ??
null;
const device_profile =
device_profiles?.[canonical_profile_name] ??
device_profiles?.[ext] ??
device_profiles?.['default'] ??
null;
const profile = {
...built_in_profile,
...(local_profile ?? {}),
...(device_profile ?? {})
};
// Per-file display override wins over everything
if (display_override) {
profile.display_mode = display_override;
}
return profile;
}

View File

@@ -35,9 +35,6 @@ let {
hide_icon = false
}: Props = $props();
// *** Import Svelte specific
import { goto } from '$app/navigation';
// *** Import other supporting libraries
import {
BadgeQuestionMark,
@@ -49,13 +46,10 @@ import {
} from '@lucide/svelte';
// *** Import Aether specific variables and functions
import {
ae_snip,
ae_loc,
ae_sess,
ae_api,
ae_trig,
slct,
slct_trigger,
type key_val
} from '$lib/stores/ae_stores';
import { api } from '$lib/api/api';
@@ -488,9 +482,8 @@ class:to-90%={$ae_sess.show_help_tech} -->
localStorage.clear();
sessionStorage.clear();
goto('/', { invalidateAll: true });
// window.location.reload();
// Reload the current iframe URL so Novi/query params stay intact.
window.location.reload();
} else {
// Confirm before clearing
if (

View File

@@ -65,7 +65,7 @@ export async function launch_from_cache({
temp_root,
filename,
hash_prefix_length = 2,
script_template = null
native_template = null
}: {
cache_root: string;
hash: string;
@@ -73,17 +73,17 @@ export async function launch_from_cache({
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.
* Resolved native launch template. If provided, Electron executes this string
* after the file is copied to temp.
*
* 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.
* Configure via per-profile launch_profiles overrides in event_device.data_json or $events_loc.launcher.
* If null, Electron should treat that as a missing profile error.
*/
script_template?: string | null;
native_template?: string | null;
}) {
if (!native)
return { success: false, error: 'Native bridge not available' };
@@ -93,7 +93,7 @@ export async function launch_from_cache({
temp_root,
filename,
hash_prefix_length,
script_template
native_template
});
}

View File

@@ -38,6 +38,7 @@ export interface LauncherLocState {
show_section__controller: boolean;
section_state__health: SectionState;
section_state__native_os: SectionState;
section_state__launch_timing: SectionState;
section_state__sync_timers: SectionState;
section_state__updates: SectionState;
section_state__controller: SectionState;
@@ -78,6 +79,13 @@ export interface LauncherLocState {
controller: string;
controller_group_code: string;
controller_client_id: string | null;
/**
* Native test mode: simulates the full native-branch open flow without Electron.
* Shows a debug popup with the resolved profile, commands, and AppleScript instead
* of actually launching files. Useful for testing LaunchProfile config from any
* device/OS without deploying to the Mac laptop.
*/
native_test_mode: boolean;
}
export interface LauncherSessState {
@@ -151,6 +159,7 @@ export const launcher_loc_defaults: LauncherLocState = {
// Values: 'collapsed' | 'auto' | 'pinned'
section_state__health: 'auto',
section_state__native_os: 'collapsed',
section_state__launch_timing: 'collapsed',
section_state__sync_timers: 'collapsed',
section_state__updates: 'collapsed',
section_state__controller: 'auto',
@@ -198,7 +207,8 @@ export const launcher_loc_defaults: LauncherLocState = {
controller: 'local',
controller_group_code: 'launcher-00',
controller_client_id: null
controller_client_id: null,
native_test_mode: false
// controller_cmd: null,
// controller_trigger_send: null,
};

View File

@@ -1,16 +1,15 @@
import { AE_IDAA_LOC_VERSION } from '$lib/stores/store_versions';
import { persisted } from 'svelte-persisted-store';
import { writable } from 'svelte/store';
import type { Writable } from 'svelte/store';
import type { key_val } from '$lib/stores/ae_stores';
const ver = '2024-08-21_1646';
/* *** BEGIN *** Initialize idaa_local_data_struct */
// Persisted to localStorage. Retains Novi identity, auth state, and IDAA
// query preferences across sessions. Wiped on schema change via store_versions.ts.
const idaa_local_data_struct: key_val = {
ver: ver,
__version: AE_IDAA_LOC_VERSION,
name: 'Aether - IDAA',
title: `OSIT's Æ IDAA`,
@@ -111,7 +110,6 @@ export const idaa_loc: Writable<key_val> = persisted(
/* *** BEGIN *** Initialize idaa_session_data_struct */
// In-memory only (not persisted). Resets on page load.
const idaa_session_data_struct: key_val = {
ver: ver,
log_lvl: 1,
archives: {
@@ -137,6 +135,7 @@ const idaa_session_data_struct: key_val = {
recovery_meetings: {
qry__status: null,
qry__fulltext_str: null,
search_version: 0,
edit__event_obj: null,
@@ -185,7 +184,7 @@ const idaa_trig_template: key_val = {
event_id: false,
post_id: false
};
export const idaa_trig: any = writable(idaa_trig_template);
export const idaa_trig: Writable<key_val> = writable(idaa_trig_template);
// Promise map — keyed by object type; used to track in-flight async operations.
const idaa_prom_template: key_val = {
@@ -194,4 +193,4 @@ const idaa_prom_template: key_val = {
event_id: false,
post_id: false
};
export const idaa_prom: any = writable(idaa_prom_template);
export const idaa_prom: Writable<key_val> = writable(idaa_prom_template);

View File

@@ -60,6 +60,7 @@ let flag_clear_idb: boolean = $state(false);
let flag_clear_local: boolean = $state(false);
let flag_clear_sess: boolean = $state(false);
let flag_reload: boolean = $state(false);
let flag_hard_reload: boolean = $state(false);
let flag_new_ver: boolean = $state(false);
let flag_expired: boolean = $state(false);
@@ -72,6 +73,7 @@ let api_error_msg = $derived($ae_loc?.account_name || 'API Server Unreachable');
let show_connection_details = $state(true);
let last_reload_time = 0;
let sw_heal_in_flight = false;
// Shallow equality guard — avoids triggering Svelte store updates when the merged
// object is functionally identical to the current one. Comparing JSON.stringify on
@@ -139,8 +141,11 @@ $effect(() => {
// B. Version & Sanity Check
if (new_loc.ver && $ae_sess.ver && new_loc.ver !== $ae_sess.ver) {
if (!flag_new_ver) {
console.log('ROOT: Version mismatch detected');
console.log(
'ROOT: Version mismatch detected; clearing stale service workers and caches before reload'
);
flag_new_ver = true;
flag_hard_reload = true;
flag_reload = true;
}
}
@@ -189,6 +194,15 @@ $effect(() => {
if (flag_clear_local) clear_local();
if (flag_clear_sess) clear_sess();
if (flag_hard_reload) {
flag_hard_reload = false;
console.log('ROOT: Executing hard reload after service worker heal');
void clear_stale_service_worker_state().finally(() => {
location.reload();
});
return;
}
console.log('ROOT: Executing throttled reload');
invalidateAll();
});
@@ -252,6 +266,35 @@ function clear_sess() {
sessionStorage.clear();
}
async function clear_stale_service_worker_state() {
if (!browser || sw_heal_in_flight) return;
sw_heal_in_flight = true;
try {
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
for (const registration of registrations) {
await registration.unregister();
}
}
} catch (err) {
console.warn('ROOT: Service worker unregister failed during heal:', err);
}
try {
if ('caches' in window) {
const cache_keys = await caches.keys();
for (const cache_key of cache_keys) {
await caches.delete(cache_key);
}
}
} catch (err) {
console.warn('ROOT: Cache cleanup failed during heal:', err);
} finally {
sw_heal_in_flight = false;
}
}
// 4. EXTERNAL INTERFACES EFFECT
$effect(() => {
if (!browser) return;

View File

@@ -33,7 +33,7 @@ const ae_account_id: null | string = null;
// const ae_no_account_id_token = PUBLIC_AE_NO_ACCOUNT_ID_TOKEN;
const ae_api_init: key_val = {
ver: '2024-08-11_11',
ver: '2026-05-13_04',
base_url: api_base_url,
base_url_bak: api_base_url_bak,
api_secret_key: api_secret_key,

View File

@@ -230,6 +230,32 @@ function apply_mode(mode: 'poster' | 'oral') {
</label>
</div>
</div>
<!-- 5. Native Test Mode (Edit Mode Only) -->
<!-- Simulates the full native open flow without Electron/Mac hardware.
Shows what commands, profile, and AppleScript WOULD be sent,
as a popup, instead of actually running them. -->
<div
class="border-surface-500/20 col-span-full mt-1 flex flex-col gap-2 border-t pt-3">
<p class="ml-1 text-[9px] font-bold uppercase opacity-50">
Dev / Testing
</p>
<label class="group flex cursor-pointer items-center gap-2 p-1">
<input
type="checkbox"
bind:checked={$events_loc.launcher.native_test_mode}
class="checkbox checkbox-sm" />
<span class="group-hover:text-warning-500 text-xs italic">
Native Test Mode
</span>
</label>
{#if $events_loc.launcher.native_test_mode}
<p class="badge preset-tonal-warning ml-1 text-[8px] leading-tight italic">
⚠ Active: Open buttons will simulate native launch and
show a debug popup instead of running commands.
</p>
{/if}
</div>
{/if}
</div>
</Launcher_Cfg_Section>

View File

@@ -146,19 +146,19 @@ function get_usage_color(pct: number) {
<div class="flex justify-between">
<span>Hostname:</span>
<span class="font-mono"
>{$ae_loc.native_device.info_hostname || '...'}</span>
>{$ae_loc.native_device?.info_hostname || '...'}</span>
</div>
<div class="flex justify-between gap-4">
<span>IP Addresses:</span>
<span class="truncate font-mono"
>{$ae_loc.native_device.info_ip_list || '...'}</span>
>{$ae_loc.native_device?.info_ip_list || '...'}</span>
</div>
<div class="mt-2 opacity-40">
<span class="text-[8px] font-bold uppercase"
>Raw Device JSON</span>
<pre
class="mt-1 max-h-32 overflow-y-auto rounded bg-black/20 p-1 text-[7px]">
{JSON.stringify($ae_loc.native_device, null, 2)}
{JSON.stringify($ae_loc.native_device ?? {}, null, 2)}
</pre>
</div>
</div>

View File

@@ -0,0 +1,368 @@
<script lang="ts">
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
import { events_func } from '$lib/ae_events/ae_events_functions';
import { events_loc } from '$lib/stores/ae_events_stores';
import {
DEFAULT_LAUNCH_PROFILE_DEFS,
resolve_launch_profile,
type LaunchProfile
} from '$lib/ae_events/ae_launcher__default_launch_profiles';
import Launcher_Cfg_Section from './launcher_cfg_section.svelte';
import { AlarmClock, RotateCcw, Save, Timer } from '@lucide/svelte';
interface Props {
on_expand?: () => void;
}
let { on_expand }: Props = $props();
type LaunchProfileOverride = Record<string, unknown> & {
post_delay_ms?: number | string | null;
};
type LaunchProfilesBucket = Record<string, LaunchProfileOverride>;
type NativeDeviceLike = {
event_device_id?: string;
id?: string;
other_json?: string | Record<string, unknown> | null;
launch_profiles?: LaunchProfilesBucket | null;
[key: string]: unknown;
};
type ProfileRow = {
name: string;
aliases: string[];
profile: LaunchProfile;
};
const profile_rows: ProfileRow[] = DEFAULT_LAUNCH_PROFILE_DEFS.filter(
({ profile }) => profile.post_delay_ms !== undefined
).map(({ name, aliases, profile }) => ({
name,
aliases,
profile
}));
const profile_row_map: Record<string, ProfileRow> = Object.fromEntries(
profile_rows.map((row) => [row.name, row])
);
let delay_inputs = $state<Record<string, string>>({});
let row_status = $state<Record<string, string>>({});
let last_device_id: string | null = null;
function get_native_device(): NativeDeviceLike | null {
const store_loc = $ae_loc as { native_device?: NativeDeviceLike };
return store_loc.native_device ?? null;
}
function get_device_id(): string | null {
const native_device = get_native_device();
return native_device?.event_device_id ?? native_device?.id ?? null;
}
function parse_other_json(raw_json: unknown): Record<string, unknown> {
if (!raw_json) return {};
if (typeof raw_json === 'object') return { ...(raw_json as Record<string, unknown>) };
if (typeof raw_json !== 'string') return {};
try {
const parsed = JSON.parse(raw_json);
return parsed && typeof parsed === 'object' ? { ...parsed } : {};
} catch {
return {};
}
}
function get_device_launch_profiles(): LaunchProfilesBucket {
const native_device = get_native_device();
const parsed_other_json = parse_other_json(native_device?.other_json);
const launcher_cfg =
(parsed_other_json.launcher as Record<string, unknown> | undefined) ?? {};
return (
(launcher_cfg.launch_profiles as LaunchProfilesBucket | undefined) ??
(native_device?.launch_profiles as LaunchProfilesBucket | undefined) ??
{}
);
}
function get_effective_delay_ms(profile_name: string): number | null {
const bucket = get_device_launch_profiles();
const profile = resolve_launch_profile(
profile_name,
null,
bucket as Record<string, Partial<LaunchProfile>>,
null
);
const delay = profile.post_delay_ms;
const parsed_delay =
typeof delay === 'string'
? Number(delay)
: typeof delay === 'number'
? delay
: Number.NaN;
return Number.isFinite(parsed_delay) && parsed_delay >= 0
? parsed_delay
: null;
}
function sync_from_device() {
const device_id = get_device_id();
if (device_id === last_device_id) return;
last_device_id = device_id;
for (const row of profile_rows) {
const current_delay = get_effective_delay_ms(row.name);
delay_inputs[row.name] = current_delay === null ? '' : String(current_delay);
}
}
sync_from_device();
$effect(() => {
sync_from_device();
});
function set_row_status(profile_name: string, message: string) {
row_status[profile_name] = message;
setTimeout(() => {
if (row_status[profile_name] === message) {
row_status[profile_name] = '';
}
}, 3000);
}
function build_other_json(profile_name: string, delay_value: number | null): string {
const native_device = get_native_device();
const other_json = parse_other_json(native_device?.other_json);
const launcher_cfg = {
...((other_json.launcher as Record<string, unknown> | undefined) ?? {})
};
const existing_profiles =
(launcher_cfg.launch_profiles as LaunchProfilesBucket | undefined) ??
(native_device?.launch_profiles as LaunchProfilesBucket | undefined) ??
{};
const next_profiles: LaunchProfilesBucket = { ...existing_profiles };
const row = profile_row_map[profile_name];
const alias_keys = row?.aliases ?? [];
let source_key: string | null = null;
if (profile_name in next_profiles) {
source_key = profile_name;
} else {
for (const alias_key of alias_keys) {
if (alias_key in next_profiles) {
source_key = alias_key;
break;
}
}
}
const source_profile = source_key
? { ...(next_profiles[source_key] as LaunchProfileOverride) }
: {};
if (source_key && source_key !== profile_name) {
delete next_profiles[source_key];
}
for (const alias_key of alias_keys) {
if (alias_key !== source_key) {
delete next_profiles[alias_key];
}
}
const default_delay_ms = row?.profile.post_delay_ms ?? null;
if (delay_value === null || delay_value === default_delay_ms) {
delete source_profile.post_delay_ms;
} else {
source_profile.post_delay_ms = delay_value;
}
if (Object.keys(source_profile).length > 0) {
next_profiles[profile_name] = source_profile;
} else {
delete next_profiles[profile_name];
}
if (Object.keys(next_profiles).length > 0) {
launcher_cfg.launch_profiles = next_profiles;
} else {
delete launcher_cfg.launch_profiles;
}
if (Object.keys(launcher_cfg).length > 0) {
other_json.launcher = launcher_cfg;
} else {
delete other_json.launcher;
}
return JSON.stringify(other_json);
}
async function save_profile_delay(profile_name: string) {
const device_id = get_device_id();
if (!device_id) {
set_row_status(profile_name, 'No native device loaded');
return;
}
const trimmed = (delay_inputs[profile_name] ?? '').trim();
const row = profile_row_map[profile_name];
const default_delay_ms = row?.profile.post_delay_ms ?? null;
let delay_value: number | null = null;
if (trimmed !== '') {
const parsed = Number(trimmed);
if (!Number.isFinite(parsed) || parsed < 0) {
set_row_status(profile_name, 'Enter a valid non-negative delay');
return;
}
delay_value = Math.round(parsed);
}
set_row_status(profile_name, 'Saving...');
const other_json = build_other_json(profile_name, delay_value);
const result = await events_func.update_ae_obj__event_device({
api_cfg: $ae_api,
event_device_id: device_id,
data_kv: {
other_json
},
log_lvl: 0
});
if (!result) {
set_row_status(profile_name, 'Save failed');
return;
}
const store_loc = $ae_loc as { native_device?: NativeDeviceLike };
store_loc.native_device = {
...get_native_device(),
...result,
other_json
};
if (delay_value === null || delay_value === default_delay_ms) {
delay_inputs[profile_name] = default_delay_ms === null ? '' : String(default_delay_ms);
set_row_status(profile_name, 'Reset to built-in default');
} else {
delay_inputs[profile_name] = String(delay_value);
set_row_status(profile_name, 'Saved');
}
}
async function reset_profile_delay(profile_name: string) {
const row = profile_row_map[profile_name];
delay_inputs[profile_name] = row?.profile.post_delay_ms === null
? ''
: String(row?.profile.post_delay_ms ?? '');
await save_profile_delay(profile_name);
}
</script>
<Launcher_Cfg_Section
title="Launch Timing"
icon={Timer}
bind:state={$events_loc.launcher.section_state__launch_timing}
{on_expand}
description="Per-profile post-open delay overrides">
{#if $ae_loc.edit_mode && !$ae_loc.is_native}
<div
class="bg-warning-500/10 border-warning-500/30 mb-1 flex items-center gap-2 rounded-lg border px-2 py-1.5">
<AlarmClock size="0.75em" class="text-warning-500" />
<span
class="text-warning-500 text-[9px] font-bold tracking-wide uppercase"
>Dev Preview — visible for layout only; save requires a native
device record</span>
</div>
{/if}
<div class="flex flex-col gap-2">
<p class="ml-1 text-[9px] font-bold uppercase opacity-50">
Per-Profile Native Open Delay
</p>
<p class="ml-1 text-[10px] leading-snug opacity-60">
Each built-in profile keeps its own timing. Leave a value at the
built-in default to avoid storing an override for that profile.
</p>
{#if !get_device_id()}
<div
class="bg-warning-500/10 border-warning-500/20 rounded-lg border px-2 py-2 text-[9px] opacity-80">
No native device record is loaded yet, so these controls are
read-only until the device config arrives.
</div>
{/if}
<div class="flex flex-col gap-2">
{#each profile_rows as row (row.name)}
<section class="border-surface-500/10 rounded-lg border p-2">
<div class="mb-2 flex items-start justify-between gap-3">
<div class="flex flex-col gap-0.5">
<p class="text-[10px] font-bold uppercase tracking-wide">
{row.profile.app}
</p>
<p class="text-[9px] opacity-55">
{row.name}
{#if row.aliases.length}
<span class="opacity-50"
>{row.aliases.join(', ')}</span
>
{/if}
</p>
</div>
<div class="text-right text-[9px] leading-tight opacity-65">
<div>
Built-in:
{row.profile.post_delay_ms ?? 'n/a'} ms
</div>
<div>
Current:
{get_effective_delay_ms(row.name) ?? 'n/a'} ms
</div>
</div>
</div>
<div class="grid grid-cols-[1fr_auto_auto] gap-2">
<input
type="number"
min="0"
step="100"
bind:value={delay_inputs[row.name]}
placeholder={`Default: ${row.profile.post_delay_ms ?? 'n/a'} ms`}
class="input input-sm preset-tonal-surface h-8 text-[10px]"
disabled={!get_device_id()} />
<button
type="button"
onclick={() => save_profile_delay(row.name)}
class="btn btn-sm preset-filled-primary h-8 px-3 text-[10px] font-bold"
disabled={!get_device_id()}>
<Save size="0.8em" class="mr-1" /> Save
</button>
<button
type="button"
onclick={() => reset_profile_delay(row.name)}
class="btn btn-sm preset-tonal-surface h-8 px-3 text-[10px]"
disabled={!get_device_id()}>
<RotateCcw size="0.8em" class="mr-1" /> Reset
</button>
</div>
{#if row_status[row.name]}
<div class="text-primary-500 mt-1 text-[9px] italic">
{row_status[row.name]}
</div>
{/if}
</section>
{/each}
</div>
</div>
</Launcher_Cfg_Section>

View File

@@ -24,6 +24,16 @@ let sync_results: Record<string, 'success' | 'error' | 'pending'> = $state({});
let sync_stats = $state({ total: 0, cached: 0, missing: 0 });
let last_heartbeat: string | null = $state(null);
function is_url_file(file_obj: any): boolean {
const filename = (file_obj?.filename ?? '') as string;
const extension = (file_obj?.extension ?? '').toLowerCase();
return (
filename.startsWith('https://') ||
filename.startsWith('http://') ||
extension === 'url'
);
}
// Loop Timings (Visible in UI)
// WHY: Session was originally 15s which combined with the SWR background-refresh
// pattern caused a continuous API call every 15s even on cache hits. 60s is still
@@ -294,11 +304,13 @@ async function run_sync_cycle() {
.anyOf(all_for_ids)
.toArray();
sync_stats.total = files.length;
const cacheable_files = files.filter((file_obj) => !is_url_file(file_obj));
sync_stats.total = cacheable_files.length;
let cached_count = 0;
let missing_count = 0;
for (const file_obj of files) {
for (const file_obj of cacheable_files) {
// Re-check pause flag each iteration — a sync cycle can run for many
// seconds if there are missing files, so we must honour a pause request
// mid-loop rather than waiting for the entire batch to finish.

View File

@@ -32,6 +32,7 @@ import Launcher_Cfg_Controller from './cfg_components/launcher_cfg_controller.sv
import Launcher_Cfg_Screen_Saver from './cfg_components/launcher_cfg_screen_saver.svelte';
import Launcher_Cfg_App_Modes from './cfg_components/launcher_cfg_app_modes.svelte';
import Launcher_Cfg_Local_Actions from './cfg_components/launcher_cfg_local_actions.svelte';
import Launcher_Cfg_Launch_Timing from './cfg_components/launcher_cfg_launch_timing.svelte';
import {
Bug,
Code,
@@ -150,7 +151,7 @@ function handle_section_expand(current_key: string) {
</div>
<!-- Tab Content -->
<div class="flex min-h-[400px] w-full flex-col gap-2">
<div class="flex min-h-100 w-full flex-col gap-2">
<!-- SETUP: everything onsite operators need day-to-day -->
{#if active_tab === 'setup'}
<div
@@ -180,6 +181,8 @@ function handle_section_expand(current_key: string) {
on_expand={() => handle_section_expand('health')} />
<Launcher_Cfg_Native_OS
on_expand={() => handle_section_expand('native_os')} />
<Launcher_Cfg_Launch_Timing
on_expand={() => handle_section_expand('launch_timing')} />
{#if $ae_loc.is_native}
<Launcher_Cfg_Updates
on_expand={() =>

View File

@@ -70,57 +70,78 @@ import {
CalendarDays,
FolderOpen,
Laptop,
Link2,
LoaderCircle,
Monitor,
Save,
Send
Send,
WifiOff
} from '@lucide/svelte';
import AE_Comp_Hosted_Files_Download_Button from '$lib/ae_core/ae_comp__hosted_files_download_button.svelte';
// 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);
/** State for the native test mode debug popup */
let test_mode_popup_open: boolean = $state(false);
let test_mode_popup_data: Record<string, any> | null = $state(null);
/** Simple promise-based delay for post-open script timing */
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/** True when the device has network connectivity. Updated reactively for URL-type files. */
let is_online: boolean = $state(typeof navigator !== 'undefined' ? navigator.onLine : true);
/**
* True when this file's filename IS a URL rather than a hosted filename.
* Convention: event_file.filename = 'https://...' or 'http://...'
* Use event_file.title as the human-readable label; extension = 'url' (or omitted).
*/
const is_url = $derived(
(event_file_obj?.filename ?? '').startsWith('https://') ||
(event_file_obj?.filename ?? '').startsWith('http://') ||
(event_file_obj?.extension ?? '').toLowerCase() === 'url'
);
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[profile] (API-driven, per-device)
* 2. $events_loc.launcher.launch_profiles[profile] (local persistent override)
* 3. DEFAULT_LAUNCH_PROFILES[profile/alias] (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 native_device = ($ae_loc as any).native_device ?? null;
const device_profiles =
native_device?.other_json?.launcher?.launch_profiles ??
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(() => {
@@ -131,6 +152,17 @@ onMount(() => {
...event_file_obj
};
}
// Only register online/offline listeners for URL-type files — no point on file rows.
if (is_url && typeof window !== 'undefined') {
const on_online = () => (is_online = true);
const on_offline = () => (is_online = false);
window.addEventListener('online', on_online);
window.addEventListener('offline', on_offline);
return () => {
window.removeEventListener('online', on_online);
window.removeEventListener('offline', on_offline);
};
}
});
async function handle_open_file() {
@@ -140,6 +172,113 @@ async function handle_open_file() {
$events_slct.event_file_id = event_file_id;
$events_slct.event_file_obj = event_file_obj;
// URL-TYPE FILE: event_file.filename is a URL (https://...), not a hosted file path.
// Handled entirely here — no cache, no download, no temp copy.
if (is_url) {
const url = event_file_obj.filename as string;
// Test mode: show debug popup instead of opening
if ($events_loc.launcher.native_test_mode && $events_loc.launcher.app_mode === 'native') {
open_file_clicked = true;
open_file_status = 'opening_file';
const profile = get_launch_profile('url', event_file_obj);
test_mode_popup_data = {
is_url: true,
filename: url,
extension: 'url',
title: event_file_obj.title || null,
hash_sha256: null,
simulated_temp_path: null,
profile,
open_cmd_resolved: `native.open_external({ url: "${url}", app: "chrome" })`,
display_override: event_file_obj?.cfg_json?.display_override ?? null,
cache_check: 'N/A — URL file',
copy_to_temp: 'N/A — URL file'
};
test_mode_popup_open = true;
open_file_status = 'open';
open_file_status_message = 'Test Mode: URL profile resolved — see popup';
setTimeout(() => (open_file_clicked = false), 6000);
return true;
}
// Offline guard: warn and abort before attempting to open
if (!is_online) {
open_file_clicked = true;
open_file_status = 'error';
open_file_status_message = 'Network offline — cannot open URL';
open_file_error_detail = `URL: ${url}`;
setTimeout(() => (open_file_clicked = false), 6000);
return false;
}
open_file_clicked = true;
open_file_status = 'opening_file';
const profile = get_launch_profile('url', event_file_obj);
// URL presentations may still want to set display mode (e.g. Google Slides → extend)
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 unconfigured — continue */
});
}
open_file_status_message = `Opening ${event_file_obj.title || 'URL'}...`;
if ($ae_loc.is_native && $events_loc.launcher.app_mode === 'native') {
// Native: open in Chrome for kiosk-style presentation; fall back to default browser
const result = await native.open_external({ url, app: 'chrome' });
if (!result?.success) {
await native.open_external({ url, app: 'default' }).catch(() => {});
}
} else {
window.open(url, '_blank', 'noopener,noreferrer');
}
open_file_status = 'open';
open_file_status_message = `Opened: ${event_file_obj.title || url}`;
setTimeout(() => (open_file_clicked = false), 4000);
return true;
}
// 0. NATIVE TEST MODE — simulate full native flow, show debug popup instead of running commands
// Active when native_test_mode toggle is on (regardless of is_native / app_mode).
// Lets you preview the resolved profile, open command, and post-script from any device.
if ($events_loc.launcher.native_test_mode && $events_loc.launcher.app_mode === 'native') {
open_file_clicked = true;
open_file_status = 'checking_cache';
open_file_status_message = 'Test Mode: simulating cache check...';
await sleep(400); // Brief simulated cache check
open_file_status = 'opening_file';
open_file_status_message = 'Test Mode: resolving launch profile...';
const profile = get_launch_profile(event_file_obj.extension, event_file_obj);
const open_cmd_resolved = profile.open_cmd
? profile.open_cmd.replaceAll('{{path}}', `/tmp/ae_test/${event_file_obj.filename}`)
: null;
test_mode_popup_data = {
filename: event_file_obj.filename,
extension: event_file_obj.extension,
hash_sha256: event_file_obj.hash_sha256,
simulated_temp_path: `/tmp/ae_test/${event_file_obj.filename}`,
profile,
open_cmd_resolved,
display_override: event_file_obj?.cfg_json?.display_override ?? null,
cache_check: 'PASS (simulated)',
copy_to_temp: 'PASS (simulated)'
};
test_mode_popup_open = true;
open_file_status = 'open';
open_file_status_message = 'Test Mode: profile resolved — see popup';
setTimeout(() => (open_file_clicked = false), 6000);
return true;
}
// 1. NATIVE MODE (Electron)
if ($ae_loc.is_native && $events_loc.launcher.app_mode === 'native') {
const cache_root = $ae_loc.local_file_cache_path;
@@ -148,6 +287,17 @@ async function handle_open_file() {
open_file_clicked = true;
open_file_status = 'checking_cache';
open_file_status_message = 'Checking local cache...';
open_file_error_detail = null; // Fix 1: clear stale error from any previous attempt
// Fix 2: safety valve — if a native call hangs and no path resets the button,
// force-release it after 60s. All normal paths reset within ~8s so this is last resort.
setTimeout(() => {
if (open_file_clicked) {
open_file_clicked = false;
open_file_status = 'error';
open_file_status_message = 'Timed out — please try again';
}
}, 60_000);
const exists = await native.check_hash_file_cache({
cache_root,
@@ -178,35 +328,134 @@ 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.
// .catch: IPC reply can be orphaned if the renderer navigates before shell.openPath
// resolves. The file still opens — treat the orphaned reply as success.
const os_result = await native.open_local_file_v2(resolved_path).catch(() => ({ success: true }));
if (!os_result.success) {
open_ok = false;
open_error = os_result.error ?? 'open_local_file_v2 failed';
}
}
// Fix 3: update the status message as soon as the open call returns so "Opening..." doesn't
// appear stuck for the entire post_script sleep. OS has the request; we're just waiting now.
if (open_ok) {
open_file_status_message = profile.post_script
? `${profile.app} opened — running setup...`
: `${profile.app} opened`;
}
// --- Step 5: Wait for app to load before running post-script ---
// Only delay if there is actually a post_script to run — no point waiting for nothing.
if (open_ok && profile.post_script) {
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);
// .catch: same orphaned-reply guard as Step 4.
const fb_result = await native.open_local_file_v2(resolved_path).catch(() => ({ success: true }));
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') {
open_file_clicked = true;
open_file_status = 'downloading_onsite';
open_file_status_message = 'Downloading (Onsite Mode)...';
open_file_error_detail = null;
let filename = event_file_obj.filename;
if (
@@ -238,6 +487,7 @@ async function handle_open_file() {
open_file_clicked = true;
open_file_status = 'downloading_default';
open_file_status_message = 'Downloading...';
open_file_error_detail = null;
const dl_promise = api.get_object({
api_cfg: $ae_api,
@@ -289,12 +539,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}
@@ -344,6 +603,7 @@ function prevent_default<T extends Event>(fn: (event: T) => void) {
hosted_file_id={event_file_id}
hosted_file_obj={event_file_obj}
require_auth={false}
track_click_promise={!($ae_loc.is_native && $events_loc.launcher.app_mode === 'native')}
classes="btn {btn_size} gap-1 justify-between min-w-full w-full max-w-96 preset-tonal-primary border border-primary-500"
click={handle_open_file}>
{#snippet label()}
@@ -362,17 +622,23 @@ function prevent_default<T extends Event>(fn: (event: T) => void) {
{/if}
</span>
{:then result}
{@const FileIcon =
ae_util.file_extension_icon_lucide(
event_file_obj.extension
)}
<FileIcon size="1em" class="mx-0.5 inline" />
{event_file_obj.extension}
{#if result === null || result === false}
<span class="text-error-500"
><AlertTriangle
size="1em"
class="mx-1 inline" />Failed!</span>
{#if is_url}
<Link2 size="1em" class="mx-0.5 inline {!is_online ? 'text-warning-500' : ''}" />
<span class:text-warning-500={!is_online}>url</span>
{#if !is_online}<WifiOff size="0.85em" class="mx-0.5 inline text-warning-500" title="Network offline" />{/if}
{:else}
{@const FileIcon =
ae_util.file_extension_icon_lucide(
event_file_obj.extension
)}
<FileIcon size="1em" class="mx-0.5 inline" />
{event_file_obj.extension}
{#if result === null || result === false}
<span class="text-error-500"
><AlertTriangle
size="1em"
class="mx-1 inline" />Failed!</span>
{/if}
{/if}
{:catch error}
<span class="text-error-500" title={error?.message}
@@ -385,7 +651,9 @@ function prevent_default<T extends Event>(fn: (event: T) => void) {
<span
class="grow {text_size} {text_size_md} w-full max-w-full overflow-hidden text-ellipsis {btn_text_align}">
{ae_util.shorten_string({
string: event_file_obj.filename_no_ext,
string: is_url
? (event_file_obj.title || event_file_obj.filename)
: event_file_obj.filename_no_ext,
begin_length: 45,
max_length: 65
})}
@@ -448,6 +716,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}>
@@ -469,3 +776,136 @@ function prevent_default<T extends Event>(fn: (event: T) => void) {
</span>
</span>
</div>
<!-- Native Test Mode Debug Popup -->
<!-- Shows what WOULD be sent to Electron: resolved profile, open command, post-script.
Appears when native_test_mode is active and a file is "opened". -->
{#if test_mode_popup_open && test_mode_popup_data}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/70 p-4"
role="presentation"
onclick={() => (test_mode_popup_open = false)}>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="bg-surface-50/95 dark:bg-surface-900/95 border-warning-500/40 relative flex max-h-[90vh] w-full max-w-2xl flex-col gap-0 overflow-hidden rounded-xl border shadow-2xl"
role="dialog"
aria-modal="true"
aria-label="Native Test Mode Debug Info"
tabindex="-1"
onclick={(e) => e.stopPropagation()}>
<!-- Header -->
<div class="bg-warning-500/10 border-warning-500/30 flex items-center gap-2 border-b px-4 py-3">
<span class="text-warning-600 dark:text-warning-400 font-mono text-xs font-bold uppercase tracking-wider">
🧪 Native Test Mode What would run on Mac
</span>
<button
type="button"
onclick={() => (test_mode_popup_open = false)}
class="btn btn-xs preset-tonal-surface ml-auto">
Close
</button>
</div>
<!-- Scrollable content -->
<div class="flex flex-col gap-3 overflow-y-auto p-4 font-mono text-xs">
<!-- File info -->
<div class="flex flex-col gap-1">
<span class="text-[9px] font-bold uppercase opacity-50">File</span>
<div class="rounded bg-surface-500/10 px-3 py-2 leading-relaxed">
{#if test_mode_popup_data.is_url}
<div><span class="opacity-50">type: </span><span class="text-warning-500">URL file (no local cache)</span></div>
<div><span class="opacity-50">url: </span><span class="text-primary-500 break-all">{test_mode_popup_data.filename}</span></div>
{#if test_mode_popup_data.title}
<div><span class="opacity-50">title: </span>{test_mode_popup_data.title}</div>
{/if}
{:else}
<div><span class="opacity-50">filename: </span>{test_mode_popup_data.filename}</div>
<div><span class="opacity-50">extension: </span>{test_mode_popup_data.extension}</div>
<div><span class="opacity-50">hash: </span><span class="opacity-60">{test_mode_popup_data.hash_sha256}</span></div>
<div><span class="opacity-50">temp path: </span><span class="text-success-600 dark:text-success-400">{test_mode_popup_data.simulated_temp_path}</span></div>
{/if}
</div>
</div>
<!-- Cache / copy -->
<div class="flex flex-col gap-1">
<span class="text-[9px] font-bold uppercase opacity-50">Steps 12</span>
<div class="rounded bg-surface-500/10 px-3 py-2 leading-relaxed">
{#if test_mode_popup_data.is_url}
<div class="opacity-40">Skipped URL file (no cache download or temp copy)</div>
{:else}
<div><span class="opacity-50">check_hash_file_cache: </span><span class="text-success-600 dark:text-success-400">{test_mode_popup_data.cache_check}</span></div>
<div><span class="opacity-50">copy_from_cache_to_temp: </span><span class="text-success-600 dark:text-success-400">{test_mode_popup_data.copy_to_temp}</span></div>
{/if}
</div>
</div>
<!-- Resolved profile -->
<div class="flex flex-col gap-1">
<span class="text-[9px] font-bold uppercase opacity-50">Resolved LaunchProfile</span>
<div class="rounded bg-surface-500/10 px-3 py-2 leading-relaxed">
<div><span class="opacity-50">app: </span><span class="text-primary-500">{test_mode_popup_data.profile.app}</span></div>
<div><span class="opacity-50">display_mode: </span><span class:text-primary-500={test_mode_popup_data.profile.display_mode === 'extend'} class:text-warning-500={test_mode_popup_data.profile.display_mode === 'mirror'} class:opacity-40={test_mode_popup_data.profile.display_mode === 'none'}>{test_mode_popup_data.profile.display_mode}</span></div>
{#if test_mode_popup_data.display_override}
<div><span class="text-warning-500 opacity-80">display_override (cfg_json): </span><span class="text-warning-500">{test_mode_popup_data.display_override}</span></div>
{/if}
<div><span class="opacity-50">post_delay_ms: </span>{test_mode_popup_data.profile.post_delay_ms ?? '(default: 2000ms)'}</div>
</div>
</div>
<!-- Step 3: set_display_layout -->
<div class="flex flex-col gap-1">
<span class="text-[9px] font-bold uppercase opacity-50">Step 3 set_display_layout</span>
<div class="rounded bg-surface-500/10 px-3 py-2">
{#if test_mode_popup_data.profile.display_mode !== 'none'}
<span class="text-primary-500">native.set_display_layout({{ mode: '{test_mode_popup_data.profile.display_mode}' }})</span>
{:else}
<span class="opacity-40">skipped (display_mode: none)</span>
{/if}
</div>
</div>
<!-- Step 4: open command -->
<div class="flex flex-col gap-1">
<span class="text-[9px] font-bold uppercase opacity-50">Step 4 — Open File</span>
<div class="rounded bg-surface-500/10 px-3 py-2">
{#if test_mode_popup_data.is_url}
<div class="mb-1 opacity-50 text-[9px]">native.open_external()</div>
<pre class="whitespace-pre-wrap break-all text-primary-500">{test_mode_popup_data.open_cmd_resolved}</pre>
{:else if test_mode_popup_data.open_cmd_resolved}
<div class="mb-1 opacity-50 text-[9px]">native.run_cmd()</div>
<pre class="whitespace-pre-wrap break-all text-success-600 dark:text-success-400">{test_mode_popup_data.open_cmd_resolved}</pre>
{:else}
<div class="opacity-50 text-[9px]">native.open_local_file_v2()</div>
<span class="text-warning-500">{test_mode_popup_data.simulated_temp_path}</span>
<span class="ml-2 opacity-40">(OS default handler)</span>
{/if}
</div>
</div>
<!-- Step 5: post-script -->
<div class="flex flex-col gap-1">
<span class="text-[9px] font-bold uppercase opacity-50">Steps 56 — Wait + Post-Script</span>
<div class="rounded bg-surface-500/10 px-3 py-2">
<div class="mb-1 opacity-50">sleep({test_mode_popup_data.profile.post_script ? (test_mode_popup_data.profile.post_delay_ms ?? 2000) : 0}ms){test_mode_popup_data.profile.post_script ? '' : ' skipped (no post_script)'}</div>
{#if test_mode_popup_data.profile.post_script}
{#if test_mode_popup_data.profile.post_script.startsWith('shell:')}
<div class="mb-1 opacity-50 text-[9px]">native.run_cmd() [shell prefix]</div>
<pre class="whitespace-pre-wrap break-all text-warning-500">{test_mode_popup_data.profile.post_script.slice(6)}</pre>
{:else}
<div class="mb-1 opacity-50 text-[9px]">native.run_osascript() [AppleScript]</div>
<pre class="whitespace-pre-wrap text-purple-500">{test_mode_popup_data.profile.post_script}</pre>
{/if}
{:else}
<span class="opacity-40">no post_script</span>
{/if}
</div>
</div>
</div><!-- end scroll -->
</div>
</div>
{/if}

View File

@@ -31,16 +31,15 @@ if (browser) {
$idaa_slct.event_id = null;
window.parent.postMessage({ event_id: null }, '*');
// Use versioning instead of boolean to avoid loops
if ($idaa_loc.recovery_meetings.search_version === undefined) {
$idaa_loc.recovery_meetings.search_version = 0;
}
$idaa_loc.recovery_meetings.search_version++;
// Use a session-scoped trigger so the persisted IDAA profile is not rewritten
// on every page mount. Recovery Meetings only needs this to kick the initial search.
$idaa_sess.recovery_meetings.search_version++;
}
let event_id_li: Array<string> = $state([]);
let search_debounce_timer: any = null;
let last_search_id = 0;
let last_executed_key = '';
// Standardized Reactive Search Pattern (Aether UI V3)
// This effect manages the orchestration between UI state and data fetching.
@@ -56,7 +55,7 @@ $effect(() => {
// Track filters and the search version (trigger)
const qry_params = {
v: $idaa_loc.recovery_meetings.search_version,
v: $idaa_sess.recovery_meetings.search_version,
str: $idaa_loc.recovery_meetings.qry__fulltext_str,
phys: $idaa_loc.recovery_meetings.qry__physical,
virt: $idaa_loc.recovery_meetings.qry__virtual,
@@ -65,13 +64,14 @@ $effect(() => {
order: $idaa_loc.recovery_meetings.qry__order_by,
remote: $idaa_loc.recovery_meetings.qry__remote_first
};
const qry_key = JSON.stringify(qry_params);
// 2. Debounce Logic
if (search_debounce_timer) clearTimeout(search_debounce_timer);
search_debounce_timer = setTimeout(() => {
// 3. Execution (Untracked to prevent loops)
untrack(() => {
handle_search_refresh();
handle_search_refresh(qry_key);
});
}, 250);
@@ -85,7 +85,10 @@ $effect(() => {
*
* GOAL: Render matching meetings in < 50ms, then update with perfect server data.
*/
async function handle_search_refresh() {
async function handle_search_refresh(qry_key: string) {
if (qry_key === last_executed_key) return;
last_executed_key = qry_key;
const current_search_id = ++last_search_id;
const account_id = $ae_loc.account_id;
const remote_first = $idaa_loc.recovery_meetings.qry__remote_first;

View File

@@ -69,10 +69,7 @@ if (
* debounced search cycle automatically.
*/
function handle_search_trigger() {
if ($idaa_loc.recovery_meetings.search_version === undefined) {
$idaa_loc.recovery_meetings.search_version = 0;
}
$idaa_loc.recovery_meetings.search_version++;
$idaa_sess.recovery_meetings.search_version++;
}
function prevent_default<T extends Event>(fn: (event: T) => void) {

View File

@@ -31,25 +31,35 @@ self.addEventListener('activate', (event) => {
});
self.addEventListener('fetch', (event) => {
// ignore POST requests etc
// Only handle same-origin GET requests for the app shell and static assets.
// Chromium can surface private-network/CORS failures on cross-origin API calls,
// so we intentionally leave those requests to the browser untouched here.
if (event.request.method !== 'GET') return;
if (!event.request.url.startsWith('http')) return;
// Skip CDN/API/extension requests. This worker should only cache the app origin.
const request_url = new URL(event.request.url);
if (request_url.origin !== self.location.origin) return;
async function respond() {
const url = new URL(event.request.url);
const cache = await caches.open(CACHE);
// `build`/`files` can always be served from the cache
if (ASSETS.includes(url.pathname)) {
const cachedResponse = await cache.match(url.pathname);
// App build assets and static files are safe to serve directly from cache.
if (ASSETS.includes(request_url.pathname)) {
const cachedResponse = await cache.match(request_url.pathname);
if (cachedResponse) return cachedResponse;
}
// for everything else, try the network first, but fall back to the cache if we're offline
// For same-origin runtime requests, prefer the network and fall back to cache if offline.
try {
const response = await fetch(event.request);
if (response.status === 200) {
cache.put(event.request, response.clone());
try {
await cache.put(event.request, response.clone());
} catch (err) {
console.warn('Service worker cache put skipped:', err);
}
}
return response;