ntk 4.0.0 → 4.1.0

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/lib/window.js CHANGED
@@ -1,10 +1,10 @@
1
- import keysym from 'keysym';
2
1
  import x11 from 'x11';
3
2
 
4
3
  import { safeRelease } from './cleanup.js';
5
4
  import Drawable from './drawable.js';
6
5
  import Pixmap from './pixmap.js';
7
6
  import * as xevents from './events_map.js';
7
+ import { decodeKey } from './keyboard.js';
8
8
 
9
9
  /**
10
10
  * How many rectangles a frame's dirty region is allowed to hold.
@@ -135,6 +135,125 @@ const forwardedXAttributes = [
135
135
  'cursor'
136
136
  ];
137
137
 
138
+ // ICCCM 4.1.2.3 WM_NORMAL_HINTS flags. The first four say who chose the
139
+ // window's geometry — without one of them a window manager is free to place
140
+ // the window wherever it likes, whatever x/y it was created with.
141
+ const SIZE_HINT = {
142
+ USPosition: 1,
143
+ USSize: 2,
144
+ PPosition: 4,
145
+ PSize: 8,
146
+ PMinSize: 16,
147
+ PMaxSize: 32,
148
+ PResizeInc: 64,
149
+ PAspect: 128,
150
+ PBaseSize: 256,
151
+ PWinGravity: 512
152
+ };
153
+
154
+ // ICCCM 4.1.2.4 WM_HINTS flags. MessageHint (128) is obsolete and unused.
155
+ const WM_HINT = {
156
+ Input: 1,
157
+ State: 2,
158
+ IconPixmap: 4,
159
+ IconWindow: 8,
160
+ IconPosition: 16,
161
+ IconMask: 32,
162
+ WindowGroup: 64,
163
+ Urgency: 256
164
+ };
165
+
166
+ const SIZE_HINT_KEYS = new Set([
167
+ 'minWidth', 'minHeight', 'maxWidth', 'maxHeight',
168
+ 'widthInc', 'heightInc', 'baseWidth', 'baseHeight',
169
+ 'minAspect', 'maxAspect', 'gravity', 'resizable',
170
+ 'position', 'size', 'x', 'y', 'width', 'height'
171
+ ]);
172
+
173
+ const WM_HINT_KEYS = new Set([
174
+ 'input', 'initialState', 'urgent', 'icon', 'iconPixmap',
175
+ 'iconMask', 'iconWindow', 'iconX', 'iconY', 'windowGroup'
176
+ ]);
177
+
178
+ // Hint keys `createWindow` also accepts at the top level, per the API sketch
179
+ // in ntk#19. x/y/width/height are deliberately absent: at the top level they
180
+ // are the window's geometry, and only inside a `hints`/`sizeHints` object do
181
+ // they mean the WM_NORMAL_HINTS fields of the same name. `position`/`size`
182
+ // are absent for the same reason — as bare creation arguments they read as
183
+ // geometry rather than as "who chose it".
184
+ const CREATION_HINT_KEYS = [
185
+ ...[...SIZE_HINT_KEYS].filter(
186
+ (k) => !['x', 'y', 'width', 'height', 'position', 'size', 'resizable'].includes(k)
187
+ ),
188
+ ...WM_HINT_KEYS,
189
+ 'transientFor',
190
+ 'protocols'
191
+ ];
192
+
193
+ // EWMH 5.7 _NET_WM_STATE, in spec order. FOCUSED and HIDDEN are set by the
194
+ // window manager rather than asked for, but a client still reads them.
195
+ const EWMH_STATES = [
196
+ 'modal',
197
+ 'sticky',
198
+ 'maximized_vert',
199
+ 'maximized_horz',
200
+ 'shaded',
201
+ 'skip_taskbar',
202
+ 'skip_pager',
203
+ 'hidden',
204
+ 'fullscreen',
205
+ 'above',
206
+ 'below',
207
+ 'demands_attention',
208
+ 'focused'
209
+ ];
210
+
211
+ /** 'fullscreen' -> '_NET_WM_STATE_FULLSCREEN'; a full atom name passes through. */
212
+ function stateAtomName(name) {
213
+ return name.startsWith('_NET_WM_STATE_') ? name : `_NET_WM_STATE_${name.toUpperCase()}`;
214
+ }
215
+
216
+ /** The inverse, for reporting what the window manager put on the window. */
217
+ function stateShortName(atomName) {
218
+ return atomName.startsWith('_NET_WM_STATE_')
219
+ ? atomName.slice('_NET_WM_STATE_'.length).toLowerCase()
220
+ : atomName;
221
+ }
222
+
223
+ /**
224
+ * Normalize the argument of setWmState.
225
+ *
226
+ * 'maximized' is the one name that is not an atom: EWMH maximizes an axis at
227
+ * a time, and giving the message both atoms at once is exactly what its two
228
+ * state slots are for.
229
+ */
230
+ function expandStateNames(names) {
231
+ const list = Array.isArray(names) ? names : [names];
232
+ return list.flatMap((n) =>
233
+ n === 'maximized' ? ['maximized_vert', 'maximized_horz'] : [n]
234
+ );
235
+ }
236
+
237
+ /**
238
+ * Warn once per process about a hint that will do nothing.
239
+ *
240
+ * These mistakes are silent everywhere else: the server stores whatever
241
+ * property bytes it is handed, and only a window manager could tell that
242
+ * what it read means nothing. Same shape as node-x11's ChangeProperty
243
+ * warning, so a caller who gets it wrong on every window hears it once.
244
+ */
245
+ const warnedHints = new Set();
246
+ function warnHint(key, message) {
247
+ if (warnedHints.has(key)) return;
248
+ warnedHints.add(key);
249
+ console.warn(`ntk: ${message} Further occurrences are not reported.`);
250
+ }
251
+
252
+ /** A Window, a Pixmap, or a bare XID — all of these name a server resource. */
253
+ function resourceId(value) {
254
+ return typeof value === 'number' ? value : (value?.id ?? 0);
255
+ }
256
+
138
257
  /**
139
258
  * Turn a GetProperty reply into the shape the caller asked for. X property
140
259
  * types are atoms, so the only distinction we can make without a round trip
@@ -191,6 +310,17 @@ export default class Window extends Drawable {
191
310
  this._mapped = false;
192
311
  this._destroyed = false;
193
312
  this._titleSerial = 0;
313
+ this._overrideRedirect = !!args.overrideRedirect;
314
+ // WM_PROTOCOLS is a set: read-modify-write, serialized (see addProtocol)
315
+ this._protocols = null;
316
+ this._protocolQueue = Promise.resolve();
317
+ // so is _NET_WM_STATE, on the unmapped path (see setWmState)
318
+ this._wmStateQueue = Promise.resolve();
319
+ this._netWmStateAtom = 0; // resolved when a 'statechange' listener appears
320
+ // what setHints has accumulated per struct, so a later call can rewrite
321
+ // the whole property without dropping what an earlier one set
322
+ this._sizeHints = null;
323
+ this._wmHints = null;
194
324
  this._readyPromiseResolve = null;
195
325
  this._readyPromise = new Promise((resolve) => {
196
326
  this._readyPromiseResolve = resolve;
@@ -302,8 +432,23 @@ export default class Window extends Drawable {
302
432
  if (args.windowType) {
303
433
  this.setWindowType(args.windowType);
304
434
  }
305
- if (args.sizeHints || args.resizable === false) {
306
- this.setSizeHints({ ...args.sizeHints, ...(args.resizable === false && { resizable: false }) });
435
+ // ICCCM/EWMH hints at creation: `hints: { ... }`, the `sizeHints`/
436
+ // `resizable` arguments that predate it, or the hint names on their own
437
+ // at the top level. All of them land in one setHints call so that a
438
+ // window created with `minWidth` and a `hints` block writes
439
+ // WM_NORMAL_HINTS once, with both.
440
+ const hints = { ...args.sizeHints, ...args.hints };
441
+ if (args.resizable !== undefined) hints.resizable = args.resizable;
442
+ for (const key of CREATION_HINT_KEYS) {
443
+ if (args[key] !== undefined) hints[key] = args[key];
444
+ }
445
+ if (Object.keys(hints).length) {
446
+ this.setHints(hints);
447
+ }
448
+ // EWMH wants a pid and a client machine on top-level windows; child
449
+ // windows are an implementation detail nothing asks about
450
+ if (!args.id && args.pid !== false && parentId === app.display.screen[0].root) {
451
+ this.setPid(typeof args.pid === 'number' ? args.pid : undefined);
307
452
  }
308
453
  if (args.alwaysOnTop) {
309
454
  this.setAlwaysOnTop(true);
@@ -330,23 +475,32 @@ export default class Window extends Drawable {
330
475
  this._handleExpose(ev);
331
476
  return;
332
477
  }
333
- // TODO cleanup
334
- // also it seems that in keysym module not all keys have valid unicode values
335
- if (eventName === 'keydown') {
336
- const evKeysym = X.keycode2keysyms[ev.keycode];
337
- const shift = ev.buttons & 1;
338
- const capsLock = ev.buttons & 2;
339
- const capital = (capsLock && !shift) || (shift && !capsLock);
340
- const symInd = capital ? 1 : 0; // TODO: AltGr & other modifiers to use syms 2, 3, 4 etc
341
- const entry = evKeysym && keysym.fromKeysym(evKeysym[symInd]);
342
- if (entry) {
343
- ev.codepoint = entry.unicode;
478
+ if (eventName === 'keydown' || eventName === 'keyup') {
479
+ // the active layout arrives in the event's own state bits, not in the
480
+ // keymap — see lib/keyboard.js. Keys that type nothing (arrows,
481
+ // F-keys, modifiers) leave `codepoint` absent rather than reporting 0.
482
+ const key = decodeKey(X.keycode2keysyms[ev.keycode], ev.buttons);
483
+ if (key) {
484
+ ev.keysym = key.keysym;
485
+ ev.baseKeysym = key.baseKeysym;
486
+ ev.group = key.group;
487
+ if (key.codepoint !== undefined) ev.codepoint = key.codepoint;
344
488
  }
345
489
  }
346
490
  if (this._coalesce && xevents.coalesce[eventName]) {
347
491
  this._enqueueCoalesced(eventName, ntkev);
348
492
  return;
349
493
  }
494
+ // the window manager reporting what it did with the window: a user who
495
+ // hit maximize or a fullscreen hotkey changes the state behind the
496
+ // app's back, and this is the only way an app that mirrors it in its
497
+ // own UI stays honest
498
+ if (eventName === 'property' && ev.atom === this._netWmStateAtom) {
499
+ this.getWmStates().then(
500
+ (states) => this.emit('statechange', states),
501
+ () => {}
502
+ );
503
+ }
350
504
  // deliver buffered state events before a discrete one so handlers see
351
505
  // them in the order they happened (a drag sees the move, then the up)
352
506
  this._flushCoalesced();
@@ -373,6 +527,16 @@ export default class Window extends Drawable {
373
527
  });
374
528
 
375
529
  this.on('newListener', (name) => {
530
+ // 'statechange' is derived from a PropertyNotify, so it needs the atom
531
+ // to compare against as well as the mask the table below selects
532
+ if (name === 'statechange' && !this._netWmStateAtom) {
533
+ this.atom('_NET_WM_STATE').then(
534
+ (atom) => {
535
+ this._netWmStateAtom = atom;
536
+ },
537
+ () => {}
538
+ );
539
+ }
376
540
  // extend the server-side event mask if this event needs it
377
541
  const eventMask = xevents.mask[name];
378
542
  if (!eventMask) return;
@@ -876,16 +1040,25 @@ export default class Window extends Drawable {
876
1040
  }
877
1041
 
878
1042
  /**
879
- * ICCCM WM_NORMAL_HINTS — how the window manager may resize this window.
1043
+ * ICCCM WM_NORMAL_HINTS — how the window manager may size and place this
1044
+ * window.
880
1045
  *
881
1046
  * setSizeHints({ minWidth, minHeight, maxWidth, maxHeight,
882
1047
  * widthInc, heightInc, baseWidth, baseHeight,
883
1048
  * minAspect: [num, den], maxAspect: [num, den],
884
- * gravity })
1049
+ * gravity, position, size, resizable })
885
1050
  *
886
1051
  * `resizable: false` is shorthand for pinning min and max to the current
887
1052
  * size. Without this property a WM lets the user resize a window to any
888
1053
  * size at all, which is why fixed-size dialogs need it.
1054
+ *
1055
+ * `position` and `size` are `'user'` or `'program'` and declare who chose
1056
+ * the geometry — a window manager that sees neither is free to place the
1057
+ * window wherever its own policy says, whatever x/y it was created with.
1058
+ * Passing `x`/`y` or `width`/`height` here implies `'program'`.
1059
+ *
1060
+ * This writes the whole struct: hints not passed are not carried over from
1061
+ * an earlier call. `setHints()` is the accumulating form.
889
1062
  */
890
1063
  setSizeHints(hints = {}) {
891
1064
  const {
@@ -900,8 +1073,13 @@ export default class Window extends Drawable {
900
1073
  minAspect,
901
1074
  maxAspect,
902
1075
  gravity,
903
- resizable
1076
+ resizable,
1077
+ x,
1078
+ y,
1079
+ width,
1080
+ height
904
1081
  } = hints;
1082
+ let { position, size } = hints;
905
1083
 
906
1084
  let minW = minWidth;
907
1085
  let minH = minHeight;
@@ -913,50 +1091,75 @@ export default class Window extends Drawable {
913
1091
  maxW = maxW ?? this.width;
914
1092
  maxH = maxH ?? this.height;
915
1093
  }
1094
+ // naming a geometry here is itself the statement that the program chose
1095
+ // it; the flag is what carries that, and the fields alone are obsolete
1096
+ if (position === undefined && (x !== undefined || y !== undefined)) position = 'program';
1097
+ if (size === undefined && (width !== undefined || height !== undefined)) size = 'program';
916
1098
 
917
1099
  // XSizeHints: 18 CARD32s, flags first (ICCCM 4.1.2.3)
918
- const PMinSize = 16;
919
- const PMaxSize = 32;
920
- const PResizeInc = 64;
921
- const PAspect = 128;
922
- const PBaseSize = 256;
923
- const PWinGravity = 512;
924
-
925
1100
  const v = new Uint32Array(18);
926
1101
  let flags = 0;
1102
+ if (position) {
1103
+ flags |= position === 'user' ? SIZE_HINT.USPosition : SIZE_HINT.PPosition;
1104
+ v[1] = x ?? this.x ?? 0;
1105
+ v[2] = y ?? this.y ?? 0;
1106
+ }
1107
+ if (size) {
1108
+ flags |= size === 'user' ? SIZE_HINT.USSize : SIZE_HINT.PSize;
1109
+ v[3] = width ?? this.width ?? 0;
1110
+ v[4] = height ?? this.height ?? 0;
1111
+ }
927
1112
  if (minW !== undefined || minH !== undefined) {
928
- flags |= PMinSize;
1113
+ flags |= SIZE_HINT.PMinSize;
929
1114
  v[5] = minW ?? 0;
930
1115
  v[6] = minH ?? 0;
931
1116
  }
932
1117
  if (maxW !== undefined || maxH !== undefined) {
933
- flags |= PMaxSize;
1118
+ flags |= SIZE_HINT.PMaxSize;
934
1119
  v[7] = maxW ?? 0;
935
1120
  v[8] = maxH ?? 0;
936
1121
  }
937
1122
  if (widthInc !== undefined || heightInc !== undefined) {
938
- flags |= PResizeInc;
1123
+ flags |= SIZE_HINT.PResizeInc;
939
1124
  v[9] = widthInc ?? 1;
940
1125
  v[10] = heightInc ?? 1;
941
1126
  }
942
1127
  if (minAspect || maxAspect) {
943
- flags |= PAspect;
1128
+ flags |= SIZE_HINT.PAspect;
944
1129
  v[11] = minAspect?.[0] ?? 0;
945
1130
  v[12] = minAspect?.[1] ?? 1;
946
1131
  v[13] = maxAspect?.[0] ?? 0;
947
1132
  v[14] = maxAspect?.[1] ?? 1;
948
1133
  }
949
1134
  if (baseWidth !== undefined || baseHeight !== undefined) {
950
- flags |= PBaseSize;
1135
+ flags |= SIZE_HINT.PBaseSize;
951
1136
  v[15] = baseWidth ?? 0;
952
1137
  v[16] = baseHeight ?? 0;
953
1138
  }
954
1139
  if (gravity !== undefined) {
955
- flags |= PWinGravity;
1140
+ flags |= SIZE_HINT.PWinGravity;
956
1141
  v[17] = gravity;
957
1142
  }
958
1143
  v[0] = flags;
959
1144
 
1145
+ if (flags === 0) {
1146
+ // WM_NORMAL_HINTS with flags 0 is a legal property meaning "I declare
1147
+ // nothing" — indistinguishable from never having written it, and
1148
+ // reported by nobody. `resizable: true` legitimately means that, so it
1149
+ // is the one silent case; anything else here is a typo or a key from
1150
+ // the wrong hint struct.
1151
+ const keys = Object.keys(hints);
1152
+ const onlyResizable = keys.length === 1 && resizable === true;
1153
+ if (!onlyResizable) {
1154
+ warnHint(
1155
+ 'sizeHints-empty',
1156
+ `setSizeHints({ ${keys.join(', ')} }) sets no WM_NORMAL_HINTS flag, so nothing was ` +
1157
+ 'written — a flags word of 0 declares nothing and no window manager reports it.'
1158
+ );
1159
+ }
1160
+ return this;
1161
+ }
1162
+
960
1163
  safeRelease(this.X, () => {
961
1164
  this.X.ChangeProperty(
962
1165
  0,
@@ -970,6 +1173,345 @@ export default class Window extends Drawable {
970
1173
  return this;
971
1174
  }
972
1175
 
1176
+ /**
1177
+ * ICCCM WM_HINTS — what the window wants from the window manager beyond
1178
+ * its geometry.
1179
+ *
1180
+ * setWmHints({ input, initialState, urgent, icon, iconMask, iconWindow,
1181
+ * iconX, iconY, windowGroup })
1182
+ *
1183
+ * - `input` is the ICCCM 4.1.7 input model: true if the window expects the
1184
+ * window manager to give it the keyboard focus. Set it before relying on
1185
+ * focus at all — a window manager reading no input hint is entitled to
1186
+ * assume the window takes focus for itself.
1187
+ * - `initialState` is `'normal'` or `'iconic'` — the state to start in,
1188
+ * read once, when the window is first mapped.
1189
+ * - `urgent` is the attention flag: taskbars flash, some window managers
1190
+ * raise. Clear it when the user has looked.
1191
+ * - `icon` (an ntk `Pixmap` or an XID) is the ICCCM icon pixmap, which is
1192
+ * the old 1-bit-or-depth-matched mechanism. Modern desktops prefer
1193
+ * EWMH `_NET_WM_ICON`; set both if you care about old window managers.
1194
+ * - `windowGroup` names a window the others belong to — the group
1195
+ * `setTransientFor('root')` refers to.
1196
+ *
1197
+ * Like `setSizeHints`, this writes the whole struct rather than merging
1198
+ * with what an earlier call set; `setHints()` is the accumulating form.
1199
+ */
1200
+ setWmHints(hints = {}) {
1201
+ const {
1202
+ input,
1203
+ initialState,
1204
+ urgent,
1205
+ icon,
1206
+ iconPixmap,
1207
+ iconMask,
1208
+ iconWindow,
1209
+ iconX,
1210
+ iconY,
1211
+ windowGroup
1212
+ } = hints;
1213
+
1214
+ // XWMHints: 9 CARD32s, flags first (ICCCM 4.1.2.4)
1215
+ const v = new Uint32Array(9);
1216
+ let flags = 0;
1217
+ if (input !== undefined) {
1218
+ flags |= WM_HINT.Input;
1219
+ v[1] = input ? 1 : 0;
1220
+ }
1221
+ if (initialState !== undefined) {
1222
+ flags |= WM_HINT.State;
1223
+ // NormalState 1, IconicState 3 (ICCCM 4.1.2.4); the states between
1224
+ // them were withdrawn from the spec
1225
+ v[2] = initialState === 'iconic' ? 3 : initialState === 'normal' ? 1 : initialState;
1226
+ }
1227
+ const pixmap = iconPixmap ?? icon;
1228
+ if (pixmap !== undefined) {
1229
+ flags |= WM_HINT.IconPixmap;
1230
+ v[3] = resourceId(pixmap);
1231
+ }
1232
+ if (iconWindow !== undefined) {
1233
+ flags |= WM_HINT.IconWindow;
1234
+ v[4] = resourceId(iconWindow);
1235
+ }
1236
+ if (iconX !== undefined || iconY !== undefined) {
1237
+ flags |= WM_HINT.IconPosition;
1238
+ v[5] = iconX ?? 0;
1239
+ v[6] = iconY ?? 0;
1240
+ }
1241
+ if (iconMask !== undefined) {
1242
+ flags |= WM_HINT.IconMask;
1243
+ v[7] = resourceId(iconMask);
1244
+ }
1245
+ if (windowGroup !== undefined) {
1246
+ flags |= WM_HINT.WindowGroup;
1247
+ v[8] = resourceId(windowGroup);
1248
+ }
1249
+ if (urgent) flags |= WM_HINT.Urgency;
1250
+ v[0] = flags;
1251
+
1252
+ // `urgent: false` is the one call that legitimately produces flags 0:
1253
+ // clearing attention means rewriting the struct without the bit. So the
1254
+ // test here is whether any key was understood, not whether a flag came
1255
+ // out of it.
1256
+ const keys = Object.keys(hints);
1257
+ const understood = keys.filter((k) => WM_HINT_KEYS.has(k));
1258
+ if (understood.length === 0) {
1259
+ warnHint(
1260
+ 'wmHints-empty',
1261
+ `setWmHints({ ${keys.join(', ')} }) recognises none of those keys, so no WM_HINTS was written.`
1262
+ );
1263
+ return this;
1264
+ }
1265
+
1266
+ safeRelease(this.X, () => {
1267
+ this.X.ChangeProperty(
1268
+ 0,
1269
+ this.id,
1270
+ this.X.atoms.WM_HINTS,
1271
+ this.X.atoms.WM_HINTS,
1272
+ 32,
1273
+ Buffer.from(v.buffer, v.byteOffset, v.byteLength)
1274
+ );
1275
+ });
1276
+ return this;
1277
+ }
1278
+
1279
+ /**
1280
+ * ICCCM WM_TRANSIENT_FOR — the window this one belongs to. It is what
1281
+ * makes a second top-level window a *dialog* rather than an unrelated
1282
+ * application window: the window manager stacks it above its owner, keeps
1283
+ * it out of the taskbar and pager, iconifies it alongside, places it
1284
+ * relative to the owner and gives it a dialog's reduced frame.
1285
+ *
1286
+ * Accepts a `Window`, an XID, `'root'` (transient for the whole window
1287
+ * group — see `setWmHints({ windowGroup })`), or `null` to clear.
1288
+ *
1289
+ * Both atoms involved are predefined, so this needs no round trip and is
1290
+ * on the wire before a `map()` on the next line — which is what ICCCM
1291
+ * 4.1.2.6 expects, since a window manager may read the property only when
1292
+ * the transient is mapped.
1293
+ *
1294
+ * Related but not the same as `setWindowType('dialog')`: this names *which*
1295
+ * window is the owner, the type says *what kind* of window this is. EWMH
1296
+ * treats a managed window with WM_TRANSIENT_FOR and no `_NET_WM_WINDOW_TYPE`
1297
+ * as a dialog, but setting any type at all turns that fallback off — so
1298
+ * real toolkits set both.
1299
+ */
1300
+ setTransientFor(owner) {
1301
+ const X = this.X;
1302
+ if (owner == null) {
1303
+ safeRelease(X, () => X.DeleteProperty(this.id, X.atoms.WM_TRANSIENT_FOR));
1304
+ return this;
1305
+ }
1306
+ const id = owner === 'root' ? this.app.display.screen[0].root : resourceId(owner);
1307
+ if (this._overrideRedirect) {
1308
+ // ICCCM 4.1.2.6 contrasts the two mechanisms: this property is for
1309
+ // windows the WM manages, and an override-redirect window is never
1310
+ // managed, so the property just sits there
1311
+ warnHint(
1312
+ 'transient-override-redirect',
1313
+ 'setTransientFor on an override-redirect window has no effect — the window manager ' +
1314
+ 'never sees the window, so nothing reads the property.'
1315
+ );
1316
+ }
1317
+ safeRelease(X, () => {
1318
+ X.ChangeProperty(0, this.id, X.atoms.WM_TRANSIENT_FOR, X.atoms.WINDOW, 32, [id]);
1319
+ });
1320
+ return this;
1321
+ }
1322
+
1323
+ /** The XID in WM_TRANSIENT_FOR, or null. The read side, for WM helpers. */
1324
+ async getTransientFor() {
1325
+ const v = await this.getProperty('WM_TRANSIENT_FOR', { as: 'numbers' }).catch(() => null);
1326
+ return v && v.length ? v[0] : null;
1327
+ }
1328
+
1329
+ /**
1330
+ * ICCCM WM_PROTOCOLS — the messages this window is willing to receive, by
1331
+ * atom name: `'WM_DELETE_WINDOW'`, `'WM_TAKE_FOCUS'`, `'_NET_WM_PING'`,
1332
+ * `'_NET_WM_SYNC_REQUEST'`. Each arrives as a `'message'` event.
1333
+ *
1334
+ * Replaces the whole list. `addProtocol`/`removeProtocol` are the
1335
+ * accumulating forms, and the ones to reach for: the property is a *set*,
1336
+ * and a plain write of one atom silently drops the rest.
1337
+ *
1338
+ * @returns {Promise<Window>}
1339
+ */
1340
+ async setProtocols(names) {
1341
+ const list = (Array.isArray(names) ? names : [names]).filter(Boolean);
1342
+ const atoms = await Promise.all(list.map((n) => this.atom(n)));
1343
+ this._protocols = new Set(atoms);
1344
+ return this._writeProtocols();
1345
+ }
1346
+
1347
+ /**
1348
+ * Add one protocol to WM_PROTOCOLS, keeping the ones already there.
1349
+ * @returns {Promise<Window>}
1350
+ */
1351
+ addProtocol(name) {
1352
+ return this._changeProtocols(name, true);
1353
+ }
1354
+
1355
+ /** Remove one protocol from WM_PROTOCOLS. @returns {Promise<Window>} */
1356
+ removeProtocol(name) {
1357
+ return this._changeProtocols(name, false);
1358
+ }
1359
+
1360
+ /** The atom names in WM_PROTOCOLS. @returns {Promise<string[]>} */
1361
+ async getProtocols() {
1362
+ const atoms = await this.getProperty('WM_PROTOCOLS', { as: 'numbers' }).catch(() => null);
1363
+ if (!atoms || !atoms.length) return [];
1364
+ return Promise.all(
1365
+ atoms.map(
1366
+ (a) =>
1367
+ new Promise((resolve) => this.X.GetAtomName(a, (err, name) => resolve(err ? null : name)))
1368
+ )
1369
+ ).then((names) => names.filter(Boolean));
1370
+ }
1371
+
1372
+ /**
1373
+ * Read-modify-write on the atom set, serialized.
1374
+ *
1375
+ * Two adds in the same tick would otherwise each read the list before the
1376
+ * other wrote it, and the second would drop the first — the same clobber
1377
+ * this method exists to prevent, just harder to see.
1378
+ */
1379
+ _changeProtocols(name, add) {
1380
+ const run = async () => {
1381
+ const [current, atom] = await Promise.all([this._loadProtocols(), this.atom(name)]);
1382
+ if (add) current.add(atom);
1383
+ else current.delete(atom);
1384
+ return this._writeProtocols();
1385
+ };
1386
+ this._protocolQueue = this._protocolQueue.then(run, run);
1387
+ return this._protocolQueue;
1388
+ }
1389
+
1390
+ /**
1391
+ * The atom set as it stands on the server, read once.
1392
+ *
1393
+ * A window we created has none. A window adopted by id may already carry a
1394
+ * list its own client wrote, and replacing that is exactly the bug.
1395
+ */
1396
+ async _loadProtocols() {
1397
+ if (this._protocols) return this._protocols;
1398
+ const current = await this.getProperty('WM_PROTOCOLS', { as: 'numbers' }).catch(() => null);
1399
+ this._protocols = new Set(current || []);
1400
+ return this._protocols;
1401
+ }
1402
+
1403
+ async _writeProtocols() {
1404
+ const property = await this.atom('WM_PROTOCOLS');
1405
+ if (this._destroyed) return this;
1406
+ const atoms = [...this._protocols];
1407
+ safeRelease(this.X, () => {
1408
+ this.X.ChangeProperty(0, this.id, property, this.X.atoms.ATOM, 32, atoms);
1409
+ });
1410
+ return this;
1411
+ }
1412
+
1413
+ /**
1414
+ * EWMH `_NET_WM_PID` and ICCCM `WM_CLIENT_MACHINE` — which process on
1415
+ * which host owns this window. Together they are how a desktop offers to
1416
+ * force-quit an unresponsive application, and how `xkill`-style tools name
1417
+ * what they are about to kill; EWMH requires the machine for the pid to
1418
+ * mean anything, so both are written or neither is.
1419
+ *
1420
+ * Top-level windows get this automatically; pass `pid: false` at creation
1421
+ * to opt out. In a browser bundle there is no pid and no hostname, so the
1422
+ * call does nothing.
1423
+ */
1424
+ setPid(pid, hostname) {
1425
+ const proc = globalThis.process;
1426
+ const id = pid ?? proc?.pid;
1427
+ const host = hostname ?? Window._hostname();
1428
+ if (id === undefined || !host) return this;
1429
+ const X = this.X;
1430
+ safeRelease(X, () => {
1431
+ X.ChangeProperty(
1432
+ 0,
1433
+ this.id,
1434
+ X.atoms.WM_CLIENT_MACHINE,
1435
+ X.atoms.STRING,
1436
+ 8,
1437
+ Buffer.from(String(host), 'latin1')
1438
+ );
1439
+ });
1440
+ return this._withAtoms(['_NET_WM_PID'], (atoms) => {
1441
+ safeRelease(X, () => {
1442
+ X.ChangeProperty(0, this.id, atoms._NET_WM_PID, X.atoms.CARDINAL, 32, [id]);
1443
+ });
1444
+ });
1445
+ }
1446
+
1447
+ /** os.hostname(), looked up once, and absent in a browser bundle. */
1448
+ static _hostname() {
1449
+ if (Window._cachedHostname === undefined) {
1450
+ const os = globalThis.process?.getBuiltinModule?.('node:os');
1451
+ Window._cachedHostname = os?.hostname?.() ?? null;
1452
+ }
1453
+ return Window._cachedHostname;
1454
+ }
1455
+
1456
+ /**
1457
+ * Set window manager hints by name, across whichever properties they
1458
+ * belong to, keeping the ones set before.
1459
+ *
1460
+ * wnd.setHints({ transientFor: main, maxWidth: 900, urgent: true });
1461
+ *
1462
+ * Every key `setSizeHints` and `setWmHints` understand works here, plus
1463
+ * `transientFor` and `protocols`. The same keys are accepted by
1464
+ * `createWindow`, at the top level or under `hints`.
1465
+ *
1466
+ * The difference from calling the two setters directly is that this one
1467
+ * remembers: each property is rewritten from everything set on this window
1468
+ * so far, so `setHints({ urgent: true })` after `setHints({ input: true })`
1469
+ * keeps the input hint. Those setters write their struct whole.
1470
+ *
1471
+ * Everything but `protocols` is on the wire when this returns — a
1472
+ * `map()` on the next line cannot overtake it. `protocols` needs the atom
1473
+ * interned first; await `setProtocols()` if the ordering matters.
1474
+ */
1475
+ setHints(hints = {}) {
1476
+ const size = {};
1477
+ const wm = {};
1478
+ let sizeTouched = false;
1479
+ let wmTouched = false;
1480
+ const unknown = [];
1481
+
1482
+ for (const key of Object.keys(hints)) {
1483
+ if (SIZE_HINT_KEYS.has(key)) {
1484
+ size[key] = hints[key];
1485
+ sizeTouched = true;
1486
+ } else if (WM_HINT_KEYS.has(key)) {
1487
+ wm[key] = hints[key];
1488
+ wmTouched = true;
1489
+ } else if (key !== 'transientFor' && key !== 'protocols') {
1490
+ unknown.push(key);
1491
+ }
1492
+ }
1493
+ if (unknown.length) {
1494
+ warnHint(
1495
+ `hints-unknown-${unknown.join(',')}`,
1496
+ `setHints ignored unknown hint${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}.`
1497
+ );
1498
+ }
1499
+
1500
+ if (hints.transientFor !== undefined) this.setTransientFor(hints.transientFor);
1501
+ if (sizeTouched) {
1502
+ this._sizeHints = { ...this._sizeHints, ...size };
1503
+ this.setSizeHints(this._sizeHints);
1504
+ }
1505
+ if (wmTouched) {
1506
+ this._wmHints = { ...this._wmHints, ...wm };
1507
+ this.setWmHints(this._wmHints);
1508
+ }
1509
+ if (hints.protocols !== undefined) {
1510
+ this.setProtocols(hints.protocols).catch((err) => this.app.options.onXError?.(err));
1511
+ }
1512
+ return this;
1513
+ }
1514
+
973
1515
  /**
974
1516
  * ICCCM WM_CLASS — the instance/class pair taskbars and WMs use to group
975
1517
  * windows, match icons and apply per-application rules. Two NUL-
@@ -1013,6 +1555,171 @@ export default class Window extends Drawable {
1013
1555
  });
1014
1556
  }
1015
1557
 
1558
+ /**
1559
+ * EWMH `_NET_WM_STATE` — fullscreen, maximized, sticky, skip-taskbar and
1560
+ * the rest of the states a window can be in.
1561
+ *
1562
+ * await wnd.setWmState('fullscreen'); // add, the default
1563
+ * await wnd.setWmState('maximized', 'add'); // both axes at once
1564
+ * await wnd.setWmState(['skip_taskbar', 'skip_pager'], 'add');
1565
+ * await wnd.setWmState('fullscreen', 'toggle');
1566
+ *
1567
+ * Names are the EWMH atoms without their `_NET_WM_STATE_` prefix, in
1568
+ * lower case; full atom names work too. `'maximized'` expands to the
1569
+ * `MAXIMIZED_VERT` + `MAXIMIZED_HORZ` pair, which is why the spec allows
1570
+ * two states per message.
1571
+ *
1572
+ * How the change is made depends on whether the window is mapped, and the
1573
+ * two are not interchangeable (EWMH 7.7): a mapped window *asks* the
1574
+ * window manager with a ClientMessage to the root, an unmapped one
1575
+ * *declares* its initial state by writing the property. This asks the
1576
+ * server which it is rather than trusting the last map/unmap event, so it
1577
+ * is right even on the line after `map()`.
1578
+ *
1579
+ * @param {string|string[]} names
1580
+ * @param {'add'|'remove'|'toggle'} [action]
1581
+ * @returns {Promise<boolean>} whether the window manager advertises every
1582
+ * state asked for in `_NET_SUPPORTED`. The request is made either way —
1583
+ * an unmapped window may legitimately declare a state before any window
1584
+ * manager is running — but `false` means nothing is listening for it.
1585
+ */
1586
+ async setWmState(names, action = 'add') {
1587
+ const list = expandStateNames(names);
1588
+ if (!list.length) return false;
1589
+ if (list.length > 2) {
1590
+ // EWMH gives the message two atom slots and no more; splitting into
1591
+ // several messages is fine, but silently sending the first two is not
1592
+ throw new RangeError(
1593
+ `setWmState: a _NET_WM_STATE message carries at most 2 states, got ${list.length}`
1594
+ );
1595
+ }
1596
+ const mode = { remove: 0, add: 1, toggle: 2 }[action];
1597
+ if (mode === undefined) {
1598
+ throw new TypeError(`setWmState: action must be add, remove or toggle, got ${action}`);
1599
+ }
1600
+
1601
+ const [atoms, supported, attrs] = await Promise.all([
1602
+ this._stateAtoms(list),
1603
+ this._netSupported(),
1604
+ this.getAttributes().catch(() => null)
1605
+ ]);
1606
+ if (this._destroyed) return false;
1607
+ const ids = list.map((n) => atoms.get(n));
1608
+ // mapState: 0 Unmapped, 1 Unviewable, 2 Viewable. Unviewable is mapped
1609
+ // under an unmapped ancestor, which is still mapped as far as EWMH cares
1610
+ const mapped = attrs ? attrs.mapState !== 0 : this._mapped;
1611
+
1612
+ if (mapped) {
1613
+ const root = this.app.display.screen[0].root;
1614
+ const messageType = await this.atom('_NET_WM_STATE');
1615
+ if (this._destroyed) return false;
1616
+ safeRelease(this.X, () => {
1617
+ this.X.SendClientMessage(root, this.id, messageType, 32, [
1618
+ mode,
1619
+ ids[0],
1620
+ ids[1] ?? 0,
1621
+ 1 // source indication: a normal application, not a pager
1622
+ ]);
1623
+ });
1624
+ } else {
1625
+ await this._writeWmState(ids, mode);
1626
+ }
1627
+ return ids.every((id) => supported.has(id));
1628
+ }
1629
+
1630
+ /** `setWmState(names, 'add')`. @returns {Promise<boolean>} */
1631
+ addWmState(names) {
1632
+ return this.setWmState(names, 'add');
1633
+ }
1634
+
1635
+ /** `setWmState(names, 'remove')`. @returns {Promise<boolean>} */
1636
+ removeWmState(names) {
1637
+ return this.setWmState(names, 'remove');
1638
+ }
1639
+
1640
+ /**
1641
+ * The states currently in `_NET_WM_STATE`, as short lower-case names.
1642
+ * `[]` when the window has none.
1643
+ *
1644
+ * This is what the window manager put there, so it is the answer to "am I
1645
+ * actually fullscreen", where `setWmState` is only the request.
1646
+ *
1647
+ * @returns {Promise<string[]>}
1648
+ */
1649
+ async getWmStates() {
1650
+ const ids = await this.getProperty('_NET_WM_STATE', { as: 'numbers' }).catch(() => null);
1651
+ if (!ids || !ids.length) return [];
1652
+ const known = await this._stateAtoms(EWMH_STATES);
1653
+ const byAtom = new Map([...known].map(([name, atom]) => [atom, name]));
1654
+ return Promise.all(
1655
+ ids.map(async (id) => {
1656
+ const name = byAtom.get(id);
1657
+ if (name) return name;
1658
+ // a state this build does not know about, or a vendor one
1659
+ const full = await new Promise((resolve) =>
1660
+ this.X.GetAtomName(id, (err, n) => resolve(err ? null : n))
1661
+ );
1662
+ return full ? stateShortName(full) : null;
1663
+ })
1664
+ ).then((names) => names.filter(Boolean));
1665
+ }
1666
+
1667
+ /**
1668
+ * Read-modify-write on the atom list an unmapped window declares.
1669
+ *
1670
+ * `_NET_WM_STATE` is a list, so a plain Replace with one atom drops the
1671
+ * rest — the same trap WM_PROTOCOLS had. Serialized for the same reason:
1672
+ * two changes in a tick would each read the list before the other wrote.
1673
+ */
1674
+ _writeWmState(ids, mode) {
1675
+ const run = async () => {
1676
+ const current = new Set(
1677
+ (await this.getProperty('_NET_WM_STATE', { as: 'numbers' }).catch(() => null)) || []
1678
+ );
1679
+ for (const id of ids) {
1680
+ const add = mode === 2 ? !current.has(id) : mode === 1;
1681
+ if (add) current.add(id);
1682
+ else current.delete(id);
1683
+ }
1684
+ const property = await this.atom('_NET_WM_STATE');
1685
+ if (this._destroyed) return this;
1686
+ safeRelease(this.X, () => {
1687
+ this.X.ChangeProperty(0, this.id, property, this.X.atoms.ATOM, 32, [...current]);
1688
+ });
1689
+ return this;
1690
+ };
1691
+ this._wmStateQueue = this._wmStateQueue.then(run, run);
1692
+ return this._wmStateQueue;
1693
+ }
1694
+
1695
+ /** Intern the atoms for a list of short state names. @returns {Promise<Map>} */
1696
+ async _stateAtoms(names) {
1697
+ const ids = await Promise.all(names.map((n) => this.atom(stateAtomName(n))));
1698
+ return new Map(names.map((n, i) => [n, ids[i]]));
1699
+ }
1700
+
1701
+ /**
1702
+ * The atoms in the root window's `_NET_SUPPORTED`, read once per
1703
+ * connection.
1704
+ *
1705
+ * It is one property on one window describing one window manager, so
1706
+ * re-reading it per window per call is pure round trips. A window manager
1707
+ * that restarts and advertises differently is the cost, and the same one
1708
+ * every toolkit accepts.
1709
+ *
1710
+ * @returns {Promise<Set<number>>}
1711
+ */
1712
+ _netSupported() {
1713
+ if (!this.app._netSupportedPromise) {
1714
+ this.app._netSupportedPromise = this.app
1715
+ .rootWindow()
1716
+ .getProperty('_NET_SUPPORTED', { as: 'numbers' })
1717
+ .then((ids) => new Set(ids || []))
1718
+ .catch(() => new Set());
1719
+ }
1720
+ return this.app._netSupportedPromise;
1721
+ }
1722
+
1016
1723
  /**
1017
1724
  * Keep this window above normal windows.
1018
1725
  *
@@ -1024,50 +1731,12 @@ export default class Window extends Drawable {
1024
1731
  * parent is root — not our own id.
1025
1732
  */
1026
1733
  setAlwaysOnTop(on = true) {
1027
- const X = this.X;
1028
- const _NET_WM_STATE_ADD = 1;
1029
- const _NET_WM_STATE_REMOVE = 0;
1030
-
1031
- this._withAtoms(['_NET_WM_STATE', '_NET_WM_STATE_ABOVE', '_NET_SUPPORTED'], (atoms) => {
1032
- const root = this.app.display.screen[0].root;
1033
- safeRelease(X, () => {
1034
- X.GetProperty(0, root, atoms._NET_SUPPORTED, X.atoms.ATOM, 0, 1024, (err, prop) => {
1035
- if (err || this._destroyed) return;
1036
- let supported = false;
1037
- const data = prop?.data;
1038
- for (let i = 0; data && i + 4 <= data.length; i += 4) {
1039
- if (data.readUInt32LE(i) === atoms._NET_WM_STATE_ABOVE) {
1040
- supported = true;
1041
- break;
1042
- }
1043
- }
1044
- if (supported) {
1045
- // a mapped window changes state by asking the WM, not by
1046
- // writing the property (EWMH 7.7). SendEvent takes raw event
1047
- // bytes, so pack the 32-byte ClientMessage by hand.
1048
- const ev = Buffer.alloc(32);
1049
- ev.writeUInt8(33, 0); // ClientMessage
1050
- ev.writeUInt8(32, 1); // format
1051
- ev.writeUInt32LE(this.id >>> 0, 4);
1052
- ev.writeUInt32LE(atoms._NET_WM_STATE >>> 0, 8);
1053
- ev.writeUInt32LE(on ? _NET_WM_STATE_ADD : _NET_WM_STATE_REMOVE, 12);
1054
- ev.writeUInt32LE(atoms._NET_WM_STATE_ABOVE >>> 0, 16);
1055
- ev.writeUInt32LE(0, 20); // second property: none
1056
- ev.writeUInt32LE(1, 24); // source indication: application
1057
- safeRelease(X, () => {
1058
- X.SendEvent(
1059
- root,
1060
- false,
1061
- x11.eventMask.SubstructureRedirect | x11.eventMask.SubstructureNotify,
1062
- ev
1063
- );
1064
- });
1065
- } else {
1066
- this._appleWMSetLevel(on);
1067
- }
1068
- });
1069
- });
1070
- });
1734
+ this.setWmState('above', on ? 'add' : 'remove').then(
1735
+ (supported) => {
1736
+ if (!supported && !this._destroyed) this._appleWMSetLevel(on);
1737
+ },
1738
+ (err) => this.app.options.onXError?.(err)
1739
+ );
1071
1740
  return this;
1072
1741
  }
1073
1742
 
@@ -1286,6 +1955,27 @@ export default class Window extends Drawable {
1286
1955
  return this;
1287
1956
  }
1288
1957
 
1958
+ /**
1959
+ * Remove a property.
1960
+ *
1961
+ * Not the same as writing an empty value, and not at all the same as
1962
+ * writing a zero: a deleted property reads back as type None — which is
1963
+ * how "this client never declared that" is spelled — and a window manager
1964
+ * watching gets a PropertyNotify with state Delete. `setTransientFor(null)`
1965
+ * goes through here for exactly that reason; a `WM_TRANSIENT_FOR` of 0
1966
+ * would name window None as the owner rather than saying there is none.
1967
+ *
1968
+ * @returns {Promise<Window>}
1969
+ */
1970
+ async deleteProperty(name) {
1971
+ const atom = await this.atom(name);
1972
+ if (this._destroyed) return this;
1973
+ safeRelease(this.X, () => {
1974
+ this.X.DeleteProperty(this.id, atom);
1975
+ });
1976
+ return this;
1977
+ }
1978
+
1289
1979
  /**
1290
1980
  * Intern an atom by name. node-x11 caches them per connection, so asking
1291
1981
  * again for one already interned costs no round trip.
@@ -1310,16 +2000,27 @@ export default class Window extends Drawable {
1310
2000
 
1311
2001
  /**
1312
2002
  * WM_NORMAL_HINTS as an object shaped like setSizeHints' argument —
1313
- * `{ minWidth, minHeight, maxWidth, maxHeight, widthInc, heightInc,
1314
- * baseWidth, baseHeight, minAspect, maxAspect, gravity }`, each present
1315
- * only if the client set its flag. Resolves to `{}` when the window has
1316
- * no hints, so callers can destructure without a null check.
2003
+ * `{ position, size, x, y, width, height, minWidth, minHeight, maxWidth,
2004
+ * maxHeight, widthInc, heightInc, baseWidth, baseHeight, minAspect,
2005
+ * maxAspect, gravity }`, each present only if the client set its flag.
2006
+ * Resolves to `{}` when the window has no hints, so callers can
2007
+ * destructure without a null check.
1317
2008
  */
1318
2009
  async getSizeHints() {
1319
2010
  const v = await this.getProperty('WM_NORMAL_HINTS', { as: 'numbers' }).catch(() => null);
1320
2011
  if (!v || v.length < 18) return {};
1321
2012
  const flags = v[0];
1322
2013
  const hints = {};
2014
+ if (flags & (SIZE_HINT.USPosition | SIZE_HINT.PPosition)) {
2015
+ hints.position = flags & SIZE_HINT.USPosition ? 'user' : 'program';
2016
+ hints.x = v[1];
2017
+ hints.y = v[2];
2018
+ }
2019
+ if (flags & (SIZE_HINT.USSize | SIZE_HINT.PSize)) {
2020
+ hints.size = flags & SIZE_HINT.USSize ? 'user' : 'program';
2021
+ hints.width = v[3];
2022
+ hints.height = v[4];
2023
+ }
1323
2024
  if (flags & 16) {
1324
2025
  hints.minWidth = v[5];
1325
2026
  hints.minHeight = v[6];
@@ -1344,6 +2045,30 @@ export default class Window extends Drawable {
1344
2045
  return hints;
1345
2046
  }
1346
2047
 
2048
+ /**
2049
+ * WM_HINTS as an object shaped like setWmHints' argument, each field
2050
+ * present only if the client set its flag. `{}` when the window has none.
2051
+ * `urgent` is reported whenever the property exists, since its absence is
2052
+ * meaningful — it is what "the user has looked" looks like.
2053
+ */
2054
+ async getWmHints() {
2055
+ const v = await this.getProperty('WM_HINTS', { as: 'numbers' }).catch(() => null);
2056
+ if (!v || v.length < 9) return {};
2057
+ const flags = v[0];
2058
+ const hints = { urgent: !!(flags & WM_HINT.Urgency) };
2059
+ if (flags & WM_HINT.Input) hints.input = !!v[1];
2060
+ if (flags & WM_HINT.State) hints.initialState = v[2] === 3 ? 'iconic' : 'normal';
2061
+ if (flags & WM_HINT.IconPixmap) hints.iconPixmap = v[3];
2062
+ if (flags & WM_HINT.IconWindow) hints.iconWindow = v[4];
2063
+ if (flags & WM_HINT.IconPosition) {
2064
+ hints.iconX = v[5];
2065
+ hints.iconY = v[6];
2066
+ }
2067
+ if (flags & WM_HINT.IconMask) hints.iconMask = v[7];
2068
+ if (flags & WM_HINT.WindowGroup) hints.windowGroup = v[8];
2069
+ return hints;
2070
+ }
2071
+
1347
2072
  /**
1348
2073
  * GetWindowAttributes — `{ mapState, overrideRedirect, ... }`. A window
1349
2074
  * manager adopting the windows that already existed when it started
@@ -1419,19 +2144,19 @@ export default class Window extends Drawable {
1419
2144
  height = this.height,
1420
2145
  borderWidth = 0
1421
2146
  } = geometry;
1422
- const ev = Buffer.alloc(32);
1423
- ev[0] = 22; // ConfigureNotify
1424
- ev.writeUInt32LE(this.id >>> 0, 4); // event window
1425
- ev.writeUInt32LE(this.id >>> 0, 8); // the window itself
1426
- ev.writeUInt32LE(0, 12); // above-sibling: None
1427
- ev.writeInt16LE(x, 16);
1428
- ev.writeInt16LE(y, 18);
1429
- ev.writeUInt16LE(width, 20);
1430
- ev.writeUInt16LE(height, 22);
1431
- ev.writeUInt16LE(borderWidth, 24);
1432
- ev[26] = 0; // override-redirect
1433
2147
  safeRelease(this.X, () =>
1434
- this.X.SendEvent(this.id, 0, x11.eventMask.StructureNotify, ev)
2148
+ this.X.SendEvent(this.id, 0, x11.eventMask.StructureNotify, {
2149
+ name: 'ConfigureNotify',
2150
+ wid: this.id, // event window
2151
+ wid1: this.id, // the window the event is about
2152
+ aboveSibling: 0, // None: bottom of the stack
2153
+ x,
2154
+ y,
2155
+ width,
2156
+ height,
2157
+ borderWidth,
2158
+ overrideRedirect: 0
2159
+ })
1435
2160
  );
1436
2161
  return this;
1437
2162
  }
@@ -1456,14 +2181,12 @@ export default class Window extends Drawable {
1456
2181
  return false;
1457
2182
  }
1458
2183
  const wmProtocols = await this.atom('WM_PROTOCOLS');
1459
- const ev = Buffer.alloc(32);
1460
- ev[0] = 33; // ClientMessage
1461
- ev[1] = 32; // format
1462
- ev.writeUInt32LE(this.id >>> 0, 4);
1463
- ev.writeUInt32LE(wmProtocols >>> 0, 8);
1464
- ev.writeUInt32LE(deleteAtom >>> 0, 12);
1465
- ev.writeUInt32LE(0, 16); // CurrentTime
1466
- safeRelease(this.X, () => this.X.SendEvent(this.id, 0, 0, ev));
2184
+ // mask 0: deliver to the client that created the window, not to whoever
2185
+ // selected events on it — a WM_PROTOCOLS message is addressed to the
2186
+ // owner, so it must not go out with the EWMH substructure default
2187
+ safeRelease(this.X, () =>
2188
+ this.X.SendClientMessage(this.id, this.id, wmProtocols, 32, [deleteAtom, 0 /* CurrentTime */], 0)
2189
+ );
1467
2190
  return true;
1468
2191
  }
1469
2192
 
@@ -1535,30 +2258,19 @@ export default class Window extends Drawable {
1535
2258
  return new Window(this.app, { parent: this, ...params });
1536
2259
  }
1537
2260
 
1538
- // opt in to the WM_DELETE_WINDOW protocol: the window manager sends a
1539
- // 'message' event instead of killing the connection when the user closes
1540
- // the window
2261
+ /**
2262
+ * Opt in to the WM_DELETE_WINDOW protocol: the window manager sends a
2263
+ * 'message' event instead of killing the connection when the user closes
2264
+ * the window.
2265
+ *
2266
+ * Kept for compatibility, and now an alias — it used to write
2267
+ * WM_PROTOCOLS as a list of exactly one atom, so the next protocol added
2268
+ * by any other means erased it. Prefer `addProtocol('WM_DELETE_WINDOW')`,
2269
+ * which is awaitable.
2270
+ */
1541
2271
  setActions() {
1542
- const X = this.X;
1543
- const wid = this.id;
1544
- // same deferred-chain shape as setTitle: each step runs from a reply
1545
- // callback and must no-op instead of throwing if the connection
1546
- // started closing (or the window died) while the replies were in flight
1547
- safeRelease(X, () => {
1548
- X.InternAtom(false, 'WM_PROTOCOLS', (err, WM_PROTOCOLS) => {
1549
- if (err) return;
1550
- safeRelease(X, () => {
1551
- X.InternAtom(false, 'WM_DELETE_WINDOW', (err2, WM_DELETE_WINDOW) => {
1552
- if (err2 || this._destroyed) return;
1553
- const data = Buffer.alloc(4);
1554
- data.writeUInt32LE(WM_DELETE_WINDOW, 0);
1555
- safeRelease(X, () => {
1556
- X.ChangeProperty(0, wid, WM_PROTOCOLS, X.atoms.ATOM, 32, data);
1557
- });
1558
- });
1559
- });
1560
- });
1561
- });
2272
+ this.addProtocol('WM_DELETE_WINDOW').catch((err) => this.app.options.onXError?.(err));
2273
+ return this;
1562
2274
  }
1563
2275
 
1564
2276
  destroy() {