@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/monitors.js ADDED
@@ -0,0 +1,146 @@
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
+ import { user32, isBun, DISPLAY_DEVICE_ATTACHED_TO_DESKTOP, DISPLAY_DEVICE_PRIMARY_DEVICE, DISPLAY_DEVICE_MIRRORING_DRIVER, EDD_GET_DEVICE_INTERFACE_NAME, DISP_CHANGE_SUCCESSFUL, DISP_CHANGE_RESTART, DISP_CHANGE_FAILED, DISP_CHANGE_BADMODE, DISP_CHANGE_NOTUPDATED, DISP_CHANGE_BADFLAGS, DISP_CHANGE_BADPARAM, DISP_CHANGE_BADDUALVIEW } from './ffi-wrapper.js';
13
+ /** The identity string a layout is keyed by */
14
+ export function monitorId(edidId, serial) {
15
+ return `${edidId}:${serial}`;
16
+ }
17
+ // EDID base block: the four 18-byte descriptors, and how a display descriptor
18
+ // (as opposed to a detailed timing) announces itself - zero pixel clock,
19
+ // zero, then the tag byte. Text descriptors carry 13 chars from byte 5,
20
+ // terminated by a newline and padded with spaces.
21
+ const EDID_DESCRIPTOR_OFFSETS = [54, 72, 90, 108];
22
+ const EDID_DESCRIPTOR_LENGTH = 18;
23
+ const EDID_DESCRIPTOR_TAG = 3;
24
+ const EDID_DESCRIPTOR_TEXT = 5;
25
+ const EDID_TAG_SERIAL = 0xff;
26
+ const EDID_TAG_NAME = 0xfc;
27
+ // The 32-bit numeric serial in the vendor block - the fallback identity for a
28
+ // panel that ships no serial descriptor.
29
+ const EDID_NUMERIC_SERIAL = 12;
30
+ /** The text of the display descriptor carrying `tag`, or '' when the EDID has none */
31
+ export function edidDescriptor(edid, tag) {
32
+ for (const offset of EDID_DESCRIPTOR_OFFSETS) {
33
+ if (offset + EDID_DESCRIPTOR_LENGTH > edid.length)
34
+ break;
35
+ if (edid.readUInt16LE(offset) !== 0 || edid[offset + 2] !== 0 || edid[offset + EDID_DESCRIPTOR_TAG] !== tag)
36
+ continue;
37
+ const text = edid.toString('latin1', offset + EDID_DESCRIPTOR_TEXT, offset + EDID_DESCRIPTOR_LENGTH);
38
+ const end = text.indexOf('\n');
39
+ return (end >= 0 ? text.substring(0, end) : text).trim();
40
+ }
41
+ return '';
42
+ }
43
+ /**
44
+ * The panel's serial number: the serial descriptor when there is one,
45
+ * otherwise the numeric serial from the vendor block (as decimal text, the
46
+ * way WMI's WmiMonitorID reports it), otherwise ''.
47
+ */
48
+ export function edidSerial(edid) {
49
+ const text = edidDescriptor(edid, EDID_TAG_SERIAL);
50
+ if (text)
51
+ return text;
52
+ if (edid.length < EDID_NUMERIC_SERIAL + 4)
53
+ return '';
54
+ const numeric = edid.readUInt32LE(EDID_NUMERIC_SERIAL);
55
+ return numeric ? String(numeric) : '';
56
+ }
57
+ /**
58
+ * The EDID Windows recorded for a monitor, from
59
+ * HKLM\SYSTEM\CurrentControlSet\Enum\DISPLAY\<EdidId>\<instance>\Device Parameters.
60
+ * Readable without elevation (checked on RMF39, 2026-09-24). Null when absent.
61
+ */
62
+ export function readEdid(edidId, instance) {
63
+ if (!edidId || !instance || !user32.RegGetBinaryHKLM)
64
+ return null;
65
+ return user32.RegGetBinaryHKLM(`SYSTEM\\CurrentControlSet\\Enum\\DISPLAY\\${edidId}\\${instance}\\Device Parameters`, 'EDID');
66
+ }
67
+ /**
68
+ * Every monitor attached to the desktop, in adapter order.
69
+ *
70
+ * Throws where the Win32 path is unavailable (Bun) - the caller decides
71
+ * whether that is fatal. The window half of winpos treats identity as
72
+ * optional; the -screens commands do not.
73
+ */
74
+ export function enumerateMonitors() {
75
+ if (isBun || !user32.EnumDisplayDevicesW)
76
+ throw new Error('Monitor arrangement needs Node.js with koffi; it is not available under Bun.');
77
+ const monitors = [];
78
+ for (let i = 0;; i++) {
79
+ const adapter = user32.EnumDisplayDevicesW(null, i, 0);
80
+ if (!adapter)
81
+ break;
82
+ if (!(adapter.stateFlags & DISPLAY_DEVICE_ATTACHED_TO_DESKTOP))
83
+ continue;
84
+ if (adapter.stateFlags & DISPLAY_DEVICE_MIRRORING_DRIVER)
85
+ continue;
86
+ // An attached adapter always has a current mode; if the call fails
87
+ // anyway the monitor is still listed, with a 0x0@0 mode that is
88
+ // visibly wrong rather than silently missing.
89
+ const mode = user32.EnumDisplaySettingsW(adapter.deviceName) ?? { x: 0, y: 0, width: 0, height: 0, hz: 0 };
90
+ // The monitor on the adapter. Its interface path is
91
+ // \\?\DISPLAY#<EdidId>#<instance>#{guid}; the middle two name the
92
+ // registry key that holds its EDID.
93
+ const monitor = user32.EnumDisplayDevicesW(adapter.deviceName, 0, EDD_GET_DEVICE_INTERFACE_NAME);
94
+ const pathParts = (monitor?.deviceID ?? '').split('#');
95
+ const edidId = pathParts[1] ?? '';
96
+ const instance = pathParts[2] ?? '';
97
+ const edid = readEdid(edidId, instance);
98
+ const serial = edid ? edidSerial(edid) : '';
99
+ const name = (edid && edidDescriptor(edid, EDID_TAG_NAME)) || monitor?.deviceString || adapter.deviceString;
100
+ monitors.push({
101
+ device: adapter.deviceName,
102
+ edidId,
103
+ serial,
104
+ id: monitorId(edidId, serial),
105
+ name,
106
+ x: mode.x,
107
+ y: mode.y,
108
+ width: mode.width,
109
+ height: mode.height,
110
+ hz: mode.hz,
111
+ primary: (adapter.stateFlags & DISPLAY_DEVICE_PRIMARY_DEVICE) !== 0,
112
+ });
113
+ }
114
+ return monitors;
115
+ }
116
+ /**
117
+ * Stage a desktop position for one monitor's adapter. Nothing moves until
118
+ * applyMonitorPositions(); staging all of them first is what lets Windows
119
+ * treat the arrangement as one change. Returns a DISP_CHANGE_* code.
120
+ */
121
+ export function stageMonitorPosition(device, x, y, primary) {
122
+ if (!user32.StageDisplayPosition)
123
+ return DISP_CHANGE_FAILED;
124
+ return user32.StageDisplayPosition(device, x, y, primary);
125
+ }
126
+ /** Apply every staged position. Returns a DISP_CHANGE_* code. */
127
+ export function applyMonitorPositions() {
128
+ if (!user32.ApplyDisplayChanges)
129
+ return DISP_CHANGE_FAILED;
130
+ return user32.ApplyDisplayChanges();
131
+ }
132
+ /** Human text for a ChangeDisplaySettingsExW return code */
133
+ export function dispChangeText(code) {
134
+ switch (code) {
135
+ case DISP_CHANGE_SUCCESSFUL: return 'success';
136
+ case DISP_CHANGE_RESTART: return 'needs a restart';
137
+ case DISP_CHANGE_FAILED: return 'the display driver failed the mode';
138
+ case DISP_CHANGE_BADMODE: return 'mode not supported';
139
+ case DISP_CHANGE_NOTUPDATED: return 'unable to write to the registry';
140
+ case DISP_CHANGE_BADFLAGS: return 'invalid flags';
141
+ case DISP_CHANGE_BADPARAM: return 'invalid parameter';
142
+ case DISP_CHANGE_BADDUALVIEW: return 'DualView not supported';
143
+ default: return `unknown code ${code}`;
144
+ }
145
+ }
146
+ //# sourceMappingURL=monitors.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/winpos",
3
- "version": "2.0.52",
3
+ "version": "2.0.53",
4
4
  "description": "TypeScript implementation of winpos - Windows window positioning utility",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -32,7 +32,8 @@
32
32
  "@types/node": "^25.3.0"
33
33
  },
34
34
  "dependencies": {
35
- "@bobfrankston/msger": "^0.1.424",
35
+ "@bobfrankston/msger": "^0.1.434",
36
+ "@bobfrankston/userconfig": "^1.0.15",
36
37
  "jsonc-parser": "^3.3.1",
37
38
  "koffi": "^2.9.2"
38
39
  },
@@ -47,14 +48,20 @@
47
48
  "url": "https://github.com/BobFrankston/winpos/issues"
48
49
  },
49
50
  "homepage": "https://github.com/BobFrankston/winpos#readme",
51
+ "allowScripts": {
52
+ "file:../../../msgx/msger": true,
53
+ "koffi": true
54
+ },
50
55
  ".dependencies": {
51
56
  "@bobfrankston/msger": "file:../msgx/msger",
57
+ "@bobfrankston/userconfig": "file:../userconfig",
52
58
  "jsonc-parser": "^3.3.1",
53
59
  "koffi": "^2.9.2"
54
60
  },
55
61
  ".transformedSnapshot": {
56
62
  "dependencies": {
57
- "@bobfrankston/msger": "^0.1.424",
63
+ "@bobfrankston/msger": "^0.1.434",
64
+ "@bobfrankston/userconfig": "^1.0.15",
58
65
  "jsonc-parser": "^3.3.1",
59
66
  "koffi": "^2.9.2"
60
67
  }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Monitor arrangement layouts: save the current arrangement, restore a saved
3
+ * one to whichever of its monitors are present, verify by reading back, and
4
+ * watch for Windows undoing it.
5
+ *
6
+ * Why this is in winpos: winpos's screen numbers are the monitors sorted by
7
+ * position, and saved window layouts address screens by that number. When
8
+ * Windows reverts the arrangement (it keeps one layout per exact set of
9
+ * connected monitors, and a DisplayPort monitor that sleeps leaves the set),
10
+ * every screen number changes and every window layout lands on the wrong
11
+ * panel. Restoring the monitors is a precondition for restoring the windows.
12
+ *
13
+ * 2026-09-24 - Claude Code (Fable 5.1), at Bob's direction. Ported from
14
+ * %OneDrive%\xfer\bin\displays.ps1; the file format is the same, so a layout
15
+ * copies between the two. Background: .llm/screens-restore.md and
16
+ * %OneDrive%\Documents\AI\displays.md.
17
+ */
18
+ import { MonitorInfo } from './monitors.js';
19
+ import type { ScreenInfo } from './screens.js';
20
+ /** One monitor as saved in a layout */
21
+ export interface SavedMonitor {
22
+ /** EdidId:serial, see MonitorInfo.id */
23
+ id: string;
24
+ /** Friendly name, for messages only - identity is `id` */
25
+ name: string;
26
+ x: number;
27
+ y: number;
28
+ width: number;
29
+ height: number;
30
+ hz: number;
31
+ primary: boolean;
32
+ }
33
+ /** A saved arrangement */
34
+ export interface ScreenLayout {
35
+ /** ISO timestamp of the save */
36
+ saved: string;
37
+ monitors: SavedMonitor[];
38
+ }
39
+ /** The layouts file: per machine, per layout name. Same shape as displays.json. */
40
+ export type LayoutsFile = Record<string, Record<string, ScreenLayout>>;
41
+ /** The one standard layout per machine; a name is only for an alternate (travel, single-monitor...) */
42
+ export declare const DEFAULT_LAYOUT_NAME = "default";
43
+ /** Where the layouts live: in the user config area, per the UserConfig path rule */
44
+ export declare const layoutsPath: string;
45
+ /**
46
+ * This machine's key in the layouts file. Upper-cased to match COMPUTERNAME,
47
+ * which is what displays.json was keyed by, so a layout copies across.
48
+ */
49
+ export declare const machine: string;
50
+ export declare function readLayouts(): LayoutsFile;
51
+ export declare function writeLayouts(all: LayoutsFile): void;
52
+ /** The layouts saved for this machine, by name */
53
+ export declare function machineLayouts(): Record<string, ScreenLayout>;
54
+ /** A saved layout for this machine, or null */
55
+ export declare function getLayout(name?: string): ScreenLayout;
56
+ /** Save the current arrangement under `name` for this machine */
57
+ export declare function saveLayout(name?: string): ScreenLayout;
58
+ /** A layout given inline (a monitors array, or a full layout) or by saved name, as a ScreenLayout */
59
+ export declare function resolveLayout(spec: string | ScreenLayout | SavedMonitor[]): ScreenLayout;
60
+ /** Whether a present monitor sits where the layout says (position and primary flag) */
61
+ export declare function inPlace(saved: SavedMonitor, actual: MonitorInfo): boolean;
62
+ /** The saved monitors that are present but not where the layout puts them */
63
+ export declare function outOfPlace(layout: ScreenLayout, present: MonitorInfo[]): SavedMonitor[];
64
+ export interface RestoreMismatch {
65
+ saved: SavedMonitor;
66
+ /** What Windows actually did; null if the monitor vanished during the apply */
67
+ actual: MonitorInfo;
68
+ }
69
+ export interface RestoreResult {
70
+ /** Everything went where it was asked */
71
+ ok: boolean;
72
+ /** Saved monitors that were present and positioned */
73
+ applied: SavedMonitor[];
74
+ /** Saved monitors not connected, skipped */
75
+ absent: SavedMonitor[];
76
+ /** Connected monitors the layout does not mention, left where Windows put them */
77
+ unknown: MonitorInfo[];
78
+ /** Applied monitors that did not land where asked */
79
+ mismatches: RestoreMismatch[];
80
+ /** The arrangement after the apply and settle - what is actually on the desktop */
81
+ after: MonitorInfo[];
82
+ }
83
+ /**
84
+ * Re-apply a layout to whichever of its monitors are present, then read the
85
+ * arrangement back and report what Windows actually did.
86
+ *
87
+ * Throws when nothing can be applied (no saved monitor connected, or the
88
+ * driver refused) - the caller turns that into stderr + exit 1. Mismatches
89
+ * are not an exception: they are the round-trip verdict, in the result.
90
+ */
91
+ export declare function restoreLayout(layout: ScreenLayout, log?: (msg: string) => void): Promise<RestoreResult>;
92
+ /**
93
+ * The monitor table, one line per monitor plus a header. With `screens` given
94
+ * (winpos's sorted list) the first column is the winpos screen number, which
95
+ * is the point: it ties "screen 2" to a physical panel.
96
+ */
97
+ export declare function formatMonitors(monitors: MonitorInfo[], screens?: ScreenInfo[]): string[];
98
+ /**
99
+ * Stay resident and re-apply the named layout whenever Windows moves a saved
100
+ * monitor away from it. Never returns; ctrl-C ends it.
101
+ *
102
+ * Traps this is built around: the apply fires a display change of its own
103
+ * (quiet period); Windows closes gaps when a monitor is absent (the read-back
104
+ * is logged, not the request); and if an apply changes nothing, Windows has
105
+ * refused that set, so retrying is a loop - it stops until the set changes.
106
+ */
107
+ export declare function watchLayout(name?: string, log?: (msg: string) => void): Promise<never>;
108
+ //# sourceMappingURL=screenlayout.d.ts.map
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Monitor arrangement layouts: save the current arrangement, restore a saved
3
+ * one to whichever of its monitors are present, verify by reading back, and
4
+ * watch for Windows undoing it.
5
+ *
6
+ * Why this is in winpos: winpos's screen numbers are the monitors sorted by
7
+ * position, and saved window layouts address screens by that number. When
8
+ * Windows reverts the arrangement (it keeps one layout per exact set of
9
+ * connected monitors, and a DisplayPort monitor that sleeps leaves the set),
10
+ * every screen number changes and every window layout lands on the wrong
11
+ * panel. Restoring the monitors is a precondition for restoring the windows.
12
+ *
13
+ * 2026-09-24 - Claude Code (Fable 5.1), at Bob's direction. Ported from
14
+ * %OneDrive%\xfer\bin\displays.ps1; the file format is the same, so a layout
15
+ * copies between the two. Background: .llm/screens-restore.md and
16
+ * %OneDrive%\Documents\AI\displays.md.
17
+ */
18
+ import { hostname } from 'os';
19
+ import { join } from 'path';
20
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
21
+ import { configDir } from '@bobfrankston/userconfig';
22
+ import { enumerateMonitors, stageMonitorPosition, applyMonitorPositions, dispChangeText } from './monitors.js';
23
+ import { DISP_CHANGE_SUCCESSFUL } from './ffi-wrapper.js';
24
+ /** The one standard layout per machine; a name is only for an alternate (travel, single-monitor...) */
25
+ export const DEFAULT_LAYOUT_NAME = 'default';
26
+ /** Where the layouts live: in the user config area, per the UserConfig path rule */
27
+ export const layoutsPath = join(configDir, 'winpos-screens.json');
28
+ /** How long Windows takes to settle after a ChangeDisplaySettingsEx apply before a read-back is meaningful */
29
+ const APPLY_SETTLE_MS = 1500;
30
+ /**
31
+ * This machine's key in the layouts file. Upper-cased to match COMPUTERNAME,
32
+ * which is what displays.json was keyed by, so a layout copies across.
33
+ */
34
+ export const machine = hostname().toUpperCase();
35
+ export function readLayouts() {
36
+ if (!existsSync(layoutsPath))
37
+ return {};
38
+ return JSON.parse(readFileSync(layoutsPath, 'utf-8'));
39
+ }
40
+ export function writeLayouts(all) {
41
+ mkdirSync(configDir, { recursive: true });
42
+ writeFileSync(layoutsPath, JSON.stringify(all, null, 2) + '\n', 'utf-8');
43
+ }
44
+ /** The layouts saved for this machine, by name */
45
+ export function machineLayouts() {
46
+ return readLayouts()[machine] ?? {};
47
+ }
48
+ /** A saved layout for this machine, or null */
49
+ export function getLayout(name = DEFAULT_LAYOUT_NAME) {
50
+ return machineLayouts()[name] ?? null;
51
+ }
52
+ /** Save the current arrangement under `name` for this machine */
53
+ export function saveLayout(name = DEFAULT_LAYOUT_NAME) {
54
+ const monitors = enumerateMonitors();
55
+ const layout = {
56
+ saved: new Date().toISOString(),
57
+ monitors: monitors.map(m => ({ id: m.id, name: m.name, x: m.x, y: m.y, width: m.width, height: m.height, hz: m.hz, primary: m.primary })),
58
+ };
59
+ const all = readLayouts();
60
+ all[machine] ??= {};
61
+ all[machine][name] = layout;
62
+ writeLayouts(all);
63
+ return layout;
64
+ }
65
+ /** A layout given inline (a monitors array, or a full layout) or by saved name, as a ScreenLayout */
66
+ export function resolveLayout(spec) {
67
+ if (typeof spec === 'string')
68
+ return getLayout(spec);
69
+ if (Array.isArray(spec))
70
+ return { saved: '', monitors: spec };
71
+ return spec;
72
+ }
73
+ /** Whether a present monitor sits where the layout says (position and primary flag) */
74
+ export function inPlace(saved, actual) {
75
+ return actual.x === saved.x && actual.y === saved.y && actual.primary === saved.primary;
76
+ }
77
+ /** The saved monitors that are present but not where the layout puts them */
78
+ export function outOfPlace(layout, present) {
79
+ const byId = new Map(present.map(m => [m.id, m]));
80
+ return layout.monitors.filter(saved => {
81
+ const actual = byId.get(saved.id);
82
+ return actual && !inPlace(saved, actual);
83
+ });
84
+ }
85
+ /**
86
+ * Re-apply a layout to whichever of its monitors are present, then read the
87
+ * arrangement back and report what Windows actually did.
88
+ *
89
+ * Throws when nothing can be applied (no saved monitor connected, or the
90
+ * driver refused) - the caller turns that into stderr + exit 1. Mismatches
91
+ * are not an exception: they are the round-trip verdict, in the result.
92
+ */
93
+ export async function restoreLayout(layout, log = console.log) {
94
+ const present = enumerateMonitors();
95
+ const byId = new Map(present.map(m => [m.id, m]));
96
+ const applied = [];
97
+ const absent = [];
98
+ for (const saved of layout.monitors) {
99
+ const target = byId.get(saved.id);
100
+ if (!target) {
101
+ absent.push(saved);
102
+ log(`Absent: ${saved.name} [${saved.id}] is in the layout but not connected; skipped.`);
103
+ continue;
104
+ }
105
+ const rc = stageMonitorPosition(target.device, saved.x, saved.y, saved.primary);
106
+ if (rc !== DISP_CHANGE_SUCCESSFUL)
107
+ throw new Error(`ChangeDisplaySettingsEx failed for ${saved.name} (${target.device}): ${dispChangeText(rc)}`);
108
+ applied.push(saved);
109
+ }
110
+ const savedIds = new Set(layout.monitors.map(m => m.id));
111
+ const unknown = present.filter(m => !savedIds.has(m.id));
112
+ for (const m of unknown)
113
+ log(`Unknown: ${m.name} [${m.id}] is connected but not in the layout; left where Windows put it.`);
114
+ if (applied.length === 0)
115
+ throw new Error('None of the monitors in the layout are connected; nothing applied.');
116
+ const rc = applyMonitorPositions();
117
+ if (rc !== DISP_CHANGE_SUCCESSFUL)
118
+ throw new Error(`Apply failed: ${dispChangeText(rc)}`);
119
+ // Round trip: report what Windows did, not what was asked. Windows closes
120
+ // gaps when a monitor is missing, so the two can differ legitimately.
121
+ await new Promise(resolve => setTimeout(resolve, APPLY_SETTLE_MS));
122
+ const after = enumerateMonitors();
123
+ const afterById = new Map(after.map(m => [m.id, m]));
124
+ const mismatches = [];
125
+ for (const saved of applied) {
126
+ const actual = afterById.get(saved.id);
127
+ if (!actual || !inPlace(saved, actual)) {
128
+ mismatches.push({ saved, actual });
129
+ const got = actual ? `${actual.x},${actual.y} primary=${actual.primary}` : 'gone';
130
+ log(`Mismatch: ${saved.name} asked ${saved.x},${saved.y} primary=${saved.primary} -> got ${got}`);
131
+ }
132
+ }
133
+ log(`Applied layout: ${applied.length} of ${layout.monitors.length} saved monitors positioned.`);
134
+ return { ok: mismatches.length === 0, applied, absent, unknown, mismatches, after };
135
+ }
136
+ /**
137
+ * The monitor table, one line per monitor plus a header. With `screens` given
138
+ * (winpos's sorted list) the first column is the winpos screen number, which
139
+ * is the point: it ties "screen 2" to a physical panel.
140
+ */
141
+ export function formatMonitors(monitors, screens = []) {
142
+ const indexByDevice = new Map(screens.map((s, i) => [s.deviceName, i]));
143
+ const rows = monitors.map(m => ({
144
+ scr: indexByDevice.has(m.device) ? String(indexByDevice.get(m.device)) : '-',
145
+ device: m.device.replace(/^\\\\\.\\/, ''),
146
+ id: m.id,
147
+ name: m.name,
148
+ position: `${m.x},${m.y}`,
149
+ mode: `${m.width}x${m.height}@${m.hz}`,
150
+ primary: m.primary ? '*' : '',
151
+ }));
152
+ const order = (scr) => scr === '-' ? Number.MAX_SAFE_INTEGER : Number(scr);
153
+ rows.sort((a, b) => order(a.scr) - order(b.scr));
154
+ const header = { scr: 'Scr', device: 'Device', id: 'Id', name: 'Name', position: 'Position', mode: 'Mode', primary: 'Primary' };
155
+ const columns = ['scr', 'device', 'id', 'name', 'position', 'mode', 'primary'];
156
+ const widths = Object.fromEntries(columns.map(c => [c, Math.max(header[c].length, ...rows.map(r => r[c].length))]));
157
+ const line = (r) => columns.map(c => r[c].padEnd(widths[c])).join(' ').trimEnd();
158
+ return [line(header), columns.map(c => '-'.repeat(widths[c])).join(' '), ...rows.map(line)];
159
+ }
160
+ // Watch: poll rather than a WM_DISPLAYCHANGE window, so there is no message
161
+ // loop and no koffi callback (problem.txt). A poll every few seconds is two
162
+ // cheap user32 calls per monitor.
163
+ const WATCH_POLL_MS = 3000;
164
+ /** Consecutive identical polls before a changed arrangement counts as settled */
165
+ const WATCH_SETTLE_POLLS = 2;
166
+ /** After an apply, ignore what looks like change for this long: the apply itself is one */
167
+ const WATCH_QUIET_MS = 6000;
168
+ /** What matters for "did the arrangement change": who is present and where */
169
+ function arrangementSignature(monitors) {
170
+ return monitors.map(m => `${m.id}@${m.x},${m.y}${m.primary ? '*' : ''}`).sort().join(' ');
171
+ }
172
+ const stamp = () => new Date().toLocaleTimeString();
173
+ /**
174
+ * Stay resident and re-apply the named layout whenever Windows moves a saved
175
+ * monitor away from it. Never returns; ctrl-C ends it.
176
+ *
177
+ * Traps this is built around: the apply fires a display change of its own
178
+ * (quiet period); Windows closes gaps when a monitor is absent (the read-back
179
+ * is logged, not the request); and if an apply changes nothing, Windows has
180
+ * refused that set, so retrying is a loop - it stops until the set changes.
181
+ */
182
+ export async function watchLayout(name = DEFAULT_LAYOUT_NAME, log = console.log) {
183
+ if (!getLayout(name))
184
+ throw new Error(`No layout '${name}' saved for ${machine}. Run 'winpos -screens save' first.`);
185
+ log(`${stamp()} Watching the monitor arrangement against layout '${name}' every ${WATCH_POLL_MS / 1000}s (ctrl-C to stop)`);
186
+ // The first read is the baseline, not a change - and it counts as settled,
187
+ // so an arrangement that is already wrong when the watch starts is fixed
188
+ // on the first poll rather than waiting for Windows to move something.
189
+ let lastSignature = arrangementSignature(enumerateMonitors());
190
+ let stablePolls = WATCH_SETTLE_POLLS;
191
+ let quietUntil = 0;
192
+ let refusedSignature = ''; // an arrangement Windows would not change; wait for the set to change
193
+ for (;;) {
194
+ await new Promise(resolve => setTimeout(resolve, WATCH_POLL_MS));
195
+ const present = enumerateMonitors();
196
+ const signature = arrangementSignature(present);
197
+ if (signature !== lastSignature) {
198
+ lastSignature = signature;
199
+ stablePolls = 0;
200
+ log(`${stamp()} Arrangement changed: ${present.map(m => `${m.name} ${m.x},${m.y}`).join('; ')}`);
201
+ continue;
202
+ }
203
+ if (++stablePolls < WATCH_SETTLE_POLLS)
204
+ continue;
205
+ if (Date.now() < quietUntil)
206
+ continue;
207
+ if (signature === refusedSignature)
208
+ continue;
209
+ // Re-read each time so a fresh 'winpos -screens save' takes effect without a restart.
210
+ const layout = getLayout(name);
211
+ if (!layout) {
212
+ log(`${stamp()} Layout '${name}' has disappeared from ${layoutsPath}; waiting.`);
213
+ continue;
214
+ }
215
+ const moved = outOfPlace(layout, present);
216
+ if (moved.length === 0)
217
+ continue;
218
+ log(`${stamp()} Out of place: ${moved.map(m => m.name).join(', ')}; restoring layout '${name}'`);
219
+ try {
220
+ const result = await restoreLayout(layout, log);
221
+ quietUntil = Date.now() + WATCH_QUIET_MS;
222
+ const afterSignature = arrangementSignature(result.after);
223
+ if (afterSignature === signature) {
224
+ refusedSignature = signature;
225
+ log(`${stamp()} Windows did not change the arrangement; not retrying until the monitor set changes.`);
226
+ }
227
+ else if (!result.ok) {
228
+ log(`${stamp()} ${result.mismatches.length} monitor(s) did not land where asked; will re-check after the next change.`);
229
+ }
230
+ else {
231
+ log(`${stamp()} Layout '${name}' restored.`);
232
+ }
233
+ lastSignature = afterSignature;
234
+ stablePolls = 0;
235
+ }
236
+ catch (error) {
237
+ quietUntil = Date.now() + WATCH_QUIET_MS;
238
+ refusedSignature = signature;
239
+ log(`${stamp()} Restore failed: ${error.message}; not retrying until the monitor set changes.`);
240
+ }
241
+ }
242
+ }
243
+ //# sourceMappingURL=screenlayout.js.map
package/screens.d.ts CHANGED
@@ -3,6 +3,14 @@
3
3
  */
4
4
  export interface ScreenInfo {
5
5
  deviceName: string;
6
+ /**
7
+ * Which physical monitor this is: EDID id + serial (e.g. GSM7721:106NTKF55597),
8
+ * the key monitor layouts use. Undefined when the identity could not be read.
9
+ * 2026-09-24 - Claude Code (Fable 5.1): added with the -screens commands.
10
+ */
11
+ id?: string;
12
+ /** The monitor's friendly name from its EDID, e.g. "LG HDR 5K" */
13
+ name?: string;
6
14
  bounds: {
7
15
  Left: number;
8
16
  Top: number;
package/screens.js CHANGED
@@ -2,6 +2,7 @@
2
2
  * Screen management - enumerate and sort displays
3
3
  */
4
4
  import { user32, isBun, MONITORINFOF_PRIMARY, DEFAULT_SCALE_PERCENT } from './ffi-wrapper.js';
5
+ import { enumerateMonitors } from './monitors.js';
5
6
  /** Display scaling of a screen as a factor (1.25 for a 125% display) */
6
7
  export function screenScale(screen) {
7
8
  return (screen.scalePercent || DEFAULT_SCALE_PERCENT) / 100;
@@ -109,6 +110,26 @@ export function enumerateScreens() {
109
110
  scalePercent: DEFAULT_SCALE_PERCENT,
110
111
  });
111
112
  }
113
+ // 2026-09-24 - Claude Code (Fable 5.1), at Bob's direction: tag each screen
114
+ // with the monitor's identity, joined on the adapter name. One identity
115
+ // mechanism for both halves of winpos: the window layouts and the monitor
116
+ // layouts name the same panel the same way.
117
+ try {
118
+ const byDevice = new Map(enumerateMonitors().map(m => [m.device, m]));
119
+ for (const screen of screens) {
120
+ const monitor = byDevice.get(screen.deviceName);
121
+ if (!monitor)
122
+ continue;
123
+ screen.id = monitor.id;
124
+ screen.name = monitor.name;
125
+ }
126
+ }
127
+ catch (error) {
128
+ // Justified swallow: identity is decoration here - window positioning
129
+ // works from bounds alone and always has. The -screens commands call
130
+ // enumerateMonitors() themselves and report this same error there,
131
+ // so it is not lost, only deferred to the command that needs it.
132
+ }
112
133
  return screens;
113
134
  }
114
135
  // Tops within this many pixels are treated as the same row (DPI/rounding noise).