feat(launcher): add display mode toggle; fix silent display layout failures

- Add Extend/Mirror toggle to Native OS config section (always visible,
  no Technical Mode required). Default: Extend. State updates on success.
- Replace .catch(() => {}) swallowing with console.warn logging on both
  set_display_layout call sites so failures appear in the Electron console
- Remove old edit-mode-only Extend button (replaced by new toggle)
- Update PROJECT doc: displayplacer install note, binary lookup order, GitHub link
- Clean up TODO__Agents.md: resolve stale items, add new low-priority Electron items

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-05-20 16:33:33 -04:00
parent d9726d062e
commit 76569a872f
4 changed files with 66 additions and 91 deletions

View File

@@ -104,7 +104,7 @@ The native shell provides specialized handlers for controlling the "Podium Exper
### 5.3 Implemented Actuators (Phase 5 Complete) ### 5.3 Implemented Actuators (Phase 5 Complete)
- **Recording:** `manage_recording({action})` — Aperture session capture (`start`, `stop`, `status`). macOS only. - **Recording:** `manage_recording({action})` — Aperture session capture (`start`, `stop`, `status`). macOS only.
- **Display Layouts:** `set_display_layout({mode, configStr?})` — Mirror / Extend via `displayplacer`. macOS only. Auto-detects displays; `configStr` is an optional manual override. - **Display Layouts:** `set_display_layout({mode, configStr?})` — Mirror / Extend via [`displayplacer`](https://github.com/jakehilborn/displayplacer). macOS only. Auto-detects connected displays via `displayplacer list`; `configStr` is an optional per-device manual override. Requires `brew install displayplacer` on each venue Mac. Failures are logged to the Electron console but do not block file open. A **Display Mode** toggle (Extend / Mirror) is available in the Launcher config — Native OS section, visible without Technical Mode.
- **Power Control:** `power_control({action})` — Shutdown, reboot, sleep. macOS + Linux. - **Power Control:** `power_control({action})` — Shutdown, reboot, sleep. macOS + Linux.
- **Window Control:** `window_control({action})` — Maximize, minimize, fullscreen, kiosk mode. - **Window Control:** `window_control({action})` — Maximize, minimize, fullscreen, kiosk mode.
- **Wallpaper:** `set_wallpaper({path?, url?, url_external?, display?, api_key?, account_id?})` — Downloads from URL (cached locally) or applies a local path. Per-display targeting (`'all'`/`'primary'`/`'external'`). macOS only in production; Linux returns a dev-mode preview payload. - **Wallpaper:** `set_wallpaper({path?, url?, url_external?, display?, api_key?, account_id?})` — Downloads from URL (cached locally) or applies a local path. Per-display targeting (`'all'`/`'primary'`/`'external'`). macOS only in production; Linux returns a dev-mode preview payload.
@@ -171,7 +171,7 @@ no-op when `window.aetherNative` is not present (i.e., in browser/non-native mod
### System Management (Phase 5) ### System Management (Phase 5)
- `set_wallpaper({path?, url?, url_external?, display?, api_key?, account_id?})` — Sets desktop wallpaper. Downloads from `url` (cached to `~/Library/Caches/OSIT/wallpaper/`) or applies a local `path`. `url_external` targets the projector/second display separately. macOS only in production; Linux returns a dev-mode preview payload without applying. - `set_wallpaper({path?, url?, url_external?, display?, api_key?, account_id?})` — Sets desktop wallpaper. Downloads from `url` (cached to `~/Library/Caches/OSIT/wallpaper/`) or applies a local `path`. `url_external` targets the projector/second display separately. macOS only in production; Linux returns a dev-mode preview payload without applying.
- `window_control({action, value?})` — Electron window management: maximize, minimize, fullscreen, kiosk. - `window_control({action, value?})` — Electron window management: maximize, minimize, fullscreen, kiosk.
- `set_display_layout({mode, configStr?})` — Mirror or extend displays via displayplacer. macOS only. Auto-detects connected displays; `configStr` is an optional manual override. - `set_display_layout({mode, configStr?})` — Mirror or extend displays via [`displayplacer`](https://github.com/jakehilborn/displayplacer). macOS only. Auto-detects via `displayplacer list`; `configStr` overrides auto-detection when set. Binary lookup order: bundled `resources/bin/displayplacer``/opt/homebrew/bin/` (Apple Silicon) → `/usr/local/bin/` (Intel). Requires `brew install displayplacer` on each venue Mac if not bundled.
- `power_control({action})` — Shutdown, reboot, or sleep the host machine. macOS + Linux. - `power_control({action})` — Shutdown, reboot, or sleep the host machine. macOS + Linux.
- `manage_recording({action, options?})` — Aperture capture control (`start`/`stop`/`status`). macOS only. - `manage_recording({action, options?})` — Aperture capture control (`start`/`stop`/`status`). macOS only.
- `open_external({url, app?})` — Opens a URL in Chrome, Firefox, or the default browser. - `open_external({url, app?})` — Opens a URL in Chrome, Firefox, or the default browser.

View File

@@ -23,6 +23,7 @@ guessing defaults.
- [x] Built-in Launcher defaults refactored into canonical profile names plus extension aliases (`ae_launcher__default_launch_profiles.ts`) - [x] Built-in Launcher defaults refactored into canonical profile names plus extension aliases (`ae_launcher__default_launch_profiles.ts`)
- [x] Device-level Launch Timing section added under Launcher Configuration → Device, with per-profile `launch_profiles[profile].post_delay_ms` overrides - [x] Device-level Launch Timing section added under Launcher Configuration → Device, with per-profile `launch_profiles[profile].post_delay_ms` overrides
- [x] **URL file launch support (2026-05-13)**`event_file.extension = 'URL'` (or filename starting with `http://`/`https://`) is treated as a non-downloaded URL. Background sync skips URL files so they are never treated as cacheable hosted files. Shared `AE_Comp_Hosted_Files_Download_Button` now hard-bypasses the download path for URL records. In native mode, `handle_open_file()` routes to `native.open_external({ url, app: 'chrome' })` with `'default'` fallback. Health section crash fixed (guarded `native_device` against undefined in preview/edit mode). - [x] **URL file launch support (2026-05-13)**`event_file.extension = 'URL'` (or filename starting with `http://`/`https://`) is treated as a non-downloaded URL. Background sync skips URL files so they are never treated as cacheable hosted files. Shared `AE_Comp_Hosted_Files_Download_Button` now hard-bypasses the download path for URL records. In native mode, `handle_open_file()` routes to `native.open_external({ url, app: 'chrome' })` with `'default'` fallback. Health section crash fixed (guarded `native_device` against undefined in preview/edit mode).
- [x] **[Launcher/Electron] Display mirroring auto-detection (2026-05-20)** — `native:set-display-layout` rewrote to auto-detect displays via `displayplacer list` when no `configStr` provided. Old code silently returned `{ success: false }` (swallowed by `.catch(() => {})` in relay). Now parses `displayplacer list` output, extracts quoted display strings, builds correct mirror/extend commands. Manual `configStr` still takes priority when provided.
**Svelte-side migration — remaining before May 26:** **Svelte-side migration — remaining before May 26:**
- [x] **[Launcher] Built-in Svelte default profiles (2026-05-11)** — canonical profile constants live in `ae_launcher__default_launch_profiles.ts` with extension aliases and a `resolve_launch_profile()` 3-step fallback (device config → event config → built-in defaults). Covers macOS (`pptx`, `ppt`, `key`, `odp`, `pdf`), media (`mp4`, `mkv`, `mp3`, etc.), Windows/Parallels variants, and URL path. - [x] **[Launcher] Built-in Svelte default profiles (2026-05-11)** — canonical profile constants live in `ae_launcher__default_launch_profiles.ts` with extension aliases and a `resolve_launch_profile()` 3-step fallback (device config → event config → built-in defaults). Covers macOS (`pptx`, `ppt`, `key`, `odp`, `pdf`), media (`mp4`, `mkv`, `mp3`, etc.), Windows/Parallels variants, and URL path.
@@ -73,6 +74,18 @@ guessing defaults.
(`aether_app_native_electron`), append a timestamp or random suffix to the temp filename on (`aether_app_native_electron`), append a timestamp or random suffix to the temp filename on
every download so macOS always sees a new path (e.g. `wallpaper_1748123456.jpg` instead of every download so macOS always sees a new path (e.g. `wallpaper_1748123456.jpg` instead of
`wallpaper.jpg`). `wallpaper.jpg`).
- [ ] **[Launcher/Electron] `run_cmd`/`run_cmd_sync` — phantom `return_stdout` param (low priority)** —
`electron_relay.ts` passes `return_stdout` in the args object, but both IPC handlers ignore it
(stdout is always returned). Effectively a no-op, but creates a misleading API contract. Fix:
remove the param from relay calls, or honor it in the handlers. No behavioral impact currently.
- [ ] **[Launcher/Electron] `power_control` — sudo requirement not surfaced in UI (low priority)** —
`shutdown` and `reboot` require `sudo` on Linux. macOS works without it; Launcher only targets
macOS so this is a non-issue in production. If Linux podiums are ever deployed, `power_control`
shutdown/reboot will fail silently with a permissions error. No fix needed before CMSC.
- [ ] **[Launcher/Electron] `manage_recording` — aperture binary path on packaged builds** —
Handler resolves the aperture binary via a dev-relative path. Needs verification that the path
resolves correctly inside a packaged `.app` bundle (`app.asar` / `resources/`). Not blocking
for CMSC (recording not part of CMSC workflow). Verify on next packaged build test.
--- ---
@@ -127,57 +140,12 @@ below. The TTL + `verify_in_flight` guards are the current mitigation.
--- ---
### [IDAA] Server-side Novi verification — 503 not auto-retried (regression, 2026-05-19) ### ~~[IDAA] Server-side Novi verification — 503 not auto-retried~~ ✅ Fixed (2026-05-20)
**File:** `src/routes/idaa/(idaa)/+layout.svelte` `verify_novi_uuid()` Fixed in commit `42f40e990` — added `503` status check in `verify_novi_uuid()` before the
generic `!response.ok` throw. On first 503, waits 3s and auto-retries (same path as network
**Bug:** After migrating to the Aether server-side proxy, a `503` response (Novi unreachable / errors). After two consecutive 503s, surfaces "Verification Unavailable" as intended.
Novi 5xx) skips the automatic retry that exists for network-level errors. File: `src/routes/idaa/(idaa)/+layout.svelte`.
**Root cause:** The auto-retry branch only fires for `TypeError` and `AbortError`:
```ts
const is_network_or_timeout =
error instanceof TypeError ||
(error instanceof Error && error.name === 'AbortError');
if (is_network_or_timeout && !is_retry) {
// 3s wait → retry
}
```
In the old client-to-Novi flow, an unreachable Novi server produced a `TypeError: Failed to
fetch` — caught above, auto-retried. In the new flow the Aether server returns a clean HTTP
`503`, which hits `!response.ok``throw new Error(...)` — a plain `Error`, not `TypeError`.
`is_network_or_timeout` is `false` → no retry → `verify_failed_for_uuid` latch is set
immediately → user sees "Verification Unavailable" requiring a manual "Try Again" click for
what may be a 2-second Novi hiccup.
**Fix:** Add a `503` check before the generic `!response.ok` throw (or after), applying the
same 3-second wait + one retry that network errors get:
```ts
if (response.status === 503) {
if (is_retry) {
throw new Error(`Novi verification: Novi unreachable (503) — retry also failed`);
}
console.warn(`IDAA Layout: Novi unreachable (503) for ${uuid}. Retrying in 3s...`);
verifying_status_msg = 'Connection issue — retrying...';
await new Promise<void>((resolve) => setTimeout(resolve, 3_000));
await verify_novi_uuid(uuid, true);
return;
}
if (!response.ok) {
throw new Error(`Novi verification returned ${response.status} for UUID ${uuid}`);
}
```
**Catch block:** After the retry, if 503 still fires, it throws a plain `Error` → caught →
`is_network_or_timeout = false``verify_error_type = 'api_error'` → "Verification
Unavailable". This is correct — after two attempts the UI should tell the user.
**Note on `verify_error_type` reset:** The recursive retry call resets `verify_error_type =
null` at its top before checking the response. For 429 this is harmless (re-set on 429 check).
For 503 after this fix it is also harmless — 503 is caught before `!response.ok`. Worth knowing
if modifying the flow further.
--- ---
@@ -261,19 +229,6 @@ Phases 1, 2a, 2b are complete (see ✅ Completed below). One phase remaining:
`$ae_loc.*` auth-field read sites across 150+ files. Deferred until a Svelte runes migration `$ae_loc.*` auth-field read sites across 150+ files. Deferred until a Svelte runes migration
of the store layer itself (touching every component anyway makes the callsite sweep cheap). of the store layer itself (touching every component anyway makes the callsite sweep cheap).
### [Backend] Join event_location_id onto event_presenter API view
The `event_presenter` object currently has `event_session_id` but not `event_location_id`.
When navigating from the Presenter View to the Launcher, the frontend has to do a secondary
session lookup to discover the location (magic redirect in launcher base `+page.svelte`).
Joining `event_session.event_location_id` into the presenter view/response would let the
frontend pass the location directly in the Launcher URL without the extra lookup.
- [x] Backend: added `event_location_id` (and `event_location_id_random`) to the `event_presenter` view or API response (2026-04-09)
- [x] Frontend: updated `ae_EventPresenter` type and `properties_to_save`; now pass as `events__launcher_id` in `presenter_page_menu.svelte` (2026-04-09)
### [TypeScript] svelte-check hidden errors — discovered 2026-03-27 ### [TypeScript] svelte-check hidden errors — discovered 2026-03-27
**HOW WE FOUND THIS:** The `@lucide/svelte` 0.577.0 update (2026-03-10) dropped `class` from **HOW WE FOUND THIS:** The `@lucide/svelte` 0.577.0 update (2026-03-10) dropped `class` from
`IconProps`. Fixing it required a `declare module '@lucide/svelte'` augmentation. That `IconProps`. Fixing it required a `declare module '@lucide/svelte'` augmentation. That
@@ -428,7 +383,7 @@ Firefox unaffected. Production unaffected (public IPs only).
- `BUILD_MODE` arg can stay if needed for other build differences; just stop using it to pick the env file - `BUILD_MODE` arg can stay if needed for other build differences; just stop using it to pick the env file
- Update `.gitignore` in sveltekit to un-ignore `.env.test` / remove stale entries if desired - Update `.gitignore` in sveltekit to un-ignore `.env.test` / remove stale entries if desired
**Do not touch before the April 21 show.** Low risk but unnecessary churn right before an event. Low risk but unnecessary churn — defer until after the next active show.
- [ ] **Branch strategy cleanup:** All environments (test, prod, bak) currently pull from the same - [ ] **Branch strategy cleanup:** All environments (test, prod, bak) currently pull from the same
branches. `deploy.sh` defaults are `ae_app_3x_llm` / `development` — acceptable for now but branches. `deploy.sh` defaults are `ae_app_3x_llm` / `development` — acceptable for now but
@@ -438,12 +393,6 @@ Firefox unaffected. Production unaffected (public IPs only).
Linode → `deploy.sh`. Deferred until Gitea usage is more established. Linode → `deploy.sh`. Deferred until Gitea usage is more established.
### [Files] Download button — wrong ID used in `handle_click()` (2026-04-22)
- [x] **Fixed (2026-05-11):** All 5 spots in `ae_comp__hosted_files_download_button.svelte` updated
to use `hosted_file_obj?.hosted_file_id ?? hosted_file_id` instead of the old
`hosted_file_obj?.id || ...` chain that stopped at `event_file_id`. Needs live re-test to
confirm downloads still work correctly from Manage Files.
### [Files] `db_events.file.clear()` on upload clears all cached files (2026-04-22) ### [Files] `db_events.file.clear()` on upload clears all cached files (2026-04-22)
In `ae_comp__event_files_upload.svelte` line 114, `db_events.file.clear()` wipes the entire In `ae_comp__event_files_upload.svelte` line 114, `db_events.file.clear()` wipes the entire
`file` Dexie table, not just files for the current session/presenter. Normally harmless (the `file` Dexie table, not just files for the current session/presenter. Normally harmless (the

View File

@@ -6,6 +6,7 @@ import Launcher_Cfg_Section from './launcher_cfg_section.svelte';
import { import {
Code, Code,
Columns2, Columns2,
Copy,
FlaskConical, FlaskConical,
FolderOpen, FolderOpen,
Image, Image,
@@ -27,6 +28,7 @@ let test_cmd_result = $state('');
let remote_app: 'powerpoint' | 'keynote' = $state('powerpoint'); let remote_app: 'powerpoint' | 'keynote' = $state('powerpoint');
let remote_status = $state(''); let remote_status = $state('');
let system_status = $state(''); let system_status = $state('');
let display_mode = $state<'extend' | 'mirror'>('extend');
async function handle_remote_control( async function handle_remote_control(
action: 'next' | 'prev' | 'start' | 'stop' action: 'next' | 'prev' | 'start' | 'stop'
@@ -55,6 +57,18 @@ async function handle_system_action(promise: Promise<any>, label: string) {
setTimeout(() => (system_status = ''), 3000); setTimeout(() => (system_status = ''), 3000);
} }
async function handle_display_mode(mode: 'extend' | 'mirror') {
system_status = `Setting display: ${mode}...`;
const res = await native.set_display_layout({ mode });
if (res?.success) {
display_mode = mode;
system_status = `Display: ${mode}`;
} else {
system_status = `Display error: ${res?.error || 'No external display?'}`;
}
setTimeout(() => (system_status = ''), 4000);
}
// Modal state for dangerous actions // Modal state for dangerous actions
let show_power_confirm = $state<{ action: string; label: string } | null>(null); let show_power_confirm = $state<{ action: string; label: string } | null>(null);
</script> </script>
@@ -124,6 +138,33 @@ let show_power_confirm = $state<{ action: string; label: string } | null>(null);
</div> </div>
</div> </div>
<!-- Display mode toggle: Extend = independent screens (default); Mirror = same content on both -->
<div class="flex flex-col gap-2">
<p class="ml-1 text-[9px] font-bold uppercase opacity-50">Display Mode</p>
<div class="grid grid-cols-2 gap-1">
<button
type="button"
onclick={() => handle_display_mode('extend')}
class="btn btn-xs justify-start border-2"
class:border-primary-500={display_mode === 'extend'}
class:preset-tonal-primary={display_mode === 'extend'}
class:border-surface-400={display_mode !== 'extend'}
class:preset-tonal-surface={display_mode !== 'extend'}>
<Columns2 size="0.85em" class="mr-1 shrink-0" /> Extend
</button>
<button
type="button"
onclick={() => handle_display_mode('mirror')}
class="btn btn-xs justify-start border-2"
class:border-warning-500={display_mode === 'mirror'}
class:preset-tonal-warning={display_mode === 'mirror'}
class:border-surface-400={display_mode !== 'mirror'}
class:preset-tonal-surface={display_mode !== 'mirror'}>
<Copy size="0.85em" class="mr-1 shrink-0" /> Mirror
</button>
</div>
</div>
<!-- 2. Presentation Remote Control (Common) --> <!-- 2. Presentation Remote Control (Common) -->
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="flex flex-row items-center justify-between px-1"> <div class="flex flex-row items-center justify-between px-1">
@@ -186,19 +227,6 @@ let show_power_confirm = $state<{ action: string; label: string } | null>(null);
System Actions System Actions
</p> </p>
<div class="grid grid-cols-1 gap-1"> <div class="grid grid-cols-1 gap-1">
<button
type="button"
onclick={() =>
handle_system_action(
native.set_display_layout({
mode: 'extend'
}),
'Extend Display'
)}
class="btn btn-xs preset-tonal-surface hover:preset-filled-primary-500 justify-start">
<Columns2 size="0.85em" class="mr-1 shrink-0" /> Extend
Mode
</button>
<button <button
type="button" type="button"
onclick={() => onclick={() =>

View File

@@ -219,9 +219,8 @@ async function handle_open_file() {
// URL presentations may still want to set display mode (e.g. Google Slides → extend) // URL presentations may still want to set display mode (e.g. Google Slides → extend)
if (profile.display_mode !== 'none') { if (profile.display_mode !== 'none') {
open_file_status_message = `Setting display (${profile.display_mode})...`; open_file_status_message = `Setting display (${profile.display_mode})...`;
await native.set_display_layout({ mode: profile.display_mode }).catch(() => { const disp_res = await native.set_display_layout({ mode: profile.display_mode }).catch((e: any) => ({ success: false, error: String(e) }));
/* No external display or displayplacer unconfigured — continue */ if (!disp_res?.success) console.warn('[Launcher] set_display_layout:', disp_res?.error ?? 'no external display');
});
} }
open_file_status_message = `Opening ${event_file_obj.title || 'URL'}...`; open_file_status_message = `Opening ${event_file_obj.title || 'URL'}...`;
@@ -357,9 +356,8 @@ async function handle_open_file() {
// --- Step 3: Set display layout (skip silently on failure / no external display) --- // --- Step 3: Set display layout (skip silently on failure / no external display) ---
if (profile.display_mode !== 'none') { if (profile.display_mode !== 'none') {
open_file_status_message = `Setting display (${profile.display_mode})...`; open_file_status_message = `Setting display (${profile.display_mode})...`;
await native.set_display_layout({ mode: profile.display_mode }).catch(() => { const disp_res = await native.set_display_layout({ mode: profile.display_mode }).catch((e: any) => ({ success: false, error: String(e) }));
/* No external display or displayplacer unavailable — continue */ if (!disp_res?.success) console.warn('[Launcher] set_display_layout:', disp_res?.error ?? 'no external display');
});
} }
// --- Step 4: Open the file --- // --- Step 4: Open the file ---