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 }) => {
if (os.platform() !== 'darwin')
return { success: false, error: 'Display control only supported on macOS' };
const binPath = electron_1.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 = 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.
if (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' };
}
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(' ')}`);
@@ -413,7 +420,7 @@ 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)`);

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:
```
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).
- ✅ 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
-**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
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": "<full configStr for extended layout>",
"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}/`).
**To build `display_control` (do this on a Mac):**
```bash
# One-time: install Xcode Command Line Tools if not already installed
xcode-select --install
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:
```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(() => {});
```
# Commit the binary:
git add resources/bin/display_control
git commit -m "build: add display_control binary (macOS CoreGraphics)"
```
**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()`
**Optional per-device override (displayplacer format, for edge cases):**
For rooms where auto-detection produces the wrong result, store the raw configStr in `event_device.data_json`:
```json
{
"displayplacer_config_extend": "<output of displayplacer list in extended layout>",
"displayplacer_config_mirror": "<output of displayplacer list in mirrored layout>"
}
```
`configStr` is passed from the Svelte call site and uses the displayplacer fallback path directly.

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' };
});
// 6. Set Display Layout (Displayplacer)
// Auto-detects connected displays via `displayplacer list` when no explicit configStr is given.
// mirror: secondary display(s) mirror the primary.
// extend: displays shown side-by-side; un-mirrors if currently mirrored.
// 6. Set Display Layout
// Primary path: display_control (native CoreGraphics, no external deps).
// Build from scripts/display_control.m via scripts/build-display-control.sh on a Mac.
// 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 }) => {
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');
// Primary: display_control — native CoreGraphics, no Homebrew dependency.
// Derived from OSIT MasterKey app (LegacyUtilities.m). No configStr support needed —
// 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 (configStr) {
return await runExec(`"${binPath}" ${configStr}`);
if (fs.existsSync(dc_bin) && !configStr) {
const dc_cmd = mode === 'mirror' ? 'mirror' : 'extend';
return await runExec(`"${dc_bin}" ${dc_cmd}`);
}
// Auto-detect: `displayplacer list` emits a ready-to-run command line at the bottom.
// We parse the quoted display strings from that line and modify them for the requested mode.
const list_result = await runExec(`"${binPath}" list`);
// Fallback: displayplacer — required when display_control binary is not built yet,
// or when a per-device configStr override is set (displayplacer-format string from event_device.data_json).
// 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) {
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 ..."
@@ -359,27 +380,24 @@ export function registerSystemHandlers() {
}
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) => {
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}`);
return await runExec(`"${dpPath}" ${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(' ')}`);
return await runExec(`"${dpPath}" ${display_strings.map(s => `"${s}"`).join(' ')}`);
}
// 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)`);
@@ -387,7 +405,7 @@ export function registerSystemHandlers() {
return `"${updated}"`;
}).join(' ');
return await runExec(`"${binPath}" ${extend_args}`);
return await runExec(`"${dpPath}" ${extend_args}`);
}
return { success: false, error: `Unsupported display mode: ${mode}` };