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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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={() =>
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
Reference in New Issue
Block a user