ciphermesh 2.4.0 → 2.5.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/README.md +3 -2
- package/README.pt-BR.md +3 -2
- package/package.json +1 -1
- package/src/client/ChatController.js +136 -12
- package/src/client/UI.js +66 -15
- package/src/p2p/P2PChatController.js +132 -0
- package/src/shared/doctor.js +225 -0
package/README.md
CHANGED
|
@@ -212,6 +212,7 @@ A green **✓** next to a name marks a SAS-verified peer; a red **✗** flags a
|
|
|
212
212
|
| `/img [path]` | Render the last received image in **full resolution** (kitty/iTerm2) |
|
|
213
213
|
| `/search <term>` | Search the encrypted local history (on disk, across sessions) |
|
|
214
214
|
| `/find [term]` — **Ctrl+F** | Search **this room's scrollback** and press Enter to **jump to the message**, highlighted |
|
|
215
|
+
| `/doctor [host:port]` | Diagnose why a connection fails: address, DNS, TCP port, TLS (CA vs self-signed) and protocol version — each failure with what to do about it |
|
|
215
216
|
| `/history [n]` | Last n messages from history |
|
|
216
217
|
| `/retention <7d\|24h\|30m>` | Purge local history older than the given age |
|
|
217
218
|
| `/export [path]` | Export history as .txt or .json (plaintext!) |
|
|
@@ -226,8 +227,8 @@ A green **✓** next to a name marks a SAS-verified peer; a red **✗** flags a
|
|
|
226
227
|
| `/away [reason]` / `/back` | Mark yourself away — while away, unreads are counted (`[away · N new]`) and `/back` shows a summary |
|
|
227
228
|
| `/mentions [n]` | Recent mentions of you this session (who, where, when) |
|
|
228
229
|
| `/status <text\|off>` | Free-form status — emojis welcome (`/status :fire: coding`) |
|
|
229
|
-
| `/react <emoji>` | React to the last message |
|
|
230
|
-
| `/edit` `/delete` | Edit
|
|
230
|
+
| `/react <emoji>` | React to the last message — the emoji lands **on the message**, with a count when several people react |
|
|
231
|
+
| `/edit` `/delete` | Edit or delete your last message — the **original line is rewritten in place** (marked *(edited)*) or replaced by a tombstone, instead of a new line you have to mentally staple to it |
|
|
231
232
|
| `/pin` `/unpin` `/pins` | Pin messages |
|
|
232
233
|
| `/sound` `/notify` | Sound / desktop notifications |
|
|
233
234
|
| `/dnd [on\|off\|mentions\|HH:MM-HH:MM]` | Do-not-disturb, mentions-only, or quiet hours |
|
package/README.pt-BR.md
CHANGED
|
@@ -213,6 +213,7 @@ Um **✓** verde ao lado de um nome indica um peer verificado por SAS; um **✗*
|
|
|
213
213
|
| `/retention <7d\|24h\|30m>` | Purga o histórico local mais antigo que o tempo dado |
|
|
214
214
|
| `/search <termo>` | Busca no histórico local cifrado (em disco, entre sessões) |
|
|
215
215
|
| `/find [termo]` — **Ctrl+F** | Busca **no histórico da sala na tela** e, com Enter, **salta para a mensagem** destacada |
|
|
216
|
+
| `/doctor [host:porta]` | Diagnostica por que a conexão falha: endereço, DNS, porta TCP, TLS (CA ou self-signed) e versão de protocolo — cada falha com o que fazer |
|
|
216
217
|
| `/history [n]` | Últimas n mensagens do histórico |
|
|
217
218
|
| `/export [caminho]` | Exporta o histórico em .txt ou .json (texto plano!) |
|
|
218
219
|
|
|
@@ -226,8 +227,8 @@ Um **✓** verde ao lado de um nome indica um peer verificado por SAS; um **✗*
|
|
|
226
227
|
| `/away [motivo]` / `/back` | Marca/remove ausência — enquanto ausente, não-lidas são contadas (`[away · N new]`) e o `/back` mostra um resumo |
|
|
227
228
|
| `/mentions [n]` | Menções recentes a você na sessão (quem, onde, quando) |
|
|
228
229
|
| `/status <texto\|off>` | Status livre — emoji à vontade (`/status :fire: codando`) |
|
|
229
|
-
| `/react <emoji>` | Reage à última mensagem |
|
|
230
|
-
| `/edit` `/delete` | Edita
|
|
230
|
+
| `/react <emoji>` | Reage à última mensagem — o emoji aparece **na própria mensagem**, com contagem quando várias pessoas reagem |
|
|
231
|
+
| `/edit` `/delete` | Edita ou apaga sua última mensagem — a **linha original é reescrita no lugar** (marcada *(edited)*) ou vira uma lápide, em vez de uma linha nova que você precisa juntar mentalmente à original |
|
|
231
232
|
| `/pin` `/unpin` `/pins` | Fixa mensagens |
|
|
232
233
|
| `/sound` `/notify` | Notificações sonoras / desktop |
|
|
233
234
|
| `/dnd [on\|off\|mentions\|HH:MM-HH:MM]` | Não perturbe, só menções, ou horário silencioso |
|
package/package.json
CHANGED
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
matchesKeyword,
|
|
61
61
|
} from '../shared/dnd.js';
|
|
62
62
|
import { saveLastSession } from '../shared/lastSession.js';
|
|
63
|
+
import { diagnose, formatDiagnosis } from '../shared/doctor.js';
|
|
63
64
|
import { COMMANDS } from './UI.js';
|
|
64
65
|
|
|
65
66
|
const TYPING_SEND_INTERVAL = 2000; // debounce: max 1 typing event per 2s
|
|
@@ -120,6 +121,9 @@ export class ChatController {
|
|
|
120
121
|
#watchWords = new Set(); // /watch — keywords that alert like a mention does
|
|
121
122
|
#awayUnread = 0; // messages received while away
|
|
122
123
|
#awayMentions = 0; // …of which mentioned me
|
|
124
|
+
#messageLines = new Map(); // messageId → { lineIndex, nickname, text, opts, room }
|
|
125
|
+
#reactions = new Map(); // messageId → Map<emoji, count>
|
|
126
|
+
#reconnectAttempts = 0; // consecutive reconnects, for the /doctor nudge
|
|
123
127
|
#autoLockMs = 0; // idle screen-lock timeout (0 = off)
|
|
124
128
|
#autoLockTimer = null;
|
|
125
129
|
// Multi-room buffers (IRC style): lines live in the UI; membership, unread
|
|
@@ -212,6 +216,7 @@ export class ChatController {
|
|
|
212
216
|
// the initial JOIN would be lost — the 'connected' event fired before we
|
|
213
217
|
// attached the listener).
|
|
214
218
|
#onConnected() {
|
|
219
|
+
this.#reconnectAttempts = 0;
|
|
215
220
|
this.#ui.setConnectionState('online');
|
|
216
221
|
this.#connection.send(
|
|
217
222
|
createJoin(this.#nickname, this.#keyManager.publicKeyB64, this.#keyManager.pqPublicKeyB64),
|
|
@@ -237,6 +242,12 @@ export class ChatController {
|
|
|
237
242
|
this.#connection.on('reconnecting', (delay) => {
|
|
238
243
|
this.#ui.setConnectionState('reconnecting');
|
|
239
244
|
this.#ui.addSystemMessage(`Reconnecting in ${delay / 1000}s...`);
|
|
245
|
+
// After a few failures this is not a blip — point at the tool that can
|
|
246
|
+
// actually explain it, once, instead of looping silently forever.
|
|
247
|
+
this.#reconnectAttempts++;
|
|
248
|
+
if (this.#reconnectAttempts === 3) {
|
|
249
|
+
this.#ui.addInfoMessage('Still failing? Run /doctor to find out where it breaks.');
|
|
250
|
+
}
|
|
240
251
|
});
|
|
241
252
|
|
|
242
253
|
this.#connection.on('cert-ca-valid', ({ issuer }) => {
|
|
@@ -1159,9 +1170,17 @@ export class ChatController {
|
|
|
1159
1170
|
}
|
|
1160
1171
|
|
|
1161
1172
|
if (data.action === 'reaction') {
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1173
|
+
// Hang the reaction off the message itself; only fall back to a log
|
|
1174
|
+
// line when the target is not on screen (older or another room).
|
|
1175
|
+
const applied =
|
|
1176
|
+
roomActive && data.targetMessageId
|
|
1177
|
+
? this.#applyReaction(data.targetMessageId, data.emoji)
|
|
1178
|
+
: false;
|
|
1179
|
+
if (!applied) {
|
|
1180
|
+
this.#ui.toBuffer(msgRoom, () => {
|
|
1181
|
+
this.#ui.addSystemMessage(`${data.emoji} ${peer.nickname} reacted to a message`);
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1165
1184
|
if (roomActive) {
|
|
1166
1185
|
this.#ui.playNotification();
|
|
1167
1186
|
}
|
|
@@ -1170,7 +1189,14 @@ export class ChatController {
|
|
|
1170
1189
|
|
|
1171
1190
|
if (data.action === 'edit_message') {
|
|
1172
1191
|
const author = this.#messageAuthors.get(data.messageId);
|
|
1173
|
-
if (author
|
|
1192
|
+
if (!author || author !== peer.nickname) {
|
|
1193
|
+
return; // only the author may rewrite their own message
|
|
1194
|
+
}
|
|
1195
|
+
const entry = this.#editableMessage(data.messageId);
|
|
1196
|
+
if (entry) {
|
|
1197
|
+
entry.text = data.newText;
|
|
1198
|
+
this.#ui.replaceMessageText(entry.lineIndex, entry.nickname, data.newText, entry.opts);
|
|
1199
|
+
} else {
|
|
1174
1200
|
this.#ui.toBuffer(msgRoom, () => {
|
|
1175
1201
|
this.#ui.addSystemMessage(`${peer.nickname} edited: ${data.newText} (edited)`);
|
|
1176
1202
|
});
|
|
@@ -1180,7 +1206,14 @@ export class ChatController {
|
|
|
1180
1206
|
|
|
1181
1207
|
if (data.action === 'delete_message') {
|
|
1182
1208
|
const author = this.#messageAuthors.get(data.messageId);
|
|
1183
|
-
if (author
|
|
1209
|
+
if (!author || author !== peer.nickname) {
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
const entry = this.#editableMessage(data.messageId);
|
|
1213
|
+
if (entry) {
|
|
1214
|
+
this.#ui.tombstoneMessage(entry.lineIndex, peer.nickname);
|
|
1215
|
+
this.#messageLines.delete(data.messageId);
|
|
1216
|
+
} else {
|
|
1184
1217
|
this.#ui.toBuffer(msgRoom, () => {
|
|
1185
1218
|
this.#ui.addSystemMessage(`${peer.nickname} deleted a message`);
|
|
1186
1219
|
});
|
|
@@ -1299,11 +1332,12 @@ export class ChatController {
|
|
|
1299
1332
|
const trust = trustBadge(this.#trustStore.getPeerRecord(peer.nickname), peer.publicKey);
|
|
1300
1333
|
// File the message into its buffer (live log when active, stored otherwise).
|
|
1301
1334
|
let lineIndex = -1;
|
|
1335
|
+
let renderInfo = null;
|
|
1302
1336
|
this.#ui.toBuffer(msgRoom, () => {
|
|
1303
1337
|
if (data.replyTo?.nickname && typeof data.replyTo.excerpt === 'string') {
|
|
1304
1338
|
this.#ui.addQuoteLine(String(data.replyTo.nickname), data.replyTo.excerpt.slice(0, 80));
|
|
1305
1339
|
}
|
|
1306
|
-
({ lineIndex } = data.isAction
|
|
1340
|
+
({ lineIndex, render: renderInfo } = data.isAction
|
|
1307
1341
|
? this.#ui.addActionMessage(peer.nickname, data.text)
|
|
1308
1342
|
: this.#ui.addMessage(
|
|
1309
1343
|
peer.nickname,
|
|
@@ -1315,6 +1349,9 @@ export class ChatController {
|
|
|
1315
1349
|
trust,
|
|
1316
1350
|
));
|
|
1317
1351
|
});
|
|
1352
|
+
if (roomActive && data.messageId) {
|
|
1353
|
+
this.#rememberMessage(data.messageId, lineIndex, peer.nickname, data.text, renderInfo);
|
|
1354
|
+
}
|
|
1318
1355
|
this.#noteBufferUnread(msgRoom, mentioned);
|
|
1319
1356
|
const notify = shouldNotify(this.#dndMode, this.#dndWindow, nowMinutes(), mentioned);
|
|
1320
1357
|
if (notify) {
|
|
@@ -1440,6 +1477,7 @@ export class ChatController {
|
|
|
1440
1477
|
);
|
|
1441
1478
|
this.#ui.addInfoMessage(' /search <term> - Search the encrypted local history');
|
|
1442
1479
|
this.#ui.addInfoMessage(' /find [term] - Find in this room and jump (Ctrl+F)');
|
|
1480
|
+
this.#ui.addInfoMessage(' /doctor [host:port] - Diagnose why a connection fails');
|
|
1443
1481
|
this.#ui.addInfoMessage(' /history [n] - Last n messages from history');
|
|
1444
1482
|
this.#ui.addInfoMessage(' /export [path] - Export the history (.txt or .json)');
|
|
1445
1483
|
this.#ui.addInfoMessage(' /audit [N] - Show the last N audit events');
|
|
@@ -2171,6 +2209,21 @@ export class ChatController {
|
|
|
2171
2209
|
break;
|
|
2172
2210
|
}
|
|
2173
2211
|
|
|
2212
|
+
case '/doctor': {
|
|
2213
|
+
const target = parts.slice(1).join(' ').trim() || this.#connection.url || '';
|
|
2214
|
+
this.#ui.addInfoMessage(`Diagnosing ${target} …`);
|
|
2215
|
+
diagnose(target)
|
|
2216
|
+
.then((steps) => {
|
|
2217
|
+
for (const line of formatDiagnosis(steps)) {
|
|
2218
|
+
this.#ui.addInfoMessage(line);
|
|
2219
|
+
}
|
|
2220
|
+
})
|
|
2221
|
+
.catch((err) => {
|
|
2222
|
+
this.#ui.addErrorMessage(`Diagnostics failed to run: ${err.message}`);
|
|
2223
|
+
});
|
|
2224
|
+
break;
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2174
2227
|
case '/find': {
|
|
2175
2228
|
const term = parts.slice(1).join(' ').trim();
|
|
2176
2229
|
// Opens the same overlay as Ctrl+F, pre-filled when a term is given.
|
|
@@ -2283,9 +2336,12 @@ export class ChatController {
|
|
|
2283
2336
|
sentAt: Date.now(),
|
|
2284
2337
|
});
|
|
2285
2338
|
this.#broadcastPayload(reactionPayload);
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2339
|
+
// Show it on the message right away instead of announcing it.
|
|
2340
|
+
if (!this.#applyReaction(this.#lastReceivedMessageId, emoji)) {
|
|
2341
|
+
this.#ui.addSystemMessage(
|
|
2342
|
+
`${emoji} You reacted to ${this.#lastReceivedNickname}'s message`,
|
|
2343
|
+
);
|
|
2344
|
+
}
|
|
2289
2345
|
break;
|
|
2290
2346
|
}
|
|
2291
2347
|
|
|
@@ -2306,7 +2362,19 @@ export class ChatController {
|
|
|
2306
2362
|
sentAt: Date.now(),
|
|
2307
2363
|
});
|
|
2308
2364
|
this.#broadcastPayload(editPayload);
|
|
2309
|
-
|
|
2365
|
+
// Rewrite our own line in place, like the peers will.
|
|
2366
|
+
const editEntry = this.#editableMessage(this.#lastSentMessageId);
|
|
2367
|
+
if (editEntry) {
|
|
2368
|
+
editEntry.text = editText;
|
|
2369
|
+
this.#ui.replaceMessageText(
|
|
2370
|
+
editEntry.lineIndex,
|
|
2371
|
+
editEntry.nickname,
|
|
2372
|
+
editText,
|
|
2373
|
+
editEntry.opts,
|
|
2374
|
+
);
|
|
2375
|
+
} else {
|
|
2376
|
+
this.#ui.addSystemMessage(`You edited: ${editText} (edited)`);
|
|
2377
|
+
}
|
|
2310
2378
|
break;
|
|
2311
2379
|
}
|
|
2312
2380
|
|
|
@@ -2321,8 +2389,14 @@ export class ChatController {
|
|
|
2321
2389
|
sentAt: Date.now(),
|
|
2322
2390
|
});
|
|
2323
2391
|
this.#broadcastPayload(deletePayload);
|
|
2392
|
+
const delEntry = this.#editableMessage(this.#lastSentMessageId);
|
|
2393
|
+
if (delEntry) {
|
|
2394
|
+
this.#ui.tombstoneMessage(delEntry.lineIndex, this.#nickname);
|
|
2395
|
+
this.#messageLines.delete(this.#lastSentMessageId);
|
|
2396
|
+
} else {
|
|
2397
|
+
this.#ui.addSystemMessage('You deleted a message');
|
|
2398
|
+
}
|
|
2324
2399
|
this.#lastSentMessageId = null;
|
|
2325
|
-
this.#ui.addSystemMessage('You deleted a message');
|
|
2326
2400
|
break;
|
|
2327
2401
|
}
|
|
2328
2402
|
|
|
@@ -3019,6 +3093,55 @@ export class ChatController {
|
|
|
3019
3093
|
this.#ui.appendBadge(tracked.lineIndex, tracked.baseLine, `{green-fg}${marker}{/green-fg}`);
|
|
3020
3094
|
}
|
|
3021
3095
|
|
|
3096
|
+
// Remember where a message was drawn so reactions/edits/deletes can change
|
|
3097
|
+
// it in place. Bounded like the receipt tracker.
|
|
3098
|
+
#rememberMessage(messageId, lineIndex, nickname, text, render) {
|
|
3099
|
+
if (!messageId || lineIndex === undefined || lineIndex < 0) {
|
|
3100
|
+
return;
|
|
3101
|
+
}
|
|
3102
|
+
this.#messageLines.set(messageId, {
|
|
3103
|
+
lineIndex,
|
|
3104
|
+
nickname,
|
|
3105
|
+
text,
|
|
3106
|
+
opts: render?.opts || {},
|
|
3107
|
+
room: this.#currentRoom,
|
|
3108
|
+
});
|
|
3109
|
+
if (this.#messageLines.size > 200) {
|
|
3110
|
+
const oldest = this.#messageLines.keys().next().value;
|
|
3111
|
+
this.#messageLines.delete(oldest);
|
|
3112
|
+
this.#reactions.delete(oldest);
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
|
|
3116
|
+
// A message can only be redrawn while its room is the one on screen.
|
|
3117
|
+
#editableMessage(messageId) {
|
|
3118
|
+
const entry = this.#messageLines.get(messageId);
|
|
3119
|
+
if (!entry || entry.room !== this.#currentRoom) {
|
|
3120
|
+
return null;
|
|
3121
|
+
}
|
|
3122
|
+
return entry;
|
|
3123
|
+
}
|
|
3124
|
+
|
|
3125
|
+
#applyReaction(messageId, emoji) {
|
|
3126
|
+
const entry = this.#editableMessage(messageId);
|
|
3127
|
+
if (!entry) {
|
|
3128
|
+
return false;
|
|
3129
|
+
}
|
|
3130
|
+
const counts = this.#reactions.get(messageId) || new Map();
|
|
3131
|
+
counts.set(emoji, (counts.get(emoji) || 0) + 1);
|
|
3132
|
+
this.#reactions.set(messageId, counts);
|
|
3133
|
+
|
|
3134
|
+
// Re-render the line, then hang the reactions off the end of it.
|
|
3135
|
+
const badge = [...counts.entries()].map(([e, n]) => (n > 1 ? `${e}${n}` : e)).join(' ');
|
|
3136
|
+
this.#ui.replaceMessageText(entry.lineIndex, entry.nickname, entry.text, {
|
|
3137
|
+
...entry.opts,
|
|
3138
|
+
edited: entry.opts?.edited,
|
|
3139
|
+
});
|
|
3140
|
+
const rebuilt = this.#ui.getLine(entry.lineIndex);
|
|
3141
|
+
this.#ui.appendBadge(entry.lineIndex, rebuilt, badge);
|
|
3142
|
+
return true;
|
|
3143
|
+
}
|
|
3144
|
+
|
|
3022
3145
|
#trackSentMessage(messageId, lineIndex) {
|
|
3023
3146
|
const baseLine = this.#ui.getLine(lineIndex);
|
|
3024
3147
|
if (baseLine === null || baseLine === undefined) {
|
|
@@ -3321,9 +3444,10 @@ export class ChatController {
|
|
|
3321
3444
|
this.#ui.addQuoteLine(replyTo.nickname, replyTo.excerpt, true);
|
|
3322
3445
|
}
|
|
3323
3446
|
const ephLabel = this.#ephemeralMode ? this.#formatDuration(this.#ephemeralDurationMs) : null;
|
|
3324
|
-
const { lineIndex } = isAction
|
|
3447
|
+
const { lineIndex, render } = isAction
|
|
3325
3448
|
? this.#ui.addActionMessage(this.#nickname, text)
|
|
3326
3449
|
: this.#ui.addMessage(this.#nickname, text, false, ephLabel, this.#deniableMode);
|
|
3450
|
+
this.#rememberMessage(messageId, lineIndex, this.#nickname, text, render);
|
|
3327
3451
|
|
|
3328
3452
|
if (this.#ephemeralMode) {
|
|
3329
3453
|
this.#scheduleEphemeralRemoval(lineIndex, this.#ephemeralDurationMs, this.#nickname);
|
package/src/client/UI.js
CHANGED
|
@@ -46,6 +46,7 @@ const COMMAND_INFO = [
|
|
|
46
46
|
['/notify', 'Desktop notifications'],
|
|
47
47
|
['/search', 'Search history (on disk)'],
|
|
48
48
|
['/find', 'Find in this room and jump to it'],
|
|
49
|
+
['/doctor', 'Diagnose why a connection fails'],
|
|
49
50
|
['/history', 'Recent messages from history'],
|
|
50
51
|
['/export', 'Export history'],
|
|
51
52
|
['/backup', 'Back up identity + trust'],
|
|
@@ -112,6 +113,7 @@ export const COMMANDS = [
|
|
|
112
113
|
'/invite',
|
|
113
114
|
'/search',
|
|
114
115
|
'/find',
|
|
116
|
+
'/doctor',
|
|
115
117
|
'/history',
|
|
116
118
|
'/export',
|
|
117
119
|
'/backup',
|
|
@@ -1781,6 +1783,64 @@ export class UI extends EventEmitter {
|
|
|
1781
1783
|
trust = 'none',
|
|
1782
1784
|
) {
|
|
1783
1785
|
this.#daySeparator();
|
|
1786
|
+
const isSelfNow = nickname === this.#nickname || nickname.includes('\u2192');
|
|
1787
|
+
const opts = {
|
|
1788
|
+
isDM,
|
|
1789
|
+
ephemeralLabel,
|
|
1790
|
+
deniable,
|
|
1791
|
+
mentioned,
|
|
1792
|
+
trust,
|
|
1793
|
+
grouped: !isSelfNow && !isDM && this.#lastSender === nickname,
|
|
1794
|
+
stamp: time(),
|
|
1795
|
+
};
|
|
1796
|
+
const line = this.#composeMessageLine(nickname, text, opts);
|
|
1797
|
+
|
|
1798
|
+
this.#lines.push(line);
|
|
1799
|
+
this.#chatLog.log(line);
|
|
1800
|
+
this.#screen.render();
|
|
1801
|
+
this.#lastSender = isSelfNow ? 'self' : nickname;
|
|
1802
|
+
if (!isSelfNow) {
|
|
1803
|
+
this.#noteIncoming(mentioned || isDM);
|
|
1804
|
+
}
|
|
1805
|
+
return { lineIndex: this.#lines.length - 1, render: { nickname, opts } };
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
/**
|
|
1809
|
+
* Rewrite an existing message line with new text — used by /edit, so an
|
|
1810
|
+
* edited message changes IN PLACE instead of arriving as a separate line the
|
|
1811
|
+
* reader has to mentally staple to the original.
|
|
1812
|
+
*/
|
|
1813
|
+
replaceMessageText(lineIndex, nickname, newText, opts) {
|
|
1814
|
+
this.updateLine(
|
|
1815
|
+
lineIndex,
|
|
1816
|
+
this.#composeMessageLine(nickname, newText, { ...opts, edited: true }),
|
|
1817
|
+
);
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
/** Replace a message with a tombstone (used by /delete). */
|
|
1821
|
+
tombstoneMessage(lineIndex, nickname) {
|
|
1822
|
+
this.updateLine(
|
|
1823
|
+
lineIndex,
|
|
1824
|
+
` {white-fg}[${time()}]{/white-fg} {#666666-fg}\ud83d\udeab ${blessed.escape(
|
|
1825
|
+
nickname,
|
|
1826
|
+
)} deleted a message{/#666666-fg}`,
|
|
1827
|
+
);
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
// Builds a message line. Shared by addMessage and replaceMessageText so an
|
|
1831
|
+
// edited message keeps exactly the layout it had (alignment, grouping,
|
|
1832
|
+
// badges) instead of drifting into a different shape.
|
|
1833
|
+
#composeMessageLine(nickname, text, opts) {
|
|
1834
|
+
const {
|
|
1835
|
+
isDM = false,
|
|
1836
|
+
ephemeralLabel = null,
|
|
1837
|
+
deniable = false,
|
|
1838
|
+
mentioned = false,
|
|
1839
|
+
trust = 'none',
|
|
1840
|
+
grouped = false,
|
|
1841
|
+
edited = false,
|
|
1842
|
+
stamp = time(),
|
|
1843
|
+
} = opts || {};
|
|
1784
1844
|
|
|
1785
1845
|
const color = nickColor(nickname);
|
|
1786
1846
|
const isSelf = nickname === this.#nickname || nickname.includes('\u2192');
|
|
@@ -1802,24 +1862,15 @@ export class UI extends EventEmitter {
|
|
|
1802
1862
|
|
|
1803
1863
|
// Consecutive messages from the same peer collapse the avatar/name into a
|
|
1804
1864
|
// compact continuation bullet (cleaner layout).
|
|
1805
|
-
const
|
|
1865
|
+
const editedMark = edited ? ' {#8888aa-fg}(edited){/#8888aa-fg}' : '';
|
|
1806
1866
|
const core = grouped
|
|
1807
|
-
? `{${tag}}\u00b7{/${tag}} ${renderMarkdown(text)}`
|
|
1808
|
-
: `${avatar} {${tag}}${nickname}{/${tag}}${trustGlyph}${dmLabel}: ${renderMarkdown(text)}`;
|
|
1867
|
+
? `{${tag}}\u00b7{/${tag}} ${renderMarkdown(text)}${editedMark}`
|
|
1868
|
+
: `${avatar} {${tag}}${nickname}{/${tag}}${trustGlyph}${dmLabel}: ${renderMarkdown(text)}${editedMark}`;
|
|
1809
1869
|
|
|
1810
1870
|
// My own messages on the right (timestamp at the end), others on the left
|
|
1811
|
-
|
|
1812
|
-
? this.#alignRight(`${core}${ephLabel}${denLabel} {white-fg}[${
|
|
1813
|
-
: `${bar}{white-fg}[${
|
|
1814
|
-
|
|
1815
|
-
this.#lines.push(line);
|
|
1816
|
-
this.#chatLog.log(line);
|
|
1817
|
-
this.#screen.render();
|
|
1818
|
-
this.#lastSender = isSelf ? 'self' : nickname;
|
|
1819
|
-
if (!isSelf) {
|
|
1820
|
-
this.#noteIncoming(mentioned || isDM);
|
|
1821
|
-
}
|
|
1822
|
-
return { lineIndex: this.#lines.length - 1 };
|
|
1871
|
+
return isSelf
|
|
1872
|
+
? this.#alignRight(`${core}${ephLabel}${denLabel} {white-fg}[${stamp}]{/white-fg}`)
|
|
1873
|
+
: `${bar}{white-fg}[${stamp}]{/white-fg}${ephLabel}${denLabel} ${mentionMark}${core}`;
|
|
1823
1874
|
}
|
|
1824
1875
|
|
|
1825
1876
|
#daySeparator() {
|
|
@@ -23,6 +23,7 @@ import { isImageFile, renderImagePreview, loadImageBuffers } from '../client/Ima
|
|
|
23
23
|
import { detectImageProtocol, encodeInlineImage } from '../shared/terminalGraphics.js';
|
|
24
24
|
import { AuditLog, AuditEvent } from '../shared/AuditLog.js';
|
|
25
25
|
import { applyShortcodes } from '../shared/emoji.js';
|
|
26
|
+
import { diagnose, formatDiagnosis } from '../shared/doctor.js';
|
|
26
27
|
import { deriveSharedKey, encryptDeniable, decryptDeniable } from '../crypto/DeniableEncrypt.js';
|
|
27
28
|
import { GroupSession } from '../crypto/SenderKey.js';
|
|
28
29
|
import { suggestCommand } from '../shared/commandSuggest.js';
|
|
@@ -90,6 +91,10 @@ export class P2PChatController {
|
|
|
90
91
|
#autoLockTimer = null;
|
|
91
92
|
#roomTopics = new Map(); // room → { text, by, at } (E2EE among peers)
|
|
92
93
|
#historyStore; // encrypted local history (opt-in, needs a passphrase)
|
|
94
|
+
#receiptsEnabled = true; // /receipts — send read confirmations
|
|
95
|
+
#sentMessageLines = new Map(); // messageId → { lineIndex, baseLine, room }
|
|
96
|
+
#messageReaders = new Map(); // messageId → Set<nickname>
|
|
97
|
+
#pendingReceipts = new Map(); // messageId → Set<nickname> acked before we tracked it
|
|
93
98
|
#lastReceivedMessageId;
|
|
94
99
|
#lastReceivedNickname;
|
|
95
100
|
#lastSentMessageId;
|
|
@@ -610,6 +615,11 @@ export class P2PChatController {
|
|
|
610
615
|
return;
|
|
611
616
|
}
|
|
612
617
|
|
|
618
|
+
if (data.action === 'read_receipt') {
|
|
619
|
+
this.#onReadReceipt(fromNickname, data.messageId);
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
|
|
613
623
|
if (data.action === 'set_topic') {
|
|
614
624
|
const room = typeof data.room === 'string' ? data.room : this.#currentRoom;
|
|
615
625
|
if (typeof data.text === 'string') {
|
|
@@ -690,6 +700,23 @@ export class P2PChatController {
|
|
|
690
700
|
this.#ui.playNotification();
|
|
691
701
|
}
|
|
692
702
|
|
|
703
|
+
// Confirm the read to the author — an ordinary E2EE payload, sent only to
|
|
704
|
+
// them. Never for ephemeral or deniable messages: acknowledging those
|
|
705
|
+
// would defeat the point of not leaving a trace.
|
|
706
|
+
if (
|
|
707
|
+
this.#receiptsEnabled &&
|
|
708
|
+
data.messageId &&
|
|
709
|
+
!data.ephemeral &&
|
|
710
|
+
!isDeniable &&
|
|
711
|
+
!data.deniable
|
|
712
|
+
) {
|
|
713
|
+
this.#broadcastPayload(
|
|
714
|
+
JSON.stringify({ action: 'read_receipt', messageId: data.messageId, sentAt: Date.now() }),
|
|
715
|
+
false,
|
|
716
|
+
fromNickname,
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
|
|
693
720
|
if (data.ephemeral && data.ephemeral > 0) {
|
|
694
721
|
this.#scheduleEphemeralRemoval(lineIndex, data.ephemeral, fromNickname);
|
|
695
722
|
}
|
|
@@ -906,6 +933,49 @@ export class P2PChatController {
|
|
|
906
933
|
const cmd = parts[0].toLowerCase();
|
|
907
934
|
|
|
908
935
|
switch (cmd) {
|
|
936
|
+
case '/doctor': {
|
|
937
|
+
const target = parts.slice(1).join(' ').trim();
|
|
938
|
+
if (!target) {
|
|
939
|
+
this.#ui.addErrorMessage(
|
|
940
|
+
'Usage: /doctor <host:port> — P2P finds peers over mDNS, so give an address to test',
|
|
941
|
+
);
|
|
942
|
+
break;
|
|
943
|
+
}
|
|
944
|
+
this.#ui.addInfoMessage(`Diagnosing ${target} …`);
|
|
945
|
+
diagnose(target)
|
|
946
|
+
.then((steps) => {
|
|
947
|
+
for (const line of formatDiagnosis(steps)) {
|
|
948
|
+
this.#ui.addInfoMessage(line);
|
|
949
|
+
}
|
|
950
|
+
})
|
|
951
|
+
.catch((err) => {
|
|
952
|
+
this.#ui.addErrorMessage(`Diagnostics failed to run: ${err.message}`);
|
|
953
|
+
});
|
|
954
|
+
break;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
case '/find': {
|
|
958
|
+
// Pure UI: searches the lines on screen, so it works the same here.
|
|
959
|
+
this.#ui.openFinder(parts.slice(1).join(' ').trim());
|
|
960
|
+
break;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
case '/receipts': {
|
|
964
|
+
const arg = parts[1]?.toLowerCase();
|
|
965
|
+
if (arg === 'off') {
|
|
966
|
+
this.#receiptsEnabled = false;
|
|
967
|
+
this.#ui.addInfoMessage('Read receipts disabled — you no longer send read confirmations');
|
|
968
|
+
} else if (arg === 'on') {
|
|
969
|
+
this.#receiptsEnabled = true;
|
|
970
|
+
this.#ui.addInfoMessage('Read receipts enabled');
|
|
971
|
+
} else {
|
|
972
|
+
this.#ui.addInfoMessage(
|
|
973
|
+
`Read receipts: ${this.#receiptsEnabled ? 'enabled' : 'disabled'}. Use /receipts on or /receipts off`,
|
|
974
|
+
);
|
|
975
|
+
}
|
|
976
|
+
break;
|
|
977
|
+
}
|
|
978
|
+
|
|
909
979
|
case '/search': {
|
|
910
980
|
if (!this.#historyStore?.isOpen) {
|
|
911
981
|
this.#ui.addErrorMessage('History disabled — start with a passphrase');
|
|
@@ -1263,6 +1333,9 @@ export class P2PChatController {
|
|
|
1263
1333
|
this.#ui.addInfoMessage(' /history [n] - Last n messages from history');
|
|
1264
1334
|
this.#ui.addInfoMessage(' /export [path] - Export the history');
|
|
1265
1335
|
this.#ui.addInfoMessage(' /retention <time> - Local history retention');
|
|
1336
|
+
this.#ui.addInfoMessage(' /receipts [on|off] - Read receipts (✓✓)');
|
|
1337
|
+
this.#ui.addInfoMessage(' /find [term] - Find in this room and jump (Ctrl+F)');
|
|
1338
|
+
this.#ui.addInfoMessage(' /doctor <host:port> - Diagnose why a connection fails');
|
|
1266
1339
|
this.#ui.addInfoMessage(' /watch [add|remove|clear] - Alert on a keyword');
|
|
1267
1340
|
this.#ui.addInfoMessage(' /help - Show this help');
|
|
1268
1341
|
this.#ui.addInfoMessage(' /tips - Show a security/UX tip');
|
|
@@ -2363,9 +2436,68 @@ export class P2PChatController {
|
|
|
2363
2436
|
|
|
2364
2437
|
if (this.#ephemeralMode) {
|
|
2365
2438
|
this.#scheduleEphemeralRemoval(lineIndex, this.#ephemeralDurationMs, this.#nickname);
|
|
2439
|
+
} else if (!this.#deniableMode) {
|
|
2440
|
+
this.#trackSentMessage(messageId, lineIndex);
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
// ── Read receipts ────────────────────────────────────────────
|
|
2445
|
+
#trackSentMessage(messageId, lineIndex) {
|
|
2446
|
+
const baseLine = this.#ui.getLine(lineIndex);
|
|
2447
|
+
if (baseLine === null || baseLine === undefined) {
|
|
2448
|
+
return;
|
|
2449
|
+
}
|
|
2450
|
+
this.#sentMessageLines.set(messageId, { lineIndex, baseLine, room: this.#currentRoom });
|
|
2451
|
+
|
|
2452
|
+
// A peer on the same machine (or a very fast link) can acknowledge before
|
|
2453
|
+
// we finish rendering our own echo. Apply anything that arrived early.
|
|
2454
|
+
const early = this.#pendingReceipts.get(messageId);
|
|
2455
|
+
if (early) {
|
|
2456
|
+
this.#pendingReceipts.delete(messageId);
|
|
2457
|
+
for (const nickname of early) {
|
|
2458
|
+
this.#onReadReceipt(nickname, messageId);
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
// Bound memory: keep only the most recent 200 tracked messages
|
|
2463
|
+
if (this.#sentMessageLines.size > 200) {
|
|
2464
|
+
const oldest = this.#sentMessageLines.keys().next().value;
|
|
2465
|
+
this.#sentMessageLines.delete(oldest);
|
|
2466
|
+
this.#messageReaders.delete(oldest);
|
|
2366
2467
|
}
|
|
2367
2468
|
}
|
|
2368
2469
|
|
|
2470
|
+
#onReadReceipt(nickname, messageId) {
|
|
2471
|
+
const tracked = this.#sentMessageLines.get(messageId);
|
|
2472
|
+
if (!tracked) {
|
|
2473
|
+
// Arrived before we tracked our own message — remember it (bounded) and
|
|
2474
|
+
// let #trackSentMessage apply it a moment later.
|
|
2475
|
+
if (messageId && this.#pendingReceipts.size < 200) {
|
|
2476
|
+
const set = this.#pendingReceipts.get(messageId) || new Set();
|
|
2477
|
+
set.add(nickname);
|
|
2478
|
+
this.#pendingReceipts.set(messageId, set);
|
|
2479
|
+
}
|
|
2480
|
+
return;
|
|
2481
|
+
}
|
|
2482
|
+
// Line indexes only address the room currently on screen.
|
|
2483
|
+
if (tracked.room && tracked.room !== this.#currentRoom) {
|
|
2484
|
+
return;
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
let readers = this.#messageReaders.get(messageId);
|
|
2488
|
+
if (!readers) {
|
|
2489
|
+
readers = new Set();
|
|
2490
|
+
this.#messageReaders.set(messageId, readers);
|
|
2491
|
+
}
|
|
2492
|
+
if (readers.has(nickname)) {
|
|
2493
|
+
return;
|
|
2494
|
+
}
|
|
2495
|
+
readers.add(nickname);
|
|
2496
|
+
|
|
2497
|
+
const marker = readers.size > 1 ? `✓✓ ${readers.size}` : '✓✓';
|
|
2498
|
+
this.#ui.appendBadge(tracked.lineIndex, tracked.baseLine, `{green-fg}${marker}{/green-fg}`);
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2369
2501
|
// ── Send encrypted DM to one peer ────────────────────────────
|
|
2370
2502
|
#sendMessageToPeer(peerNickname, text) {
|
|
2371
2503
|
const peerPublicKey = this.#handshake.getPeerPublicKey(peerNickname);
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Connection doctor: answers "why can't I connect?" in the order the failures
|
|
2
|
+
// actually happen, so a user without a terminal debugger can fix it alone.
|
|
3
|
+
//
|
|
4
|
+
// Every step reports what was checked, whether it passed, and — when it fails
|
|
5
|
+
// — what to do about it. A failing step stops the run: there is no point
|
|
6
|
+
// testing TLS when the TCP port never opened.
|
|
7
|
+
import { connect as netConnect } from 'node:net';
|
|
8
|
+
import { connect as tlsConnect } from 'node:tls';
|
|
9
|
+
import { lookup as dnsLookup } from 'node:dns';
|
|
10
|
+
import { PROTOCOL_VERSION } from './constants.js';
|
|
11
|
+
|
|
12
|
+
const DEFAULT_TIMEOUT_MS = 6000;
|
|
13
|
+
|
|
14
|
+
/** Split "wss://host:3600" (or "host:3600") into its parts. */
|
|
15
|
+
export function parseTarget(raw) {
|
|
16
|
+
const value = String(raw || '').trim();
|
|
17
|
+
if (!value) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const withScheme = /^wss?:\/\//.test(value) ? value : `wss://${value}`;
|
|
21
|
+
try {
|
|
22
|
+
const url = new URL(withScheme);
|
|
23
|
+
const port = Number(url.port) || 3600;
|
|
24
|
+
if (!url.hostname || !Number.isInteger(port) || port < 1 || port > 65535) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
return { host: url.hostname, port, tls: url.protocol === 'wss:', url: withScheme };
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const isIpLiteral = (host) => /^[\d.]+$/.test(host) || host.includes(':');
|
|
34
|
+
|
|
35
|
+
function step(name, ok, detail, hint = null) {
|
|
36
|
+
return hint ? { name, ok, detail, hint } : { name, ok, detail };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function resolveHost(host, deps) {
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
deps.lookup(host, (err, address) => resolve(err ? null : address));
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function probeTcp(host, port, timeoutMs, deps) {
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
const socket = deps.netConnect({ host, port });
|
|
48
|
+
const done = (result) => {
|
|
49
|
+
socket.removeAllListeners();
|
|
50
|
+
socket.destroy();
|
|
51
|
+
resolve(result);
|
|
52
|
+
};
|
|
53
|
+
socket.setTimeout(timeoutMs);
|
|
54
|
+
socket.on('connect', () => done({ ok: true }));
|
|
55
|
+
socket.on('timeout', () => done({ ok: false, reason: 'timeout' }));
|
|
56
|
+
socket.on('error', (err) => done({ ok: false, reason: err.code || err.message }));
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function probeTls(host, port, timeoutMs, deps) {
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
// SNI is a hostname field: Node throws outright if given an IP, and on a
|
|
63
|
+
// LAN the target is almost always an IP. Send it only when we have a name.
|
|
64
|
+
const sni = isIpLiteral(host) ? {} : { servername: host };
|
|
65
|
+
// Diagnostic socket only: it completes the handshake, reads the
|
|
66
|
+
// certificate, reports it and closes — no data is ever sent over it.
|
|
67
|
+
// rejectUnauthorized is off precisely so a self-signed LAN certificate
|
|
68
|
+
// still reaches this point and can be DESCRIBED to the user instead of
|
|
69
|
+
// failing opaquely. The real chat connection does not run this way: it
|
|
70
|
+
// enforces strict verification for hosts that ever presented a CA-valid
|
|
71
|
+
// certificate (see crypto/CertPinStore.js).
|
|
72
|
+
const socket = deps.tlsConnect({ host, port, rejectUnauthorized: false, ...sni });
|
|
73
|
+
const done = (result) => {
|
|
74
|
+
socket.removeAllListeners();
|
|
75
|
+
socket.destroy();
|
|
76
|
+
resolve(result);
|
|
77
|
+
};
|
|
78
|
+
socket.setTimeout(timeoutMs);
|
|
79
|
+
socket.on('secureConnect', () => {
|
|
80
|
+
const cert = socket.getPeerCertificate?.() || {};
|
|
81
|
+
done({
|
|
82
|
+
ok: true,
|
|
83
|
+
authorized: socket.authorized === true,
|
|
84
|
+
issuer: cert.issuer?.O || cert.issuer?.CN || 'unknown',
|
|
85
|
+
fingerprint: cert.fingerprint256 || null,
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
socket.on('timeout', () => done({ ok: false, reason: 'timeout' }));
|
|
89
|
+
socket.on('error', (err) => done({ ok: false, reason: err.code || err.message }));
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Run the connection checks against `target`.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} target - what the user typed at the Server prompt
|
|
97
|
+
* @param {object} [opts]
|
|
98
|
+
* @param {number} [opts.timeoutMs]
|
|
99
|
+
* @param {object} [opts.deps] - injectable node primitives (tests)
|
|
100
|
+
* @returns {Promise<Array<{name, ok, detail, hint?}>>}
|
|
101
|
+
*/
|
|
102
|
+
export async function diagnose(target, opts = {}) {
|
|
103
|
+
const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
104
|
+
const deps = {
|
|
105
|
+
lookup: dnsLookup,
|
|
106
|
+
netConnect,
|
|
107
|
+
tlsConnect,
|
|
108
|
+
...(opts.deps || {}),
|
|
109
|
+
};
|
|
110
|
+
const steps = [];
|
|
111
|
+
|
|
112
|
+
const parsed = parseTarget(target);
|
|
113
|
+
if (!parsed) {
|
|
114
|
+
steps.push(
|
|
115
|
+
step(
|
|
116
|
+
'Address',
|
|
117
|
+
false,
|
|
118
|
+
`cannot parse "${target}"`,
|
|
119
|
+
'Use host:port — for example 192.168.1.10:3600 — or a ciphermesh:// invite.',
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
return steps;
|
|
123
|
+
}
|
|
124
|
+
steps.push(
|
|
125
|
+
step('Address', true, `${parsed.host}:${parsed.port} (${parsed.tls ? 'TLS' : 'plain'})`),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
if (/^(localhost|127\.|::1)/.test(parsed.host)) {
|
|
129
|
+
steps.push(
|
|
130
|
+
step(
|
|
131
|
+
'Target',
|
|
132
|
+
true,
|
|
133
|
+
'localhost — this points at YOUR machine',
|
|
134
|
+
'To reach someone else, use THEIR address. localhost never leaves this computer.',
|
|
135
|
+
),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!isIpLiteral(parsed.host)) {
|
|
140
|
+
const address = await resolveHost(parsed.host, deps);
|
|
141
|
+
steps.push(
|
|
142
|
+
address
|
|
143
|
+
? step('DNS', true, `${parsed.host} → ${address}`)
|
|
144
|
+
: step(
|
|
145
|
+
'DNS',
|
|
146
|
+
false,
|
|
147
|
+
`cannot resolve ${parsed.host}`,
|
|
148
|
+
'Check the name, or use the IP directly.',
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
if (!address) {
|
|
152
|
+
return steps;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const tcp = await probeTcp(parsed.host, parsed.port, timeoutMs, deps);
|
|
157
|
+
if (!tcp.ok) {
|
|
158
|
+
steps.push(
|
|
159
|
+
step(
|
|
160
|
+
'TCP port',
|
|
161
|
+
false,
|
|
162
|
+
`port ${parsed.port} unreachable (${tcp.reason})`,
|
|
163
|
+
tcp.reason === 'timeout'
|
|
164
|
+
? 'Something is dropping the packets: a firewall on either side, or client isolation on the Wi-Fi router. If you can ping the host but not open the port, that is the usual cause.'
|
|
165
|
+
: 'Nothing is listening there. Is the server running, and is the port right?',
|
|
166
|
+
),
|
|
167
|
+
);
|
|
168
|
+
return steps;
|
|
169
|
+
}
|
|
170
|
+
steps.push(step('TCP port', true, `${parsed.host}:${parsed.port} is open`));
|
|
171
|
+
|
|
172
|
+
if (parsed.tls) {
|
|
173
|
+
const tls = await probeTls(parsed.host, parsed.port, timeoutMs, deps);
|
|
174
|
+
if (!tls.ok) {
|
|
175
|
+
steps.push(
|
|
176
|
+
step(
|
|
177
|
+
'TLS',
|
|
178
|
+
false,
|
|
179
|
+
`handshake failed (${tls.reason})`,
|
|
180
|
+
'The port answered but did not negotiate TLS. Is that really a CipherMesh relay?',
|
|
181
|
+
),
|
|
182
|
+
);
|
|
183
|
+
return steps;
|
|
184
|
+
}
|
|
185
|
+
steps.push(
|
|
186
|
+
tls.authorized
|
|
187
|
+
? step('TLS', true, `verified against a public CA (${tls.issuer})`)
|
|
188
|
+
: step(
|
|
189
|
+
'TLS',
|
|
190
|
+
true,
|
|
191
|
+
'self-signed certificate (normal on a LAN)',
|
|
192
|
+
'Trust is pinned on first use — compare fingerprints out-of-band if you want certainty.',
|
|
193
|
+
),
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
steps.push(
|
|
198
|
+
step(
|
|
199
|
+
'Protocol',
|
|
200
|
+
true,
|
|
201
|
+
`this client speaks v${PROTOCOL_VERSION}`,
|
|
202
|
+
'If the server refuses with a protocol mismatch, update BOTH sides: npx ciphermesh@latest, or git pull && npm install from source.',
|
|
203
|
+
),
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
return steps;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Render diagnose() output as lines ready for the chat log. */
|
|
210
|
+
export function formatDiagnosis(steps) {
|
|
211
|
+
const lines = [];
|
|
212
|
+
for (const s of steps) {
|
|
213
|
+
lines.push(`${s.ok ? '✓' : '✗'} ${s.name}: ${s.detail}`);
|
|
214
|
+
if (s.hint) {
|
|
215
|
+
lines.push(` ↳ ${s.hint}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const failed = steps.find((s) => !s.ok);
|
|
219
|
+
lines.push(
|
|
220
|
+
failed
|
|
221
|
+
? `Blocked at "${failed.name}". Fix that first — the checks after it were skipped.`
|
|
222
|
+
: 'All checks passed. If the chat still fails, the problem is above the network layer.',
|
|
223
|
+
);
|
|
224
|
+
return lines;
|
|
225
|
+
}
|