ciphermesh 2.13.0 → 2.14.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.
@@ -0,0 +1,388 @@
1
+ // Keyboard-protocol shim.
2
+ //
3
+ // Terminals cannot tell Shift+Enter from Enter unless the application asks them
4
+ // to: with no protocol negotiated both arrive as a bare `\r`. The two ways to
5
+ // ask are the kitty keyboard protocol (`CSI > 1 u`, reported as `CSI 13;2 u`)
6
+ // and xterm's modifyOtherKeys (`CSI > 4 ; 1 m`, reported as `CSI 27;2;13 ~`).
7
+ //
8
+ // Asking is only half of it. blessed's key parser splits input with
9
+ //
10
+ // (?:\x1b+)(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|…|(?:1;)?(\d+)?([a-zA-Z]))
11
+ //
12
+ // which matches neither a `u` final byte after two parameters nor a `~` after
13
+ // three. Both reports fall through to blessed's `/\x1b./` catch-all, so it
14
+ // consumes `\x1b[` and emits the rest — `13;2u` — as ordinary characters typed
15
+ // into the composer. The same regex is why `\x1b\r` (Alt+Enter) reaches blessed
16
+ // with `key.name === undefined`.
17
+ //
18
+ // So the reports are decoded here, upstream of blessed, on the raw byte stream:
19
+ // Enter with a modifier becomes a newline event, every other enhanced report is
20
+ // rewritten to the legacy encoding blessed already understands, and anything
21
+ // unrecognised is passed through untouched. Nothing new can reach the composer
22
+ // as literal text, which is the property that makes enabling a protocol safe.
23
+
24
+ import { PassThrough } from 'node:stream';
25
+ import { StringDecoder } from 'node:string_decoder';
26
+
27
+ const ESC = '\x1b';
28
+ const PASTE_START = '\x1b[200~';
29
+ const PASTE_END = '\x1b[201~';
30
+
31
+ // Ask for both protocols. Terminals that implement neither ignore both; ones
32
+ // that implement the kitty protocol let it take precedence over modifyOtherKeys.
33
+ // Level 1 of modifyOtherKeys is deliberate — level 2 also re-encodes Ctrl+C and
34
+ // friends, which is a much larger blast radius for a newline.
35
+ export const KEY_PROTOCOL_ENABLE = '\x1b[>4;1m\x1b[>1u';
36
+ export const KEY_PROTOCOL_DISABLE = '\x1b[<u\x1b[>4;0m';
37
+
38
+ // Modifier bits, as reported minus one.
39
+ const MOD_SHIFT = 1;
40
+ const MOD_ALT = 2;
41
+ const MOD_CTRL = 4;
42
+
43
+ // Kitty's private-use range for keys with no Unicode code point. Under the
44
+ // disambiguate flag most of these keep their legacy encoding, but the keypad
45
+ // reports through CSI u, so the ones that produce text are mapped back.
46
+ const KEYPAD = new Map([
47
+ [57399, '0'],
48
+ [57400, '1'],
49
+ [57401, '2'],
50
+ [57402, '3'],
51
+ [57403, '4'],
52
+ [57404, '5'],
53
+ [57405, '6'],
54
+ [57406, '7'],
55
+ [57407, '8'],
56
+ [57408, '9'],
57
+ [57409, '.'],
58
+ [57410, '/'],
59
+ [57411, '*'],
60
+ [57412, '-'],
61
+ [57413, '+'],
62
+ [57414, '\r'], // KP_ENTER
63
+ [57415, '='],
64
+ ]);
65
+
66
+ /**
67
+ * The control byte a Ctrl+<key> chord produces on a legacy terminal, or '' when
68
+ * the chord has no legacy encoding (Ctrl+1, say). Pure, exported for testing.
69
+ */
70
+ export function ctrlByte(code) {
71
+ if (code >= 97 && code <= 122) {
72
+ return String.fromCharCode(code - 96); // ctrl+a … ctrl+z
73
+ }
74
+ if (code >= 65 && code <= 90) {
75
+ return String.fromCharCode(code - 64); // ctrl+A … ctrl+Z
76
+ }
77
+ switch (code) {
78
+ case 32: // ctrl+space → NUL
79
+ case 64: // ctrl+@
80
+ return '\x00';
81
+ case 91: // ctrl+[
82
+ return '\x1b';
83
+ case 92: // ctrl+backslash
84
+ return '\x1c';
85
+ case 93: // ctrl+]
86
+ return '\x1d';
87
+ case 94: // ctrl+^
88
+ return '\x1e';
89
+ case 95: // ctrl+_
90
+ case 47: // ctrl+/
91
+ return '\x1f';
92
+ case 63: // ctrl+?
93
+ return '\x7f';
94
+ default:
95
+ return '';
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Rewrite one decoded key report as the bytes a legacy terminal would have
101
+ * sent, so blessed's parser sees input it already knows. Returns '' for reports
102
+ * with no legacy equivalent — dropping them is the point, since letting them
103
+ * through is what types `13;2u` into the composer. Pure, exported for testing.
104
+ *
105
+ * @param {number} code Unicode code point (or a kitty functional key number)
106
+ * @param {number} mods modifier bitmask, already decremented by one
107
+ */
108
+ export function toLegacy(code, mods) {
109
+ const shift = !!(mods & MOD_SHIFT);
110
+ const alt = !!(mods & MOD_ALT);
111
+ const ctrl = !!(mods & MOD_CTRL);
112
+ const meta = (s) => (alt ? ESC + s : s);
113
+
114
+ switch (code) {
115
+ case 13:
116
+ return meta('\r'); // enter — modified enter is intercepted upstream
117
+ case 27:
118
+ return ESC; // escape carries no modifiers in the legacy encoding
119
+ case 9:
120
+ return shift ? '\x1b[Z' : meta('\t');
121
+ case 8:
122
+ case 127:
123
+ return meta('\x7f'); // backspace
124
+ }
125
+
126
+ if (KEYPAD.has(code)) {
127
+ return meta(KEYPAD.get(code));
128
+ }
129
+ // Kitty numbers its functional keys inside the Unicode private-use area, so
130
+ // anything landing there is a key (Caps Lock, media keys, …) rather than a
131
+ // character. Emitting it would put an invisible glyph in the composer.
132
+ if (code >= 0xe000 && code <= 0xf8ff) {
133
+ return '';
134
+ }
135
+ if (code < 32 || code > 0x10ffff) {
136
+ return ''; // control codes we have no encoding for
137
+ }
138
+
139
+ if (ctrl) {
140
+ const byte = ctrlByte(code);
141
+ return byte ? meta(byte) : '';
142
+ }
143
+ // Kitty reports the *unshifted* code point plus a shift bit.
144
+ const base = shift && code >= 97 && code <= 122 ? code - 32 : code;
145
+ return meta(String.fromCodePoint(base));
146
+ }
147
+
148
+ // Match a CSI sequence starting at `from`. Returns the parameter string and
149
+ // final byte, `null` when the sequence is still incomplete (hold and wait for
150
+ // more input), or `false` when it can never become one.
151
+ function matchCsi(text, from) {
152
+ let i = from + 2; // past ESC [
153
+ while (i < text.length) {
154
+ const c = text.charCodeAt(i);
155
+ const isParam = (c >= 0x30 && c <= 0x3f) || (c >= 0x20 && c <= 0x2f); // 0-9;:<=>?!"#$%&'()*+,-./
156
+ if (isParam) {
157
+ i++;
158
+ continue;
159
+ }
160
+ if (c >= 0x40 && c <= 0x7e) {
161
+ return { params: text.slice(from + 2, i), final: text[i], end: i + 1 };
162
+ }
163
+ return false; // not a CSI sequence after all
164
+ }
165
+ return null; // ran out of input mid-sequence
166
+ }
167
+
168
+ // Parameters as numbers, taking the first sub-parameter of each (kitty uses
169
+ // `code:shifted:base` and `mods:event`, and only the first matters here).
170
+ function numericParams(params) {
171
+ return params.split(';').map((p) => {
172
+ const n = Number.parseInt(p.split(':')[0], 10);
173
+ return Number.isNaN(n) ? 0 : n;
174
+ });
175
+ }
176
+
177
+ /**
178
+ * Decode an enhanced key report into `{ code, mods }`, or null if the sequence
179
+ * is not one. Understands the kitty form `CSI <code>[;<mods>] u` and xterm's
180
+ * modifyOtherKeys form `CSI 27;<mods>;<code> ~`. Pure, exported for testing.
181
+ */
182
+ export function parseEnhancedKey(params, final) {
183
+ const nums = numericParams(params);
184
+ if (final === 'u' && !params.startsWith('>') && !params.startsWith('<')) {
185
+ const code = nums[0];
186
+ if (!code) {
187
+ return null;
188
+ }
189
+ return { code, mods: Math.max(0, (nums[1] || 1) - 1) };
190
+ }
191
+ if (final === '~' && nums.length === 3 && nums[0] === 27) {
192
+ return { code: nums[2], mods: Math.max(0, (nums[1] || 1) - 1) };
193
+ }
194
+ return null;
195
+ }
196
+
197
+ /** Enter plus any modifier means "new line in the composer", never "send". */
198
+ function isNewline(key) {
199
+ return key.code === 13 && (key.mods & (MOD_SHIFT | MOD_ALT | MOD_CTRL)) !== 0;
200
+ }
201
+
202
+ /**
203
+ * Scan a chunk of raw terminal input, splitting it into what blessed should see
204
+ * and the newline requests it never could. Pure and incremental — feed the
205
+ * returned `pending`/`pasting` back in with the next chunk.
206
+ *
207
+ * Bracketed-paste blocks are copied through verbatim: text arriving from the
208
+ * clipboard is data, and a `\x1b\r` inside it is two pasted characters, not a
209
+ * key chord.
210
+ *
211
+ * @returns {{ events: Array<{type: 'data'|'newline', text?: string}>,
212
+ * pending: string, pasting: boolean }}
213
+ */
214
+ export function filterKeys(input, state = {}) {
215
+ const text = (state.pending || '') + input;
216
+ let pasting = !!state.pasting;
217
+ const events = [];
218
+ let out = '';
219
+ let i = 0;
220
+
221
+ const flush = () => {
222
+ if (out) {
223
+ events.push({ type: 'data', text: out });
224
+ out = '';
225
+ }
226
+ };
227
+
228
+ while (i < text.length) {
229
+ if (pasting) {
230
+ const end = text.indexOf(PASTE_END, i);
231
+ if (end === -1) {
232
+ out += text.slice(i);
233
+ i = text.length;
234
+ break;
235
+ }
236
+ out += text.slice(i, end + PASTE_END.length);
237
+ i = end + PASTE_END.length;
238
+ pasting = false;
239
+ continue;
240
+ }
241
+
242
+ if (text[i] !== ESC) {
243
+ let j = i;
244
+ while (j < text.length && text[j] !== ESC) {
245
+ j++;
246
+ }
247
+ out += text.slice(i, j);
248
+ i = j;
249
+ continue;
250
+ }
251
+
252
+ if (text.startsWith(PASTE_START, i)) {
253
+ out += PASTE_START;
254
+ i += PASTE_START.length;
255
+ pasting = true;
256
+ continue;
257
+ }
258
+
259
+ // Alt+Enter as sent by terminals with no protocol negotiated. blessed
260
+ // cannot name this one either, so it is intercepted here for every
261
+ // terminal, protocol or not.
262
+ if (text.startsWith('\x1b\r', i) || text.startsWith('\x1b\n', i)) {
263
+ flush();
264
+ events.push({ type: 'newline' });
265
+ i += 2;
266
+ continue;
267
+ }
268
+
269
+ if (text.startsWith('\x1b[', i)) {
270
+ const csi = matchCsi(text, i);
271
+ if (csi === null) {
272
+ break; // incomplete — hold it for the next chunk
273
+ }
274
+ if (csi === false) {
275
+ out += text[i];
276
+ i++;
277
+ continue;
278
+ }
279
+ const key = parseEnhancedKey(csi.params, csi.final);
280
+ if (!key) {
281
+ out += text.slice(i, csi.end); // arrows, function keys, mouse — untouched
282
+ i = csi.end;
283
+ continue;
284
+ }
285
+ if (isNewline(key)) {
286
+ flush();
287
+ events.push({ type: 'newline' });
288
+ } else {
289
+ out += toLegacy(key.code, key.mods);
290
+ }
291
+ i = csi.end;
292
+ continue;
293
+ }
294
+
295
+ out += text[i];
296
+ i++;
297
+ }
298
+
299
+ flush();
300
+ return { events, pending: text.slice(i), pasting };
301
+ }
302
+
303
+ /**
304
+ * A stdin stand-in for blessed that has already had the enhanced key reports
305
+ * taken out of it. Pass it as `blessed.screen({ input })`.
306
+ *
307
+ * blessed only needs `data`/`keypress` events plus `setRawMode`, `isRaw` and
308
+ * `pause`/`resume` from its input, so the raw-mode calls are forwarded to the
309
+ * real tty and everything else is an ordinary PassThrough.
310
+ */
311
+ export class EnhancedInput extends PassThrough {
312
+ #source;
313
+ #onNewline;
314
+ #onData;
315
+ #decoder = new StringDecoder('utf8');
316
+ #state = { pending: '', pasting: false };
317
+ #flushTimer = null;
318
+
319
+ constructor(source, onNewline) {
320
+ super();
321
+ this.#source = source;
322
+ this.#onNewline = onNewline;
323
+ this.#onData = (chunk) => this.#feed(chunk);
324
+ this.#source.on('data', this.#onData);
325
+ }
326
+
327
+ get isTTY() {
328
+ return !!this.#source.isTTY;
329
+ }
330
+
331
+ get isRaw() {
332
+ return !!this.#source.isRaw;
333
+ }
334
+
335
+ setRawMode(mode) {
336
+ this.#source.setRawMode?.(mode);
337
+ return this;
338
+ }
339
+
340
+ /** Stop consuming the tty (used when the screen is torn down). */
341
+ detach() {
342
+ this.#source.removeListener('data', this.#onData);
343
+ if (this.#flushTimer) {
344
+ clearTimeout(this.#flushTimer);
345
+ this.#flushTimer = null;
346
+ }
347
+ }
348
+
349
+ #feed(chunk) {
350
+ // Decode through a StringDecoder so a multi-byte character split across two
351
+ // reads is not turned into replacement characters on the way back out.
352
+ const text = Buffer.isBuffer(chunk) ? this.#decoder.write(chunk) : String(chunk);
353
+ if (!text) {
354
+ return;
355
+ }
356
+ const { events, pending, pasting } = filterKeys(text, this.#state);
357
+ this.#state = { pending, pasting };
358
+ for (const event of events) {
359
+ if (event.type === 'newline') {
360
+ this.#onNewline?.();
361
+ } else {
362
+ this.push(Buffer.from(event.text, 'utf8'));
363
+ }
364
+ }
365
+ this.#armFlush();
366
+ }
367
+
368
+ // A held partial sequence must never swallow input for good: if nothing
369
+ // completes it, release it as ordinary bytes.
370
+ #armFlush() {
371
+ if (this.#flushTimer) {
372
+ clearTimeout(this.#flushTimer);
373
+ this.#flushTimer = null;
374
+ }
375
+ if (!this.#state.pending) {
376
+ return;
377
+ }
378
+ this.#flushTimer = setTimeout(() => {
379
+ this.#flushTimer = null;
380
+ const held = this.#state.pending;
381
+ this.#state = { pending: '', pasting: this.#state.pasting };
382
+ if (held) {
383
+ this.push(Buffer.from(held, 'utf8'));
384
+ }
385
+ }, 50);
386
+ this.#flushTimer.unref?.();
387
+ }
388
+ }
@@ -1,11 +1,11 @@
1
1
  import sodium from 'sodium-native';
2
- import notifier from 'node-notifier';
3
2
  import qrcode from 'qrcode-terminal';
4
3
  import { writeFileSync, mkdirSync } from 'node:fs';
5
4
  import { resolve, dirname } from 'node:path';
6
5
  import { tmpdir } from 'node:os';
7
6
  import { exportBackup } from '../crypto/IdentityBackup.js';
8
7
  import { keyArt } from '../shared/keyArt.js';
8
+ import { DesktopNotifier } from '../shared/desktopNotify.js';
9
9
  import {
10
10
  KEY_ROTATION_INTERVAL_MS,
11
11
  EMOJI_MAP,
@@ -112,6 +112,7 @@ export class P2PChatController {
112
112
  #roomTopics = new Map(); // room → { text, by, at } (E2EE among peers)
113
113
  #historyStore; // encrypted local history (opt-in, needs a passphrase)
114
114
  #receiptsEnabled = true; // /receipts — send read confirmations
115
+ #desktopNotifier; // OS notifications, isolated + rate-limited
115
116
  #sentMessageLines = new Map(); // messageId → { lineIndex, baseLine, room }
116
117
  #messageReaders = new Map(); // messageId → Set<nickname>
117
118
  #pendingReceipts = new Map(); // messageId → Set<nickname> acked before we tracked it
@@ -164,6 +165,9 @@ export class P2PChatController {
164
165
  this.#trustStore.importData(restoredState.trust);
165
166
  }
166
167
  this.#auditLog = new AuditLog();
168
+ this.#desktopNotifier = new DesktopNotifier({
169
+ onUnavailable: (reason) => this.#onNotifierUnavailable(reason),
170
+ });
167
171
  this.#historyStore = historyStore;
168
172
  this.#ephemeralMode = false;
169
173
  this.#ephemeralDurationMs = 0;
@@ -753,7 +757,7 @@ export class P2PChatController {
753
757
  }
754
758
 
755
759
  if (notify && (this.#ui.notifyEnabled || mentioned)) {
756
- notifier.notify({
760
+ this.#desktopNotifier.notify({
757
761
  title: mentioned
758
762
  ? `🔔 ${fromNickname} mentioned you`
759
763
  : data.isDM
@@ -765,6 +769,17 @@ export class P2PChatController {
765
769
  }
766
770
  }
767
771
 
772
+ // The OS refused a desktop notification (Windows with notifications turned
773
+ // off for the app is the common case). Say it once, then stay quiet — the
774
+ // notifier has already stopped trying, so the chat never sees it again.
775
+ #onNotifierUnavailable(reason) {
776
+ this.#ui.setNotifyEnabled(false);
777
+ this.#ui.addInfoMessage(
778
+ `Desktop notifications unavailable — ${reason}. Muted for this session; ` +
779
+ 'sound alerts still work. Use /notify on to retry.',
780
+ );
781
+ }
782
+
768
783
  // ── TOFU: Trust On First Use ───────────────────────────────────
769
784
  #checkTrust(nickname, publicKey) {
770
785
  const result = this.#trustStore.checkPeer(nickname, publicKey);
@@ -1269,7 +1284,6 @@ export class P2PChatController {
1269
1284
  this.#ui.addQuoteLine(
1270
1285
  this.#lastReceivedNickname,
1271
1286
  (this.#lastReceivedText || '').slice(0, 80),
1272
- true,
1273
1287
  );
1274
1288
  this.#sendMessageToAll(replyText);
1275
1289
  break;
@@ -1551,14 +1565,17 @@ export class P2PChatController {
1551
1565
  const notifyArg = parts[1]?.toLowerCase();
1552
1566
  if (notifyArg === 'off') {
1553
1567
  this.#ui.setNotifyEnabled(false);
1568
+ this.#desktopNotifier.disable();
1554
1569
  this.#ui.addInfoMessage('Desktop notifications disabled');
1555
1570
  } else if (notifyArg === 'on') {
1556
1571
  this.#ui.setNotifyEnabled(true);
1572
+ this.#desktopNotifier.reset(); // give a previously refusing OS another go
1557
1573
  this.#ui.addInfoMessage('Desktop notifications enabled');
1558
1574
  } else {
1559
1575
  const status = this.#ui.notifyEnabled ? 'enabled' : 'disabled';
1576
+ const blocked = this.#desktopNotifier.available ? '' : ' (blocked by the OS)';
1560
1577
  this.#ui.addInfoMessage(
1561
- `Desktop notifications: ${status}. Use /notify on or /notify off`,
1578
+ `Desktop notifications: ${status}${blocked}. Use /notify on or /notify off`,
1562
1579
  );
1563
1580
  }
1564
1581
  break;
@@ -0,0 +1,204 @@
1
+ // Desktop notifications, kept away from the chat UI.
2
+ //
3
+ // node-notifier drives SnoreToast on Windows. When notifications are disabled
4
+ // for the application, SnoreToast ignores the stdio pipes node-notifier gives
5
+ // it and writes its diagnostics to the *attached console* instead — the very
6
+ // console blessed is drawing the chat on. The result is unreadable: raw
7
+ // "Notifications are disabled / Reason: DisabledForApplication / Command Line:
8
+ // …snoretoast-x64.exe…" text smeared across the message log, once per message.
9
+ //
10
+ // Three guards, in order of importance:
11
+ // 1. On Windows every notification is delivered by a detached, console-less
12
+ // helper process, so nothing the notifier prints can reach our terminal.
13
+ // 2. The first failure trips a breaker: desktop notifications go quiet for
14
+ // the rest of the session and the caller is told once, in-app.
15
+ // 3. Notifications are throttled, so a burst of messages can't flood the
16
+ // desktop (or, on the broken path, the terminal).
17
+
18
+ import { spawn } from 'node:child_process';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ // Exit code the helper uses for "the OS refused to show the notification".
22
+ export const NOTIFY_FAILED_EXIT = 3;
23
+
24
+ // Minimum gap between two desktop notifications. Sound alerts and the unread
25
+ // pill are unthrottled — this only rate-limits the OS-level popup.
26
+ export const NOTIFY_MIN_INTERVAL_MS = 3000;
27
+
28
+ const WORKER_PATH = fileURLToPath(new URL('./notifyWorker.js', import.meta.url));
29
+
30
+ /**
31
+ * True when the platform needs the out-of-process delivery path. Only Windows
32
+ * has a notifier that writes to the console behind our back; macOS and Linux
33
+ * back-ends stay in-process (spawning a Node runtime per message would be far
34
+ * more expensive than the notification itself).
35
+ */
36
+ export function needsIsolation(platform = process.platform) {
37
+ return platform === 'win32';
38
+ }
39
+
40
+ /**
41
+ * Desktop notifier with a breaker and a throttle.
42
+ *
43
+ * @param {object} [opts]
44
+ * @param {Function} [opts.onUnavailable] called once, with a human-readable
45
+ * reason, the first time the OS refuses a notification.
46
+ * @param {number} [opts.minIntervalMs] throttle window.
47
+ * @param {Function} [opts.now] clock, for tests.
48
+ */
49
+ export class DesktopNotifier {
50
+ #available = true;
51
+ #reported = false;
52
+ #lastSentAt = 0;
53
+ #onUnavailable;
54
+ #minIntervalMs;
55
+ #now;
56
+ #isolate;
57
+ #send;
58
+
59
+ constructor({
60
+ onUnavailable = null,
61
+ minIntervalMs = NOTIFY_MIN_INTERVAL_MS,
62
+ now = Date.now,
63
+ isolate = needsIsolation(),
64
+ send = null,
65
+ } = {}) {
66
+ this.#onUnavailable = onUnavailable;
67
+ this.#minIntervalMs = minIntervalMs;
68
+ this.#now = now;
69
+ this.#isolate = isolate;
70
+ this.#send = send; // injected transport, for tests
71
+ }
72
+
73
+ /** False once the OS has told us notifications are not going to work. */
74
+ get available() {
75
+ return this.#available;
76
+ }
77
+
78
+ /**
79
+ * Fire a desktop notification. Never throws, never blocks, and never lets the
80
+ * platform notifier write to our terminal.
81
+ *
82
+ * @returns {boolean} true if the notification was handed to the OS.
83
+ */
84
+ notify({ title, message, sound = false }) {
85
+ if (!this.#available) {
86
+ return false;
87
+ }
88
+ const at = this.#now();
89
+ if (at - this.#lastSentAt < this.#minIntervalMs) {
90
+ return false; // throttled — the sound alert already fired
91
+ }
92
+ this.#lastSentAt = at;
93
+
94
+ const options = { title: String(title ?? ''), message: String(message ?? ''), sound: !!sound };
95
+ try {
96
+ if (this.#send) {
97
+ this.#send(options, (err) => this.#onResult(err));
98
+ } else if (this.#isolate) {
99
+ this.#sendIsolated(options);
100
+ } else {
101
+ this.#sendInProcess(options);
102
+ }
103
+ } catch (err) {
104
+ this.#onResult(err);
105
+ return false;
106
+ }
107
+ return true;
108
+ }
109
+
110
+ /** Stop trying for the rest of the session (used by `/notify off`). */
111
+ disable() {
112
+ this.#available = false;
113
+ }
114
+
115
+ /**
116
+ * Re-arm the breaker. `/notify on` means the user believes they fixed the OS
117
+ * setting we tripped on, so give the platform another chance — including the
118
+ * one-off in-app warning if it fails again.
119
+ */
120
+ reset() {
121
+ this.#available = true;
122
+ this.#reported = false;
123
+ this.#lastSentAt = 0;
124
+ }
125
+
126
+ // Windows: a detached, hidden helper with no stdio and no console of its own.
127
+ // Anything SnoreToast decides to print goes nowhere near the chat.
128
+ #sendIsolated(options) {
129
+ const child = spawn(process.execPath, [WORKER_PATH, JSON.stringify(options)], {
130
+ detached: true,
131
+ windowsHide: true,
132
+ stdio: 'ignore',
133
+ });
134
+ child.on('error', (err) => this.#onResult(err));
135
+ child.on('exit', (code) => {
136
+ if (code === NOTIFY_FAILED_EXIT) {
137
+ this.#onResult(new Error('the operating system refused the notification'));
138
+ }
139
+ });
140
+ child.unref();
141
+ }
142
+
143
+ // macOS / Linux: in-process, but always with a callback so a failure is
144
+ // handled instead of surfacing as an unhandled 'error' event.
145
+ #sendInProcess(options) {
146
+ import('node-notifier')
147
+ .then(({ default: notifier }) => {
148
+ notifier.notify(options, (err) => this.#onResult(err));
149
+ })
150
+ .catch((err) => this.#onResult(err));
151
+ }
152
+
153
+ #onResult(err) {
154
+ if (!isFailure(err)) {
155
+ return;
156
+ }
157
+ this.#available = false;
158
+ if (this.#reported) {
159
+ return;
160
+ }
161
+ this.#reported = true;
162
+ this.#onUnavailable?.(describeFailure(err));
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Whether what the notifier handed back is a real failure.
168
+ *
169
+ * node-notifier's `fileCommand` reports a non-zero exit as an Error but also
170
+ * passes plain stderr through as the "error" argument on success — a chatty
171
+ * `notify-send` or `terminal-notifier` build would otherwise mute notifications
172
+ * for the whole session. Errors always count; loose text only when it names a
173
+ * failure. Pure, exported for testing.
174
+ */
175
+ export function isFailure(err) {
176
+ if (!err) {
177
+ return false;
178
+ }
179
+ if (err instanceof Error) {
180
+ return true;
181
+ }
182
+ const text = String(err).trim();
183
+ return (
184
+ text !== '' && /\b(disabled|denied|not found|no such|fail|error|refus|invalid)/i.test(text)
185
+ );
186
+ }
187
+
188
+ /**
189
+ * Turn whatever the platform notifier reported into one short sentence. The raw
190
+ * text is multi-line and full of absolute paths — exactly what we don't want in
191
+ * the chat log. Pure, exported for testing.
192
+ */
193
+ export function describeFailure(err) {
194
+ const raw = String(err?.message ?? err ?? '')
195
+ .replace(/\s+/g, ' ')
196
+ .trim();
197
+ if (/disabledforapplication|notifications are disabled/i.test(raw)) {
198
+ return 'the OS has notifications turned off for this app';
199
+ }
200
+ if (/not found on system|enoent/i.test(raw)) {
201
+ return 'no notification backend is installed';
202
+ }
203
+ return raw.slice(0, 120) || 'the OS rejected it';
204
+ }