diff --git a/documentation/TODO_AGENTS.md b/documentation/TODO_AGENTS.md index 8d41db1..3560ec0 100644 --- a/documentation/TODO_AGENTS.md +++ b/documentation/TODO_AGENTS.md @@ -66,54 +66,42 @@ --- -## Pending Feature: set_display_layout — displayplacer Per-Device Config +## set_display_layout — displayplacer Setup & Status (updated 2026-05-20) -**Background (added 2026-05-12, from Svelte-side LaunchProfile work):** +**Reference:** [jakehilborn/displayplacer](https://github.com/jakehilborn/displayplacer) -The Svelte Events Launcher (`launcher_file_cont.svelte`) now resolves a `LaunchProfile` per file extension and calls `native.set_display_layout({ mode })` before opening a presentation. The underlying handler in `src/main/system_handlers.ts` uses a bundled `displayplacer` macOS binary. The wiring is complete on both ends — but it **silently no-ops on every device** because `displayplacer` requires a per-machine `configStr` (the output of `displayplacer list` on *that specific Mac*) to identify the exact display UUIDs and pixel positions. Without it, `displayplacer` cannot apply any layout. +**Current state (2026-05-20):** -**What's needed:** +- ✅ Handler auto-detects displays via `displayplacer list` — no per-device `configStr` required for normal use +- ✅ Binary lookup order: `resources/bin/displayplacer` (bundled) → `/opt/homebrew/bin/displayplacer` (Apple Silicon Homebrew) → `/usr/local/bin/displayplacer` (Intel Homebrew) +- ✅ Correct `mirror_of_display:` syntax used (was `mirror:` — wrong, now fixed) +- ✅ Failures logged to Electron console (`[Launcher] set_display_layout:`) instead of silently swallowed +- ✅ **Display Mode toggle** added to Launcher config (Native OS section) — Extend/Mirror buttons always visible, no Technical Mode required -1. **Capture `configStr` per room Mac.** On each presentation Mac, run: - ``` - displayplacer list - ``` - This prints the current display configuration. Copy the full output for both the "extend" and "mirror" layouts (they differ in which display is primary and how they're positioned). +**One-time setup on each venue Mac:** +```bash +brew install displayplacer +``` +The binary is not bundled in the Electron build yet. Homebrew installs it to `/opt/homebrew/bin/` (Apple Silicon) or `/usr/local/bin/` (Intel) — both are in the fallback lookup chain. -2. **Store configs in the API.** The Svelte side reads device config from `event_device.data_json`. Store the captured strings there: - ```json - { - "displayplacer_config_extend": "", - "displayplacer_config_mirror": "" - } - ``` - Admin UI to set these values already exists via the normal event_device edit flow. You can also set them directly via the V3 CRUD API (`PATCH /v3/crud/event_device/{id}/`). +**Bundling the binary (future):** +To ship displayplacer inside the `.app` bundle without requiring Homebrew on venue Macs: +1. Copy the `displayplacer` binary to `resources/bin/displayplacer` +2. Mark it executable: `chmod +x resources/bin/displayplacer` +3. Add to `package.json` `extraResources` so `@electron/packager` includes it -3. ✅ **`set_display_layout` in `src/main/system_handlers.ts` already accepts `configStr`.** Handler signature is `{ mode, configStr }` and uses it correctly — no change needed. +**Optional per-device override (manual tuning):** +For rooms where auto-detection produces the wrong result, store the raw configStr in `event_device.data_json`: +```json +{ + "displayplacer_config_extend": "", + "displayplacer_config_mirror": "" +} +``` +The handler accepts `configStr` and uses it directly, bypassing auto-detection. Pass it from the Svelte call site if needed. -4. ✅ **Electron relay (`src/preload/index.ts`) already forwards `configStr`** — args passed as-is. `AetherNativeBridge` type in `src/shared/types.ts` updated to include `set_display_layout` with correct signature (2026-05-12). - -5. **Pass `configStr` from Svelte.** The Svelte call site in `launcher_file_cont.svelte` (Step 3 in `handle_open_file`) currently calls: - ```ts - await native.set_display_layout({ mode: profile.display_mode }).catch(() => {}); - ``` - It needs to be updated to thread `configStr` from `$ae_loc.native_device.data_json`: - ```ts - const cfg_key = profile.display_mode === 'mirror' - ? 'displayplacer_config_mirror' - : 'displayplacer_config_extend'; - const configStr = ($ae_loc as any).native_device?.data_json?.[cfg_key] ?? null; - await native.set_display_layout({ mode: profile.display_mode, configStr }).catch(() => {}); - ``` - -**Contract already in place (Svelte side — no action needed):** -- `$ae_loc.native_device` is the `event_device` object loaded during native app bootstrap -- `data_json` is an open JSON field on that object -- `handle_open_file()` already calls `set_display_layout` at the right point — it just needs the configStr threaded through - -**Resources:** -- displayplacer GitHub (usage + examples): https://github.com/jakehilborn/displayplacer -- `displayplacer list` — prints current layout as a re-runnable config string -- `displayplacer ` — applies layout; the configStr from `list` is what you pass back -- Current Electron handler: `src/main/system_handlers.ts` — find the `set_display_layout` IPC handler -- Svelte call site: `src/routes/events/[event_id]/(launcher)/launcher_file_cont.svelte` — Step 3 comment in `handle_open_file()` +**displayplacer quick reference:** +- `displayplacer list` — prints current display info and a ready-to-run config string at the bottom +- `displayplacer "" ""` — applies layout +- Mirror syntax: add `mirror_of_display:` to the secondary display string +- Extend syntax: set `origin:(,0)` with non-overlapping x offsets per display diff --git a/src/main/system_handlers.ts b/src/main/system_handlers.ts index 13cf5ce..42fc707 100644 --- a/src/main/system_handlers.ts +++ b/src/main/system_handlers.ts @@ -325,9 +325,16 @@ export function registerSystemHandlers() { ipcMain.handle('native:set-display-layout', async (event, { mode, configStr }) => { if (os.platform() !== 'darwin') return { success: false, error: 'Display control only supported on macOS' }; - const binPath = app.isPackaged - ? path.join(process.resourcesPath, 'bin', 'displayplacer') - : path.join(__dirname, '../../resources/bin/displayplacer'); + // Try bundled binary first; fall back to common Homebrew/system locations. + // Install on a dev/venue Mac via: brew install displayplacer + const _bin_candidates = app.isPackaged + ? [path.join(process.resourcesPath, 'bin', 'displayplacer')] + : [ + path.join(__dirname, '../../resources/bin/displayplacer'), + '/opt/homebrew/bin/displayplacer', // Apple Silicon Homebrew + '/usr/local/bin/displayplacer', // Intel Homebrew + ]; + const binPath = _bin_candidates.find(p => fs.existsSync(p)) ?? _bin_candidates[0]; // Explicit config string always takes priority — allows manual override per device. if (configStr) { @@ -359,18 +366,18 @@ export function registerSystemHandlers() { } const primary_id = primary_id_match[1]; - // Primary display unchanged; secondary display(s) get mirror:. + // Primary display unchanged; secondary display(s) get mirror_of_display:. const mirror_args = display_strings.map((s, i) => { if (i === 0) return `"${s}"`; - const without_existing_mirror = s.replace(/\s*mirror:\S+/g, '').trim(); - return `"${without_existing_mirror} mirror:${primary_id}"`; + const without_existing_mirror = s.replace(/\s*mirror_of_display:\S+/g, '').trim(); + return `"${without_existing_mirror} mirror_of_display:${primary_id}"`; }).join(' '); return await runExec(`"${binPath}" ${mirror_args}`); } if (mode === 'extend') { - const any_mirrored = display_strings.some(s => /\bmirror:\S+/.test(s)); + const any_mirrored = display_strings.some(s => /\bmirror_of_display:\S+/.test(s)); if (!any_mirrored) { // Already extended — re-apply current layout to ensure it is active. return await runExec(`"${binPath}" ${display_strings.map(s => `"${s}"`).join(' ')}`); @@ -379,7 +386,7 @@ export function registerSystemHandlers() { // Remove mirror keys and compute side-by-side origins from each display's resolution. let x_offset = 0; const extend_args = display_strings.map((s) => { - const without_mirror = s.replace(/\s*mirror:\S+/g, '').trim(); + const without_mirror = s.replace(/\s*mirror_of_display:\S+/g, '').trim(); const res_match = without_mirror.match(/\bres:(\d+)x\d+/); const width = res_match ? parseInt(res_match[1]) : 1920; const updated = without_mirror.replace(/\borigin:\([^)]+\)/, `origin:(${x_offset},0)`);