@bobfrankston/winpos 2.0.52 → 2.0.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ffi-wrapper.js CHANGED
@@ -22,6 +22,61 @@ const VERTSIZE = 6;
22
22
  export const MONITORINFOF_PRIMARY = 1;
23
23
  // Monitor scale factor as a percentage (100 = no scaling, 125 = 125%)
24
24
  export const DEFAULT_SCALE_PERCENT = 100;
25
+ // 2026-09-24 - Claude Code (Fable 5.1), at Bob's direction: monitor arrangement
26
+ // save/restore folded in from %OneDrive%\xfer\bin\displays.ps1 (see
27
+ // .llm/screens-restore.md). Everything below down to WindowsAPI is for that.
28
+ // DISPLAY_DEVICEW.StateFlags
29
+ export const DISPLAY_DEVICE_ATTACHED_TO_DESKTOP = 0x1;
30
+ export const DISPLAY_DEVICE_PRIMARY_DEVICE = 0x4;
31
+ export const DISPLAY_DEVICE_MIRRORING_DRIVER = 0x8;
32
+ // EnumDisplayDevicesW dwFlags: DeviceID becomes the device interface path,
33
+ // \\?\DISPLAY#<EdidId>#<instance>#{guid}, which is what names the registry
34
+ // key holding the monitor's EDID.
35
+ export const EDD_GET_DEVICE_INTERFACE_NAME = 0x1;
36
+ // EnumDisplaySettingsW iModeNum
37
+ const ENUM_CURRENT_SETTINGS = -1;
38
+ // DEVMODEW.dmFields: only the position is being set
39
+ const DM_POSITION = 0x20;
40
+ // ChangeDisplaySettingsExW dwflags
41
+ const CDS_UPDATEREGISTRY = 0x1;
42
+ const CDS_SET_PRIMARY = 0x10;
43
+ const CDS_NORESET = 0x10000000;
44
+ /** ChangeDisplaySettingsExW return codes */
45
+ export const DISP_CHANGE_SUCCESSFUL = 0;
46
+ export const DISP_CHANGE_RESTART = 1;
47
+ export const DISP_CHANGE_FAILED = -1;
48
+ export const DISP_CHANGE_BADMODE = -2;
49
+ export const DISP_CHANGE_NOTUPDATED = -3;
50
+ export const DISP_CHANGE_BADFLAGS = -4;
51
+ export const DISP_CHANGE_BADPARAM = -5;
52
+ export const DISP_CHANGE_BADDUALVIEW = -6;
53
+ // Registry: RegGetValueW dwFlags restricting the value type, and the
54
+ // predefined HKLM handle. The SDK defines HKEY_LOCAL_MACHINE as
55
+ // (HKEY)(ULONG_PTR)(LONG)0x80000002, i.e. SIGN-extended on 64-bit, so the
56
+ // value that has to cross the FFI boundary is 0xFFFFFFFF80000002, not
57
+ // 0x80000002.
58
+ const RRF_RT_REG_BINARY = 0x08;
59
+ const HKEY_LOCAL_MACHINE = 0xffffffff80000002n;
60
+ const ERROR_SUCCESS = 0;
61
+ // Largest registry value this reads. EDIDs are 128-512 bytes.
62
+ const REG_VALUE_MAX = 4096;
63
+ /** A fixed-size WCHAR array out of a struct buffer, up to its terminator */
64
+ function wideString(buf, offset, chars) {
65
+ const text = buf.toString('utf16le', offset, offset + chars * 2);
66
+ const end = text.indexOf('\0');
67
+ return end >= 0 ? text.substring(0, end) : text;
68
+ }
69
+ // DISPLAY_DEVICEW: cb DWORD, DeviceName WCHAR[32], DeviceString WCHAR[128],
70
+ // StateFlags DWORD, DeviceID WCHAR[128], DeviceKey WCHAR[128]. 840 bytes.
71
+ const DISPLAY_DEVICEW_SIZE = 840;
72
+ const DD_DEVICENAME = 4, DD_DEVICESTRING = 68, DD_STATEFLAGS = 324, DD_DEVICEID = 328, DD_DEVICEKEY = 584;
73
+ // DEVMODEW (the W layout - the A one has the position at 44): dmDeviceName
74
+ // WCHAR[32] @0, four WORDs @64 (dmSize @68), dmFields @72, then the position
75
+ // union @76, ... dmPelsWidth @172, dmPelsHeight @176, dmDisplayFrequency @184.
76
+ // 220 bytes, and dmSize must say so or the call fails.
77
+ const DEVMODEW_SIZE = 220;
78
+ const DM_SIZE = 68, DM_FIELDS = 72, DM_POSITION_X = 76, DM_POSITION_Y = 80;
79
+ const DM_PELSWIDTH = 172, DM_PELSHEIGHT = 176, DM_DISPLAYFREQUENCY = 184;
25
80
  async function initializeFFI() {
26
81
  if (isBun) {
27
82
  // @ts-ignore - bun:ffi is only available in Bun
@@ -128,6 +183,29 @@ async function initializeFFI() {
128
183
  catch {
129
184
  CreateDCW = null;
130
185
  }
186
+ // Monitor arrangement. These three are in every user32 there is, so no
187
+ // try around them: a missing one is a broken install, not a variant.
188
+ const EnumDisplayDevicesWRaw = lib.func('bool __stdcall EnumDisplayDevicesW(const char16_t *lpDevice, uint32_t iDevNum, void *lpDisplayDevice, uint32_t dwFlags)');
189
+ const EnumDisplaySettingsWRaw = lib.func('bool __stdcall EnumDisplaySettingsW(const char16_t *lpszDeviceName, int iModeNum, void *lpDevMode)');
190
+ const ChangeDisplaySettingsExWRaw = lib.func('int __stdcall ChangeDisplaySettingsExW(const char16_t *lpszDeviceName, void *lpDevMode, void *hwnd, uint32_t dwflags, void *lParam)');
191
+ // The monitor's EDID lives in the registry; that is where the serial
192
+ // number and the friendly name come from. Optional like gdi32: without
193
+ // it monitors are identified by EDID id alone and the listing shows the
194
+ // serial column empty, which is visible rather than silent.
195
+ let RegGetValueWRaw = null;
196
+ try {
197
+ const advapi32 = koffi.load('advapi32.dll');
198
+ RegGetValueWRaw = advapi32.func('int __stdcall RegGetValueW(uintptr_t hkey, const char16_t *lpSubKey, const char16_t *lpValue, uint32_t dwFlags, void *pdwType, void *pvData, void *pcbData)');
199
+ }
200
+ catch {
201
+ RegGetValueWRaw = null; // Justified: RegGetBinaryHKLM below returns null, and monitors.ts reports the missing serial
202
+ }
203
+ /** DEVMODEW buffer holding an adapter's current mode, or null when it has none */
204
+ function currentModeBuffer(device) {
205
+ const buf = Buffer.alloc(DEVMODEW_SIZE);
206
+ buf.writeUInt16LE(DEVMODEW_SIZE, DM_SIZE);
207
+ return EnumDisplaySettingsWRaw(device, ENUM_CURRENT_SETTINGS, buf) ? buf : null;
208
+ }
131
209
  // Keep references to registered callbacks to prevent garbage collection
132
210
  // These must persist for the entire duration of the native callback invocation
133
211
  const __callbackRefs = [];
@@ -239,6 +317,61 @@ async function initializeFFI() {
239
317
  }
240
318
  }
241
319
  },
320
+ EnumDisplayDevicesW: (device, iDevNum, flags) => {
321
+ const buf = Buffer.alloc(DISPLAY_DEVICEW_SIZE);
322
+ buf.writeUInt32LE(DISPLAY_DEVICEW_SIZE, 0); // cb
323
+ if (!EnumDisplayDevicesWRaw(device, iDevNum, buf, flags))
324
+ return null;
325
+ return {
326
+ deviceName: wideString(buf, DD_DEVICENAME, 32),
327
+ deviceString: wideString(buf, DD_DEVICESTRING, 128),
328
+ stateFlags: buf.readUInt32LE(DD_STATEFLAGS),
329
+ deviceID: wideString(buf, DD_DEVICEID, 128),
330
+ deviceKey: wideString(buf, DD_DEVICEKEY, 128),
331
+ };
332
+ },
333
+ EnumDisplaySettingsW: (device) => {
334
+ const buf = currentModeBuffer(device);
335
+ if (!buf)
336
+ return null;
337
+ return {
338
+ x: buf.readInt32LE(DM_POSITION_X),
339
+ y: buf.readInt32LE(DM_POSITION_Y),
340
+ width: buf.readUInt32LE(DM_PELSWIDTH),
341
+ height: buf.readUInt32LE(DM_PELSHEIGHT),
342
+ hz: buf.readUInt32LE(DM_DISPLAYFREQUENCY),
343
+ };
344
+ },
345
+ StageDisplayPosition: (device, x, y, primary) => {
346
+ // Start from the current mode so everything but the position is
347
+ // what the adapter already has; dmFields says only the position
348
+ // counts. CDS_NORESET stages it, CDS_UPDATEREGISTRY makes Windows
349
+ // remember this arrangement for the current set of monitors.
350
+ const buf = currentModeBuffer(device);
351
+ if (!buf)
352
+ return DISP_CHANGE_BADPARAM;
353
+ buf.writeUInt32LE(DM_POSITION, DM_FIELDS);
354
+ buf.writeInt32LE(x, DM_POSITION_X);
355
+ buf.writeInt32LE(y, DM_POSITION_Y);
356
+ let flags = CDS_UPDATEREGISTRY | CDS_NORESET;
357
+ if (primary)
358
+ flags |= CDS_SET_PRIMARY;
359
+ return ChangeDisplaySettingsExWRaw(device, buf, null, flags >>> 0, null);
360
+ },
361
+ ApplyDisplayChanges: () => {
362
+ return ChangeDisplaySettingsExWRaw(null, null, null, 0, null);
363
+ },
364
+ RegGetBinaryHKLM: (subKey, valueName) => {
365
+ if (!RegGetValueWRaw)
366
+ return null;
367
+ const data = Buffer.alloc(REG_VALUE_MAX);
368
+ const size = Buffer.alloc(4);
369
+ size.writeUInt32LE(REG_VALUE_MAX, 0);
370
+ const rc = RegGetValueWRaw(HKEY_LOCAL_MACHINE, subKey, valueName, RRF_RT_REG_BINARY, null, data, size);
371
+ if (rc !== ERROR_SUCCESS)
372
+ return null;
373
+ return data.subarray(0, size.readUInt32LE(0));
374
+ },
242
375
  };
243
376
  }
244
377
  const user32 = await initializeFFI();
package/index.d.ts CHANGED
@@ -8,6 +8,8 @@ import { enumerateScreens, sortScreens, screenScale, screenDpi, ScreenInfo } fro
8
8
  import { enumerateWindows, findWindowsByPattern, WindowInfo, WindowState, getWindowState, setWindowState, isMinimized, isMaximized, isNormal, minimizeWindow, maximizeWindow, restoreWindow } from './windows.js';
9
9
  import { enumerateTabs, TabInfo, mightHaveTabs, TAB_ENUMERATION_NOTE } from './tabs.js';
10
10
  import { showScreenNumbers, OVERLAY_SECONDS, OVERLAY_INCHES } from './screennums.js';
11
+ import { MonitorInfo, enumerateMonitors } from './monitors.js';
12
+ import { SavedMonitor, ScreenLayout, RestoreResult, DEFAULT_LAYOUT_NAME, layoutsPath, getLayout, saveLayout, machineLayouts, restoreLayout, watchLayout, formatMonitors } from './screenlayout.js';
11
13
  export interface WindowConfig {
12
14
  name: string;
13
15
  regex?: boolean;
@@ -23,8 +25,17 @@ export interface WindowConfig {
23
25
  minimize?: boolean;
24
26
  maximize?: boolean;
25
27
  }
26
- export type ConfigFile = WindowConfig | WindowConfig[];
27
- export { RECT, ScreenInfo, WindowInfo, WindowState, TabInfo, enumerateScreens, sortScreens, screenScale, screenDpi, enumerateWindows, findWindowsByPattern, getWindowState, setWindowState, isMinimized, isMaximized, isNormal, minimizeWindow, maximizeWindow, restoreWindow, enumerateTabs, mightHaveTabs, TAB_ENUMERATION_NOTE, showScreenNumbers, OVERLAY_INCHES, OVERLAY_SECONDS, user32 };
28
+ /**
29
+ * The wrapped config form: monitors first, then windows. `screens` is the
30
+ * name of a saved monitor layout, an inline layout, or just its monitors
31
+ * array. Either key may be omitted.
32
+ */
33
+ export interface LayoutFile {
34
+ screens?: string | ScreenLayout | SavedMonitor[];
35
+ windows?: WindowConfig[];
36
+ }
37
+ export type ConfigFile = WindowConfig | WindowConfig[] | LayoutFile;
38
+ export { RECT, ScreenInfo, WindowInfo, WindowState, TabInfo, enumerateScreens, sortScreens, screenScale, screenDpi, enumerateWindows, findWindowsByPattern, getWindowState, setWindowState, isMinimized, isMaximized, isNormal, minimizeWindow, maximizeWindow, restoreWindow, enumerateTabs, mightHaveTabs, TAB_ENUMERATION_NOTE, showScreenNumbers, OVERLAY_INCHES, OVERLAY_SECONDS, user32, MonitorInfo, SavedMonitor, ScreenLayout, RestoreResult, DEFAULT_LAYOUT_NAME, layoutsPath, enumerateMonitors, getLayout, saveLayout, machineLayouts, restoreLayout, watchLayout, formatMonitors };
28
39
  /**
29
40
  * Convert screen-relative coordinates to global coordinates
30
41
  * @param x - X coordinate relative to screen
package/index.js CHANGED
@@ -8,12 +8,18 @@ import { enumerateScreens, sortScreens, screenScale, screenDpi } from './screens
8
8
  import { enumerateWindows, findWindowsByPattern, loadIgnorePatterns, WindowState, getWindowState, setWindowState, isMinimized, isMaximized, isNormal, minimizeWindow, maximizeWindow, restoreWindow } from './windows.js';
9
9
  import { enumerateTabs, mightHaveTabs, TAB_ENUMERATION_NOTE } from './tabs.js';
10
10
  import { showScreenNumbers, OVERLAY_SECONDS, OVERLAY_INCHES } from './screennums.js';
11
+ // 2026-09-24 - Claude Code (Fable 5.1), at Bob's direction: monitor arrangement
12
+ // save/restore (-screens), folded in from displays.ps1. See .llm/screens-restore.md.
13
+ import { enumerateMonitors } from './monitors.js';
14
+ import { DEFAULT_LAYOUT_NAME, layoutsPath, machine, getLayout, saveLayout, machineLayouts, resolveLayout, restoreLayout, watchLayout, formatMonitors } from './screenlayout.js';
11
15
  import { join, resolve, isAbsolute } from 'path';
12
16
  import { readFileSync, writeFileSync, existsSync } from 'fs';
13
17
  import { parse as parseJsonc, printParseErrorCode } from 'jsonc-parser';
14
18
  import * as packageJson from './package.json' with { type: 'json' };
19
+ /** The -screens verbs. No verb means list. */
20
+ const SCREEN_VERBS = ['list', 'save', 'restore', 'layouts', 'watch'];
15
21
  // Re-export public API for library usage
16
- export { WindowState, enumerateScreens, sortScreens, screenScale, screenDpi, enumerateWindows, findWindowsByPattern, getWindowState, setWindowState, isMinimized, isMaximized, isNormal, minimizeWindow, maximizeWindow, restoreWindow, enumerateTabs, mightHaveTabs, TAB_ENUMERATION_NOTE, showScreenNumbers, OVERLAY_INCHES, OVERLAY_SECONDS, user32 };
22
+ export { WindowState, enumerateScreens, sortScreens, screenScale, screenDpi, enumerateWindows, findWindowsByPattern, getWindowState, setWindowState, isMinimized, isMaximized, isNormal, minimizeWindow, maximizeWindow, restoreWindow, enumerateTabs, mightHaveTabs, TAB_ENUMERATION_NOTE, showScreenNumbers, OVERLAY_INCHES, OVERLAY_SECONDS, user32, DEFAULT_LAYOUT_NAME, layoutsPath, enumerateMonitors, getLayout, saveLayout, machineLayouts, restoreLayout, watchLayout, formatMonitors };
17
23
  // WindowConfig and ConfigFile types are exported from interface definitions above
18
24
  // Note: screenPosToPos is exported separately below (after the function definition)
19
25
  /**
@@ -37,6 +43,17 @@ function loadConfigFile(filePath) {
37
43
  }
38
44
  return parsed;
39
45
  }
46
+ /** Replace the entry with the same name, or append */
47
+ function mergeWindowConfig(list, config) {
48
+ const idx = list.findIndex(e => e.name === config.name);
49
+ if (idx >= 0) {
50
+ list[idx] = config;
51
+ }
52
+ else {
53
+ list.push(config);
54
+ }
55
+ return list;
56
+ }
40
57
  /**
41
58
  * Save window config to JSON file
42
59
  * If file exists and contains array, merge; otherwise create/overwrite
@@ -46,16 +63,13 @@ function saveConfigFile(filePath, config, merge = false) {
46
63
  let finalConfig;
47
64
  if (merge && existsSync(absPath)) {
48
65
  const existing = loadConfigFile(absPath);
49
- if (Array.isArray(existing)) {
50
- // Find and update existing entry with same name, or append
51
- const idx = existing.findIndex(e => e.name === config.name);
52
- if (idx >= 0) {
53
- existing[idx] = config;
54
- }
55
- else {
56
- existing.push(config);
57
- }
58
- finalConfig = existing;
66
+ if (isLayoutFile(existing)) {
67
+ // 2026-09-24 - Claude Code (Fable 5.1): the wrapped form keeps its
68
+ // screens section; only the windows list is merged into.
69
+ finalConfig = { ...existing, windows: mergeWindowConfig(existing.windows ?? [], config) };
70
+ }
71
+ else if (Array.isArray(existing)) {
72
+ finalConfig = mergeWindowConfig(existing, config);
59
73
  }
60
74
  else {
61
75
  // Convert single entry to array
@@ -195,6 +209,26 @@ function parseArgs(args) {
195
209
  case 'max':
196
210
  opts.maximize = true;
197
211
  break;
212
+ case 'screens': {
213
+ // -screens [list|save|restore|layouts|watch] [name]. Bare
214
+ // -screens lists. The name is optional and only for an
215
+ // alternate layout; the machine's standard one needs none.
216
+ const next = args[i + 1];
217
+ const verb = next?.toLowerCase();
218
+ if (next && !next.startsWith('-') && !SCREEN_VERBS.includes(verb)) {
219
+ console.error(`Unknown -screens command '${next}'. Expected one of: ${SCREEN_VERBS.join(', ')}`);
220
+ process.exit(1);
221
+ }
222
+ opts.screens = { verb: (SCREEN_VERBS.includes(verb) ? verb : 'list') };
223
+ if (opts.screens.verb !== 'list')
224
+ i++;
225
+ const takesName = opts.screens.verb === 'save' || opts.screens.verb === 'restore' || opts.screens.verb === 'watch';
226
+ if (takesName && i + 1 < args.length && !args[i + 1].startsWith('-')) {
227
+ i++;
228
+ opts.screens.name = args[i];
229
+ }
230
+ break;
231
+ }
198
232
  case 'help':
199
233
  case '?':
200
234
  return opts; // Will trigger usage display
@@ -438,7 +472,7 @@ function listAllWindows(includeIgnored = false) {
438
472
  const screen = screens[i];
439
473
  // console.log(`${i} Screen: ${screen.deviceName} (${screen.bounds.Top}/${screen.bounds.Bottom}x${screen.bounds.Left}/${screen.bounds.Right})`);
440
474
  const b = screen.bounds;
441
- console.log(`${i} Screen: ${screen.deviceName} ${padn(b.Left)},${padn(b.Top)} (lower right ${padn(b.Right)} ${padn(b.Bottom)})`);
475
+ console.log(`${i} Screen: ${screen.deviceName} ${padn(b.Left)},${padn(b.Top)} (lower right ${padn(b.Right)} ${padn(b.Bottom)}) ${screen.name ?? ''}`);
442
476
  }
443
477
  console.log();
444
478
  const windows = enumerateWindows(includeIgnored).sort((a, b) => a.title.localeCompare(b.title));
@@ -480,6 +514,7 @@ function printUsage(msg = '') {
480
514
  console.log(' winpos <title> min | -min');
481
515
  console.log(' winpos <config.json|config.jsonc>');
482
516
  console.log(' winpos -load <config.json|config.jsonc> [-save <output.json>]');
517
+ console.log(' winpos -screens [list | save [name] | restore [name] | layouts | watch [name]]');
483
518
  console.log();
484
519
  console.log('Options:');
485
520
  console.log(' -d Debug mode');
@@ -494,6 +529,9 @@ function printUsage(msg = '') {
494
529
  console.log(' -max Maximize window');
495
530
  console.log(' -load file Load config from JSON/JSONC file (comments + trailing commas OK)');
496
531
  console.log(' -save file Save config to JSON file (merges if file exists; comments not preserved)');
532
+ console.log(' -screens Monitor arrangement: list (default), save, restore, layouts, watch');
533
+ console.log(` A name is only for an alternate layout; the standard one is '${DEFAULT_LAYOUT_NAME}'.`);
534
+ console.log(` Layouts are kept per machine in ${layoutsPath}`);
497
535
  console.log();
498
536
  console.log('Position/size values can be pixels or percentages (e.g. 50%)');
499
537
  console.log('Title can be regex (/pattern/) or prefix (title*)');
@@ -503,12 +541,121 @@ function printUsage(msg = '') {
503
541
  console.log();
504
542
  console.log('JSON format: {"name":"app","regex":false,"pos":{"x":0,"y":0,"screen":0},"size":{"w":800,"h":600}}');
505
543
  console.log(' or array: [{...}, {...}]');
544
+ console.log(' or {"screens":"default","windows":[{...}]} to restore the monitor arrangement first');
506
545
  console.log();
507
- console.log('Screen | X Y | W H ');
508
- console.log('------------------------------------');
546
+ console.log('Screen | X Y | W H | Monitor');
547
+ console.log('---------------------------------------------');
509
548
  for (let i = 0; i < screens.length; i++) {
510
549
  const screen = screens[i];
511
- console.log(`${i.toString().padStart(6)} | ${screen.bounds.Left.toString().padStart(5)} ${screen.bounds.Top.toString().padStart(5)} | ${screen.bounds.Width.toString().padStart(5)} ${screen.bounds.Height.toString().padStart(5)}`);
550
+ console.log(`${i.toString().padStart(6)} | ${screen.bounds.Left.toString().padStart(5)} ${screen.bounds.Top.toString().padStart(5)} | ${screen.bounds.Width.toString().padStart(5)} ${screen.bounds.Height.toString().padStart(5)} | ${screen.name ?? ''}`);
551
+ }
552
+ }
553
+ /**
554
+ * The -screens commands. Returns the exit code; throws for failures that
555
+ * should end with a message on stderr (run() prints it).
556
+ */
557
+ async function runScreens(cmd) {
558
+ const name = cmd.name ?? DEFAULT_LAYOUT_NAME;
559
+ switch (cmd.verb) {
560
+ case 'list': {
561
+ for (const line of formatMonitors(enumerateMonitors(), screens))
562
+ console.log(line);
563
+ return 0;
564
+ }
565
+ case 'layouts': {
566
+ const all = machineLayouts();
567
+ const names = Object.keys(all).sort();
568
+ if (names.length === 0) {
569
+ console.log(`No monitor layouts saved for ${machine} in ${layoutsPath}`);
570
+ return 0;
571
+ }
572
+ for (const n of names)
573
+ console.log(`${n.padEnd(16)} saved ${all[n].saved} [${all[n].monitors.map(m => m.name).join(', ')}]`);
574
+ return 0;
575
+ }
576
+ case 'save': {
577
+ const layout = saveLayout(name);
578
+ console.log(`Saved monitor layout '${name}' for ${machine} (${layout.monitors.length} monitors) to ${layoutsPath}`);
579
+ for (const line of formatMonitors(enumerateMonitors(), screens))
580
+ console.log(line);
581
+ return 0;
582
+ }
583
+ case 'restore': {
584
+ const layout = getLayout(name);
585
+ if (!layout)
586
+ throw new Error(`No monitor layout '${name}' saved for ${machine}. Run 'winpos -screens layouts' to see what exists.`);
587
+ const result = await restoreLayout(layout);
588
+ // Renumber against what is on the desktop now, so the Scr column is current
589
+ screens = sortScreens(enumerateScreens());
590
+ for (const line of formatMonitors(result.after, screens))
591
+ console.log(line);
592
+ if (!result.ok) {
593
+ console.log(`${result.mismatches.length} monitor(s) did not land where asked (Windows closes gaps when a monitor is missing).`);
594
+ return 1;
595
+ }
596
+ return 0;
597
+ }
598
+ case 'watch':
599
+ await watchLayout(name); // stays resident; only returns by throwing
600
+ return 0;
601
+ }
602
+ }
603
+ /** Whether a parsed config is the wrapped {screens, windows} form */
604
+ function isLayoutFile(config) {
605
+ return !Array.isArray(config) && !('name' in config) && ('screens' in config || 'windows' in config);
606
+ }
607
+ /**
608
+ * winpos <config.json> / -load: monitors first when the file has a `screens`
609
+ * section, then the windows. The screen numbers the window entries use are
610
+ * derived from the arrangement, so it has to be right before any window moves.
611
+ */
612
+ async function runConfigFile(opts) {
613
+ const config = loadConfigFile(opts.loadFile);
614
+ let wrapped = null;
615
+ let configs;
616
+ if (isLayoutFile(config)) {
617
+ wrapped = config;
618
+ configs = wrapped.windows ?? [];
619
+ }
620
+ else {
621
+ configs = Array.isArray(config) ? config : [config];
622
+ }
623
+ if (wrapped?.screens !== undefined) {
624
+ const layout = resolveLayout(wrapped.screens);
625
+ if (!layout)
626
+ throw new Error(`No monitor layout '${wrapped.screens}' saved for ${machine} in ${layoutsPath}`);
627
+ const result = await restoreLayout(layout);
628
+ if (!result.ok) {
629
+ console.log(`${result.mismatches.length} monitor(s) did not land where asked; continuing with the windows.`);
630
+ process.exitCode = 1;
631
+ }
632
+ screens = sortScreens(enumerateScreens());
633
+ }
634
+ // If CLI params specify a window, merge into loaded config
635
+ if (opts.windowTitle && (opts.pos || opts.size || opts.minimize || opts.maximize)) {
636
+ const cliConfig = buildConfigFromOptions(opts);
637
+ if (cliConfig) {
638
+ // Find matching entry by name and override it
639
+ const idx = configs.findIndex(c => c.name === cliConfig.name);
640
+ if (idx >= 0) {
641
+ configs[idx] = cliConfig;
642
+ }
643
+ else {
644
+ configs.push(cliConfig);
645
+ }
646
+ }
647
+ }
648
+ // Apply all configs
649
+ for (const cfg of configs) {
650
+ applyWindowConfig(cfg, screens);
651
+ }
652
+ // Save merged config if requested - in the same form it was read
653
+ if (opts.saveFile !== undefined) {
654
+ const savePath = opts.saveFile || opts.loadFile; // Default to loadFile if -save has no arg
655
+ const absPath = isAbsolute(savePath) ? savePath : resolve(process.cwd(), savePath);
656
+ const finalConfig = wrapped ? { ...wrapped, windows: configs } : configs.length === 1 ? configs[0] : configs;
657
+ writeFileSync(absPath, JSON.stringify(finalConfig, null, 2), 'utf-8');
658
+ console.log(`Config saved to: ${absPath}`);
512
659
  }
513
660
  }
514
661
  export function run(args) {
@@ -542,43 +689,24 @@ export function run(args) {
542
689
  console.log(screens.length);
543
690
  process.exit(screens.length);
544
691
  }
545
- // Handle load file (JSON config)
692
+ // Handle -screens (monitor arrangement)
693
+ if (opts.screens) {
694
+ runScreens(opts.screens)
695
+ .then(code => process.exit(code))
696
+ .catch((e) => {
697
+ console.error(`winpos: ${e.message}`);
698
+ process.exit(1);
699
+ });
700
+ return;
701
+ }
702
+ // Handle load file (JSON config). Async since a `screens` section
703
+ // restores the monitor arrangement first and waits for it to settle.
546
704
  if (opts.loadFile) {
547
- try {
548
- const config = loadConfigFile(opts.loadFile);
549
- let configs = Array.isArray(config) ? config : [config];
550
- // If CLI params specify a window, merge into loaded config
551
- if (opts.windowTitle && (opts.pos || opts.size || opts.minimize || opts.maximize)) {
552
- const cliConfig = buildConfigFromOptions(opts);
553
- if (cliConfig) {
554
- // Find matching entry by name and override it
555
- const idx = configs.findIndex(c => c.name === cliConfig.name);
556
- if (idx >= 0) {
557
- configs[idx] = cliConfig;
558
- }
559
- else {
560
- configs.push(cliConfig);
561
- }
562
- }
563
- }
564
- // Apply all configs
565
- for (const cfg of configs) {
566
- applyWindowConfig(cfg, screens);
567
- }
568
- // Save merged config if requested
569
- if (opts.saveFile !== undefined) {
570
- const savePath = opts.saveFile || opts.loadFile; // Default to loadFile if -save has no arg
571
- const absPath = isAbsolute(savePath) ? savePath : resolve(process.cwd(), savePath);
572
- const finalConfig = configs.length === 1 ? configs[0] : configs;
573
- writeFileSync(absPath, JSON.stringify(finalConfig, null, 2), 'utf-8');
574
- console.log(`Config saved to: ${absPath}`);
575
- }
576
- return;
577
- }
578
- catch (e) {
705
+ runConfigFile(opts).catch((e) => {
579
706
  console.error(`Error loading config: ${e.message}`);
580
707
  process.exit(1);
581
- }
708
+ });
709
+ return;
582
710
  }
583
711
  // Handle new flag-based syntax (-pos, -size, -min, -max)
584
712
  if (opts.pos || opts.size || opts.minimize || opts.maximize) {
package/monitors.d.ts ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Monitors as the display driver sees them: which adapter, what mode, where
3
+ * on the desktop, and - the part EnumDisplayMonitors cannot give - WHO they
4
+ * are. Identity is the EDID manufacturer/product id plus the panel's serial
5
+ * number, read from the EDID Windows keeps in the registry. \\.\DISPLAYn is
6
+ * not an identity: it renumbers whenever a monitor drops off the bus.
7
+ *
8
+ * 2026-09-24 - Claude Code (Fable 5.1), at Bob's direction. Ported from
9
+ * %OneDrive%\xfer\bin\displays.ps1 so winpos can put the monitors back before
10
+ * it puts the windows back. Why and how: .llm/screens-restore.md.
11
+ */
12
+ /** One connected monitor: its adapter, identity, and current placement */
13
+ export interface MonitorInfo {
14
+ /** Adapter name, \\.\DISPLAY1 - what the Win32 calls take, and what ScreenInfo.deviceName holds */
15
+ device: string;
16
+ /** Manufacturer + product code from the device path, e.g. GSM7721 */
17
+ edidId: string;
18
+ /** Serial number from the EDID; '' when the panel carries none or the EDID is unreadable */
19
+ serial: string;
20
+ /** `${edidId}:${serial}` - the stable identity a layout is keyed by */
21
+ id: string;
22
+ /** Friendly name from the EDID (e.g. "LG HDR 5K"), else the driver's description */
23
+ name: string;
24
+ /** Desktop position of the top-left corner; the primary monitor is at 0,0 */
25
+ x: number;
26
+ y: number;
27
+ width: number;
28
+ height: number;
29
+ /** Refresh rate in Hz */
30
+ hz: number;
31
+ primary: boolean;
32
+ }
33
+ /** The identity string a layout is keyed by */
34
+ export declare function monitorId(edidId: string, serial: string): string;
35
+ /** The text of the display descriptor carrying `tag`, or '' when the EDID has none */
36
+ export declare function edidDescriptor(edid: Buffer, tag: number): string;
37
+ /**
38
+ * The panel's serial number: the serial descriptor when there is one,
39
+ * otherwise the numeric serial from the vendor block (as decimal text, the
40
+ * way WMI's WmiMonitorID reports it), otherwise ''.
41
+ */
42
+ export declare function edidSerial(edid: Buffer): string;
43
+ /**
44
+ * The EDID Windows recorded for a monitor, from
45
+ * HKLM\SYSTEM\CurrentControlSet\Enum\DISPLAY\<EdidId>\<instance>\Device Parameters.
46
+ * Readable without elevation (checked on RMF39, 2026-09-24). Null when absent.
47
+ */
48
+ export declare function readEdid(edidId: string, instance: string): Buffer;
49
+ /**
50
+ * Every monitor attached to the desktop, in adapter order.
51
+ *
52
+ * Throws where the Win32 path is unavailable (Bun) - the caller decides
53
+ * whether that is fatal. The window half of winpos treats identity as
54
+ * optional; the -screens commands do not.
55
+ */
56
+ export declare function enumerateMonitors(): MonitorInfo[];
57
+ /**
58
+ * Stage a desktop position for one monitor's adapter. Nothing moves until
59
+ * applyMonitorPositions(); staging all of them first is what lets Windows
60
+ * treat the arrangement as one change. Returns a DISP_CHANGE_* code.
61
+ */
62
+ export declare function stageMonitorPosition(device: string, x: number, y: number, primary: boolean): number;
63
+ /** Apply every staged position. Returns a DISP_CHANGE_* code. */
64
+ export declare function applyMonitorPositions(): number;
65
+ /** Human text for a ChangeDisplaySettingsExW return code */
66
+ export declare function dispChangeText(code: number): string;
67
+ //# sourceMappingURL=monitors.d.ts.map