paironce 0.0.0-stage → 0.1.1
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/LICENSE +202 -0
- package/README.md +67 -2
- package/dist/paironce.mjs +4472 -0
- package/package.json +36 -4
|
@@ -0,0 +1,4472 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// paironce CLI (Apache-2.0). Source: https://github.com/paironce/cli
|
|
3
|
+
|
|
4
|
+
// <define:import.meta.env>
|
|
5
|
+
var define_import_meta_env_default = {};
|
|
6
|
+
|
|
7
|
+
// src/env.ts
|
|
8
|
+
import { RTCDataChannel, RTCIceCandidate, RTCPeerConnection as RTCPeerConnection2, RTCSessionDescription } from "node-datachannel/polyfill";
|
|
9
|
+
var [major] = process.versions.node.split(".").map(Number);
|
|
10
|
+
if (!major || major < 22) {
|
|
11
|
+
console.error(`PairOnce needs Node.js 22 or later (this is ${process.versions.node}).`);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
Object.assign(globalThis, { RTCPeerConnection: RTCPeerConnection2, RTCSessionDescription, RTCIceCandidate, RTCDataChannel });
|
|
15
|
+
|
|
16
|
+
// src/cli.ts
|
|
17
|
+
import { createInterface as createInterface2 } from "node:readline/promises";
|
|
18
|
+
import { mkdir as mkdir3, rename as rename2, stat as stat3, writeFile as writeFile2 } from "node:fs/promises";
|
|
19
|
+
import path5 from "node:path";
|
|
20
|
+
|
|
21
|
+
// ../../node_modules/@paironce/crypto/src/index.ts
|
|
22
|
+
var encoder = new TextEncoder();
|
|
23
|
+
function webCrypto() {
|
|
24
|
+
if (!globalThis.crypto?.subtle) {
|
|
25
|
+
throw new Error("Web Crypto is unavailable in this browser");
|
|
26
|
+
}
|
|
27
|
+
return globalThis.crypto;
|
|
28
|
+
}
|
|
29
|
+
function randomBytes(length) {
|
|
30
|
+
if (!Number.isSafeInteger(length) || length <= 0) {
|
|
31
|
+
throw new RangeError("Random byte length must be a positive integer");
|
|
32
|
+
}
|
|
33
|
+
return webCrypto().getRandomValues(new Uint8Array(length));
|
|
34
|
+
}
|
|
35
|
+
function generateSessionSecret() {
|
|
36
|
+
return randomBytes(32);
|
|
37
|
+
}
|
|
38
|
+
function createNoncePrefix() {
|
|
39
|
+
return randomBytes(8);
|
|
40
|
+
}
|
|
41
|
+
async function deriveDataChannelKey(secret, hostNonce, joinNonce, context) {
|
|
42
|
+
if (secret.byteLength !== 32) throw new RangeError("A session secret must be exactly 32 bytes");
|
|
43
|
+
if (hostNonce.byteLength !== 16 || joinNonce.byteLength !== 16) {
|
|
44
|
+
throw new RangeError("DataChannel session nonces must be exactly 16 bytes");
|
|
45
|
+
}
|
|
46
|
+
const keyMaterial = await webCrypto().subtle.importKey("raw", secret, "HKDF", false, ["deriveKey"]);
|
|
47
|
+
const label = encoder.encode("PairOnce DataChannel key v1");
|
|
48
|
+
const extra = context ?? new Uint8Array(0);
|
|
49
|
+
const info = new Uint8Array(label.byteLength + hostNonce.byteLength + joinNonce.byteLength + extra.byteLength);
|
|
50
|
+
info.set(label, 0);
|
|
51
|
+
info.set(hostNonce, label.byteLength);
|
|
52
|
+
info.set(joinNonce, label.byteLength + hostNonce.byteLength);
|
|
53
|
+
info.set(extra, label.byteLength + hostNonce.byteLength + joinNonce.byteLength);
|
|
54
|
+
return webCrypto().subtle.deriveKey(
|
|
55
|
+
{
|
|
56
|
+
name: "HKDF",
|
|
57
|
+
hash: "SHA-256",
|
|
58
|
+
salt: encoder.encode("PairOnce DataChannel salt v1"),
|
|
59
|
+
info
|
|
60
|
+
},
|
|
61
|
+
keyMaterial,
|
|
62
|
+
{ name: "AES-GCM", length: 256 },
|
|
63
|
+
false,
|
|
64
|
+
["encrypt", "decrypt"]
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
async function deriveDirectionalDataChannelKeys(secret, hostNonce, joinNonce, context) {
|
|
68
|
+
const derive = (sender) => deriveDataChannelKey(
|
|
69
|
+
secret,
|
|
70
|
+
hostNonce,
|
|
71
|
+
joinNonce,
|
|
72
|
+
encoder.encode(JSON.stringify(["paironce-frame-v2", sender, context ? toBase64Url(context) : null]))
|
|
73
|
+
);
|
|
74
|
+
const [host, join] = await Promise.all([derive("host"), derive("join")]);
|
|
75
|
+
return { host, join };
|
|
76
|
+
}
|
|
77
|
+
function createIv(prefix, chunkIndex) {
|
|
78
|
+
if (prefix.byteLength !== 8) {
|
|
79
|
+
throw new RangeError("Nonce prefix must be exactly 8 bytes");
|
|
80
|
+
}
|
|
81
|
+
if (!Number.isSafeInteger(chunkIndex) || chunkIndex < 0 || chunkIndex > 4294967295) {
|
|
82
|
+
throw new RangeError("Chunk index must fit an unsigned 32-bit counter");
|
|
83
|
+
}
|
|
84
|
+
const iv = new Uint8Array(12);
|
|
85
|
+
iv.set(prefix, 0);
|
|
86
|
+
new DataView(iv.buffer).setUint32(8, chunkIndex, false);
|
|
87
|
+
return iv;
|
|
88
|
+
}
|
|
89
|
+
function additionalData(context) {
|
|
90
|
+
if (!Number.isSafeInteger(context.chunkIndex) || context.chunkIndex < 0) {
|
|
91
|
+
throw new RangeError("Invalid chunk index");
|
|
92
|
+
}
|
|
93
|
+
if (!Number.isSafeInteger(context.totalChunks) || context.totalChunks <= 0) {
|
|
94
|
+
throw new RangeError("Invalid total chunk count");
|
|
95
|
+
}
|
|
96
|
+
if (!Number.isSafeInteger(context.plainSize) || context.plainSize < 0) {
|
|
97
|
+
throw new RangeError("Invalid plaintext size");
|
|
98
|
+
}
|
|
99
|
+
return encoder.encode(
|
|
100
|
+
JSON.stringify([
|
|
101
|
+
context.channel ? 2 : 1,
|
|
102
|
+
context.transferId,
|
|
103
|
+
context.kind,
|
|
104
|
+
context.chunkIndex,
|
|
105
|
+
context.totalChunks,
|
|
106
|
+
context.plainSize,
|
|
107
|
+
...context.channel ? [context.channel.sender, context.channel.frameType] : []
|
|
108
|
+
])
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
async function encryptChunk(key, noncePrefix, plaintext, context) {
|
|
112
|
+
const ciphertext = await webCrypto().subtle.encrypt(
|
|
113
|
+
{
|
|
114
|
+
name: "AES-GCM",
|
|
115
|
+
iv: createIv(noncePrefix, context.chunkIndex),
|
|
116
|
+
additionalData: additionalData(context),
|
|
117
|
+
tagLength: 128
|
|
118
|
+
},
|
|
119
|
+
key,
|
|
120
|
+
plaintext
|
|
121
|
+
);
|
|
122
|
+
return new Uint8Array(ciphertext);
|
|
123
|
+
}
|
|
124
|
+
async function decryptChunk(key, noncePrefix, ciphertext, context) {
|
|
125
|
+
const plaintext = await webCrypto().subtle.decrypt(
|
|
126
|
+
{
|
|
127
|
+
name: "AES-GCM",
|
|
128
|
+
iv: createIv(noncePrefix, context.chunkIndex),
|
|
129
|
+
additionalData: additionalData(context),
|
|
130
|
+
tagLength: 128
|
|
131
|
+
},
|
|
132
|
+
key,
|
|
133
|
+
ciphertext
|
|
134
|
+
);
|
|
135
|
+
return new Uint8Array(plaintext);
|
|
136
|
+
}
|
|
137
|
+
function toBase64Url(bytes) {
|
|
138
|
+
let binary = "";
|
|
139
|
+
for (let offset = 0; offset < bytes.length; offset += 32768) {
|
|
140
|
+
binary += String.fromCharCode(...bytes.subarray(offset, offset + 32768));
|
|
141
|
+
}
|
|
142
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
143
|
+
}
|
|
144
|
+
function fromBase64Url(value) {
|
|
145
|
+
if (!/^[A-Za-z0-9_-]+$/.test(value)) {
|
|
146
|
+
throw new Error("Invalid base64url value");
|
|
147
|
+
}
|
|
148
|
+
const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
|
|
149
|
+
const binary = atob(padded);
|
|
150
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ../../node_modules/@paironce/protocol/src/index.ts
|
|
154
|
+
import { z } from "zod";
|
|
155
|
+
|
|
156
|
+
// ../../node_modules/@paironce/protocol/src/pro-pass.ts
|
|
157
|
+
var PRO_PASS_MS = 24 * 36e5;
|
|
158
|
+
|
|
159
|
+
// ../../node_modules/@paironce/protocol/src/index.ts
|
|
160
|
+
var PROTOCOL_VERSION = 1;
|
|
161
|
+
var descriptionSchema = z.object({
|
|
162
|
+
type: z.enum(["offer", "answer"]),
|
|
163
|
+
sdp: z.string().min(1).max(64 * 1024)
|
|
164
|
+
});
|
|
165
|
+
var iceCandidateSchema = z.object({
|
|
166
|
+
candidate: z.string().max(8 * 1024),
|
|
167
|
+
sdpMid: z.string().max(256).nullable().optional(),
|
|
168
|
+
sdpMLineIndex: z.number().int().nonnegative().nullable().optional(),
|
|
169
|
+
usernameFragment: z.string().max(256).nullable().optional()
|
|
170
|
+
});
|
|
171
|
+
var MAX_FILE_CHUNKS = 1048576;
|
|
172
|
+
var MAX_FILE_BYTES = 1e10;
|
|
173
|
+
var helloSchema = z.object({
|
|
174
|
+
v: z.literal(PROTOCOL_VERSION),
|
|
175
|
+
type: z.literal("hello"),
|
|
176
|
+
role: z.enum(["host", "join"]),
|
|
177
|
+
// Fresh per-connection nonce used to derive the application frame key.
|
|
178
|
+
// It is public handshake context, not a credential.
|
|
179
|
+
sessionNonce: z.string().regex(/^[A-Za-z0-9_-]{22}$/),
|
|
180
|
+
// The identity fields are public device descriptors. The corresponding
|
|
181
|
+
// private key never crosses the signaling channel.
|
|
182
|
+
deviceId: z.string().regex(/^[A-Za-z0-9_-]{16,128}$/).optional(),
|
|
183
|
+
deviceName: z.string().min(1).max(80).optional(),
|
|
184
|
+
devicePublicKey: z.string().regex(/^[A-Za-z0-9_-]{40,200}$/).optional(),
|
|
185
|
+
// Capabilities are negotiated per DataChannel and bound into the frame key when both sides send a list (protocol, section 5.2).
|
|
186
|
+
capabilities: z.array(z.enum(["file-approval-v1", "offer-preview-v1"])).max(8).optional(),
|
|
187
|
+
// Safety-code version (pairing-sas-v2). A separate field, not a capability: a peer that predates it drops unknown keys
|
|
188
|
+
// instead of rejecting the whole hello, so it keeps connecting and simply cannot pair with a safety code.
|
|
189
|
+
safetyCode: z.literal(2).optional(),
|
|
190
|
+
// Parsed as optional to give older peers a clear refresh message; current sessions require version 2.
|
|
191
|
+
frameProtocol: z.literal(2).optional(),
|
|
192
|
+
// Binary file-chunk frames (binary-frame.ts). A separate optional field so a peer that predates it drops the
|
|
193
|
+
// key and keeps using JSON chunks; binary frames are used only when both hellos carry it.
|
|
194
|
+
binaryFileChunks: z.literal(1).optional(),
|
|
195
|
+
// This side writes large incoming files to disk and accepts offers up to 2 GB.
|
|
196
|
+
// A separate optional field for the same reason: a peer that predates it drops the key and keeps offering up to 50 MiB.
|
|
197
|
+
largeFiles: z.literal(1).optional(),
|
|
198
|
+
// This side reports how far it has handled an incoming file and keeps a sender within its receive window
|
|
199
|
+
// (receive-window.ts). Optional for the same reason: without it in both hellos neither side changes.
|
|
200
|
+
receiveWindow: z.literal(1).optional(),
|
|
201
|
+
// This side writes files to disk and takes offers above 2 GB, up to MAX_FILE_BYTES, when its storage has room. Optional
|
|
202
|
+
// like the flags above: a peer that predates it drops the key and is never offered more than 2 GB.
|
|
203
|
+
hugeFiles: z.literal(1).optional(),
|
|
204
|
+
// This device has PairOnce Pro. Files over 2 GB need Pro on one of the two devices; a direct transfer costs the service
|
|
205
|
+
// nothing, so this is a plan boundary and the sender checks it.
|
|
206
|
+
pro: z.literal(1).optional(),
|
|
207
|
+
// The service's signed pass for that Pro (pro-pass.ts, 2.19.1): the other device turns Pro on only with a valid one.
|
|
208
|
+
// Optional like the flags above: a peer that predates it drops the key.
|
|
209
|
+
proPass: z.string().regex(/^[A-Za-z0-9_-]{40,400}\.[A-Za-z0-9_-]{86}$/).optional(),
|
|
210
|
+
// This side can show and watch a live picture (camera or screen) over this connection, set up by encrypted `live`
|
|
211
|
+
// frames. Optional like the flags above: a peer that predates it drops the key and is never sent a `live` frame.
|
|
212
|
+
liveMedia: z.literal(1).optional()
|
|
213
|
+
});
|
|
214
|
+
var signalMessageSchema = z.discriminatedUnion("type", [
|
|
215
|
+
helloSchema,
|
|
216
|
+
z.object({
|
|
217
|
+
v: z.literal(PROTOCOL_VERSION),
|
|
218
|
+
type: z.literal("offer"),
|
|
219
|
+
sessionNonce: z.string().regex(/^[A-Za-z0-9_-]{22}$/),
|
|
220
|
+
description: descriptionSchema.extend({ type: z.literal("offer") })
|
|
221
|
+
}),
|
|
222
|
+
z.object({
|
|
223
|
+
v: z.literal(PROTOCOL_VERSION),
|
|
224
|
+
type: z.literal("answer"),
|
|
225
|
+
sessionNonce: z.string().regex(/^[A-Za-z0-9_-]{22}$/),
|
|
226
|
+
description: descriptionSchema.extend({ type: z.literal("answer") })
|
|
227
|
+
}),
|
|
228
|
+
z.object({
|
|
229
|
+
v: z.literal(PROTOCOL_VERSION),
|
|
230
|
+
type: z.literal("ice"),
|
|
231
|
+
candidate: iceCandidateSchema
|
|
232
|
+
})
|
|
233
|
+
]);
|
|
234
|
+
var idSchema = z.string().regex(/^[A-Za-z0-9_-]{16,128}$/);
|
|
235
|
+
var encodedBytesSchema = z.string().regex(/^[A-Za-z0-9_-]+$/).max(256 * 1024);
|
|
236
|
+
var noncePrefixSchema = z.string().regex(/^[A-Za-z0-9_-]{11}$/);
|
|
237
|
+
var encryptedControlSchema = z.object({
|
|
238
|
+
v: z.literal(PROTOCOL_VERSION),
|
|
239
|
+
transferId: idSchema,
|
|
240
|
+
noncePrefix: noncePrefixSchema,
|
|
241
|
+
ciphertext: encodedBytesSchema,
|
|
242
|
+
plainSize: z.number().int().positive().max(4096)
|
|
243
|
+
});
|
|
244
|
+
var dataFrameSchema = z.discriminatedUnion("type", [
|
|
245
|
+
encryptedControlSchema.extend({ type: z.literal("pair-claim") }),
|
|
246
|
+
encryptedControlSchema.extend({ type: z.literal("pair-result") }),
|
|
247
|
+
encryptedControlSchema.extend({ type: z.literal("pair-confirm") }),
|
|
248
|
+
encryptedControlSchema.extend({ type: z.literal("file-offer") }),
|
|
249
|
+
encryptedControlSchema.extend({ type: z.literal("file-decision") }),
|
|
250
|
+
encryptedControlSchema.extend({ type: z.literal("file-cancel") }),
|
|
251
|
+
encryptedControlSchema.extend({ type: z.literal("file-receipt") }),
|
|
252
|
+
encryptedControlSchema.extend({ type: z.literal("file-progress") }),
|
|
253
|
+
// A small JPEG in a JSON envelope (offer-preview-v1): larger than the other control frames, still tiny.
|
|
254
|
+
encryptedControlSchema.extend({ type: z.literal("file-preview"), plainSize: z.number().int().positive().max(24 * 1024) }),
|
|
255
|
+
// Live picture set-up (live-media.ts): requests, answers, the renegotiation description and the DTLS fingerprint
|
|
256
|
+
// check. Sent only to a peer whose hello carried liveMedia; a description is larger than the other control frames.
|
|
257
|
+
encryptedControlSchema.extend({ type: z.literal("live"), plainSize: z.number().int().positive().max(32 * 1024) }),
|
|
258
|
+
z.object({
|
|
259
|
+
v: z.literal(PROTOCOL_VERSION),
|
|
260
|
+
type: z.literal("text"),
|
|
261
|
+
transferId: idSchema,
|
|
262
|
+
noncePrefix: noncePrefixSchema,
|
|
263
|
+
ciphertext: encodedBytesSchema,
|
|
264
|
+
plainSize: z.number().int().nonnegative().max(256 * 1024)
|
|
265
|
+
}),
|
|
266
|
+
z.object({
|
|
267
|
+
v: z.literal(PROTOCOL_VERSION),
|
|
268
|
+
type: z.literal("file-meta"),
|
|
269
|
+
transferId: idSchema,
|
|
270
|
+
name: z.string().min(1).max(512),
|
|
271
|
+
mime: z.string().max(256),
|
|
272
|
+
// Up to 10 GB (MAX_FILE_BYTES). The session holds each file to the limit it
|
|
273
|
+
// announced: 50 MiB unless it writes large files to disk; this schema only bounds what can be parsed.
|
|
274
|
+
size: z.number().int().nonnegative().max(MAX_FILE_BYTES),
|
|
275
|
+
chunkSize: z.number().int().min(16 * 1024).max(64 * 1024),
|
|
276
|
+
totalChunks: z.number().int().positive().max(MAX_FILE_CHUNKS),
|
|
277
|
+
noncePrefix: noncePrefixSchema,
|
|
278
|
+
// Continuing a file the receiver kept after a dropped connection: chunks before this index are already there.
|
|
279
|
+
// Optional, so a peer that predates it drops the key; a sender only sets it when the receiver asked for it.
|
|
280
|
+
resumeFrom: z.number().int().positive().max(MAX_FILE_CHUNKS - 1).optional()
|
|
281
|
+
}),
|
|
282
|
+
z.object({
|
|
283
|
+
v: z.literal(PROTOCOL_VERSION),
|
|
284
|
+
type: z.literal("file-chunk"),
|
|
285
|
+
transferId: idSchema,
|
|
286
|
+
index: z.number().int().nonnegative().max(MAX_FILE_CHUNKS - 1),
|
|
287
|
+
ciphertext: encodedBytesSchema
|
|
288
|
+
}),
|
|
289
|
+
z.object({
|
|
290
|
+
v: z.literal(PROTOCOL_VERSION),
|
|
291
|
+
type: z.literal("file-complete"),
|
|
292
|
+
transferId: idSchema
|
|
293
|
+
}),
|
|
294
|
+
z.object({
|
|
295
|
+
v: z.literal(PROTOCOL_VERSION),
|
|
296
|
+
type: z.literal("file-ack"),
|
|
297
|
+
transferId: idSchema
|
|
298
|
+
})
|
|
299
|
+
]);
|
|
300
|
+
function parseSignalMessage(input) {
|
|
301
|
+
const message = signalMessageSchema.parse(input);
|
|
302
|
+
if (message.type === "hello") {
|
|
303
|
+
const present = [message.deviceId, message.deviceName, message.devicePublicKey].filter((item) => item !== void 0).length;
|
|
304
|
+
if (present !== 0 && present !== 3) throw new Error("Device identity fields must be supplied together");
|
|
305
|
+
}
|
|
306
|
+
return message;
|
|
307
|
+
}
|
|
308
|
+
function parseDataFrame(input) {
|
|
309
|
+
return dataFrameSchema.parse(input);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ../../node_modules/@paironce/transfer/src/index.ts
|
|
313
|
+
function chunkCount(byteLength, chunkSize) {
|
|
314
|
+
if (!Number.isSafeInteger(byteLength) || byteLength < 0) throw new RangeError("Invalid length");
|
|
315
|
+
if (!Number.isSafeInteger(chunkSize) || chunkSize <= 0) throw new RangeError("Invalid chunk size");
|
|
316
|
+
return Math.max(1, Math.ceil(byteLength / chunkSize));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ../core/src/constants.ts
|
|
320
|
+
var FILE_CHUNK_SIZE = 32 * 1024;
|
|
321
|
+
|
|
322
|
+
// ../core/src/pairing.ts
|
|
323
|
+
async function deviceSessionSecret(privateKey, local, peer, nonce, room) {
|
|
324
|
+
const publicKey = await crypto.subtle.importKey("raw", fromBase64Url(peer.publicKey), { name: "ECDH", namedCurve: "P-256" }, false, []);
|
|
325
|
+
const bits = await crypto.subtle.deriveBits({ name: "ECDH", public: publicKey }, privateKey, 256);
|
|
326
|
+
const material = await crypto.subtle.importKey("raw", bits, "HKDF", false, ["deriveBits"]);
|
|
327
|
+
const devices = [local, peer].sort((a, b) => a.id < b.id ? -1 : 1).map((d) => [d.id, d.publicKey]);
|
|
328
|
+
return new Uint8Array(await crypto.subtle.deriveBits({
|
|
329
|
+
name: "HKDF",
|
|
330
|
+
hash: "SHA-256",
|
|
331
|
+
salt: nonce,
|
|
332
|
+
info: new TextEncoder().encode(JSON.stringify(["paironce-device-session-v2", room, devices]))
|
|
333
|
+
}, material, 256));
|
|
334
|
+
}
|
|
335
|
+
var SAFETY_CODE_DIGITS = 13;
|
|
336
|
+
async function derivePairingSas(privateKey, local, peer, invitationSecret, roomId) {
|
|
337
|
+
if (invitationSecret.byteLength !== 32 || !/^[A-Za-z0-9_-]{20,128}$/.test(roomId) || !isDeviceDescriptor(local) || !isDeviceDescriptor(peer)) throw new Error("Invalid pairing SAS context");
|
|
338
|
+
const peerKey = await crypto.subtle.importKey("raw", fromBase64Url(peer.publicKey), { name: "ECDH", namedCurve: "P-256" }, false, []);
|
|
339
|
+
const shared = new Uint8Array(await crypto.subtle.deriveBits({ name: "ECDH", public: peerKey }, privateKey, 256));
|
|
340
|
+
const material = await crypto.subtle.importKey("raw", shared, "HKDF", false, ["deriveBits"]);
|
|
341
|
+
const keys = [local.publicKey, peer.publicKey].sort();
|
|
342
|
+
const info = new TextEncoder().encode(JSON.stringify(["paironce-pairing-sas-v2", roomId, ...keys]));
|
|
343
|
+
const bits = await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: invitationSecret, info }, material, 64);
|
|
344
|
+
const value = new DataView(bits).getBigUint64(0, false) % 10000000000000n;
|
|
345
|
+
return String(value).padStart(SAFETY_CODE_DIGITS, "0");
|
|
346
|
+
}
|
|
347
|
+
async function deviceProofKey(input) {
|
|
348
|
+
if (input.privateKey.type !== "private" || input.privateKey.algorithm.name !== "ECDH") {
|
|
349
|
+
throw new Error("A device ECDH private key is required");
|
|
350
|
+
}
|
|
351
|
+
const publicBytes = fromBase64Url(input.peerPublicKey);
|
|
352
|
+
if (publicBytes.byteLength !== 65 || publicBytes[0] !== 4) throw new Error("Invalid device public key");
|
|
353
|
+
const publicKey = await crypto.subtle.importKey(
|
|
354
|
+
"raw",
|
|
355
|
+
publicBytes,
|
|
356
|
+
{ name: "ECDH", namedCurve: "P-256" },
|
|
357
|
+
false,
|
|
358
|
+
[]
|
|
359
|
+
);
|
|
360
|
+
const sharedBits = await crypto.subtle.deriveBits({ name: "ECDH", public: publicKey }, input.privateKey, 256);
|
|
361
|
+
const keyMaterial = await crypto.subtle.importKey("raw", sharedBits, "HKDF", false, ["deriveKey"]);
|
|
362
|
+
return crypto.subtle.deriveKey(
|
|
363
|
+
{
|
|
364
|
+
name: "HKDF",
|
|
365
|
+
hash: "SHA-256",
|
|
366
|
+
salt: input.invitationSecret,
|
|
367
|
+
info: new TextEncoder().encode("PairOnce device possession proof v1")
|
|
368
|
+
},
|
|
369
|
+
keyMaterial,
|
|
370
|
+
{ name: "HMAC", hash: "SHA-256", length: 256 },
|
|
371
|
+
false,
|
|
372
|
+
["sign", "verify"]
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
function deviceProofTranscript(input) {
|
|
376
|
+
if (!/^(?:\d{6}|\d{13})$/.test(input.code)) throw new Error("Invalid pairing code for device proof");
|
|
377
|
+
if (!/^[A-Za-z0-9_-]{16,128}$/.test(input.claimId)) throw new Error("Invalid claim ID for device proof");
|
|
378
|
+
return new TextEncoder().encode(JSON.stringify([
|
|
379
|
+
"paironce-device-proof-v1",
|
|
380
|
+
input.direction,
|
|
381
|
+
input.roomId,
|
|
382
|
+
input.claimId,
|
|
383
|
+
input.host.id,
|
|
384
|
+
input.host.publicKey,
|
|
385
|
+
input.join.id,
|
|
386
|
+
input.join.publicKey,
|
|
387
|
+
input.code
|
|
388
|
+
]));
|
|
389
|
+
}
|
|
390
|
+
async function createDeviceProof(input) {
|
|
391
|
+
const key = await deviceProofKey(input);
|
|
392
|
+
return toBase64Url(new Uint8Array(await crypto.subtle.sign("HMAC", key, deviceProofTranscript(input))));
|
|
393
|
+
}
|
|
394
|
+
async function verifyDeviceProof(input, proof) {
|
|
395
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(proof)) return false;
|
|
396
|
+
const key = await deviceProofKey(input);
|
|
397
|
+
return crypto.subtle.verify("HMAC", key, fromBase64Url(proof), deviceProofTranscript(input));
|
|
398
|
+
}
|
|
399
|
+
function isDeviceDescriptor(value) {
|
|
400
|
+
if (!value || typeof value !== "object") return false;
|
|
401
|
+
const descriptor = value;
|
|
402
|
+
return typeof descriptor.id === "string" && /^[A-Za-z0-9_-]{16,128}$/.test(descriptor.id) && typeof descriptor.name === "string" && descriptor.name.length > 0 && descriptor.name.length <= 80 && typeof descriptor.publicKey === "string" && /^[A-Za-z0-9_-]{40,200}$/.test(descriptor.publicKey);
|
|
403
|
+
}
|
|
404
|
+
function parsePairingPayload(raw) {
|
|
405
|
+
const value = JSON.parse(raw);
|
|
406
|
+
if (!value || typeof value !== "object") throw new Error("Invalid pairing message");
|
|
407
|
+
const payload = value;
|
|
408
|
+
const claimId = payload.claimId;
|
|
409
|
+
if (typeof claimId !== "string" || !/^[A-Za-z0-9_-]{16,128}$/.test(claimId)) {
|
|
410
|
+
throw new Error("Invalid pairing claim ID");
|
|
411
|
+
}
|
|
412
|
+
if (payload.type === "pair-claim") {
|
|
413
|
+
if (typeof payload.code !== "string" || !/^(?:\d{6}|\d{13})$/.test(payload.code) || !isDeviceDescriptor(payload.device) || !isDeviceProof(payload.proof)) {
|
|
414
|
+
throw new Error("Invalid pairing claim");
|
|
415
|
+
}
|
|
416
|
+
return { type: "pair-claim", claimId, code: payload.code, device: payload.device, proof: payload.proof };
|
|
417
|
+
}
|
|
418
|
+
if (payload.type === "pair-result") {
|
|
419
|
+
if (typeof payload.accepted !== "boolean") throw new Error("Invalid pairing result");
|
|
420
|
+
if (payload.terminal !== void 0 && typeof payload.terminal !== "boolean") throw new Error("Invalid pairing result");
|
|
421
|
+
if (payload.accepted && (!isDeviceDescriptor(payload.device) || !isDeviceProof(payload.proof))) {
|
|
422
|
+
throw new Error("Pairing result omitted host device or proof");
|
|
423
|
+
}
|
|
424
|
+
if (payload.accepted && payload.terminal) throw new Error("Accepted pairing cannot be terminal");
|
|
425
|
+
if (payload.reason !== void 0 && (typeof payload.reason !== "string" || payload.reason.length > 120)) {
|
|
426
|
+
throw new Error("Invalid pairing rejection reason");
|
|
427
|
+
}
|
|
428
|
+
return {
|
|
429
|
+
type: "pair-result",
|
|
430
|
+
claimId,
|
|
431
|
+
accepted: payload.accepted,
|
|
432
|
+
...isDeviceDescriptor(payload.device) ? { device: payload.device } : {},
|
|
433
|
+
...isDeviceProof(payload.proof) ? { proof: payload.proof } : {},
|
|
434
|
+
...typeof payload.reason === "string" ? { reason: payload.reason } : {},
|
|
435
|
+
...typeof payload.terminal === "boolean" ? { terminal: payload.terminal } : {}
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
if (payload.type === "pair-confirm" && isDeviceProof(payload.proof)) {
|
|
439
|
+
return { type: "pair-confirm", claimId, proof: payload.proof };
|
|
440
|
+
}
|
|
441
|
+
throw new Error("Unknown pairing message type");
|
|
442
|
+
}
|
|
443
|
+
function isDeviceProof(value) {
|
|
444
|
+
return typeof value === "string" && /^[A-Za-z0-9_-]{43}$/.test(value);
|
|
445
|
+
}
|
|
446
|
+
function equalPairingCodes(expected, actual) {
|
|
447
|
+
if (!/^(?:\d{6}|\d{13})$/.test(expected) || actual.length !== expected.length || !/^\d+$/.test(actual)) return false;
|
|
448
|
+
let mismatch = 0;
|
|
449
|
+
for (let index = 0; index < expected.length; index += 1) mismatch |= expected.charCodeAt(index) ^ actual.charCodeAt(index);
|
|
450
|
+
return mismatch === 0;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ../core/src/binary-frame.ts
|
|
454
|
+
var MAGIC = 80;
|
|
455
|
+
var VERSION = 1;
|
|
456
|
+
var FILE_CHUNK = 1;
|
|
457
|
+
var ID = /^[A-Za-z0-9_-]{16,128}$/;
|
|
458
|
+
var MAX_INDEX = MAX_FILE_CHUNKS - 1;
|
|
459
|
+
var MAX_CIPHERTEXT = 64 * 1024 + 16;
|
|
460
|
+
var MAX_BINARY_FRAME_BYTES = 8 + 128 + MAX_CIPHERTEXT;
|
|
461
|
+
function encodeFileChunk(transferId, index, ciphertext) {
|
|
462
|
+
if (!ID.test(transferId)) throw new Error("Invalid transfer ID for a binary frame");
|
|
463
|
+
if (!Number.isSafeInteger(index) || index < 0 || index > MAX_INDEX) throw new Error("Invalid chunk index for a binary frame");
|
|
464
|
+
if (ciphertext.byteLength < 17 || ciphertext.byteLength > MAX_CIPHERTEXT) throw new Error("Invalid ciphertext size for a binary frame");
|
|
465
|
+
const id = new TextEncoder().encode(transferId);
|
|
466
|
+
const frame = new Uint8Array(8 + id.byteLength + ciphertext.byteLength);
|
|
467
|
+
frame[0] = MAGIC;
|
|
468
|
+
frame[1] = VERSION;
|
|
469
|
+
frame[2] = FILE_CHUNK;
|
|
470
|
+
frame[3] = id.byteLength;
|
|
471
|
+
frame.set(id, 4);
|
|
472
|
+
new DataView(frame.buffer).setUint32(4 + id.byteLength, index, false);
|
|
473
|
+
frame.set(ciphertext, 8 + id.byteLength);
|
|
474
|
+
return frame.buffer;
|
|
475
|
+
}
|
|
476
|
+
function decodeBinaryFrame(data) {
|
|
477
|
+
if (!(data instanceof ArrayBuffer) || data.byteLength < 8 + 16 + 17 || data.byteLength > MAX_BINARY_FRAME_BYTES) {
|
|
478
|
+
throw new Error("Binary frame size is out of range");
|
|
479
|
+
}
|
|
480
|
+
const bytes = new Uint8Array(data);
|
|
481
|
+
if (bytes[0] !== MAGIC || bytes[1] !== VERSION) throw new Error("Unknown binary frame format");
|
|
482
|
+
if (bytes[2] !== FILE_CHUNK) throw new Error("Unknown binary frame type");
|
|
483
|
+
const idLength = bytes[3];
|
|
484
|
+
if (idLength < 16 || idLength > 128 || 8 + idLength + 17 > bytes.byteLength) throw new Error("Invalid binary frame header");
|
|
485
|
+
let transferId = "";
|
|
486
|
+
for (let i = 4; i < 4 + idLength; i++) transferId += String.fromCharCode(bytes[i]);
|
|
487
|
+
if (!ID.test(transferId)) throw new Error("Invalid binary frame header");
|
|
488
|
+
const index = new DataView(data).getUint32(4 + idLength, false);
|
|
489
|
+
if (index > MAX_INDEX) throw new Error("File chunk index is out of range");
|
|
490
|
+
return { type: "file-chunk", transferId, index, ciphertext: new Uint8Array(data, 8 + idLength) };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ../core/src/chunk-assembler.ts
|
|
494
|
+
var ASSEMBLY_RUN_BYTES = 4 * 1024 * 1024;
|
|
495
|
+
var ChunkAssembler = class {
|
|
496
|
+
#total;
|
|
497
|
+
#runLimit;
|
|
498
|
+
#next = 0;
|
|
499
|
+
#early = /* @__PURE__ */ new Map();
|
|
500
|
+
#run = [];
|
|
501
|
+
#runBytes = 0;
|
|
502
|
+
#parts = [];
|
|
503
|
+
constructor(totalChunks, runLimit = ASSEMBLY_RUN_BYTES) {
|
|
504
|
+
if (!Number.isSafeInteger(totalChunks) || totalChunks < 1) throw new RangeError("A file needs at least one chunk");
|
|
505
|
+
this.#total = totalChunks;
|
|
506
|
+
this.#runLimit = runLimit;
|
|
507
|
+
}
|
|
508
|
+
add(index, chunk) {
|
|
509
|
+
if (!Number.isSafeInteger(index) || index < this.#next || index >= this.#total || this.#early.has(index)) {
|
|
510
|
+
throw new Error("File chunk index is out of order");
|
|
511
|
+
}
|
|
512
|
+
this.#early.set(index, chunk);
|
|
513
|
+
for (let next = this.#early.get(this.#next); next; next = this.#early.get(this.#next)) {
|
|
514
|
+
this.#early.delete(this.#next);
|
|
515
|
+
this.#next += 1;
|
|
516
|
+
this.#run.push(next);
|
|
517
|
+
this.#runBytes += next.byteLength;
|
|
518
|
+
if (this.#runBytes >= this.#runLimit) this.#flush();
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
get complete() {
|
|
522
|
+
return this.#next === this.#total;
|
|
523
|
+
}
|
|
524
|
+
/** Bytes still held by the page (the current run and any early chunks), for tests and diagnostics. */
|
|
525
|
+
get heldBytes() {
|
|
526
|
+
let early = 0;
|
|
527
|
+
for (const chunk of this.#early.values()) early += chunk.byteLength;
|
|
528
|
+
return this.#runBytes + early;
|
|
529
|
+
}
|
|
530
|
+
/** The whole file as one Blob. Joining Blob parts does not copy their bytes into the page. */
|
|
531
|
+
finish(type) {
|
|
532
|
+
if (!this.complete) throw new Error("File completion arrived before all chunks");
|
|
533
|
+
this.#flush();
|
|
534
|
+
const blob = new Blob(this.#parts, { type });
|
|
535
|
+
this.#parts = [];
|
|
536
|
+
return blob;
|
|
537
|
+
}
|
|
538
|
+
/** Drop everything, for a cancelled or failed transfer. */
|
|
539
|
+
clear() {
|
|
540
|
+
this.#early.clear();
|
|
541
|
+
this.#run = [];
|
|
542
|
+
this.#runBytes = 0;
|
|
543
|
+
this.#parts = [];
|
|
544
|
+
}
|
|
545
|
+
#flush() {
|
|
546
|
+
if (this.#run.length === 0) return;
|
|
547
|
+
this.#parts.push(new Blob(this.#run));
|
|
548
|
+
this.#run = [];
|
|
549
|
+
this.#runBytes = 0;
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
// ../core/src/receive-window.ts
|
|
554
|
+
var RECEIVE_WINDOW_BYTES = 16 * 1024 * 1024;
|
|
555
|
+
var PROGRESS_STEP_BYTES = 2 * 1024 * 1024;
|
|
556
|
+
var RECEIVE_WINDOW_CHUNKS = RECEIVE_WINDOW_BYTES / FILE_CHUNK_SIZE;
|
|
557
|
+
var PROGRESS_STEP_CHUNKS = PROGRESS_STEP_BYTES / FILE_CHUNK_SIZE;
|
|
558
|
+
var RECEIVE_STALL_MS = 12e4;
|
|
559
|
+
var RECEIVE_STALL_MESSAGE = "The receiving browser stopped saving this file; try sending it again";
|
|
560
|
+
function progressDue(next, reported, total, step = PROGRESS_STEP_CHUNKS) {
|
|
561
|
+
return next < total && next - reported >= step;
|
|
562
|
+
}
|
|
563
|
+
var SendWindow = class {
|
|
564
|
+
constructor(start, total, limit = RECEIVE_WINDOW_CHUNKS) {
|
|
565
|
+
this.total = total;
|
|
566
|
+
this.limit = limit;
|
|
567
|
+
this.#acked = start;
|
|
568
|
+
}
|
|
569
|
+
total;
|
|
570
|
+
limit;
|
|
571
|
+
#acked;
|
|
572
|
+
#wake;
|
|
573
|
+
get acked() {
|
|
574
|
+
return this.#acked;
|
|
575
|
+
}
|
|
576
|
+
/** Take a report. Old, repeated or impossible values are ignored, so a replayed report cannot move the window. */
|
|
577
|
+
acknowledge(next) {
|
|
578
|
+
if (!Number.isSafeInteger(next) || next <= this.#acked || next > this.total) return false;
|
|
579
|
+
this.#acked = next;
|
|
580
|
+
const wake = this.#wake;
|
|
581
|
+
this.#wake = void 0;
|
|
582
|
+
wake?.();
|
|
583
|
+
return true;
|
|
584
|
+
}
|
|
585
|
+
/** Resolves once chunk `index` may be sent. Rejects when `signal` aborts or no report arrives for `stallMs`. */
|
|
586
|
+
async before(index, signal, stallMs = RECEIVE_STALL_MS) {
|
|
587
|
+
while (index - this.#acked >= this.limit) {
|
|
588
|
+
signal.throwIfAborted();
|
|
589
|
+
await new Promise((resolve, reject) => {
|
|
590
|
+
const done = (error) => {
|
|
591
|
+
clearTimeout(timer);
|
|
592
|
+
signal.removeEventListener("abort", onAbort);
|
|
593
|
+
if (this.#wake === wake) this.#wake = void 0;
|
|
594
|
+
if (error) reject(error);
|
|
595
|
+
else resolve();
|
|
596
|
+
};
|
|
597
|
+
const onAbort = () => done(signal.reason ?? new Error("File transfer cancelled"));
|
|
598
|
+
const wake = () => done();
|
|
599
|
+
const timer = setTimeout(() => done(new Error(RECEIVE_STALL_MESSAGE)), stallMs);
|
|
600
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
601
|
+
this.#wake = wake;
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
// ../../apps/app/src/abort.ts
|
|
608
|
+
function anySignal(signals) {
|
|
609
|
+
const native = AbortSignal.any;
|
|
610
|
+
if (typeof native === "function") return native.call(AbortSignal, [...signals]);
|
|
611
|
+
const controller = new AbortController();
|
|
612
|
+
const cleanups = [];
|
|
613
|
+
const finish = (source) => {
|
|
614
|
+
for (const cleanup of cleanups) cleanup();
|
|
615
|
+
cleanups.length = 0;
|
|
616
|
+
controller.abort(source.reason);
|
|
617
|
+
};
|
|
618
|
+
for (const signal of signals) {
|
|
619
|
+
if (signal.aborted) {
|
|
620
|
+
finish(signal);
|
|
621
|
+
break;
|
|
622
|
+
}
|
|
623
|
+
const onAbort = () => finish(signal);
|
|
624
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
625
|
+
cleanups.push(() => signal.removeEventListener("abort", onAbort));
|
|
626
|
+
}
|
|
627
|
+
return controller.signal;
|
|
628
|
+
}
|
|
629
|
+
function timeoutSignal(milliseconds) {
|
|
630
|
+
const native = AbortSignal.timeout;
|
|
631
|
+
if (typeof native === "function") return native.call(AbortSignal, milliseconds);
|
|
632
|
+
const controller = new AbortController();
|
|
633
|
+
setTimeout(() => {
|
|
634
|
+
let reason;
|
|
635
|
+
try {
|
|
636
|
+
reason = new DOMException("The operation timed out.", "TimeoutError");
|
|
637
|
+
} catch {
|
|
638
|
+
reason = new Error("The operation timed out.");
|
|
639
|
+
}
|
|
640
|
+
controller.abort(reason);
|
|
641
|
+
}, milliseconds);
|
|
642
|
+
return controller.signal;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// ../../apps/app/src/file-control.ts
|
|
646
|
+
var MAX_FILE_SIZE = 5e7;
|
|
647
|
+
var LARGE_FILE_MAX_BYTES = 2e9;
|
|
648
|
+
var PRO_FILE_MAX_BYTES = 1e10;
|
|
649
|
+
function isTransferId(value) {
|
|
650
|
+
return typeof value === "string" && /^[A-Za-z0-9_-]{16,128}$/.test(value);
|
|
651
|
+
}
|
|
652
|
+
function parseFileControlPayload(raw, maxSize = MAX_FILE_SIZE) {
|
|
653
|
+
let value;
|
|
654
|
+
try {
|
|
655
|
+
value = JSON.parse(raw);
|
|
656
|
+
} catch {
|
|
657
|
+
throw new Error("Invalid file control message");
|
|
658
|
+
}
|
|
659
|
+
if (!value || typeof value !== "object") throw new Error("Invalid file control message");
|
|
660
|
+
const input = value;
|
|
661
|
+
if (!isTransferId(input.transferId) || typeof input.type !== "string") throw new Error("Invalid file control message");
|
|
662
|
+
if (input.type === "file-offer") {
|
|
663
|
+
const size = input.size;
|
|
664
|
+
const chunkSize = input.chunkSize;
|
|
665
|
+
const totalChunks = input.totalChunks;
|
|
666
|
+
if (typeof input.name !== "string" || input.name.length < 1 || input.name.length > 512 || typeof input.mime !== "string" || input.mime.length > 256 || !Number.isSafeInteger(size) || size < 0 || size > maxSize || chunkSize !== FILE_CHUNK_SIZE || !Number.isSafeInteger(totalChunks) || totalChunks !== chunkCount(size, FILE_CHUNK_SIZE)) throw new Error("Invalid file offer");
|
|
667
|
+
if (input.resumeKey !== void 0 && !isTransferId(input.resumeKey)) throw new Error("Invalid file offer");
|
|
668
|
+
return {
|
|
669
|
+
type: "file-offer",
|
|
670
|
+
transferId: input.transferId,
|
|
671
|
+
name: input.name,
|
|
672
|
+
mime: input.mime,
|
|
673
|
+
size,
|
|
674
|
+
chunkSize,
|
|
675
|
+
totalChunks,
|
|
676
|
+
...input.resumeKey !== void 0 ? { resumeKey: input.resumeKey } : {}
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
if (input.type === "file-decision") {
|
|
680
|
+
if (!["accept", "reject", "busy", "timeout"].includes(input.decision)) throw new Error("Invalid file decision");
|
|
681
|
+
if (input.resumeFrom !== void 0 && (input.decision !== "accept" || !Number.isSafeInteger(input.resumeFrom) || input.resumeFrom < 1)) {
|
|
682
|
+
throw new Error("Invalid file decision");
|
|
683
|
+
}
|
|
684
|
+
if (input.privateMeta !== void 0 && (input.privateMeta !== true || input.decision !== "accept")) throw new Error("Invalid file decision");
|
|
685
|
+
return {
|
|
686
|
+
type: "file-decision",
|
|
687
|
+
transferId: input.transferId,
|
|
688
|
+
decision: input.decision,
|
|
689
|
+
...input.resumeFrom !== void 0 ? { resumeFrom: input.resumeFrom } : {},
|
|
690
|
+
...input.privateMeta === true ? { privateMeta: true } : {}
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
if (input.type === "file-cancel") {
|
|
694
|
+
if (input.reason !== void 0 && (typeof input.reason !== "string" || input.reason.length > 256)) throw new Error("Invalid file cancellation");
|
|
695
|
+
return { type: "file-cancel", transferId: input.transferId, reason: input.reason };
|
|
696
|
+
}
|
|
697
|
+
if (input.type === "file-receipt") {
|
|
698
|
+
if (input.stage !== "received" && input.stage !== "saved") throw new Error("Invalid file receipt");
|
|
699
|
+
return { type: "file-receipt", transferId: input.transferId, stage: input.stage };
|
|
700
|
+
}
|
|
701
|
+
if (input.type === "file-progress") {
|
|
702
|
+
if (!Number.isSafeInteger(input.next) || input.next < 1) throw new Error("Invalid file progress");
|
|
703
|
+
return { type: "file-progress", transferId: input.transferId, next: input.next };
|
|
704
|
+
}
|
|
705
|
+
throw new Error("Unknown file control message");
|
|
706
|
+
}
|
|
707
|
+
function offerToPayload(offer) {
|
|
708
|
+
return { type: "file-offer", ...offer };
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// ../../apps/app/src/debug-flags.ts
|
|
712
|
+
function fromAddress(flag) {
|
|
713
|
+
try {
|
|
714
|
+
return typeof window !== "undefined" && new URL(window.location.href).searchParams.get(flag) === "1";
|
|
715
|
+
} catch {
|
|
716
|
+
return false;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
function fromStorage(flag) {
|
|
720
|
+
try {
|
|
721
|
+
return typeof localStorage !== "undefined" && localStorage.getItem(`paironce.${flag}`) === "1";
|
|
722
|
+
} catch {
|
|
723
|
+
return false;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
function debugFlagOff(flag) {
|
|
727
|
+
try {
|
|
728
|
+
return typeof localStorage !== "undefined" && localStorage.getItem(`paironce.${flag}`) === "0";
|
|
729
|
+
} catch {
|
|
730
|
+
return false;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
function debugFlag(flag) {
|
|
734
|
+
return define_import_meta_env_default.DEV && fromAddress(flag) || fromStorage(flag);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ../../apps/app/src/offer-preview.ts
|
|
738
|
+
var PREVIEW_MAX_BYTES = 12 * 1024;
|
|
739
|
+
var PREVIEW_MAX_SIDE = 160;
|
|
740
|
+
var PREVIEW_MAX_DECODE_SIDE = 256;
|
|
741
|
+
var PREVIEW_MIN_BYTES = 125;
|
|
742
|
+
var PREVIEW_QUALITIES = [0.72, 0.58, 0.45, 0.32];
|
|
743
|
+
var PREVIEW_SIDES = [PREVIEW_MAX_SIDE, 112];
|
|
744
|
+
var PREVIEW_TIMEOUT_MS = 4e3;
|
|
745
|
+
var SOURCE_TYPES = /^image\/(?:jpeg|png|webp)$/;
|
|
746
|
+
var MAX_SOURCE_BYTES = 5e7;
|
|
747
|
+
function previewEligible(mime, size) {
|
|
748
|
+
return SOURCE_TYPES.test(mime.split(";", 1)[0].trim().toLowerCase()) && Number.isSafeInteger(size) && size > 0 && size <= MAX_SOURCE_BYTES;
|
|
749
|
+
}
|
|
750
|
+
function fitWithin(width, height, max) {
|
|
751
|
+
if (!(width > 0) || !(height > 0)) return { width: 1, height: 1 };
|
|
752
|
+
const scale = Math.min(1, max / Math.max(width, height));
|
|
753
|
+
return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)) };
|
|
754
|
+
}
|
|
755
|
+
function jpegDimensions(bytes) {
|
|
756
|
+
if (bytes.length < 4 || bytes[0] !== 255 || bytes[1] !== 216) return void 0;
|
|
757
|
+
let at = 2;
|
|
758
|
+
while (at + 4 <= bytes.length) {
|
|
759
|
+
if (bytes[at] !== 255) return void 0;
|
|
760
|
+
let marker = bytes[at + 1];
|
|
761
|
+
while (marker === 255 && at + 2 < bytes.length) {
|
|
762
|
+
at += 1;
|
|
763
|
+
marker = bytes[at + 1];
|
|
764
|
+
}
|
|
765
|
+
if (marker === 216 || marker === 1 || marker >= 208 && marker <= 215) {
|
|
766
|
+
at += 2;
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
if (marker === 217 || marker === 218) return void 0;
|
|
770
|
+
const length = bytes[at + 2] << 8 | bytes[at + 3];
|
|
771
|
+
if (length < 2 || at + 2 + length > bytes.length) return void 0;
|
|
772
|
+
if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204) {
|
|
773
|
+
if (length < 8) return void 0;
|
|
774
|
+
const height = bytes[at + 5] << 8 | bytes[at + 6];
|
|
775
|
+
const width = bytes[at + 7] << 8 | bytes[at + 8];
|
|
776
|
+
return width > 0 && height > 0 ? { width, height } : void 0;
|
|
777
|
+
}
|
|
778
|
+
at += 2 + length;
|
|
779
|
+
}
|
|
780
|
+
return void 0;
|
|
781
|
+
}
|
|
782
|
+
function validateOfferPreview(bytes) {
|
|
783
|
+
if (bytes.length < PREVIEW_MIN_BYTES || bytes.length > PREVIEW_MAX_BYTES) return false;
|
|
784
|
+
if (bytes[bytes.length - 2] !== 255 || bytes[bytes.length - 1] !== 217) return false;
|
|
785
|
+
const size = jpegDimensions(bytes);
|
|
786
|
+
return size !== void 0 && size.width <= PREVIEW_MAX_DECODE_SIDE && size.height <= PREVIEW_MAX_DECODE_SIDE;
|
|
787
|
+
}
|
|
788
|
+
function encodeOfferPreviewPayload(transferId, image) {
|
|
789
|
+
return new TextEncoder().encode(JSON.stringify({ type: "file-preview", transferId, mime: "image/jpeg", data: toBase64Url(image) }));
|
|
790
|
+
}
|
|
791
|
+
function parseOfferPreviewPayload(raw, transferId) {
|
|
792
|
+
let value;
|
|
793
|
+
try {
|
|
794
|
+
value = JSON.parse(raw);
|
|
795
|
+
} catch {
|
|
796
|
+
return void 0;
|
|
797
|
+
}
|
|
798
|
+
if (!value || typeof value !== "object") return void 0;
|
|
799
|
+
const input = value;
|
|
800
|
+
if (input.type !== "file-preview" || input.transferId !== transferId || input.mime !== "image/jpeg" || typeof input.data !== "string") return void 0;
|
|
801
|
+
if (input.data.length > Math.ceil(PREVIEW_MAX_BYTES * 4 / 3) + 4) return void 0;
|
|
802
|
+
let image;
|
|
803
|
+
try {
|
|
804
|
+
image = fromBase64Url(input.data);
|
|
805
|
+
} catch {
|
|
806
|
+
return void 0;
|
|
807
|
+
}
|
|
808
|
+
return validateOfferPreview(image) ? image : void 0;
|
|
809
|
+
}
|
|
810
|
+
function createCanvas(width, height) {
|
|
811
|
+
try {
|
|
812
|
+
if (typeof OffscreenCanvas !== "undefined") return new OffscreenCanvas(width, height);
|
|
813
|
+
if (typeof document !== "undefined") {
|
|
814
|
+
const canvas = document.createElement("canvas");
|
|
815
|
+
canvas.width = width;
|
|
816
|
+
canvas.height = height;
|
|
817
|
+
return canvas;
|
|
818
|
+
}
|
|
819
|
+
} catch {
|
|
820
|
+
}
|
|
821
|
+
return void 0;
|
|
822
|
+
}
|
|
823
|
+
function encodeJpeg(canvas, quality) {
|
|
824
|
+
if (typeof OffscreenCanvas !== "undefined" && canvas instanceof OffscreenCanvas) return canvas.convertToBlob({ type: "image/jpeg", quality });
|
|
825
|
+
return new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", quality));
|
|
826
|
+
}
|
|
827
|
+
function withTimeout(work, ms) {
|
|
828
|
+
return new Promise((resolve, reject) => {
|
|
829
|
+
const timer = setTimeout(() => reject(new Error("preview timed out")), ms);
|
|
830
|
+
work.then((value) => {
|
|
831
|
+
clearTimeout(timer);
|
|
832
|
+
resolve(value);
|
|
833
|
+
}, (error) => {
|
|
834
|
+
clearTimeout(timer);
|
|
835
|
+
reject(error);
|
|
836
|
+
});
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
async function makeOfferPreview(file) {
|
|
840
|
+
if (typeof createImageBitmap !== "function" || !previewEligible(file.type, file.size)) return void 0;
|
|
841
|
+
let bitmap;
|
|
842
|
+
try {
|
|
843
|
+
bitmap = await withTimeout(createImageBitmap(file, { imageOrientation: "from-image" }), PREVIEW_TIMEOUT_MS);
|
|
844
|
+
for (const side of PREVIEW_SIDES) {
|
|
845
|
+
const { width, height } = fitWithin(bitmap.width, bitmap.height, side);
|
|
846
|
+
const canvas = createCanvas(width, height);
|
|
847
|
+
const context = canvas?.getContext("2d");
|
|
848
|
+
if (!canvas || !context) return void 0;
|
|
849
|
+
context.fillStyle = "#ffffff";
|
|
850
|
+
context.fillRect(0, 0, width, height);
|
|
851
|
+
context.drawImage(bitmap, 0, 0, width, height);
|
|
852
|
+
for (const quality of PREVIEW_QUALITIES) {
|
|
853
|
+
const blob = await encodeJpeg(canvas, quality);
|
|
854
|
+
if (!blob) return void 0;
|
|
855
|
+
const bytes = new Uint8Array(await blob.arrayBuffer());
|
|
856
|
+
if (bytes.byteLength <= PREVIEW_MAX_BYTES) return validateOfferPreview(bytes) ? bytes : void 0;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
return void 0;
|
|
860
|
+
} catch {
|
|
861
|
+
return void 0;
|
|
862
|
+
} finally {
|
|
863
|
+
bitmap?.close();
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// ../../apps/app/src/live-media.ts
|
|
868
|
+
var RECORDING_MS = { free: 5 * 6e4, pro: 60 * 6e4 };
|
|
869
|
+
var LIVE_CONTROL_MAX_BYTES = 32 * 1024;
|
|
870
|
+
var LIVE_ANSWER_TIMEOUT_MS = 6e4;
|
|
871
|
+
var LIVE_NEGOTIATION_TIMEOUT_MS = 15e3;
|
|
872
|
+
var ID2 = /^[A-Za-z0-9_-]{16,128}$/;
|
|
873
|
+
var FINGERPRINT = /^sha-(?:256|384|512) (?:[0-9A-F]{2}:){31,63}[0-9A-F]{2}$/;
|
|
874
|
+
function exactKeys(value, keys) {
|
|
875
|
+
return Object.keys(value).sort().join(",") === [...keys].sort().join(",");
|
|
876
|
+
}
|
|
877
|
+
function parseLivePayload(text) {
|
|
878
|
+
let value;
|
|
879
|
+
try {
|
|
880
|
+
value = JSON.parse(text);
|
|
881
|
+
} catch {
|
|
882
|
+
throw new Error("Invalid live control message");
|
|
883
|
+
}
|
|
884
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid live control message");
|
|
885
|
+
const v = value;
|
|
886
|
+
const id = typeof v.id === "string" && ID2.test(v.id);
|
|
887
|
+
switch (v.op) {
|
|
888
|
+
case "fingerprint":
|
|
889
|
+
if (exactKeys(v, ["op", "value"]) && typeof v.value === "string" && FINGERPRINT.test(v.value)) return { op: "fingerprint", value: v.value };
|
|
890
|
+
break;
|
|
891
|
+
case "request":
|
|
892
|
+
if (exactKeys(v, ["op", "id", "kind"]) && id && (v.kind === "camera" || v.kind === "screen")) return { op: "request", id: v.id, kind: v.kind };
|
|
893
|
+
break;
|
|
894
|
+
case "answer":
|
|
895
|
+
if (exactKeys(v, ["op", "id", "accept"]) && id && typeof v.accept === "boolean") return { op: "answer", id: v.id, accept: v.accept };
|
|
896
|
+
break;
|
|
897
|
+
case "sdp": {
|
|
898
|
+
const d = v.description;
|
|
899
|
+
if (exactKeys(v, ["op", "id", "description"]) && id && d && typeof d === "object" && exactKeys(d, ["type", "sdp"]) && (d.type === "offer" || d.type === "answer") && typeof d.sdp === "string" && d.sdp.length > 0 && d.sdp.length <= LIVE_CONTROL_MAX_BYTES)
|
|
900
|
+
return { op: "sdp", id: v.id, description: { type: d.type, sdp: d.sdp } };
|
|
901
|
+
break;
|
|
902
|
+
}
|
|
903
|
+
case "stop":
|
|
904
|
+
case "shutter":
|
|
905
|
+
if (exactKeys(v, ["op", "id"]) && id) return { op: v.op, id: v.id };
|
|
906
|
+
break;
|
|
907
|
+
case "shutter-file":
|
|
908
|
+
if (exactKeys(v, ["op", "id", "transferId"]) && id && typeof v.transferId === "string" && ID2.test(v.transferId))
|
|
909
|
+
return { op: "shutter-file", id: v.id, transferId: v.transferId };
|
|
910
|
+
break;
|
|
911
|
+
}
|
|
912
|
+
throw new Error("Invalid live control message");
|
|
913
|
+
}
|
|
914
|
+
function sdpFingerprints(sdp) {
|
|
915
|
+
if (!sdp) return [];
|
|
916
|
+
const found = /* @__PURE__ */ new Set();
|
|
917
|
+
for (const match of sdp.matchAll(/^a=fingerprint:(\S+) ([0-9A-Fa-f:]+)\s*$/gm)) found.add(`${match[1].toLowerCase()} ${match[2].toUpperCase()}`);
|
|
918
|
+
return [...found];
|
|
919
|
+
}
|
|
920
|
+
function fingerprintMatches(reported, remoteSdp) {
|
|
921
|
+
const remote = sdpFingerprints(remoteSdp);
|
|
922
|
+
return remote.length === 1 && remote[0] === reported;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// ../../apps/app/src/session.ts
|
|
926
|
+
var PRO_FILE_MESSAGE = "Files over 2 GB need PairOnce Pro on one of the two devices.";
|
|
927
|
+
var RELAY_CREDENTIAL_ATTEMPTS = 3;
|
|
928
|
+
var NEARBY_RENEW_MS = 25e3;
|
|
929
|
+
var RETRYABLE_RELAY_STATUS = /* @__PURE__ */ new Set([500, 502, 503, 504]);
|
|
930
|
+
var PRIVATE_META_NAME = "-";
|
|
931
|
+
var MAX_RECEIVE_BYTES = 1e8;
|
|
932
|
+
var RELAY_SESSION_BYTE_LIMIT = 32 * 1024 * 1024;
|
|
933
|
+
var RELAY_LIMIT_MESSAGE = "This connection uses the relay, and the relay allowance left for this device is not enough for this file. Choose a smaller file, or put both devices on the same Wi-Fi.";
|
|
934
|
+
var RELAY_QUOTA_MESSAGE = "This device has used this month's relay allowance";
|
|
935
|
+
var RELAY_REPORT_BYTES = 4 * 1024 * 1024;
|
|
936
|
+
var MAX_DATA_FRAME_CHARS = 512 * 1024;
|
|
937
|
+
var MAX_QUEUED_FRAMES = 8192;
|
|
938
|
+
var MAX_QUEUED_BYTES = 128 * 1024 * 1024;
|
|
939
|
+
var MAX_CONTROL_FRAME_KEYS = 2e5;
|
|
940
|
+
var BUFFER_HIGH_WATER = 512 * 1024;
|
|
941
|
+
var BUFFER_POLL_MS = 250;
|
|
942
|
+
var DIRECT_RETRY_TIMEOUT_MS = 15e3;
|
|
943
|
+
var DISCONNECT_RECOVERY_TIMEOUT_MS = 45e3;
|
|
944
|
+
var FILE_ACK_TIMEOUT_MS = 3e4;
|
|
945
|
+
var READ_BLOCK_BYTES = 32 * FILE_CHUNK_SIZE;
|
|
946
|
+
var FILE_OFFER_TIMEOUT_MS = 18e4;
|
|
947
|
+
var FILE_DECISION_TIMEOUT_MS = FILE_OFFER_TIMEOUT_MS + 15e3;
|
|
948
|
+
var FILE_META_TIMEOUT_MS = 3e4;
|
|
949
|
+
var MAX_PENDING_FILE_OFFERS = 16;
|
|
950
|
+
var LIVE_RELAY_BITRATE = 15e5;
|
|
951
|
+
var MAX_FILE_HISTORY = 256;
|
|
952
|
+
var PAIRING_RESPONSE_TIMEOUT_MS = 9e4;
|
|
953
|
+
var MAX_PAIRING_ATTEMPTS = 3;
|
|
954
|
+
function shouldRetryWithTurn(input) {
|
|
955
|
+
return (input.relayAllowed ?? true) && !input.forceRelay && input.retryCount < 1 && !input.channelReady;
|
|
956
|
+
}
|
|
957
|
+
function isLoopbackHostname(hostname) {
|
|
958
|
+
return /^(localhost|127(?:\.\d{1,3}){3}|\[::1\])$/.test(hostname);
|
|
959
|
+
}
|
|
960
|
+
function signalingOrigin() {
|
|
961
|
+
const configured = "https://paironce.com"?.trim() || void 0;
|
|
962
|
+
if (typeof window !== "undefined" && isLoopbackHostname(window.location.hostname)) {
|
|
963
|
+
if (configured) {
|
|
964
|
+
try {
|
|
965
|
+
const endpoint = new URL(configured);
|
|
966
|
+
if (/^(https?|wss?):$/.test(endpoint.protocol) && isLoopbackHostname(endpoint.hostname)) return configured;
|
|
967
|
+
} catch {
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
return window.location.origin;
|
|
971
|
+
}
|
|
972
|
+
return configured ?? (typeof window !== "undefined" ? window.location.origin : "http://localhost:8787");
|
|
973
|
+
}
|
|
974
|
+
function acceptFrameKey(seen, key) {
|
|
975
|
+
if (!key || seen.has(key)) return false;
|
|
976
|
+
seen.add(key);
|
|
977
|
+
return true;
|
|
978
|
+
}
|
|
979
|
+
var UNOPENED_PART = {
|
|
980
|
+
write: async () => {
|
|
981
|
+
throw new Error("This kept part is not open");
|
|
982
|
+
},
|
|
983
|
+
finish: async () => {
|
|
984
|
+
throw new Error("This kept part is not open");
|
|
985
|
+
},
|
|
986
|
+
abort: async () => {
|
|
987
|
+
},
|
|
988
|
+
park: async () => {
|
|
989
|
+
}
|
|
990
|
+
};
|
|
991
|
+
function abortable(operation, signal) {
|
|
992
|
+
return new Promise((resolve, reject) => {
|
|
993
|
+
const onAbort = () => {
|
|
994
|
+
cleanup();
|
|
995
|
+
reject(signal.reason);
|
|
996
|
+
};
|
|
997
|
+
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
|
998
|
+
operation.then((value) => {
|
|
999
|
+
cleanup();
|
|
1000
|
+
if (!signal.aborted) resolve(value);
|
|
1001
|
+
}, (error) => {
|
|
1002
|
+
cleanup();
|
|
1003
|
+
reject(error);
|
|
1004
|
+
});
|
|
1005
|
+
if (signal.aborted) onAbort();
|
|
1006
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
var SIGNAL_PING_MS = 25e3;
|
|
1010
|
+
var SIGNAL_PING = '{"type":"ping"}';
|
|
1011
|
+
var SIGNAL_PONG = '{"type":"pong"}';
|
|
1012
|
+
var CODE_RATE_LIMIT_RETRY_MS = [5e3, 15e3, 45e3];
|
|
1013
|
+
function codeRetryDelay(status, limitedSoFar) {
|
|
1014
|
+
return status === 429 ? CODE_RATE_LIMIT_RETRY_MS[limitedSoFar] : void 0;
|
|
1015
|
+
}
|
|
1016
|
+
var PeerSession = class {
|
|
1017
|
+
#roomId;
|
|
1018
|
+
#role;
|
|
1019
|
+
#secret;
|
|
1020
|
+
#callbacks;
|
|
1021
|
+
#options;
|
|
1022
|
+
#sessionId = toBase64Url(randomBytes(24));
|
|
1023
|
+
#deviceClaim;
|
|
1024
|
+
#receiveQueuedFrames = 0;
|
|
1025
|
+
#receiveQueuedBytes = 0;
|
|
1026
|
+
/** Fresh public context for deriving the current DataChannel frame key. */
|
|
1027
|
+
#channelNonce = randomBytes(16);
|
|
1028
|
+
#peerChannelNonce;
|
|
1029
|
+
#incomingFiles = /* @__PURE__ */ new Map();
|
|
1030
|
+
#pendingFileAcks = /* @__PURE__ */ new Map();
|
|
1031
|
+
/** Files being sent under the peer's receive window, by transfer ID. */
|
|
1032
|
+
#sendWindows = /* @__PURE__ */ new Map();
|
|
1033
|
+
#pendingFileDecisions = /* @__PURE__ */ new Map();
|
|
1034
|
+
#activeFileAborters = /* @__PURE__ */ new Map();
|
|
1035
|
+
#pendingFileOffers = /* @__PURE__ */ new Map();
|
|
1036
|
+
#acceptedFileOffers = /* @__PURE__ */ new Map();
|
|
1037
|
+
#receivedFileHistory = /* @__PURE__ */ new Map();
|
|
1038
|
+
#closedFileTransfers = /* @__PURE__ */ new Set();
|
|
1039
|
+
#closedFileTransferOrder = [];
|
|
1040
|
+
#seenPairingClaims = /* @__PURE__ */ new Set();
|
|
1041
|
+
#receivedFrameKeys = /* @__PURE__ */ new Set();
|
|
1042
|
+
#controlFrameKeys = 0;
|
|
1043
|
+
#pendingCandidates = [];
|
|
1044
|
+
#socket;
|
|
1045
|
+
#signalKeepAlive;
|
|
1046
|
+
#nearbyTimer;
|
|
1047
|
+
#nearbyListed = false;
|
|
1048
|
+
#peer;
|
|
1049
|
+
#channel;
|
|
1050
|
+
#frameKey;
|
|
1051
|
+
#frameKeyPromise;
|
|
1052
|
+
#peerFrameProtocol = false;
|
|
1053
|
+
#agreementPeer;
|
|
1054
|
+
#peerPromise;
|
|
1055
|
+
#offered = false;
|
|
1056
|
+
#echoedHello = false;
|
|
1057
|
+
#receiveChain = Promise.resolve();
|
|
1058
|
+
#signalChain = Promise.resolve();
|
|
1059
|
+
#forceRelay;
|
|
1060
|
+
#retryCount = 0;
|
|
1061
|
+
#connectionReady = false;
|
|
1062
|
+
#connectionTimer;
|
|
1063
|
+
#disconnectTimer;
|
|
1064
|
+
#disconnectRetryTimer;
|
|
1065
|
+
#relayExpiresAt;
|
|
1066
|
+
#relayExpiryTimer;
|
|
1067
|
+
#relayRenewalTimer;
|
|
1068
|
+
#relayRenewing = false;
|
|
1069
|
+
#peerGeneration = 0;
|
|
1070
|
+
#retryPromise;
|
|
1071
|
+
#closed = false;
|
|
1072
|
+
#transportAbort = new AbortController();
|
|
1073
|
+
#paired = false;
|
|
1074
|
+
#pairedPeerIdentity;
|
|
1075
|
+
#pairingAttempts = 0;
|
|
1076
|
+
#pairingClaimId;
|
|
1077
|
+
#pairingCodeEntered;
|
|
1078
|
+
#pairingCodeForClaim;
|
|
1079
|
+
#pendingPeerIdentity;
|
|
1080
|
+
#awaitingHostApproval = false;
|
|
1081
|
+
#pairingDenied = false;
|
|
1082
|
+
#readyPath;
|
|
1083
|
+
#pairingTimer;
|
|
1084
|
+
#relayChallengePending;
|
|
1085
|
+
#peerCapabilities = /* @__PURE__ */ new Set();
|
|
1086
|
+
// Whether the peer's hello carried a capabilities field at all. Only then does the frame key bind the lists.
|
|
1087
|
+
#peerSentCapabilities = false;
|
|
1088
|
+
// Whether the peer's hello announced the safety-code version (pairing-sas-v2).
|
|
1089
|
+
#peerSafetyCode = false;
|
|
1090
|
+
/** This page announces binary file-chunk frames. */
|
|
1091
|
+
#localBinaryChunks;
|
|
1092
|
+
/** Both hellos announced binary file-chunk frames (binary-frame.ts). */
|
|
1093
|
+
#peerBinaryChunks = false;
|
|
1094
|
+
// The peer writes large files to disk and takes offers up to LARGE_FILE_MAX_BYTES.
|
|
1095
|
+
#peerLargeFiles = false;
|
|
1096
|
+
#peerHugeFiles = false;
|
|
1097
|
+
#peerPro = false;
|
|
1098
|
+
// Both hellos announced the receive window (receive-window.ts, 2026-10-21 audit F02).
|
|
1099
|
+
#peerReceiveWindow = false;
|
|
1100
|
+
#peerLiveMedia = false;
|
|
1101
|
+
/** The peer's DTLS fingerprint, reported over the encrypted channel, matched the description signaling delivered. */
|
|
1102
|
+
#liveVerified = false;
|
|
1103
|
+
#pendingLiveFingerprint;
|
|
1104
|
+
#live;
|
|
1105
|
+
/** The live picture that ended last: its closing renegotiation is still accepted. */
|
|
1106
|
+
#liveLastId;
|
|
1107
|
+
#liveAnswer;
|
|
1108
|
+
#liveSdpAnswer;
|
|
1109
|
+
#liveChain = Promise.resolve();
|
|
1110
|
+
#realtimeOnly;
|
|
1111
|
+
#relayFallback;
|
|
1112
|
+
/** Payload bytes sent, received or reserved on this connection while it runs through the relay. */
|
|
1113
|
+
#relayBytes = 0;
|
|
1114
|
+
/** The most #relayBytes may reach: what was reported plus what the server said is left, or 32 MiB without an allowance. */
|
|
1115
|
+
#relayLimit = RELAY_SESSION_BYTE_LIMIT;
|
|
1116
|
+
/** Whether the server names a monthly allowance; only then is usage reported. */
|
|
1117
|
+
#relayAllowance = false;
|
|
1118
|
+
#relayReported = 0;
|
|
1119
|
+
#relayUnreported = 0;
|
|
1120
|
+
#relayReporting;
|
|
1121
|
+
constructor(roomId, role, secret, callbacks, options = {}) {
|
|
1122
|
+
if (!/^[A-Za-z0-9_-]{20,128}$/.test(roomId)) throw new Error("Invalid room ID");
|
|
1123
|
+
if (secret.byteLength !== 32) throw new Error("Invalid invitation secret");
|
|
1124
|
+
this.#roomId = roomId;
|
|
1125
|
+
this.#role = role;
|
|
1126
|
+
this.#secret = secret;
|
|
1127
|
+
this.#callbacks = callbacks;
|
|
1128
|
+
this.#options = options;
|
|
1129
|
+
this.#realtimeOnly = true;
|
|
1130
|
+
this.#relayFallback = true;
|
|
1131
|
+
this.#agreementPeer = options.deviceKeyAgreement ? options.expectedPeerIdentity : void 0;
|
|
1132
|
+
if (options.deviceKeyAgreement && !options.requirePairing) throw new Error("Device key agreement requires explicit pairing approval");
|
|
1133
|
+
if (options.requirePairing) {
|
|
1134
|
+
if (!options.identity) throw new Error("A local device identity is required for pairing");
|
|
1135
|
+
if (!options.identityPrivateKey || options.identityPrivateKey.type !== "private" || options.identityPrivateKey.algorithm.name !== "ECDH" || options.identityPrivateKey.extractable) {
|
|
1136
|
+
throw new Error("The non-exportable device ECDH private key is required for pairing");
|
|
1137
|
+
}
|
|
1138
|
+
if (role === "host" && (!options.deviceKeyAgreement && !/^\d{6}$/.test(options.pairingCode ?? "") || !Number.isSafeInteger(options.pairingExpiresAt))) {
|
|
1139
|
+
throw new Error("A six-digit code and expiry are required for the pairing host");
|
|
1140
|
+
}
|
|
1141
|
+
if (role === "join" && !isDeviceDescriptor(options.expectedPeerIdentity)) {
|
|
1142
|
+
throw new Error("The invitation must pin the expected host device identity");
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
this.#forceRelay = !options.directOnly && (!this.#realtimeOnly || this.#relayFallback) && (options.forceRelay ?? debugFlag("forceRelay"));
|
|
1146
|
+
this.#localBinaryChunks = options.binaryFileChunks ?? !debugFlagOff("binaryChunks");
|
|
1147
|
+
}
|
|
1148
|
+
async connect() {
|
|
1149
|
+
this.#closed = false;
|
|
1150
|
+
this.#sessionId = toBase64Url(randomBytes(24));
|
|
1151
|
+
this.#deviceClaim = await this.#options.deviceClaim?.(this.#sessionId).catch(() => void 0);
|
|
1152
|
+
this.#channelNonce = randomBytes(16);
|
|
1153
|
+
this.#peerChannelNonce = void 0;
|
|
1154
|
+
this.#peerFrameProtocol = false;
|
|
1155
|
+
this.#frameKey = void 0;
|
|
1156
|
+
this.#frameKeyPromise = void 0;
|
|
1157
|
+
this.#paired = false;
|
|
1158
|
+
this.#pairedPeerIdentity = void 0;
|
|
1159
|
+
this.#peerCapabilities.clear();
|
|
1160
|
+
this.#peerSentCapabilities = false;
|
|
1161
|
+
this.#peerSafetyCode = false;
|
|
1162
|
+
this.#peerBinaryChunks = false;
|
|
1163
|
+
this.#peerLargeFiles = false;
|
|
1164
|
+
this.#peerHugeFiles = false;
|
|
1165
|
+
this.#peerPro = false;
|
|
1166
|
+
this.#peerReceiveWindow = false;
|
|
1167
|
+
this.#peerLiveMedia = false;
|
|
1168
|
+
this.#liveVerified = false;
|
|
1169
|
+
this.#pendingLiveFingerprint = void 0;
|
|
1170
|
+
this.#live = void 0;
|
|
1171
|
+
this.#openSignaling(false);
|
|
1172
|
+
}
|
|
1173
|
+
#openSignaling(recovering) {
|
|
1174
|
+
const endpoint = this.#edgeEndpoint(`/v1/pair/${this.#roomId}`, "websocket");
|
|
1175
|
+
endpoint.searchParams.set("session", this.#sessionId);
|
|
1176
|
+
endpoint.searchParams.set("device", this.#options.identity?.id ?? randomId());
|
|
1177
|
+
if (this.#deviceClaim) {
|
|
1178
|
+
endpoint.searchParams.set("key", this.#deviceClaim.key);
|
|
1179
|
+
endpoint.searchParams.set("proof", this.#deviceClaim.proof);
|
|
1180
|
+
endpoint.searchParams.set("at", String(this.#deviceClaim.at));
|
|
1181
|
+
}
|
|
1182
|
+
if (!recovering) this.#callbacks.onStatus("Connecting to the rendezvous service\u2026");
|
|
1183
|
+
const socket = new WebSocket(endpoint);
|
|
1184
|
+
this.#socket = socket;
|
|
1185
|
+
clearInterval(this.#signalKeepAlive);
|
|
1186
|
+
socket.addEventListener("open", () => {
|
|
1187
|
+
if (this.#closed || this.#socket !== socket) return;
|
|
1188
|
+
this.#signalKeepAlive = setInterval(() => {
|
|
1189
|
+
if (this.#socket === socket && socket.readyState === WebSocket.OPEN) socket.send(SIGNAL_PING);
|
|
1190
|
+
}, SIGNAL_PING_MS);
|
|
1191
|
+
if (!recovering) this.#callbacks.onStatus("Waiting for the other browser\u2026");
|
|
1192
|
+
this.#sendHello();
|
|
1193
|
+
if (!recovering && this.#options.publishConnectionCode) void this.#publishConnectionCode();
|
|
1194
|
+
if (recovering) this.#attemptDisconnectRecovery();
|
|
1195
|
+
});
|
|
1196
|
+
socket.addEventListener("message", (event) => {
|
|
1197
|
+
if (event.data === SIGNAL_PONG) return;
|
|
1198
|
+
if (this.#socket === socket) this.#queueSignal(event.data);
|
|
1199
|
+
});
|
|
1200
|
+
socket.addEventListener("error", () => {
|
|
1201
|
+
if (this.#socket === socket && !this.#closed && !this.#connectionReady) this.#callbacks.onError("The signaling connection failed.");
|
|
1202
|
+
});
|
|
1203
|
+
socket.addEventListener("close", (event) => {
|
|
1204
|
+
if (this.#socket === socket) clearInterval(this.#signalKeepAlive);
|
|
1205
|
+
if (this.#closed || this.#socket !== socket) return;
|
|
1206
|
+
if (this.#relayChallengePending) {
|
|
1207
|
+
clearTimeout(this.#relayChallengePending.timer);
|
|
1208
|
+
this.#relayChallengePending.reject(new Error("The signaling connection closed"));
|
|
1209
|
+
this.#relayChallengePending = void 0;
|
|
1210
|
+
}
|
|
1211
|
+
if (this.#connectionReady && this.#peer && this.#channel?.readyState === "open") {
|
|
1212
|
+
if ([1003, 1008, 1009].includes(event.code)) {
|
|
1213
|
+
this.#endTransferChannel();
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
this.#beginDisconnectRecovery(this.#peer);
|
|
1217
|
+
this.#scheduleDisconnectRetry();
|
|
1218
|
+
} else if (!event.wasClean && !this.#channel) this.#callbacks.onError("The signaling connection closed early.");
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
async #publishConnectionCode() {
|
|
1222
|
+
try {
|
|
1223
|
+
if (this.#role !== "host" || !this.#options.deviceKeyAgreement || !this.#options.identity) throw new Error("invalid_host");
|
|
1224
|
+
let response;
|
|
1225
|
+
const pool = this.#options.connectionPool;
|
|
1226
|
+
let limited = 0;
|
|
1227
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1228
|
+
if (this.#closed) return;
|
|
1229
|
+
const poolCode = pool?.code();
|
|
1230
|
+
response = await fetch(this.#edgeEndpoint(`/v1/pair/${this.#roomId}/connection-code`, "http"), {
|
|
1231
|
+
// Code-Notify: this page understands the `code-claimed` signal (older pages would treat it as an error).
|
|
1232
|
+
method: "POST",
|
|
1233
|
+
headers: {
|
|
1234
|
+
"Content-Type": "application/json",
|
|
1235
|
+
"X-PairOnce-Session": this.#sessionId,
|
|
1236
|
+
"X-PairOnce-Code-Notify": "1",
|
|
1237
|
+
...pool ? { "X-PairOnce-Pool": poolCode ? `${poolCode}.${pool.key}` : pool.key } : {}
|
|
1238
|
+
},
|
|
1239
|
+
body: JSON.stringify({
|
|
1240
|
+
roomId: this.#roomId,
|
|
1241
|
+
nonce: toBase64Url(this.#secret),
|
|
1242
|
+
host: this.#options.identity,
|
|
1243
|
+
expiresAt: this.#options.pairingExpiresAt
|
|
1244
|
+
}),
|
|
1245
|
+
signal: timeoutSignal(1e4)
|
|
1246
|
+
});
|
|
1247
|
+
const wait = codeRetryDelay(response.status, limited);
|
|
1248
|
+
if (wait !== void 0) {
|
|
1249
|
+
limited += 1;
|
|
1250
|
+
attempt -= 1;
|
|
1251
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
1252
|
+
continue;
|
|
1253
|
+
}
|
|
1254
|
+
if (response.status !== 409) break;
|
|
1255
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
1256
|
+
}
|
|
1257
|
+
if (response?.status === 429) throw new Error("code_limited");
|
|
1258
|
+
if (response?.status === 404 && pool?.code()) throw new Error("code_expired");
|
|
1259
|
+
if (!response?.ok) throw new Error("code_unavailable");
|
|
1260
|
+
const result = await response.json();
|
|
1261
|
+
if (typeof result.code !== "string" || !/^\d{6}$/.test(result.code)) throw new Error("invalid_code");
|
|
1262
|
+
if (!this.#closed) this.#callbacks.onConnectionCode?.(result.code);
|
|
1263
|
+
if (!this.#closed && this.#options.nearby && !pool) void this.#renewNearby();
|
|
1264
|
+
} catch (error) {
|
|
1265
|
+
if (this.#closed) return;
|
|
1266
|
+
const reason = error instanceof Error && error.message === "code_limited" ? "limited" : error instanceof Error && error.message === "code_expired" ? "expired" : void 0;
|
|
1267
|
+
this.#callbacks.onConnectionCodeUnavailable?.(reason);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
/**
|
|
1271
|
+
* A disk probe that finished after this session started (2.13.5): while the session has not met its peer yet, it takes
|
|
1272
|
+
* the receiver, so the hello it sends when the other device arrives already announces large files. The code stays.
|
|
1273
|
+
*/
|
|
1274
|
+
adoptDiskReceive(disk) {
|
|
1275
|
+
if (this.#closed || this.#options.diskReceive || this.#echoedHello || this.#connectionReady) return false;
|
|
1276
|
+
this.#options.diskReceive = disk;
|
|
1277
|
+
return true;
|
|
1278
|
+
}
|
|
1279
|
+
/** Put this waiting connection in its network's nearby list, or take it out; then again in NEARBY_RENEW_MS (2.20.0). */
|
|
1280
|
+
async #renewNearby() {
|
|
1281
|
+
clearTimeout(this.#nearbyTimer);
|
|
1282
|
+
if (this.#closed || !this.#options.nearby || this.#role !== "host") return;
|
|
1283
|
+
if (this.#connectionReady || this.#paired || this.#peer) {
|
|
1284
|
+
this.#nearbyListed = false;
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
const visible = this.#options.nearby();
|
|
1288
|
+
if (visible || this.#nearbyListed) {
|
|
1289
|
+
try {
|
|
1290
|
+
const response = await fetch(this.#edgeEndpoint(`/v1/pair/${this.#roomId}/nearby`, "http"), {
|
|
1291
|
+
method: "POST",
|
|
1292
|
+
headers: { "Content-Type": "application/json", "X-PairOnce-Session": this.#sessionId },
|
|
1293
|
+
body: JSON.stringify({ visible }),
|
|
1294
|
+
signal: timeoutSignal(1e4)
|
|
1295
|
+
});
|
|
1296
|
+
const result = response.ok ? await response.json() : void 0;
|
|
1297
|
+
this.#nearbyListed = result?.listed === true;
|
|
1298
|
+
} catch {
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
if (!this.#closed) this.#nearbyTimer = setTimeout(() => void this.#renewNearby(), NEARBY_RENEW_MS);
|
|
1302
|
+
}
|
|
1303
|
+
/** The person switched "Visible on this Wi-Fi": tell the list now rather than at the next renewal. */
|
|
1304
|
+
nearbyChanged() {
|
|
1305
|
+
if (!this.#closed && this.#options.nearby && !this.#options.connectionPool) void this.#renewNearby();
|
|
1306
|
+
}
|
|
1307
|
+
/** Host only: publish a fresh one-time code (after the previous one was used without anyone arriving). */
|
|
1308
|
+
refreshConnectionCode() {
|
|
1309
|
+
if (!this.#closed && this.#role === "host" && this.#options.publishConnectionCode) void this.#publishConnectionCode();
|
|
1310
|
+
}
|
|
1311
|
+
close() {
|
|
1312
|
+
if (this.#closed) return;
|
|
1313
|
+
clearInterval(this.#signalKeepAlive);
|
|
1314
|
+
clearTimeout(this.#nearbyTimer);
|
|
1315
|
+
void this.flushRelayUsage();
|
|
1316
|
+
this.#closed = true;
|
|
1317
|
+
this.#transportAbort.abort(new Error("The transfer channel closed"));
|
|
1318
|
+
this.#clearConnectionTimer();
|
|
1319
|
+
this.#clearDisconnectTimer();
|
|
1320
|
+
this.#clearRelayTimers();
|
|
1321
|
+
this.#clearPairingTimer();
|
|
1322
|
+
this.#connectionReady = false;
|
|
1323
|
+
if (this.#relayChallengePending) {
|
|
1324
|
+
clearTimeout(this.#relayChallengePending.timer);
|
|
1325
|
+
this.#relayChallengePending.reject(new Error("The connection closed"));
|
|
1326
|
+
this.#relayChallengePending = void 0;
|
|
1327
|
+
}
|
|
1328
|
+
this.#awaitingHostApproval = false;
|
|
1329
|
+
this.#live = void 0;
|
|
1330
|
+
this.#liveAnswer?.settle("cancel");
|
|
1331
|
+
this.#liveSdpAnswer?.reject(new Error("The connection closed"));
|
|
1332
|
+
this.#pendingPeerIdentity = void 0;
|
|
1333
|
+
this.#pairingClaimId = void 0;
|
|
1334
|
+
this.#pairingCodeEntered = void 0;
|
|
1335
|
+
this.#pairingCodeForClaim = void 0;
|
|
1336
|
+
this.#paired = false;
|
|
1337
|
+
this.#pairedPeerIdentity = void 0;
|
|
1338
|
+
this.#rejectPendingFileAcks("The transfer channel closed before receipt confirmation");
|
|
1339
|
+
this.#rejectPendingFileDecisions("The transfer channel closed before file approval");
|
|
1340
|
+
this.#channel?.close();
|
|
1341
|
+
this.#peer?.close();
|
|
1342
|
+
this.#socket?.close(1e3, "client closed");
|
|
1343
|
+
this.#channel = void 0;
|
|
1344
|
+
this.#peer = void 0;
|
|
1345
|
+
this.#peerPromise = void 0;
|
|
1346
|
+
this.#discardIncomingFiles();
|
|
1347
|
+
for (const transferId of this.#pendingFileOffers.keys()) this.#removePendingFileOffer(transferId);
|
|
1348
|
+
this.#keepAcceptedPartials();
|
|
1349
|
+
this.#pendingFileOffers.clear();
|
|
1350
|
+
this.#receivedFileHistory.clear();
|
|
1351
|
+
this.#closedFileTransfers.clear();
|
|
1352
|
+
this.#closedFileTransferOrder.length = 0;
|
|
1353
|
+
this.#receivedFrameKeys.clear();
|
|
1354
|
+
this.#controlFrameKeys = 0;
|
|
1355
|
+
this.#frameKey = void 0;
|
|
1356
|
+
this.#frameKeyPromise = void 0;
|
|
1357
|
+
this.#peerChannelNonce = void 0;
|
|
1358
|
+
}
|
|
1359
|
+
/** Return the current server-bound session context after local pairing. */
|
|
1360
|
+
getPairingAuthorizationContext() {
|
|
1361
|
+
if (!this.#paired || !this.#options.identity || !this.#pairedPeerIdentity) return void 0;
|
|
1362
|
+
return {
|
|
1363
|
+
roomId: this.#roomId,
|
|
1364
|
+
sessionId: this.#sessionId,
|
|
1365
|
+
deviceId: this.#options.identity.id,
|
|
1366
|
+
peerDeviceId: this.#pairedPeerIdentity.id
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1369
|
+
/** Revoke the active peer's server-side paired-inbox authorizations. */
|
|
1370
|
+
async revokePairedPeer() {
|
|
1371
|
+
const context = this.getPairingAuthorizationContext();
|
|
1372
|
+
if (!context) throw new Error("The paired session is not active");
|
|
1373
|
+
const endpoint = this.#edgeEndpoint(
|
|
1374
|
+
`/v1/pair/${encodeURIComponent(context.roomId)}/devices/${encodeURIComponent(context.peerDeviceId)}/revoke`,
|
|
1375
|
+
"http"
|
|
1376
|
+
);
|
|
1377
|
+
const response = await fetch(endpoint, {
|
|
1378
|
+
method: "POST",
|
|
1379
|
+
headers: { "Content-Type": "application/json", "X-PairOnce-Session": context.sessionId },
|
|
1380
|
+
cache: "no-store",
|
|
1381
|
+
credentials: "same-origin"
|
|
1382
|
+
});
|
|
1383
|
+
if (!response.ok) {
|
|
1384
|
+
const payload = await response.json().catch(() => void 0);
|
|
1385
|
+
throw new Error(typeof payload?.error === "string" ? `Device revocation failed: ${payload.error}` : `Device revocation failed (${response.status})`);
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
/** Called by the joining browser after the user reads the host's code. */
|
|
1389
|
+
async submitPairingCode(code) {
|
|
1390
|
+
if (!this.#options.requirePairing || this.#role !== "join") throw new Error("This session does not accept a pairing code");
|
|
1391
|
+
if (!/^(?:\d{6}|\d{13})$/.test(code)) throw new Error("Enter the code shown on the other device");
|
|
1392
|
+
if (!this.#connectionReady || this.#channel?.readyState !== "open") throw new Error("Wait for the secure channel before confirming the code");
|
|
1393
|
+
if (this.#paired) return;
|
|
1394
|
+
if (this.#pairingDenied) throw new Error("Pairing was declined; start a new session");
|
|
1395
|
+
if (this.#pairingAttempts >= MAX_PAIRING_ATTEMPTS) throw new Error("Too many pairing attempts; start a new session");
|
|
1396
|
+
if (this.#pairingClaimId && this.#pairingCodeEntered === code) return;
|
|
1397
|
+
if (this.#pairingClaimId) throw new Error("Waiting for the other device to confirm the previous code");
|
|
1398
|
+
const claimId = randomId();
|
|
1399
|
+
this.#pairingClaimId = claimId;
|
|
1400
|
+
this.#pairingCodeEntered = code;
|
|
1401
|
+
try {
|
|
1402
|
+
await this.#registerCodeAttempt(claimId);
|
|
1403
|
+
if (this.#closed || this.#pairingClaimId !== claimId) throw new Error("The pairing session closed");
|
|
1404
|
+
const proof = await createDeviceProof({
|
|
1405
|
+
privateKey: this.#options.identityPrivateKey,
|
|
1406
|
+
peerPublicKey: this.#options.expectedPeerIdentity.publicKey,
|
|
1407
|
+
invitationSecret: this.#secret,
|
|
1408
|
+
roomId: this.#roomId,
|
|
1409
|
+
claimId,
|
|
1410
|
+
host: this.#options.expectedPeerIdentity,
|
|
1411
|
+
join: this.#options.identity,
|
|
1412
|
+
code,
|
|
1413
|
+
direction: "join-claim"
|
|
1414
|
+
});
|
|
1415
|
+
this.#pairingAttempts += 1;
|
|
1416
|
+
await this.#sendPairingFrame("pair-claim", {
|
|
1417
|
+
type: "pair-claim",
|
|
1418
|
+
claimId,
|
|
1419
|
+
code,
|
|
1420
|
+
device: this.#options.identity,
|
|
1421
|
+
proof
|
|
1422
|
+
});
|
|
1423
|
+
this.#startPairingTimer();
|
|
1424
|
+
this.#callbacks.onStatus("Confirming the code with the other device\u2026");
|
|
1425
|
+
} catch (error) {
|
|
1426
|
+
this.#pairingClaimId = void 0;
|
|
1427
|
+
this.#pairingCodeEntered = void 0;
|
|
1428
|
+
throw error;
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
/** Return the deterministic v2 SAS for the joining device. */
|
|
1432
|
+
async getPairingSas() {
|
|
1433
|
+
if (!this.#options.deviceKeyAgreement || this.#role !== "join" || !this.#options.identity || !this.#options.expectedPeerIdentity) {
|
|
1434
|
+
throw new Error("This session does not expose a pairing SAS");
|
|
1435
|
+
}
|
|
1436
|
+
return this.#safetyCodeFor(this.#options.expectedPeerIdentity);
|
|
1437
|
+
}
|
|
1438
|
+
/** The capabilities this side names in its hello. An empty list means the hello carries no capabilities field. */
|
|
1439
|
+
#localCapabilities() {
|
|
1440
|
+
return [
|
|
1441
|
+
...this.#options.fileApproval ? ["file-approval-v1"] : [],
|
|
1442
|
+
...this.#options.fileApproval && this.#options.offerPreview ? ["offer-preview-v1"] : []
|
|
1443
|
+
];
|
|
1444
|
+
}
|
|
1445
|
+
/**
|
|
1446
|
+
* When both hellos carried capability lists, both lists go into the frame key, in role order. A signalling hop that
|
|
1447
|
+
* strips one list then leaves the two ends with different keys, so frames fail closed. A peer that sent no list keeps
|
|
1448
|
+
* the original derivation, so older text-only peers still work.
|
|
1449
|
+
*/
|
|
1450
|
+
#capabilityContext() {
|
|
1451
|
+
const local = this.#localCapabilities().sort();
|
|
1452
|
+
if (!this.#peerSentCapabilities || local.length === 0) return void 0;
|
|
1453
|
+
const peer = [...this.#peerCapabilities].sort();
|
|
1454
|
+
const [host, join] = this.#role === "host" ? [local, peer] : [peer, local];
|
|
1455
|
+
return new TextEncoder().encode(JSON.stringify(["paironce-capabilities-v1", host, join]));
|
|
1456
|
+
}
|
|
1457
|
+
/**
|
|
1458
|
+
* The safety code both ends must show. It needs the safety-code version (pairing-sas-v2) from the other device. Without
|
|
1459
|
+
* it the session refuses, rather than falling back to a shorter code that a signalling attacker could search for.
|
|
1460
|
+
*/
|
|
1461
|
+
#safetyCodeFor(peer) {
|
|
1462
|
+
if (!this.#peerSafetyCode) throw new Error("The other device needs an update before pairing. Refresh both pages.");
|
|
1463
|
+
return derivePairingSas(this.#options.identityPrivateKey, this.#options.identity, peer, this.#secret, this.#roomId);
|
|
1464
|
+
}
|
|
1465
|
+
// Same-origin HTTP calls must retain private staging/access cookies.
|
|
1466
|
+
// Fetch still omits cookies when a separate signaling origin is configured.
|
|
1467
|
+
async #registerCodeAttempt(claimId) {
|
|
1468
|
+
const endpoint = this.#edgeEndpoint(`/v1/pair/${this.#roomId}/code-attempt`, "http");
|
|
1469
|
+
const response = await fetch(endpoint, {
|
|
1470
|
+
method: "POST",
|
|
1471
|
+
headers: { "Content-Type": "application/json", "X-PairOnce-Session": this.#sessionId },
|
|
1472
|
+
body: JSON.stringify({ claimId }),
|
|
1473
|
+
cache: "no-store",
|
|
1474
|
+
credentials: "same-origin"
|
|
1475
|
+
});
|
|
1476
|
+
if (response.status === 429) throw new Error("Too many pairing-code attempts; wait before trying again");
|
|
1477
|
+
if (!response.ok) throw new Error("Pairing protection is unavailable; try a new private link");
|
|
1478
|
+
}
|
|
1479
|
+
async #verifyCodeAttempt(claimId, deviceId) {
|
|
1480
|
+
const endpoint = this.#edgeEndpoint(
|
|
1481
|
+
`/v1/pair/${this.#roomId}/code-attempt/${encodeURIComponent(claimId)}/verify`,
|
|
1482
|
+
"http"
|
|
1483
|
+
);
|
|
1484
|
+
const response = await fetch(endpoint, {
|
|
1485
|
+
method: "POST",
|
|
1486
|
+
headers: { "Content-Type": "application/json", "X-PairOnce-Session": this.#sessionId },
|
|
1487
|
+
body: JSON.stringify({ deviceId }),
|
|
1488
|
+
cache: "no-store",
|
|
1489
|
+
credentials: "same-origin"
|
|
1490
|
+
});
|
|
1491
|
+
if (!response.ok) throw new Error("The pairing-code attempt was not authorized");
|
|
1492
|
+
}
|
|
1493
|
+
/**
|
|
1494
|
+
* Host only, after it approved the device and checked its final proof: tell the service the pairing finished, for its
|
|
1495
|
+
* daily totals (no identifiers kept there). A failure changes nothing about the pairing.
|
|
1496
|
+
*/
|
|
1497
|
+
async #reportPairingComplete(claimId) {
|
|
1498
|
+
try {
|
|
1499
|
+
await fetch(this.#edgeEndpoint(`/v1/pair/${this.#roomId}/code-attempt/${encodeURIComponent(claimId)}/complete`, "http"), {
|
|
1500
|
+
method: "POST",
|
|
1501
|
+
headers: { "Content-Type": "application/json", "X-PairOnce-Session": this.#sessionId },
|
|
1502
|
+
body: "{}",
|
|
1503
|
+
cache: "no-store",
|
|
1504
|
+
credentials: "same-origin"
|
|
1505
|
+
});
|
|
1506
|
+
} catch {
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
/** Host only: a valid code and joining-device proof still require a local human decision. */
|
|
1510
|
+
async approvePairing() {
|
|
1511
|
+
if (this.#role !== "host" || !this.#options.requirePairing || !this.#awaitingHostApproval || !this.#pairingClaimId || !this.#pendingPeerIdentity || this.#closed || this.#pairingDenied) {
|
|
1512
|
+
throw new Error("No joining device is waiting for host approval");
|
|
1513
|
+
}
|
|
1514
|
+
if (Date.now() >= (this.#options.pairingExpiresAt ?? 0)) {
|
|
1515
|
+
await this.#rejectHostApproval("Pairing code expired; start a new session");
|
|
1516
|
+
throw new Error("Pairing code expired; start a new session");
|
|
1517
|
+
}
|
|
1518
|
+
this.#awaitingHostApproval = false;
|
|
1519
|
+
const claimId = this.#pairingClaimId;
|
|
1520
|
+
const peer = this.#pendingPeerIdentity;
|
|
1521
|
+
try {
|
|
1522
|
+
const hostProof = await createDeviceProof({
|
|
1523
|
+
privateKey: this.#options.identityPrivateKey,
|
|
1524
|
+
peerPublicKey: peer.publicKey,
|
|
1525
|
+
invitationSecret: this.#secret,
|
|
1526
|
+
roomId: this.#roomId,
|
|
1527
|
+
claimId,
|
|
1528
|
+
host: this.#options.identity,
|
|
1529
|
+
join: peer,
|
|
1530
|
+
code: this.#pairingCodeForClaim ?? this.#options.pairingCode,
|
|
1531
|
+
direction: "host-approve"
|
|
1532
|
+
});
|
|
1533
|
+
if (this.#closed || this.#pairingClaimId !== claimId || this.#pendingPeerIdentity !== peer) {
|
|
1534
|
+
throw new Error("The pairing request is no longer active");
|
|
1535
|
+
}
|
|
1536
|
+
await this.#sendPairingFrame("pair-result", {
|
|
1537
|
+
type: "pair-result",
|
|
1538
|
+
claimId,
|
|
1539
|
+
accepted: true,
|
|
1540
|
+
device: this.#options.identity,
|
|
1541
|
+
proof: hostProof
|
|
1542
|
+
});
|
|
1543
|
+
this.#startPairingTimer();
|
|
1544
|
+
this.#callbacks.onStatus("Host approved; waiting for the joining device to confirm.");
|
|
1545
|
+
} catch (error) {
|
|
1546
|
+
if (!this.#closed && this.#pairingClaimId === claimId && this.#pendingPeerIdentity === peer) {
|
|
1547
|
+
this.#awaitingHostApproval = true;
|
|
1548
|
+
}
|
|
1549
|
+
throw error;
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
/** Host only: refuse the pending device without issuing an acceptance proof. */
|
|
1553
|
+
async rejectPairing() {
|
|
1554
|
+
if (this.#role !== "host" || !this.#awaitingHostApproval || !this.#pendingPeerIdentity || this.#closed) {
|
|
1555
|
+
throw new Error("No joining device is waiting for host approval");
|
|
1556
|
+
}
|
|
1557
|
+
await this.#rejectHostApproval("Host declined pairing; start a new session");
|
|
1558
|
+
}
|
|
1559
|
+
async #rejectHostApproval(reason) {
|
|
1560
|
+
const claimId = this.#pairingClaimId;
|
|
1561
|
+
if (!claimId) throw new Error("No joining device is waiting for host approval");
|
|
1562
|
+
this.#awaitingHostApproval = false;
|
|
1563
|
+
this.#pairingDenied = true;
|
|
1564
|
+
this.#clearPairingTimer();
|
|
1565
|
+
this.#pendingPeerIdentity = void 0;
|
|
1566
|
+
this.#pairingClaimId = void 0;
|
|
1567
|
+
try {
|
|
1568
|
+
await this.#sendPairingFrame("pair-result", {
|
|
1569
|
+
type: "pair-result",
|
|
1570
|
+
claimId,
|
|
1571
|
+
accepted: false,
|
|
1572
|
+
reason,
|
|
1573
|
+
terminal: true
|
|
1574
|
+
});
|
|
1575
|
+
} finally {
|
|
1576
|
+
this.#callbacks.onPairingRejected?.(reason, true);
|
|
1577
|
+
this.#callbacks.onStatus(reason);
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
async sendText(text) {
|
|
1581
|
+
this.#requirePairing();
|
|
1582
|
+
const plaintext = new TextEncoder().encode(text);
|
|
1583
|
+
if (plaintext.byteLength === 0 || plaintext.byteLength > 128 * 1024) {
|
|
1584
|
+
throw new Error("Text must be between 1 byte and 128 KiB");
|
|
1585
|
+
}
|
|
1586
|
+
const relayed = this.#relayed();
|
|
1587
|
+
const relayClaim = relayed ? this.#claimRelay(plaintext.byteLength) : void 0;
|
|
1588
|
+
if (relayed && !relayClaim) throw new Error(RELAY_LIMIT_MESSAGE);
|
|
1589
|
+
try {
|
|
1590
|
+
const transferId = randomId();
|
|
1591
|
+
const noncePrefix = createNoncePrefix();
|
|
1592
|
+
const ciphertext = await encryptChunk(await this.#requiredKey("send"), noncePrefix, plaintext, {
|
|
1593
|
+
channel: this.#frameContext("send", "text"),
|
|
1594
|
+
transferId,
|
|
1595
|
+
kind: "text",
|
|
1596
|
+
chunkIndex: 0,
|
|
1597
|
+
totalChunks: 1,
|
|
1598
|
+
plainSize: plaintext.byteLength
|
|
1599
|
+
});
|
|
1600
|
+
this.#sendFrame({
|
|
1601
|
+
v: PROTOCOL_VERSION,
|
|
1602
|
+
type: "text",
|
|
1603
|
+
transferId,
|
|
1604
|
+
noncePrefix: toBase64Url(noncePrefix),
|
|
1605
|
+
ciphertext: toBase64Url(ciphertext),
|
|
1606
|
+
plainSize: plaintext.byteLength
|
|
1607
|
+
});
|
|
1608
|
+
this.#useRelay(relayClaim, plaintext.byteLength);
|
|
1609
|
+
} catch (error) {
|
|
1610
|
+
this.#releaseRelay(relayClaim);
|
|
1611
|
+
throw error;
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
/** Whether the negotiated peer supports aborting one file without closing the session. */
|
|
1615
|
+
canCancelFileIndependently() {
|
|
1616
|
+
return this.#fileApprovalNegotiated();
|
|
1617
|
+
}
|
|
1618
|
+
/** Accept a pending authenticated offer without blocking the receive chain. */
|
|
1619
|
+
async acceptFile(transferId) {
|
|
1620
|
+
this.#requirePairing();
|
|
1621
|
+
const pending = this.#pendingFileOffers.get(transferId);
|
|
1622
|
+
if (!pending) throw new Error("This file offer is no longer pending");
|
|
1623
|
+
this.#removePendingFileOffer(transferId);
|
|
1624
|
+
const resume = this.#matchingPartial(pending);
|
|
1625
|
+
const needed = this.#stillNeeded(pending, resume);
|
|
1626
|
+
const room = await this.#roomFor(pending.size, 0, needed);
|
|
1627
|
+
if (this.#closed || this.#closedFileTransfers.has(transferId)) return;
|
|
1628
|
+
const relayed = this.#relayed();
|
|
1629
|
+
const relayClaim = relayed && room ? this.#claimRelay(needed) : void 0;
|
|
1630
|
+
if (!room || relayed && !relayClaim) {
|
|
1631
|
+
this.#rememberClosedTransfer(transferId);
|
|
1632
|
+
await this.#sendFileControl({ type: "file-decision", transferId, decision: "busy" });
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1635
|
+
if (resume) this.#options.diskReceive.takePartial(resume);
|
|
1636
|
+
const timer = setTimeout(() => {
|
|
1637
|
+
this.#acceptedFileOffers.delete(transferId);
|
|
1638
|
+
this.#releaseRelay(relayClaim);
|
|
1639
|
+
if (resume) void this.#options.diskReceive.discardPartial(resume);
|
|
1640
|
+
this.#rememberClosedTransfer(transferId);
|
|
1641
|
+
void this.#sendFileControl({ type: "file-cancel", transferId, reason: "offer_expired" }).catch(() => {
|
|
1642
|
+
});
|
|
1643
|
+
}, FILE_META_TIMEOUT_MS);
|
|
1644
|
+
this.#acceptedFileOffers.set(transferId, { ...pending, timer, relayClaim, ...resume ? { resume } : {} });
|
|
1645
|
+
try {
|
|
1646
|
+
await this.#sendFileControl({ type: "file-decision", transferId, decision: "accept", privateMeta: true, ...resume ? { resumeFrom: resume.next } : {} });
|
|
1647
|
+
} catch (error) {
|
|
1648
|
+
clearTimeout(timer);
|
|
1649
|
+
this.#acceptedFileOffers.delete(transferId);
|
|
1650
|
+
this.#releaseRelay(relayClaim);
|
|
1651
|
+
if (resume) void this.#options.diskReceive.discardPartial(resume);
|
|
1652
|
+
this.#rememberClosedTransfer(transferId);
|
|
1653
|
+
throw error;
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
/** Reject a pending offer; the peer keeps the session for other files. */
|
|
1657
|
+
async rejectFile(transferId) {
|
|
1658
|
+
this.#requirePairing();
|
|
1659
|
+
const pending = this.#pendingFileOffers.get(transferId);
|
|
1660
|
+
if (!pending) throw new Error("This file offer is no longer pending");
|
|
1661
|
+
this.#removePendingFileOffer(transferId);
|
|
1662
|
+
this.#rememberClosedTransfer(transferId);
|
|
1663
|
+
await this.#sendFileControl({ type: "file-decision", transferId, decision: "reject" });
|
|
1664
|
+
}
|
|
1665
|
+
/** Confirm that the caller has durably saved a received file in the OS/app. */
|
|
1666
|
+
async confirmFileSaved(transferId) {
|
|
1667
|
+
this.#requirePairing();
|
|
1668
|
+
const receipt = this.#receivedFileHistory.get(transferId);
|
|
1669
|
+
if (!receipt) throw new Error("This received file is no longer available for confirmation");
|
|
1670
|
+
if (receipt.stage === "saved") return;
|
|
1671
|
+
if (this.#fileApprovalNegotiated()) await this.#sendFileControl({ type: "file-receipt", transferId, stage: "saved" });
|
|
1672
|
+
receipt.stage = "saved";
|
|
1673
|
+
}
|
|
1674
|
+
/** Cancel one active send while preserving the paired channel for other files. */
|
|
1675
|
+
cancelFile(transferId) {
|
|
1676
|
+
const controller = this.#activeFileAborters.get(transferId);
|
|
1677
|
+
if (!controller) return false;
|
|
1678
|
+
controller.abort(new DOMException("File transfer cancelled", "AbortError"));
|
|
1679
|
+
return true;
|
|
1680
|
+
}
|
|
1681
|
+
#fileApprovalNegotiated() {
|
|
1682
|
+
return this.#options.fileApproval === true && this.#peerCapabilities.has("file-approval-v1");
|
|
1683
|
+
}
|
|
1684
|
+
/** Thumbnails are only exchanged when both sides asked for them and file approval itself is in force. */
|
|
1685
|
+
#previewNegotiated() {
|
|
1686
|
+
return this.#options.offerPreview === true && this.#fileApprovalNegotiated() && this.#peerCapabilities.has("offer-preview-v1");
|
|
1687
|
+
}
|
|
1688
|
+
/** Send the thumbnail after the offer, never before it, so a slow image decode cannot delay the question. */
|
|
1689
|
+
#sendOfferPreview(file, transferId, signal) {
|
|
1690
|
+
if (!this.#previewNegotiated() || this.#relayed() || !previewEligible(file.type, file.size)) return;
|
|
1691
|
+
void (async () => {
|
|
1692
|
+
const image = await (this.#options.makePreview ?? makeOfferPreview)(file);
|
|
1693
|
+
if (!image || signal.aborted || this.#closed || !this.#pendingFileDecisions.has(transferId)) return;
|
|
1694
|
+
const payload = encodeOfferPreviewPayload(transferId, image);
|
|
1695
|
+
const noncePrefix = createNoncePrefix();
|
|
1696
|
+
const ciphertext = await encryptChunk(await this.#requiredKey("send"), noncePrefix, payload, {
|
|
1697
|
+
channel: this.#frameContext("send", "file-preview"),
|
|
1698
|
+
transferId,
|
|
1699
|
+
kind: "text",
|
|
1700
|
+
chunkIndex: 0,
|
|
1701
|
+
totalChunks: 1,
|
|
1702
|
+
plainSize: payload.byteLength
|
|
1703
|
+
});
|
|
1704
|
+
signal.throwIfAborted();
|
|
1705
|
+
if (!this.#pendingFileDecisions.has(transferId)) return;
|
|
1706
|
+
this.#sendFrame({
|
|
1707
|
+
v: PROTOCOL_VERSION,
|
|
1708
|
+
type: "file-preview",
|
|
1709
|
+
transferId,
|
|
1710
|
+
noncePrefix: toBase64Url(noncePrefix),
|
|
1711
|
+
ciphertext: toBase64Url(ciphertext),
|
|
1712
|
+
plainSize: payload.byteLength
|
|
1713
|
+
});
|
|
1714
|
+
})().catch(() => {
|
|
1715
|
+
});
|
|
1716
|
+
}
|
|
1717
|
+
/**
|
|
1718
|
+
* A thumbnail is a courtesy, so everything unexpected about one is dropped quietly: it never ends the session or
|
|
1719
|
+
* raises an error. It is shown only for an offer that is still waiting, once, and only if it passes the size,
|
|
1720
|
+
* dimension and format checks before any decoder sees it.
|
|
1721
|
+
*/
|
|
1722
|
+
async #handleFilePreview(frame, signal) {
|
|
1723
|
+
if (!this.#previewNegotiated() || !this.#pendingFileOffers.has(frame.transferId)) return;
|
|
1724
|
+
if (!acceptFrameKey(this.#receivedFrameKeys, `file-preview:${frame.transferId}`)) return;
|
|
1725
|
+
let image;
|
|
1726
|
+
try {
|
|
1727
|
+
const plaintext = await decryptChunk(await this.#requiredKey("receive"), fromBase64Url(frame.noncePrefix), fromBase64Url(frame.ciphertext), {
|
|
1728
|
+
channel: this.#frameContext("receive", frame.type),
|
|
1729
|
+
transferId: frame.transferId,
|
|
1730
|
+
kind: "text",
|
|
1731
|
+
chunkIndex: 0,
|
|
1732
|
+
totalChunks: 1,
|
|
1733
|
+
plainSize: frame.plainSize
|
|
1734
|
+
});
|
|
1735
|
+
image = parseOfferPreviewPayload(new TextDecoder("utf-8", { fatal: true }).decode(plaintext), frame.transferId);
|
|
1736
|
+
} catch {
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
signal.throwIfAborted();
|
|
1740
|
+
if (!image || !this.#pendingFileOffers.has(frame.transferId)) return;
|
|
1741
|
+
try {
|
|
1742
|
+
this.#callbacks.onFileOfferPreview?.(frame.transferId, image);
|
|
1743
|
+
} catch {
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
async #requestFileDecision(offer, signal, afterOfferSent) {
|
|
1747
|
+
if (this.#pendingFileDecisions.size >= MAX_PENDING_FILE_OFFERS) throw new Error("Too many file offers are already awaiting a decision");
|
|
1748
|
+
const decision = new Promise((resolve, reject) => {
|
|
1749
|
+
const timer = setTimeout(() => {
|
|
1750
|
+
this.#pendingFileDecisions.delete(offer.transferId);
|
|
1751
|
+
resolve({ decision: "timeout" });
|
|
1752
|
+
}, FILE_DECISION_TIMEOUT_MS);
|
|
1753
|
+
this.#pendingFileDecisions.set(offer.transferId, { resolve, reject, timer });
|
|
1754
|
+
});
|
|
1755
|
+
const waiting = abortable(decision, signal);
|
|
1756
|
+
void waiting.catch(() => {
|
|
1757
|
+
});
|
|
1758
|
+
try {
|
|
1759
|
+
await abortable(this.#sendFileControl(offerToPayload(offer), signal), signal);
|
|
1760
|
+
} catch (error) {
|
|
1761
|
+
const pending = this.#pendingFileDecisions.get(offer.transferId);
|
|
1762
|
+
if (pending) {
|
|
1763
|
+
clearTimeout(pending.timer);
|
|
1764
|
+
this.#pendingFileDecisions.delete(offer.transferId);
|
|
1765
|
+
pending.reject(error instanceof Error ? error : new Error("File offer could not be sent"));
|
|
1766
|
+
}
|
|
1767
|
+
throw error;
|
|
1768
|
+
}
|
|
1769
|
+
try {
|
|
1770
|
+
afterOfferSent?.();
|
|
1771
|
+
} catch {
|
|
1772
|
+
}
|
|
1773
|
+
return waiting;
|
|
1774
|
+
}
|
|
1775
|
+
/** Whether a file of this size is written to disk rather than held in page memory. */
|
|
1776
|
+
#toDisk(size) {
|
|
1777
|
+
return Boolean(this.#options.diskReceive && size > this.#options.diskReceive.threshold);
|
|
1778
|
+
}
|
|
1779
|
+
/** Page memory taken by files arriving now (disk-bound files do not count). */
|
|
1780
|
+
#incomingBytes() {
|
|
1781
|
+
return [...this.#incomingFiles.values()].reduce((sum, file) => sum + (file.sink ? 0 : file.size), 0);
|
|
1782
|
+
}
|
|
1783
|
+
#acceptedFileBytes() {
|
|
1784
|
+
return [...this.#acceptedFileOffers.values()].reduce((sum, file) => sum + (this.#toDisk(file.size) ? 0 : file.size), 0);
|
|
1785
|
+
}
|
|
1786
|
+
/** Disk space already promised to accepted or arriving disk-bound files (the part not yet written). */
|
|
1787
|
+
#diskPromisedBytes() {
|
|
1788
|
+
let bytes = 0;
|
|
1789
|
+
for (const file of this.#incomingFiles.values()) if (file.sink) bytes += Math.max(0, file.size - file.next * file.chunkSize);
|
|
1790
|
+
for (const file of this.#acceptedFileOffers.values()) if (this.#toDisk(file.size)) bytes += this.#stillNeeded(file, file.resume);
|
|
1791
|
+
return bytes;
|
|
1792
|
+
}
|
|
1793
|
+
/** Bytes still to come for a file: all of it, or what follows the part a kept file already holds (2026-10-21 audit, F06). */
|
|
1794
|
+
#stillNeeded(offer, resume) {
|
|
1795
|
+
return resume ? Math.max(0, offer.size - resume.next * resume.chunkSize) : offer.size;
|
|
1796
|
+
}
|
|
1797
|
+
/** Room for one more incoming file of `size`: page memory for small files, disk space for large ones (`needed` of it). */
|
|
1798
|
+
async #roomFor(size, extraMemory = 0, needed = size) {
|
|
1799
|
+
if (this.#toDisk(size)) {
|
|
1800
|
+
const available = await this.#options.diskReceive.available().catch(() => 0);
|
|
1801
|
+
return this.#diskPromisedBytes() + needed <= available;
|
|
1802
|
+
}
|
|
1803
|
+
return this.#incomingBytes() + this.#acceptedFileBytes() + extraMemory + (this.#options.retainedReceiveBytes?.() ?? 0) + size <= MAX_RECEIVE_BYTES;
|
|
1804
|
+
}
|
|
1805
|
+
/** The connection is gone: memory files are dropped; a disk file that can be continued is kept for its sender. */
|
|
1806
|
+
#discardIncomingFiles() {
|
|
1807
|
+
const disk = this.#options.diskReceive;
|
|
1808
|
+
for (const file of this.#incomingFiles.values()) {
|
|
1809
|
+
file.assembler?.clear();
|
|
1810
|
+
if (!file.sink) continue;
|
|
1811
|
+
if (disk && file.resumeKey && file.peerId && file.entry && file.next > 0 && file.next < file.totalChunks) {
|
|
1812
|
+
void disk.keepPartial({
|
|
1813
|
+
peerId: file.peerId,
|
|
1814
|
+
resumeKey: file.resumeKey,
|
|
1815
|
+
entry: file.entry,
|
|
1816
|
+
name: file.name,
|
|
1817
|
+
mime: file.mime,
|
|
1818
|
+
size: file.size,
|
|
1819
|
+
chunkSize: file.chunkSize,
|
|
1820
|
+
totalChunks: file.totalChunks,
|
|
1821
|
+
next: file.next
|
|
1822
|
+
}, file.sink).catch(() => {
|
|
1823
|
+
});
|
|
1824
|
+
} else void file.sink.abort();
|
|
1825
|
+
}
|
|
1826
|
+
this.#incomingFiles.clear();
|
|
1827
|
+
}
|
|
1828
|
+
/**
|
|
1829
|
+
* The connection is gone before an accepted file's data came: a part this device kept goes back to be continued later,
|
|
1830
|
+
* not lost with the attempt (2.19.3, audit R09), and what the attempt reserved of the relay is returned.
|
|
1831
|
+
*/
|
|
1832
|
+
#keepAcceptedPartials() {
|
|
1833
|
+
const disk = this.#options.diskReceive;
|
|
1834
|
+
for (const file of this.#acceptedFileOffers.values()) {
|
|
1835
|
+
clearTimeout(file.timer);
|
|
1836
|
+
this.#releaseRelay(file.relayClaim);
|
|
1837
|
+
if (disk && file.resume) void disk.keepPartial(file.resume, UNOPENED_PART).catch(() => {
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1840
|
+
this.#acceptedFileOffers.clear();
|
|
1841
|
+
}
|
|
1842
|
+
/** A kept, cut-off file this offer continues: same device, same chosen file, same layout. */
|
|
1843
|
+
#matchingPartial(offer) {
|
|
1844
|
+
const disk = this.#options.diskReceive;
|
|
1845
|
+
const peerId = this.#pairedPeerIdentity?.id;
|
|
1846
|
+
if (!disk || !offer.resumeKey || !peerId || !this.#toDisk(offer.size)) return void 0;
|
|
1847
|
+
const partial = disk.findPartial(peerId, offer.resumeKey);
|
|
1848
|
+
return partial && partial.name === offer.name && partial.mime === offer.mime && partial.size === offer.size && partial.chunkSize === offer.chunkSize && partial.totalChunks === offer.totalChunks && partial.next > 0 && partial.next < offer.totalChunks ? partial : void 0;
|
|
1849
|
+
}
|
|
1850
|
+
/** Whether the other device takes files up to LARGE_FILE_MAX_BYTES. */
|
|
1851
|
+
peerTakesLargeFiles() {
|
|
1852
|
+
return this.#peerLargeFiles;
|
|
1853
|
+
}
|
|
1854
|
+
/** The largest file this connection can send now, by the same rules sendFile applies (live review: say it up front). */
|
|
1855
|
+
sendLimit() {
|
|
1856
|
+
if (!this.#peerLargeFiles) return MAX_FILE_SIZE;
|
|
1857
|
+
return this.#peerHugeFiles && this.#proConnection() ? PRO_FILE_MAX_BYTES : LARGE_FILE_MAX_BYTES;
|
|
1858
|
+
}
|
|
1859
|
+
/** One of the two devices has Pro: this connection may carry files over 2 GB. Checked by the sender, with its own Pro
|
|
1860
|
+
* read now, so Pro activated during a connection counts at once. */
|
|
1861
|
+
#proConnection() {
|
|
1862
|
+
return Boolean(this.#options.isPro?.()) || this.#peerPro;
|
|
1863
|
+
}
|
|
1864
|
+
/** The same for the page (2.17.0): scan pages and resolution, live picture quality and recording length follow it. */
|
|
1865
|
+
proConnection() {
|
|
1866
|
+
return this.#proConnection();
|
|
1867
|
+
}
|
|
1868
|
+
/**
|
|
1869
|
+
* The largest file this side takes: up to 10 GB when it writes to disk (it announced hugeFiles), 50 MB in memory.
|
|
1870
|
+
* Whether a file over 2 GB is allowed by plan is the sender's check: the receiver's view of the peer's Pro comes from a
|
|
1871
|
+
* hello that can be older than a licence activated since. Room on disk is checked for each offer (#roomFor).
|
|
1872
|
+
*/
|
|
1873
|
+
#receiveLimit() {
|
|
1874
|
+
return this.#options.diskReceive ? PRO_FILE_MAX_BYTES : MAX_FILE_SIZE;
|
|
1875
|
+
}
|
|
1876
|
+
async #sendFileControl(payload, signal = this.#transportAbort.signal) {
|
|
1877
|
+
signal.throwIfAborted();
|
|
1878
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(payload));
|
|
1879
|
+
if (plaintext.byteLength > 4096) throw new Error("File control message is too large");
|
|
1880
|
+
const noncePrefix = createNoncePrefix();
|
|
1881
|
+
const ciphertext = await encryptChunk(await this.#requiredKey("send"), noncePrefix, plaintext, {
|
|
1882
|
+
channel: this.#frameContext("send", payload.type),
|
|
1883
|
+
transferId: payload.transferId,
|
|
1884
|
+
kind: "text",
|
|
1885
|
+
chunkIndex: 0,
|
|
1886
|
+
totalChunks: 1,
|
|
1887
|
+
plainSize: plaintext.byteLength
|
|
1888
|
+
});
|
|
1889
|
+
signal.throwIfAborted();
|
|
1890
|
+
this.#sendFrame({
|
|
1891
|
+
v: PROTOCOL_VERSION,
|
|
1892
|
+
type: payload.type,
|
|
1893
|
+
transferId: payload.transferId,
|
|
1894
|
+
noncePrefix: toBase64Url(noncePrefix),
|
|
1895
|
+
ciphertext: toBase64Url(ciphertext),
|
|
1896
|
+
plainSize: plaintext.byteLength
|
|
1897
|
+
});
|
|
1898
|
+
}
|
|
1899
|
+
#rememberClosedTransfer(transferId) {
|
|
1900
|
+
if (this.#closedFileTransfers.has(transferId)) return;
|
|
1901
|
+
this.#closedFileTransfers.add(transferId);
|
|
1902
|
+
this.#closedFileTransferOrder.push(transferId);
|
|
1903
|
+
while (this.#closedFileTransferOrder.length > MAX_FILE_HISTORY) {
|
|
1904
|
+
const old = this.#closedFileTransferOrder.shift();
|
|
1905
|
+
if (old) this.#closedFileTransfers.delete(old);
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
#forgetClosedTransfer(transferId) {
|
|
1909
|
+
this.#closedFileTransfers.delete(transferId);
|
|
1910
|
+
for (let index = this.#closedFileTransferOrder.length - 1; index >= 0; index -= 1) {
|
|
1911
|
+
if (this.#closedFileTransferOrder[index] === transferId) this.#closedFileTransferOrder.splice(index, 1);
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
/** True when this connection runs through the relay, so the per-connection byte limit applies. */
|
|
1915
|
+
#relayed() {
|
|
1916
|
+
return this.#readyPath === "turn";
|
|
1917
|
+
}
|
|
1918
|
+
/** Reserve bytes against the relay limit; undefined (and no change) when they would not fit. */
|
|
1919
|
+
#claimRelay(size) {
|
|
1920
|
+
if (this.#relayBytes + size > this.#relayLimit) return void 0;
|
|
1921
|
+
this.#relayBytes += size;
|
|
1922
|
+
return { unused: size };
|
|
1923
|
+
}
|
|
1924
|
+
/** Mark reserved bytes as carried: they have crossed the relay and stay counted. */
|
|
1925
|
+
#useRelay(claim, bytes) {
|
|
1926
|
+
if (claim) {
|
|
1927
|
+
claim.unused = Math.max(0, claim.unused - bytes);
|
|
1928
|
+
this.#noteRelayed(bytes);
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
/** What this device may still relay this month, or undefined where the server names no allowance. */
|
|
1932
|
+
relayAllowanceLeft() {
|
|
1933
|
+
return this.#relayAllowance ? Math.max(0, this.#relayLimit - this.#relayBytes) : void 0;
|
|
1934
|
+
}
|
|
1935
|
+
#noteRelayed(bytes) {
|
|
1936
|
+
if (!this.#relayAllowance || bytes <= 0) return;
|
|
1937
|
+
this.#relayUnreported += bytes;
|
|
1938
|
+
if (this.#relayUnreported >= RELAY_REPORT_BYTES) void this.flushRelayUsage();
|
|
1939
|
+
}
|
|
1940
|
+
#setRelayLeft(left) {
|
|
1941
|
+
this.#relayLimit = this.#relayReported + Math.max(0, left);
|
|
1942
|
+
try {
|
|
1943
|
+
this.#callbacks.onRelayAllowance?.(Math.max(0, this.#relayLimit - this.#relayBytes));
|
|
1944
|
+
} catch {
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
/**
|
|
1948
|
+
* Tell the server what this device relayed since the last report, and learn what is left. Called every few MiB, when the
|
|
1949
|
+
* session closes and when the page goes away. A report that fails is kept for the next one.
|
|
1950
|
+
*/
|
|
1951
|
+
async flushRelayUsage() {
|
|
1952
|
+
if (!this.#relayAllowance || this.#relayUnreported < 1 || this.#relayReporting) return this.#relayReporting;
|
|
1953
|
+
const bytes = this.#relayUnreported;
|
|
1954
|
+
this.#relayUnreported = 0;
|
|
1955
|
+
this.#relayReported += bytes;
|
|
1956
|
+
this.#relayReporting = (async () => {
|
|
1957
|
+
try {
|
|
1958
|
+
const response = await fetch(this.#edgeEndpoint(`/v1/pair/${this.#roomId}/relay-usage`, "http"), {
|
|
1959
|
+
method: "POST",
|
|
1960
|
+
keepalive: true,
|
|
1961
|
+
cache: "no-store",
|
|
1962
|
+
credentials: "same-origin",
|
|
1963
|
+
headers: { Accept: "application/json", "Content-Type": "application/json", "X-PairOnce-Session": this.#sessionId },
|
|
1964
|
+
body: JSON.stringify({ bytes }),
|
|
1965
|
+
signal: timeoutSignal(1e4)
|
|
1966
|
+
});
|
|
1967
|
+
if (!response.ok) throw new Error("relay usage not recorded");
|
|
1968
|
+
const payload = await response.json();
|
|
1969
|
+
if (typeof payload.relayRemaining === "number" && payload.relayRemaining >= 0) this.#setRelayLeft(payload.relayRemaining);
|
|
1970
|
+
} catch {
|
|
1971
|
+
this.#relayReported -= bytes;
|
|
1972
|
+
this.#relayUnreported += bytes;
|
|
1973
|
+
} finally {
|
|
1974
|
+
this.#relayReporting = void 0;
|
|
1975
|
+
}
|
|
1976
|
+
})();
|
|
1977
|
+
return this.#relayReporting;
|
|
1978
|
+
}
|
|
1979
|
+
/** Give back what a transfer reserved but never carried. Safe to call again: it returns each byte once. */
|
|
1980
|
+
/** Give back `bytes` of a reservation that will not be used, at most what is still unused. */
|
|
1981
|
+
#returnRelay(claim, bytes) {
|
|
1982
|
+
if (!claim || bytes <= 0) return;
|
|
1983
|
+
const back = Math.min(claim.unused, bytes);
|
|
1984
|
+
claim.unused -= back;
|
|
1985
|
+
this.#relayBytes = Math.max(0, this.#relayBytes - back);
|
|
1986
|
+
}
|
|
1987
|
+
#releaseRelay(claim) {
|
|
1988
|
+
if (!claim) return;
|
|
1989
|
+
this.#relayBytes = Math.max(0, this.#relayBytes - claim.unused);
|
|
1990
|
+
claim.unused = 0;
|
|
1991
|
+
}
|
|
1992
|
+
/** `resumeKey`: the same for every attempt at this chosen file, so a device that kept part of it can continue. */
|
|
1993
|
+
async sendFile(file, transferId = randomId(), signal, options = {}) {
|
|
1994
|
+
this.#requirePairing();
|
|
1995
|
+
if (file.size > PRO_FILE_MAX_BYTES) throw new Error("Live files are limited to 10 GB each. Choose a smaller file.");
|
|
1996
|
+
if (file.size > MAX_FILE_SIZE && !this.#peerLargeFiles) {
|
|
1997
|
+
throw new Error("The other device's browser takes files up to 50 MB. Open PairOnce there in Chrome, Edge or Firefox, or on iOS 26, for larger files.");
|
|
1998
|
+
}
|
|
1999
|
+
if (file.size > LARGE_FILE_MAX_BYTES) {
|
|
2000
|
+
if (!this.#peerHugeFiles) throw new Error("The other device needs an update before it can take files over 2 GB. Refresh PairOnce there.");
|
|
2001
|
+
if (!this.#proConnection()) throw new Error(PRO_FILE_MESSAGE);
|
|
2002
|
+
}
|
|
2003
|
+
if (!/^[A-Za-z0-9_-]{16,128}$/.test(transferId)) throw new Error("Invalid file transfer ID");
|
|
2004
|
+
if (this.#activeFileAborters.has(transferId)) throw new Error("This file transfer is already active");
|
|
2005
|
+
if (this.#options.fileApproval && !this.#fileApprovalNegotiated()) {
|
|
2006
|
+
throw new Error("The other device does not support file approval. Refresh it, then send again.");
|
|
2007
|
+
}
|
|
2008
|
+
const approvalNegotiated = this.#fileApprovalNegotiated();
|
|
2009
|
+
const resumeKey = approvalNegotiated && this.#peerLargeFiles && options.resumeKey && isTransferId(options.resumeKey) ? options.resumeKey : void 0;
|
|
2010
|
+
const relayed = this.#relayed();
|
|
2011
|
+
let relayClaim = relayed ? this.#claimRelay(file.size) : void 0;
|
|
2012
|
+
const relayAfterAnswer = relayed && !relayClaim && Boolean(resumeKey) && this.#relayLimit > this.#relayBytes;
|
|
2013
|
+
if (relayed && !relayClaim && !relayAfterAnswer) throw new Error(RELAY_LIMIT_MESSAGE);
|
|
2014
|
+
const localAbort = new AbortController();
|
|
2015
|
+
this.#activeFileAborters.set(transferId, localAbort);
|
|
2016
|
+
const operationSignal = anySignal([this.#transportAbort.signal, localAbort.signal, ...signal ? [signal] : []]);
|
|
2017
|
+
let announced = false;
|
|
2018
|
+
let offerSent = false;
|
|
2019
|
+
let privateMeta = false;
|
|
2020
|
+
try {
|
|
2021
|
+
operationSignal.throwIfAborted();
|
|
2022
|
+
const totalChunks = chunkCount(file.size, FILE_CHUNK_SIZE);
|
|
2023
|
+
let startChunk = 0;
|
|
2024
|
+
const noncePrefix = createNoncePrefix();
|
|
2025
|
+
const first = await abortable(file.slice(0, FILE_CHUNK_SIZE).arrayBuffer(), operationSignal);
|
|
2026
|
+
operationSignal.throwIfAborted();
|
|
2027
|
+
if (approvalNegotiated) {
|
|
2028
|
+
offerSent = true;
|
|
2029
|
+
const answer2 = await this.#requestFileDecision(
|
|
2030
|
+
{
|
|
2031
|
+
transferId,
|
|
2032
|
+
name: file.name,
|
|
2033
|
+
mime: file.type || "application/octet-stream",
|
|
2034
|
+
size: file.size,
|
|
2035
|
+
chunkSize: FILE_CHUNK_SIZE,
|
|
2036
|
+
totalChunks,
|
|
2037
|
+
...resumeKey ? { resumeKey } : {}
|
|
2038
|
+
},
|
|
2039
|
+
operationSignal,
|
|
2040
|
+
() => this.#sendOfferPreview(file, transferId, operationSignal)
|
|
2041
|
+
);
|
|
2042
|
+
const decision = answer2.decision;
|
|
2043
|
+
privateMeta = answer2.privateMeta === true;
|
|
2044
|
+
if (answer2.resumeFrom !== void 0) {
|
|
2045
|
+
if (!resumeKey || answer2.resumeFrom >= totalChunks) throw new Error("The receiving browser asked to continue from an invalid point");
|
|
2046
|
+
startChunk = answer2.resumeFrom;
|
|
2047
|
+
this.#returnRelay(relayClaim, startChunk * FILE_CHUNK_SIZE);
|
|
2048
|
+
}
|
|
2049
|
+
if (decision !== "accept") {
|
|
2050
|
+
this.#rememberClosedTransfer(transferId);
|
|
2051
|
+
throw new Error(decision === "reject" ? "The receiving browser declined this file" : decision === "busy" ? "The receiving browser is busy; try this file again" : "The receiving browser did not answer this file offer in time");
|
|
2052
|
+
}
|
|
2053
|
+
if (relayAfterAnswer) {
|
|
2054
|
+
relayClaim = this.#claimRelay(file.size - startChunk * FILE_CHUNK_SIZE);
|
|
2055
|
+
if (!relayClaim) throw new Error(RELAY_LIMIT_MESSAGE);
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
this.#sendFrame({
|
|
2059
|
+
v: PROTOCOL_VERSION,
|
|
2060
|
+
type: "file-meta",
|
|
2061
|
+
transferId,
|
|
2062
|
+
// The name and type already went in the encrypted offer; the receiver takes them from there.
|
|
2063
|
+
...privateMeta ? { name: PRIVATE_META_NAME, mime: "" } : { name: file.name, mime: file.type || "application/octet-stream" },
|
|
2064
|
+
size: file.size,
|
|
2065
|
+
chunkSize: FILE_CHUNK_SIZE,
|
|
2066
|
+
totalChunks,
|
|
2067
|
+
noncePrefix: toBase64Url(noncePrefix),
|
|
2068
|
+
...startChunk > 0 ? { resumeFrom: startChunk } : {}
|
|
2069
|
+
});
|
|
2070
|
+
announced = true;
|
|
2071
|
+
const window2 = this.#useReceiveWindow() ? new SendWindow(startChunk, totalChunks) : void 0;
|
|
2072
|
+
if (window2) this.#sendWindows.set(transferId, window2);
|
|
2073
|
+
let block = startChunk === 0 ? { start: 0, bytes: new Uint8Array(first) } : { start: startChunk * FILE_CHUNK_SIZE, bytes: new Uint8Array(0) };
|
|
2074
|
+
for (let index = startChunk; index < totalChunks; index += 1) {
|
|
2075
|
+
operationSignal.throwIfAborted();
|
|
2076
|
+
if (window2) await window2.before(index, operationSignal);
|
|
2077
|
+
const start = index * FILE_CHUNK_SIZE;
|
|
2078
|
+
if (start >= block.start + block.bytes.byteLength) {
|
|
2079
|
+
block = { start, bytes: new Uint8Array(await abortable(file.slice(start, start + READ_BLOCK_BYTES).arrayBuffer(), operationSignal)) };
|
|
2080
|
+
}
|
|
2081
|
+
const offset = start - block.start;
|
|
2082
|
+
const plaintext = block.bytes.slice(offset, offset + Math.min(FILE_CHUNK_SIZE, file.size - start));
|
|
2083
|
+
operationSignal.throwIfAborted();
|
|
2084
|
+
const key = await abortable(this.#requiredKey("send"), operationSignal);
|
|
2085
|
+
const ciphertext = await abortable(encryptChunk(key, noncePrefix, plaintext, {
|
|
2086
|
+
channel: this.#frameContext("send", "file-chunk"),
|
|
2087
|
+
transferId,
|
|
2088
|
+
kind: "file",
|
|
2089
|
+
chunkIndex: index,
|
|
2090
|
+
totalChunks,
|
|
2091
|
+
plainSize: plaintext.byteLength
|
|
2092
|
+
}), operationSignal);
|
|
2093
|
+
await this.#waitForWritableChannel(operationSignal);
|
|
2094
|
+
operationSignal.throwIfAborted();
|
|
2095
|
+
if (this.#peerBinaryChunks) this.#sendBinary(encodeFileChunk(transferId, index, ciphertext));
|
|
2096
|
+
else this.#sendFrame({ v: PROTOCOL_VERSION, type: "file-chunk", transferId, index, ciphertext: toBase64Url(ciphertext) });
|
|
2097
|
+
this.#useRelay(relayClaim, plaintext.byteLength);
|
|
2098
|
+
this.#callbacks.onProgress(file.name, (index + 1) / totalChunks, "sent");
|
|
2099
|
+
}
|
|
2100
|
+
operationSignal.throwIfAborted();
|
|
2101
|
+
await this.#waitForWritableChannel(operationSignal, true);
|
|
2102
|
+
operationSignal.throwIfAborted();
|
|
2103
|
+
await abortable(new Promise((resolve, reject) => {
|
|
2104
|
+
const timer = setTimeout(() => {
|
|
2105
|
+
this.#pendingFileAcks.delete(transferId);
|
|
2106
|
+
reject(new Error("The receiving browser did not confirm the file in time"));
|
|
2107
|
+
}, FILE_ACK_TIMEOUT_MS);
|
|
2108
|
+
this.#pendingFileAcks.set(transferId, { resolve, reject, timer });
|
|
2109
|
+
this.#sendFrame({ v: PROTOCOL_VERSION, type: "file-complete", transferId });
|
|
2110
|
+
}), operationSignal);
|
|
2111
|
+
this.#releaseRelay(relayClaim);
|
|
2112
|
+
} catch (error) {
|
|
2113
|
+
this.#releaseRelay(relayClaim);
|
|
2114
|
+
const pending = this.#pendingFileAcks.get(transferId);
|
|
2115
|
+
if (pending) {
|
|
2116
|
+
clearTimeout(pending.timer);
|
|
2117
|
+
this.#pendingFileAcks.delete(transferId);
|
|
2118
|
+
pending.reject(error instanceof Error ? error : new Error("File transfer cancelled"));
|
|
2119
|
+
}
|
|
2120
|
+
const pendingDecision = this.#pendingFileDecisions.get(transferId);
|
|
2121
|
+
if (pendingDecision) {
|
|
2122
|
+
clearTimeout(pendingDecision.timer);
|
|
2123
|
+
this.#pendingFileDecisions.delete(transferId);
|
|
2124
|
+
pendingDecision.reject(error instanceof Error ? error : new Error("File offer cancelled"));
|
|
2125
|
+
}
|
|
2126
|
+
if (approvalNegotiated && (offerSent || announced)) {
|
|
2127
|
+
this.#rememberClosedTransfer(transferId);
|
|
2128
|
+
try {
|
|
2129
|
+
await this.#sendFileControl({ type: "file-cancel", transferId, reason: operationSignal.aborted ? "cancelled" : "send_failed" });
|
|
2130
|
+
} catch {
|
|
2131
|
+
}
|
|
2132
|
+
} else if (announced) {
|
|
2133
|
+
this.#endTransferChannel();
|
|
2134
|
+
}
|
|
2135
|
+
throw error;
|
|
2136
|
+
} finally {
|
|
2137
|
+
this.#activeFileAborters.delete(transferId);
|
|
2138
|
+
this.#sendWindows.delete(transferId);
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
/** The receive window applies only when both hellos announced it and file control messages are in use. */
|
|
2142
|
+
#useReceiveWindow() {
|
|
2143
|
+
return this.#peerReceiveWindow && this.#fileApprovalNegotiated();
|
|
2144
|
+
}
|
|
2145
|
+
#queueSignal(raw) {
|
|
2146
|
+
if (this.#closed) return;
|
|
2147
|
+
try {
|
|
2148
|
+
if (typeof raw === "string" && JSON.parse(raw)?.type === "relay-challenge") {
|
|
2149
|
+
void this.#handleSignal(raw);
|
|
2150
|
+
return;
|
|
2151
|
+
}
|
|
2152
|
+
} catch {
|
|
2153
|
+
}
|
|
2154
|
+
this.#signalChain = this.#signalChain.then(async () => {
|
|
2155
|
+
if (!this.#closed) await this.#handleSignal(raw);
|
|
2156
|
+
});
|
|
2157
|
+
}
|
|
2158
|
+
async #handleSignal(raw) {
|
|
2159
|
+
let renewingRelay = false;
|
|
2160
|
+
try {
|
|
2161
|
+
if (typeof raw !== "string") throw new Error("Signaling frames must be JSON text");
|
|
2162
|
+
const decoded = JSON.parse(raw);
|
|
2163
|
+
if (decoded?.v === 1 && decoded.type === "relay-challenge") {
|
|
2164
|
+
const pending = this.#relayChallengePending;
|
|
2165
|
+
if (!pending || decoded.requestId !== pending.id) return;
|
|
2166
|
+
clearTimeout(pending.timer);
|
|
2167
|
+
this.#relayChallengePending = void 0;
|
|
2168
|
+
if (typeof decoded.challenge === "string" && /^[A-Za-z0-9_-]{16,128}$/.test(decoded.challenge)) pending.resolve(decoded.challenge);
|
|
2169
|
+
else pending.reject(new Error("Relay connection is currently unavailable"));
|
|
2170
|
+
return;
|
|
2171
|
+
}
|
|
2172
|
+
if (decoded?.v === 1 && decoded.type === "code-claimed") {
|
|
2173
|
+
if (this.#role === "host") this.#callbacks.onConnectionCodeClaimed?.();
|
|
2174
|
+
return;
|
|
2175
|
+
}
|
|
2176
|
+
const message = parseSignalMessage(decoded);
|
|
2177
|
+
if (message.type === "hello") {
|
|
2178
|
+
if (this.#connectionReady) return;
|
|
2179
|
+
if (message.role === this.#role) return;
|
|
2180
|
+
if (message.frameProtocol !== 2) {
|
|
2181
|
+
this.close();
|
|
2182
|
+
throw new Error("The other device needs an update before pairing. Refresh both pages.");
|
|
2183
|
+
}
|
|
2184
|
+
this.#peerFrameProtocol = true;
|
|
2185
|
+
this.#peerCapabilities = new Set(message.capabilities ?? []);
|
|
2186
|
+
this.#peerSentCapabilities = message.capabilities !== void 0;
|
|
2187
|
+
this.#peerSafetyCode = message.safetyCode === 2;
|
|
2188
|
+
this.#peerBinaryChunks = this.#localBinaryChunks && message.binaryFileChunks === 1;
|
|
2189
|
+
this.#peerLargeFiles = message.largeFiles === 1;
|
|
2190
|
+
this.#peerHugeFiles = message.hugeFiles === 1;
|
|
2191
|
+
this.#peerPro = false;
|
|
2192
|
+
const passFor = message.deviceId && message.devicePublicKey ? { id: message.deviceId, publicKey: message.devicePublicKey } : this.#agreementPeer ?? this.#options.expectedPeerIdentity;
|
|
2193
|
+
if (message.pro === 1 && message.proPass && passFor && this.#options.verifyProPass) {
|
|
2194
|
+
const generation2 = this.#peerGeneration;
|
|
2195
|
+
void this.#options.verifyProPass(message.proPass, passFor.id, passFor.publicKey).then((valid) => {
|
|
2196
|
+
if (valid && generation2 === this.#peerGeneration) this.#peerPro = true;
|
|
2197
|
+
}, () => {
|
|
2198
|
+
});
|
|
2199
|
+
}
|
|
2200
|
+
this.#peerReceiveWindow = message.receiveWindow === 1;
|
|
2201
|
+
this.#peerLiveMedia = message.liveMedia === 1;
|
|
2202
|
+
if (this.#options.deviceKeyAgreement && message.role !== this.#role) {
|
|
2203
|
+
const descriptor = { id: message.deviceId, name: message.deviceName, publicKey: message.devicePublicKey };
|
|
2204
|
+
if (!isDeviceDescriptor(descriptor)) throw new Error("The other device did not provide a valid identity");
|
|
2205
|
+
const pinned = this.#agreementPeer ?? this.#options.expectedPeerIdentity;
|
|
2206
|
+
if (pinned && (pinned.id !== descriptor.id || pinned.publicKey !== descriptor.publicKey)) {
|
|
2207
|
+
this.close();
|
|
2208
|
+
throw new Error("The other device identity changed. Create a new connection.");
|
|
2209
|
+
}
|
|
2210
|
+
this.#agreementPeer = descriptor;
|
|
2211
|
+
}
|
|
2212
|
+
const peerNonce = fromBase64Url(message.sessionNonce);
|
|
2213
|
+
if (peerNonce.byteLength !== 16) throw new Error("Invalid DataChannel session nonce");
|
|
2214
|
+
const peerRestart = this.#peerChannelNonce !== void 0 && !sameBytes(this.#peerChannelNonce, peerNonce);
|
|
2215
|
+
this.#setPeerChannelNonce(peerNonce);
|
|
2216
|
+
if (!this.#options.requirePairing && message.deviceId && message.deviceName && message.devicePublicKey) {
|
|
2217
|
+
this.#options.onPeerIdentity?.({
|
|
2218
|
+
id: message.deviceId,
|
|
2219
|
+
name: message.deviceName,
|
|
2220
|
+
publicKey: message.devicePublicKey
|
|
2221
|
+
});
|
|
2222
|
+
}
|
|
2223
|
+
if (this.#peer?.connectionState === "failed" && !this.#connectionReady) {
|
|
2224
|
+
await this.#beginRelayRetry(false);
|
|
2225
|
+
}
|
|
2226
|
+
if (message.role === this.#role) return;
|
|
2227
|
+
if (this.#role === "join" && message.role === "host") {
|
|
2228
|
+
if (peerRestart && !this.#connectionReady && !this.#forceRelay) {
|
|
2229
|
+
await this.#beginRelayRetry(false);
|
|
2230
|
+
}
|
|
2231
|
+
if (!this.#echoedHello) {
|
|
2232
|
+
this.#echoedHello = true;
|
|
2233
|
+
this.#sendHello();
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
if (this.#role === "host" && message.role === "join") {
|
|
2237
|
+
if (!this.#echoedHello) {
|
|
2238
|
+
this.#echoedHello = true;
|
|
2239
|
+
this.#sendHello();
|
|
2240
|
+
}
|
|
2241
|
+
if (peerRestart && this.#offered && !this.#connectionReady && !this.#forceRelay) {
|
|
2242
|
+
await this.#beginRelayRetry(false);
|
|
2243
|
+
}
|
|
2244
|
+
await this.#makeOffer();
|
|
2245
|
+
}
|
|
2246
|
+
return;
|
|
2247
|
+
}
|
|
2248
|
+
if (message.type !== "ice") {
|
|
2249
|
+
const peerNonce = fromBase64Url(message.sessionNonce);
|
|
2250
|
+
if (peerNonce.byteLength !== 16) throw new Error("Invalid DataChannel session nonce");
|
|
2251
|
+
if (this.#connectionReady && !sameBytes(this.#peerChannelNonce, peerNonce)) return;
|
|
2252
|
+
this.#setPeerChannelNonce(peerNonce);
|
|
2253
|
+
}
|
|
2254
|
+
const generation = this.#peerGeneration;
|
|
2255
|
+
const peer = await this.#ensurePeer();
|
|
2256
|
+
this.#assertActivePeer(peer, generation);
|
|
2257
|
+
if (message.type === "offer" && this.#role === "join") {
|
|
2258
|
+
if (this.#connectionReady && this.#forceRelay) {
|
|
2259
|
+
renewingRelay = true;
|
|
2260
|
+
const iceServers = await this.#loadIceServers(true);
|
|
2261
|
+
if (this.#closed || this.#peer !== peer) return;
|
|
2262
|
+
peer.setConfiguration({ ...peer.getConfiguration(), iceServers });
|
|
2263
|
+
}
|
|
2264
|
+
await peer.setRemoteDescription(message.description);
|
|
2265
|
+
this.#assertActivePeer(peer, generation);
|
|
2266
|
+
await this.#flushCandidates(peer, generation);
|
|
2267
|
+
const answer2 = await peer.createAnswer();
|
|
2268
|
+
this.#assertActivePeer(peer, generation);
|
|
2269
|
+
if (!answer2.sdp) throw new Error("The browser created an empty WebRTC answer");
|
|
2270
|
+
await peer.setLocalDescription(answer2);
|
|
2271
|
+
this.#assertActivePeer(peer, generation);
|
|
2272
|
+
this.#sendSignal({
|
|
2273
|
+
v: PROTOCOL_VERSION,
|
|
2274
|
+
type: "answer",
|
|
2275
|
+
sessionNonce: toBase64Url(this.#channelNonce),
|
|
2276
|
+
description: { type: "answer", sdp: answer2.sdp }
|
|
2277
|
+
});
|
|
2278
|
+
this.#completeDisconnectRecovery(peer);
|
|
2279
|
+
} else if (message.type === "answer" && this.#role === "host") {
|
|
2280
|
+
await peer.setRemoteDescription(message.description);
|
|
2281
|
+
this.#assertActivePeer(peer, generation);
|
|
2282
|
+
await this.#flushCandidates(peer, generation);
|
|
2283
|
+
this.#completeDisconnectRecovery(peer);
|
|
2284
|
+
} else if (message.type === "ice") {
|
|
2285
|
+
if (peer.remoteDescription) await this.#applyCandidate(peer, generation, message.candidate);
|
|
2286
|
+
else this.#pendingCandidates.push(message.candidate);
|
|
2287
|
+
}
|
|
2288
|
+
} catch (error) {
|
|
2289
|
+
if (error instanceof Error && error.message === "Peer connection superseded") return;
|
|
2290
|
+
this.#callbacks.onError(renewingRelay ? "Relay renewal is unavailable. Reconnect to continue; your files remain here." : error instanceof Error ? error.message : "Invalid signaling message");
|
|
2291
|
+
if (renewingRelay) this.#endTransferChannel();
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
async #makeOffer() {
|
|
2295
|
+
if (this.#offered) return;
|
|
2296
|
+
this.#offered = true;
|
|
2297
|
+
const generation = this.#peerGeneration;
|
|
2298
|
+
const peer = await this.#ensurePeer();
|
|
2299
|
+
this.#assertActivePeer(peer, generation);
|
|
2300
|
+
const channel = peer.createDataChannel("paironce", { ordered: true });
|
|
2301
|
+
this.#attachChannel(channel);
|
|
2302
|
+
const offer = await peer.createOffer();
|
|
2303
|
+
this.#assertActivePeer(peer, generation);
|
|
2304
|
+
if (!offer.sdp) throw new Error("The browser created an empty WebRTC offer");
|
|
2305
|
+
await peer.setLocalDescription(offer);
|
|
2306
|
+
this.#assertActivePeer(peer, generation);
|
|
2307
|
+
this.#sendSignal({
|
|
2308
|
+
v: PROTOCOL_VERSION,
|
|
2309
|
+
type: "offer",
|
|
2310
|
+
sessionNonce: toBase64Url(this.#channelNonce),
|
|
2311
|
+
description: { type: "offer", sdp: offer.sdp }
|
|
2312
|
+
});
|
|
2313
|
+
this.#callbacks.onStatus("Negotiating a direct browser connection\u2026");
|
|
2314
|
+
}
|
|
2315
|
+
async #ensurePeer() {
|
|
2316
|
+
if (this.#peer) return this.#peer;
|
|
2317
|
+
this.#peerPromise ??= this.#createPeer();
|
|
2318
|
+
return this.#peerPromise;
|
|
2319
|
+
}
|
|
2320
|
+
async #createPeer() {
|
|
2321
|
+
const generation = this.#peerGeneration;
|
|
2322
|
+
const forceRelay = this.#forceRelay;
|
|
2323
|
+
const iceServers = await this.#loadIceServers(forceRelay);
|
|
2324
|
+
const peer = new RTCPeerConnection({
|
|
2325
|
+
iceServers,
|
|
2326
|
+
// Pre-gathering can emit candidates before the applied SDP carries the
|
|
2327
|
+
// matching ICE username fragment. That creates intermittent direct
|
|
2328
|
+
// failures, and it can allocate relay candidates before TURN is needed.
|
|
2329
|
+
// Gather on demand for both paths so signaling and cost accounting stay
|
|
2330
|
+
// aligned with the active description.
|
|
2331
|
+
iceCandidatePoolSize: 0,
|
|
2332
|
+
iceTransportPolicy: forceRelay ? "relay" : "all"
|
|
2333
|
+
});
|
|
2334
|
+
peer.addEventListener("icecandidate", (event) => {
|
|
2335
|
+
if (generation !== this.#peerGeneration || this.#peer !== peer || this.#closed) return;
|
|
2336
|
+
if (event.candidate) {
|
|
2337
|
+
const candidate = event.candidate.toJSON();
|
|
2338
|
+
this.#sendSignal({
|
|
2339
|
+
v: PROTOCOL_VERSION,
|
|
2340
|
+
type: "ice",
|
|
2341
|
+
candidate: {
|
|
2342
|
+
candidate: candidate.candidate ?? event.candidate.candidate,
|
|
2343
|
+
sdpMid: candidate.sdpMid,
|
|
2344
|
+
sdpMLineIndex: candidate.sdpMLineIndex,
|
|
2345
|
+
usernameFragment: candidate.usernameFragment
|
|
2346
|
+
}
|
|
2347
|
+
});
|
|
2348
|
+
}
|
|
2349
|
+
});
|
|
2350
|
+
peer.addEventListener("track", (event) => {
|
|
2351
|
+
if (generation !== this.#peerGeneration || this.#peer !== peer || this.#closed) return;
|
|
2352
|
+
const live = this.#live;
|
|
2353
|
+
if (!live || live.role !== "view" || !live.accepted || event.track.kind !== "video") {
|
|
2354
|
+
event.track.stop();
|
|
2355
|
+
return;
|
|
2356
|
+
}
|
|
2357
|
+
live.on = true;
|
|
2358
|
+
try {
|
|
2359
|
+
this.#callbacks.onLiveStream?.(event.streams[0] ?? new MediaStream([event.track]), live.kind);
|
|
2360
|
+
} catch {
|
|
2361
|
+
}
|
|
2362
|
+
});
|
|
2363
|
+
peer.addEventListener("datachannel", (event) => {
|
|
2364
|
+
if (generation !== this.#peerGeneration || this.#peer !== peer || this.#closed) {
|
|
2365
|
+
event.channel.close();
|
|
2366
|
+
return;
|
|
2367
|
+
}
|
|
2368
|
+
this.#attachChannel(event.channel);
|
|
2369
|
+
});
|
|
2370
|
+
peer.addEventListener("connectionstatechange", () => {
|
|
2371
|
+
if (generation !== this.#peerGeneration || this.#peer !== peer || this.#closed) return;
|
|
2372
|
+
if (peer.connectionState === "failed") {
|
|
2373
|
+
this.#handlePeerFailure(peer);
|
|
2374
|
+
}
|
|
2375
|
+
if (peer.connectionState === "disconnected" && this.#connectionReady) this.#beginDisconnectRecovery(peer);
|
|
2376
|
+
if (peer.connectionState === "connected") this.#completeDisconnectRecovery(peer);
|
|
2377
|
+
});
|
|
2378
|
+
if (generation !== this.#peerGeneration || this.#closed) {
|
|
2379
|
+
peer.close();
|
|
2380
|
+
throw new Error("Peer connection superseded");
|
|
2381
|
+
}
|
|
2382
|
+
this.#peer = peer;
|
|
2383
|
+
this.#startConnectionTimer(generation);
|
|
2384
|
+
return peer;
|
|
2385
|
+
}
|
|
2386
|
+
#startConnectionTimer(generation) {
|
|
2387
|
+
this.#clearConnectionTimer();
|
|
2388
|
+
this.#clearPairingTimer();
|
|
2389
|
+
this.#connectionTimer = setTimeout(() => {
|
|
2390
|
+
if (generation !== this.#peerGeneration || this.#connectionReady || this.#closed) return;
|
|
2391
|
+
this.#handlePeerFailure(this.#peer);
|
|
2392
|
+
}, DIRECT_RETRY_TIMEOUT_MS);
|
|
2393
|
+
}
|
|
2394
|
+
#clearConnectionTimer() {
|
|
2395
|
+
if (this.#connectionTimer !== void 0) {
|
|
2396
|
+
clearTimeout(this.#connectionTimer);
|
|
2397
|
+
this.#connectionTimer = void 0;
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
#handlePeerFailure(peer) {
|
|
2401
|
+
if (this.#closed || peer && peer !== this.#peer) return;
|
|
2402
|
+
if (this.#connectionReady) {
|
|
2403
|
+
if (peer && this.#channel?.readyState === "open") this.#beginDisconnectRecovery(peer);
|
|
2404
|
+
else this.#endTransferChannel();
|
|
2405
|
+
return;
|
|
2406
|
+
}
|
|
2407
|
+
if (shouldRetryWithTurn({
|
|
2408
|
+
forceRelay: this.#forceRelay,
|
|
2409
|
+
retryCount: this.#retryCount,
|
|
2410
|
+
channelReady: this.#connectionReady,
|
|
2411
|
+
relayAllowed: !this.#options.directOnly && (!this.#realtimeOnly || this.#relayFallback)
|
|
2412
|
+
})) {
|
|
2413
|
+
this.#retryPromise ??= this.#beginRelayRetry(true).finally(() => {
|
|
2414
|
+
this.#retryPromise = void 0;
|
|
2415
|
+
});
|
|
2416
|
+
return;
|
|
2417
|
+
}
|
|
2418
|
+
this.#clearConnectionTimer();
|
|
2419
|
+
this.#callbacks.onError(
|
|
2420
|
+
this.#forceRelay ? "The TURN relay connection failed." : "The peer connection failed."
|
|
2421
|
+
);
|
|
2422
|
+
}
|
|
2423
|
+
#beginDisconnectRecovery(peer) {
|
|
2424
|
+
if (this.#disconnectTimer !== void 0 || this.#closed || this.#peer !== peer) return;
|
|
2425
|
+
const generation = this.#peerGeneration;
|
|
2426
|
+
this.#callbacks.onConnectivityChanged?.(false);
|
|
2427
|
+
this.#callbacks.onStatus("Connection interrupted. Trying to reconnect\u2026");
|
|
2428
|
+
this.#disconnectTimer = setTimeout(() => {
|
|
2429
|
+
this.#disconnectTimer = void 0;
|
|
2430
|
+
if (generation !== this.#peerGeneration || this.#peer !== peer || this.#closed) return;
|
|
2431
|
+
this.#endTransferChannel();
|
|
2432
|
+
}, DISCONNECT_RECOVERY_TIMEOUT_MS);
|
|
2433
|
+
this.#attemptDisconnectRecovery();
|
|
2434
|
+
}
|
|
2435
|
+
#attemptDisconnectRecovery() {
|
|
2436
|
+
if (this.#closed || this.#disconnectTimer === void 0) return;
|
|
2437
|
+
if (this.#disconnectRetryTimer !== void 0) clearTimeout(this.#disconnectRetryTimer);
|
|
2438
|
+
this.#disconnectRetryTimer = void 0;
|
|
2439
|
+
if (!this.#socket || this.#socket.readyState >= 2) this.#openSignaling(true);
|
|
2440
|
+
if (this.#socket?.readyState !== WebSocket.OPEN) {
|
|
2441
|
+
this.#scheduleDisconnectRetry();
|
|
2442
|
+
return;
|
|
2443
|
+
}
|
|
2444
|
+
if (this.#role === "host") void this.#restartIce(this.#forceRelay, true);
|
|
2445
|
+
}
|
|
2446
|
+
#scheduleDisconnectRetry() {
|
|
2447
|
+
if (this.#closed || this.#disconnectTimer === void 0 || this.#disconnectRetryTimer !== void 0) return;
|
|
2448
|
+
this.#disconnectRetryTimer = setTimeout(() => {
|
|
2449
|
+
this.#disconnectRetryTimer = void 0;
|
|
2450
|
+
this.#attemptDisconnectRecovery();
|
|
2451
|
+
}, 2e3);
|
|
2452
|
+
}
|
|
2453
|
+
#clearDisconnectTimer() {
|
|
2454
|
+
if (this.#disconnectTimer !== void 0) clearTimeout(this.#disconnectTimer);
|
|
2455
|
+
if (this.#disconnectRetryTimer !== void 0) clearTimeout(this.#disconnectRetryTimer);
|
|
2456
|
+
this.#disconnectTimer = void 0;
|
|
2457
|
+
this.#disconnectRetryTimer = void 0;
|
|
2458
|
+
}
|
|
2459
|
+
#completeDisconnectRecovery(peer) {
|
|
2460
|
+
if (this.#closed || this.#peer !== peer || this.#disconnectTimer === void 0 || peer.connectionState !== "connected" || peer.signalingState !== "stable" || this.#socket?.readyState !== WebSocket.OPEN || this.#channel?.readyState !== "open") return;
|
|
2461
|
+
this.#clearDisconnectTimer();
|
|
2462
|
+
if (this.#paired || !this.#options.requirePairing) {
|
|
2463
|
+
this.#callbacks.onConnectivityChanged?.(true);
|
|
2464
|
+
this.#callbacks.onStatus("Connection restored. You can continue sending files.");
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
#clearRelayTimers() {
|
|
2468
|
+
if (this.#relayExpiryTimer !== void 0) clearTimeout(this.#relayExpiryTimer);
|
|
2469
|
+
if (this.#relayRenewalTimer !== void 0) clearTimeout(this.#relayRenewalTimer);
|
|
2470
|
+
this.#relayExpiryTimer = void 0;
|
|
2471
|
+
this.#relayRenewalTimer = void 0;
|
|
2472
|
+
this.#relayExpiresAt = void 0;
|
|
2473
|
+
}
|
|
2474
|
+
#scheduleRelayRenewal() {
|
|
2475
|
+
if (this.#role !== "host" || !this.#forceRelay || !this.#relayExpiresAt || this.#closed || !this.#connectionReady || this.#options.requirePairing && !this.#paired) return;
|
|
2476
|
+
if (this.#relayRenewalTimer !== void 0) clearTimeout(this.#relayRenewalTimer);
|
|
2477
|
+
const remaining = this.#relayExpiresAt - Date.now();
|
|
2478
|
+
const lead = Math.min(6e4, Math.floor(remaining / 3));
|
|
2479
|
+
this.#relayRenewalTimer = setTimeout(() => {
|
|
2480
|
+
this.#relayRenewalTimer = void 0;
|
|
2481
|
+
void this.#restartIce();
|
|
2482
|
+
}, Math.max(0, remaining - lead));
|
|
2483
|
+
}
|
|
2484
|
+
async #restartIce(refreshRelay = true, recovering = false) {
|
|
2485
|
+
const peer = this.#peer;
|
|
2486
|
+
if (!peer || this.#closed || this.#relayRenewing) return;
|
|
2487
|
+
this.#relayRenewing = true;
|
|
2488
|
+
try {
|
|
2489
|
+
const iceServers = refreshRelay ? await this.#loadIceServers(true) : void 0;
|
|
2490
|
+
if (this.#closed || this.#peer !== peer) return;
|
|
2491
|
+
if (peer.signalingState !== "stable") throw new Error("Relay negotiation is busy");
|
|
2492
|
+
if (iceServers) peer.setConfiguration({ ...peer.getConfiguration(), iceServers });
|
|
2493
|
+
const offer = await peer.createOffer({ iceRestart: true });
|
|
2494
|
+
if (!offer.sdp) throw new Error("Empty relay renewal offer");
|
|
2495
|
+
if (this.#closed || this.#peer !== peer) return;
|
|
2496
|
+
await peer.setLocalDescription(offer);
|
|
2497
|
+
if (this.#closed || this.#peer !== peer) return;
|
|
2498
|
+
this.#sendSignal({
|
|
2499
|
+
v: PROTOCOL_VERSION,
|
|
2500
|
+
type: "offer",
|
|
2501
|
+
sessionNonce: toBase64Url(this.#channelNonce),
|
|
2502
|
+
description: { type: "offer", sdp: offer.sdp }
|
|
2503
|
+
});
|
|
2504
|
+
this.#scheduleRelayRenewal();
|
|
2505
|
+
} catch {
|
|
2506
|
+
if (!this.#closed && this.#peer === peer) {
|
|
2507
|
+
if (recovering) {
|
|
2508
|
+
this.#scheduleDisconnectRetry();
|
|
2509
|
+
return;
|
|
2510
|
+
}
|
|
2511
|
+
this.#callbacks.onError(refreshRelay ? "Relay renewal is unavailable. Reconnect to continue; your files remain here." : "The peer connection failed.");
|
|
2512
|
+
this.#endTransferChannel();
|
|
2513
|
+
}
|
|
2514
|
+
} finally {
|
|
2515
|
+
this.#relayRenewing = false;
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
#endTransferChannel() {
|
|
2519
|
+
if (this.#closed) return;
|
|
2520
|
+
this.close();
|
|
2521
|
+
this.#callbacks.onStatus("Transfer channel closed");
|
|
2522
|
+
}
|
|
2523
|
+
async #beginRelayRetry(notifyPeer) {
|
|
2524
|
+
if (this.#closed || this.#retryCount >= 1 || this.#connectionReady) return;
|
|
2525
|
+
this.#retryCount = 1;
|
|
2526
|
+
this.#forceRelay = true;
|
|
2527
|
+
this.#callbacks.onTransportRetry?.("turn");
|
|
2528
|
+
this.#callbacks.onStatus("Direct connection failed; retrying through TURN relay\u2026");
|
|
2529
|
+
this.#resetPeer();
|
|
2530
|
+
if (notifyPeer && this.#socket?.readyState === WebSocket.OPEN) this.#sendHello();
|
|
2531
|
+
if (this.#role === "host") {
|
|
2532
|
+
try {
|
|
2533
|
+
await this.#makeOffer();
|
|
2534
|
+
} catch (error) {
|
|
2535
|
+
this.#callbacks.onError(error instanceof Error ? error.message : "TURN relay unavailable");
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
#resetPeer() {
|
|
2540
|
+
this.#peerGeneration += 1;
|
|
2541
|
+
this.#transportAbort.abort(new Error("The transfer channel restarted"));
|
|
2542
|
+
this.#transportAbort = new AbortController();
|
|
2543
|
+
this.#channelNonce = randomBytes(16);
|
|
2544
|
+
this.#frameKey = void 0;
|
|
2545
|
+
this.#frameKeyPromise = void 0;
|
|
2546
|
+
this.#receivedFrameKeys.clear();
|
|
2547
|
+
this.#controlFrameKeys = 0;
|
|
2548
|
+
this.#discardIncomingFiles();
|
|
2549
|
+
this.#clearConnectionTimer();
|
|
2550
|
+
this.#rejectPendingFileAcks("The transfer channel restarted before receipt confirmation");
|
|
2551
|
+
this.#rejectPendingFileDecisions("The transfer channel restarted before file approval");
|
|
2552
|
+
for (const transferId of this.#pendingFileOffers.keys()) this.#removePendingFileOffer(transferId);
|
|
2553
|
+
this.#keepAcceptedPartials();
|
|
2554
|
+
this.#pendingFileOffers.clear();
|
|
2555
|
+
this.#receivedFileHistory.clear();
|
|
2556
|
+
this.#closedFileTransfers.clear();
|
|
2557
|
+
this.#closedFileTransferOrder.length = 0;
|
|
2558
|
+
this.#clearDisconnectTimer();
|
|
2559
|
+
this.#clearRelayTimers();
|
|
2560
|
+
this.#channel?.close();
|
|
2561
|
+
this.#peer?.close();
|
|
2562
|
+
this.#channel = void 0;
|
|
2563
|
+
this.#peer = void 0;
|
|
2564
|
+
this.#peerPromise = void 0;
|
|
2565
|
+
this.#pendingCandidates.length = 0;
|
|
2566
|
+
this.#offered = false;
|
|
2567
|
+
this.#connectionReady = false;
|
|
2568
|
+
this.#readyPath = void 0;
|
|
2569
|
+
this.#pairingClaimId = void 0;
|
|
2570
|
+
this.#pairingCodeEntered = void 0;
|
|
2571
|
+
this.#pendingPeerIdentity = void 0;
|
|
2572
|
+
this.#awaitingHostApproval = false;
|
|
2573
|
+
}
|
|
2574
|
+
#startPairingTimer() {
|
|
2575
|
+
this.#clearPairingTimer();
|
|
2576
|
+
this.#pairingTimer = setTimeout(() => {
|
|
2577
|
+
if (this.#paired || this.#closed) return;
|
|
2578
|
+
this.#pairingDenied = true;
|
|
2579
|
+
this.#awaitingHostApproval = false;
|
|
2580
|
+
this.#callbacks.onPairingRejected?.("Pairing confirmation timed out; start a new session.", true);
|
|
2581
|
+
this.#callbacks.onError("Pairing confirmation timed out; start a new session.");
|
|
2582
|
+
this.close();
|
|
2583
|
+
}, PAIRING_RESPONSE_TIMEOUT_MS);
|
|
2584
|
+
}
|
|
2585
|
+
#clearPairingTimer() {
|
|
2586
|
+
if (this.#pairingTimer !== void 0) {
|
|
2587
|
+
clearTimeout(this.#pairingTimer);
|
|
2588
|
+
this.#pairingTimer = void 0;
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
#sendHello() {
|
|
2592
|
+
this.#sendSignal({
|
|
2593
|
+
v: PROTOCOL_VERSION,
|
|
2594
|
+
type: "hello",
|
|
2595
|
+
role: this.#role,
|
|
2596
|
+
frameProtocol: 2,
|
|
2597
|
+
...this.#localBinaryChunks ? { binaryFileChunks: 1 } : {},
|
|
2598
|
+
...this.#options.diskReceive ? { largeFiles: 1, hugeFiles: 1 } : {},
|
|
2599
|
+
...this.#options.isPro?.() ? { pro: 1, ...this.#options.proPass?.() ? { proPass: this.#options.proPass() } : {} } : {},
|
|
2600
|
+
receiveWindow: 1,
|
|
2601
|
+
...this.#options.liveMedia ? { liveMedia: 1 } : {},
|
|
2602
|
+
sessionNonce: toBase64Url(this.#channelNonce),
|
|
2603
|
+
...this.#localCapabilities().length > 0 ? { capabilities: this.#localCapabilities() } : {},
|
|
2604
|
+
...this.#options.deviceKeyAgreement ? { safetyCode: 2 } : {},
|
|
2605
|
+
...(!this.#options.requirePairing || this.#options.deviceKeyAgreement) && this.#options.identity ? {
|
|
2606
|
+
deviceId: this.#options.identity.id,
|
|
2607
|
+
deviceName: this.#options.identity.name,
|
|
2608
|
+
devicePublicKey: this.#options.identity.publicKey
|
|
2609
|
+
} : {}
|
|
2610
|
+
});
|
|
2611
|
+
}
|
|
2612
|
+
async #loadIceServers(forceRelay) {
|
|
2613
|
+
const fallback = [
|
|
2614
|
+
{ urls: "stun:stun.cloudflare.com:3478" }
|
|
2615
|
+
];
|
|
2616
|
+
if (!forceRelay) return fallback;
|
|
2617
|
+
const generation = this.#peerGeneration;
|
|
2618
|
+
try {
|
|
2619
|
+
const endpoint = this.#edgeEndpoint(
|
|
2620
|
+
`/v1/pair/${this.#roomId}/turn-credentials`,
|
|
2621
|
+
"http"
|
|
2622
|
+
);
|
|
2623
|
+
let response;
|
|
2624
|
+
let requestedAt = Date.now();
|
|
2625
|
+
for (let attempt = 1; ; attempt++) {
|
|
2626
|
+
try {
|
|
2627
|
+
const challenge = await this.#requestRelayChallenge();
|
|
2628
|
+
requestedAt = Date.now();
|
|
2629
|
+
response = await fetch(endpoint, {
|
|
2630
|
+
method: "POST",
|
|
2631
|
+
headers: { Accept: "application/json", "Content-Type": "application/json", "X-PairOnce-Session": this.#sessionId },
|
|
2632
|
+
body: JSON.stringify({ challenge }),
|
|
2633
|
+
signal: anySignal([this.#transportAbort.signal, timeoutSignal(15e3)]),
|
|
2634
|
+
cache: "no-store",
|
|
2635
|
+
credentials: "same-origin"
|
|
2636
|
+
});
|
|
2637
|
+
if (response.ok || !RETRYABLE_RELAY_STATUS.has(response.status) || attempt >= RELAY_CREDENTIAL_ATTEMPTS) break;
|
|
2638
|
+
} catch (error) {
|
|
2639
|
+
if (attempt >= RELAY_CREDENTIAL_ATTEMPTS || this.#closed || this.#transportAbort.signal.aborted) throw error;
|
|
2640
|
+
}
|
|
2641
|
+
await new Promise((resolve) => setTimeout(resolve, attempt === 1 ? 1e3 : 3e3));
|
|
2642
|
+
if (generation !== this.#peerGeneration || this.#closed) throw new Error("Peer connection superseded");
|
|
2643
|
+
}
|
|
2644
|
+
if (!response.ok) {
|
|
2645
|
+
const refused = response.status === 429 ? await response.json().catch(() => ({})) : {};
|
|
2646
|
+
throw new Error(refused.error === "relay_quota_exhausted" ? RELAY_QUOTA_MESSAGE : `TURN credential request failed (${response.status})`);
|
|
2647
|
+
}
|
|
2648
|
+
const payload = await response.json();
|
|
2649
|
+
if (!isRtcIceServerList(payload.iceServers)) throw new Error("TURN credential response was invalid");
|
|
2650
|
+
if (!Number.isInteger(payload.ttlSeconds) || payload.ttlSeconds < 1 || payload.ttlSeconds > 900)
|
|
2651
|
+
throw new Error("TURN credential lifetime was invalid");
|
|
2652
|
+
if (generation !== this.#peerGeneration || this.#closed) throw new Error("Peer connection superseded");
|
|
2653
|
+
if (typeof payload.relayAllowanceBytes === "number" && Number.isSafeInteger(payload.relayAllowanceBytes) && payload.relayAllowanceBytes >= 0) {
|
|
2654
|
+
this.#relayAllowance = true;
|
|
2655
|
+
this.#setRelayLeft(payload.relayAllowanceBytes);
|
|
2656
|
+
}
|
|
2657
|
+
const expiresAt = requestedAt + payload.ttlSeconds * 1e3 - 1e3;
|
|
2658
|
+
if (expiresAt <= Date.now()) throw new Error("TURN credentials have expired");
|
|
2659
|
+
if (this.#relayExpiryTimer !== void 0) clearTimeout(this.#relayExpiryTimer);
|
|
2660
|
+
this.#relayExpiresAt = expiresAt;
|
|
2661
|
+
this.#relayExpiryTimer = setTimeout(() => {
|
|
2662
|
+
if (generation !== this.#peerGeneration || this.#closed) return;
|
|
2663
|
+
this.#callbacks.onError("Relay authorization expired. Reconnect to continue; your files remain here.");
|
|
2664
|
+
this.#endTransferChannel();
|
|
2665
|
+
}, expiresAt - Date.now());
|
|
2666
|
+
return payload.iceServers;
|
|
2667
|
+
} catch (error) {
|
|
2668
|
+
if (forceRelay) {
|
|
2669
|
+
throw new Error(
|
|
2670
|
+
error instanceof Error ? `TURN relay unavailable: ${error.message}` : "TURN relay unavailable"
|
|
2671
|
+
);
|
|
2672
|
+
}
|
|
2673
|
+
return fallback;
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
#requestRelayChallenge() {
|
|
2677
|
+
if (this.#socket?.readyState !== WebSocket.OPEN || this.#relayChallengePending) return Promise.reject(new Error("The relay session is not ready"));
|
|
2678
|
+
return new Promise((resolve, reject) => {
|
|
2679
|
+
const id = randomId();
|
|
2680
|
+
const timer = setTimeout(() => {
|
|
2681
|
+
this.#relayChallengePending = void 0;
|
|
2682
|
+
reject(new Error("Relay authorization timed out"));
|
|
2683
|
+
}, 5e3);
|
|
2684
|
+
this.#relayChallengePending = { id, resolve, reject, timer };
|
|
2685
|
+
this.#socket.send(JSON.stringify({ v: 1, type: "relay-challenge", requestId: id }));
|
|
2686
|
+
});
|
|
2687
|
+
}
|
|
2688
|
+
#edgeEndpoint(pathname, transport) {
|
|
2689
|
+
const endpoint = new URL(signalingOrigin());
|
|
2690
|
+
if (transport === "websocket") {
|
|
2691
|
+
endpoint.protocol = endpoint.protocol === "https:" || endpoint.protocol === "wss:" ? "wss:" : "ws:";
|
|
2692
|
+
} else {
|
|
2693
|
+
endpoint.protocol = endpoint.protocol === "wss:" ? "https:" : endpoint.protocol === "ws:" ? "http:" : endpoint.protocol;
|
|
2694
|
+
}
|
|
2695
|
+
endpoint.pathname = pathname;
|
|
2696
|
+
endpoint.search = "";
|
|
2697
|
+
endpoint.hash = "";
|
|
2698
|
+
return endpoint;
|
|
2699
|
+
}
|
|
2700
|
+
#attachChannel(channel) {
|
|
2701
|
+
channel.binaryType = "arraybuffer";
|
|
2702
|
+
if (this.#channel && this.#channel !== channel) this.#channel.close();
|
|
2703
|
+
this.#channel = channel;
|
|
2704
|
+
const generation = this.#peerGeneration;
|
|
2705
|
+
const signal = this.#transportAbort.signal;
|
|
2706
|
+
channel.bufferedAmountLowThreshold = 128 * 1024;
|
|
2707
|
+
channel.addEventListener("open", async () => {
|
|
2708
|
+
if (generation !== this.#peerGeneration || this.#channel !== channel || this.#closed) return;
|
|
2709
|
+
if (!this.#peerFrameProtocol) {
|
|
2710
|
+
this.close();
|
|
2711
|
+
this.#callbacks.onError("The other device needs an update before pairing. Refresh both pages.");
|
|
2712
|
+
return;
|
|
2713
|
+
}
|
|
2714
|
+
this.#connectionReady = true;
|
|
2715
|
+
this.#clearConnectionTimer();
|
|
2716
|
+
const path6 = await this.#detectPath();
|
|
2717
|
+
if (generation !== this.#peerGeneration || this.#channel !== channel || this.#closed) return;
|
|
2718
|
+
this.#readyPath = path6;
|
|
2719
|
+
if (this.#options.requirePairing) {
|
|
2720
|
+
this.#callbacks.onStatus("Secure channel ready; confirm the six-digit code on the joining device.");
|
|
2721
|
+
this.#callbacks.onPairingRequired?.();
|
|
2722
|
+
} else {
|
|
2723
|
+
this.#callbacks.onStatus(path6 === "turn" ? "Connected through TURN relay" : "Direct connection established");
|
|
2724
|
+
this.#callbacks.onReady(path6);
|
|
2725
|
+
this.#scheduleRelayRenewal();
|
|
2726
|
+
}
|
|
2727
|
+
});
|
|
2728
|
+
channel.addEventListener("message", (event) => {
|
|
2729
|
+
if (generation !== this.#peerGeneration || this.#channel !== channel || this.#closed) return;
|
|
2730
|
+
const size = typeof event.data === "string" ? event.data.length : event.data instanceof ArrayBuffer ? event.data.byteLength : 0;
|
|
2731
|
+
if (this.#receiveQueuedFrames >= MAX_QUEUED_FRAMES || this.#receiveQueuedBytes + size > MAX_QUEUED_BYTES) {
|
|
2732
|
+
this.#callbacks.onError("The other device sent more than this page can take at once. Connect again.");
|
|
2733
|
+
channel.close();
|
|
2734
|
+
return;
|
|
2735
|
+
}
|
|
2736
|
+
this.#receiveQueuedFrames += 1;
|
|
2737
|
+
this.#receiveQueuedBytes += size;
|
|
2738
|
+
this.#receiveChain = this.#receiveChain.then(() => {
|
|
2739
|
+
if (generation !== this.#peerGeneration || this.#channel !== channel || signal.aborted) return;
|
|
2740
|
+
return this.#handleDataFrame(event.data, signal);
|
|
2741
|
+
}).catch((error) => {
|
|
2742
|
+
if (generation !== this.#peerGeneration || this.#channel !== channel || signal.aborted) return;
|
|
2743
|
+
this.#callbacks.onError(error instanceof Error ? error.message : "Could not read encrypted payload");
|
|
2744
|
+
}).finally(() => {
|
|
2745
|
+
this.#receiveQueuedFrames -= 1;
|
|
2746
|
+
this.#receiveQueuedBytes -= size;
|
|
2747
|
+
});
|
|
2748
|
+
});
|
|
2749
|
+
channel.addEventListener("close", () => {
|
|
2750
|
+
if (generation !== this.#peerGeneration || this.#channel !== channel || this.#closed) return;
|
|
2751
|
+
this.#endTransferChannel();
|
|
2752
|
+
});
|
|
2753
|
+
}
|
|
2754
|
+
async #handleDataFrame(raw, signal) {
|
|
2755
|
+
signal.throwIfAborted();
|
|
2756
|
+
let frame;
|
|
2757
|
+
if (raw instanceof ArrayBuffer) {
|
|
2758
|
+
if (!this.#peerBinaryChunks) throw new Error("Unsupported binary frame");
|
|
2759
|
+
frame = decodeBinaryFrame(raw);
|
|
2760
|
+
} else {
|
|
2761
|
+
if (typeof raw !== "string") throw new Error("Unsupported binary frame");
|
|
2762
|
+
if (raw.length > MAX_DATA_FRAME_CHARS) throw new Error("Data channel frame is too large");
|
|
2763
|
+
frame = parseDataFrame(JSON.parse(raw));
|
|
2764
|
+
}
|
|
2765
|
+
if (frame.type === "pair-claim" || frame.type === "pair-result" || frame.type === "pair-confirm") {
|
|
2766
|
+
await this.#handlePairingFrame(frame, signal);
|
|
2767
|
+
return;
|
|
2768
|
+
}
|
|
2769
|
+
if (frame.type === "file-offer" || frame.type === "file-decision" || frame.type === "file-cancel" || frame.type === "file-receipt" || frame.type === "file-progress") {
|
|
2770
|
+
await this.#handleFileControlFrame(frame, signal);
|
|
2771
|
+
return;
|
|
2772
|
+
}
|
|
2773
|
+
if (frame.type === "file-ack") {
|
|
2774
|
+
if (this.#options.fileApproval) return;
|
|
2775
|
+
const pending = this.#pendingFileAcks.get(frame.transferId);
|
|
2776
|
+
if (!pending) {
|
|
2777
|
+
if (this.#closedFileTransfers.has(frame.transferId)) return;
|
|
2778
|
+
throw new Error("Unexpected file receipt confirmation");
|
|
2779
|
+
}
|
|
2780
|
+
clearTimeout(pending.timer);
|
|
2781
|
+
this.#pendingFileAcks.delete(frame.transferId);
|
|
2782
|
+
pending.resolve();
|
|
2783
|
+
return;
|
|
2784
|
+
}
|
|
2785
|
+
if (frame.type === "file-preview") {
|
|
2786
|
+
this.#requirePairing();
|
|
2787
|
+
await this.#handleFilePreview(frame, signal);
|
|
2788
|
+
return;
|
|
2789
|
+
}
|
|
2790
|
+
if (frame.type === "live") {
|
|
2791
|
+
await this.#handleLiveFrame(frame, signal);
|
|
2792
|
+
return;
|
|
2793
|
+
}
|
|
2794
|
+
this.#requirePairing();
|
|
2795
|
+
if (frame.type === "text") {
|
|
2796
|
+
const frameKey = `text:${frame.transferId}`;
|
|
2797
|
+
if (this.#receivedFrameKeys.has(frameKey)) throw new Error("Encrypted frame replay detected");
|
|
2798
|
+
const plaintext = await decryptChunk(
|
|
2799
|
+
await this.#requiredKey("receive"),
|
|
2800
|
+
fromBase64Url(frame.noncePrefix),
|
|
2801
|
+
fromBase64Url(frame.ciphertext),
|
|
2802
|
+
{
|
|
2803
|
+
channel: this.#frameContext("receive", frame.type),
|
|
2804
|
+
transferId: frame.transferId,
|
|
2805
|
+
kind: "text",
|
|
2806
|
+
chunkIndex: 0,
|
|
2807
|
+
totalChunks: 1,
|
|
2808
|
+
plainSize: frame.plainSize
|
|
2809
|
+
}
|
|
2810
|
+
);
|
|
2811
|
+
signal.throwIfAborted();
|
|
2812
|
+
if (!acceptFrameKey(this.#receivedFrameKeys, frameKey)) throw new Error("Encrypted frame replay detected");
|
|
2813
|
+
if (this.#relayed()) {
|
|
2814
|
+
this.#relayBytes += plaintext.byteLength;
|
|
2815
|
+
this.#noteRelayed(plaintext.byteLength);
|
|
2816
|
+
}
|
|
2817
|
+
this.#callbacks.onText(new TextDecoder("utf-8", { fatal: true }).decode(plaintext));
|
|
2818
|
+
return;
|
|
2819
|
+
}
|
|
2820
|
+
try {
|
|
2821
|
+
await this.#receiveFileFrame(frame, signal);
|
|
2822
|
+
} catch (error) {
|
|
2823
|
+
if (!signal.aborted) {
|
|
2824
|
+
this.#cancelIncomingFile(frame.transferId);
|
|
2825
|
+
if (this.#fileApprovalNegotiated()) {
|
|
2826
|
+
try {
|
|
2827
|
+
await this.#sendFileControl({ type: "file-cancel", transferId: frame.transferId, reason: "receive_failed" });
|
|
2828
|
+
} catch {
|
|
2829
|
+
}
|
|
2830
|
+
}
|
|
2831
|
+
}
|
|
2832
|
+
throw error;
|
|
2833
|
+
}
|
|
2834
|
+
}
|
|
2835
|
+
async #receiveFileFrame(frame, signal) {
|
|
2836
|
+
if (frame.type === "file-meta") {
|
|
2837
|
+
if (frame.totalChunks !== chunkCount(frame.size, frame.chunkSize)) throw new Error("Invalid file chunk count");
|
|
2838
|
+
let accepted;
|
|
2839
|
+
if (this.#options.fileApproval) {
|
|
2840
|
+
accepted = this.#acceptedFileOffers.get(frame.transferId);
|
|
2841
|
+
if (!accepted) {
|
|
2842
|
+
if (this.#closedFileTransfers.has(frame.transferId)) return;
|
|
2843
|
+
throw new Error("File metadata arrived without an accepted offer");
|
|
2844
|
+
}
|
|
2845
|
+
clearTimeout(accepted.timer);
|
|
2846
|
+
this.#acceptedFileOffers.delete(frame.transferId);
|
|
2847
|
+
const privateMeta = frame.name === PRIVATE_META_NAME && frame.mime === "";
|
|
2848
|
+
if (!privateMeta && (accepted.name !== frame.name || accepted.mime !== frame.mime) || accepted.size !== frame.size || accepted.chunkSize !== frame.chunkSize || accepted.totalChunks !== frame.totalChunks) {
|
|
2849
|
+
this.#releaseRelay(accepted.relayClaim);
|
|
2850
|
+
if (accepted.resume) void this.#options.diskReceive?.discardPartial(accepted.resume);
|
|
2851
|
+
this.#rememberClosedTransfer(frame.transferId);
|
|
2852
|
+
await this.#sendFileControl({ type: "file-cancel", transferId: frame.transferId, reason: "manifest_mismatch" });
|
|
2853
|
+
return;
|
|
2854
|
+
}
|
|
2855
|
+
if (privateMeta) frame = { ...frame, name: accepted.name, mime: accepted.mime };
|
|
2856
|
+
} else {
|
|
2857
|
+
if (frame.size > this.#receiveLimit()) throw new Error("File is larger than this device takes");
|
|
2858
|
+
if (!await this.#roomFor(frame.size)) {
|
|
2859
|
+
throw new Error("Receive memory limit reached. Save and remove received files, then reconnect.");
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2862
|
+
const frameKey2 = `meta:${frame.transferId}`;
|
|
2863
|
+
if (!acceptFrameKey(this.#receivedFrameKeys, frameKey2)) {
|
|
2864
|
+
this.#releaseRelay(accepted?.relayClaim);
|
|
2865
|
+
if (accepted?.resume) void this.#options.diskReceive?.discardPartial(accepted.resume);
|
|
2866
|
+
throw new Error("Encrypted file metadata replay detected");
|
|
2867
|
+
}
|
|
2868
|
+
let sink;
|
|
2869
|
+
let entry = frame.transferId;
|
|
2870
|
+
let next = 0;
|
|
2871
|
+
const resume = accepted?.resume;
|
|
2872
|
+
if (frame.resumeFrom !== void 0 && frame.resumeFrom !== resume?.next) {
|
|
2873
|
+
this.#releaseRelay(accepted?.relayClaim);
|
|
2874
|
+
if (resume) void this.#options.diskReceive.discardPartial(resume);
|
|
2875
|
+
throw new Error("File continuation did not match what this device kept");
|
|
2876
|
+
}
|
|
2877
|
+
if (this.#toDisk(frame.size)) {
|
|
2878
|
+
const disk = this.#options.diskReceive;
|
|
2879
|
+
try {
|
|
2880
|
+
if (resume && frame.resumeFrom === resume.next) {
|
|
2881
|
+
entry = resume.entry;
|
|
2882
|
+
next = resume.next;
|
|
2883
|
+
sink = await disk.open(entry, frame.name, frame.mime, next * frame.chunkSize);
|
|
2884
|
+
} else {
|
|
2885
|
+
if (resume) await disk.discardPartial(resume);
|
|
2886
|
+
sink = await disk.open(entry, frame.name, frame.mime);
|
|
2887
|
+
}
|
|
2888
|
+
} catch (error) {
|
|
2889
|
+
this.#releaseRelay(accepted?.relayClaim);
|
|
2890
|
+
if (resume) void disk.discardPartial(resume);
|
|
2891
|
+
throw error;
|
|
2892
|
+
}
|
|
2893
|
+
if (signal.aborted || this.#closed) {
|
|
2894
|
+
this.#releaseRelay(accepted?.relayClaim);
|
|
2895
|
+
const peerId = this.#pairedPeerIdentity?.id;
|
|
2896
|
+
if (resume && accepted?.resumeKey && peerId) {
|
|
2897
|
+
void disk.keepPartial({
|
|
2898
|
+
peerId,
|
|
2899
|
+
resumeKey: accepted.resumeKey,
|
|
2900
|
+
entry,
|
|
2901
|
+
name: frame.name,
|
|
2902
|
+
mime: frame.mime,
|
|
2903
|
+
size: frame.size,
|
|
2904
|
+
chunkSize: frame.chunkSize,
|
|
2905
|
+
totalChunks: frame.totalChunks,
|
|
2906
|
+
next
|
|
2907
|
+
}, sink).catch(() => {
|
|
2908
|
+
});
|
|
2909
|
+
} else void sink.abort();
|
|
2910
|
+
return;
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
this.#incomingFiles.set(frame.transferId, {
|
|
2914
|
+
name: frame.name,
|
|
2915
|
+
mime: frame.mime,
|
|
2916
|
+
size: frame.size,
|
|
2917
|
+
chunkSize: frame.chunkSize,
|
|
2918
|
+
totalChunks: frame.totalChunks,
|
|
2919
|
+
noncePrefix: fromBase64Url(frame.noncePrefix),
|
|
2920
|
+
...sink ? { sink, entry } : { assembler: new ChunkAssembler(frame.totalChunks) },
|
|
2921
|
+
next,
|
|
2922
|
+
reported: next,
|
|
2923
|
+
...sink && accepted?.resumeKey && this.#pairedPeerIdentity ? { resumeKey: accepted.resumeKey, peerId: this.#pairedPeerIdentity.id } : {},
|
|
2924
|
+
relayClaim: accepted?.relayClaim
|
|
2925
|
+
});
|
|
2926
|
+
return;
|
|
2927
|
+
}
|
|
2928
|
+
const incoming = this.#incomingFiles.get(frame.transferId);
|
|
2929
|
+
if (!incoming) {
|
|
2930
|
+
if (this.#closedFileTransfers.has(frame.transferId)) return;
|
|
2931
|
+
throw new Error("File chunk arrived without metadata");
|
|
2932
|
+
}
|
|
2933
|
+
if (frame.type === "file-chunk") {
|
|
2934
|
+
if (frame.index >= incoming.totalChunks) throw new Error("File chunk index is out of range");
|
|
2935
|
+
if (incoming.sink && frame.index !== incoming.next) throw new Error("Encrypted file chunk replay detected");
|
|
2936
|
+
const frameKey2 = `chunk:${frame.transferId}:${frame.index}`;
|
|
2937
|
+
if (!incoming.sink && this.#receivedFrameKeys.has(frameKey2)) throw new Error("Encrypted file chunk replay detected");
|
|
2938
|
+
const expectedSize = Math.max(
|
|
2939
|
+
0,
|
|
2940
|
+
Math.min(incoming.chunkSize, incoming.size - frame.index * incoming.chunkSize)
|
|
2941
|
+
);
|
|
2942
|
+
const ciphertext = typeof frame.ciphertext === "string" ? fromBase64Url(frame.ciphertext) : frame.ciphertext;
|
|
2943
|
+
if (ciphertext.byteLength !== expectedSize + 16) throw new Error("File chunk size did not match its manifest");
|
|
2944
|
+
const plaintext = await decryptChunk(
|
|
2945
|
+
await this.#requiredKey("receive"),
|
|
2946
|
+
incoming.noncePrefix,
|
|
2947
|
+
ciphertext,
|
|
2948
|
+
{
|
|
2949
|
+
channel: this.#frameContext("receive", frame.type),
|
|
2950
|
+
transferId: frame.transferId,
|
|
2951
|
+
kind: "file",
|
|
2952
|
+
chunkIndex: frame.index,
|
|
2953
|
+
totalChunks: incoming.totalChunks,
|
|
2954
|
+
plainSize: expectedSize
|
|
2955
|
+
}
|
|
2956
|
+
);
|
|
2957
|
+
signal.throwIfAborted();
|
|
2958
|
+
if (incoming.sink) {
|
|
2959
|
+
if (frame.index !== incoming.next) throw new Error("Encrypted file chunk replay detected");
|
|
2960
|
+
incoming.next += 1;
|
|
2961
|
+
await incoming.sink.write(plaintext);
|
|
2962
|
+
} else {
|
|
2963
|
+
if (!acceptFrameKey(this.#receivedFrameKeys, frameKey2)) throw new Error("Encrypted file chunk replay detected");
|
|
2964
|
+
incoming.assembler.add(frame.index, plaintext);
|
|
2965
|
+
}
|
|
2966
|
+
this.#useRelay(incoming.relayClaim, plaintext.byteLength);
|
|
2967
|
+
this.#callbacks.onProgress(incoming.name, (frame.index + 1) / incoming.totalChunks, "received");
|
|
2968
|
+
if (this.#useReceiveWindow() && progressDue(frame.index + 1, incoming.reported, incoming.totalChunks)) {
|
|
2969
|
+
incoming.reported = frame.index + 1;
|
|
2970
|
+
await this.#sendFileControl({ type: "file-progress", transferId: frame.transferId, next: incoming.reported }, signal);
|
|
2971
|
+
}
|
|
2972
|
+
return;
|
|
2973
|
+
}
|
|
2974
|
+
if (incoming.sink && incoming.next !== incoming.totalChunks) throw new Error("Received file size did not match its manifest");
|
|
2975
|
+
const blob = incoming.sink ? await incoming.sink.finish() : incoming.assembler.finish(incoming.mime);
|
|
2976
|
+
if (blob.size !== incoming.size) throw new Error("Received file size did not match its manifest");
|
|
2977
|
+
const frameKey = `complete:${frame.transferId}`;
|
|
2978
|
+
if (!acceptFrameKey(this.#receivedFrameKeys, frameKey)) throw new Error("Encrypted file completion replay detected");
|
|
2979
|
+
this.#incomingFiles.delete(frame.transferId);
|
|
2980
|
+
this.#releaseRelay(incoming.relayClaim);
|
|
2981
|
+
const received = {
|
|
2982
|
+
transferId: frame.transferId,
|
|
2983
|
+
name: incoming.name,
|
|
2984
|
+
size: incoming.size,
|
|
2985
|
+
mime: incoming.mime,
|
|
2986
|
+
blob,
|
|
2987
|
+
url: URL.createObjectURL(blob),
|
|
2988
|
+
...incoming.sink ? { onDisk: true, diskEntry: incoming.entry } : {}
|
|
2989
|
+
};
|
|
2990
|
+
this.#receivedFileHistory.set(frame.transferId, { transferId: frame.transferId, stage: "received" });
|
|
2991
|
+
while (this.#receivedFileHistory.size > MAX_FILE_HISTORY) {
|
|
2992
|
+
const oldest = this.#receivedFileHistory.keys().next().value;
|
|
2993
|
+
if (!oldest) break;
|
|
2994
|
+
this.#receivedFileHistory.delete(oldest);
|
|
2995
|
+
}
|
|
2996
|
+
this.#callbacks.onFile(received);
|
|
2997
|
+
if (this.#fileApprovalNegotiated()) await this.#sendFileControl({ type: "file-receipt", transferId: frame.transferId, stage: "received" });
|
|
2998
|
+
else this.#sendFrame({ v: PROTOCOL_VERSION, type: "file-ack", transferId: frame.transferId });
|
|
2999
|
+
}
|
|
3000
|
+
async #handleFileControlFrame(frame, signal) {
|
|
3001
|
+
this.#requirePairing();
|
|
3002
|
+
const frameKey = `file-control:${frame.type}:${frame.transferId}:${frame.noncePrefix}`;
|
|
3003
|
+
const remember = frame.type !== "file-progress";
|
|
3004
|
+
if (remember && this.#receivedFrameKeys.has(frameKey)) throw new Error("Encrypted file control replay detected");
|
|
3005
|
+
const plaintext = await decryptChunk(await this.#requiredKey("receive"), fromBase64Url(frame.noncePrefix), fromBase64Url(frame.ciphertext), {
|
|
3006
|
+
channel: this.#frameContext("receive", frame.type),
|
|
3007
|
+
transferId: frame.transferId,
|
|
3008
|
+
kind: "text",
|
|
3009
|
+
chunkIndex: 0,
|
|
3010
|
+
totalChunks: 1,
|
|
3011
|
+
plainSize: frame.plainSize
|
|
3012
|
+
});
|
|
3013
|
+
signal.throwIfAborted();
|
|
3014
|
+
if (remember) {
|
|
3015
|
+
if (!acceptFrameKey(this.#receivedFrameKeys, frameKey)) throw new Error("Encrypted file control replay detected");
|
|
3016
|
+
if (++this.#controlFrameKeys > MAX_CONTROL_FRAME_KEYS) {
|
|
3017
|
+
this.#channel?.close();
|
|
3018
|
+
throw new Error("The other device sent more than this page can take at once. Connect again.");
|
|
3019
|
+
}
|
|
3020
|
+
}
|
|
3021
|
+
const payload = parseFileControlPayload(new TextDecoder("utf-8", { fatal: true }).decode(plaintext), this.#receiveLimit());
|
|
3022
|
+
if (payload.type !== frame.type || payload.transferId !== frame.transferId) throw new Error("File control message type mismatch");
|
|
3023
|
+
if (payload.type === "file-offer") {
|
|
3024
|
+
await this.#handleFileOffer(payload);
|
|
3025
|
+
return;
|
|
3026
|
+
}
|
|
3027
|
+
if (payload.type === "file-decision") {
|
|
3028
|
+
const pending2 = this.#pendingFileDecisions.get(payload.transferId);
|
|
3029
|
+
if (!pending2) {
|
|
3030
|
+
if (this.#closedFileTransfers.has(payload.transferId)) return;
|
|
3031
|
+
throw new Error("Unexpected file decision");
|
|
3032
|
+
}
|
|
3033
|
+
clearTimeout(pending2.timer);
|
|
3034
|
+
this.#pendingFileDecisions.delete(payload.transferId);
|
|
3035
|
+
pending2.resolve({
|
|
3036
|
+
decision: payload.decision,
|
|
3037
|
+
...payload.resumeFrom !== void 0 ? { resumeFrom: payload.resumeFrom } : {},
|
|
3038
|
+
...payload.privateMeta ? { privateMeta: true } : {}
|
|
3039
|
+
});
|
|
3040
|
+
return;
|
|
3041
|
+
}
|
|
3042
|
+
if (payload.type === "file-progress") {
|
|
3043
|
+
this.#sendWindows.get(payload.transferId)?.acknowledge(payload.next);
|
|
3044
|
+
return;
|
|
3045
|
+
}
|
|
3046
|
+
if (payload.type === "file-cancel") {
|
|
3047
|
+
this.#activeFileAborters.get(payload.transferId)?.abort(new Error("The receiving browser stopped this file transfer"));
|
|
3048
|
+
this.#cancelIncomingFile(payload.transferId);
|
|
3049
|
+
return;
|
|
3050
|
+
}
|
|
3051
|
+
const pending = this.#pendingFileAcks.get(payload.transferId);
|
|
3052
|
+
if (pending && payload.stage === "received") {
|
|
3053
|
+
clearTimeout(pending.timer);
|
|
3054
|
+
this.#pendingFileAcks.delete(payload.transferId);
|
|
3055
|
+
pending.resolve();
|
|
3056
|
+
}
|
|
3057
|
+
this.#callbacks.onFileReceipt?.({ transferId: payload.transferId, stage: payload.stage });
|
|
3058
|
+
}
|
|
3059
|
+
async #handleFileOffer(offer) {
|
|
3060
|
+
if (!this.#fileApprovalNegotiated()) {
|
|
3061
|
+
this.#rememberClosedTransfer(offer.transferId);
|
|
3062
|
+
await this.#sendFileControl({ type: "file-decision", transferId: offer.transferId, decision: "reject" });
|
|
3063
|
+
return;
|
|
3064
|
+
}
|
|
3065
|
+
if (this.#closedFileTransfers.has(offer.transferId)) {
|
|
3066
|
+
this.#forgetClosedTransfer(offer.transferId);
|
|
3067
|
+
for (const key of this.#receivedFrameKeys) {
|
|
3068
|
+
if (key === `meta:${offer.transferId}` || key.startsWith(`chunk:${offer.transferId}:`) || key === `complete:${offer.transferId}`) {
|
|
3069
|
+
this.#receivedFrameKeys.delete(key);
|
|
3070
|
+
}
|
|
3071
|
+
}
|
|
3072
|
+
}
|
|
3073
|
+
if (this.#pendingFileOffers.has(offer.transferId) || this.#acceptedFileOffers.has(offer.transferId) || this.#incomingFiles.has(offer.transferId)) return;
|
|
3074
|
+
const pendingMemory = [...this.#pendingFileOffers.values()].reduce((sum, file) => sum + (this.#toDisk(file.size) ? 0 : file.size), 0);
|
|
3075
|
+
const needed = this.#stillNeeded(offer, this.#matchingPartial(offer));
|
|
3076
|
+
if (this.#pendingFileOffers.size >= MAX_PENDING_FILE_OFFERS || !await this.#roomFor(offer.size, pendingMemory, needed)) {
|
|
3077
|
+
this.#rememberClosedTransfer(offer.transferId);
|
|
3078
|
+
await this.#sendFileControl({ type: "file-decision", transferId: offer.transferId, decision: "busy" });
|
|
3079
|
+
return;
|
|
3080
|
+
}
|
|
3081
|
+
const timer = setTimeout(() => {
|
|
3082
|
+
const pending = this.#pendingFileOffers.get(offer.transferId);
|
|
3083
|
+
if (!pending) return;
|
|
3084
|
+
this.#removePendingFileOffer(offer.transferId);
|
|
3085
|
+
this.#rememberClosedTransfer(offer.transferId);
|
|
3086
|
+
void this.#sendFileControl({ type: "file-decision", transferId: offer.transferId, decision: "timeout" }).catch(() => {
|
|
3087
|
+
});
|
|
3088
|
+
}, FILE_OFFER_TIMEOUT_MS);
|
|
3089
|
+
this.#pendingFileOffers.set(offer.transferId, { ...offer, timer });
|
|
3090
|
+
if (this.#matchingPartial(offer)) {
|
|
3091
|
+
void this.acceptFile(offer.transferId).catch(() => {
|
|
3092
|
+
});
|
|
3093
|
+
return;
|
|
3094
|
+
}
|
|
3095
|
+
try {
|
|
3096
|
+
this.#callbacks.onFileOffer?.(offer);
|
|
3097
|
+
} catch {
|
|
3098
|
+
}
|
|
3099
|
+
}
|
|
3100
|
+
#cancelIncomingFile(transferId) {
|
|
3101
|
+
this.#removePendingFileOffer(transferId);
|
|
3102
|
+
const accepted = this.#acceptedFileOffers.get(transferId);
|
|
3103
|
+
if (accepted) {
|
|
3104
|
+
clearTimeout(accepted.timer);
|
|
3105
|
+
this.#releaseRelay(accepted.relayClaim);
|
|
3106
|
+
if (accepted.resume) void this.#options.diskReceive?.discardPartial(accepted.resume);
|
|
3107
|
+
}
|
|
3108
|
+
this.#acceptedFileOffers.delete(transferId);
|
|
3109
|
+
const incoming = this.#incomingFiles.get(transferId);
|
|
3110
|
+
if (incoming) {
|
|
3111
|
+
incoming.assembler?.clear();
|
|
3112
|
+
if (incoming.sink) void incoming.sink.abort();
|
|
3113
|
+
this.#releaseRelay(incoming.relayClaim);
|
|
3114
|
+
}
|
|
3115
|
+
this.#incomingFiles.delete(transferId);
|
|
3116
|
+
this.#rememberClosedTransfer(transferId);
|
|
3117
|
+
if (accepted || incoming) {
|
|
3118
|
+
try {
|
|
3119
|
+
this.#callbacks.onIncomingFileClosed?.(transferId, incoming?.name ?? accepted?.name);
|
|
3120
|
+
} catch {
|
|
3121
|
+
}
|
|
3122
|
+
}
|
|
3123
|
+
}
|
|
3124
|
+
#removePendingFileOffer(transferId) {
|
|
3125
|
+
const pending = this.#pendingFileOffers.get(transferId);
|
|
3126
|
+
if (!pending) return;
|
|
3127
|
+
clearTimeout(pending.timer);
|
|
3128
|
+
this.#pendingFileOffers.delete(transferId);
|
|
3129
|
+
try {
|
|
3130
|
+
this.#callbacks.onFileOfferClosed?.(transferId);
|
|
3131
|
+
} catch {
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
async #sendPairingFrame(type, payload) {
|
|
3135
|
+
const signal = this.#transportAbort.signal;
|
|
3136
|
+
signal.throwIfAborted();
|
|
3137
|
+
if (type !== payload.type) throw new Error("Pairing frame type mismatch");
|
|
3138
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(payload));
|
|
3139
|
+
if (plaintext.byteLength > 4096) throw new Error("Pairing message is too large");
|
|
3140
|
+
const transferId = randomId();
|
|
3141
|
+
const noncePrefix = createNoncePrefix();
|
|
3142
|
+
const ciphertext = await encryptChunk(await this.#requiredKey("send"), noncePrefix, plaintext, {
|
|
3143
|
+
channel: this.#frameContext("send", type),
|
|
3144
|
+
transferId,
|
|
3145
|
+
kind: "text",
|
|
3146
|
+
chunkIndex: 0,
|
|
3147
|
+
totalChunks: 1,
|
|
3148
|
+
plainSize: plaintext.byteLength
|
|
3149
|
+
});
|
|
3150
|
+
signal.throwIfAborted();
|
|
3151
|
+
this.#sendFrame({
|
|
3152
|
+
v: PROTOCOL_VERSION,
|
|
3153
|
+
type,
|
|
3154
|
+
transferId,
|
|
3155
|
+
noncePrefix: toBase64Url(noncePrefix),
|
|
3156
|
+
ciphertext: toBase64Url(ciphertext),
|
|
3157
|
+
plainSize: plaintext.byteLength
|
|
3158
|
+
});
|
|
3159
|
+
}
|
|
3160
|
+
async #handlePairingFrame(frame, signal) {
|
|
3161
|
+
if (!this.#options.requirePairing || this.#paired) throw new Error("Unexpected pairing message");
|
|
3162
|
+
const frameKey = `pair:${frame.transferId}`;
|
|
3163
|
+
if (this.#receivedFrameKeys.has(frameKey)) throw new Error("Pairing message replay detected");
|
|
3164
|
+
const plaintext = await decryptChunk(
|
|
3165
|
+
await this.#requiredKey("receive"),
|
|
3166
|
+
fromBase64Url(frame.noncePrefix),
|
|
3167
|
+
fromBase64Url(frame.ciphertext),
|
|
3168
|
+
{
|
|
3169
|
+
channel: this.#frameContext("receive", frame.type),
|
|
3170
|
+
transferId: frame.transferId,
|
|
3171
|
+
kind: "text",
|
|
3172
|
+
chunkIndex: 0,
|
|
3173
|
+
totalChunks: 1,
|
|
3174
|
+
plainSize: frame.plainSize
|
|
3175
|
+
}
|
|
3176
|
+
);
|
|
3177
|
+
signal.throwIfAborted();
|
|
3178
|
+
if (!acceptFrameKey(this.#receivedFrameKeys, frameKey)) throw new Error("Pairing message replay detected");
|
|
3179
|
+
const payload = parsePairingPayload(new TextDecoder("utf-8", { fatal: true }).decode(plaintext));
|
|
3180
|
+
if (payload.type !== frame.type) throw new Error("Pairing message type mismatch");
|
|
3181
|
+
if (payload.type === "pair-claim") {
|
|
3182
|
+
if (this.#role !== "host" || this.#pendingPeerIdentity || this.#pairingDenied) throw new Error("Unexpected pairing claim");
|
|
3183
|
+
if (this.#seenPairingClaims.has(payload.claimId)) throw new Error("Pairing claim replay detected");
|
|
3184
|
+
this.#seenPairingClaims.add(payload.claimId);
|
|
3185
|
+
try {
|
|
3186
|
+
await this.#verifyCodeAttempt(payload.claimId, payload.device.id);
|
|
3187
|
+
} catch {
|
|
3188
|
+
signal.throwIfAborted();
|
|
3189
|
+
this.#pairingDenied = true;
|
|
3190
|
+
const reason = "Pairing attempt could not be verified; create a new private link";
|
|
3191
|
+
await this.#sendPairingFrame("pair-result", {
|
|
3192
|
+
type: "pair-result",
|
|
3193
|
+
claimId: payload.claimId,
|
|
3194
|
+
accepted: false,
|
|
3195
|
+
reason,
|
|
3196
|
+
terminal: true
|
|
3197
|
+
});
|
|
3198
|
+
signal.throwIfAborted();
|
|
3199
|
+
this.#callbacks.onPairingRejected?.(reason, true);
|
|
3200
|
+
this.#callbacks.onStatus(reason);
|
|
3201
|
+
return;
|
|
3202
|
+
}
|
|
3203
|
+
const expectedCode = this.#options.deviceKeyAgreement ? this.#options.pairingCode ?? await this.#safetyCodeFor(payload.device) : this.#options.pairingCode ?? "";
|
|
3204
|
+
signal.throwIfAborted();
|
|
3205
|
+
this.#pairingCodeForClaim = expectedCode;
|
|
3206
|
+
this.#callbacks.onPairingSas?.(expectedCode);
|
|
3207
|
+
const validCode = this.#pairingAttempts < MAX_PAIRING_ATTEMPTS && Date.now() < (this.#options.pairingExpiresAt ?? 0) && equalPairingCodes(payload.code, expectedCode);
|
|
3208
|
+
let validProof = false;
|
|
3209
|
+
if (validCode) {
|
|
3210
|
+
try {
|
|
3211
|
+
validProof = await verifyDeviceProof({
|
|
3212
|
+
privateKey: this.#options.identityPrivateKey,
|
|
3213
|
+
peerPublicKey: payload.device.publicKey,
|
|
3214
|
+
invitationSecret: this.#secret,
|
|
3215
|
+
roomId: this.#roomId,
|
|
3216
|
+
claimId: payload.claimId,
|
|
3217
|
+
host: this.#options.identity,
|
|
3218
|
+
join: payload.device,
|
|
3219
|
+
code: payload.code,
|
|
3220
|
+
direction: "join-claim"
|
|
3221
|
+
}, payload.proof);
|
|
3222
|
+
} catch {
|
|
3223
|
+
validProof = false;
|
|
3224
|
+
}
|
|
3225
|
+
}
|
|
3226
|
+
signal.throwIfAborted();
|
|
3227
|
+
const pinned = this.#options.expectedPeerIdentity;
|
|
3228
|
+
const valid = validCode && validProof && (!pinned || pinned.id === payload.device.id && pinned.publicKey === payload.device.publicKey);
|
|
3229
|
+
if (!valid) {
|
|
3230
|
+
this.#pairingAttempts += 1;
|
|
3231
|
+
const reason = this.#pairingAttempts >= MAX_PAIRING_ATTEMPTS ? "Too many pairing attempts; start a new session" : Date.now() >= (this.#options.pairingExpiresAt ?? 0) ? "Pairing code expired; start a new session" : "Pairing code or device proof did not match";
|
|
3232
|
+
const terminal = this.#pairingAttempts >= MAX_PAIRING_ATTEMPTS || Date.now() >= (this.#options.pairingExpiresAt ?? 0);
|
|
3233
|
+
if (terminal) this.#pairingDenied = true;
|
|
3234
|
+
await this.#sendPairingFrame("pair-result", {
|
|
3235
|
+
type: "pair-result",
|
|
3236
|
+
claimId: payload.claimId,
|
|
3237
|
+
accepted: false,
|
|
3238
|
+
reason,
|
|
3239
|
+
terminal
|
|
3240
|
+
});
|
|
3241
|
+
signal.throwIfAborted();
|
|
3242
|
+
this.#callbacks.onPairingRejected?.(reason, terminal);
|
|
3243
|
+
return;
|
|
3244
|
+
}
|
|
3245
|
+
this.#pairingClaimId = payload.claimId;
|
|
3246
|
+
this.#pendingPeerIdentity = payload.device;
|
|
3247
|
+
this.#awaitingHostApproval = true;
|
|
3248
|
+
this.#startPairingTimer();
|
|
3249
|
+
this.#callbacks.onPairingApprovalRequired?.(payload.device);
|
|
3250
|
+
this.#callbacks.onStatus("Code matched; approve or reject the joining device on this browser.");
|
|
3251
|
+
return;
|
|
3252
|
+
}
|
|
3253
|
+
if (payload.type === "pair-result") {
|
|
3254
|
+
if (this.#role !== "join" || !this.#pairingClaimId || payload.claimId !== this.#pairingClaimId) {
|
|
3255
|
+
throw new Error("Unexpected pairing result");
|
|
3256
|
+
}
|
|
3257
|
+
if (!payload.accepted) {
|
|
3258
|
+
this.#pairingClaimId = void 0;
|
|
3259
|
+
this.#pairingCodeEntered = void 0;
|
|
3260
|
+
this.#clearPairingTimer();
|
|
3261
|
+
if (payload.terminal) this.#pairingDenied = true;
|
|
3262
|
+
this.#callbacks.onPairingRejected?.(payload.reason ?? "Pairing code was rejected", payload.terminal);
|
|
3263
|
+
return;
|
|
3264
|
+
}
|
|
3265
|
+
if (!payload.device) throw new Error("Pairing result omitted the host device");
|
|
3266
|
+
const expectedHost = this.#options.expectedPeerIdentity;
|
|
3267
|
+
if (payload.device.id !== expectedHost.id || payload.device.publicKey !== expectedHost.publicKey || !payload.proof) {
|
|
3268
|
+
throw new Error("Host device identity did not match the invitation");
|
|
3269
|
+
}
|
|
3270
|
+
const proofInput = {
|
|
3271
|
+
privateKey: this.#options.identityPrivateKey,
|
|
3272
|
+
peerPublicKey: expectedHost.publicKey,
|
|
3273
|
+
invitationSecret: this.#secret,
|
|
3274
|
+
roomId: this.#roomId,
|
|
3275
|
+
claimId: payload.claimId,
|
|
3276
|
+
host: expectedHost,
|
|
3277
|
+
join: this.#options.identity,
|
|
3278
|
+
code: this.#pairingCodeEntered,
|
|
3279
|
+
direction: "host-approve"
|
|
3280
|
+
};
|
|
3281
|
+
if (!await verifyDeviceProof(proofInput, payload.proof)) {
|
|
3282
|
+
throw new Error("Host device private-key proof failed");
|
|
3283
|
+
}
|
|
3284
|
+
signal.throwIfAborted();
|
|
3285
|
+
const finalProof = await createDeviceProof({ ...proofInput, direction: "join-confirm" });
|
|
3286
|
+
signal.throwIfAborted();
|
|
3287
|
+
await this.#sendPairingFrame("pair-confirm", { type: "pair-confirm", claimId: payload.claimId, proof: finalProof });
|
|
3288
|
+
signal.throwIfAborted();
|
|
3289
|
+
this.#pairingClaimId = void 0;
|
|
3290
|
+
this.#pairingCodeEntered = void 0;
|
|
3291
|
+
this.#finishPairing(expectedHost);
|
|
3292
|
+
return;
|
|
3293
|
+
}
|
|
3294
|
+
if (this.#role !== "host" || !this.#pendingPeerIdentity || this.#awaitingHostApproval || payload.claimId !== this.#pairingClaimId) {
|
|
3295
|
+
throw new Error("Unexpected pairing confirmation");
|
|
3296
|
+
}
|
|
3297
|
+
if (!await verifyDeviceProof({
|
|
3298
|
+
privateKey: this.#options.identityPrivateKey,
|
|
3299
|
+
peerPublicKey: this.#pendingPeerIdentity.publicKey,
|
|
3300
|
+
invitationSecret: this.#secret,
|
|
3301
|
+
roomId: this.#roomId,
|
|
3302
|
+
claimId: payload.claimId,
|
|
3303
|
+
host: this.#options.identity,
|
|
3304
|
+
join: this.#pendingPeerIdentity,
|
|
3305
|
+
code: this.#pairingCodeForClaim ?? this.#options.pairingCode,
|
|
3306
|
+
direction: "join-confirm"
|
|
3307
|
+
}, payload.proof)) {
|
|
3308
|
+
throw new Error("Joining device private-key proof failed");
|
|
3309
|
+
}
|
|
3310
|
+
signal.throwIfAborted();
|
|
3311
|
+
void this.#reportPairingComplete(payload.claimId);
|
|
3312
|
+
this.#finishPairing(this.#pendingPeerIdentity);
|
|
3313
|
+
this.#pendingPeerIdentity = void 0;
|
|
3314
|
+
this.#awaitingHostApproval = false;
|
|
3315
|
+
this.#pairingClaimId = void 0;
|
|
3316
|
+
this.#pairingCodeForClaim = void 0;
|
|
3317
|
+
}
|
|
3318
|
+
#finishPairing(peer) {
|
|
3319
|
+
if (this.#paired) return;
|
|
3320
|
+
this.#paired = true;
|
|
3321
|
+
this.#pairedPeerIdentity = peer;
|
|
3322
|
+
this.#clearPairingTimer();
|
|
3323
|
+
this.#options.onPeerIdentity?.(peer);
|
|
3324
|
+
this.#callbacks.onPairingConfirmed?.(peer);
|
|
3325
|
+
void this.#sendLiveFingerprint();
|
|
3326
|
+
if (this.#pendingLiveFingerprint) {
|
|
3327
|
+
const reported = this.#pendingLiveFingerprint;
|
|
3328
|
+
this.#pendingLiveFingerprint = void 0;
|
|
3329
|
+
this.#verifyLiveFingerprint(reported);
|
|
3330
|
+
}
|
|
3331
|
+
const path6 = this.#readyPath;
|
|
3332
|
+
if (path6) {
|
|
3333
|
+
this.#callbacks.onStatus(path6 === "turn" ? "Paired through TURN relay" : "Paired through direct connection");
|
|
3334
|
+
this.#callbacks.onReady(path6);
|
|
3335
|
+
this.#scheduleRelayRenewal();
|
|
3336
|
+
}
|
|
3337
|
+
}
|
|
3338
|
+
// ---------- live picture (live-media.ts) ----------
|
|
3339
|
+
/**
|
|
3340
|
+
* A live picture can start now: both pages offer it, the devices are paired and the media identity checked, directly,
|
|
3341
|
+
* or over the relay when either device has Pro (2.17.0; the relay's measured bytes count against its allowance).
|
|
3342
|
+
*/
|
|
3343
|
+
liveAvailable() {
|
|
3344
|
+
return this.#options.liveMedia === true && this.#peerLiveMedia && this.#paired && this.#liveVerified && (!this.#relayed() || this.#proConnection()) && this.#channel?.readyState === "open" && !this.#closed;
|
|
3345
|
+
}
|
|
3346
|
+
/** The other page offers a live picture at all (an older page does not). */
|
|
3347
|
+
peerOffersLive() {
|
|
3348
|
+
return this.#peerLiveMedia;
|
|
3349
|
+
}
|
|
3350
|
+
/**
|
|
3351
|
+
* Ask to show this stream's video, and once the other person accepts, add it to the connection. Resolves when the
|
|
3352
|
+
* picture flows; rejects when it is declined, not answered in time, cancelled, or the connection cannot carry it.
|
|
3353
|
+
*/
|
|
3354
|
+
async startLive(stream, kind) {
|
|
3355
|
+
if (!this.liveAvailable()) throw new Error("A live picture needs a direct connection on the same Wi-Fi, or Pro.");
|
|
3356
|
+
if (this.#live) throw new Error("A live picture is already on.");
|
|
3357
|
+
const track = stream.getVideoTracks()[0];
|
|
3358
|
+
if (!track) throw new Error("No picture to show");
|
|
3359
|
+
const live = { id: randomId(), role: "share", kind, on: false };
|
|
3360
|
+
this.#live = live;
|
|
3361
|
+
try {
|
|
3362
|
+
const answered = new Promise((resolve) => {
|
|
3363
|
+
const timer = setTimeout(() => settle("timeout"), LIVE_ANSWER_TIMEOUT_MS);
|
|
3364
|
+
const settle = (answer3) => {
|
|
3365
|
+
clearTimeout(timer);
|
|
3366
|
+
if (this.#liveAnswer?.id === live.id) this.#liveAnswer = void 0;
|
|
3367
|
+
resolve(answer3);
|
|
3368
|
+
};
|
|
3369
|
+
this.#liveAnswer = { id: live.id, settle };
|
|
3370
|
+
});
|
|
3371
|
+
await this.#sendLive({ op: "request", id: live.id, kind });
|
|
3372
|
+
const answer2 = await answered;
|
|
3373
|
+
if (answer2 !== "accept" || this.#live !== live) {
|
|
3374
|
+
if (answer2 === "timeout") void this.#sendLive({ op: "stop", id: live.id }).catch(() => {
|
|
3375
|
+
});
|
|
3376
|
+
throw new Error(answer2 === "decline" ? "The other device declined the live picture" : answer2 === "timeout" ? "The other device did not answer the live picture in time" : "The live picture was cancelled");
|
|
3377
|
+
}
|
|
3378
|
+
const peer = this.#peer;
|
|
3379
|
+
if (!peer) throw new Error("The connection closed");
|
|
3380
|
+
if (kind === "screen") {
|
|
3381
|
+
try {
|
|
3382
|
+
track.contentHint = "detail";
|
|
3383
|
+
} catch {
|
|
3384
|
+
}
|
|
3385
|
+
}
|
|
3386
|
+
live.sender = peer.addTrack(track, stream);
|
|
3387
|
+
await this.#renegotiateLive(live.id);
|
|
3388
|
+
if (this.#live !== live) throw new Error("The live picture was cancelled");
|
|
3389
|
+
try {
|
|
3390
|
+
const parameters = live.sender.getParameters();
|
|
3391
|
+
if (parameters.encodings?.length) {
|
|
3392
|
+
parameters.encodings[0].maxBitrate = this.#relayed() ? LIVE_RELAY_BITRATE : kind === "screen" ? 3e6 : 25e5;
|
|
3393
|
+
await live.sender.setParameters(parameters);
|
|
3394
|
+
}
|
|
3395
|
+
} catch {
|
|
3396
|
+
}
|
|
3397
|
+
live.on = true;
|
|
3398
|
+
} catch (error) {
|
|
3399
|
+
if (this.#live === live) this.#endLive(live, true);
|
|
3400
|
+
throw error;
|
|
3401
|
+
}
|
|
3402
|
+
}
|
|
3403
|
+
/** Viewer: accept or decline a request (onLiveRequest). */
|
|
3404
|
+
async answerLive(id, accept) {
|
|
3405
|
+
const live = this.#live;
|
|
3406
|
+
if (!live || live.role !== "view" || live.id !== id) return;
|
|
3407
|
+
if (!accept) {
|
|
3408
|
+
this.#live = void 0;
|
|
3409
|
+
this.#liveLastId = id;
|
|
3410
|
+
} else live.accepted = true;
|
|
3411
|
+
await this.#sendLive({ op: "answer", id, accept });
|
|
3412
|
+
}
|
|
3413
|
+
/** Either side: end the live picture (or withdraw a request). */
|
|
3414
|
+
stopLive() {
|
|
3415
|
+
const live = this.#live;
|
|
3416
|
+
if (live) this.#endLive(live, true);
|
|
3417
|
+
}
|
|
3418
|
+
/** Sharer: show another track (the other camera) without renegotiating. */
|
|
3419
|
+
async replaceLiveTrack(track) {
|
|
3420
|
+
const live = this.#live;
|
|
3421
|
+
if (!live || live.role !== "share" || !live.sender) throw new Error("No live picture is on");
|
|
3422
|
+
await live.sender.replaceTrack(track);
|
|
3423
|
+
}
|
|
3424
|
+
/** Viewer: ask the sharer for a full-resolution still. */
|
|
3425
|
+
requestLiveStill() {
|
|
3426
|
+
const live = this.#live;
|
|
3427
|
+
if (live?.role === "view" && live.on) {
|
|
3428
|
+
live.stillsAsked = (live.stillsAsked ?? 0) + 1;
|
|
3429
|
+
void this.#sendLive({ op: "shutter", id: live.id }).catch(() => {
|
|
3430
|
+
});
|
|
3431
|
+
}
|
|
3432
|
+
}
|
|
3433
|
+
/** Sharer: the file about to be sent with this ID is the still the viewer asked for. */
|
|
3434
|
+
async announceLiveStill(transferId) {
|
|
3435
|
+
const live = this.#live;
|
|
3436
|
+
if (live?.role !== "share") throw new Error("No live picture is on");
|
|
3437
|
+
await this.#sendLive({ op: "shutter-file", id: live.id, transferId });
|
|
3438
|
+
}
|
|
3439
|
+
#endLive(live, notify) {
|
|
3440
|
+
if (this.#live !== live) return;
|
|
3441
|
+
this.#live = void 0;
|
|
3442
|
+
this.#liveLastId = live.id;
|
|
3443
|
+
if (this.#liveAnswer?.id === live.id) this.#liveAnswer.settle("cancel");
|
|
3444
|
+
if (notify && this.#channel?.readyState === "open") void this.#sendLive({ op: "stop", id: live.id }).catch(() => {
|
|
3445
|
+
});
|
|
3446
|
+
if (live.role === "share" && live.sender && this.#peer && !this.#closed) {
|
|
3447
|
+
try {
|
|
3448
|
+
this.#peer.removeTrack(live.sender);
|
|
3449
|
+
} catch {
|
|
3450
|
+
}
|
|
3451
|
+
if (live.on) void this.#renegotiateLive(live.id).catch(() => {
|
|
3452
|
+
});
|
|
3453
|
+
}
|
|
3454
|
+
}
|
|
3455
|
+
/** Sharer: one offer/answer round over the encrypted channel, one at a time. */
|
|
3456
|
+
#renegotiateLive(id) {
|
|
3457
|
+
const run = async () => {
|
|
3458
|
+
const peer = this.#peer;
|
|
3459
|
+
if (!peer || this.#closed) throw new Error("The connection closed");
|
|
3460
|
+
const answer2 = new Promise((resolve, reject) => {
|
|
3461
|
+
const timer = setTimeout(() => fail(new Error("The live picture could not start in time")), LIVE_NEGOTIATION_TIMEOUT_MS);
|
|
3462
|
+
const done = () => {
|
|
3463
|
+
clearTimeout(timer);
|
|
3464
|
+
if (this.#liveSdpAnswer?.id === id) this.#liveSdpAnswer = void 0;
|
|
3465
|
+
};
|
|
3466
|
+
const fail = (error) => {
|
|
3467
|
+
done();
|
|
3468
|
+
reject(error);
|
|
3469
|
+
};
|
|
3470
|
+
this.#liveSdpAnswer = { id, resolve: (description) => {
|
|
3471
|
+
done();
|
|
3472
|
+
resolve(description);
|
|
3473
|
+
}, reject: fail };
|
|
3474
|
+
});
|
|
3475
|
+
await peer.setLocalDescription(await peer.createOffer());
|
|
3476
|
+
await this.#sendLive({ op: "sdp", id, description: { type: "offer", sdp: peer.localDescription.sdp } });
|
|
3477
|
+
await peer.setRemoteDescription(await answer2);
|
|
3478
|
+
};
|
|
3479
|
+
const next = this.#liveChain.then(run, run);
|
|
3480
|
+
this.#liveChain = next.catch(() => {
|
|
3481
|
+
});
|
|
3482
|
+
return next;
|
|
3483
|
+
}
|
|
3484
|
+
async #sendLiveFingerprint() {
|
|
3485
|
+
if (!this.#options.liveMedia || !this.#peerLiveMedia) return;
|
|
3486
|
+
const local = sdpFingerprints(this.#peer?.localDescription?.sdp);
|
|
3487
|
+
if (local.length !== 1) return;
|
|
3488
|
+
try {
|
|
3489
|
+
await this.#sendLive({ op: "fingerprint", value: local[0] });
|
|
3490
|
+
} catch {
|
|
3491
|
+
}
|
|
3492
|
+
}
|
|
3493
|
+
#verifyLiveFingerprint(reported) {
|
|
3494
|
+
if (!fingerprintMatches(reported, this.#peer?.currentRemoteDescription?.sdp ?? this.#peer?.remoteDescription?.sdp)) {
|
|
3495
|
+
this.#callbacks.onError("The device proof did not match the media connection. For your safety this connection stopped.");
|
|
3496
|
+
this.#endTransferChannel();
|
|
3497
|
+
return;
|
|
3498
|
+
}
|
|
3499
|
+
if (this.#liveVerified) return;
|
|
3500
|
+
this.#liveVerified = true;
|
|
3501
|
+
try {
|
|
3502
|
+
this.#callbacks.onLiveReady?.();
|
|
3503
|
+
} catch {
|
|
3504
|
+
}
|
|
3505
|
+
}
|
|
3506
|
+
async #sendLive(payload) {
|
|
3507
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(payload));
|
|
3508
|
+
if (plaintext.byteLength > LIVE_CONTROL_MAX_BYTES) throw new Error("Live control message is too large");
|
|
3509
|
+
const transferId = randomId();
|
|
3510
|
+
const noncePrefix = createNoncePrefix();
|
|
3511
|
+
const ciphertext = await encryptChunk(await this.#requiredKey("send"), noncePrefix, plaintext, {
|
|
3512
|
+
channel: this.#frameContext("send", "live"),
|
|
3513
|
+
transferId,
|
|
3514
|
+
kind: "text",
|
|
3515
|
+
chunkIndex: 0,
|
|
3516
|
+
totalChunks: 1,
|
|
3517
|
+
plainSize: plaintext.byteLength
|
|
3518
|
+
});
|
|
3519
|
+
this.#sendFrame({
|
|
3520
|
+
v: PROTOCOL_VERSION,
|
|
3521
|
+
type: "live",
|
|
3522
|
+
transferId,
|
|
3523
|
+
noncePrefix: toBase64Url(noncePrefix),
|
|
3524
|
+
ciphertext: toBase64Url(ciphertext),
|
|
3525
|
+
plainSize: plaintext.byteLength
|
|
3526
|
+
});
|
|
3527
|
+
}
|
|
3528
|
+
async #handleLiveFrame(frame, signal) {
|
|
3529
|
+
if (!this.#options.liveMedia || !this.#peerLiveMedia) throw new Error("Unexpected live control message");
|
|
3530
|
+
const frameKey = `live:${frame.transferId}:${frame.noncePrefix}`;
|
|
3531
|
+
if (this.#receivedFrameKeys.has(frameKey)) throw new Error("Encrypted live control replay detected");
|
|
3532
|
+
const plaintext = await decryptChunk(await this.#requiredKey("receive"), fromBase64Url(frame.noncePrefix), fromBase64Url(frame.ciphertext), {
|
|
3533
|
+
channel: this.#frameContext("receive", "live"),
|
|
3534
|
+
transferId: frame.transferId,
|
|
3535
|
+
kind: "text",
|
|
3536
|
+
chunkIndex: 0,
|
|
3537
|
+
totalChunks: 1,
|
|
3538
|
+
plainSize: frame.plainSize
|
|
3539
|
+
});
|
|
3540
|
+
signal.throwIfAborted();
|
|
3541
|
+
if (!acceptFrameKey(this.#receivedFrameKeys, frameKey)) throw new Error("Encrypted live control replay detected");
|
|
3542
|
+
const payload = parseLivePayload(new TextDecoder("utf-8", { fatal: true }).decode(plaintext));
|
|
3543
|
+
if (payload.op === "fingerprint") {
|
|
3544
|
+
if (!this.#paired) {
|
|
3545
|
+
this.#pendingLiveFingerprint = payload.value;
|
|
3546
|
+
return;
|
|
3547
|
+
}
|
|
3548
|
+
this.#verifyLiveFingerprint(payload.value);
|
|
3549
|
+
return;
|
|
3550
|
+
}
|
|
3551
|
+
this.#requirePairing();
|
|
3552
|
+
if (!this.#liveVerified) return;
|
|
3553
|
+
const live = this.#live;
|
|
3554
|
+
switch (payload.op) {
|
|
3555
|
+
case "request":
|
|
3556
|
+
if (live || !this.liveAvailable()) {
|
|
3557
|
+
await this.#sendLive({ op: "answer", id: payload.id, accept: false });
|
|
3558
|
+
return;
|
|
3559
|
+
}
|
|
3560
|
+
this.#live = { id: payload.id, role: "view", kind: payload.kind, on: false };
|
|
3561
|
+
try {
|
|
3562
|
+
this.#callbacks.onLiveRequest?.({ id: payload.id, kind: payload.kind });
|
|
3563
|
+
} catch {
|
|
3564
|
+
}
|
|
3565
|
+
return;
|
|
3566
|
+
case "answer":
|
|
3567
|
+
if (this.#liveAnswer?.id === payload.id) this.#liveAnswer.settle(payload.accept ? "accept" : "decline");
|
|
3568
|
+
return;
|
|
3569
|
+
case "sdp": {
|
|
3570
|
+
if (payload.description.type === "answer") {
|
|
3571
|
+
if (this.#liveSdpAnswer?.id === payload.id) this.#liveSdpAnswer.resolve(payload.description);
|
|
3572
|
+
return;
|
|
3573
|
+
}
|
|
3574
|
+
if (!(live?.role === "view" && live.id === payload.id && live.accepted || this.#liveLastId === payload.id)) return;
|
|
3575
|
+
const peer = this.#peer;
|
|
3576
|
+
if (!peer) return;
|
|
3577
|
+
const respond = async () => {
|
|
3578
|
+
await peer.setRemoteDescription(payload.description);
|
|
3579
|
+
await peer.setLocalDescription(await peer.createAnswer());
|
|
3580
|
+
await this.#sendLive({ op: "sdp", id: payload.id, description: { type: "answer", sdp: peer.localDescription.sdp } });
|
|
3581
|
+
};
|
|
3582
|
+
const next = this.#liveChain.then(respond, respond);
|
|
3583
|
+
this.#liveChain = next.catch(() => {
|
|
3584
|
+
});
|
|
3585
|
+
await next;
|
|
3586
|
+
return;
|
|
3587
|
+
}
|
|
3588
|
+
case "stop":
|
|
3589
|
+
if (live?.id !== payload.id) return;
|
|
3590
|
+
this.#endLive(live, false);
|
|
3591
|
+
try {
|
|
3592
|
+
this.#callbacks.onLiveEnded?.("stopped");
|
|
3593
|
+
} catch {
|
|
3594
|
+
}
|
|
3595
|
+
return;
|
|
3596
|
+
case "shutter":
|
|
3597
|
+
if (live?.role === "share" && live.on && live.id === payload.id) {
|
|
3598
|
+
try {
|
|
3599
|
+
this.#callbacks.onLiveShutter?.();
|
|
3600
|
+
} catch {
|
|
3601
|
+
}
|
|
3602
|
+
}
|
|
3603
|
+
return;
|
|
3604
|
+
case "shutter-file":
|
|
3605
|
+
if (live?.role === "view" && live.id === payload.id && live.accepted && (live.stillsAsked ?? 0) > 0) {
|
|
3606
|
+
live.stillsAsked -= 1;
|
|
3607
|
+
try {
|
|
3608
|
+
this.#callbacks.onLiveShutterFile?.(payload.transferId);
|
|
3609
|
+
} catch {
|
|
3610
|
+
}
|
|
3611
|
+
}
|
|
3612
|
+
return;
|
|
3613
|
+
}
|
|
3614
|
+
}
|
|
3615
|
+
#requirePairing() {
|
|
3616
|
+
if (this.#options.requirePairing && !this.#paired) {
|
|
3617
|
+
throw new Error("Confirm the six-digit pairing code before sending content");
|
|
3618
|
+
}
|
|
3619
|
+
}
|
|
3620
|
+
#rejectPendingFileAcks(message) {
|
|
3621
|
+
for (const [transferId, pending] of this.#pendingFileAcks) {
|
|
3622
|
+
clearTimeout(pending.timer);
|
|
3623
|
+
this.#pendingFileAcks.delete(transferId);
|
|
3624
|
+
pending.reject(new Error(message));
|
|
3625
|
+
}
|
|
3626
|
+
}
|
|
3627
|
+
#rejectPendingFileDecisions(message) {
|
|
3628
|
+
for (const [transferId, pending] of this.#pendingFileDecisions) {
|
|
3629
|
+
clearTimeout(pending.timer);
|
|
3630
|
+
this.#pendingFileDecisions.delete(transferId);
|
|
3631
|
+
pending.reject(new Error(message));
|
|
3632
|
+
}
|
|
3633
|
+
}
|
|
3634
|
+
#assertActivePeer(peer, generation) {
|
|
3635
|
+
if (this.#closed || generation !== this.#peerGeneration || this.#peer !== peer) {
|
|
3636
|
+
throw new Error("Peer connection superseded");
|
|
3637
|
+
}
|
|
3638
|
+
}
|
|
3639
|
+
async #applyCandidate(peer, generation, candidate) {
|
|
3640
|
+
this.#assertActivePeer(peer, generation);
|
|
3641
|
+
try {
|
|
3642
|
+
await peer.addIceCandidate(candidate);
|
|
3643
|
+
} catch (error) {
|
|
3644
|
+
this.#assertActivePeer(peer, generation);
|
|
3645
|
+
const name = error instanceof Error ? error.name : void 0;
|
|
3646
|
+
if (name !== "OperationError" && name !== "TypeError") {
|
|
3647
|
+
throw new Error("Peer ICE candidate could not be applied");
|
|
3648
|
+
}
|
|
3649
|
+
}
|
|
3650
|
+
this.#assertActivePeer(peer, generation);
|
|
3651
|
+
}
|
|
3652
|
+
async #flushCandidates(peer, generation) {
|
|
3653
|
+
this.#assertActivePeer(peer, generation);
|
|
3654
|
+
for (const candidate of this.#pendingCandidates.splice(0)) {
|
|
3655
|
+
await this.#applyCandidate(peer, generation, candidate);
|
|
3656
|
+
}
|
|
3657
|
+
}
|
|
3658
|
+
#sendSignal(message) {
|
|
3659
|
+
if (this.#socket?.readyState !== WebSocket.OPEN) throw new Error("Signaling is not connected");
|
|
3660
|
+
this.#socket.send(JSON.stringify(message));
|
|
3661
|
+
}
|
|
3662
|
+
#setPeerChannelNonce(peerNonce) {
|
|
3663
|
+
if (sameBytes(this.#peerChannelNonce, peerNonce)) return;
|
|
3664
|
+
this.#peerChannelNonce = peerNonce;
|
|
3665
|
+
this.#frameKey = void 0;
|
|
3666
|
+
this.#frameKeyPromise = void 0;
|
|
3667
|
+
this.#receivedFrameKeys.clear();
|
|
3668
|
+
this.#controlFrameKeys = 0;
|
|
3669
|
+
this.#discardIncomingFiles();
|
|
3670
|
+
}
|
|
3671
|
+
#sendFrame(frame) {
|
|
3672
|
+
if (this.#channel?.readyState !== "open") throw new Error("The peer channel is not ready");
|
|
3673
|
+
this.#channel.send(JSON.stringify(frame));
|
|
3674
|
+
}
|
|
3675
|
+
#sendBinary(frame) {
|
|
3676
|
+
if (this.#channel?.readyState !== "open") throw new Error("The peer channel is not ready");
|
|
3677
|
+
this.#channel.send(frame);
|
|
3678
|
+
}
|
|
3679
|
+
async #waitForWritableChannel(signal, drain = false) {
|
|
3680
|
+
signal?.throwIfAborted();
|
|
3681
|
+
const transportSignal = this.#transportAbort.signal;
|
|
3682
|
+
transportSignal.throwIfAborted();
|
|
3683
|
+
const channel = this.#channel;
|
|
3684
|
+
if (!channel || channel.readyState !== "open") throw new Error("The peer channel is not ready");
|
|
3685
|
+
if (drain ? channel.bufferedAmount === 0 : channel.bufferedAmount < BUFFER_HIGH_WATER) return;
|
|
3686
|
+
await new Promise((resolve, reject) => {
|
|
3687
|
+
const onLow = () => {
|
|
3688
|
+
if (channel.bufferedAmount > (drain ? 0 : channel.bufferedAmountLowThreshold)) return;
|
|
3689
|
+
cleanup();
|
|
3690
|
+
resolve();
|
|
3691
|
+
};
|
|
3692
|
+
const onClose = () => {
|
|
3693
|
+
cleanup();
|
|
3694
|
+
reject(new Error("The transfer channel closed"));
|
|
3695
|
+
};
|
|
3696
|
+
const onAbort = () => {
|
|
3697
|
+
cleanup();
|
|
3698
|
+
reject(signal?.reason ?? new DOMException("Transfer stopped", "AbortError"));
|
|
3699
|
+
};
|
|
3700
|
+
const poll = setInterval(onLow, BUFFER_POLL_MS);
|
|
3701
|
+
const cleanup = () => {
|
|
3702
|
+
clearInterval(poll);
|
|
3703
|
+
signal?.removeEventListener("abort", onAbort);
|
|
3704
|
+
transportSignal.removeEventListener("abort", onClose);
|
|
3705
|
+
channel.removeEventListener("bufferedamountlow", onLow);
|
|
3706
|
+
channel.removeEventListener("close", onClose);
|
|
3707
|
+
};
|
|
3708
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
3709
|
+
transportSignal.addEventListener("abort", onClose, { once: true });
|
|
3710
|
+
channel.addEventListener("bufferedamountlow", onLow);
|
|
3711
|
+
channel.addEventListener("close", onClose, { once: true });
|
|
3712
|
+
});
|
|
3713
|
+
}
|
|
3714
|
+
async #detectPath() {
|
|
3715
|
+
const unknown = this.#forceRelay ? "turn" : "direct";
|
|
3716
|
+
const reports = await this.#peer?.getStats().catch(() => void 0);
|
|
3717
|
+
if (!reports) return unknown;
|
|
3718
|
+
let selectedPairIds;
|
|
3719
|
+
for (const report of reports.values()) {
|
|
3720
|
+
if (report.type === "transport" && report.selectedCandidatePairId) {
|
|
3721
|
+
const pair = reports.get(report.selectedCandidatePairId);
|
|
3722
|
+
if (typeof pair?.localCandidateId === "string" && typeof pair.remoteCandidateId === "string") {
|
|
3723
|
+
selectedPairIds = { local: pair.localCandidateId, remote: pair.remoteCandidateId };
|
|
3724
|
+
break;
|
|
3725
|
+
}
|
|
3726
|
+
}
|
|
3727
|
+
if (report.type === "candidate-pair" && report.state === "succeeded" && (report.nominated || report.selected)) {
|
|
3728
|
+
const pair = report;
|
|
3729
|
+
if (typeof pair.localCandidateId === "string" && typeof pair.remoteCandidateId === "string") {
|
|
3730
|
+
selectedPairIds = { local: pair.localCandidateId, remote: pair.remoteCandidateId };
|
|
3731
|
+
}
|
|
3732
|
+
}
|
|
3733
|
+
}
|
|
3734
|
+
if (selectedPairIds) {
|
|
3735
|
+
const local = reports.get(selectedPairIds.local);
|
|
3736
|
+
const remote = reports.get(selectedPairIds.remote);
|
|
3737
|
+
if (local?.candidateType === "relay" || remote?.candidateType === "relay") return "turn";
|
|
3738
|
+
if (local && remote) return "direct";
|
|
3739
|
+
}
|
|
3740
|
+
return unknown;
|
|
3741
|
+
}
|
|
3742
|
+
#frameContext(direction, frameType) {
|
|
3743
|
+
return { sender: direction === "send" ? this.#role : this.#role === "host" ? "join" : "host", frameType };
|
|
3744
|
+
}
|
|
3745
|
+
async #requiredKey(direction) {
|
|
3746
|
+
if (!this.#peerFrameProtocol) throw new Error("The secure channel session is not established");
|
|
3747
|
+
const sender = direction === "send" ? this.#role : this.#role === "host" ? "join" : "host";
|
|
3748
|
+
if (this.#frameKey) return this.#frameKey[sender];
|
|
3749
|
+
const peerNonce = this.#peerChannelNonce;
|
|
3750
|
+
if (!peerNonce) throw new Error("The secure channel session is not established");
|
|
3751
|
+
const hostNonce = this.#role === "host" ? this.#channelNonce : peerNonce;
|
|
3752
|
+
const joinNonce = this.#role === "host" ? peerNonce : this.#channelNonce;
|
|
3753
|
+
const promise = this.#frameKeyPromise ??= (async () => {
|
|
3754
|
+
if (!this.#options.deviceKeyAgreement) return deriveDirectionalDataChannelKeys(this.#secret, hostNonce, joinNonce, this.#capabilityContext());
|
|
3755
|
+
if (!this.#options.identityPrivateKey || !this.#options.identity || !this.#agreementPeer) throw new Error("Device key agreement is unavailable");
|
|
3756
|
+
const secret = await deviceSessionSecret(this.#options.identityPrivateKey, this.#options.identity, this.#agreementPeer, this.#secret, this.#roomId);
|
|
3757
|
+
return deriveDirectionalDataChannelKeys(secret, hostNonce, joinNonce, this.#capabilityContext());
|
|
3758
|
+
})();
|
|
3759
|
+
const key = await promise;
|
|
3760
|
+
if (this.#frameKeyPromise !== promise || !sameBytes(this.#peerChannelNonce, peerNonce)) {
|
|
3761
|
+
throw new Error("The secure channel session restarted");
|
|
3762
|
+
}
|
|
3763
|
+
this.#frameKey = key;
|
|
3764
|
+
return key[sender];
|
|
3765
|
+
}
|
|
3766
|
+
};
|
|
3767
|
+
function sameBytes(a, b) {
|
|
3768
|
+
if (!a || a.byteLength !== b.byteLength) return false;
|
|
3769
|
+
let mismatch = 0;
|
|
3770
|
+
for (let index = 0; index < b.byteLength; index += 1) mismatch |= a[index] ^ b[index];
|
|
3771
|
+
return mismatch === 0;
|
|
3772
|
+
}
|
|
3773
|
+
function isRtcIceServerList(value) {
|
|
3774
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > 16) return false;
|
|
3775
|
+
return value.every((entry) => {
|
|
3776
|
+
if (!entry || typeof entry !== "object") return false;
|
|
3777
|
+
const candidate = entry;
|
|
3778
|
+
const urls = typeof candidate.urls === "string" ? [candidate.urls] : candidate.urls;
|
|
3779
|
+
return Array.isArray(urls) && urls.length > 0 && urls.every((url) => typeof url === "string" && /^(?:stun|turn|turns):/i.test(url)) && (candidate.username === void 0 || typeof candidate.username === "string") && (candidate.credential === void 0 || typeof candidate.credential === "string");
|
|
3780
|
+
});
|
|
3781
|
+
}
|
|
3782
|
+
function randomId() {
|
|
3783
|
+
return toBase64Url(randomBytes(18));
|
|
3784
|
+
}
|
|
3785
|
+
|
|
3786
|
+
// ../../apps/app/src/identity.ts
|
|
3787
|
+
var PAIRING_CODE_TTL_MS = 10 * 60 * 1e3;
|
|
3788
|
+
var PAIRING_LOCKOUT_MS = 60 * 1e3;
|
|
3789
|
+
|
|
3790
|
+
// ../../apps/app/src/returning-device.ts
|
|
3791
|
+
async function returningDeviceSession(identity, peer) {
|
|
3792
|
+
if (identity.id === peer.id) throw new Error("Choose a different device");
|
|
3793
|
+
const descriptors = [identity, peer].sort((a, b) => a.id < b.id ? -1 : 1);
|
|
3794
|
+
const context = new TextEncoder().encode(JSON.stringify(descriptors.map((d) => [d.id, d.publicKey])));
|
|
3795
|
+
const publicKey = await crypto.subtle.importKey("raw", fromBase64Url(peer.publicKey), { name: "ECDH", namedCurve: "P-256" }, false, []);
|
|
3796
|
+
const shared = await crypto.subtle.deriveBits({ name: "ECDH", public: publicKey }, identity.privateKey, 256);
|
|
3797
|
+
const key = await crypto.subtle.importKey("raw", shared, "HKDF", false, ["deriveBits"]);
|
|
3798
|
+
async function derive(purpose) {
|
|
3799
|
+
return new Uint8Array(await crypto.subtle.deriveBits({
|
|
3800
|
+
name: "HKDF",
|
|
3801
|
+
hash: "SHA-256",
|
|
3802
|
+
salt: context,
|
|
3803
|
+
info: new TextEncoder().encode(`paironce-return-v1:${purpose}`)
|
|
3804
|
+
}, key, 256));
|
|
3805
|
+
}
|
|
3806
|
+
const room = await derive("rendezvous");
|
|
3807
|
+
const secret = await derive("session-secret");
|
|
3808
|
+
const codeBytes = await derive("confirmation");
|
|
3809
|
+
const presenceToken = toBase64Url(await derive("presence"));
|
|
3810
|
+
const presenceRoom = toBase64Url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(presenceToken))));
|
|
3811
|
+
const code = String(new DataView(codeBytes.buffer).getUint32(0) % 1e6).padStart(6, "0");
|
|
3812
|
+
const role = identity.id < peer.id ? "host" : "join";
|
|
3813
|
+
const host = role === "host" ? identity : peer;
|
|
3814
|
+
return {
|
|
3815
|
+
roomId: toBase64Url(room),
|
|
3816
|
+
secret,
|
|
3817
|
+
role,
|
|
3818
|
+
presenceToken,
|
|
3819
|
+
presenceRoom,
|
|
3820
|
+
hostIdentity: { id: host.id, name: host.name, publicKey: host.publicKey },
|
|
3821
|
+
offer: { code, expiresAt: Date.now() + PAIRING_CODE_TTL_MS, host: { id: host.id, name: host.name, publicKey: host.publicKey } },
|
|
3822
|
+
peer
|
|
3823
|
+
};
|
|
3824
|
+
}
|
|
3825
|
+
async function returningPresence(identity, peer, requestConnection, signal) {
|
|
3826
|
+
const target2 = await returningDeviceSession(identity, peer);
|
|
3827
|
+
const endpoint = new URL(`/v1/presence/${target2.presenceRoom}`, "https://paironce.com");
|
|
3828
|
+
endpoint.protocol = endpoint.protocol === "ws:" ? "http:" : endpoint.protocol === "wss:" ? "https:" : endpoint.protocol;
|
|
3829
|
+
const response = await fetch(endpoint, {
|
|
3830
|
+
method: "POST",
|
|
3831
|
+
headers: { "Content-Type": "application/json", "X-PairOnce-Presence": target2.presenceToken },
|
|
3832
|
+
body: JSON.stringify({ deviceId: identity.id, peerId: peer.id, requestConnection }),
|
|
3833
|
+
signal: signal ? anySignal([signal, timeoutSignal(1e4)]) : timeoutSignal(1e4)
|
|
3834
|
+
});
|
|
3835
|
+
if (!response.ok) throw new Error("presence_unavailable");
|
|
3836
|
+
return response.json();
|
|
3837
|
+
}
|
|
3838
|
+
|
|
3839
|
+
// src/store.ts
|
|
3840
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3841
|
+
import { homedir, platform } from "node:os";
|
|
3842
|
+
import path from "node:path";
|
|
3843
|
+
var home = () => process.env.PAIRONCE_HOME || path.join(homedir(), ".paironce");
|
|
3844
|
+
var identityFile = () => path.join(home(), "identity.json");
|
|
3845
|
+
var devicesFile = () => path.join(home(), "devices.json");
|
|
3846
|
+
function terminalName(system = platform()) {
|
|
3847
|
+
const name = system === "win32" ? "Windows" : system === "darwin" ? "macOS" : system === "linux" ? "Linux" : "Computer";
|
|
3848
|
+
return `${name} \xB7 Terminal`;
|
|
3849
|
+
}
|
|
3850
|
+
async function writePrivate(file, value) {
|
|
3851
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 448 });
|
|
3852
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
3853
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}
|
|
3854
|
+
`, { mode: 384 });
|
|
3855
|
+
await rename(temporary, file);
|
|
3856
|
+
}
|
|
3857
|
+
async function readJson(file) {
|
|
3858
|
+
try {
|
|
3859
|
+
return JSON.parse(await readFile(file, "utf8"));
|
|
3860
|
+
} catch (error) {
|
|
3861
|
+
if (error.code === "ENOENT") return void 0;
|
|
3862
|
+
throw error;
|
|
3863
|
+
}
|
|
3864
|
+
}
|
|
3865
|
+
async function usable(stored) {
|
|
3866
|
+
const privateKey = await crypto.subtle.importKey("jwk", stored.privateKey, { name: "ECDH", namedCurve: "P-256" }, false, ["deriveBits"]);
|
|
3867
|
+
return { id: stored.id, name: stored.name, publicKey: stored.publicKey, privateKey, createdAt: stored.createdAt };
|
|
3868
|
+
}
|
|
3869
|
+
async function loadOrCreateIdentity() {
|
|
3870
|
+
const existing = await readJson(identityFile());
|
|
3871
|
+
if (existing) return usable(existing);
|
|
3872
|
+
const pair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
|
|
3873
|
+
const stored = {
|
|
3874
|
+
id: toBase64Url(randomBytes(12)),
|
|
3875
|
+
name: terminalName(),
|
|
3876
|
+
publicKey: toBase64Url(new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey))),
|
|
3877
|
+
privateKey: await crypto.subtle.exportKey("jwk", pair.privateKey),
|
|
3878
|
+
createdAt: Date.now()
|
|
3879
|
+
};
|
|
3880
|
+
await writePrivate(identityFile(), stored);
|
|
3881
|
+
return usable(stored);
|
|
3882
|
+
}
|
|
3883
|
+
async function listDevices() {
|
|
3884
|
+
return (await readJson(devicesFile()) ?? []).sort((a, b) => b.lastSeenAt - a.lastSeenAt);
|
|
3885
|
+
}
|
|
3886
|
+
async function saveDevice(device, now = Date.now()) {
|
|
3887
|
+
const devices = await listDevices();
|
|
3888
|
+
const known = devices.find((item) => item.id === device.id);
|
|
3889
|
+
if (known && known.publicKey !== device.publicKey) throw new Error("A saved device with this ID has a different key. Remove it first.");
|
|
3890
|
+
const saved = { ...known, id: device.id, name: device.name, publicKey: device.publicKey, pairedAt: known?.pairedAt ?? now, lastSeenAt: now };
|
|
3891
|
+
await writePrivate(devicesFile(), [saved, ...devices.filter((item) => item.id !== device.id)]);
|
|
3892
|
+
return saved;
|
|
3893
|
+
}
|
|
3894
|
+
async function forgetDevice(id) {
|
|
3895
|
+
const devices = await listDevices();
|
|
3896
|
+
const rest = devices.filter((item) => item.id !== id);
|
|
3897
|
+
if (rest.length === devices.length) return false;
|
|
3898
|
+
await writePrivate(devicesFile(), rest);
|
|
3899
|
+
return true;
|
|
3900
|
+
}
|
|
3901
|
+
function pickDevice(devices, query) {
|
|
3902
|
+
if (!query) return devices.length === 1 ? devices[0] : devices.length ? "ambiguous" : "none";
|
|
3903
|
+
const wanted = query.toLowerCase();
|
|
3904
|
+
const exact = devices.filter((device) => device.name.toLowerCase() === wanted || device.id === query);
|
|
3905
|
+
if (exact.length === 1) return exact[0];
|
|
3906
|
+
const matches = devices.filter((device) => device.name.toLowerCase().includes(wanted));
|
|
3907
|
+
return matches.length === 1 ? matches[0] : matches.length ? "ambiguous" : "none";
|
|
3908
|
+
}
|
|
3909
|
+
|
|
3910
|
+
// src/connection.ts
|
|
3911
|
+
var httpOrigin = () => {
|
|
3912
|
+
const url = new URL(signalingOrigin());
|
|
3913
|
+
url.protocol = url.protocol === "ws:" ? "http:" : url.protocol === "wss:" ? "https:" : url.protocol;
|
|
3914
|
+
return url;
|
|
3915
|
+
};
|
|
3916
|
+
async function lookupCode(code, identity) {
|
|
3917
|
+
const response = await fetch(new URL("/v1/connection-code/lookup", httpOrigin()), {
|
|
3918
|
+
method: "POST",
|
|
3919
|
+
headers: { "Content-Type": "application/json" },
|
|
3920
|
+
body: JSON.stringify({ code, device: identity.id }),
|
|
3921
|
+
signal: AbortSignal.timeout(1e4)
|
|
3922
|
+
});
|
|
3923
|
+
if (response.status === 429) throw new Error("Too many requests. Wait a moment, then try again.");
|
|
3924
|
+
if (!response.ok) throw new Error("Connection code expired or unavailable.");
|
|
3925
|
+
const offer = await response.json();
|
|
3926
|
+
const secret = fromBase64Url(offer.nonce);
|
|
3927
|
+
if (!/^[A-Za-z0-9_-]{20,128}$/.test(offer.roomId) || secret.byteLength !== 32 || offer.expiresAt <= Date.now() || !offer.host || !/^[A-Za-z0-9_-]{16,128}$/.test(offer.host.id) || !/^[A-Za-z0-9_-]{87}$/.test(offer.host.publicKey)) {
|
|
3928
|
+
throw new Error("Connection code expired or unavailable.");
|
|
3929
|
+
}
|
|
3930
|
+
if (offer.host.id === identity.id) throw new Error("That code is shown by this same device. Type it on your other device.");
|
|
3931
|
+
if (offer.room) throw new Error("That is a distribution room's code. Open it in a browser to join the room.");
|
|
3932
|
+
return { roomId: offer.roomId, secret, host: offer.host };
|
|
3933
|
+
}
|
|
3934
|
+
async function connect(identity, target2, ui, options = {}) {
|
|
3935
|
+
const events = {};
|
|
3936
|
+
let room;
|
|
3937
|
+
const returning = target2.kind === "saved" ? target2.device : void 0;
|
|
3938
|
+
if (target2.kind === "host") {
|
|
3939
|
+
room = { roomId: randomId(), secret: generateSessionSecret(), role: "host", expiresAt: Date.now() + PAIRING_CODE_TTL_MS };
|
|
3940
|
+
} else if (target2.kind === "join") {
|
|
3941
|
+
const found = await lookupCode(target2.code, identity);
|
|
3942
|
+
room = { roomId: found.roomId, secret: found.secret, role: "join", expected: found.host, expiresAt: Date.now() + PAIRING_CODE_TTL_MS };
|
|
3943
|
+
} else {
|
|
3944
|
+
const next = await returningDeviceSession(identity, target2.device);
|
|
3945
|
+
void returningPresence(identity, target2.device, true, AbortSignal.timeout(1e4)).catch(() => {
|
|
3946
|
+
});
|
|
3947
|
+
room = { roomId: next.roomId, secret: next.secret, role: next.role, expected: target2.device, code: next.offer.code, expiresAt: next.offer.expiresAt };
|
|
3948
|
+
}
|
|
3949
|
+
return new Promise((resolve, reject) => {
|
|
3950
|
+
let peer;
|
|
3951
|
+
let saving = Promise.resolve();
|
|
3952
|
+
let settled = false;
|
|
3953
|
+
const fail = (error) => {
|
|
3954
|
+
if (settled) {
|
|
3955
|
+
events.error?.(error instanceof Error ? error.message : String(error));
|
|
3956
|
+
return;
|
|
3957
|
+
}
|
|
3958
|
+
settled = true;
|
|
3959
|
+
session?.close();
|
|
3960
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
3961
|
+
};
|
|
3962
|
+
options.signal?.addEventListener("abort", () => fail(new Error(returning ? `${returning.name} did not connect. Open https://paironce.com there, tap Connect where it says "${identity.name} wants to reconnect", then try again.` : "Nobody connected in time. Try again.")), { once: true });
|
|
3963
|
+
const callbacks = {
|
|
3964
|
+
onStatus: (text) => {
|
|
3965
|
+
if (text === "Transfer channel closed") {
|
|
3966
|
+
if (settled) events.closed?.();
|
|
3967
|
+
else fail(new Error("The connection ended."));
|
|
3968
|
+
return;
|
|
3969
|
+
}
|
|
3970
|
+
ui.status?.(text);
|
|
3971
|
+
},
|
|
3972
|
+
onConnectionCode: (code) => ui.code?.(code),
|
|
3973
|
+
// A saved device has nothing to compare: its code comes from both devices' keys and is checked by the session.
|
|
3974
|
+
onPairingSas: (code) => {
|
|
3975
|
+
if (!returning) ui.safetyCode?.(code);
|
|
3976
|
+
},
|
|
3977
|
+
onPairingRequired: () => {
|
|
3978
|
+
if (room.role !== "join") return;
|
|
3979
|
+
void (async () => {
|
|
3980
|
+
const code = room.code ?? await session.getPairingSas();
|
|
3981
|
+
if (!room.code) ui.safetyCode?.(code);
|
|
3982
|
+
await session.submitPairingCode(code);
|
|
3983
|
+
})().catch(fail);
|
|
3984
|
+
},
|
|
3985
|
+
onPairingApprovalRequired: (candidate) => {
|
|
3986
|
+
if (returning && candidate.id === returning.id && candidate.publicKey === returning.publicKey) {
|
|
3987
|
+
void session.approvePairing().catch(fail);
|
|
3988
|
+
return;
|
|
3989
|
+
}
|
|
3990
|
+
if (!ui.approve) {
|
|
3991
|
+
void session.rejectPairing().catch(() => {
|
|
3992
|
+
});
|
|
3993
|
+
fail(new Error("A new device can only be approved in the terminal."));
|
|
3994
|
+
return;
|
|
3995
|
+
}
|
|
3996
|
+
void ui.approve(candidate).then((yes) => yes ? session.approvePairing() : session.rejectPairing()).catch(fail);
|
|
3997
|
+
},
|
|
3998
|
+
onPairingConfirmed: (confirmed) => {
|
|
3999
|
+
peer = { ...confirmed, pairedAt: Date.now(), lastSeenAt: Date.now() };
|
|
4000
|
+
saving = saveDevice(confirmed).then((saved) => {
|
|
4001
|
+
peer = saved;
|
|
4002
|
+
}, (error) => {
|
|
4003
|
+
ui.status?.(error instanceof Error ? error.message : String(error));
|
|
4004
|
+
});
|
|
4005
|
+
},
|
|
4006
|
+
onPairingRejected: (reason, terminal) => {
|
|
4007
|
+
if (terminal) fail(new Error(reason));
|
|
4008
|
+
else ui.status?.(reason);
|
|
4009
|
+
},
|
|
4010
|
+
onReady: (path6) => {
|
|
4011
|
+
if (settled) return;
|
|
4012
|
+
settled = true;
|
|
4013
|
+
void saving.then(() => resolve({ session, peer: peer ?? { ...room.expected, pairedAt: Date.now(), lastSeenAt: Date.now() }, path: path6, events, close: () => session.close() }));
|
|
4014
|
+
},
|
|
4015
|
+
onText: (text) => events.text?.(text),
|
|
4016
|
+
onFile: (file) => events.file?.(file),
|
|
4017
|
+
onFileOffer: (offer) => events.offer?.(offer),
|
|
4018
|
+
onFileReceipt: (receipt) => events.receipt?.(receipt),
|
|
4019
|
+
onProgress: (label, fraction, direction) => events.progress?.(label, fraction, direction),
|
|
4020
|
+
onError: (message) => fail(new Error(message)),
|
|
4021
|
+
onConnectionCodeUnavailable: (reason) => fail(new Error(reason === "limited" ? "Too many requests. Wait a moment, then try again." : "No code is available right now. Try again."))
|
|
4022
|
+
};
|
|
4023
|
+
let session;
|
|
4024
|
+
try {
|
|
4025
|
+
session = new PeerSession(room.roomId, room.role, room.secret, callbacks, {
|
|
4026
|
+
identity: { id: identity.id, name: identity.name, publicKey: identity.publicKey },
|
|
4027
|
+
identityPrivateKey: identity.privateKey,
|
|
4028
|
+
requirePairing: true,
|
|
4029
|
+
// As the web app: new pairings (host, join) agree keys from the devices (v2); a saved device's room already comes
|
|
4030
|
+
// from both devices' keys, and its host checks the code both derived instead (returning-device.ts).
|
|
4031
|
+
deviceKeyAgreement: target2.kind !== "saved",
|
|
4032
|
+
fileApproval: true,
|
|
4033
|
+
publishConnectionCode: target2.kind === "host",
|
|
4034
|
+
pairingExpiresAt: room.expiresAt,
|
|
4035
|
+
...target2.kind === "saved" && room.role === "host" && room.code ? { pairingCode: room.code } : {},
|
|
4036
|
+
isPro: () => false,
|
|
4037
|
+
...room.expected ? { expectedPeerIdentity: room.expected } : {},
|
|
4038
|
+
...options.diskReceive ? { diskReceive: options.diskReceive } : {}
|
|
4039
|
+
});
|
|
4040
|
+
} catch (error) {
|
|
4041
|
+
fail(error);
|
|
4042
|
+
return;
|
|
4043
|
+
}
|
|
4044
|
+
session.connect().catch(fail);
|
|
4045
|
+
});
|
|
4046
|
+
}
|
|
4047
|
+
|
|
4048
|
+
// src/disk.ts
|
|
4049
|
+
import { createWriteStream, openAsBlob } from "node:fs";
|
|
4050
|
+
import { mkdir as mkdir2, rm, stat, statfs, truncate } from "node:fs/promises";
|
|
4051
|
+
import path2 from "node:path";
|
|
4052
|
+
|
|
4053
|
+
// ../../apps/app/src/realtime/disk-receive.ts
|
|
4054
|
+
var DISK_THRESHOLD_BYTES = 16e6;
|
|
4055
|
+
var RESERVE_BYTES = 512 * 1024 * 1024;
|
|
4056
|
+
var WRITE_BYTES = 1024 * 1024;
|
|
4057
|
+
var ABANDONED_MS = 24 * 60 * 60 * 1e3;
|
|
4058
|
+
var PARTIAL_KEEP_MS = 30 * 60 * 1e3;
|
|
4059
|
+
var MAX_PARTIALS = 3;
|
|
4060
|
+
|
|
4061
|
+
// src/disk.ts
|
|
4062
|
+
var RESERVE_BYTES2 = 512 * 1024 * 1024;
|
|
4063
|
+
var partPath = (folder, entry) => path2.join(folder, `.paironce-${entry.replace(/[^A-Za-z0-9_-]/g, "_")}.part`);
|
|
4064
|
+
function sinkFor(file, stream, name, mime) {
|
|
4065
|
+
const closed = () => new Promise((resolve, reject) => stream.end((error) => error ? reject(error) : resolve()));
|
|
4066
|
+
return {
|
|
4067
|
+
write: (chunk) => new Promise((resolve, reject) => stream.write(chunk, (error) => error ? reject(error) : resolve())),
|
|
4068
|
+
async finish() {
|
|
4069
|
+
await closed();
|
|
4070
|
+
return new File([await openAsBlob(file, { type: mime })], name, { type: mime, lastModified: Date.now() });
|
|
4071
|
+
},
|
|
4072
|
+
async abort() {
|
|
4073
|
+
stream.destroy();
|
|
4074
|
+
await rm(file, { force: true });
|
|
4075
|
+
},
|
|
4076
|
+
park: closed
|
|
4077
|
+
};
|
|
4078
|
+
}
|
|
4079
|
+
function createDiskReceiver(folder) {
|
|
4080
|
+
const kept = [];
|
|
4081
|
+
const drop = async (index) => {
|
|
4082
|
+
const [gone] = kept.splice(index, 1);
|
|
4083
|
+
if (gone) await rm(partPath(folder, gone.partial.entry), { force: true });
|
|
4084
|
+
};
|
|
4085
|
+
return {
|
|
4086
|
+
threshold: DISK_THRESHOLD_BYTES,
|
|
4087
|
+
async available() {
|
|
4088
|
+
await mkdir2(folder, { recursive: true });
|
|
4089
|
+
const disk = await statfs(folder);
|
|
4090
|
+
return Math.max(0, disk.bavail * disk.bsize - RESERVE_BYTES2);
|
|
4091
|
+
},
|
|
4092
|
+
async open(entry, name, mime, offset = 0) {
|
|
4093
|
+
await mkdir2(folder, { recursive: true });
|
|
4094
|
+
const file = partPath(folder, entry);
|
|
4095
|
+
if (offset > 0) {
|
|
4096
|
+
if ((await stat(file)).size < offset) throw new Error("disk_partial_short");
|
|
4097
|
+
await truncate(file, offset);
|
|
4098
|
+
}
|
|
4099
|
+
return sinkFor(file, createWriteStream(file, { flags: offset > 0 ? "a" : "w" }), name, mime);
|
|
4100
|
+
},
|
|
4101
|
+
async remove(entry) {
|
|
4102
|
+
await rm(partPath(folder, entry), { force: true });
|
|
4103
|
+
},
|
|
4104
|
+
async keepPartial(partial, sink) {
|
|
4105
|
+
try {
|
|
4106
|
+
await sink.park();
|
|
4107
|
+
} catch {
|
|
4108
|
+
await sink.abort();
|
|
4109
|
+
return;
|
|
4110
|
+
}
|
|
4111
|
+
for (let index = kept.length - 1; index >= 0; index--) if (Date.now() - kept[index].at > PARTIAL_KEEP_MS) await drop(index);
|
|
4112
|
+
const same = kept.findIndex((item) => item.partial.peerId === partial.peerId && item.partial.resumeKey === partial.resumeKey);
|
|
4113
|
+
if (same >= 0) kept.splice(same, 1);
|
|
4114
|
+
kept.push({ partial, at: Date.now() });
|
|
4115
|
+
while (kept.length > MAX_PARTIALS) await drop(0);
|
|
4116
|
+
},
|
|
4117
|
+
findPartial: (peerId, resumeKey) => kept.find((item) => item.partial.peerId === peerId && item.partial.resumeKey === resumeKey && Date.now() - item.at <= PARTIAL_KEEP_MS)?.partial,
|
|
4118
|
+
takePartial(partial) {
|
|
4119
|
+
const index = kept.findIndex((item) => item.partial === partial);
|
|
4120
|
+
if (index >= 0) kept.splice(index, 1);
|
|
4121
|
+
},
|
|
4122
|
+
async discardPartial(partial) {
|
|
4123
|
+
const index = kept.findIndex((item) => item.partial === partial);
|
|
4124
|
+
if (index >= 0) await drop(index);
|
|
4125
|
+
else await rm(partPath(folder, partial.entry), { force: true });
|
|
4126
|
+
}
|
|
4127
|
+
};
|
|
4128
|
+
}
|
|
4129
|
+
|
|
4130
|
+
// src/mcp.ts
|
|
4131
|
+
import { createInterface } from "node:readline";
|
|
4132
|
+
import path4 from "node:path";
|
|
4133
|
+
|
|
4134
|
+
// src/send.ts
|
|
4135
|
+
import { openAsBlob as openAsBlob2 } from "node:fs";
|
|
4136
|
+
import { stat as stat2 } from "node:fs/promises";
|
|
4137
|
+
import path3 from "node:path";
|
|
4138
|
+
var say = (text) => {
|
|
4139
|
+
process.stderr.write(`${text}
|
|
4140
|
+
`);
|
|
4141
|
+
};
|
|
4142
|
+
var human = (bytes) => bytes >= 1e9 ? `${(bytes / 1e9).toFixed(2)} GB` : bytes >= 1e6 ? `${(bytes / 1e6).toFixed(1)} MB` : `${Math.ceil(bytes / 1e3)} kB`;
|
|
4143
|
+
async function sendPath(link, file, report = say) {
|
|
4144
|
+
const info = await stat2(file);
|
|
4145
|
+
if (!info.isFile()) throw new Error(`${file} is not a file. Send the files inside a folder with: paironce send ${path3.join(file, "*")}`);
|
|
4146
|
+
const name = path3.basename(file);
|
|
4147
|
+
const blob = await openAsBlob2(file);
|
|
4148
|
+
const received = new Promise((resolve) => {
|
|
4149
|
+
const before = link.events.receipt;
|
|
4150
|
+
link.events.receipt = (receipt) => {
|
|
4151
|
+
before?.(receipt);
|
|
4152
|
+
if (receipt.stage === "received") resolve();
|
|
4153
|
+
};
|
|
4154
|
+
});
|
|
4155
|
+
let shown = -1;
|
|
4156
|
+
link.events.progress = (label, fraction, direction) => {
|
|
4157
|
+
const percent = Math.floor(fraction * 100);
|
|
4158
|
+
if (direction === "sent" && percent >= shown + 10) {
|
|
4159
|
+
shown = percent;
|
|
4160
|
+
report(` ${label}: ${percent}%`);
|
|
4161
|
+
}
|
|
4162
|
+
};
|
|
4163
|
+
report(`Sending ${name} (${human(info.size)}). Waiting for ${link.peer.name} to accept\u2026`);
|
|
4164
|
+
await link.session.sendFile(new File([blob], name, { lastModified: info.mtimeMs }));
|
|
4165
|
+
await Promise.race([received, new Promise((resolve) => setTimeout(resolve, 3e4))]);
|
|
4166
|
+
report(`Sent ${name}.`);
|
|
4167
|
+
}
|
|
4168
|
+
|
|
4169
|
+
// src/version.ts
|
|
4170
|
+
var CLI_VERSION = "0.1.1";
|
|
4171
|
+
|
|
4172
|
+
// src/mcp.ts
|
|
4173
|
+
var log = (text) => process.stderr.write(`${text}
|
|
4174
|
+
`);
|
|
4175
|
+
var target = { type: "string", description: "Name of a paired device (from list_devices). Optional when only one is paired." };
|
|
4176
|
+
var TOOLS = [
|
|
4177
|
+
{ name: "list_devices", description: "List the devices this computer is paired with through PairOnce.", inputSchema: { type: "object", properties: {} } },
|
|
4178
|
+
{
|
|
4179
|
+
name: "send_file",
|
|
4180
|
+
description: "Send files from this computer to one of the user's paired devices (phone or computer) through PairOnce, end-to-end encrypted. The user accepts each file on that device, and PairOnce must be open there.",
|
|
4181
|
+
inputSchema: { type: "object", properties: { paths: { type: "array", items: { type: "string" }, description: "Files to send (absolute paths, or relative to the server's folder)." }, device: target }, required: ["paths"] }
|
|
4182
|
+
},
|
|
4183
|
+
{
|
|
4184
|
+
name: "send_text",
|
|
4185
|
+
description: "Send a message or a link to one of the user's paired devices through PairOnce, end-to-end encrypted. PairOnce must be open there.",
|
|
4186
|
+
inputSchema: { type: "object", properties: { text: { type: "string" }, device: target }, required: ["text"] }
|
|
4187
|
+
}
|
|
4188
|
+
];
|
|
4189
|
+
async function openSaved(device) {
|
|
4190
|
+
const identity = await loadOrCreateIdentity();
|
|
4191
|
+
const devices = await listDevices();
|
|
4192
|
+
const picked = pickDevice(devices, device);
|
|
4193
|
+
if (picked === "none") throw new Error(devices.length ? `No paired device is called "${device}".` : 'No device is paired yet. Run "npx paironce pair" in a terminal first.');
|
|
4194
|
+
if (picked === "ambiguous") throw new Error(`Name the device: ${devices.map((item) => item.name).join(", ")}.`);
|
|
4195
|
+
log(`Connecting to ${picked.name}; open https://paironce.com there.`);
|
|
4196
|
+
return connect(identity, { kind: "saved", device: picked }, { status: log }, { signal: AbortSignal.timeout(12e4) });
|
|
4197
|
+
}
|
|
4198
|
+
async function callTool(name, args) {
|
|
4199
|
+
if (name === "list_devices") {
|
|
4200
|
+
const devices = await listDevices();
|
|
4201
|
+
return devices.length ? devices.map((device2) => `${device2.name} (last seen ${new Date(device2.lastSeenAt).toISOString()})`).join("\n") : 'No paired devices. Run "npx paironce pair" in a terminal.';
|
|
4202
|
+
}
|
|
4203
|
+
const device = typeof args.device === "string" && args.device ? args.device : void 0;
|
|
4204
|
+
if (name === "send_text") {
|
|
4205
|
+
if (typeof args.text !== "string" || !args.text) throw new Error("text is required.");
|
|
4206
|
+
const link = await openSaved(device);
|
|
4207
|
+
try {
|
|
4208
|
+
await link.session.sendText(args.text);
|
|
4209
|
+
return `Sent to ${link.peer.name}.`;
|
|
4210
|
+
} finally {
|
|
4211
|
+
link.close();
|
|
4212
|
+
}
|
|
4213
|
+
}
|
|
4214
|
+
if (name === "send_file") {
|
|
4215
|
+
const paths = Array.isArray(args.paths) ? args.paths.filter((item) => typeof item === "string") : [];
|
|
4216
|
+
if (!paths.length) throw new Error("paths is required.");
|
|
4217
|
+
const link = await openSaved(device);
|
|
4218
|
+
try {
|
|
4219
|
+
for (const file of paths) await sendPath(link, path4.resolve(file), log);
|
|
4220
|
+
return `Sent ${paths.length === 1 ? path4.basename(paths[0]) : `${paths.length} files`} to ${link.peer.name}.`;
|
|
4221
|
+
} finally {
|
|
4222
|
+
link.close();
|
|
4223
|
+
}
|
|
4224
|
+
}
|
|
4225
|
+
throw new Error(`Unknown tool ${name}.`);
|
|
4226
|
+
}
|
|
4227
|
+
async function answer(request) {
|
|
4228
|
+
const reply = (result) => ({ jsonrpc: "2.0", id: request.id, result });
|
|
4229
|
+
if (request.id === void 0) return void 0;
|
|
4230
|
+
switch (request.method) {
|
|
4231
|
+
case "initialize":
|
|
4232
|
+
return reply({
|
|
4233
|
+
protocolVersion: typeof request.params?.protocolVersion === "string" ? request.params.protocolVersion : "2025-06-18",
|
|
4234
|
+
capabilities: { tools: {} },
|
|
4235
|
+
serverInfo: { name: "paironce", version: CLI_VERSION }
|
|
4236
|
+
});
|
|
4237
|
+
case "ping":
|
|
4238
|
+
return reply({});
|
|
4239
|
+
case "tools/list":
|
|
4240
|
+
return reply({ tools: TOOLS });
|
|
4241
|
+
case "tools/call": {
|
|
4242
|
+
const params = request.params ?? {};
|
|
4243
|
+
try {
|
|
4244
|
+
const text = await callTool(String(params.name), params.arguments ?? {});
|
|
4245
|
+
return reply({ content: [{ type: "text", text }] });
|
|
4246
|
+
} catch (error) {
|
|
4247
|
+
return reply({ content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }], isError: true });
|
|
4248
|
+
}
|
|
4249
|
+
}
|
|
4250
|
+
default:
|
|
4251
|
+
return { jsonrpc: "2.0", id: request.id, error: { code: -32601, message: `Method not found: ${request.method}` } };
|
|
4252
|
+
}
|
|
4253
|
+
}
|
|
4254
|
+
function runMcp() {
|
|
4255
|
+
const lines = createInterface({ input: process.stdin });
|
|
4256
|
+
let queue = Promise.resolve();
|
|
4257
|
+
lines.on("line", (line) => {
|
|
4258
|
+
if (!line.trim()) return;
|
|
4259
|
+
let request;
|
|
4260
|
+
try {
|
|
4261
|
+
request = JSON.parse(line);
|
|
4262
|
+
} catch {
|
|
4263
|
+
process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } })}
|
|
4264
|
+
`);
|
|
4265
|
+
return;
|
|
4266
|
+
}
|
|
4267
|
+
queue = queue.then(async () => {
|
|
4268
|
+
const response = await answer(request);
|
|
4269
|
+
if (response) process.stdout.write(`${JSON.stringify(response)}
|
|
4270
|
+
`);
|
|
4271
|
+
});
|
|
4272
|
+
});
|
|
4273
|
+
return new Promise((resolve) => lines.on("close", () => {
|
|
4274
|
+
void queue.then(() => resolve(0));
|
|
4275
|
+
}));
|
|
4276
|
+
}
|
|
4277
|
+
|
|
4278
|
+
// src/cli.ts
|
|
4279
|
+
function parse(argv) {
|
|
4280
|
+
const flags = {};
|
|
4281
|
+
const rest = [];
|
|
4282
|
+
for (let index = 0; index < argv.length; index++) {
|
|
4283
|
+
const arg = argv[index];
|
|
4284
|
+
if (arg === "--") {
|
|
4285
|
+
rest.push(...argv.slice(index + 1));
|
|
4286
|
+
break;
|
|
4287
|
+
}
|
|
4288
|
+
const match = /^--([a-z-]+)(?:=(.*))?$/.exec(arg);
|
|
4289
|
+
if (!match) {
|
|
4290
|
+
rest.push(arg);
|
|
4291
|
+
continue;
|
|
4292
|
+
}
|
|
4293
|
+
if (match[2] !== void 0) flags[match[1]] = match[2];
|
|
4294
|
+
else if (["to", "from"].includes(match[1]) && argv[index + 1] && !argv[index + 1].startsWith("--")) flags[match[1]] = argv[++index];
|
|
4295
|
+
else flags[match[1]] = true;
|
|
4296
|
+
}
|
|
4297
|
+
return { flags, rest };
|
|
4298
|
+
}
|
|
4299
|
+
async function ask(question) {
|
|
4300
|
+
if (!process.stdin.isTTY) return false;
|
|
4301
|
+
const line = createInterface2({ input: process.stdin, output: process.stderr });
|
|
4302
|
+
try {
|
|
4303
|
+
return /^y(es)?$/i.test((await line.question(`${question} [y/N] `)).trim());
|
|
4304
|
+
} finally {
|
|
4305
|
+
line.close();
|
|
4306
|
+
}
|
|
4307
|
+
}
|
|
4308
|
+
var groups = (code) => (code.length > 6 ? code.match(/.{1,4}/g) ?? [code] : [code.slice(0, 3), code.slice(3)]).join(" ");
|
|
4309
|
+
var terminalUi = {
|
|
4310
|
+
code: (code) => say(`
|
|
4311
|
+
On your other device, open https://paironce.com, choose "Enter a code" and type:
|
|
4312
|
+
|
|
4313
|
+
${code.slice(0, 3)} ${code.slice(3)}
|
|
4314
|
+
`),
|
|
4315
|
+
safetyCode: (code) => say(`Safety code: ${groups(code)} (the other screen must show the same)`),
|
|
4316
|
+
approve: (peer) => ask(`Connect "${peer.name}"? Check that both screens show the same safety code.`)
|
|
4317
|
+
};
|
|
4318
|
+
async function open(identity, name, folder) {
|
|
4319
|
+
const devices = await listDevices();
|
|
4320
|
+
const picked = pickDevice(devices, name);
|
|
4321
|
+
if (picked === "ambiguous") throw new Error(`Which device? Use --to with one of: ${devices.map((device) => `"${device.name}"`).join(", ")}`);
|
|
4322
|
+
if (picked === "none" && name) throw new Error(`No paired device is called "${name}". Run "paironce devices" to see them.`);
|
|
4323
|
+
const target2 = picked === "none" ? { kind: "host" } : { kind: "saved", device: picked };
|
|
4324
|
+
if (target2.kind === "saved") say(`Connecting to ${target2.device.name}\u2026 Open https://paironce.com there: it shows "${identity.name} wants to reconnect"; tap Connect.`);
|
|
4325
|
+
const diskReceive = folder ? createDiskReceiver(folder) : void 0;
|
|
4326
|
+
const link = await connect(identity, target2, terminalUi, { diskReceive, signal: AbortSignal.timeout(target2.kind === "saved" ? 18e4 : 6e5) });
|
|
4327
|
+
say(`Connected to ${link.peer.name} (${link.path === "turn" ? "through the encrypted relay" : "direct"}).`);
|
|
4328
|
+
return link;
|
|
4329
|
+
}
|
|
4330
|
+
async function uniquePath(folder, name) {
|
|
4331
|
+
const safe = path5.basename(name).replace(/[\u0000-\u001f<>:"/\\|?*]/g, "_").replace(/^\.+/, "_") || "file";
|
|
4332
|
+
const { name: stem, ext } = path5.parse(safe);
|
|
4333
|
+
for (let index = 0; ; index++) {
|
|
4334
|
+
const candidate = path5.join(folder, index ? `${stem} (${index})${ext}` : safe);
|
|
4335
|
+
try {
|
|
4336
|
+
await stat3(candidate);
|
|
4337
|
+
} catch {
|
|
4338
|
+
return candidate;
|
|
4339
|
+
}
|
|
4340
|
+
}
|
|
4341
|
+
}
|
|
4342
|
+
async function receive(link, folder, acceptAll) {
|
|
4343
|
+
await mkdir3(folder, { recursive: true });
|
|
4344
|
+
say(`Receiving into ${folder}. Press Ctrl+C to stop.`);
|
|
4345
|
+
link.events.offer = (offer) => {
|
|
4346
|
+
void (async () => {
|
|
4347
|
+
const yes = acceptAll || await ask(`${link.peer.name} sends ${offer.name} (${human(offer.size)}). Accept?`);
|
|
4348
|
+
await (yes ? link.session.acceptFile(offer.transferId) : link.session.rejectFile(offer.transferId));
|
|
4349
|
+
})().catch((error) => say(String(error instanceof Error ? error.message : error)));
|
|
4350
|
+
};
|
|
4351
|
+
link.events.file = (file) => {
|
|
4352
|
+
void (async () => {
|
|
4353
|
+
const target2 = await uniquePath(folder, file.name);
|
|
4354
|
+
if (file.onDisk) await rename2(partPath(folder, file.diskEntry ?? file.transferId), target2);
|
|
4355
|
+
else await writeFile2(target2, new Uint8Array(await (file.blob ?? await (await fetch(file.url)).blob()).arrayBuffer()));
|
|
4356
|
+
URL.revokeObjectURL(file.url);
|
|
4357
|
+
await link.session.confirmFileSaved(file.transferId).catch(() => {
|
|
4358
|
+
});
|
|
4359
|
+
say(`Saved ${target2}`);
|
|
4360
|
+
})().catch((error) => say(`Could not save ${file.name}: ${error instanceof Error ? error.message : error}`));
|
|
4361
|
+
};
|
|
4362
|
+
link.events.text = (text) => process.stdout.write(`${text}
|
|
4363
|
+
`);
|
|
4364
|
+
await new Promise((resolve) => {
|
|
4365
|
+
link.events.closed = resolve;
|
|
4366
|
+
process.once("SIGINT", () => resolve());
|
|
4367
|
+
});
|
|
4368
|
+
}
|
|
4369
|
+
var HELP = `paironce ${CLI_VERSION}: send files and text from this terminal to your own devices (https://paironce.com).
|
|
4370
|
+
|
|
4371
|
+
paironce pair show a code to type on the other device
|
|
4372
|
+
paironce pair 123456 type the code the other device shows
|
|
4373
|
+
paironce send <file>... send files (--to <device name> when several are paired)
|
|
4374
|
+
paironce text "<message>" send a message or a link
|
|
4375
|
+
paironce receive receive into this folder (--to <folder>, --yes accepts without asking)
|
|
4376
|
+
paironce devices list paired devices
|
|
4377
|
+
paironce forget <name> forget a paired device
|
|
4378
|
+
paironce mcp run as an MCP server for AI agents (send_file, send_text, list_devices)
|
|
4379
|
+
|
|
4380
|
+
Files are end-to-end encrypted and go directly between the devices, or through an encrypted relay when a network
|
|
4381
|
+
blocks direct connections. The other device accepts every file before it arrives. This device is kept in ~/.paironce.`;
|
|
4382
|
+
async function main(argv) {
|
|
4383
|
+
const { flags, rest } = parse(argv);
|
|
4384
|
+
const [command, ...args] = rest;
|
|
4385
|
+
if (flags.version) {
|
|
4386
|
+
process.stdout.write(`${CLI_VERSION}
|
|
4387
|
+
`);
|
|
4388
|
+
return 0;
|
|
4389
|
+
}
|
|
4390
|
+
if (!command || flags.help || command === "help") {
|
|
4391
|
+
process.stdout.write(`${HELP}
|
|
4392
|
+
`);
|
|
4393
|
+
return command || flags.help ? 0 : 1;
|
|
4394
|
+
}
|
|
4395
|
+
if (command === "mcp") return runMcp();
|
|
4396
|
+
const identity = await loadOrCreateIdentity();
|
|
4397
|
+
if (command === "devices") {
|
|
4398
|
+
const devices = await listDevices();
|
|
4399
|
+
if (!devices.length) say(`No paired devices yet. Run "paironce pair".`);
|
|
4400
|
+
for (const device of devices) process.stdout.write(`${device.name} last seen ${new Date(device.lastSeenAt).toLocaleString()}
|
|
4401
|
+
`);
|
|
4402
|
+
return 0;
|
|
4403
|
+
}
|
|
4404
|
+
if (command === "forget") {
|
|
4405
|
+
const picked = pickDevice(await listDevices(), args.join(" ") || void 0);
|
|
4406
|
+
if (typeof picked === "string") {
|
|
4407
|
+
say(picked === "none" ? "No such device." : "Several devices match; name it more exactly.");
|
|
4408
|
+
return 1;
|
|
4409
|
+
}
|
|
4410
|
+
await forgetDevice(picked.id);
|
|
4411
|
+
say(`Forgot ${picked.name}. Remove "${identity.name}" on that device too.`);
|
|
4412
|
+
return 0;
|
|
4413
|
+
}
|
|
4414
|
+
if (command === "pair") {
|
|
4415
|
+
const code = args.join("").replace(/\s/g, "");
|
|
4416
|
+
if (code && !/^\d{6}$/.test(code)) {
|
|
4417
|
+
say("The code has six digits.");
|
|
4418
|
+
return 1;
|
|
4419
|
+
}
|
|
4420
|
+
const link = await connect(identity, code ? { kind: "join", code } : { kind: "host" }, terminalUi, { signal: AbortSignal.timeout(6e5) });
|
|
4421
|
+
say(`Paired with ${link.peer.name}. Next time, "paironce send <file>" connects without a code.`);
|
|
4422
|
+
link.close();
|
|
4423
|
+
return 0;
|
|
4424
|
+
}
|
|
4425
|
+
if (command === "send") {
|
|
4426
|
+
if (!args.length) {
|
|
4427
|
+
say("Name the files to send: paironce send report.pdf photo.jpg");
|
|
4428
|
+
return 1;
|
|
4429
|
+
}
|
|
4430
|
+
const link = await open(identity, typeof flags.to === "string" ? flags.to : void 0);
|
|
4431
|
+
try {
|
|
4432
|
+
for (const file of args) await sendPath(link, file);
|
|
4433
|
+
} finally {
|
|
4434
|
+
link.close();
|
|
4435
|
+
}
|
|
4436
|
+
return 0;
|
|
4437
|
+
}
|
|
4438
|
+
if (command === "text") {
|
|
4439
|
+
const text = args.join(" ");
|
|
4440
|
+
if (!text) {
|
|
4441
|
+
say('Write the message: paironce text "https://example.com"');
|
|
4442
|
+
return 1;
|
|
4443
|
+
}
|
|
4444
|
+
const link = await open(identity, typeof flags.to === "string" ? flags.to : void 0);
|
|
4445
|
+
try {
|
|
4446
|
+
await link.session.sendText(text);
|
|
4447
|
+
say(`Sent to ${link.peer.name}.`);
|
|
4448
|
+
} finally {
|
|
4449
|
+
link.close();
|
|
4450
|
+
}
|
|
4451
|
+
return 0;
|
|
4452
|
+
}
|
|
4453
|
+
if (command === "receive") {
|
|
4454
|
+
const folder = path5.resolve(typeof flags.to === "string" ? flags.to : ".");
|
|
4455
|
+
const link = await open(identity, typeof flags.from === "string" ? flags.from : void 0, folder);
|
|
4456
|
+
try {
|
|
4457
|
+
await receive(link, folder, flags.yes === true);
|
|
4458
|
+
} finally {
|
|
4459
|
+
link.close();
|
|
4460
|
+
}
|
|
4461
|
+
return 0;
|
|
4462
|
+
}
|
|
4463
|
+
say(`Unknown command "${command}". Run "paironce help".`);
|
|
4464
|
+
return 1;
|
|
4465
|
+
}
|
|
4466
|
+
main(process.argv.slice(2)).then((code) => process.exit(code), (error) => {
|
|
4467
|
+
say(error instanceof Error ? error.message : String(error));
|
|
4468
|
+
process.exit(1);
|
|
4469
|
+
});
|
|
4470
|
+
export {
|
|
4471
|
+
terminalUi
|
|
4472
|
+
};
|