freertc 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +246 -0
- package/bin/freertc.mjs +106 -0
- package/package.json +68 -0
- package/public/app.js +2851 -0
- package/public/index.html +821 -0
- package/scripts/d1-schema.sql +44 -0
- package/scripts/dev-server.mjs +129 -0
- package/scripts/non-cloudflare-server.mjs +427 -0
- package/scripts/postinstall-message.mjs +19 -0
- package/scripts/project-bootstrap.mjs +113 -0
- package/scripts/wrangler-install-wizard.mjs +697 -0
- package/src/index.js +690 -0
- package/wrangler.template.jsonc +71 -0
- package/wrangler.workers-dev.jsonc +19 -0
package/public/app.js
ADDED
|
@@ -0,0 +1,2851 @@
|
|
|
1
|
+
import { createApp, computed, nextTick, onMounted, ref, watch } from "https://unpkg.com/vue@3.5.13/dist/vue.esm-browser.prod.js";
|
|
2
|
+
|
|
3
|
+
const PSP_VERSION = "1.0";
|
|
4
|
+
const PSP_SPEC_URL = "https://github.com/draeder/Peer-Signaling-Protocol-Specification";
|
|
5
|
+
const RTC_CONFIG = {
|
|
6
|
+
iceServers: [
|
|
7
|
+
{ urls: "stun:stun.l.google.com:19302" },
|
|
8
|
+
{ urls: "stun:stun1.l.google.com:19302" },
|
|
9
|
+
{ urls: "stun:stun2.l.google.com:19302" },
|
|
10
|
+
{ urls: "stun:global.stun.twilio.com:3478" }
|
|
11
|
+
]
|
|
12
|
+
};
|
|
13
|
+
const SHARED_IDS_KEY = "freertc.shared.ids.v1";
|
|
14
|
+
const UI_PREFS_KEY = "freertc.ui.prefs.v1";
|
|
15
|
+
|
|
16
|
+
function newId(prefix = "msg") {
|
|
17
|
+
return `${prefix}-${crypto.randomUUID()}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function now() {
|
|
21
|
+
return Date.now();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function waitForIceGatheringComplete(pc, timeoutMs = 4000) {
|
|
25
|
+
if (!pc || pc.iceGatheringState === "complete") {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
await new Promise((resolve) => {
|
|
29
|
+
let settled = false;
|
|
30
|
+
const done = () => {
|
|
31
|
+
if (settled) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
settled = true;
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
try {
|
|
37
|
+
pc.removeEventListener("icegatheringstatechange", onChange);
|
|
38
|
+
} catch {
|
|
39
|
+
// no-op
|
|
40
|
+
}
|
|
41
|
+
resolve();
|
|
42
|
+
};
|
|
43
|
+
const onChange = () => {
|
|
44
|
+
if (pc.iceGatheringState === "complete") {
|
|
45
|
+
done();
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
const timer = setTimeout(done, timeoutMs);
|
|
49
|
+
try {
|
|
50
|
+
pc.addEventListener("icegatheringstatechange", onChange);
|
|
51
|
+
onChange();
|
|
52
|
+
} catch {
|
|
53
|
+
done();
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function safeJsonParse(raw, fallback) {
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(raw);
|
|
61
|
+
} catch {
|
|
62
|
+
return fallback;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function randomHex(bytes = 32) {
|
|
67
|
+
return Array.from(crypto.getRandomValues(new Uint8Array(bytes)), (value) => value.toString(16).padStart(2, "0")).join("");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isValidPeerId(value) {
|
|
71
|
+
if (typeof value !== "string") {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
const peerId = value.trim();
|
|
75
|
+
if (!peerId || peerId.length > 256) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Strict profile: token-safe IDs only (no whitespace) and fingerprint-like length.
|
|
80
|
+
if (!/^[A-Za-z0-9._:/-]+$/.test(peerId)) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Accept common fingerprint styles and app IDs that are long enough to avoid weak collisions.
|
|
85
|
+
const isHexFingerprint = /^[a-f0-9]{40,128}$/i.test(peerId);
|
|
86
|
+
const isMultibaseLike = /^[zbu][A-Za-z0-9]{16,}$/i.test(peerId);
|
|
87
|
+
const isOpaqueStrongToken = peerId.length >= 16;
|
|
88
|
+
return isHexFingerprint || isMultibaseLike || isOpaqueStrongToken;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isSpecToken(value) {
|
|
92
|
+
return typeof value === "string" && /^[A-Za-z0-9._:/-]{1,128}$/.test(value);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function normalizePeerId(value) {
|
|
96
|
+
if (typeof value === "string") {
|
|
97
|
+
return value.trim();
|
|
98
|
+
}
|
|
99
|
+
if (typeof value === "number" || typeof value === "bigint") {
|
|
100
|
+
return String(value);
|
|
101
|
+
}
|
|
102
|
+
return "";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function normalizeText(value) {
|
|
106
|
+
if (typeof value === "string") {
|
|
107
|
+
return value.trim();
|
|
108
|
+
}
|
|
109
|
+
if (value === null || value === undefined) {
|
|
110
|
+
return "";
|
|
111
|
+
}
|
|
112
|
+
return String(value).trim();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
createApp({
|
|
116
|
+
setup() {
|
|
117
|
+
const host = window.location.host;
|
|
118
|
+
const wsScheme = window.location.protocol === "https:" ? "wss" : "ws";
|
|
119
|
+
|
|
120
|
+
const wsUrl = ref(`${wsScheme}://${host}/ws`);
|
|
121
|
+
const status = ref("disconnected");
|
|
122
|
+
const socket = ref(null);
|
|
123
|
+
|
|
124
|
+
const network = ref("room:test");
|
|
125
|
+
|
|
126
|
+
function normalizedWsUrlValue() {
|
|
127
|
+
const trimmed = normalizeText(wsUrl.value);
|
|
128
|
+
return trimmed || `${wsScheme}://${host}/ws`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function normalizedNetworkValue() {
|
|
132
|
+
const trimmed = normalizeText(network.value);
|
|
133
|
+
return trimmed || "room:test";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Keep peer identity in-memory per tab so duplicate tabs cannot share IDs.
|
|
137
|
+
function getOrAssignPeerId() {
|
|
138
|
+
return randomHex();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const fromPeer = ref(getOrAssignPeerId());
|
|
142
|
+
const appliedWsUrl = ref(wsUrl.value);
|
|
143
|
+
const appliedNetwork = ref(network.value);
|
|
144
|
+
const appliedFromPeer = ref(fromPeer.value);
|
|
145
|
+
const toPeer = ref("");
|
|
146
|
+
const sessionId = ref("");
|
|
147
|
+
const instanceId = ref("");
|
|
148
|
+
|
|
149
|
+
const activeView = ref("webrtc");
|
|
150
|
+
const relayType = ref("announce");
|
|
151
|
+
const relayBody = ref('{\n "roles": ["peer"]\n}');
|
|
152
|
+
|
|
153
|
+
const rtcPhase = ref("idle");
|
|
154
|
+
const rtcState = ref("new");
|
|
155
|
+
const dataState = ref("closed");
|
|
156
|
+
const chatInput = ref("");
|
|
157
|
+
const chatSendMode = ref("broadcast");
|
|
158
|
+
const selectedTargetPeers = ref([]);
|
|
159
|
+
const chatLog = ref([]);
|
|
160
|
+
const chatThreadRef = ref(null);
|
|
161
|
+
const debugExpanded = ref(false);
|
|
162
|
+
|
|
163
|
+
const discoveredPeers = ref([]);
|
|
164
|
+
const autoDiscovery = ref(true);
|
|
165
|
+
const autoConnect = ref(true);
|
|
166
|
+
const partialMesh = ref(true);
|
|
167
|
+
const partialMeshMaxPeers = ref(4);
|
|
168
|
+
const meshConnectedCount = ref(0);
|
|
169
|
+
const meshTargetCount = ref(0);
|
|
170
|
+
const specLinkFlash = ref(false);
|
|
171
|
+
const pingFlash = ref(false);
|
|
172
|
+
const discoverFlash = ref(false);
|
|
173
|
+
const announceFlash = ref(false);
|
|
174
|
+
const pingButtonText = ref("ping");
|
|
175
|
+
|
|
176
|
+
let discoveryTimer = null;
|
|
177
|
+
let reconnectTimer = null;
|
|
178
|
+
let specLinkFlashTimer = null;
|
|
179
|
+
let pingFlashTimer = null;
|
|
180
|
+
let discoverFlashTimer = null;
|
|
181
|
+
let announceFlashTimer = null;
|
|
182
|
+
let pendingPingStartedAt = 0;
|
|
183
|
+
let lastDiscoverSyncAt = 0;
|
|
184
|
+
let manualDisconnect = false;
|
|
185
|
+
let stoppingRtc = false;
|
|
186
|
+
let reconnectLockedByBye = false;
|
|
187
|
+
const CONNECT_REQUEST_TIMEOUT_MS = 10000; // 10 second timeout
|
|
188
|
+
const DISCOVER_SYNC_INTERVAL_MS = 10000;
|
|
189
|
+
const FAILED_PEER_COOLDOWN_MS = 5000;
|
|
190
|
+
const BYE_COOLDOWN_MS = 30000;
|
|
191
|
+
const LIVE_PEER_MAX_AGE_MS = 25000;
|
|
192
|
+
const LINK_GRACE_MS = 45000;
|
|
193
|
+
const failedPeerCooldowns = new Map();
|
|
194
|
+
const byeCooldowns = new Map();
|
|
195
|
+
const meshLinks = new Map();
|
|
196
|
+
|
|
197
|
+
const logs = ref([]);
|
|
198
|
+
|
|
199
|
+
const isConnected = computed(() => status.value === "connected");
|
|
200
|
+
const canConnect = computed(() => {
|
|
201
|
+
const ws = socket.value;
|
|
202
|
+
if (!ws) {
|
|
203
|
+
return status.value !== "connecting";
|
|
204
|
+
}
|
|
205
|
+
return ws.readyState === WebSocket.CLOSING || ws.readyState === WebSocket.CLOSED;
|
|
206
|
+
});
|
|
207
|
+
const canDisconnect = computed(() => {
|
|
208
|
+
const ws = socket.value;
|
|
209
|
+
if (!ws) {
|
|
210
|
+
return status.value === "connecting" || status.value === "connected";
|
|
211
|
+
}
|
|
212
|
+
return ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN;
|
|
213
|
+
});
|
|
214
|
+
const canRunRtc = computed(() => isConnected.value && (Boolean(toPeer.value) || autoDiscovery.value));
|
|
215
|
+
const chatMessages = computed(() =>
|
|
216
|
+
chatLog.value.filter((entry) => entry.kind === "incoming" || entry.kind === "outgoing")
|
|
217
|
+
);
|
|
218
|
+
const chatEmptyMessage = computed(() => {
|
|
219
|
+
const connectedPeers = meshConnectedCount.value;
|
|
220
|
+
if (!isConnected.value) {
|
|
221
|
+
return "Connect socket to begin.";
|
|
222
|
+
}
|
|
223
|
+
if (connectedPeers > 0 && chatSendMode.value === "broadcast") {
|
|
224
|
+
return `Connected to ${connectedPeers} peer(s). Broadcast is ready.`;
|
|
225
|
+
}
|
|
226
|
+
if (!toPeer.value) {
|
|
227
|
+
return connectedPeers > 0
|
|
228
|
+
? `Connected to ${connectedPeers} peer(s). Choose a peer for target mode.`
|
|
229
|
+
: "Connected. Waiting for peers...";
|
|
230
|
+
}
|
|
231
|
+
if (dataState.value !== "open") {
|
|
232
|
+
if (connectedPeers > 0) {
|
|
233
|
+
return `Connected to ${connectedPeers} peer(s). Selected peer is still negotiating.`;
|
|
234
|
+
}
|
|
235
|
+
if (["requesting", "waiting-offer", "accepted", "offered", "answering", "answered", "connected-pending"].includes(rtcPhase.value)) {
|
|
236
|
+
return "Connected to peer. Negotiating DataChannel...";
|
|
237
|
+
}
|
|
238
|
+
return "Peer selected. Start Auto Handshake to open the DataChannel.";
|
|
239
|
+
}
|
|
240
|
+
return "DataChannel is open. Send a message to start chatting.";
|
|
241
|
+
});
|
|
242
|
+
const fromPeerDisplay = computed(() => {
|
|
243
|
+
const peer = normalizePeerId(fromPeer.value);
|
|
244
|
+
if (!peer) {
|
|
245
|
+
return "none";
|
|
246
|
+
}
|
|
247
|
+
if (peer.length <= 28) {
|
|
248
|
+
return peer;
|
|
249
|
+
}
|
|
250
|
+
return `${peer.slice(0, 12)}...${peer.slice(-12)}`;
|
|
251
|
+
});
|
|
252
|
+
const targetPeerDisplay = computed(() => {
|
|
253
|
+
const peer = normalizePeerId(toPeer.value);
|
|
254
|
+
if (!peer) {
|
|
255
|
+
return "waiting";
|
|
256
|
+
}
|
|
257
|
+
if (peer.length <= 28) {
|
|
258
|
+
return peer;
|
|
259
|
+
}
|
|
260
|
+
return `${peer.slice(0, 12)}...${peer.slice(-12)}`;
|
|
261
|
+
});
|
|
262
|
+
const relayTargetDisplay = computed(() => {
|
|
263
|
+
if (!toPeer.value) {
|
|
264
|
+
return "broadcast";
|
|
265
|
+
}
|
|
266
|
+
return targetPeerDisplay.value;
|
|
267
|
+
});
|
|
268
|
+
const sendRouteDisplay = computed(() => {
|
|
269
|
+
if (chatSendMode.value === "broadcast") {
|
|
270
|
+
const count = meshConnectedCount.value;
|
|
271
|
+
return count > 0 ? `broadcast (${count} peers)` : "broadcast (0 peers)";
|
|
272
|
+
}
|
|
273
|
+
const selected = selectedTargetPeers.value;
|
|
274
|
+
if (selected.length > 1) {
|
|
275
|
+
return "target (multiple)";
|
|
276
|
+
}
|
|
277
|
+
if (selected.length === 1) {
|
|
278
|
+
return `target (${shortPeer(selected[0])})`;
|
|
279
|
+
}
|
|
280
|
+
return toPeer.value ? `target (${targetPeerDisplay.value})` : "target (none)";
|
|
281
|
+
});
|
|
282
|
+
const sendRouteTooltip = computed(() => {
|
|
283
|
+
if (chatSendMode.value !== "target") {
|
|
284
|
+
return "";
|
|
285
|
+
}
|
|
286
|
+
if (selectedTargetPeers.value.length > 0) {
|
|
287
|
+
return selectedTargetPeers.value.join("\n");
|
|
288
|
+
}
|
|
289
|
+
return toPeer.value || "";
|
|
290
|
+
});
|
|
291
|
+
const specWarnings = computed(() => {
|
|
292
|
+
const warnings = [];
|
|
293
|
+
const ws = normalizeText(wsUrl.value);
|
|
294
|
+
try {
|
|
295
|
+
const parsed = new URL(ws);
|
|
296
|
+
if (!["ws:", "wss:"].includes(parsed.protocol)) {
|
|
297
|
+
warnings.push("Signaling Server URL must use ws:// or wss://");
|
|
298
|
+
}
|
|
299
|
+
} catch {
|
|
300
|
+
warnings.push("Signaling Server URL must be a valid absolute WebSocket URL");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const topic = normalizeText(network.value);
|
|
304
|
+
if (!topic) {
|
|
305
|
+
warnings.push("Room / Topic is required");
|
|
306
|
+
} else if (!isSpecToken(topic)) {
|
|
307
|
+
warnings.push("Room / Topic should be 1-128 chars using A-Z, a-z, 0-9, . _ : / -");
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const localPeer = normalizePeerId(fromPeer.value);
|
|
311
|
+
if (!isValidPeerId(localPeer)) {
|
|
312
|
+
warnings.push("Your Peer ID should be a token-safe, fingerprint-like ID (no spaces, typically >=16 chars)");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const target = normalizePeerId(toPeer.value);
|
|
316
|
+
if (target && !isValidPeerId(target)) {
|
|
317
|
+
warnings.push("To Peer must be empty or a token-safe, fingerprint-like peer ID");
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const sess = normalizeText(sessionId.value);
|
|
321
|
+
if (!sess) {
|
|
322
|
+
warnings.push("Session ID is required");
|
|
323
|
+
} else if (!isSpecToken(sess)) {
|
|
324
|
+
warnings.push("Session ID should be 1-128 chars using A-Z, a-z, 0-9, . _ : / -");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const inst = normalizeText(instanceId.value);
|
|
328
|
+
if (!inst) {
|
|
329
|
+
warnings.push("Instance ID is required");
|
|
330
|
+
} else if (!isSpecToken(inst)) {
|
|
331
|
+
warnings.push("Instance ID should be 1-128 chars using A-Z, a-z, 0-9, . _ : / -");
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return warnings;
|
|
335
|
+
});
|
|
336
|
+
const specWarningCount = computed(() => specWarnings.value.length);
|
|
337
|
+
const specPillLabel = computed(() => (specLinkFlash.value ? "OK" : "PSP Spec"));
|
|
338
|
+
const pingButtonLabel = computed(() => pingButtonText.value);
|
|
339
|
+
const discoverButtonLabel = computed(() => (discoverFlash.value ? "OK" : "discover"));
|
|
340
|
+
const announceButtonLabel = computed(() => (announceFlash.value ? "OK" : "announce"));
|
|
341
|
+
const meshPeers = computed(() => {
|
|
342
|
+
// Depend on mesh counters so this recomputes when link map state changes.
|
|
343
|
+
void meshTargetCount.value;
|
|
344
|
+
void meshConnectedCount.value;
|
|
345
|
+
const isNegotiatingPhase = (phase) =>
|
|
346
|
+
["requesting", "waiting-offer", "accepted", "offered", "answering", "answered", "connected-pending", "ready"].includes(phase);
|
|
347
|
+
|
|
348
|
+
return getVisiblePeerIds()
|
|
349
|
+
.map((peerId) => {
|
|
350
|
+
const link = getMeshLink(peerId, false);
|
|
351
|
+
const dataStateValue = link?.dc?.readyState ?? link?.dataState ?? "closed";
|
|
352
|
+
const role = fromPeer.value && peerId && fromPeer.value.localeCompare(peerId) < 0 ? "initiator" : "responder";
|
|
353
|
+
const errorState = ["failed", "disconnected"].includes(link?.rtcState) || link?.phase === "rejected";
|
|
354
|
+
const negotiatingState = isNegotiatingPhase(link?.phase);
|
|
355
|
+
const statusTone = dataStateValue === "open" ? "ok" : errorState ? "error" : negotiatingState ? "warn" : "idle";
|
|
356
|
+
const statusLabel = dataStateValue === "open" ? "connected" : errorState ? "error" : negotiatingState ? "negotiating" : "idle";
|
|
357
|
+
return {
|
|
358
|
+
peerId,
|
|
359
|
+
display: shortPeer(peerId),
|
|
360
|
+
selected: selectedTargetPeers.value.includes(peerId),
|
|
361
|
+
phase: link?.phase ?? "idle",
|
|
362
|
+
rtcState: link?.rtcState ?? "new",
|
|
363
|
+
dataState: dataStateValue,
|
|
364
|
+
connected: dataStateValue === "open",
|
|
365
|
+
role,
|
|
366
|
+
statusTone,
|
|
367
|
+
statusLabel
|
|
368
|
+
};
|
|
369
|
+
})
|
|
370
|
+
.sort((left, right) => {
|
|
371
|
+
if (left.selected !== right.selected) {
|
|
372
|
+
return left.selected ? -1 : 1;
|
|
373
|
+
}
|
|
374
|
+
if (left.connected !== right.connected) {
|
|
375
|
+
return left.connected ? -1 : 1;
|
|
376
|
+
}
|
|
377
|
+
return left.peerId.localeCompare(right.peerId);
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
const logsText = computed(() => {
|
|
382
|
+
return logs.value
|
|
383
|
+
.map((entry) => {
|
|
384
|
+
const payload =
|
|
385
|
+
typeof entry.payload === "string"
|
|
386
|
+
? entry.payload
|
|
387
|
+
: JSON.stringify(entry.payload, null, 2);
|
|
388
|
+
return `[${entry.at}] ${entry.label}\n${payload}`;
|
|
389
|
+
})
|
|
390
|
+
.join("\n\n");
|
|
391
|
+
});
|
|
392
|
+
const logCount = computed(() => logs.value.length);
|
|
393
|
+
const logPreviewEntries = computed(() => {
|
|
394
|
+
return logs.value.slice(0, 4).map((entry, index) => {
|
|
395
|
+
let preview = "";
|
|
396
|
+
if (typeof entry.payload === "string") {
|
|
397
|
+
preview = entry.payload;
|
|
398
|
+
} else if (entry.payload && typeof entry.payload === "object") {
|
|
399
|
+
const type = entry.payload.type;
|
|
400
|
+
const from = entry.payload.from ? `from ${shortPeer(entry.payload.from)}` : "";
|
|
401
|
+
const to = entry.payload.to ? `to ${shortPeer(entry.payload.to)}` : "";
|
|
402
|
+
const summary = [type, from, to].filter(Boolean).join(" • ");
|
|
403
|
+
preview = summary || JSON.stringify(entry.payload);
|
|
404
|
+
} else {
|
|
405
|
+
preview = String(entry.payload ?? "");
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
preview = preview.replace(/\s+/g, " ").trim();
|
|
409
|
+
if (preview.length > 92) {
|
|
410
|
+
preview = `${preview.slice(0, 89)}...`;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
id: `${entry.at}-${entry.label}-${index}`,
|
|
415
|
+
at: new Date(entry.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }),
|
|
416
|
+
label: entry.label,
|
|
417
|
+
preview: preview || "(no payload)"
|
|
418
|
+
};
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
const logHeadline = computed(() => {
|
|
422
|
+
if (!logPreviewEntries.value.length) {
|
|
423
|
+
return "No events yet";
|
|
424
|
+
}
|
|
425
|
+
const latest = logPreviewEntries.value[0];
|
|
426
|
+
return `${logCount.value} events • latest ${latest.label} @ ${latest.at}`;
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
function pushLog(label, payload) {
|
|
430
|
+
const line = {
|
|
431
|
+
at: new Date().toISOString(),
|
|
432
|
+
label,
|
|
433
|
+
payload
|
|
434
|
+
};
|
|
435
|
+
logs.value.unshift(line);
|
|
436
|
+
if (logs.value.length > 200) {
|
|
437
|
+
logs.value.length = 200;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function handleSpecLinkClick() {
|
|
442
|
+
specLinkFlash.value = true;
|
|
443
|
+
if (specLinkFlashTimer) {
|
|
444
|
+
clearTimeout(specLinkFlashTimer);
|
|
445
|
+
}
|
|
446
|
+
specLinkFlashTimer = setTimeout(() => {
|
|
447
|
+
specLinkFlash.value = false;
|
|
448
|
+
specLinkFlashTimer = null;
|
|
449
|
+
}, 900);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function flashPingButton() {
|
|
453
|
+
if (pingFlashTimer) {
|
|
454
|
+
clearTimeout(pingFlashTimer);
|
|
455
|
+
}
|
|
456
|
+
pingFlash.value = true;
|
|
457
|
+
pingFlashTimer = setTimeout(() => {
|
|
458
|
+
pingFlash.value = false;
|
|
459
|
+
pingButtonText.value = "ping";
|
|
460
|
+
pingFlashTimer = null;
|
|
461
|
+
}, 1200);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function flashDiscoverButton() {
|
|
465
|
+
discoverFlash.value = true;
|
|
466
|
+
if (discoverFlashTimer) {
|
|
467
|
+
clearTimeout(discoverFlashTimer);
|
|
468
|
+
}
|
|
469
|
+
discoverFlashTimer = setTimeout(() => {
|
|
470
|
+
discoverFlash.value = false;
|
|
471
|
+
discoverFlashTimer = null;
|
|
472
|
+
}, 700);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function flashAnnounceButton() {
|
|
476
|
+
announceFlash.value = true;
|
|
477
|
+
if (announceFlashTimer) {
|
|
478
|
+
clearTimeout(announceFlashTimer);
|
|
479
|
+
}
|
|
480
|
+
announceFlashTimer = setTimeout(() => {
|
|
481
|
+
announceFlash.value = false;
|
|
482
|
+
announceFlashTimer = null;
|
|
483
|
+
}, 700);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function pushChatMessage(text, kind = "system") {
|
|
487
|
+
chatLog.value.push({
|
|
488
|
+
id: newId("chat"),
|
|
489
|
+
text,
|
|
490
|
+
kind,
|
|
491
|
+
at: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
|
492
|
+
});
|
|
493
|
+
if (chatLog.value.length > 120) {
|
|
494
|
+
chatLog.value.shift();
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function scrollChatToLatest() {
|
|
499
|
+
nextTick(() => {
|
|
500
|
+
const el = chatThreadRef.value;
|
|
501
|
+
if (!el) {
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
el.scrollTop = el.scrollHeight;
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
watch(
|
|
509
|
+
() => chatMessages.value.map((entry) => entry.id).join(","),
|
|
510
|
+
() => {
|
|
511
|
+
scrollChatToLatest();
|
|
512
|
+
}
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
function shortPeer(peerId) {
|
|
516
|
+
const peer = normalizePeerId(peerId);
|
|
517
|
+
if (!peer) {
|
|
518
|
+
return "none";
|
|
519
|
+
}
|
|
520
|
+
if (peer.length <= 22) {
|
|
521
|
+
return peer;
|
|
522
|
+
}
|
|
523
|
+
return `${peer.slice(0, 8)}...${peer.slice(-8)}`;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function getMeshLink(peerId, create = true) {
|
|
527
|
+
if (!peerId) {
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
let link = meshLinks.get(peerId);
|
|
531
|
+
if (!link && create) {
|
|
532
|
+
link = {
|
|
533
|
+
pc: null,
|
|
534
|
+
dc: null,
|
|
535
|
+
phase: "idle",
|
|
536
|
+
rtcState: "new",
|
|
537
|
+
dataState: "closed",
|
|
538
|
+
connectRequested: false,
|
|
539
|
+
connectRequestTime: 0,
|
|
540
|
+
offerWaitTime: 0, // Tracks when responder started waiting for offer
|
|
541
|
+
answerWaitTime: 0, // Tracks when initiator started waiting for answer
|
|
542
|
+
pendingCandidates: [],
|
|
543
|
+
lastSignalAt: now()
|
|
544
|
+
};
|
|
545
|
+
meshLinks.set(peerId, link);
|
|
546
|
+
refreshMeshStats();
|
|
547
|
+
}
|
|
548
|
+
return link || null;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function isNegotiatingPhase(phase) {
|
|
552
|
+
return ["requesting", "waiting-offer", "accepted", "offering", "offered", "answering", "answered", "connected-pending", "ready"].includes(phase);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function shouldKeepLinkWithoutAnnouncement(link) {
|
|
556
|
+
if (!link) {
|
|
557
|
+
return false;
|
|
558
|
+
}
|
|
559
|
+
// Guard against stale browser state where DC reports open after PC failed.
|
|
560
|
+
if (["failed", "closed"].includes(link.rtcState) && link.dc?.readyState === "open") {
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
if (link.dc?.readyState === "open") {
|
|
564
|
+
return true;
|
|
565
|
+
}
|
|
566
|
+
if (isNegotiatingPhase(link.phase)) {
|
|
567
|
+
return true;
|
|
568
|
+
}
|
|
569
|
+
const lastSignalAt = Number(link.lastSignalAt || 0);
|
|
570
|
+
return lastSignalAt > 0 && now() - lastSignalAt <= LINK_GRACE_MS;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function flushPendingIceCandidates(peerId, link) {
|
|
574
|
+
const target = link || getMeshLink(peerId, false);
|
|
575
|
+
if (!target?.pc || !target.pc.remoteDescription) {
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
const queued = Array.isArray(target.pendingCandidates) ? target.pendingCandidates : [];
|
|
579
|
+
if (queued.length === 0) {
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
target.pendingCandidates = [];
|
|
583
|
+
for (const candidate of queued) {
|
|
584
|
+
try {
|
|
585
|
+
await target.pc.addIceCandidate(candidate);
|
|
586
|
+
} catch (error) {
|
|
587
|
+
pushLog("rtc:ice-error", error?.message || `failed to apply queued candidate from ${peerId}`);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function refreshMeshStats() {
|
|
593
|
+
// Count only local WebRTC links with an open DataChannel.
|
|
594
|
+
const openPeerIds = getVisiblePeerIds();
|
|
595
|
+
meshConnectedCount.value = openPeerIds.length;
|
|
596
|
+
meshTargetCount.value = openPeerIds.length;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function getVisiblePeerIds() {
|
|
600
|
+
const visible = new Set();
|
|
601
|
+
for (const [peerId, link] of meshLinks.entries()) {
|
|
602
|
+
if (!peerId || peerId === fromPeer.value) {
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
if (
|
|
606
|
+
link?.dc?.readyState === "open" &&
|
|
607
|
+
!["failed", "closed", "disconnected"].includes(link?.rtcState)
|
|
608
|
+
) {
|
|
609
|
+
visible.add(peerId);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
return Array.from(visible.values());
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function selectedPeerId() {
|
|
617
|
+
// toPeer must point to an announced peer or be empty.
|
|
618
|
+
if (toPeer.value) {
|
|
619
|
+
const isAnnounced = discoveredPeers.value.some((p) => p?.peer_id === toPeer.value);
|
|
620
|
+
if (isAnnounced) {
|
|
621
|
+
return toPeer.value;
|
|
622
|
+
}
|
|
623
|
+
// Stale peer reference; clear it.
|
|
624
|
+
toPeer.value = "";
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// If there is an active open link, prefer it for panel status.
|
|
628
|
+
for (const [peerId, link] of meshLinks.entries()) {
|
|
629
|
+
if (!peerId || peerId === fromPeer.value) {
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
if (link?.dc?.readyState === "open") {
|
|
633
|
+
return peerId;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Otherwise pick the first known non-self mesh link before falling back to peer_list.
|
|
638
|
+
for (const peerId of meshLinks.keys()) {
|
|
639
|
+
if (peerId && peerId !== fromPeer.value) {
|
|
640
|
+
return peerId;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (discoveredPeers.value.length > 0) {
|
|
645
|
+
return discoveredPeers.value[0].peer_id;
|
|
646
|
+
}
|
|
647
|
+
return "";
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function refreshSelectedPeerSnapshot() {
|
|
651
|
+
const peerId = selectedPeerId();
|
|
652
|
+
if (!peerId) {
|
|
653
|
+
rtcPhase.value = "idle";
|
|
654
|
+
rtcState.value = "new";
|
|
655
|
+
dataState.value = "closed";
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const link = getMeshLink(peerId, false);
|
|
660
|
+
if (!link) {
|
|
661
|
+
rtcPhase.value = "idle";
|
|
662
|
+
rtcState.value = "new";
|
|
663
|
+
dataState.value = "closed";
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
rtcPhase.value = link.phase;
|
|
668
|
+
rtcState.value = link.rtcState;
|
|
669
|
+
dataState.value = link.dataState;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function initSharedIds() {
|
|
673
|
+
const params = new URLSearchParams(window.location.search);
|
|
674
|
+
const sessionParam = normalizeText(params.get("session_id") || params.get("sessionId"));
|
|
675
|
+
const instanceParam = normalizeText(params.get("instance_id") || params.get("instanceId"));
|
|
676
|
+
|
|
677
|
+
const hasValidUrlSession = isSpecToken(sessionParam);
|
|
678
|
+
const hasValidUrlInstance = isSpecToken(instanceParam);
|
|
679
|
+
|
|
680
|
+
if (sessionParam && !hasValidUrlSession) {
|
|
681
|
+
pushLog("ids:error", "Ignoring invalid URL session_id");
|
|
682
|
+
}
|
|
683
|
+
if (instanceParam && !hasValidUrlInstance) {
|
|
684
|
+
pushLog("ids:error", "Ignoring invalid URL instance_id");
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
sessionId.value = hasValidUrlSession ? sessionParam : `sess-${Math.random().toString(36).slice(2, 10)}`;
|
|
688
|
+
instanceId.value = hasValidUrlInstance ? instanceParam : `inst-${Math.random().toString(36).slice(2, 10)}`;
|
|
689
|
+
persistSharedIds();
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function persistSharedIds() {
|
|
693
|
+
localStorage.setItem(
|
|
694
|
+
SHARED_IDS_KEY,
|
|
695
|
+
JSON.stringify({
|
|
696
|
+
session_id: sessionId.value,
|
|
697
|
+
instance_id: instanceId.value
|
|
698
|
+
})
|
|
699
|
+
);
|
|
700
|
+
|
|
701
|
+
const url = new URL(window.location.href);
|
|
702
|
+
url.searchParams.set("session_id", sessionId.value);
|
|
703
|
+
url.searchParams.set("instance_id", instanceId.value);
|
|
704
|
+
window.history.replaceState(null, "", url);
|
|
705
|
+
|
|
706
|
+
pushLog("ids", {
|
|
707
|
+
session_id: sessionId.value,
|
|
708
|
+
instance_id: instanceId.value
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function normalizeMeshLimit(value) {
|
|
713
|
+
const parsed = Number(value);
|
|
714
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
715
|
+
return 4;
|
|
716
|
+
}
|
|
717
|
+
return Math.max(1, Math.min(99, Math.floor(parsed)));
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function persistUiPrefs() {
|
|
721
|
+
try {
|
|
722
|
+
sessionStorage.setItem(
|
|
723
|
+
UI_PREFS_KEY,
|
|
724
|
+
JSON.stringify({
|
|
725
|
+
ws_url: normalizedWsUrlValue(),
|
|
726
|
+
network: normalizedNetworkValue(),
|
|
727
|
+
partial_mesh: Boolean(partialMesh.value),
|
|
728
|
+
partial_mesh_max_peers: normalizeMeshLimit(partialMeshMaxPeers.value),
|
|
729
|
+
chat_send_mode: chatSendMode.value === "target" ? "target" : "broadcast",
|
|
730
|
+
active_view: activeView.value === "console" ? "console" : "webrtc"
|
|
731
|
+
})
|
|
732
|
+
);
|
|
733
|
+
} catch {
|
|
734
|
+
// Ignore storage write errors (private mode/quota).
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function loadUiPrefs() {
|
|
739
|
+
try {
|
|
740
|
+
const raw = sessionStorage.getItem(UI_PREFS_KEY);
|
|
741
|
+
if (!raw) {
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
const saved = safeJsonParse(raw, null);
|
|
745
|
+
if (!saved || typeof saved !== "object") {
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const savedWs = normalizeText(saved.ws_url);
|
|
750
|
+
if (savedWs) {
|
|
751
|
+
wsUrl.value = savedWs;
|
|
752
|
+
appliedWsUrl.value = savedWs;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const savedNetwork = normalizeText(saved.network);
|
|
756
|
+
if (savedNetwork) {
|
|
757
|
+
network.value = savedNetwork;
|
|
758
|
+
appliedNetwork.value = savedNetwork;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (typeof saved.partial_mesh === "boolean") {
|
|
762
|
+
partialMesh.value = saved.partial_mesh;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
partialMeshMaxPeers.value = normalizeMeshLimit(saved.partial_mesh_max_peers);
|
|
766
|
+
|
|
767
|
+
if (saved.chat_send_mode === "target" || saved.chat_send_mode === "broadcast") {
|
|
768
|
+
chatSendMode.value = saved.chat_send_mode;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
if (saved.active_view === "console" || saved.active_view === "webrtc") {
|
|
772
|
+
activeView.value = saved.active_view;
|
|
773
|
+
}
|
|
774
|
+
} catch {
|
|
775
|
+
// Ignore storage read/parse errors.
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function regenerateSharedIds() {
|
|
780
|
+
sessionId.value = `sess-${Math.random().toString(36).slice(2, 10)}`;
|
|
781
|
+
instanceId.value = `inst-${Math.random().toString(36).slice(2, 10)}`;
|
|
782
|
+
persistSharedIds();
|
|
783
|
+
pushLog("ids", "Generated new shared session_id and instance_id");
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function regeneratePeerId() {
|
|
787
|
+
fromPeer.value = randomHex();
|
|
788
|
+
appliedFromPeer.value = fromPeer.value;
|
|
789
|
+
toPeer.value = "";
|
|
790
|
+
stopRtc();
|
|
791
|
+
pushLog("ids", { peer_id: fromPeer.value });
|
|
792
|
+
|
|
793
|
+
if (isConnected.value) {
|
|
794
|
+
sendAnnounce({ feedback: false });
|
|
795
|
+
sendDiscover({ feedback: false });
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function applyNetworkChange() {
|
|
800
|
+
const next = normalizeText(network.value) || "room:test";
|
|
801
|
+
const changed = next !== appliedNetwork.value;
|
|
802
|
+
network.value = next;
|
|
803
|
+
|
|
804
|
+
if (!changed) {
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
appliedNetwork.value = next;
|
|
808
|
+
|
|
809
|
+
reconnectLockedByBye = false;
|
|
810
|
+
byeCooldowns.clear();
|
|
811
|
+
failedPeerCooldowns.clear();
|
|
812
|
+
toPeer.value = "";
|
|
813
|
+
discoveredPeers.value = [];
|
|
814
|
+
|
|
815
|
+
stopAutomation();
|
|
816
|
+
stopRtc();
|
|
817
|
+
meshLinks.clear();
|
|
818
|
+
refreshMeshStats();
|
|
819
|
+
refreshSelectedPeerSnapshot();
|
|
820
|
+
pushLog("network", `Switched to ${network.value}`);
|
|
821
|
+
|
|
822
|
+
if (isConnected.value) {
|
|
823
|
+
void beginRtc();
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function applyWsUrlChange() {
|
|
828
|
+
const next = normalizeText(wsUrl.value);
|
|
829
|
+
const resolved = next || `${wsScheme}://${host}/ws`;
|
|
830
|
+
const changed = resolved !== appliedWsUrl.value;
|
|
831
|
+
wsUrl.value = resolved;
|
|
832
|
+
|
|
833
|
+
if (!changed) {
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
appliedWsUrl.value = resolved;
|
|
837
|
+
|
|
838
|
+
pushLog("socket", `Updated signaling URL: ${wsUrl.value}`);
|
|
839
|
+
if (socket.value && (socket.value.readyState === WebSocket.CONNECTING || socket.value.readyState === WebSocket.OPEN)) {
|
|
840
|
+
disconnect();
|
|
841
|
+
manualDisconnect = false;
|
|
842
|
+
connect();
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function applyFromPeerChange() {
|
|
847
|
+
const previous = appliedFromPeer.value;
|
|
848
|
+
const next = normalizePeerId(fromPeer.value);
|
|
849
|
+
if (!next) {
|
|
850
|
+
fromPeer.value = previous;
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
if (!isValidPeerId(next)) {
|
|
855
|
+
pushLog("ids:error", "Peer ID must be token-safe with no spaces and usually >=16 chars");
|
|
856
|
+
fromPeer.value = previous;
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
if (next === previous) {
|
|
861
|
+
fromPeer.value = previous;
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
fromPeer.value = next;
|
|
866
|
+
appliedFromPeer.value = next;
|
|
867
|
+
reconnectLockedByBye = false;
|
|
868
|
+
byeCooldowns.clear();
|
|
869
|
+
failedPeerCooldowns.clear();
|
|
870
|
+
toPeer.value = "";
|
|
871
|
+
discoveredPeers.value = [];
|
|
872
|
+
|
|
873
|
+
stopAutomation();
|
|
874
|
+
stopRtc();
|
|
875
|
+
meshLinks.clear();
|
|
876
|
+
refreshMeshStats();
|
|
877
|
+
refreshSelectedPeerSnapshot();
|
|
878
|
+
pushLog("ids", { peer_id: fromPeer.value });
|
|
879
|
+
|
|
880
|
+
if (isConnected.value) {
|
|
881
|
+
void beginRtc();
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function sendEnvelope(envelope) {
|
|
886
|
+
if (!socket.value || socket.value.readyState !== WebSocket.OPEN) {
|
|
887
|
+
pushLog("client:error", "Socket is not connected");
|
|
888
|
+
return false;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// Keep network token stable even if the editable field has trailing spaces.
|
|
892
|
+
const stableNetwork = normalizedNetworkValue();
|
|
893
|
+
if (stableNetwork !== network.value) {
|
|
894
|
+
network.value = stableNetwork;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const outbound = {
|
|
898
|
+
...envelope,
|
|
899
|
+
network: stableNetwork
|
|
900
|
+
};
|
|
901
|
+
|
|
902
|
+
socket.value.send(JSON.stringify(outbound));
|
|
903
|
+
pushLog("client:send", outbound);
|
|
904
|
+
return true;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function sendRelayEnvelope(type, body, override = {}) {
|
|
908
|
+
const hasExplicitTarget = Object.prototype.hasOwnProperty.call(override, "to");
|
|
909
|
+
const target = hasExplicitTarget ? override.to : toPeer.value || null;
|
|
910
|
+
|
|
911
|
+
if (!target && !hasExplicitTarget) {
|
|
912
|
+
pushLog("client:error", "No target peer selected yet");
|
|
913
|
+
return false;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
return sendEnvelope({
|
|
917
|
+
psp_version: PSP_VERSION,
|
|
918
|
+
type,
|
|
919
|
+
network: network.value,
|
|
920
|
+
from: fromPeer.value,
|
|
921
|
+
to: target,
|
|
922
|
+
session_id: sessionId.value,
|
|
923
|
+
message_id: newId(type),
|
|
924
|
+
timestamp: now(),
|
|
925
|
+
ttl_ms: 30000,
|
|
926
|
+
body,
|
|
927
|
+
...override
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function connect() {
|
|
932
|
+
manualDisconnect = false;
|
|
933
|
+
if (reconnectTimer) {
|
|
934
|
+
clearTimeout(reconnectTimer);
|
|
935
|
+
reconnectTimer = null;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const existing = socket.value;
|
|
939
|
+
if (existing && (existing.readyState === WebSocket.CONNECTING || existing.readyState === WebSocket.OPEN)) {
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
if (!canConnect.value) {
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
if (existing && (existing.readyState === WebSocket.CLOSING || existing.readyState === WebSocket.CLOSED)) {
|
|
947
|
+
socket.value = null;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
status.value = "connecting";
|
|
951
|
+
const resolvedWsUrl = normalizedWsUrlValue();
|
|
952
|
+
if (resolvedWsUrl !== wsUrl.value) {
|
|
953
|
+
wsUrl.value = resolvedWsUrl;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
let ws;
|
|
957
|
+
try {
|
|
958
|
+
ws = new WebSocket(resolvedWsUrl);
|
|
959
|
+
} catch (error) {
|
|
960
|
+
status.value = "disconnected";
|
|
961
|
+
socket.value = null;
|
|
962
|
+
pushLog("socket:error", error?.message || "failed to create websocket");
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
socket.value = ws;
|
|
966
|
+
|
|
967
|
+
ws.onopen = () => {
|
|
968
|
+
status.value = "connected";
|
|
969
|
+
pushLog("socket", `connected to ${resolvedWsUrl}`);
|
|
970
|
+
// Auto-start handshake on connect
|
|
971
|
+
void beginRtc();
|
|
972
|
+
};
|
|
973
|
+
|
|
974
|
+
ws.onmessage = (event) => {
|
|
975
|
+
try {
|
|
976
|
+
const msg = JSON.parse(event.data);
|
|
977
|
+
pushLog("server:recv", msg);
|
|
978
|
+
void onServerEnvelope(msg);
|
|
979
|
+
} catch {
|
|
980
|
+
pushLog("server:recv", event.data);
|
|
981
|
+
}
|
|
982
|
+
};
|
|
983
|
+
|
|
984
|
+
ws.onerror = () => {
|
|
985
|
+
pushLog("socket:error", "WebSocket error");
|
|
986
|
+
if (ws.readyState !== WebSocket.OPEN) {
|
|
987
|
+
status.value = "disconnected";
|
|
988
|
+
}
|
|
989
|
+
};
|
|
990
|
+
|
|
991
|
+
ws.onclose = () => {
|
|
992
|
+
status.value = "disconnected";
|
|
993
|
+
socket.value = null;
|
|
994
|
+
pendingPingStartedAt = 0;
|
|
995
|
+
pingButtonText.value = "ping";
|
|
996
|
+
pingFlash.value = false;
|
|
997
|
+
stopAutoLoop();
|
|
998
|
+
stopRtc();
|
|
999
|
+
pushLog("socket", "closed");
|
|
1000
|
+
|
|
1001
|
+
// Stay fully hands-off: reconnect automatically unless user explicitly disconnected.
|
|
1002
|
+
if (!manualDisconnect) {
|
|
1003
|
+
reconnectTimer = setTimeout(() => {
|
|
1004
|
+
reconnectTimer = null;
|
|
1005
|
+
if (!socket.value) {
|
|
1006
|
+
connect();
|
|
1007
|
+
}
|
|
1008
|
+
}, 1200);
|
|
1009
|
+
}
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function disconnect() {
|
|
1014
|
+
manualDisconnect = true;
|
|
1015
|
+
if (reconnectTimer) {
|
|
1016
|
+
clearTimeout(reconnectTimer);
|
|
1017
|
+
reconnectTimer = null;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
if (!socket.value) {
|
|
1021
|
+
status.value = "disconnected";
|
|
1022
|
+
stopAutoLoop();
|
|
1023
|
+
stopRtc();
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
const ws = socket.value;
|
|
1027
|
+
if (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN) {
|
|
1028
|
+
ws.close(1000, "client requested close");
|
|
1029
|
+
}
|
|
1030
|
+
socket.value = null;
|
|
1031
|
+
status.value = "disconnected";
|
|
1032
|
+
stopAutoLoop();
|
|
1033
|
+
stopRtc();
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function sendAnnounce(options = {}) {
|
|
1037
|
+
const { feedback = true } = options;
|
|
1038
|
+
const sent = sendEnvelope({
|
|
1039
|
+
psp_version: PSP_VERSION,
|
|
1040
|
+
type: "announce",
|
|
1041
|
+
network: network.value,
|
|
1042
|
+
from: fromPeer.value,
|
|
1043
|
+
to: null,
|
|
1044
|
+
session_id: sessionId.value,
|
|
1045
|
+
message_id: newId("announce"),
|
|
1046
|
+
timestamp: now(),
|
|
1047
|
+
ttl_ms: 30000,
|
|
1048
|
+
body: {
|
|
1049
|
+
instance_id: instanceId.value,
|
|
1050
|
+
roles: ["peer"],
|
|
1051
|
+
capabilities: {
|
|
1052
|
+
trickle_ice: true,
|
|
1053
|
+
datachannel: true,
|
|
1054
|
+
media: false
|
|
1055
|
+
},
|
|
1056
|
+
hints: {
|
|
1057
|
+
wants_peers: true,
|
|
1058
|
+
max_peers: partialMesh.value ? effectivePartialMeshLimit() : null
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
});
|
|
1062
|
+
if (sent && feedback) {
|
|
1063
|
+
flashAnnounceButton();
|
|
1064
|
+
}
|
|
1065
|
+
return sent;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function sendWithdraw() {
|
|
1069
|
+
sendEnvelope({
|
|
1070
|
+
psp_version: PSP_VERSION,
|
|
1071
|
+
type: "withdraw",
|
|
1072
|
+
network: network.value,
|
|
1073
|
+
from: fromPeer.value,
|
|
1074
|
+
to: null,
|
|
1075
|
+
session_id: null,
|
|
1076
|
+
message_id: newId("withdraw"),
|
|
1077
|
+
timestamp: now(),
|
|
1078
|
+
ttl_ms: 30000,
|
|
1079
|
+
body: {
|
|
1080
|
+
reason: "peer_offline"
|
|
1081
|
+
}
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function sendDiscover(options = {}) {
|
|
1086
|
+
const { feedback = true } = options;
|
|
1087
|
+
const sent = sendEnvelope({
|
|
1088
|
+
psp_version: PSP_VERSION,
|
|
1089
|
+
type: "discover",
|
|
1090
|
+
network: network.value,
|
|
1091
|
+
from: fromPeer.value,
|
|
1092
|
+
to: null,
|
|
1093
|
+
session_id: null,
|
|
1094
|
+
message_id: newId("discover"),
|
|
1095
|
+
timestamp: now(),
|
|
1096
|
+
ttl_ms: 30000,
|
|
1097
|
+
body: {
|
|
1098
|
+
limit: 16,
|
|
1099
|
+
exclude_peers: [fromPeer.value]
|
|
1100
|
+
}
|
|
1101
|
+
});
|
|
1102
|
+
if (sent && feedback) {
|
|
1103
|
+
flashDiscoverButton();
|
|
1104
|
+
}
|
|
1105
|
+
return sent;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
function isMatchingSessionId(value) {
|
|
1109
|
+
return normalizeText(value) === normalizeText(sessionId.value);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
function sendPing(peerId = toPeer.value || null) {
|
|
1113
|
+
if (!peerId) {
|
|
1114
|
+
return false;
|
|
1115
|
+
}
|
|
1116
|
+
pendingPingStartedAt = performance.now();
|
|
1117
|
+
pingButtonText.value = "...";
|
|
1118
|
+
pingFlash.value = true;
|
|
1119
|
+
if (pingFlashTimer) {
|
|
1120
|
+
clearTimeout(pingFlashTimer);
|
|
1121
|
+
pingFlashTimer = null;
|
|
1122
|
+
}
|
|
1123
|
+
const sent = sendEnvelope({
|
|
1124
|
+
psp_version: PSP_VERSION,
|
|
1125
|
+
type: "ping",
|
|
1126
|
+
network: network.value,
|
|
1127
|
+
from: fromPeer.value,
|
|
1128
|
+
to: peerId,
|
|
1129
|
+
session_id: null,
|
|
1130
|
+
message_id: newId("ping"),
|
|
1131
|
+
timestamp: now(),
|
|
1132
|
+
ttl_ms: 30000,
|
|
1133
|
+
body: {
|
|
1134
|
+
nonce: Math.random().toString(36).slice(2)
|
|
1135
|
+
}
|
|
1136
|
+
});
|
|
1137
|
+
if (!sent) {
|
|
1138
|
+
pendingPingStartedAt = 0;
|
|
1139
|
+
pingButtonText.value = "ping";
|
|
1140
|
+
pingFlash.value = false;
|
|
1141
|
+
}
|
|
1142
|
+
return sent;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function sendRelay() {
|
|
1146
|
+
let body;
|
|
1147
|
+
try {
|
|
1148
|
+
body = JSON.parse(relayBody.value);
|
|
1149
|
+
} catch {
|
|
1150
|
+
pushLog("client:error", "Relay body must be valid JSON");
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// Manual relay defaults to broadcast when no target peer is selected.
|
|
1155
|
+
const relayTargetPeerId = normalizePeerId(toPeer.value);
|
|
1156
|
+
toPeer.value = relayTargetPeerId;
|
|
1157
|
+
sendRelayEnvelope(relayType.value, body, { to: relayTargetPeerId || null });
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function isPeerCoolingDown(peerId) {
|
|
1161
|
+
const expiresAt = failedPeerCooldowns.get(peerId);
|
|
1162
|
+
if (!expiresAt) {
|
|
1163
|
+
return false;
|
|
1164
|
+
}
|
|
1165
|
+
if (expiresAt <= now()) {
|
|
1166
|
+
failedPeerCooldowns.delete(peerId);
|
|
1167
|
+
return false;
|
|
1168
|
+
}
|
|
1169
|
+
return true;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
function isPeerInByeCooldown(peerId) {
|
|
1173
|
+
const expiresAt = byeCooldowns.get(peerId);
|
|
1174
|
+
if (!expiresAt) {
|
|
1175
|
+
return false;
|
|
1176
|
+
}
|
|
1177
|
+
if (expiresAt <= now()) {
|
|
1178
|
+
byeCooldowns.delete(peerId);
|
|
1179
|
+
return false;
|
|
1180
|
+
}
|
|
1181
|
+
return true;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function markPeerFailed(peerId, reason) {
|
|
1185
|
+
if (!peerId) {
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
failedPeerCooldowns.set(peerId, now() + FAILED_PEER_COOLDOWN_MS);
|
|
1189
|
+
pushLog("rtc", `Peer cooldown (${reason}): ${peerId}`);
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function markPeerBye(peerId, reason) {
|
|
1193
|
+
if (!peerId) {
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
byeCooldowns.set(peerId, now() + BYE_COOLDOWN_MS);
|
|
1197
|
+
pushLog("rtc", `Peer hold (${reason}): ${peerId}`);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
function isFreshPeerAnnouncement(peer) {
|
|
1201
|
+
const updated = Number(peer?.timestamp || 0);
|
|
1202
|
+
if (!Number.isFinite(updated) || updated <= 0) {
|
|
1203
|
+
return false;
|
|
1204
|
+
}
|
|
1205
|
+
return now() - updated <= LIVE_PEER_MAX_AGE_MS;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
function failoverFromCurrentPeer(peerId, reason) {
|
|
1209
|
+
if (!peerId) {
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
markPeerFailed(peerId, reason);
|
|
1213
|
+
closeMeshLink(peerId);
|
|
1214
|
+
if (toPeer.value === peerId) {
|
|
1215
|
+
toPeer.value = "";
|
|
1216
|
+
}
|
|
1217
|
+
selectNextPeerAndConnect(reason);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function selectNextPeerAndConnect(reason = "fallback") {
|
|
1221
|
+
const selected = choosePeerFromList(discoveredPeers.value);
|
|
1222
|
+
if (selected) {
|
|
1223
|
+
selectPeer(selected.peer_id);
|
|
1224
|
+
maybeAutoConnectPeer(selected.peer_id);
|
|
1225
|
+
return true;
|
|
1226
|
+
}
|
|
1227
|
+
pushLog("rtc", `No available peer (${reason}); requesting discover`);
|
|
1228
|
+
sendDiscover();
|
|
1229
|
+
return false;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
function choosePeerFromList(peers) {
|
|
1233
|
+
if (!Array.isArray(peers)) {
|
|
1234
|
+
return null;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
const candidates = peers
|
|
1238
|
+
.filter((peer) => peer && typeof peer.peer_id === "string" && peer.peer_id !== fromPeer.value)
|
|
1239
|
+
.sort((a, b) => a.peer_id.localeCompare(b.peer_id));
|
|
1240
|
+
|
|
1241
|
+
const readyCandidates = candidates.filter((peer) => !isPeerCoolingDown(peer.peer_id) && !isPeerInByeCooldown(peer.peer_id));
|
|
1242
|
+
if (readyCandidates.length > 0) {
|
|
1243
|
+
// Prefer peers we can initiate with, to avoid waiting forever for a remote offer.
|
|
1244
|
+
const initiatorPreferred = readyCandidates.filter((peer) => fromPeer.value.localeCompare(peer.peer_id) < 0);
|
|
1245
|
+
if (initiatorPreferred.length > 0) {
|
|
1246
|
+
return initiatorPreferred[0];
|
|
1247
|
+
}
|
|
1248
|
+
return readyCandidates[0];
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// All peers are in cooldown; wait for cooldown expiry instead of immediate re-trying a known busy peer.
|
|
1252
|
+
return null;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
function selectPeer(peerId, options = {}) {
|
|
1256
|
+
const { manual = false, allowUnannounced = false } = options;
|
|
1257
|
+
|
|
1258
|
+
// Only allow selecting announced peers to prevent phantom links.
|
|
1259
|
+
const isAnnounced = discoveredPeers.value.some((p) => p?.peer_id === peerId);
|
|
1260
|
+
if (!isAnnounced && !allowUnannounced) {
|
|
1261
|
+
pushLog("discovery:error", `Cannot select unannounced peer: ${peerId}`);
|
|
1262
|
+
toPeer.value = "";
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
toPeer.value = peerId;
|
|
1267
|
+
if (manual && !reconnectLockedByBye) {
|
|
1268
|
+
byeCooldowns.delete(peerId);
|
|
1269
|
+
}
|
|
1270
|
+
getMeshLink(peerId);
|
|
1271
|
+
refreshSelectedPeerSnapshot();
|
|
1272
|
+
pushLog("discovery", `Selected target peer: ${peerId}`);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
function selectPeerFromUi(peerId) {
|
|
1276
|
+
if (activeView.value === "console") {
|
|
1277
|
+
// In console mode, clicking a peer should always update relay target.
|
|
1278
|
+
selectPeer(peerId, { manual: true, allowUnannounced: true });
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// Clicking mesh rows toggles multi-target chat routing.
|
|
1283
|
+
chatSendMode.value = "target";
|
|
1284
|
+
|
|
1285
|
+
const idx = selectedTargetPeers.value.indexOf(peerId);
|
|
1286
|
+
if (idx >= 0) {
|
|
1287
|
+
const next = [...selectedTargetPeers.value];
|
|
1288
|
+
next.splice(idx, 1);
|
|
1289
|
+
selectedTargetPeers.value = next;
|
|
1290
|
+
if (toPeer.value === peerId) {
|
|
1291
|
+
toPeer.value = next[0] || "";
|
|
1292
|
+
refreshSelectedPeerSnapshot();
|
|
1293
|
+
}
|
|
1294
|
+
pushLog("discovery", `Unselected target peer: ${peerId}`);
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
selectedTargetPeers.value = [...selectedTargetPeers.value, peerId];
|
|
1299
|
+
selectPeer(peerId, { manual: true, allowUnannounced: true });
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
function startAutoLoop() {
|
|
1303
|
+
stopAutoLoop();
|
|
1304
|
+
|
|
1305
|
+
// Send announce immediately on join.
|
|
1306
|
+
if (isConnected.value) {
|
|
1307
|
+
sendAnnounce({ feedback: false });
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// Periodic connection management: pings, auto-connect, stale pruning, and TTL keep-alive.
|
|
1311
|
+
discoveryTimer = setInterval(() => {
|
|
1312
|
+
if (!isConnected.value) {
|
|
1313
|
+
return;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
// Keep discovery state bounded so stale peers cannot accumulate forever.
|
|
1317
|
+
const dedupedPeers = new Map();
|
|
1318
|
+
for (const peer of discoveredPeers.value) {
|
|
1319
|
+
const peerId = peer?.peer_id;
|
|
1320
|
+
if (
|
|
1321
|
+
typeof peerId !== "string" ||
|
|
1322
|
+
peerId === fromPeer.value ||
|
|
1323
|
+
!isValidPeerId(peerId) ||
|
|
1324
|
+
!isFreshPeerAnnouncement(peer) ||
|
|
1325
|
+
!isMatchingSessionId(peer?.session_id)
|
|
1326
|
+
) {
|
|
1327
|
+
continue;
|
|
1328
|
+
}
|
|
1329
|
+
dedupedPeers.set(peerId, peer);
|
|
1330
|
+
}
|
|
1331
|
+
discoveredPeers.value = Array.from(dedupedPeers.values());
|
|
1332
|
+
const announcedPeerIds = new Set(dedupedPeers.keys());
|
|
1333
|
+
|
|
1334
|
+
for (const peerId of Array.from(meshLinks.keys())) {
|
|
1335
|
+
if (!peerId || peerId === fromPeer.value) continue;
|
|
1336
|
+
const link = getMeshLink(peerId, false);
|
|
1337
|
+
// If peer is not currently announced, keep links that are active/recent.
|
|
1338
|
+
if (!announcedPeerIds.has(peerId)) {
|
|
1339
|
+
if (shouldKeepLinkWithoutAnnouncement(link)) {
|
|
1340
|
+
continue;
|
|
1341
|
+
}
|
|
1342
|
+
closeMeshLink(peerId);
|
|
1343
|
+
meshLinks.delete(peerId);
|
|
1344
|
+
continue;
|
|
1345
|
+
}
|
|
1346
|
+
// Keep healthy or recently-active links for announced peers too.
|
|
1347
|
+
// Otherwise the periodic loop can tear down valid channels and cause flap/reconnect churn.
|
|
1348
|
+
if (shouldKeepLinkWithoutAnnouncement(link)) {
|
|
1349
|
+
continue;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// Prune idle/dead links for still-announced peers.
|
|
1353
|
+
closeMeshLink(peerId);
|
|
1354
|
+
meshLinks.delete(peerId);
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
// Re-announce every 3s to keep D1 TTL alive. Server suppresses peer_list broadcasts
|
|
1358
|
+
// for heartbeat re-announces, so this is cheap and ensures continuous peer discovery.
|
|
1359
|
+
sendAnnounce({ feedback: false });
|
|
1360
|
+
|
|
1361
|
+
// Low-frequency discover keeps peer_list timestamps fresh when heartbeat broadcasts are suppressed.
|
|
1362
|
+
if (autoDiscovery.value && now() - lastDiscoverSyncAt >= DISCOVER_SYNC_INTERVAL_MS) {
|
|
1363
|
+
sendDiscover({ feedback: false });
|
|
1364
|
+
lastDiscoverSyncAt = now();
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
const peers = discoveredPeers.value
|
|
1368
|
+
.map((peer) => peer?.peer_id)
|
|
1369
|
+
.filter((peerId) => typeof peerId === "string" && peerId !== fromPeer.value);
|
|
1370
|
+
|
|
1371
|
+
for (const peerId of peers) {
|
|
1372
|
+
const link = getMeshLink(peerId);
|
|
1373
|
+
if (link.dc && link.dc.readyState === "open") {
|
|
1374
|
+
// Data channel is open; WebRTC keepalive handles liveness, no signaling ping needed.
|
|
1375
|
+
continue;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
// Detect dead peers: if PC is closed/failed while still in requesting phase, mark failed immediately.
|
|
1379
|
+
if (
|
|
1380
|
+
["requesting", "waiting-offer"].includes(link.phase) &&
|
|
1381
|
+
link.pc &&
|
|
1382
|
+
["closed", "failed"].includes(link.pc.connectionState)
|
|
1383
|
+
) {
|
|
1384
|
+
markPeerFailed(peerId, `pc-${link.pc.connectionState}`);
|
|
1385
|
+
link.connectRequested = false;
|
|
1386
|
+
link.phase = "idle";
|
|
1387
|
+
continue;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
// PSP Section 11.2: Responders should timeout if initiator doesn't send offer within 10s.
|
|
1391
|
+
if (
|
|
1392
|
+
link.phase === "accepted" &&
|
|
1393
|
+
link.offerWaitTime &&
|
|
1394
|
+
now() - link.offerWaitTime > CONNECT_REQUEST_TIMEOUT_MS
|
|
1395
|
+
) {
|
|
1396
|
+
markPeerFailed(peerId, "offer-timeout");
|
|
1397
|
+
link.phase = "idle";
|
|
1398
|
+
link.offerWaitTime = 0;
|
|
1399
|
+
link.connectRequested = false;
|
|
1400
|
+
pushLog("rtc", `${peerId} did not send offer within timeout`);
|
|
1401
|
+
continue;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
// PSP Section 12.2: Initiators should timeout if responder doesn't send answer within 10s.
|
|
1405
|
+
if (
|
|
1406
|
+
link.phase === "offered" &&
|
|
1407
|
+
link.answerWaitTime &&
|
|
1408
|
+
now() - link.answerWaitTime > CONNECT_REQUEST_TIMEOUT_MS
|
|
1409
|
+
) {
|
|
1410
|
+
markPeerFailed(peerId, "answer-timeout");
|
|
1411
|
+
link.phase = "idle";
|
|
1412
|
+
link.answerWaitTime = 0;
|
|
1413
|
+
link.connectRequested = false;
|
|
1414
|
+
pushLog("rtc", `${peerId} did not send answer within timeout`);
|
|
1415
|
+
continue;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
if (
|
|
1419
|
+
["requesting", "waiting-offer"].includes(link.phase) &&
|
|
1420
|
+
link.connectRequestTime &&
|
|
1421
|
+
now() - link.connectRequestTime > CONNECT_REQUEST_TIMEOUT_MS
|
|
1422
|
+
) {
|
|
1423
|
+
markPeerFailed(peerId, "connect-timeout");
|
|
1424
|
+
link.connectRequested = false;
|
|
1425
|
+
link.connectRequestTime = 0;
|
|
1426
|
+
link.phase = "idle";
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
if (autoConnect.value) {
|
|
1430
|
+
maybeAutoConnectPeer(peerId);
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
refreshSelectedPeerSnapshot();
|
|
1435
|
+
}, 3000);
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
function stopAutoLoop() {
|
|
1439
|
+
if (discoveryTimer) {
|
|
1440
|
+
clearInterval(discoveryTimer);
|
|
1441
|
+
discoveryTimer = null;
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
function requestConnect(peerId) {
|
|
1446
|
+
if (!peerId || !isConnected.value) {
|
|
1447
|
+
return;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
// Deterministic initiator rule: only lexicographically smaller peer starts the offer flow.
|
|
1451
|
+
if (fromPeer.value.localeCompare(peerId) >= 0) {
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
const isAnnounced = discoveredPeers.value.some((p) => p?.peer_id === peerId);
|
|
1456
|
+
if (!isAnnounced) {
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
const link = getMeshLink(peerId);
|
|
1461
|
+
if (!link || link.connectRequested || (link.dc && link.dc.readyState === "open")) {
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
// Keep one in-flight attempt state per peer.
|
|
1466
|
+
if (["offering", "offered", "answering", "answered", "connected-pending", "connected"].includes(link.phase)) {
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
link.connectRequested = true;
|
|
1471
|
+
link.connectRequestTime = now();
|
|
1472
|
+
link.phase = "offering";
|
|
1473
|
+
refreshSelectedPeerSnapshot();
|
|
1474
|
+
|
|
1475
|
+
// Hard fallback path: skip connect_request/connect_accept handshake and
|
|
1476
|
+
// go straight to offer to avoid request/accept races in auto mode.
|
|
1477
|
+
startOfferFlow(peerId)
|
|
1478
|
+
.then(() => {
|
|
1479
|
+
const current = getMeshLink(peerId, false);
|
|
1480
|
+
if (!current) return;
|
|
1481
|
+
current.connectRequested = false;
|
|
1482
|
+
current.connectRequestTime = 0;
|
|
1483
|
+
if (current.phase === "offering") {
|
|
1484
|
+
current.phase = "offered";
|
|
1485
|
+
}
|
|
1486
|
+
refreshSelectedPeerSnapshot();
|
|
1487
|
+
})
|
|
1488
|
+
.catch((error) => {
|
|
1489
|
+
const current = getMeshLink(peerId, false);
|
|
1490
|
+
if (current) {
|
|
1491
|
+
current.connectRequested = false;
|
|
1492
|
+
current.connectRequestTime = 0;
|
|
1493
|
+
current.phase = "idle";
|
|
1494
|
+
}
|
|
1495
|
+
pushLog("rtc:error", error?.message || `failed to start offer flow for ${peerId}`);
|
|
1496
|
+
markPeerFailed(peerId, "direct-offer-failed");
|
|
1497
|
+
refreshSelectedPeerSnapshot();
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
function activeMeshPeerCount() {
|
|
1502
|
+
const activePhases = new Set(["requesting", "waiting-offer", "offering", "offered", "answering", "answered", "connected-pending", "ready"]);
|
|
1503
|
+
let count = 0;
|
|
1504
|
+
for (const [pid, link] of meshLinks.entries()) {
|
|
1505
|
+
if (!pid || pid === fromPeer.value) continue;
|
|
1506
|
+
if (link?.dc?.readyState === "open" || activePhases.has(link?.phase)) count++;
|
|
1507
|
+
}
|
|
1508
|
+
return count;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
function effectivePartialMeshLimit() {
|
|
1512
|
+
const rawLimit = Number(partialMeshMaxPeers.value);
|
|
1513
|
+
if (!Number.isFinite(rawLimit) || rawLimit <= 0) {
|
|
1514
|
+
return 1;
|
|
1515
|
+
}
|
|
1516
|
+
return Math.max(1, Math.floor(rawLimit));
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
function hasMeshCapacityForPeer(peerId) {
|
|
1520
|
+
if (!partialMesh.value) {
|
|
1521
|
+
return true;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
const existing = getMeshLink(peerId, false);
|
|
1525
|
+
if (existing && (existing?.dc?.readyState === "open" || isNegotiatingPhase(existing?.phase))) {
|
|
1526
|
+
return true;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
return activeMeshPeerCount() < effectivePartialMeshLimit();
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
function maybeAutoConnectPeer(peerId) {
|
|
1533
|
+
if (reconnectLockedByBye || !autoConnect.value || !isConnected.value) {
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
if (!hasMeshCapacityForPeer(peerId)) {
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
if (!peerId || isPeerCoolingDown(peerId) || isPeerInByeCooldown(peerId) || peerId === fromPeer.value) {
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
// Deterministic role split prevents dual-initiator glare loops.
|
|
1546
|
+
if (fromPeer.value.localeCompare(peerId) >= 0) {
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
const isAnnounced = discoveredPeers.value.some((p) => p?.peer_id === peerId);
|
|
1551
|
+
if (!isAnnounced) {
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
const link = getMeshLink(peerId);
|
|
1556
|
+
if (
|
|
1557
|
+
["requesting", "waiting-offer", "offering", "offered", "answering", "answered", "connected-pending", "connected"].includes(link.phase) ||
|
|
1558
|
+
(link.pc && ["connecting", "connected"].includes(link.pc.connectionState)) ||
|
|
1559
|
+
(link.dc && link.dc.readyState === "open")
|
|
1560
|
+
) {
|
|
1561
|
+
return;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
requestConnect(peerId);
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
function closeMeshLink(peerId) {
|
|
1568
|
+
const link = getMeshLink(peerId, false);
|
|
1569
|
+
if (!link) {
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// Mark idle before closing transports so close callbacks do not trigger failover.
|
|
1574
|
+
link.phase = "idle";
|
|
1575
|
+
link.connectRequested = false;
|
|
1576
|
+
link.connectRequestTime = 0;
|
|
1577
|
+
link.offerWaitTime = 0;
|
|
1578
|
+
link.answerWaitTime = 0;
|
|
1579
|
+
link.rtcState = "closed";
|
|
1580
|
+
link.dataState = "closed";
|
|
1581
|
+
|
|
1582
|
+
if (link.dc) {
|
|
1583
|
+
try {
|
|
1584
|
+
link.dc.close();
|
|
1585
|
+
} catch {
|
|
1586
|
+
// no-op
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
link.dc = null;
|
|
1590
|
+
|
|
1591
|
+
if (link.pc) {
|
|
1592
|
+
try {
|
|
1593
|
+
link.pc.close();
|
|
1594
|
+
} catch {
|
|
1595
|
+
// no-op
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
link.pc = null;
|
|
1599
|
+
refreshMeshStats();
|
|
1600
|
+
refreshSelectedPeerSnapshot();
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
function setupDataChannel(peerId, channel, source) {
|
|
1604
|
+
const link = getMeshLink(peerId);
|
|
1605
|
+
link.dc = channel;
|
|
1606
|
+
link.dataState = channel.readyState;
|
|
1607
|
+
pushLog("rtc:datachannel", `${peerId} attached (${source})`);
|
|
1608
|
+
|
|
1609
|
+
if (channel.readyState === "open") {
|
|
1610
|
+
link.phase = "connected";
|
|
1611
|
+
pushLog("rtc:datachannel", `${peerId} open`);
|
|
1612
|
+
refreshMeshStats();
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
channel.onopen = () => {
|
|
1616
|
+
link.dataState = channel.readyState;
|
|
1617
|
+
link.phase = "connected";
|
|
1618
|
+
link.connectRequested = false;
|
|
1619
|
+
link.connectRequestTime = 0;
|
|
1620
|
+
pushLog("rtc:datachannel", `${peerId} open`);
|
|
1621
|
+
refreshMeshStats();
|
|
1622
|
+
refreshSelectedPeerSnapshot();
|
|
1623
|
+
};
|
|
1624
|
+
|
|
1625
|
+
channel.onclose = () => {
|
|
1626
|
+
link.dataState = channel.readyState;
|
|
1627
|
+
pushLog("rtc:datachannel", `${peerId} closed`);
|
|
1628
|
+
refreshMeshStats();
|
|
1629
|
+
if (!stoppingRtc && autoConnect.value && link.phase === "connected") {
|
|
1630
|
+
failoverFromCurrentPeer(peerId, "datachannel-closed");
|
|
1631
|
+
}
|
|
1632
|
+
refreshSelectedPeerSnapshot();
|
|
1633
|
+
};
|
|
1634
|
+
|
|
1635
|
+
channel.onerror = () => {
|
|
1636
|
+
pushLog("rtc:datachannel", `${peerId} error`);
|
|
1637
|
+
};
|
|
1638
|
+
|
|
1639
|
+
channel.onmessage = (event) => {
|
|
1640
|
+
pushChatMessage(event.data, "incoming");
|
|
1641
|
+
};
|
|
1642
|
+
|
|
1643
|
+
refreshSelectedPeerSnapshot();
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
function ensurePeerConnection(peerId) {
|
|
1647
|
+
const link = getMeshLink(peerId);
|
|
1648
|
+
if (link.pc) {
|
|
1649
|
+
return link.pc;
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
const peerPc = new RTCPeerConnection(RTC_CONFIG);
|
|
1653
|
+
// Firefox can gather candidates more reliably with at least one media transceiver.
|
|
1654
|
+
try {
|
|
1655
|
+
peerPc.addTransceiver("audio", { direction: "recvonly" });
|
|
1656
|
+
} catch {
|
|
1657
|
+
// Ignore browser support differences.
|
|
1658
|
+
}
|
|
1659
|
+
link.pc = peerPc;
|
|
1660
|
+
link.rtcState = peerPc.connectionState;
|
|
1661
|
+
link.phase = "ready";
|
|
1662
|
+
|
|
1663
|
+
peerPc.onconnectionstatechange = () => {
|
|
1664
|
+
link.rtcState = peerPc.connectionState;
|
|
1665
|
+
|
|
1666
|
+
if (link.dc && link.dc.readyState === "open") {
|
|
1667
|
+
link.phase = "connected";
|
|
1668
|
+
link.connectRequested = false;
|
|
1669
|
+
link.connectRequestTime = 0;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
if (["failed", "closed"].includes(peerPc.connectionState) && link.dc) {
|
|
1673
|
+
try {
|
|
1674
|
+
link.dc.close();
|
|
1675
|
+
} catch {
|
|
1676
|
+
// no-op
|
|
1677
|
+
}
|
|
1678
|
+
link.dataState = "closed";
|
|
1679
|
+
link.dc = null;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
pushLog("rtc:state", `${peerId} ${peerPc.connectionState}`);
|
|
1683
|
+
if (
|
|
1684
|
+
!stoppingRtc &&
|
|
1685
|
+
autoConnect.value &&
|
|
1686
|
+
["failed", "disconnected"].includes(peerPc.connectionState) &&
|
|
1687
|
+
(!link.dc || link.dc.readyState !== "open")
|
|
1688
|
+
) {
|
|
1689
|
+
failoverFromCurrentPeer(peerId, `pc-${peerPc.connectionState}`);
|
|
1690
|
+
}
|
|
1691
|
+
refreshSelectedPeerSnapshot();
|
|
1692
|
+
};
|
|
1693
|
+
|
|
1694
|
+
peerPc.onicecandidate = (event) => {
|
|
1695
|
+
if (event.candidate) {
|
|
1696
|
+
sendRelayEnvelope("ice_candidate", { candidate: event.candidate.toJSON() }, { to: peerId });
|
|
1697
|
+
return;
|
|
1698
|
+
}
|
|
1699
|
+
sendRelayEnvelope("ice_end", {}, { to: peerId });
|
|
1700
|
+
};
|
|
1701
|
+
|
|
1702
|
+
peerPc.onicecandidateerror = (event) => {
|
|
1703
|
+
const code = event?.errorCode ?? "unknown";
|
|
1704
|
+
const text = event?.errorText ?? "unknown";
|
|
1705
|
+
pushLog("rtc:ice-error", `${peerId} ice candidate error code=${code} text=${text}`);
|
|
1706
|
+
};
|
|
1707
|
+
|
|
1708
|
+
peerPc.ondatachannel = (event) => {
|
|
1709
|
+
setupDataChannel(peerId, event.channel, "remote-offer");
|
|
1710
|
+
};
|
|
1711
|
+
|
|
1712
|
+
refreshSelectedPeerSnapshot();
|
|
1713
|
+
return peerPc;
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
async function startOfferFlow(peerId) {
|
|
1717
|
+
let peerPc = ensurePeerConnection(peerId);
|
|
1718
|
+
let link = getMeshLink(peerId);
|
|
1719
|
+
|
|
1720
|
+
if (link.dc && link.dc.readyState === "open") {
|
|
1721
|
+
link.phase = "connected";
|
|
1722
|
+
link.connectRequested = false;
|
|
1723
|
+
link.connectRequestTime = 0;
|
|
1724
|
+
refreshSelectedPeerSnapshot();
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
const needsFreshPeerConnection = Boolean(
|
|
1729
|
+
link.pc && (
|
|
1730
|
+
link.pc.signalingState !== "stable" ||
|
|
1731
|
+
link.pc.currentLocalDescription ||
|
|
1732
|
+
link.pc.currentRemoteDescription ||
|
|
1733
|
+
link.pc.pendingLocalDescription ||
|
|
1734
|
+
link.pc.pendingRemoteDescription ||
|
|
1735
|
+
link.dc
|
|
1736
|
+
)
|
|
1737
|
+
);
|
|
1738
|
+
|
|
1739
|
+
if (needsFreshPeerConnection) {
|
|
1740
|
+
pushLog("rtc", `Resetting stale offer state for ${peerId}`);
|
|
1741
|
+
closeMeshLink(peerId);
|
|
1742
|
+
peerPc = ensurePeerConnection(peerId);
|
|
1743
|
+
link = getMeshLink(peerId);
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
try {
|
|
1747
|
+
if (!link.dc) {
|
|
1748
|
+
const channel = peerPc.createDataChannel("freertc");
|
|
1749
|
+
setupDataChannel(peerId, channel, "local-offer");
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
const offer = await peerPc.createOffer();
|
|
1753
|
+
await peerPc.setLocalDescription(offer);
|
|
1754
|
+
await waitForIceGatheringComplete(peerPc);
|
|
1755
|
+
link.phase = "offered";
|
|
1756
|
+
link.answerWaitTime = now();
|
|
1757
|
+
|
|
1758
|
+
sendRelayEnvelope(
|
|
1759
|
+
"offer",
|
|
1760
|
+
{
|
|
1761
|
+
sdp: peerPc.localDescription?.sdp || offer.sdp,
|
|
1762
|
+
trickle_ice: true,
|
|
1763
|
+
restart_ice: false,
|
|
1764
|
+
setup_role: "actpass"
|
|
1765
|
+
},
|
|
1766
|
+
{ to: peerId }
|
|
1767
|
+
);
|
|
1768
|
+
} catch (error) {
|
|
1769
|
+
pushLog("rtc:error", error?.message || "failed to create local offer");
|
|
1770
|
+
markPeerFailed(peerId, "offer-create-failed");
|
|
1771
|
+
closeMeshLink(peerId);
|
|
1772
|
+
if (toPeer.value === peerId) {
|
|
1773
|
+
toPeer.value = "";
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
refreshSelectedPeerSnapshot();
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
async function beginRtc() {
|
|
1781
|
+
if (!isConnected.value) {
|
|
1782
|
+
pushLog("rtc:error", "Connect socket first");
|
|
1783
|
+
return;
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
reconnectLockedByBye = false;
|
|
1787
|
+
autoDiscovery.value = true;
|
|
1788
|
+
autoConnect.value = true;
|
|
1789
|
+
persistSharedIds();
|
|
1790
|
+
sendAnnounce({ feedback: false });
|
|
1791
|
+
sendDiscover({ feedback: false });
|
|
1792
|
+
lastDiscoverSyncAt = now();
|
|
1793
|
+
startAutoLoop();
|
|
1794
|
+
|
|
1795
|
+
if (!toPeer.value) {
|
|
1796
|
+
rtcPhase.value = "discovering";
|
|
1797
|
+
pushLog("rtc", "Waiting for peer_list to select target peer");
|
|
1798
|
+
return;
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
maybeAutoConnectPeer(toPeer.value);
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
function stopRtc() {
|
|
1805
|
+
stoppingRtc = true;
|
|
1806
|
+
for (const peerId of meshLinks.keys()) {
|
|
1807
|
+
closeMeshLink(peerId);
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
rtcPhase.value = "idle";
|
|
1811
|
+
rtcState.value = "closed";
|
|
1812
|
+
dataState.value = "closed";
|
|
1813
|
+
stoppingRtc = false;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
function resetRtc() {
|
|
1817
|
+
// Reset should start fresh by sending bye to all peers first, then clearing state.
|
|
1818
|
+
const peers = new Set([
|
|
1819
|
+
...Array.from(meshLinks.keys()),
|
|
1820
|
+
...discoveredPeers.value.map((peer) => peer?.peer_id).filter((peerId) => typeof peerId === "string")
|
|
1821
|
+
]);
|
|
1822
|
+
for (const peerId of peers) {
|
|
1823
|
+
if (peerId && peerId !== fromPeer.value) {
|
|
1824
|
+
sendRelayEnvelope("bye", { reason: "reset_rtc" }, { to: peerId });
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
reconnectLockedByBye = false;
|
|
1829
|
+
byeCooldowns.clear();
|
|
1830
|
+
failedPeerCooldowns.clear();
|
|
1831
|
+
toPeer.value = "";
|
|
1832
|
+
|
|
1833
|
+
stopAutomation();
|
|
1834
|
+
stopRtc();
|
|
1835
|
+
|
|
1836
|
+
if (isConnected.value) {
|
|
1837
|
+
void beginRtc();
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
function stopAutomation() {
|
|
1842
|
+
autoDiscovery.value = false;
|
|
1843
|
+
autoConnect.value = false;
|
|
1844
|
+
for (const link of meshLinks.values()) {
|
|
1845
|
+
link.connectRequested = false;
|
|
1846
|
+
link.connectRequestTime = 0;
|
|
1847
|
+
}
|
|
1848
|
+
stopAutoLoop();
|
|
1849
|
+
pushLog("auto", "Stopped auto discovery/connect loop");
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
async function onServerEnvelope(message) {
|
|
1853
|
+
if (!message || typeof message !== "object") {
|
|
1854
|
+
return;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
const isForMe = !message.to || message.to === fromPeer.value;
|
|
1858
|
+
const sameNetwork = normalizeText(message.network) === normalizedNetworkValue();
|
|
1859
|
+
if (!isForMe || !sameNetwork) {
|
|
1860
|
+
return;
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
if (message.type === "error") {
|
|
1864
|
+
pushLog("server:error", message.body || {});
|
|
1865
|
+
return;
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
if (
|
|
1869
|
+
["connect_request", "connect_accept", "connect_reject", "offer", "answer", "ice_candidate", "ice_end", "bye"].includes(message.type) &&
|
|
1870
|
+
!isMatchingSessionId(message.session_id)
|
|
1871
|
+
) {
|
|
1872
|
+
if (message.type === "connect_request" && message.from) {
|
|
1873
|
+
sendRelayEnvelope(
|
|
1874
|
+
"connect_reject",
|
|
1875
|
+
{
|
|
1876
|
+
code: "session_mismatch",
|
|
1877
|
+
reason: "session_id does not match"
|
|
1878
|
+
},
|
|
1879
|
+
{ to: message.from, reply_to: message.message_id }
|
|
1880
|
+
);
|
|
1881
|
+
}
|
|
1882
|
+
pushLog("rtc", `Ignored ${message.type} with mismatched session_id from ${message.from || "unknown"}`);
|
|
1883
|
+
return;
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
if (message.type === "peer_list") {
|
|
1887
|
+
const peers = Array.isArray(message.body?.peers) ? message.body.peers : [];
|
|
1888
|
+
const deduped = new Map();
|
|
1889
|
+
for (const peer of peers) {
|
|
1890
|
+
const peerId = peer?.peer_id;
|
|
1891
|
+
if (
|
|
1892
|
+
typeof peerId !== "string" ||
|
|
1893
|
+
peerId === fromPeer.value ||
|
|
1894
|
+
!isValidPeerId(peerId) ||
|
|
1895
|
+
!isFreshPeerAnnouncement(peer) ||
|
|
1896
|
+
!isMatchingSessionId(peer?.session_id)
|
|
1897
|
+
) {
|
|
1898
|
+
continue;
|
|
1899
|
+
}
|
|
1900
|
+
deduped.set(peerId, peer);
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
discoveredPeers.value = Array.from(deduped.values());
|
|
1904
|
+
const announcedPeerIds = new Set(deduped.keys());
|
|
1905
|
+
|
|
1906
|
+
for (const peerId of Array.from(meshLinks.keys())) {
|
|
1907
|
+
if (!peerId || peerId === fromPeer.value || announcedPeerIds.has(peerId)) {
|
|
1908
|
+
continue;
|
|
1909
|
+
}
|
|
1910
|
+
const link = getMeshLink(peerId, false);
|
|
1911
|
+
if (shouldKeepLinkWithoutAnnouncement(link)) {
|
|
1912
|
+
continue;
|
|
1913
|
+
}
|
|
1914
|
+
pushLog("discovery", `Pruned stale peer not in latest peer_list: ${peerId}`);
|
|
1915
|
+
closeMeshLink(peerId);
|
|
1916
|
+
meshLinks.delete(peerId);
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
if (toPeer.value && !announcedPeerIds.has(toPeer.value)) {
|
|
1920
|
+
toPeer.value = "";
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
const selected = choosePeerFromList(discoveredPeers.value);
|
|
1924
|
+
if (selected && !toPeer.value) {
|
|
1925
|
+
selectPeer(selected.peer_id);
|
|
1926
|
+
}
|
|
1927
|
+
if (autoConnect.value) {
|
|
1928
|
+
for (const peer of discoveredPeers.value) {
|
|
1929
|
+
maybeAutoConnectPeer(peer.peer_id);
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
refreshMeshStats();
|
|
1933
|
+
refreshSelectedPeerSnapshot();
|
|
1934
|
+
return;
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
if (
|
|
1938
|
+
["connect_accept", "connect_reject", "offer", "answer", "ice_candidate", "ice_end", "bye"].includes(message.type)
|
|
1939
|
+
) {
|
|
1940
|
+
if (!message.from || message.from === fromPeer.value) {
|
|
1941
|
+
return;
|
|
1942
|
+
}
|
|
1943
|
+
// Accept RTC signaling even before peer_list catches up; upsert temporary discovery entry.
|
|
1944
|
+
const isAnnounced = discoveredPeers.value.some((p) => p?.peer_id === message.from);
|
|
1945
|
+
if (!isAnnounced) {
|
|
1946
|
+
const existingPeers = new Map(
|
|
1947
|
+
discoveredPeers.value
|
|
1948
|
+
.filter((peer) => typeof peer?.peer_id === "string")
|
|
1949
|
+
.map((peer) => [peer.peer_id, peer])
|
|
1950
|
+
);
|
|
1951
|
+
existingPeers.set(message.from, {
|
|
1952
|
+
peer_id: message.from,
|
|
1953
|
+
session_id: message.session_id || null,
|
|
1954
|
+
timestamp: now()
|
|
1955
|
+
});
|
|
1956
|
+
discoveredPeers.value = Array.from(existingPeers.values());
|
|
1957
|
+
pushLog("discovery", `Accepted inbound RTC peer before peer_list sync: ${message.from}`);
|
|
1958
|
+
refreshMeshStats();
|
|
1959
|
+
}
|
|
1960
|
+
const link = getMeshLink(message.from);
|
|
1961
|
+
if (link) {
|
|
1962
|
+
link.lastSignalAt = now();
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
if (message.type === "connect_request" && message.from && message.from !== fromPeer.value) {
|
|
1967
|
+
const isAnnounced = discoveredPeers.value.some((p) => p?.peer_id === message.from);
|
|
1968
|
+
if (!isAnnounced) {
|
|
1969
|
+
const existingPeers = new Map(
|
|
1970
|
+
discoveredPeers.value
|
|
1971
|
+
.filter((peer) => typeof peer?.peer_id === "string")
|
|
1972
|
+
.map((peer) => [peer.peer_id, peer])
|
|
1973
|
+
);
|
|
1974
|
+
existingPeers.set(message.from, {
|
|
1975
|
+
peer_id: message.from,
|
|
1976
|
+
session_id: message.session_id || null,
|
|
1977
|
+
timestamp: now()
|
|
1978
|
+
});
|
|
1979
|
+
discoveredPeers.value = Array.from(existingPeers.values());
|
|
1980
|
+
pushLog("discovery", `Accepted inbound peer before peer_list sync: ${message.from}`);
|
|
1981
|
+
refreshMeshStats();
|
|
1982
|
+
}
|
|
1983
|
+
if (!toPeer.value) {
|
|
1984
|
+
selectPeer(message.from, { allowUnannounced: true });
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
if (
|
|
1989
|
+
message.from &&
|
|
1990
|
+
(reconnectLockedByBye || isPeerInByeCooldown(message.from)) &&
|
|
1991
|
+
["connect_request", "connect_accept", "offer", "answer", "ice_candidate", "ice_end"].includes(message.type)
|
|
1992
|
+
) {
|
|
1993
|
+
if (message.type === "connect_request") {
|
|
1994
|
+
sendRelayEnvelope(
|
|
1995
|
+
"connect_reject",
|
|
1996
|
+
{
|
|
1997
|
+
code: reconnectLockedByBye ? "manual_hangup_lock" : "peer_on_hold",
|
|
1998
|
+
reason: reconnectLockedByBye ? "manual hangup lock" : "recent bye",
|
|
1999
|
+
retry_after_ms: BYE_COOLDOWN_MS
|
|
2000
|
+
},
|
|
2001
|
+
{ to: message.from, reply_to: message.message_id }
|
|
2002
|
+
);
|
|
2003
|
+
}
|
|
2004
|
+
pushLog("rtc", `Ignored ${message.type} from held peer ${message.from}`);
|
|
2005
|
+
return;
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
switch (message.type) {
|
|
2009
|
+
case "connect_request":
|
|
2010
|
+
await onConnectRequest(message);
|
|
2011
|
+
break;
|
|
2012
|
+
case "connect_accept":
|
|
2013
|
+
await onConnectAccept(message);
|
|
2014
|
+
break;
|
|
2015
|
+
case "connect_reject":
|
|
2016
|
+
onConnectReject(message);
|
|
2017
|
+
break;
|
|
2018
|
+
case "offer":
|
|
2019
|
+
await onOffer(message);
|
|
2020
|
+
break;
|
|
2021
|
+
case "answer":
|
|
2022
|
+
await onAnswer(message);
|
|
2023
|
+
break;
|
|
2024
|
+
case "ice_candidate":
|
|
2025
|
+
await onIceCandidate(message);
|
|
2026
|
+
break;
|
|
2027
|
+
case "ice_end":
|
|
2028
|
+
pushLog("rtc:ice", "remote end-of-candidates");
|
|
2029
|
+
break;
|
|
2030
|
+
case "bye":
|
|
2031
|
+
pushLog("rtc", `remote ended session ${message.from || "unknown"}`);
|
|
2032
|
+
if (message.from) {
|
|
2033
|
+
markPeerBye(message.from, "remote-bye");
|
|
2034
|
+
closeMeshLink(message.from);
|
|
2035
|
+
}
|
|
2036
|
+
if (toPeer.value === message.from) {
|
|
2037
|
+
toPeer.value = "";
|
|
2038
|
+
}
|
|
2039
|
+
break;
|
|
2040
|
+
case "ack":
|
|
2041
|
+
break;
|
|
2042
|
+
case "pong":
|
|
2043
|
+
if (pendingPingStartedAt > 0) {
|
|
2044
|
+
const elapsedMs = Math.max(1, Math.round(performance.now() - pendingPingStartedAt));
|
|
2045
|
+
pendingPingStartedAt = 0;
|
|
2046
|
+
pingButtonText.value = `OK ${elapsedMs}ms`;
|
|
2047
|
+
flashPingButton();
|
|
2048
|
+
}
|
|
2049
|
+
break;
|
|
2050
|
+
default:
|
|
2051
|
+
break;
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
async function onConnectRequest(message) {
|
|
2056
|
+
if (!message.from) {
|
|
2057
|
+
return;
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
const peerId = message.from;
|
|
2061
|
+
const link = getMeshLink(peerId);
|
|
2062
|
+
|
|
2063
|
+
if (!hasMeshCapacityForPeer(peerId)) {
|
|
2064
|
+
sendRelayEnvelope(
|
|
2065
|
+
"connect_reject",
|
|
2066
|
+
{
|
|
2067
|
+
code: "mesh_full",
|
|
2068
|
+
reason: "local partial mesh limit reached"
|
|
2069
|
+
},
|
|
2070
|
+
{ to: peerId, reply_to: message.message_id }
|
|
2071
|
+
);
|
|
2072
|
+
pushLog("rtc", `Rejected connect_request from ${peerId}: local mesh full`);
|
|
2073
|
+
return;
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
if (link.dc && link.dc.readyState === "open") {
|
|
2077
|
+
sendRelayEnvelope(
|
|
2078
|
+
"connect_reject",
|
|
2079
|
+
{
|
|
2080
|
+
code: "already_connected",
|
|
2081
|
+
reason: "datachannel already open"
|
|
2082
|
+
},
|
|
2083
|
+
{ to: peerId, reply_to: message.message_id }
|
|
2084
|
+
);
|
|
2085
|
+
pushLog("rtc", `Ignored duplicate connect_request from connected peer ${peerId}`);
|
|
2086
|
+
return;
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
if (!toPeer.value) {
|
|
2090
|
+
selectPeer(peerId);
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
const initiator = fromPeer.value.localeCompare(peerId) < 0 ? fromPeer.value : peerId;
|
|
2094
|
+
sendRelayEnvelope(
|
|
2095
|
+
"connect_accept",
|
|
2096
|
+
{
|
|
2097
|
+
initiator_decision: initiator,
|
|
2098
|
+
expires_in_ms: 10000
|
|
2099
|
+
},
|
|
2100
|
+
{ to: peerId, reply_to: message.message_id }
|
|
2101
|
+
);
|
|
2102
|
+
|
|
2103
|
+
link.connectRequested = false;
|
|
2104
|
+
link.connectRequestTime = 0;
|
|
2105
|
+
link.phase = "accepted";
|
|
2106
|
+
|
|
2107
|
+
if (initiator === fromPeer.value) {
|
|
2108
|
+
await startOfferFlow(peerId);
|
|
2109
|
+
}
|
|
2110
|
+
refreshSelectedPeerSnapshot();
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
async function onConnectAccept(message) {
|
|
2114
|
+
if (!message.from) {
|
|
2115
|
+
return;
|
|
2116
|
+
}
|
|
2117
|
+
const peerId = message.from;
|
|
2118
|
+
const link = getMeshLink(peerId);
|
|
2119
|
+
|
|
2120
|
+
if (link.dc && link.dc.readyState === "open") {
|
|
2121
|
+
link.phase = "connected";
|
|
2122
|
+
link.connectRequested = false;
|
|
2123
|
+
link.connectRequestTime = 0;
|
|
2124
|
+
link.offerWaitTime = 0;
|
|
2125
|
+
refreshSelectedPeerSnapshot();
|
|
2126
|
+
return;
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
link.connectRequested = false;
|
|
2130
|
+
link.connectRequestTime = 0;
|
|
2131
|
+
|
|
2132
|
+
const initiator = message.body?.initiator_decision;
|
|
2133
|
+
link.phase = "accepted";
|
|
2134
|
+
|
|
2135
|
+
if (initiator === fromPeer.value) {
|
|
2136
|
+
await startOfferFlow(peerId);
|
|
2137
|
+
} else {
|
|
2138
|
+
// Responder mode: start timeout waiting for offer from initiator (PSP Section 11.2)
|
|
2139
|
+
link.offerWaitTime = now();
|
|
2140
|
+
pushLog("rtc", `Connect accepted by ${peerId}, waiting for offer from initiator`);
|
|
2141
|
+
}
|
|
2142
|
+
refreshSelectedPeerSnapshot();
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
function onConnectReject(message) {
|
|
2146
|
+
if (!message.from) {
|
|
2147
|
+
return;
|
|
2148
|
+
}
|
|
2149
|
+
const peerId = message.from;
|
|
2150
|
+
const rejectCode = message.body?.code || "connect_reject";
|
|
2151
|
+
const link = getMeshLink(peerId);
|
|
2152
|
+
link.connectRequested = false;
|
|
2153
|
+
link.connectRequestTime = 0;
|
|
2154
|
+
link.phase = "idle";
|
|
2155
|
+
pushLog("rtc", message.body || { code: "connect_reject" });
|
|
2156
|
+
markPeerFailed(peerId, `connect-reject:${rejectCode}`);
|
|
2157
|
+
closeMeshLink(peerId);
|
|
2158
|
+
|
|
2159
|
+
if (["peer_on_hold", "manual_hangup_lock"].includes(rejectCode)) {
|
|
2160
|
+
markPeerBye(peerId, rejectCode);
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
if (toPeer.value === peerId) {
|
|
2164
|
+
toPeer.value = "";
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
if (autoConnect.value) {
|
|
2168
|
+
selectNextPeerAndConnect(`connect-reject:${rejectCode}`);
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
refreshSelectedPeerSnapshot();
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
async function onOffer(message) {
|
|
2175
|
+
if (!message.from) {
|
|
2176
|
+
return;
|
|
2177
|
+
}
|
|
2178
|
+
const peerId = message.from;
|
|
2179
|
+
|
|
2180
|
+
if (!hasMeshCapacityForPeer(peerId)) {
|
|
2181
|
+
sendRelayEnvelope(
|
|
2182
|
+
"connect_reject",
|
|
2183
|
+
{
|
|
2184
|
+
code: "mesh_full",
|
|
2185
|
+
reason: "local partial mesh limit reached"
|
|
2186
|
+
},
|
|
2187
|
+
{ to: peerId, reply_to: message.message_id }
|
|
2188
|
+
);
|
|
2189
|
+
pushLog("rtc", `Rejected offer from ${peerId}: local mesh full`);
|
|
2190
|
+
return;
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
const link = getMeshLink(peerId);
|
|
2194
|
+
if (!message.body?.sdp) {
|
|
2195
|
+
return;
|
|
2196
|
+
}
|
|
2197
|
+
if (link.phase === "connected" || (link.dc && link.dc.readyState === "open")) {
|
|
2198
|
+
pushLog("rtc", `Ignored offer from connected peer ${peerId}`);
|
|
2199
|
+
return;
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
// Responder received the offer, stop waiting timeout (PSP Section 11.2)
|
|
2203
|
+
link.offerWaitTime = 0;
|
|
2204
|
+
|
|
2205
|
+
const peerPc = ensurePeerConnection(peerId);
|
|
2206
|
+
if (!["stable", "have-local-offer"].includes(peerPc.signalingState)) {
|
|
2207
|
+
pushLog("rtc", `Ignored stale offer from ${peerId} while in ${peerPc.signalingState}`);
|
|
2208
|
+
return;
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
// Glare handling: if both sides offered concurrently, keep one deterministic winner.
|
|
2212
|
+
if (peerPc.signalingState === "have-local-offer") {
|
|
2213
|
+
const iAmInitiator = fromPeer.value.localeCompare(peerId) < 0;
|
|
2214
|
+
if (iAmInitiator) {
|
|
2215
|
+
// Initiator keeps its own offer and ignores collided remote offer.
|
|
2216
|
+
pushLog("rtc", `Ignored collided offer from ${peerId} (keeping local offer)`);
|
|
2217
|
+
return;
|
|
2218
|
+
}
|
|
2219
|
+
try {
|
|
2220
|
+
await peerPc.setLocalDescription({ type: "rollback" });
|
|
2221
|
+
pushLog("rtc", `Rolled back local offer to accept remote offer from ${peerId}`);
|
|
2222
|
+
} catch (error) {
|
|
2223
|
+
pushLog("rtc:error", error?.message || `failed rollback during glare with ${peerId}`);
|
|
2224
|
+
return;
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
try {
|
|
2229
|
+
await peerPc.setRemoteDescription({ type: "offer", sdp: message.body.sdp });
|
|
2230
|
+
} catch (error) {
|
|
2231
|
+
pushLog("rtc:error", error?.message || `failed to apply offer from ${peerId}`);
|
|
2232
|
+
markPeerFailed(peerId, "offer-apply-failed");
|
|
2233
|
+
closeMeshLink(peerId);
|
|
2234
|
+
return;
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
await flushPendingIceCandidates(peerId, link);
|
|
2238
|
+
link.phase = "answering";
|
|
2239
|
+
|
|
2240
|
+
const answer = await peerPc.createAnswer();
|
|
2241
|
+
await peerPc.setLocalDescription(answer);
|
|
2242
|
+
await waitForIceGatheringComplete(peerPc);
|
|
2243
|
+
link.phase = "answered";
|
|
2244
|
+
|
|
2245
|
+
sendRelayEnvelope(
|
|
2246
|
+
"answer",
|
|
2247
|
+
{
|
|
2248
|
+
sdp: peerPc.localDescription?.sdp || answer.sdp,
|
|
2249
|
+
trickle_ice: true,
|
|
2250
|
+
restart_ice: false
|
|
2251
|
+
},
|
|
2252
|
+
{ to: peerId, reply_to: message.message_id }
|
|
2253
|
+
);
|
|
2254
|
+
refreshSelectedPeerSnapshot();
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
async function onAnswer(message) {
|
|
2258
|
+
if (!message.from || !message.body?.sdp) {
|
|
2259
|
+
return;
|
|
2260
|
+
}
|
|
2261
|
+
const peerId = message.from;
|
|
2262
|
+
const link = getMeshLink(peerId, false);
|
|
2263
|
+
if (!link?.pc || link.phase === "connected") {
|
|
2264
|
+
return;
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
if (link.dc && link.dc.readyState === "open") {
|
|
2268
|
+
link.phase = "connected";
|
|
2269
|
+
link.connectRequested = false;
|
|
2270
|
+
link.connectRequestTime = 0;
|
|
2271
|
+
link.answerWaitTime = 0;
|
|
2272
|
+
refreshSelectedPeerSnapshot();
|
|
2273
|
+
return;
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
if (link.pc.signalingState !== "have-local-offer") {
|
|
2277
|
+
pushLog("rtc", `Ignored stale answer from ${peerId} while in ${link.pc.signalingState}`);
|
|
2278
|
+
return;
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
try {
|
|
2282
|
+
await link.pc.setRemoteDescription({ type: "answer", sdp: message.body.sdp });
|
|
2283
|
+
} catch (error) {
|
|
2284
|
+
pushLog("rtc:error", error?.message || `failed to apply answer from ${peerId}`);
|
|
2285
|
+
markPeerFailed(peerId, "answer-apply-failed");
|
|
2286
|
+
closeMeshLink(peerId);
|
|
2287
|
+
if (toPeer.value === peerId) {
|
|
2288
|
+
toPeer.value = "";
|
|
2289
|
+
}
|
|
2290
|
+
return;
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
await flushPendingIceCandidates(peerId, link);
|
|
2294
|
+
link.phase = "connected-pending";
|
|
2295
|
+
link.answerWaitTime = 0; // Answer received, stop waiting
|
|
2296
|
+
pushLog("rtc", `answer applied from ${peerId}`);
|
|
2297
|
+
refreshSelectedPeerSnapshot();
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
async function onIceCandidate(message) {
|
|
2301
|
+
if (!message.from || !message.body?.candidate) {
|
|
2302
|
+
return;
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
const link = getMeshLink(message.from);
|
|
2306
|
+
if (!Array.isArray(link.pendingCandidates)) {
|
|
2307
|
+
link.pendingCandidates = [];
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
if (!link?.pc || !link.pc.remoteDescription) {
|
|
2311
|
+
link.pendingCandidates.push(message.body.candidate);
|
|
2312
|
+
return;
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
try {
|
|
2316
|
+
await link.pc.addIceCandidate(message.body.candidate);
|
|
2317
|
+
} catch (error) {
|
|
2318
|
+
pushLog("rtc:ice-error", error?.message || "failed to apply candidate");
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
function sendChat() {
|
|
2323
|
+
const text = normalizeText(chatInput.value);
|
|
2324
|
+
if (!text) {
|
|
2325
|
+
return;
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
const openLinks = Array.from(meshLinks.values()).filter((link) => link.dc && link.dc.readyState === "open");
|
|
2329
|
+
if (openLinks.length === 0) {
|
|
2330
|
+
pushLog("chat:error", "No open DataChannel");
|
|
2331
|
+
return;
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
if (chatSendMode.value === "target") {
|
|
2335
|
+
const targetPeerIds = selectedTargetPeers.value.length > 0
|
|
2336
|
+
? [...selectedTargetPeers.value]
|
|
2337
|
+
: (toPeer.value ? [toPeer.value] : []);
|
|
2338
|
+
|
|
2339
|
+
if (targetPeerIds.length === 0) {
|
|
2340
|
+
pushLog("chat:error", "Target mode requires selected peer");
|
|
2341
|
+
return;
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
let sentCount = 0;
|
|
2345
|
+
const unavailable = [];
|
|
2346
|
+
for (const peerId of targetPeerIds) {
|
|
2347
|
+
const selected = getMeshLink(peerId, false);
|
|
2348
|
+
if (!selected?.dc || selected.dc.readyState !== "open") {
|
|
2349
|
+
unavailable.push(peerId);
|
|
2350
|
+
continue;
|
|
2351
|
+
}
|
|
2352
|
+
selected.dc.send(text);
|
|
2353
|
+
sentCount += 1;
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
if (sentCount === 0) {
|
|
2357
|
+
pushLog("chat:error", "Selected peer DataChannel is not open");
|
|
2358
|
+
return;
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
if (unavailable.length > 0) {
|
|
2362
|
+
pushLog("chat:warn", `Some selected peers are unavailable: ${unavailable.join(", ")}`);
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
pushChatMessage(text, "outgoing");
|
|
2366
|
+
chatInput.value = "";
|
|
2367
|
+
return;
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
for (const link of openLinks) {
|
|
2371
|
+
link.dc.send(text);
|
|
2372
|
+
}
|
|
2373
|
+
pushChatMessage(text, "outgoing");
|
|
2374
|
+
chatInput.value = "";
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
function hangup() {
|
|
2378
|
+
reconnectLockedByBye = true;
|
|
2379
|
+
const peers = new Set([
|
|
2380
|
+
...Array.from(meshLinks.keys()),
|
|
2381
|
+
...discoveredPeers.value.map((peer) => peer?.peer_id).filter((peerId) => typeof peerId === "string")
|
|
2382
|
+
]);
|
|
2383
|
+
for (const peerId of peers) {
|
|
2384
|
+
if (peerId && peerId !== fromPeer.value) {
|
|
2385
|
+
markPeerBye(peerId, "local-bye");
|
|
2386
|
+
sendRelayEnvelope("bye", { reason: "manual_hangup" }, { to: peerId });
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
stopAutomation();
|
|
2390
|
+
stopRtc();
|
|
2391
|
+
toPeer.value = "";
|
|
2392
|
+
pushLog("rtc", "sent bye and disconnected peer session");
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
watch(
|
|
2396
|
+
[wsUrl, network, partialMesh, partialMeshMaxPeers, chatSendMode, activeView],
|
|
2397
|
+
() => {
|
|
2398
|
+
persistUiPrefs();
|
|
2399
|
+
},
|
|
2400
|
+
{ flush: "sync" }
|
|
2401
|
+
);
|
|
2402
|
+
|
|
2403
|
+
loadUiPrefs();
|
|
2404
|
+
initSharedIds();
|
|
2405
|
+
|
|
2406
|
+
onMounted(() => {
|
|
2407
|
+
// Withdraw on page unload/reload using both beforeunload and pagehide to cover all cases.
|
|
2408
|
+
// Use fetch with keepalive to guarantee delivery even if socket closes immediately.
|
|
2409
|
+
window.addEventListener("beforeunload", () => {
|
|
2410
|
+
const envelope = {
|
|
2411
|
+
psp_version: PSP_VERSION,
|
|
2412
|
+
type: "withdraw",
|
|
2413
|
+
network: network.value || "room:test",
|
|
2414
|
+
from: fromPeer.value,
|
|
2415
|
+
to: null,
|
|
2416
|
+
session_id: null,
|
|
2417
|
+
message_id: crypto.randomUUID(),
|
|
2418
|
+
timestamp: Date.now(),
|
|
2419
|
+
ttl_ms: 30000,
|
|
2420
|
+
body: { reason: "peer_offline" }
|
|
2421
|
+
};
|
|
2422
|
+
// Try WebSocket first if open
|
|
2423
|
+
if (socket.value && socket.value.readyState === WebSocket.OPEN) {
|
|
2424
|
+
try {
|
|
2425
|
+
socket.value.send(JSON.stringify(envelope));
|
|
2426
|
+
} catch (e) {
|
|
2427
|
+
// Ignored
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
});
|
|
2431
|
+
|
|
2432
|
+
window.addEventListener("pagehide", () => {
|
|
2433
|
+
if (socket.value && socket.value.readyState === WebSocket.OPEN) {
|
|
2434
|
+
sendWithdraw();
|
|
2435
|
+
}
|
|
2436
|
+
});
|
|
2437
|
+
|
|
2438
|
+
// No-click behavior: boot socket + auto-handshake loop on load.
|
|
2439
|
+
connect();
|
|
2440
|
+
});
|
|
2441
|
+
|
|
2442
|
+
return {
|
|
2443
|
+
pspSpecUrl: PSP_SPEC_URL,
|
|
2444
|
+
specPillLabel,
|
|
2445
|
+
pingButtonLabel,
|
|
2446
|
+
discoverButtonLabel,
|
|
2447
|
+
announceButtonLabel,
|
|
2448
|
+
pingFlash,
|
|
2449
|
+
discoverFlash,
|
|
2450
|
+
announceFlash,
|
|
2451
|
+
activeView,
|
|
2452
|
+
wsUrl,
|
|
2453
|
+
status,
|
|
2454
|
+
network,
|
|
2455
|
+
fromPeer,
|
|
2456
|
+
toPeer,
|
|
2457
|
+
sessionId,
|
|
2458
|
+
instanceId,
|
|
2459
|
+
relayType,
|
|
2460
|
+
relayBody,
|
|
2461
|
+
discoveredPeers,
|
|
2462
|
+
autoDiscovery,
|
|
2463
|
+
autoConnect,
|
|
2464
|
+
partialMesh,
|
|
2465
|
+
partialMeshMaxPeers,
|
|
2466
|
+
debugExpanded,
|
|
2467
|
+
logsText,
|
|
2468
|
+
logCount,
|
|
2469
|
+
logHeadline,
|
|
2470
|
+
logPreviewEntries,
|
|
2471
|
+
isConnected,
|
|
2472
|
+
canConnect,
|
|
2473
|
+
canDisconnect,
|
|
2474
|
+
canRunRtc,
|
|
2475
|
+
chatMessages,
|
|
2476
|
+
chatEmptyMessage,
|
|
2477
|
+
fromPeerDisplay,
|
|
2478
|
+
targetPeerDisplay,
|
|
2479
|
+
relayTargetDisplay,
|
|
2480
|
+
sendRouteDisplay,
|
|
2481
|
+
sendRouteTooltip,
|
|
2482
|
+
specWarnings,
|
|
2483
|
+
specWarningCount,
|
|
2484
|
+
meshPeers,
|
|
2485
|
+
meshConnectedCount,
|
|
2486
|
+
meshTargetCount,
|
|
2487
|
+
rtcPhase,
|
|
2488
|
+
rtcState,
|
|
2489
|
+
dataState,
|
|
2490
|
+
chatInput,
|
|
2491
|
+
chatSendMode,
|
|
2492
|
+
selectedTargetPeers,
|
|
2493
|
+
chatThreadRef,
|
|
2494
|
+
connect,
|
|
2495
|
+
disconnect,
|
|
2496
|
+
applyWsUrlChange,
|
|
2497
|
+
applyNetworkChange,
|
|
2498
|
+
applyFromPeerChange,
|
|
2499
|
+
sendAnnounce,
|
|
2500
|
+
sendDiscover,
|
|
2501
|
+
sendPing,
|
|
2502
|
+
sendRelay,
|
|
2503
|
+
beginRtc,
|
|
2504
|
+
stopAutomation,
|
|
2505
|
+
stopRtc,
|
|
2506
|
+
resetRtc,
|
|
2507
|
+
sendChat,
|
|
2508
|
+
hangup,
|
|
2509
|
+
regeneratePeerId,
|
|
2510
|
+
regenerateSharedIds,
|
|
2511
|
+
persistSharedIds,
|
|
2512
|
+
selectPeer,
|
|
2513
|
+
selectPeerFromUi,
|
|
2514
|
+
handleSpecLinkClick
|
|
2515
|
+
};
|
|
2516
|
+
},
|
|
2517
|
+
template: `
|
|
2518
|
+
<main class="shell">
|
|
2519
|
+
<section class="card hero">
|
|
2520
|
+
<div class="hero-compact">
|
|
2521
|
+
<div class="brand-lockup">
|
|
2522
|
+
<span class="logo-badge" aria-hidden="true">
|
|
2523
|
+
<svg class="brand-icon" viewBox="0 0 48 48" role="img" aria-label="freertc logo">
|
|
2524
|
+
<circle cx="24" cy="24" r="18" fill="none" stroke="#8b5cf6" stroke-width="2.8" />
|
|
2525
|
+
<circle cx="24" cy="24" r="10" fill="none" stroke="#8b5cf6" stroke-width="2.4" />
|
|
2526
|
+
<circle cx="24" cy="24" r="3.2" fill="#8b5cf6" />
|
|
2527
|
+
<path d="M24 7.4v6.2" stroke="#8b5cf6" stroke-width="2" stroke-linecap="round" />
|
|
2528
|
+
<path d="M24 34.4v6.2" stroke="#8b5cf6" stroke-width="2" stroke-linecap="round" />
|
|
2529
|
+
<path d="M7.4 24h6.2" stroke="#8b5cf6" stroke-width="2" stroke-linecap="round" />
|
|
2530
|
+
<path d="M34.4 24h6.2" stroke="#8b5cf6" stroke-width="2" stroke-linecap="round" />
|
|
2531
|
+
</svg>
|
|
2532
|
+
</span>
|
|
2533
|
+
<h1 class="brand-wordmark"><span class="brand-free">free</span><span class="brand-rtc">rtc</span></h1>
|
|
2534
|
+
</div>
|
|
2535
|
+
<div class="hero-copy"><a class="inline-link" :href="pspSpecUrl" target="_blank" rel="noopener noreferrer" @click="handleSpecLinkClick">PSP</a> signaling and WebRTC data-channel mesh primitives for real-time peer networking.</div>
|
|
2536
|
+
</div>
|
|
2537
|
+
<div class="hero-status">
|
|
2538
|
+
<div class="status-pill" :class="status">
|
|
2539
|
+
<strong>{{ status }}</strong>
|
|
2540
|
+
</div>
|
|
2541
|
+
<div class="pill" :class="{ 'pill-warn': specWarningCount > 0, 'pill-ok': specLinkFlash }">
|
|
2542
|
+
<strong><a class="pill-link" :href="pspSpecUrl" target="_blank" rel="noopener noreferrer" @click="handleSpecLinkClick">{{ specPillLabel }}</a></strong>
|
|
2543
|
+
{{ specWarningCount > 0 ? specWarningCount + ' warning' + (specWarningCount === 1 ? '' : 's') : 'ok' }}
|
|
2544
|
+
</div>
|
|
2545
|
+
</div>
|
|
2546
|
+
</section>
|
|
2547
|
+
|
|
2548
|
+
<section v-if="specWarningCount > 0" class="card spec-warning-card stack">
|
|
2549
|
+
<div class="section-header">
|
|
2550
|
+
<h2 class="panel-title"><a class="pill-link" :href="pspSpecUrl" target="_blank" rel="noopener noreferrer" @click="handleSpecLinkClick">PSP Spec</a> Warnings</h2>
|
|
2551
|
+
<span class="pill pill-warn"><strong>count</strong> {{ specWarningCount }}</span>
|
|
2552
|
+
</div>
|
|
2553
|
+
<ul class="spec-warning-list">
|
|
2554
|
+
<li v-for="warning in specWarnings" :key="warning" class="spec-warning-item">{{ warning }}</li>
|
|
2555
|
+
</ul>
|
|
2556
|
+
</section>
|
|
2557
|
+
|
|
2558
|
+
<section class="headline-grid">
|
|
2559
|
+
<article class="card stack">
|
|
2560
|
+
<div class="section-header">
|
|
2561
|
+
<h2 class="panel-title">Session Surface</h2>
|
|
2562
|
+
<div class="tabs">
|
|
2563
|
+
<button class="tab" :class="{ active: activeView === 'webrtc' }" @click="activeView = 'webrtc'">Auto WebRTC</button>
|
|
2564
|
+
<button class="tab" :class="{ active: activeView === 'console' }" @click="activeView = 'console'">Console</button>
|
|
2565
|
+
</div>
|
|
2566
|
+
</div>
|
|
2567
|
+
|
|
2568
|
+
<div class="info-grid">
|
|
2569
|
+
<div class="stat-card">
|
|
2570
|
+
<div class="stat-label">Signaling Server URL</div>
|
|
2571
|
+
<input class="stat-input mono" v-model="wsUrl" @change="applyWsUrlChange" placeholder="wss://peer.ooo/ws">
|
|
2572
|
+
</div>
|
|
2573
|
+
<div class="stat-card">
|
|
2574
|
+
<div class="stat-label">Room / Topic</div>
|
|
2575
|
+
<input class="stat-input mono" v-model="network" @change="applyNetworkChange" placeholder="room:abc">
|
|
2576
|
+
</div>
|
|
2577
|
+
<div class="stat-card">
|
|
2578
|
+
<div class="stat-label">Your Peer ID</div>
|
|
2579
|
+
<input class="stat-input mono" v-model="fromPeer" @change="applyFromPeerChange" placeholder="opaque peer id">
|
|
2580
|
+
</div>
|
|
2581
|
+
</div>
|
|
2582
|
+
|
|
2583
|
+
<div class="row">
|
|
2584
|
+
<button @click="connect" :disabled="!canConnect">Connect</button>
|
|
2585
|
+
<button class="secondary" @click="disconnect" :disabled="!canDisconnect">Disconnect</button>
|
|
2586
|
+
<button class="secondary" @click="regeneratePeerId">New Peer ID</button>
|
|
2587
|
+
<button class="secondary" @click="regenerateSharedIds">Regenerate Session + Instance IDs</button>
|
|
2588
|
+
</div>
|
|
2589
|
+
|
|
2590
|
+
<div class="metrics">
|
|
2591
|
+
<dl class="metric">
|
|
2592
|
+
<dt>Target Peer</dt>
|
|
2593
|
+
<dd class="mono peer-value">{{ targetPeerDisplay }}</dd>
|
|
2594
|
+
</dl>
|
|
2595
|
+
<dl class="metric">
|
|
2596
|
+
<dt>RTC Phase</dt>
|
|
2597
|
+
<dd>{{ rtcPhase }}</dd>
|
|
2598
|
+
</dl>
|
|
2599
|
+
<dl class="metric">
|
|
2600
|
+
<dt>PeerConnection</dt>
|
|
2601
|
+
<dd>{{ rtcState }}</dd>
|
|
2602
|
+
</dl>
|
|
2603
|
+
<dl class="metric">
|
|
2604
|
+
<dt>DataChannel</dt>
|
|
2605
|
+
<dd>{{ dataState }}</dd>
|
|
2606
|
+
</dl>
|
|
2607
|
+
</div>
|
|
2608
|
+
</article>
|
|
2609
|
+
|
|
2610
|
+
<article class="card stack">
|
|
2611
|
+
<h2 class="panel-title">Connection Details</h2>
|
|
2612
|
+
<div class="field-grid">
|
|
2613
|
+
<label>
|
|
2614
|
+
WebSocket URL
|
|
2615
|
+
<input v-model="wsUrl" placeholder="wss://peer.ooo/ws" @change="applyWsUrlChange">
|
|
2616
|
+
</label>
|
|
2617
|
+
<label>
|
|
2618
|
+
Network
|
|
2619
|
+
<input v-model="network" placeholder="room:abc" @change="applyNetworkChange">
|
|
2620
|
+
</label>
|
|
2621
|
+
</div>
|
|
2622
|
+
<div class="row">
|
|
2623
|
+
<span class="pill"><strong>mesh</strong> {{ meshConnectedCount }}/{{ meshTargetCount }} connected</span>
|
|
2624
|
+
</div>
|
|
2625
|
+
<div class="field-grid">
|
|
2626
|
+
<label>
|
|
2627
|
+
Session ID
|
|
2628
|
+
<input v-model="sessionId" @change="persistSharedIds">
|
|
2629
|
+
</label>
|
|
2630
|
+
<label>
|
|
2631
|
+
Instance ID
|
|
2632
|
+
<input v-model="instanceId" @change="persistSharedIds">
|
|
2633
|
+
</label>
|
|
2634
|
+
</div>
|
|
2635
|
+
<div class="row">
|
|
2636
|
+
<button class="secondary" @click="regenerateSharedIds">Regenerate Session + Instance IDs</button>
|
|
2637
|
+
</div>
|
|
2638
|
+
<div class="row">
|
|
2639
|
+
<button :class="{ active: announceFlash }" @click="sendAnnounce" :disabled="!isConnected">{{ announceButtonLabel }}</button>
|
|
2640
|
+
<button class="secondary" :class="{ active: discoverFlash }" @click="sendDiscover" :disabled="!isConnected">{{ discoverButtonLabel }}</button>
|
|
2641
|
+
<button class="secondary" :class="{ active: pingFlash }" @click="sendPing" :disabled="!isConnected">{{ pingButtonLabel }}</button>
|
|
2642
|
+
</div>
|
|
2643
|
+
</article>
|
|
2644
|
+
</section>
|
|
2645
|
+
|
|
2646
|
+
<section class="control-grid">
|
|
2647
|
+
<article v-if="activeView === 'console'" class="card console-card stack">
|
|
2648
|
+
<div class="section-header">
|
|
2649
|
+
<h2 class="panel-title">Raw Relay Console</h2>
|
|
2650
|
+
<span class="pill"><strong><a class="pill-link" :href="pspSpecUrl" target="_blank" rel="noopener noreferrer" @click="handleSpecLinkClick">psp</a></strong> manual envelope mode</span>
|
|
2651
|
+
<label class="pill" :title="toPeer || 'broadcast'" style="gap:6px; text-transform:none; letter-spacing:0.04em; padding:6px 10px; min-width:260px; justify-content:flex-start;">
|
|
2652
|
+
<strong style="text-transform:uppercase; letter-spacing:0.08em;">to</strong>
|
|
2653
|
+
<input
|
|
2654
|
+
v-model="toPeer"
|
|
2655
|
+
placeholder="broadcast"
|
|
2656
|
+
:title="toPeer || 'broadcast'"
|
|
2657
|
+
style="background:transparent; border:0; outline:none; color:inherit; width:100%; padding:0; font-size:0.84rem;"
|
|
2658
|
+
>
|
|
2659
|
+
</label>
|
|
2660
|
+
</div>
|
|
2661
|
+
<label>
|
|
2662
|
+
Relay Type
|
|
2663
|
+
<select v-model="relayType">
|
|
2664
|
+
<option>announce</option>
|
|
2665
|
+
<option>discover</option>
|
|
2666
|
+
<option>ping</option>
|
|
2667
|
+
<option>connect_request</option>
|
|
2668
|
+
<option>connect_accept</option>
|
|
2669
|
+
<option>connect_reject</option>
|
|
2670
|
+
<option>offer</option>
|
|
2671
|
+
<option>answer</option>
|
|
2672
|
+
<option>ice_candidate</option>
|
|
2673
|
+
<option>ice_end</option>
|
|
2674
|
+
<option>renegotiate</option>
|
|
2675
|
+
<option>bye</option>
|
|
2676
|
+
<option>ext</option>
|
|
2677
|
+
</select>
|
|
2678
|
+
</label>
|
|
2679
|
+
|
|
2680
|
+
<label>
|
|
2681
|
+
Body (JSON)
|
|
2682
|
+
<textarea v-model="relayBody"></textarea>
|
|
2683
|
+
</label>
|
|
2684
|
+
|
|
2685
|
+
<div class="row">
|
|
2686
|
+
<button class="console-action-btn" @click="sendRelay" :disabled="!isConnected">Send Relay</button>
|
|
2687
|
+
</div>
|
|
2688
|
+
|
|
2689
|
+
<p class="muted">Send protocol frames directly when you need to inspect or force specific signaling behavior.</p>
|
|
2690
|
+
</article>
|
|
2691
|
+
|
|
2692
|
+
<article v-else class="card stack">
|
|
2693
|
+
<div class="section-header">
|
|
2694
|
+
<h2 class="panel-title">Auto Handshake</h2>
|
|
2695
|
+
<span class="pill"><strong>peers</strong> {{ meshConnectedCount }}</span>
|
|
2696
|
+
<span class="pill"><strong>route</strong> {{ sendRouteDisplay }}</span>
|
|
2697
|
+
</div>
|
|
2698
|
+
|
|
2699
|
+
<p class="muted">Auto mode runs peer discovery, target selection, and WebRTC negotiation using the shared session and instance identifiers across tabs. Chat is broadcast by default.</p>
|
|
2700
|
+
|
|
2701
|
+
<div class="row">
|
|
2702
|
+
<button @click="beginRtc" :disabled="!canRunRtc">Start Auto Handshake</button>
|
|
2703
|
+
<button
|
|
2704
|
+
class="secondary"
|
|
2705
|
+
:class="{ active: !autoDiscovery && !autoConnect }"
|
|
2706
|
+
@click="stopAutomation"
|
|
2707
|
+
>
|
|
2708
|
+
Stop Auto Discovery/Connect
|
|
2709
|
+
</button>
|
|
2710
|
+
<button class="secondary" @click="hangup" :disabled="!canRunRtc">Send bye</button>
|
|
2711
|
+
<button class="secondary" @click="resetRtc">Reset RTC</button>
|
|
2712
|
+
</div>
|
|
2713
|
+
|
|
2714
|
+
<div class="runtime-strip">
|
|
2715
|
+
<article class="runtime-chip" :class="autoDiscovery ? 'ok' : 'idle'">
|
|
2716
|
+
<div class="runtime-label">Auto Discovery</div>
|
|
2717
|
+
<div class="runtime-value">{{ autoDiscovery ? 'On' : 'Off' }}</div>
|
|
2718
|
+
</article>
|
|
2719
|
+
<article class="runtime-chip" :class="autoConnect ? 'ok' : 'idle'">
|
|
2720
|
+
<div class="runtime-label">Auto Connect</div>
|
|
2721
|
+
<div class="runtime-value">{{ autoConnect ? 'On' : 'Off' }}</div>
|
|
2722
|
+
</article>
|
|
2723
|
+
<article class="runtime-chip" :class="partialMesh ? 'ok' : 'idle'" style="cursor:pointer" @click="partialMesh = !partialMesh" :title="'Partial mesh limits connections to ' + partialMeshMaxPeers + ' peers (full mesh connects to all)'">
|
|
2724
|
+
<div class="runtime-label">Partial Mesh</div>
|
|
2725
|
+
<div class="runtime-value" style="display:flex;align-items:center;gap:4px">
|
|
2726
|
+
<span>{{ partialMesh ? 'On' : 'Off' }}</span>
|
|
2727
|
+
<input v-if="partialMesh" type="number" min="1" max="99" :value="partialMeshMaxPeers" @input.stop="partialMeshMaxPeers = Math.max(1, parseInt($event.target.value) || 1)" @click.stop style="width:3em;font-size:0.85em;background:transparent;border:1px solid currentColor;border-radius:4px;padding:0 3px;color:inherit;text-align:center" title="Max peers" />
|
|
2728
|
+
</div>
|
|
2729
|
+
</article>
|
|
2730
|
+
<article class="runtime-chip selected">
|
|
2731
|
+
<div class="runtime-label">Send Route</div>
|
|
2732
|
+
<div class="runtime-value mono runtime-route-value" :title="sendRouteTooltip">{{ sendRouteDisplay }}</div>
|
|
2733
|
+
</article>
|
|
2734
|
+
</div>
|
|
2735
|
+
|
|
2736
|
+
<div ref="chatThreadRef" class="chat-thread" v-if="chatMessages.length">
|
|
2737
|
+
<div
|
|
2738
|
+
v-for="entry in chatMessages"
|
|
2739
|
+
:key="entry.id"
|
|
2740
|
+
class="chat-row"
|
|
2741
|
+
:class="entry.kind"
|
|
2742
|
+
>
|
|
2743
|
+
<article class="chat-bubble">
|
|
2744
|
+
<div class="chat-meta">{{ entry.kind === 'outgoing' ? 'you' : 'peer' }} · {{ entry.at }}</div>
|
|
2745
|
+
<div class="chat-body">{{ entry.text }}</div>
|
|
2746
|
+
</article>
|
|
2747
|
+
</div>
|
|
2748
|
+
</div>
|
|
2749
|
+
<div v-else class="chat-empty">{{ chatEmptyMessage }}</div>
|
|
2750
|
+
|
|
2751
|
+
<section class="chat-composer stack">
|
|
2752
|
+
<label class="chat-label">
|
|
2753
|
+
Message
|
|
2754
|
+
<input v-model="chatInput" placeholder="Write a message" @keyup.enter="sendChat">
|
|
2755
|
+
</label>
|
|
2756
|
+
|
|
2757
|
+
<div class="chat-toolbar">
|
|
2758
|
+
<div class="mode-toggle" role="group" aria-label="Chat send mode">
|
|
2759
|
+
<button
|
|
2760
|
+
class="mode-btn"
|
|
2761
|
+
:class="{ active: chatSendMode === 'broadcast' }"
|
|
2762
|
+
:aria-pressed="chatSendMode === 'broadcast'"
|
|
2763
|
+
@click="chatSendMode = 'broadcast'"
|
|
2764
|
+
>
|
|
2765
|
+
Broadcast
|
|
2766
|
+
</button>
|
|
2767
|
+
<button
|
|
2768
|
+
class="mode-btn"
|
|
2769
|
+
:class="{ active: chatSendMode === 'target' }"
|
|
2770
|
+
:aria-pressed="chatSendMode === 'target'"
|
|
2771
|
+
@click="chatSendMode = 'target'"
|
|
2772
|
+
>
|
|
2773
|
+
Selected only
|
|
2774
|
+
</button>
|
|
2775
|
+
</div>
|
|
2776
|
+
<button class="chat-send-btn" @click="sendChat">Send</button>
|
|
2777
|
+
</div>
|
|
2778
|
+
|
|
2779
|
+
<p class="mode-indicator">
|
|
2780
|
+
{{ chatSendMode === 'broadcast'
|
|
2781
|
+
? 'Broadcast to ' + meshConnectedCount + ' connected peer' + (meshConnectedCount === 1 ? '' : 's')
|
|
2782
|
+
: (selectedTargetPeers.length > 1
|
|
2783
|
+
? 'Selected route: ' + selectedTargetPeers.length + ' peers'
|
|
2784
|
+
: 'Selected route: ' + targetPeerDisplay) }}
|
|
2785
|
+
</p>
|
|
2786
|
+
</section>
|
|
2787
|
+
</article>
|
|
2788
|
+
|
|
2789
|
+
<div class="sidebar-stack">
|
|
2790
|
+
<article class="card stack">
|
|
2791
|
+
<div class="section-header">
|
|
2792
|
+
<h2 class="panel-title">Mesh Peers</h2>
|
|
2793
|
+
<span class="pill"><strong>links</strong> {{ meshConnectedCount }}/{{ meshTargetCount }}</span>
|
|
2794
|
+
</div>
|
|
2795
|
+
<p class="muted mesh-helper">Click any peer row to set selected target when mode is Selected only.</p>
|
|
2796
|
+
|
|
2797
|
+
<div v-if="meshPeers.length" class="mesh-list">
|
|
2798
|
+
<button
|
|
2799
|
+
v-for="peer in meshPeers"
|
|
2800
|
+
:key="peer.peerId"
|
|
2801
|
+
class="mesh-row"
|
|
2802
|
+
:class="{ 'is-selected': peer.selected, 'is-connected': peer.connected }"
|
|
2803
|
+
@click="selectPeerFromUi(peer.peerId)"
|
|
2804
|
+
>
|
|
2805
|
+
<span class="mesh-main">
|
|
2806
|
+
<span class="mesh-peer-row">
|
|
2807
|
+
<span class="mesh-peer mono" :title="peer.peerId">{{ peer.display }}</span>
|
|
2808
|
+
<span class="mesh-badges">
|
|
2809
|
+
<span class="mesh-badge mesh-role" :class="peer.role">{{ peer.role }}</span>
|
|
2810
|
+
<span class="mesh-badge" :class="peer.statusTone">{{ peer.statusLabel }}</span>
|
|
2811
|
+
</span>
|
|
2812
|
+
</span>
|
|
2813
|
+
<span class="mesh-state-line">{{ peer.phase }} • RTC {{ peer.rtcState }} • DC {{ peer.dataState }}</span>
|
|
2814
|
+
</span>
|
|
2815
|
+
</button>
|
|
2816
|
+
</div>
|
|
2817
|
+
<div v-else class="chat-empty mesh-empty">No peers announced yet.</div>
|
|
2818
|
+
</article>
|
|
2819
|
+
|
|
2820
|
+
<article class="card debug-card stack">
|
|
2821
|
+
<div class="section-header">
|
|
2822
|
+
<h2 class="panel-title">Debug Log</h2>
|
|
2823
|
+
<div class="row">
|
|
2824
|
+
<span class="pill"><strong>rtc</strong> newest first</span>
|
|
2825
|
+
<span class="pill"><strong>events</strong> {{ logCount }}</span>
|
|
2826
|
+
<button class="secondary" @click="debugExpanded = !debugExpanded">{{ debugExpanded ? 'Hide log' : 'Show log' }}</button>
|
|
2827
|
+
</div>
|
|
2828
|
+
</div>
|
|
2829
|
+
<div v-if="debugExpanded" class="log">{{ logsText }}</div>
|
|
2830
|
+
<div v-else class="log-preview">
|
|
2831
|
+
<div class="log-preview-head">{{ logHeadline }}</div>
|
|
2832
|
+
<ul v-if="logPreviewEntries.length" class="log-preview-list">
|
|
2833
|
+
<li v-for="entry in logPreviewEntries" :key="entry.id" class="log-preview-item">
|
|
2834
|
+
<span class="log-preview-time">{{ entry.at }}</span>
|
|
2835
|
+
<span class="log-preview-label">{{ entry.label }}</span>
|
|
2836
|
+
<span class="log-preview-text">{{ entry.preview }}</span>
|
|
2837
|
+
</li>
|
|
2838
|
+
</ul>
|
|
2839
|
+
<p v-else class="muted">No log events yet.</p>
|
|
2840
|
+
</div>
|
|
2841
|
+
</article>
|
|
2842
|
+
</div>
|
|
2843
|
+
</section>
|
|
2844
|
+
|
|
2845
|
+
<section class="card footer-note muted">
|
|
2846
|
+
<div>freertc · generic realtime signaling console</div>
|
|
2847
|
+
<div>Worker endpoint: <code>{{ wsUrl }}</code></div>
|
|
2848
|
+
</section>
|
|
2849
|
+
</main>
|
|
2850
|
+
`
|
|
2851
|
+
}).mount("#app");
|