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)
- **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.
- **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.
@@ -171,7 +171,7 @@ no-op when `window.aetherNative` is not present (i.e., in browser/non-native mod
### 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.
- `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.
- `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.

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] 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] **[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:**
- [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
every download so macOS always sees a new path (e.g. `wallpaper_1748123456.jpg` instead of
`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()`
**Bug:** After migrating to the Aether server-side proxy, a `503` response (Novi unreachable /
Novi 5xx) skips the automatic retry that exists for network-level errors.
**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.
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
errors). After two consecutive 503s, surfaces "Verification Unavailable" as intended.
File: `src/routes/idaa/(idaa)/+layout.svelte`.
---
@@ -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
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
**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
@@ -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
- 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
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.
### [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)
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

View File

@@ -6,6 +6,7 @@ import Launcher_Cfg_Section from './launcher_cfg_section.svelte';
import {
Code,
Columns2,
Copy,
FlaskConical,
FolderOpen,
Image,
@@ -27,6 +28,7 @@ let test_cmd_result = $state('');
let remote_app: 'powerpoint' | 'keynote' = $state('powerpoint');
let remote_status = $state('');
let system_status = $state('');
let display_mode = $state<'extend' | 'mirror'>('extend');
async function handle_remote_control(
action: 'next' | 'prev' | 'start' | 'stop'
@@ -55,6 +57,18 @@ async function handle_system_action(promise: Promise<any>, label: string) {
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
let show_power_confirm = $state<{ action: string; label: string } | null>(null);
</script>
@@ -124,6 +138,33 @@ let show_power_confirm = $state<{ action: string; label: string } | null>(null);
</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) -->
<div class="flex flex-col gap-2">
<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
</p>
<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
type="button"
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)
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 */
});
const disp_res = await native.set_display_layout({ mode: profile.display_mode }).catch((e: any) => ({ success: false, error: String(e) }));
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'}...`;
@@ -357,9 +356,8 @@ async function handle_open_file() {
// --- 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 */
});
const disp_res = await native.set_display_layout({ mode: profile.display_mode }).catch((e: any) => ({ success: false, error: String(e) }));
if (!disp_res?.success) console.warn('[Launcher] set_display_layout:', disp_res?.error ?? 'no external display');
}
// --- Step 4: Open the file ---