@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/README.md +296 -213
- package/ffi-wrapper.d.ts +49 -0
- package/ffi-wrapper.js +133 -0
- package/index.d.ts +13 -2
- package/index.js +177 -49
- package/monitors.d.ts +67 -0
- package/monitors.js +146 -0
- package/package.json +10 -3
- package/screenlayout.d.ts +108 -0
- package/screenlayout.js +243 -0
- package/screens.d.ts +8 -0
- package/screens.js +21 -0
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
|
-
|
|
27
|
-
|
|
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 (
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
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
|
-
|
|
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
|