fix(display): fix mirror_of_display syntax, add Homebrew fallback, update docs

- `mirror:` was wrong; correct displayplacer param is `mirror_of_display:<uuid>`
- Same fix applied to the strip regex (extend path) and mirror detection check
- Binary lookup now tries resources/bin → /opt/homebrew/bin → /usr/local/bin
  so dev/venue Macs with `brew install displayplacer` work without bundling
- Updated TODO_AGENTS.md: marks auto-detection complete, documents one-time
  brew install step, bundling path, and optional per-device configStr override

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-05-20 16:33:25 -04:00
parent 2bf4d7c141
commit 9df9d884e5
2 changed files with 47 additions and 52 deletions

View File

@@ -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:<uuid>` 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:
**One-time setup on each venue Mac:**
```bash
brew install displayplacer
```
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).
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:
**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
**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": "<full configStr for extended layout>",
"displayplacer_config_mirror": "<full configStr for mirrored layout>"
"displayplacer_config_extend": "<output of displayplacer list in extended layout>",
"displayplacer_config_mirror": "<output of displayplacer list in mirrored layout>"
}
```
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}/`).
The handler accepts `configStr` and uses it directly, bypassing auto-detection. Pass it from the Svelte call site if needed.
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.
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 <configStr>` — 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 "<display_string>" "<display_string>"` — applies layout
- Mirror syntax: add `mirror_of_display:<primary_uuid>` to the secondary display string
- Extend syntax: set `origin:(<x>,0)` with non-overlapping x offsets per display

View File

@@ -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_id>.
// Primary display unchanged; secondary display(s) get mirror_of_display:<primary_id>.
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)`);