diff --git a/scripts/display_control.m b/scripts/display_control.m index d95e7bf..b71743e 100644 --- a/scripts/display_control.m +++ b/scripts/display_control.m @@ -11,8 +11,12 @@ #import #include +#include #define MAX_DISPLAYS 8 +#define MAX_MODES 256 + +typedef struct { long src_index; size_t w, h, pw, ph; double refresh; } ModeEntry; static int mirror_displays(void) { CGDirectDisplayID onlineDspys[MAX_DISPLAYS] = {0}; @@ -125,10 +129,200 @@ static void print_status(void) { (numOnline > 1 && numActive < numOnline) ? "mirrored" : "extended"); } +// ── list-modes ────────────────────────────────────────────────────────────── +// Outputs a JSON array describing every online display and its available modes. + +static void list_modes(void) { + CGDirectDisplayID displays[MAX_DISPLAYS]; + CGDisplayCount count = 0; + CGGetOnlineDisplayList(MAX_DISPLAYS, displays, &count); + CGDirectDisplayID mainID = CGMainDisplayID(); + + printf("[\n"); + for (CGDisplayCount d = 0; d < count; d++) { + CGDirectDisplayID dID = displays[d]; + CGDisplayModeRef currentMode = CGDisplayCopyDisplayMode(dID); + size_t curW = CGDisplayModeGetWidth(currentMode); + size_t curH = CGDisplayModeGetHeight(currentMode); + double curR = CGDisplayModeGetRefreshRate(currentMode); + size_t curPW = CGDisplayModeGetPixelWidth(currentMode); + size_t curPH = CGDisplayModeGetPixelHeight(currentMode); + + // Include HiDPI duplicate entries so scaled modes are visible. + CFStringRef optKeys[] = { kCGDisplayShowDuplicateLowResolutionModes }; + CFBooleanRef optVals[] = { kCFBooleanTrue }; + CFDictionaryRef opts = CFDictionaryCreate(NULL, + (const void **)optKeys, (const void **)optVals, 1, + &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks); + CFArrayRef allModes = CGDisplayCopyAllDisplayModes(dID, opts); + CFRelease(opts); + CFIndex total = CFArrayGetCount(allModes); + + // Collect usable modes first so we know the count for comma handling. + ModeEntry usable[MAX_MODES]; + int usable_count = 0; + for (CFIndex m = 0; m < total && usable_count < MAX_MODES; m++) { + CGDisplayModeRef mode = (CGDisplayModeRef)CFArrayGetValueAtIndex(allModes, m); + if (!CGDisplayModeIsUsableForDesktopGUI(mode)) continue; + usable[usable_count++] = (ModeEntry){ + .src_index = (long)m, + .w = CGDisplayModeGetWidth(mode), + .h = CGDisplayModeGetHeight(mode), + .pw = CGDisplayModeGetPixelWidth(mode), + .ph = CGDisplayModeGetPixelHeight(mode), + .refresh = CGDisplayModeGetRefreshRate(mode) + }; + } + + printf(" {\n"); + printf(" \"index\": %u,\n", d); + printf(" \"id\": %u,\n", (unsigned int)dID); + printf(" \"is_main\": %s,\n", dID == mainID ? "true" : "false"); + printf(" \"current_width\": %zu,\n", curW); + printf(" \"current_height\": %zu,\n", curH); + printf(" \"current_refresh\": %.2f,\n", curR); + printf(" \"current_pixel_width\": %zu,\n", curPW); + printf(" \"current_pixel_height\": %zu,\n", curPH); + printf(" \"modes\": [\n"); + for (int i = 0; i < usable_count; i++) { + ModeEntry *e = &usable[i]; + int isCurrent = (e->w == curW && e->h == curH && + e->pw == curPW && e->ph == curPH && + fabs(e->refresh - curR) < 0.5); + printf(" {\"index\":%ld,\"width\":%zu,\"height\":%zu," + "\"refresh\":%.2f,\"pixel_width\":%zu,\"pixel_height\":%zu," + "\"hidpi\":%s,\"is_current\":%s}%s\n", + e->src_index, e->w, e->h, e->refresh, e->pw, e->ph, + (e->pw > e->w || e->ph > e->h) ? "true" : "false", + isCurrent ? "true" : "false", + (i < usable_count - 1) ? "," : ""); + } + printf(" ]\n"); + printf(" }%s\n", (d < count - 1) ? "," : ""); + + CGDisplayModeRelease(currentMode); + CFRelease(allModes); + } + printf("]\n"); +} + +// ── set-mode ───────────────────────────────────────────────────────────────── +// display_idx : index from list-modes (0 = primary, 1 = first external, ...) +// req_w/h : logical width × height (what macOS calls "looks like X×Y") +// req_refresh : 0 = pick highest available; >0 = must be within 1 Hz +// force_hidpi : 1 = HiDPI only; -1 = non-HiDPI only; 0 = auto +// auto prefers HiDPI on the built-in, non-HiDPI on externals + +static int set_mode(int display_idx, size_t req_w, size_t req_h, + double req_refresh, int force_hidpi) { + CGDirectDisplayID displays[MAX_DISPLAYS]; + CGDisplayCount count = 0; + CGGetOnlineDisplayList(MAX_DISPLAYS, displays, &count); + + if (display_idx < 0 || (CGDisplayCount)display_idx >= count) { + fprintf(stderr, "Display index %d out of range (0..%u).\n", + display_idx, count > 0 ? count - 1 : 0); + return 1; + } + + CGDirectDisplayID dID = displays[display_idx]; + int isMain = (dID == CGMainDisplayID()); + + CFStringRef optKeys[] = { kCGDisplayShowDuplicateLowResolutionModes }; + CFBooleanRef optVals[] = { kCFBooleanTrue }; + CFDictionaryRef opts = CFDictionaryCreate(NULL, + (const void **)optKeys, (const void **)optVals, 1, + &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks); + CFArrayRef allModes = CGDisplayCopyAllDisplayModes(dID, opts); + CFRelease(opts); + CFIndex total = CFArrayGetCount(allModes); + + CGDisplayModeRef bestMode = NULL; + double bestRefresh = -1.0; + int bestScore = -1; // higher = more preferred + + for (CFIndex m = 0; m < total; m++) { + CGDisplayModeRef mode = (CGDisplayModeRef)CFArrayGetValueAtIndex(allModes, m); + if (!CGDisplayModeIsUsableForDesktopGUI(mode)) continue; + + size_t w = CGDisplayModeGetWidth(mode); + size_t h = CGDisplayModeGetHeight(mode); + if (w != req_w || h != req_h) continue; + + double refresh = CGDisplayModeGetRefreshRate(mode); + if (req_refresh > 0.0 && fabs(refresh - req_refresh) > 1.0) continue; + + size_t pw = CGDisplayModeGetPixelWidth(mode); + int isHiDPI = (pw > w); + + if (force_hidpi == 1 && !isHiDPI) continue; + if (force_hidpi == -1 && isHiDPI) continue; + + // Score: prefer HiDPI on main display, non-HiDPI on external. + int score = (force_hidpi == 0) + ? ((isMain && isHiDPI) || (!isMain && !isHiDPI)) ? 1 : 0 + : 0; + + if (bestMode == NULL || score > bestScore || + (score == bestScore && refresh > bestRefresh)) { + bestMode = mode; + bestRefresh = refresh; + bestScore = score; + } + } + + if (!bestMode) { + fprintf(stderr, "No matching mode for display %d: %zux%zu", + display_idx, req_w, req_h); + if (req_refresh > 0.0) fprintf(stderr, " @%.0fHz", req_refresh); + if (force_hidpi == 1) fprintf(stderr, " [HiDPI required]"); + if (force_hidpi == -1) fprintf(stderr, " [non-HiDPI required]"); + fprintf(stderr, ".\nRun: display_control list-modes\n"); + CFRelease(allModes); + return 1; + } + + size_t setW = CGDisplayModeGetWidth(bestMode); + size_t setH = CGDisplayModeGetHeight(bestMode); + double setR = CGDisplayModeGetRefreshRate(bestMode); + size_t setPW = CGDisplayModeGetPixelWidth(bestMode); + size_t setPH = CGDisplayModeGetPixelHeight(bestMode); + + CGDisplayConfigRef config; + CGError err = CGBeginDisplayConfiguration(&config); + if (err != kCGErrorSuccess) { + fprintf(stderr, "CGBeginDisplayConfiguration failed: %d\n", err); + CFRelease(allModes); + return 1; + } + + err = CGConfigureDisplayWithDisplayMode(config, dID, bestMode, NULL); + if (err != kCGErrorSuccess) { + fprintf(stderr, "CGConfigureDisplayWithDisplayMode failed: %d\n", err); + CGCancelDisplayConfiguration(config); + CFRelease(allModes); + return 1; + } + + err = CGCompleteDisplayConfiguration(config, kCGConfigurePermanently); + if (err != kCGErrorSuccess) { + fprintf(stderr, "CGCompleteDisplayConfiguration failed: %d\n", err); + CFRelease(allModes); + return 1; + } + + printf("Set display %d to %zux%zu @%.0fHz (pixel %zux%zu).\n", + display_idx, setW, setH, setR, setPW, setPH); + CFRelease(allModes); + return 0; +} + +// ── main ───────────────────────────────────────────────────────────────────── + int main(int argc, const char * argv[]) { @autoreleasepool { if (argc < 2) { - fprintf(stderr, "Usage: display_control \n"); + fprintf(stderr, "Usage: display_control \n"); return 1; } @@ -141,8 +335,31 @@ int main(int argc, const char * argv[]) { } else if (strcmp(cmd, "status") == 0) { print_status(); return 0; + } else if (strcmp(cmd, "list-modes") == 0) { + list_modes(); + return 0; + } else if (strcmp(cmd, "set-mode") == 0) { + if (argc < 5) { + fprintf(stderr, "Usage: display_control set-mode [--refresh ] [--hidpi] [--no-hidpi]\n"); + return 1; + } + int display_idx = atoi(argv[2]); + size_t req_w = (size_t)atol(argv[3]); + size_t req_h = (size_t)atol(argv[4]); + double req_refresh = 0.0; + int force_hidpi = 0; + for (int i = 5; i < argc; i++) { + if (strcmp(argv[i], "--refresh") == 0 && i + 1 < argc) { + req_refresh = atof(argv[++i]); + } else if (strcmp(argv[i], "--hidpi") == 0) { + force_hidpi = 1; + } else if (strcmp(argv[i], "--no-hidpi") == 0) { + force_hidpi = -1; + } + } + return set_mode(display_idx, req_w, req_h, req_refresh, force_hidpi); } else { - fprintf(stderr, "Unknown command: %s\nUsage: display_control \n", cmd); + fprintf(stderr, "Unknown command: %s\nUsage: display_control \n", cmd); return 1; } } diff --git a/src/main/system_handlers.ts b/src/main/system_handlers.ts index ba2600a..63ffa34 100644 --- a/src/main/system_handlers.ts +++ b/src/main/system_handlers.ts @@ -413,6 +413,63 @@ export function registerSystemHandlers() { return { success: false, error: `Unsupported display mode: ${mode}` }; }); + // 6b. List Display Modes + ipcMain.handle('native:list-display-modes', async () => { + if (os.platform() !== 'darwin') return { success: false, error: 'Display control only supported on macOS' }; + + const dc_bin = app.isPackaged + ? path.join(process.resourcesPath, 'bin', 'display_control') + : path.join(__dirname, '../../resources/bin/display_control'); + + if (!fs.existsSync(dc_bin)) { + return { success: false, error: 'display_control binary not found. Build via scripts/build-display-control.sh.' }; + } + + const result = await runExec(`"${dc_bin}" list-modes`); + if (!result.success || !result.stdout) { + return { success: false, error: result.error ?? 'list-modes returned no output' }; + } + + try { + const displays = JSON.parse(result.stdout); + return { success: true, displays }; + } catch (e: any) { + return { success: false, error: `Failed to parse list-modes output: ${e.message}`, raw: result.stdout }; + } + }); + + // 6c. Set Display Mode + ipcMain.handle('native:set-display-mode', async (event, { + display_index, + width, + height, + refresh_rate, + hidpi + }: { + display_index: number; + width: number; + height: number; + refresh_rate?: number; + hidpi?: boolean | null; + }) => { + if (os.platform() !== 'darwin') return { success: false, error: 'Display control only supported on macOS' }; + + const dc_bin = app.isPackaged + ? path.join(process.resourcesPath, 'bin', 'display_control') + : path.join(__dirname, '../../resources/bin/display_control'); + + if (!fs.existsSync(dc_bin)) { + return { success: false, error: 'display_control binary not found. Build via scripts/build-display-control.sh.' }; + } + + let cmd = `"${dc_bin}" set-mode ${display_index} ${width} ${height}`; + if (refresh_rate) cmd += ` --refresh ${refresh_rate}`; + if (hidpi === true) cmd += ' --hidpi'; + if (hidpi === false) cmd += ' --no-hidpi'; + + return await runExec(cmd); + }); + // 7. Update App ipcMain.handle('native:update-app', async (event, { source, url, path: localPath }) => { // 1. Determine Source File diff --git a/src/preload/index.ts b/src/preload/index.ts index 1175173..4e64d0a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -5,7 +5,7 @@ contextBridge.exposeInMainWorld('aetherNative', { get_device_config: () => ipcRenderer.invoke('get-device-config'), get_jwt: () => ipcRenderer.invoke('get-jwt'), get_device_info: () => ipcRenderer.invoke('get-device-info'), - + open_folder: (path: string) => ipcRenderer.invoke('native:open-folder', path), run_cmd: (args: any) => ipcRenderer.invoke('native:run-cmd', args), run_cmd_sync: (args: any) => ipcRenderer.invoke('native:run-cmd-sync', args), @@ -27,6 +27,8 @@ contextBridge.exposeInMainWorld('aetherNative', { window_control: (args: any) => ipcRenderer.invoke('native:window-control', args), manage_recording: (args: any) => ipcRenderer.invoke('native:manage-recording', args), set_display_layout: (args: any) => ipcRenderer.invoke('native:set-display-layout', args), + list_display_modes: () => ipcRenderer.invoke('native:list-display-modes'), + set_display_mode: (args: any) => ipcRenderer.invoke('native:set-display-mode', args), power_control: (args: any) => ipcRenderer.invoke('native:power-control', args), open_external: (args: any) => ipcRenderer.invoke('native:open-external', args), }); diff --git a/src/shared/types.ts b/src/shared/types.ts index 2aeb1e9..bbcf89d 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,3 +1,26 @@ +export interface DisplayMode { + index: number; + width: number; + height: number; + refresh: number; + pixel_width: number; + pixel_height: number; + hidpi: boolean; + is_current: boolean; +} + +export interface DisplayInfo { + index: number; + id: number; + is_main: boolean; + current_width: number; + current_height: number; + current_refresh: number; + current_pixel_width: number; + current_pixel_height: number; + modes: DisplayMode[]; +} + export interface SeedConfig { event_device_id: string; primary_api_base_url: string; @@ -37,6 +60,8 @@ export interface AetherNativeBridge { open_external: (args: {url: string, app?: 'chrome' | 'firefox'}) => Promise<{success: boolean, error?: string}>; manage_recording: (args: {action: 'start' | 'stop' | 'status', options?: {fps?: number, audioDeviceId?: string, output?: string}}) => Promise<{success: boolean, isRecording?: boolean, pid?: number, error?: string}>; set_display_layout: (args: {mode: 'mirror' | 'extend', configStr?: string | null}) => Promise<{success: boolean, error?: string, stdout?: string, stderr?: string}>; + list_display_modes: () => Promise<{success: boolean, displays?: DisplayInfo[], error?: string, raw?: string}>; + set_display_mode: (args: {display_index: number, width: number, height: number, refresh_rate?: number, hidpi?: boolean | null}) => Promise<{success: boolean, stdout?: string, stderr?: string, error?: string}>; update_app: (args: {source: 'url' | 'file', url?: string, path?: string}) => Promise<{success: boolean, message?: string, downloadedPath?: string, error?: string}>; // Self-Documentation