Compare commits
25 Commits
a3d229c803
...
530b53aa6d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
530b53aa6d | ||
|
|
cc990084fb | ||
|
|
5fdb0d1d87 | ||
|
|
44cc538ce0 | ||
|
|
978a9a6960 | ||
|
|
82430649db | ||
|
|
cdcec259f7 | ||
|
|
e5883cd53c | ||
|
|
8d5c5e39c9 | ||
|
|
39749c608a | ||
|
|
4923099cfb | ||
|
|
36bd32f172 | ||
|
|
1374f0728e | ||
|
|
c79ae92be0 | ||
|
|
49c6a2351e | ||
|
|
b697126495 | ||
|
|
8dd22912c3 | ||
|
|
f8fe4ac5a2 | ||
|
|
2c1e9d294e | ||
|
|
768fdbfb21 | ||
|
|
e74dc7a388 | ||
|
|
a3f2f17480 | ||
|
|
6c73812187 | ||
|
|
ff824ebbe5 | ||
|
|
422c9c341c |
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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}}\"",
|
||||
|
||||
@@ -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
|
||||
|
||||
45
src/app.html
45
src/app.html
@@ -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" />
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
303
src/lib/ae_events/ae_launcher__default_launch_profiles.ts
Normal file
303
src/lib/ae_events/ae_launcher__default_launch_profiles.ts
Normal 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;
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
|
||||
@@ -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={() =>
|
||||
|
||||
@@ -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 1–2</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 5–6 — 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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user