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.
- package/CHANGELOG.md +69 -0
- package/README.md +4 -3
- package/README.pt-BR.md +4 -3
- package/docs/ARCHITECTURE.md +321 -225
- package/docs/SETUP.md +18 -0
- package/docs/commands.json +2 -2
- package/package.json +2 -2
- package/src/client/ChatController.js +22 -4
- package/src/client/UI.js +617 -124
- package/src/client/keyboard.js +388 -0
- package/src/p2p/P2PChatController.js +21 -4
- package/src/shared/desktopNotify.js +204 -0
- package/src/shared/notifyWorker.js +46 -0
- package/src/shared/tips.js +1 -0
|
@@ -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
|
-
|
|
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
|
+
}
|