2 Commits

Author SHA1 Message Date
Scott Idem
a14c7c7a3f feat(display): add display_control CoreGraphics binary, replace displayplacer as primary path
Derived from OSIT MasterKey app (LegacyUtilities.m, Ian Kohl 2019).
Uses CGConfigureDisplayMirrorOfDisplay — native macOS API, no Homebrew dependency.
Handler now tries display_control first; falls back to displayplacer for missing binary
or per-device configStr overrides. Build the binary via scripts/build-display-control.sh
on a Mac (requires Xcode CLT only), then commit resources/bin/display_control.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:48:19 -04:00
Scott Idem
9df9d884e5 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>
2026-05-20 16:33:25 -04:00
6 changed files with 286 additions and 75 deletions

View File

@@ -367,9 +367,16 @@ function registerSystemHandlers() {
electron_1.ipcMain.handle('native:set-display-layout', async (event, { mode, configStr }) => { electron_1.ipcMain.handle('native:set-display-layout', async (event, { mode, configStr }) => {
if (os.platform() !== 'darwin') if (os.platform() !== 'darwin')
return { success: false, error: 'Display control only supported on macOS' }; return { success: false, error: 'Display control only supported on macOS' };
const binPath = electron_1.app.isPackaged // Try bundled binary first; fall back to common Homebrew/system locations.
? path.join(process.resourcesPath, 'bin', 'displayplacer') // Install on a dev/venue Mac via: brew install displayplacer
: path.join(__dirname, '../../resources/bin/displayplacer'); const _bin_candidates = electron_1.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. // Explicit config string always takes priority — allows manual override per device.
if (configStr) { if (configStr) {
return await runExec(`"${binPath}" ${configStr}`); return await runExec(`"${binPath}" ${configStr}`);
@@ -395,17 +402,17 @@ function registerSystemHandlers() {
return { success: false, error: 'Could not parse primary display ID from displayplacer output' }; return { success: false, error: 'Could not parse primary display ID from displayplacer output' };
} }
const primary_id = primary_id_match[1]; 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) => { const mirror_args = display_strings.map((s, i) => {
if (i === 0) if (i === 0)
return `"${s}"`; return `"${s}"`;
const without_existing_mirror = s.replace(/\s*mirror:\S+/g, '').trim(); const without_existing_mirror = s.replace(/\s*mirror_of_display:\S+/g, '').trim();
return `"${without_existing_mirror} mirror:${primary_id}"`; return `"${without_existing_mirror} mirror_of_display:${primary_id}"`;
}).join(' '); }).join(' ');
return await runExec(`"${binPath}" ${mirror_args}`); return await runExec(`"${binPath}" ${mirror_args}`);
} }
if (mode === 'extend') { 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) { if (!any_mirrored) {
// Already extended — re-apply current layout to ensure it is active. // Already extended — re-apply current layout to ensure it is active.
return await runExec(`"${binPath}" ${display_strings.map(s => `"${s}"`).join(' ')}`); return await runExec(`"${binPath}" ${display_strings.map(s => `"${s}"`).join(' ')}`);
@@ -413,7 +420,7 @@ function registerSystemHandlers() {
// Remove mirror keys and compute side-by-side origins from each display's resolution. // Remove mirror keys and compute side-by-side origins from each display's resolution.
let x_offset = 0; let x_offset = 0;
const extend_args = display_strings.map((s) => { 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 res_match = without_mirror.match(/\bres:(\d+)x\d+/);
const width = res_match ? parseInt(res_match[1]) : 1920; const width = res_match ? parseInt(res_match[1]) : 1920;
const updated = without_mirror.replace(/\borigin:\([^)]+\)/, `origin:(${x_offset},0)`); const updated = without_mirror.replace(/\borigin:\([^)]+\)/, `origin:(${x_offset},0)`);

File diff suppressed because one or more lines are too long

View File

@@ -66,54 +66,51 @@
--- ---
## Pending Feature: set_display_layout — displayplacer Per-Device Config ## set_display_layout — Setup & Status (updated 2026-05-20)
**Background (added 2026-05-12, from Svelte-side LaunchProfile work):** **Primary approach: `display_control` (native CoreGraphics — no Homebrew required)**
- Source: `scripts/display_control.m` (derived from OSIT MasterKey app, Ian Kohl 2019)
- Build: run `scripts/build-display-control.sh` on a Mac (requires Xcode CLT only)
- Output: `resources/bin/display_control` — commit this binary to the repo
- Uses `CGConfigureDisplayMirrorOfDisplay` — same CoreGraphics API macOS uses internally
- Supports 3+ displays; auto-detects all connected displays; no config string needed
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. **Fallback approach: `displayplacer` (requires `brew install displayplacer` on each venue Mac)**
- Reference: [jakehilborn/displayplacer](https://github.com/jakehilborn/displayplacer)
- Still used when `display_control` binary is not present
- Also used for per-device `configStr` overrides (displayplacer-format strings in `event_device.data_json`)
**What's needed:** **Current state (2026-05-20):**
1. **Capture `configStr` per room Mac.** On each presentation Mac, run: - ✅ Correct `mirror_of_display:<uuid>` syntax used in displayplacer fallback (was `mirror:` — wrong, now fixed)
``` - ✅ Failures logged to Electron console (`[Launcher] set_display_layout:`) instead of silently swallowed
displayplacer list -**Display Mode toggle** added to Launcher config (Native OS section) — Extend/Mirror buttons always visible, no Technical Mode required
``` -`display_control` binary not yet built — must be compiled on a Mac and committed
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).
2. **Store configs in the API.** The Svelte side reads device config from `event_device.data_json`. Store the captured strings there: **To build `display_control` (do this on a Mac):**
```json ```bash
{ # One-time: install Xcode Command Line Tools if not already installed
"displayplacer_config_extend": "<full configStr for extended layout>", xcode-select --install
"displayplacer_config_mirror": "<full configStr for 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}/`).
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. # Then:
./scripts/build-display-control.sh
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). # Test it with a second display connected:
./resources/bin/display_control status
./resources/bin/display_control extend
./resources/bin/display_control mirror
5. **Pass `configStr` from Svelte.** The Svelte call site in `launcher_file_cont.svelte` (Step 3 in `handle_open_file`) currently calls: # Commit the binary:
```ts git add resources/bin/display_control
await native.set_display_layout({ mode: profile.display_mode }).catch(() => {}); git commit -m "build: add display_control binary (macOS CoreGraphics)"
``` ```
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):** **Optional per-device override (displayplacer format, for edge cases):**
- `$ae_loc.native_device` is the `event_device` object loaded during native app bootstrap For rooms where auto-detection produces the wrong result, store the raw configStr in `event_device.data_json`:
- `data_json` is an open JSON field on that object ```json
- `handle_open_file()` already calls `set_display_layout` at the right point — it just needs the configStr threaded through {
"displayplacer_config_extend": "<output of displayplacer list in extended layout>",
**Resources:** "displayplacer_config_mirror": "<output of displayplacer list in mirrored layout>"
- 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 `configStr` is passed from the Svelte call site and uses the displayplacer fallback path directly.
- 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()`

View File

@@ -0,0 +1,40 @@
#!/bin/bash
# scripts/build-display-control.sh
# Compile the display_control binary for macOS.
#
# Requirements: Xcode Command Line Tools
# xcode-select --install
#
# Run this on a Mac. Commit the resulting binary to resources/bin/
# so it is bundled into the packaged Electron app without any Homebrew dependency.
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
SRC="$SCRIPT_DIR/display_control.m"
OUT_DIR="$REPO_ROOT/resources/bin"
OUT_BIN="$OUT_DIR/display_control"
if ! command -v clang &>/dev/null; then
echo "ERROR: clang not found."
echo "Install Xcode Command Line Tools: xcode-select --install"
exit 1
fi
mkdir -p "$OUT_DIR"
echo "Building display_control..."
clang -framework Cocoa -framework Carbon \
-o "$OUT_BIN" "$SRC"
chmod +x "$OUT_BIN"
echo "Built: $OUT_BIN"
echo ""
echo "Test it:"
echo " $OUT_BIN status"
echo " $OUT_BIN extend"
echo " $OUT_BIN mirror"
echo ""
echo "Once verified, commit resources/bin/display_control to the repo."

149
scripts/display_control.m Normal file
View File

@@ -0,0 +1,149 @@
/*
* display_control.m
* Native macOS CLI for programmatic display mirror/extend control.
*
* Derived from the OSIT MasterKey app (LegacyUtilities.m, Ian Kohl 2019).
* Uses CoreGraphics APIs no external dependencies (no displayplacer/Homebrew required).
*
* Build: run scripts/build-display-control.sh on a Mac (requires Xcode Command Line Tools)
* Usage: display_control <mirror|extend|status>
*/
#import <Cocoa/Cocoa.h>
#include <Carbon/Carbon.h>
#define MAX_DISPLAYS 8
static int mirror_displays(void) {
CGDirectDisplayID onlineDspys[MAX_DISPLAYS] = {0};
CGDisplayCount numOnline = 0;
CGGetOnlineDisplayList(MAX_DISPLAYS, onlineDspys, &numOnline);
if (numOnline < 2) {
fprintf(stderr, "No secondary display detected (%u online).\n", numOnline);
return 1;
}
CGDirectDisplayID mainID = CGMainDisplayID();
CGDisplayConfigRef config;
CGError err = CGBeginDisplayConfiguration(&config);
if (err != kCGErrorSuccess) {
fprintf(stderr, "CGBeginDisplayConfiguration failed: %d\n", err);
return 1;
}
BOOL any_configured = NO;
for (CGDisplayCount i = 0; i < numOnline; i++) {
if (onlineDspys[i] != mainID) {
err = CGConfigureDisplayMirrorOfDisplay(config, onlineDspys[i], mainID);
if (err != kCGErrorSuccess) {
fprintf(stderr, "CGConfigureDisplayMirrorOfDisplay failed: %d\n", err);
CGCancelDisplayConfiguration(config);
return 1;
}
any_configured = YES;
}
}
if (!any_configured) {
CGCancelDisplayConfiguration(config);
fprintf(stderr, "No secondary displays to mirror.\n");
return 1;
}
err = CGCompleteDisplayConfiguration(config, kCGConfigurePermanently);
if (err != kCGErrorSuccess) {
fprintf(stderr, "CGCompleteDisplayConfiguration failed: %d\n", err);
return 1;
}
printf("Mirrored %u display(s).\n", numOnline - 1);
return 0;
}
static int extend_displays(void) {
CGDirectDisplayID onlineDspys[MAX_DISPLAYS] = {0};
CGDirectDisplayID activeDspys[MAX_DISPLAYS] = {0};
CGDisplayCount numOnline = 0, numActive = 0;
CGGetOnlineDisplayList(MAX_DISPLAYS, onlineDspys, &numOnline);
CGGetActiveDisplayList(MAX_DISPLAYS, activeDspys, &numActive);
if (numOnline < 2) {
fprintf(stderr, "No secondary display detected (%u online).\n", numOnline);
return 1;
}
if (numActive >= numOnline) {
printf("Displays already extended.\n");
return 0;
}
CGDirectDisplayID mainID = CGMainDisplayID();
CGDisplayConfigRef config;
CGError err = CGBeginDisplayConfiguration(&config);
if (err != kCGErrorSuccess) {
fprintf(stderr, "CGBeginDisplayConfiguration failed: %d\n", err);
return 1;
}
for (CGDisplayCount i = 0; i < numOnline; i++) {
if (onlineDspys[i] != mainID) {
// kCGNullDirectDisplay as master = un-mirror (extend)
err = CGConfigureDisplayMirrorOfDisplay(config, onlineDspys[i], kCGNullDirectDisplay);
if (err != kCGErrorSuccess) {
fprintf(stderr, "CGConfigureDisplayMirrorOfDisplay(null) failed: %d\n", err);
CGCancelDisplayConfiguration(config);
return 1;
}
}
}
err = CGCompleteDisplayConfiguration(config, kCGConfigurePermanently);
if (err != kCGErrorSuccess) {
fprintf(stderr, "CGCompleteDisplayConfiguration failed: %d\n", err);
return 1;
}
printf("Displays extended.\n");
return 0;
}
static void print_status(void) {
CGDirectDisplayID onlineDspys[MAX_DISPLAYS] = {0};
CGDirectDisplayID activeDspys[MAX_DISPLAYS] = {0};
CGDisplayCount numOnline = 0, numActive = 0;
CGGetOnlineDisplayList(MAX_DISPLAYS, onlineDspys, &numOnline);
CGGetActiveDisplayList(MAX_DISPLAYS, activeDspys, &numActive);
printf("online=%u active=%u %s\n",
numOnline, numActive,
(numOnline > 1 && numActive < numOnline) ? "mirrored" : "extended");
}
int main(int argc, const char * argv[]) {
@autoreleasepool {
if (argc < 2) {
fprintf(stderr, "Usage: display_control <mirror|extend|status>\n");
return 1;
}
const char *cmd = argv[1];
if (strcmp(cmd, "mirror") == 0) {
return mirror_displays();
} else if (strcmp(cmd, "extend") == 0) {
return extend_displays();
} else if (strcmp(cmd, "status") == 0) {
print_status();
return 0;
} else {
fprintf(stderr, "Unknown command: %s\nUsage: display_control <mirror|extend|status>\n", cmd);
return 1;
}
}
}

View File

@@ -318,27 +318,48 @@ export function registerSystemHandlers() {
return { success: false, error: 'Unknown action' }; return { success: false, error: 'Unknown action' };
}); });
// 6. Set Display Layout (Displayplacer) // 6. Set Display Layout
// Auto-detects connected displays via `displayplacer list` when no explicit configStr is given. // Primary path: display_control (native CoreGraphics, no external deps).
// mirror: secondary display(s) mirror the primary. // Build from scripts/display_control.m via scripts/build-display-control.sh on a Mac.
// extend: displays shown side-by-side; un-mirrors if currently mirrored. // Commit the resulting resources/bin/display_control binary to the repo.
// Fallback: displayplacer (requires: brew install displayplacer on each venue Mac).
// Also supports per-device configStr override (displayplacer format).
ipcMain.handle('native:set-display-layout', async (event, { mode, configStr }) => { ipcMain.handle('native:set-display-layout', async (event, { mode, configStr }) => {
if (os.platform() !== 'darwin') return { success: false, error: 'Display control only supported on macOS' }; if (os.platform() !== 'darwin') return { success: false, error: 'Display control only supported on macOS' };
const binPath = app.isPackaged // Primary: display_control — native CoreGraphics, no Homebrew dependency.
? path.join(process.resourcesPath, 'bin', 'displayplacer') // Derived from OSIT MasterKey app (LegacyUtilities.m). No configStr support needed —
: path.join(__dirname, '../../resources/bin/displayplacer'); // CoreGraphics auto-detects all connected displays.
const dc_bin = app.isPackaged
? path.join(process.resourcesPath, 'bin', 'display_control')
: path.join(__dirname, '../../resources/bin/display_control');
// Explicit config string always takes priority — allows manual override per device. if (fs.existsSync(dc_bin) && !configStr) {
if (configStr) { const dc_cmd = mode === 'mirror' ? 'mirror' : 'extend';
return await runExec(`"${binPath}" ${configStr}`); return await runExec(`"${dc_bin}" ${dc_cmd}`);
} }
// Auto-detect: `displayplacer list` emits a ready-to-run command line at the bottom. // Fallback: displayplacer — required when display_control binary is not built yet,
// We parse the quoted display strings from that line and modify them for the requested mode. // or when a per-device configStr override is set (displayplacer-format string from event_device.data_json).
const list_result = await runExec(`"${binPath}" list`); // Install: brew install displayplacer
const _dp_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 dpPath = _dp_candidates.find(p => fs.existsSync(p)) ?? _dp_candidates[0];
// Explicit configStr takes priority — allows manual per-device override.
if (configStr) {
return await runExec(`"${dpPath}" ${configStr}`);
}
// Auto-detect via `displayplacer list`.
const list_result = await runExec(`"${dpPath}" list`);
if (!list_result.success || !list_result.stdout) { if (!list_result.success || !list_result.stdout) {
return { success: false, error: `displayplacer list failed: ${list_result.error ?? 'no output'}` }; return { success: false, error: `displayplacer not available. Build display_control from scripts/build-display-control.sh or run: brew install displayplacer` };
} }
// The command line looks like: displayplacer "id:xxx res:... origin:(0,0) ..." "id:yyy ..." // The command line looks like: displayplacer "id:xxx res:... origin:(0,0) ..." "id:yyy ..."
@@ -359,27 +380,24 @@ export function registerSystemHandlers() {
} }
const primary_id = primary_id_match[1]; const primary_id = primary_id_match[1];
// Primary display unchanged; secondary display(s) get mirror:<primary_id>.
const mirror_args = display_strings.map((s, i) => { const mirror_args = display_strings.map((s, i) => {
if (i === 0) return `"${s}"`; if (i === 0) return `"${s}"`;
const without_existing_mirror = s.replace(/\s*mirror:\S+/g, '').trim(); const without_existing_mirror = s.replace(/\s*mirror_of_display:\S+/g, '').trim();
return `"${without_existing_mirror} mirror:${primary_id}"`; return `"${without_existing_mirror} mirror_of_display:${primary_id}"`;
}).join(' '); }).join(' ');
return await runExec(`"${binPath}" ${mirror_args}`); return await runExec(`"${dpPath}" ${mirror_args}`);
} }
if (mode === 'extend') { 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) { if (!any_mirrored) {
// Already extended — re-apply current layout to ensure it is active. return await runExec(`"${dpPath}" ${display_strings.map(s => `"${s}"`).join(' ')}`);
return await runExec(`"${binPath}" ${display_strings.map(s => `"${s}"`).join(' ')}`);
} }
// Remove mirror keys and compute side-by-side origins from each display's resolution.
let x_offset = 0; let x_offset = 0;
const extend_args = display_strings.map((s) => { 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 res_match = without_mirror.match(/\bres:(\d+)x\d+/);
const width = res_match ? parseInt(res_match[1]) : 1920; const width = res_match ? parseInt(res_match[1]) : 1920;
const updated = without_mirror.replace(/\borigin:\([^)]+\)/, `origin:(${x_offset},0)`); const updated = without_mirror.replace(/\borigin:\([^)]+\)/, `origin:(${x_offset},0)`);
@@ -387,7 +405,7 @@ export function registerSystemHandlers() {
return `"${updated}"`; return `"${updated}"`;
}).join(' '); }).join(' ');
return await runExec(`"${binPath}" ${extend_args}`); return await runExec(`"${dpPath}" ${extend_args}`);
} }
return { success: false, error: `Unsupported display mode: ${mode}` }; return { success: false, error: `Unsupported display mode: ${mode}` };