@crosslink/sdk-browser 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +163 -0
- package/README.md +11 -0
- package/dist/chunk-FKNR6EFT.js +54 -0
- package/dist/chunk-RCHT4DYR.js +221 -0
- package/dist/crosslink.global.js +1123 -0
- package/dist/device-crypto-storage-NEJ3IT2Z.js +42 -0
- package/dist/index.d.ts +1491 -0
- package/dist/index.js +4520 -0
- package/dist/storage-FHFZA2HW.js +10 -0
- package/package.json +37 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,4520 @@
|
|
|
1
|
+
import {
|
|
2
|
+
JsonStore,
|
|
3
|
+
LocalStorageSecureStorage,
|
|
4
|
+
MemorySecureStorage
|
|
5
|
+
} from "./chunk-FKNR6EFT.js";
|
|
6
|
+
import {
|
|
7
|
+
AsyncStorageAdapter,
|
|
8
|
+
HydratedSecureStorage,
|
|
9
|
+
IndexedDbSecureStorage,
|
|
10
|
+
createSecureStorage
|
|
11
|
+
} from "./chunk-RCHT4DYR.js";
|
|
12
|
+
|
|
13
|
+
// src/client.ts
|
|
14
|
+
import {
|
|
15
|
+
ClientLink,
|
|
16
|
+
DeviceIdentity,
|
|
17
|
+
createClaim,
|
|
18
|
+
filterEndpoints,
|
|
19
|
+
filterEndpointsForOrigin,
|
|
20
|
+
noopLogger,
|
|
21
|
+
normalPairingTarget,
|
|
22
|
+
parsePairingUri,
|
|
23
|
+
processChallenge,
|
|
24
|
+
signClaim,
|
|
25
|
+
toHttpUrl,
|
|
26
|
+
toWebSocketUrl,
|
|
27
|
+
DEVICE_LINK_RPC_METHOD
|
|
28
|
+
} from "@crosslink/core";
|
|
29
|
+
import { bytesToBase64 } from "@crosslink/protocol";
|
|
30
|
+
import { unwrapBootstrapUri } from "@crosslink/core";
|
|
31
|
+
import {
|
|
32
|
+
tryUpgradeToWebrtc
|
|
33
|
+
} from "@crosslink/webrtc-adapter";
|
|
34
|
+
|
|
35
|
+
// src/ws.ts
|
|
36
|
+
function toBytes(data) {
|
|
37
|
+
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
|
38
|
+
if (ArrayBuffer.isView(data)) {
|
|
39
|
+
const view = data;
|
|
40
|
+
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
|
|
41
|
+
}
|
|
42
|
+
if (typeof data === "string") throw new Error("unexpected text frame");
|
|
43
|
+
if (typeof Blob !== "undefined" && data instanceof Blob) {
|
|
44
|
+
return data.arrayBuffer().then((buf) => new Uint8Array(buf));
|
|
45
|
+
}
|
|
46
|
+
throw new Error("unsupported websocket message type");
|
|
47
|
+
}
|
|
48
|
+
function openWithTimeout(ws, timeoutMs) {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
let settled = false;
|
|
51
|
+
const timer = setTimeout(() => {
|
|
52
|
+
if (settled) return;
|
|
53
|
+
settled = true;
|
|
54
|
+
try {
|
|
55
|
+
ws.close();
|
|
56
|
+
} catch {
|
|
57
|
+
}
|
|
58
|
+
reject(new Error(`connection timed out after ${timeoutMs}ms`));
|
|
59
|
+
}, timeoutMs);
|
|
60
|
+
const onOpen = () => {
|
|
61
|
+
if (settled) return;
|
|
62
|
+
settled = true;
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
ws.removeEventListener?.("error", onError);
|
|
65
|
+
resolve();
|
|
66
|
+
};
|
|
67
|
+
const onError = (ev) => {
|
|
68
|
+
if (settled) return;
|
|
69
|
+
settled = true;
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
const detail = ev;
|
|
72
|
+
reject(new Error(`connection failed: ${detail?.error?.message ?? detail?.message ?? "unknown"}`));
|
|
73
|
+
};
|
|
74
|
+
ws.addEventListener("open", onOpen);
|
|
75
|
+
ws.addEventListener("error", onError);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
function wsTransport(ws, kind) {
|
|
79
|
+
try {
|
|
80
|
+
ws.binaryType = "arraybuffer";
|
|
81
|
+
} catch {
|
|
82
|
+
}
|
|
83
|
+
let dataHandler;
|
|
84
|
+
let closeHandler;
|
|
85
|
+
let closed = false;
|
|
86
|
+
ws.addEventListener("message", (ev) => {
|
|
87
|
+
if (closed) return;
|
|
88
|
+
let bytes;
|
|
89
|
+
try {
|
|
90
|
+
bytes = toBytes(ev.data);
|
|
91
|
+
} catch {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (bytes instanceof Uint8Array) {
|
|
95
|
+
dataHandler?.(bytes);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
void bytes.then((resolved) => {
|
|
99
|
+
if (!closed) dataHandler?.(resolved);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
const onCloseOnce = () => {
|
|
103
|
+
if (closed) return;
|
|
104
|
+
closed = true;
|
|
105
|
+
closeHandler?.("ws-closed");
|
|
106
|
+
};
|
|
107
|
+
ws.addEventListener("close", onCloseOnce);
|
|
108
|
+
ws.addEventListener("error", () => {
|
|
109
|
+
try {
|
|
110
|
+
ws.close();
|
|
111
|
+
} catch {
|
|
112
|
+
}
|
|
113
|
+
onCloseOnce();
|
|
114
|
+
});
|
|
115
|
+
return {
|
|
116
|
+
kind,
|
|
117
|
+
onData(cb) {
|
|
118
|
+
dataHandler = cb;
|
|
119
|
+
},
|
|
120
|
+
onClose(cb) {
|
|
121
|
+
closeHandler = cb;
|
|
122
|
+
},
|
|
123
|
+
async send(bytes) {
|
|
124
|
+
if (closed || ws.readyState !== 1) throw new Error("transport closed");
|
|
125
|
+
ws.send(bytes);
|
|
126
|
+
},
|
|
127
|
+
close(reason) {
|
|
128
|
+
if (closed) return;
|
|
129
|
+
try {
|
|
130
|
+
ws.close(1e3, typeof reason === "string" ? reason.slice(0, 100) : void 0);
|
|
131
|
+
} catch {
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// src/signaling-peer.ts
|
|
138
|
+
var SignalingPeer = class _SignalingPeer {
|
|
139
|
+
constructor(ws) {
|
|
140
|
+
this.ws = ws;
|
|
141
|
+
ws.addEventListener("message", (ev) => {
|
|
142
|
+
let msg;
|
|
143
|
+
try {
|
|
144
|
+
msg = JSON.parse(String(ev.data));
|
|
145
|
+
} catch {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (msg.op === "pair_deliver" && typeof msg.blob === "string") {
|
|
149
|
+
const entry = { from: String(msg.from), blob: msg.blob };
|
|
150
|
+
const r = this.resolvers.shift();
|
|
151
|
+
if (r) r(entry);
|
|
152
|
+
else this.queue.push(entry);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (msg.op === "error") {
|
|
156
|
+
this.fail(new Error(`signaling error: ${JSON.stringify(msg.error ?? {})}`));
|
|
157
|
+
}
|
|
158
|
+
if (msg.op === "pair_not_found") {
|
|
159
|
+
this.fail(new Error("PAIRING_EXPIRED: code not found or expired"));
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
ws.addEventListener(
|
|
163
|
+
"close",
|
|
164
|
+
() => this.fail(new Error("signaling connection closed"))
|
|
165
|
+
);
|
|
166
|
+
ws.addEventListener("error", () => this.fail(new Error("signaling connection failed")));
|
|
167
|
+
}
|
|
168
|
+
ws;
|
|
169
|
+
queue = [];
|
|
170
|
+
resolvers = [];
|
|
171
|
+
failure;
|
|
172
|
+
failureWaiters = [];
|
|
173
|
+
static async open(wsFactory, timeoutMs = 1e4) {
|
|
174
|
+
const ws = wsFactory();
|
|
175
|
+
const peer = new _SignalingPeer(ws);
|
|
176
|
+
try {
|
|
177
|
+
await openWithTimeout(ws, timeoutMs);
|
|
178
|
+
} catch (err) {
|
|
179
|
+
throw new Error(`cannot reach signaling: ${String(err?.message ?? err)}`, { cause: err });
|
|
180
|
+
}
|
|
181
|
+
return peer;
|
|
182
|
+
}
|
|
183
|
+
/** Resolves a pairing code; returns psid, host connection id, and presence. */
|
|
184
|
+
async resolve(code) {
|
|
185
|
+
this.send({ op: "pair_resolve", code });
|
|
186
|
+
return new Promise((resolve, reject) => {
|
|
187
|
+
const onMsg = (ev) => {
|
|
188
|
+
let msg;
|
|
189
|
+
try {
|
|
190
|
+
msg = JSON.parse(String(ev.data));
|
|
191
|
+
} catch {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (msg.op === "pair_found") {
|
|
195
|
+
this.ws.removeEventListener?.("message", onMsg);
|
|
196
|
+
resolve({
|
|
197
|
+
psid: String(msg.psid),
|
|
198
|
+
hostConn: String(msg.host_conn),
|
|
199
|
+
app: msg.app
|
|
200
|
+
});
|
|
201
|
+
} else if (msg.op === "pair_not_found") {
|
|
202
|
+
this.ws.removeEventListener?.("message", onMsg);
|
|
203
|
+
reject(new Error("PAIRING_EXPIRED"));
|
|
204
|
+
} else if (msg.op === "error") {
|
|
205
|
+
this.ws.removeEventListener?.("message", onMsg);
|
|
206
|
+
reject(new Error(String(msg.error?.code ?? "error")));
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
this.ws.addEventListener("message", onMsg);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
/** Sends an opaque blob to a connected peer (host or waiter). */
|
|
213
|
+
sendTo(connId, blob) {
|
|
214
|
+
this.send({ op: "pair_payload", to: connId, blob });
|
|
215
|
+
}
|
|
216
|
+
/** Awaits the next blob delivered from `fromConnId`. */
|
|
217
|
+
nextBlob(fromConnId, timeoutMs = 15e3) {
|
|
218
|
+
const idx = this.queue.findIndex((q) => q.from === fromConnId);
|
|
219
|
+
if (idx >= 0) return Promise.resolve(this.queue.splice(idx, 1)[0].blob);
|
|
220
|
+
return new Promise((resolve, reject) => {
|
|
221
|
+
const timer = setTimeout(() => {
|
|
222
|
+
this.failureWaiters = this.failureWaiters.filter((w) => w !== wake);
|
|
223
|
+
reject(new Error("timeout awaiting pairing reply"));
|
|
224
|
+
}, timeoutMs);
|
|
225
|
+
const wake = (err) => {
|
|
226
|
+
clearTimeout(timer);
|
|
227
|
+
err ? reject(err) : reject(new Error("peer closed"));
|
|
228
|
+
};
|
|
229
|
+
this.resolvers.push((entry) => {
|
|
230
|
+
clearTimeout(timer);
|
|
231
|
+
this.failureWaiters = this.failureWaiters.filter((w) => w !== wake);
|
|
232
|
+
if (entry.from === fromConnId) resolve(entry.blob);
|
|
233
|
+
else {
|
|
234
|
+
this.queue.push(entry);
|
|
235
|
+
this.resolvers.push((e2) => resolve(e2.blob));
|
|
236
|
+
reject(new Error("blob from unexpected sender"));
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
this.failureWaiters.push(wake);
|
|
240
|
+
if (this.failure) wake(this.failure);
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
close() {
|
|
244
|
+
try {
|
|
245
|
+
this.ws.close(1e3, "done");
|
|
246
|
+
} catch {
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
send(obj) {
|
|
250
|
+
this.ws.send(JSON.stringify(obj));
|
|
251
|
+
}
|
|
252
|
+
fail(err) {
|
|
253
|
+
this.failure = err;
|
|
254
|
+
const waiters = this.failureWaiters.splice(0);
|
|
255
|
+
for (const w of waiters) w(err);
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
// src/pairing-channel.ts
|
|
260
|
+
var DirectPairingChannel = class _DirectPairingChannel {
|
|
261
|
+
constructor(ws) {
|
|
262
|
+
this.ws = ws;
|
|
263
|
+
try {
|
|
264
|
+
ws.binaryType = "arraybuffer";
|
|
265
|
+
} catch {
|
|
266
|
+
}
|
|
267
|
+
ws.addEventListener("message", (ev) => {
|
|
268
|
+
const frame = parseFrame(ev.data);
|
|
269
|
+
if (!frame) return;
|
|
270
|
+
if (frame instanceof Promise) {
|
|
271
|
+
void frame.then((resolved) => {
|
|
272
|
+
if (resolved) this.deliver(resolved);
|
|
273
|
+
});
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
this.deliver(frame);
|
|
277
|
+
});
|
|
278
|
+
ws.addEventListener("close", () => this.fail(new Error("host closed the pairing connection")));
|
|
279
|
+
ws.addEventListener("error", () => this.fail(new Error("pairing connection failed")));
|
|
280
|
+
}
|
|
281
|
+
ws;
|
|
282
|
+
kind = "direct";
|
|
283
|
+
queue = [];
|
|
284
|
+
waiters = [];
|
|
285
|
+
failure;
|
|
286
|
+
static async open(url, wsFactory, timeoutMs) {
|
|
287
|
+
const ws = wsFactory(url);
|
|
288
|
+
const channel = new _DirectPairingChannel(ws);
|
|
289
|
+
try {
|
|
290
|
+
await openWithTimeout(ws, timeoutMs);
|
|
291
|
+
} catch (err) {
|
|
292
|
+
throw new Error(`cannot reach ${url}: ${String(err?.message ?? err)}`, { cause: err });
|
|
293
|
+
}
|
|
294
|
+
return channel;
|
|
295
|
+
}
|
|
296
|
+
async resolve(code) {
|
|
297
|
+
this.send({ kind: "pair_hello", code });
|
|
298
|
+
const frame = await this.next();
|
|
299
|
+
if (frame.kind === "pair_error") {
|
|
300
|
+
throw new Error(pairErrorMessage(frame));
|
|
301
|
+
}
|
|
302
|
+
if (frame.kind !== "pair_ready" || typeof frame.ps !== "string") {
|
|
303
|
+
throw new Error(`unexpected reply to pair_hello: ${String(frame.kind)}`);
|
|
304
|
+
}
|
|
305
|
+
return { psid: frame.ps, app: frame.app };
|
|
306
|
+
}
|
|
307
|
+
send(frame) {
|
|
308
|
+
this.ws.send(new TextEncoder().encode(JSON.stringify(frame)));
|
|
309
|
+
}
|
|
310
|
+
next() {
|
|
311
|
+
const queued = this.queue.shift();
|
|
312
|
+
if (queued) return Promise.resolve(queued);
|
|
313
|
+
if (this.failure) return Promise.reject(this.failure);
|
|
314
|
+
return new Promise((resolve, reject) => this.waiters.push({ resolve, reject }));
|
|
315
|
+
}
|
|
316
|
+
close() {
|
|
317
|
+
try {
|
|
318
|
+
this.ws.close();
|
|
319
|
+
} catch {
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
deliver(frame) {
|
|
323
|
+
const waiter = this.waiters.shift();
|
|
324
|
+
if (waiter) waiter.resolve(frame);
|
|
325
|
+
else this.queue.push(frame);
|
|
326
|
+
}
|
|
327
|
+
fail(err) {
|
|
328
|
+
if (this.failure) return;
|
|
329
|
+
this.failure = err;
|
|
330
|
+
for (const waiter of this.waiters.splice(0)) waiter.reject(err);
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
var BrokeredPairingChannel = class {
|
|
334
|
+
constructor(peer) {
|
|
335
|
+
this.peer = peer;
|
|
336
|
+
}
|
|
337
|
+
peer;
|
|
338
|
+
kind = "brokered";
|
|
339
|
+
hostConn = "";
|
|
340
|
+
async resolve(code) {
|
|
341
|
+
const found = await this.peer.resolve(code);
|
|
342
|
+
this.hostConn = found.hostConn;
|
|
343
|
+
return { psid: found.psid, app: found.app };
|
|
344
|
+
}
|
|
345
|
+
send(frame) {
|
|
346
|
+
this.peer.sendTo(this.hostConn, JSON.stringify(frame));
|
|
347
|
+
}
|
|
348
|
+
async next() {
|
|
349
|
+
const blob = await this.peer.nextBlob(this.hostConn);
|
|
350
|
+
return JSON.parse(blob);
|
|
351
|
+
}
|
|
352
|
+
close() {
|
|
353
|
+
this.peer.close();
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
function pairErrorMessage(frame) {
|
|
357
|
+
const error = frame.error;
|
|
358
|
+
return `PAIRING_FAILED${error?.code ? ` (${error.code})` : ""}: ${error?.message ?? "the host rejected the pairing attempt"}`;
|
|
359
|
+
}
|
|
360
|
+
function parseFrame(data) {
|
|
361
|
+
if (typeof Blob !== "undefined" && data instanceof Blob) {
|
|
362
|
+
return data.text().then(decodeJsonObject);
|
|
363
|
+
}
|
|
364
|
+
if (typeof data === "string") return decodeJsonObject(data);
|
|
365
|
+
try {
|
|
366
|
+
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : ArrayBuffer.isView(data) ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength) : null;
|
|
367
|
+
if (!bytes) return null;
|
|
368
|
+
return decodeJsonObject(new TextDecoder().decode(bytes));
|
|
369
|
+
} catch {
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
function decodeJsonObject(text) {
|
|
374
|
+
try {
|
|
375
|
+
const parsed = JSON.parse(text);
|
|
376
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
377
|
+
} catch {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// src/client.ts
|
|
383
|
+
var StorageBackedAppStore = class {
|
|
384
|
+
file;
|
|
385
|
+
constructor(storage) {
|
|
386
|
+
this.file = new JsonStore(storage, "crosslink.apps");
|
|
387
|
+
}
|
|
388
|
+
all() {
|
|
389
|
+
return this.file.load({ apps: {} }).apps;
|
|
390
|
+
}
|
|
391
|
+
list() {
|
|
392
|
+
return Object.values(this.all());
|
|
393
|
+
}
|
|
394
|
+
get(appId) {
|
|
395
|
+
return this.all()[appId];
|
|
396
|
+
}
|
|
397
|
+
upsert(record) {
|
|
398
|
+
const data = this.file.load({ apps: {} });
|
|
399
|
+
data.apps[record.appId] = record;
|
|
400
|
+
this.file.save(data);
|
|
401
|
+
}
|
|
402
|
+
remove(appId) {
|
|
403
|
+
const data = this.file.load({ apps: {} });
|
|
404
|
+
delete data.apps[appId];
|
|
405
|
+
this.file.save(data);
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
var CrosslinkClient = class _CrosslinkClient {
|
|
409
|
+
constructor(options = {}) {
|
|
410
|
+
this.options = options;
|
|
411
|
+
const storage = options.storage ?? new MemorySecureStorage();
|
|
412
|
+
this.storage = storage;
|
|
413
|
+
this.appStore = new StorageBackedAppStore(storage);
|
|
414
|
+
this.hints = new JsonStore(storage, "crosslink.hints");
|
|
415
|
+
this.log = (options.logger ?? noopLogger).child({ component: "crosslink-client" });
|
|
416
|
+
const seedKey = "crosslink.identity.seed";
|
|
417
|
+
const existing = storage.get(seedKey);
|
|
418
|
+
if (existing) {
|
|
419
|
+
this.identity = DeviceIdentity.fromSeed(base64ToBytesLocal(existing));
|
|
420
|
+
} else {
|
|
421
|
+
this.identity = DeviceIdentity.create();
|
|
422
|
+
storage.set(seedKey, bytesToBase64(this.identity.seed));
|
|
423
|
+
this.log.info("client.identity-created", { deviceId: this.identity.deviceId });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
options;
|
|
427
|
+
log;
|
|
428
|
+
identity;
|
|
429
|
+
appStore;
|
|
430
|
+
hints;
|
|
431
|
+
link;
|
|
432
|
+
storage;
|
|
433
|
+
deviceCryptoStorage;
|
|
434
|
+
stateListeners = /* @__PURE__ */ new Set();
|
|
435
|
+
/**
|
|
436
|
+
* Builds a client whose identity and paired-app records are encrypted at
|
|
437
|
+
* rest with a non-extractable WebCrypto key, rather than sitting in
|
|
438
|
+
* `localStorage` in the clear. Prefer this over `new CrosslinkClient()` in
|
|
439
|
+
* browsers; the constructor stays synchronous for embedders that supply
|
|
440
|
+
* their own storage.
|
|
441
|
+
*/
|
|
442
|
+
static async create(options = {}) {
|
|
443
|
+
if (options.storage) return new _CrosslinkClient(options);
|
|
444
|
+
const log = options.logger ?? noopLogger;
|
|
445
|
+
const { storage, kind, encrypted } = await createSecureStorage({
|
|
446
|
+
...options.allowPlaintextFallback !== void 0 ? { allowPlaintextFallback: options.allowPlaintextFallback } : {},
|
|
447
|
+
onWriteError: (err, key) => log.error("client.storage-write-failed", { key, error: err })
|
|
448
|
+
});
|
|
449
|
+
if (!encrypted) {
|
|
450
|
+
log.warn("client.storage-not-encrypted", {
|
|
451
|
+
kind,
|
|
452
|
+
detail: "identity seed is stored in the clear; WebCrypto/IndexedDB unavailable"
|
|
453
|
+
});
|
|
454
|
+
} else {
|
|
455
|
+
log.info("client.storage", { kind });
|
|
456
|
+
}
|
|
457
|
+
return new _CrosslinkClient({ ...options, storage });
|
|
458
|
+
}
|
|
459
|
+
get deviceId() {
|
|
460
|
+
return this.identity.deviceId;
|
|
461
|
+
}
|
|
462
|
+
listApps() {
|
|
463
|
+
return this.appStore.list();
|
|
464
|
+
}
|
|
465
|
+
forget(appId) {
|
|
466
|
+
this.appStore.remove(appId);
|
|
467
|
+
this.link?.close();
|
|
468
|
+
this.link = void 0;
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Runs the full pairing flow against a scanned QR / URI: resolve code via
|
|
472
|
+
* signaling, verify pinned fingerprint, verify challenge signature, confirm
|
|
473
|
+
* SAS, persist the paired-app record.
|
|
474
|
+
*
|
|
475
|
+
* Accepts either a raw `crosslink://pair?…` manifest URI or a hosted
|
|
476
|
+
* bootstrap URL (`https://…/…&pair=<manifest>`), because iOS Safari has no
|
|
477
|
+
* handler for the custom scheme — the phone's camera produces the hosted
|
|
478
|
+
* link and this call transparently unwraps it.
|
|
479
|
+
*/
|
|
480
|
+
async pairFromQr(text, requestedCaps, codeOverride) {
|
|
481
|
+
if (!this.deviceCryptoStorage) {
|
|
482
|
+
try {
|
|
483
|
+
const storageModule = await import("./device-crypto-storage-NEJ3IT2Z.js");
|
|
484
|
+
this.deviceCryptoStorage = await storageModule.SecureDeviceCryptoStorage.open();
|
|
485
|
+
} catch (e) {
|
|
486
|
+
this.log.warn("client.device-crypto-init-failed", { error: String(e) });
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
const manifest = unwrapBootstrapUri(text);
|
|
490
|
+
const uri = parsePairingUri(manifest);
|
|
491
|
+
const rawCode = codeOverride ?? uri.code;
|
|
492
|
+
const code = uri.link ? rawCode : rawCode.replace(/\D/g, "");
|
|
493
|
+
if (!uri.link && code.length !== 9) {
|
|
494
|
+
throw new Error("A valid 9-digit pairing code is required");
|
|
495
|
+
}
|
|
496
|
+
const channel = await this.openPairingChannel(uri);
|
|
497
|
+
try {
|
|
498
|
+
const found = await channel.resolve(code);
|
|
499
|
+
if (!found.app.fingerprint.startsWith(uri.fp16)) {
|
|
500
|
+
this.log.error("client.fingerprint-mismatch", {
|
|
501
|
+
expected: uri.fp16,
|
|
502
|
+
got: found.app.fingerprint.slice(0, 16)
|
|
503
|
+
});
|
|
504
|
+
throw new Error("SECURITY: host fingerprint does not match the scanned code");
|
|
505
|
+
}
|
|
506
|
+
const { claim, state } = createClaim(this.identity, uri, this.options.deviceName ?? "browser", requestedCaps);
|
|
507
|
+
signClaim(this.identity, claim, found.psid);
|
|
508
|
+
channel.send(claim);
|
|
509
|
+
const challenge = await channel.next();
|
|
510
|
+
if (challenge.kind === "pair_error") throw new Error(pairErrorMessage(challenge));
|
|
511
|
+
const defaultConfirm = (req) => {
|
|
512
|
+
if (typeof window !== "undefined" && typeof window.confirm === "function") {
|
|
513
|
+
return window.confirm(
|
|
514
|
+
`Confirm pairing with "${req.hostName}"?
|
|
515
|
+
|
|
516
|
+
SAS: ${req.sas}
|
|
517
|
+
Capabilities: ${req.grantedCaps.join(", ") || "(none)"}`
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
return true;
|
|
521
|
+
};
|
|
522
|
+
const normalConfirm = this.options.onConfirmPairing ?? defaultConfirm;
|
|
523
|
+
const confirm = (req) => req.link ? true : normalConfirm(req);
|
|
524
|
+
const { complete, record } = await processChallenge(
|
|
525
|
+
this.identity,
|
|
526
|
+
uri,
|
|
527
|
+
state,
|
|
528
|
+
challenge,
|
|
529
|
+
confirm
|
|
530
|
+
);
|
|
531
|
+
channel.send(complete);
|
|
532
|
+
const done = await channel.next();
|
|
533
|
+
if (done.kind === "pair_error") throw new Error(pairErrorMessage(done));
|
|
534
|
+
record.lastConnected = Date.now();
|
|
535
|
+
this.appStore.upsert(record);
|
|
536
|
+
this.log.info("client.paired", {
|
|
537
|
+
appId: record.appId,
|
|
538
|
+
appName: record.appName,
|
|
539
|
+
grantedCaps: record.grantedCaps,
|
|
540
|
+
requestedCaps: requestedCaps ?? null
|
|
541
|
+
});
|
|
542
|
+
const hintsAll = this.hints.load({});
|
|
543
|
+
hintsAll[record.appId] = {
|
|
544
|
+
endpoints: uri.endpoints,
|
|
545
|
+
relay: found.app.relay,
|
|
546
|
+
lan: found.app.lan,
|
|
547
|
+
signalingUrl: uri.signalingUrl
|
|
548
|
+
};
|
|
549
|
+
this.hints.save(hintsAll);
|
|
550
|
+
return record;
|
|
551
|
+
} finally {
|
|
552
|
+
channel.close();
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Picks how to carry out the pairing exchange.
|
|
557
|
+
*
|
|
558
|
+
* Direct endpoints are tried first and in QR order: a socket straight to the
|
|
559
|
+
* host is faster, keeps the exchange off any third party, and — crucially —
|
|
560
|
+
* needs no service to be deployed anywhere. Only when every direct endpoint
|
|
561
|
+
* refuses does this fall back to a signaling service, and if there is no
|
|
562
|
+
* signaling endpoint either, the error names every route that was tried
|
|
563
|
+
* rather than blaming a missing signaling URL.
|
|
564
|
+
*/
|
|
565
|
+
async openPairingChannel(uri) {
|
|
566
|
+
const dialTimeoutMs = this.options.dialTimeoutMs ?? 1e4;
|
|
567
|
+
const failures = [];
|
|
568
|
+
const { usable, blocked } = filterEndpointsForOrigin(uri.endpoints, this.pageOrigin());
|
|
569
|
+
for (const entry of blocked) {
|
|
570
|
+
failures.push(`${entry.endpoint.kind} ${entry.endpoint.url}: ${entry.reason}`);
|
|
571
|
+
this.log.warn("client.endpoint-blocked", {
|
|
572
|
+
endpoint: entry.endpoint.kind,
|
|
573
|
+
reason: entry.reason
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
for (const endpoint of filterEndpoints(usable, ["lan", "wan", "tunnel"])) {
|
|
577
|
+
try {
|
|
578
|
+
const channel = await DirectPairingChannel.open(
|
|
579
|
+
toWebSocketUrl(endpoint.url),
|
|
580
|
+
(u) => this.ws(u),
|
|
581
|
+
dialTimeoutMs
|
|
582
|
+
);
|
|
583
|
+
this.log.info("client.pairing-channel", { kind: "direct", endpoint: endpoint.kind });
|
|
584
|
+
return channel;
|
|
585
|
+
} catch (err) {
|
|
586
|
+
failures.push(`${endpoint.kind} ${endpoint.url}: ${String(err?.message ?? err)}`);
|
|
587
|
+
this.log.debug("client.pairing-endpoint-failed", { endpoint: endpoint.kind, error: String(err) });
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
for (const endpoint of filterEndpoints(usable, ["sig"])) {
|
|
591
|
+
const wsUrl = `${toWebSocketUrl(endpoint.url).replace(/\/$/, "")}/ws`;
|
|
592
|
+
try {
|
|
593
|
+
const peer = await SignalingPeer.open(() => this.ws(wsUrl), dialTimeoutMs);
|
|
594
|
+
this.log.info("client.pairing-channel", { kind: "brokered", endpoint: endpoint.kind });
|
|
595
|
+
return new BrokeredPairingChannel(peer);
|
|
596
|
+
} catch (err) {
|
|
597
|
+
failures.push(`sig ${wsUrl}: ${String(err?.message ?? err)}`);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
throw new Error(
|
|
601
|
+
`cannot reach the host on any route from this QR code.
|
|
602
|
+
Tried:
|
|
603
|
+
${failures.join("\n ")}
|
|
604
|
+
` + (blocked.length > 0 && usable.length === 0 ? "Every route this host advertises is insecure, and this page is served over https, so the browser refuses all of them. The host needs a wss:// route \u2014 a relay or a tunnel \u2014 to be reachable from an installable Crosslink origin." : 'If the phone is not on the same Wi-Fi, the host needs remote access (networkMode: "remote") or a signaling service.')
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* The origin of the page this client is running in, or null off-browser.
|
|
609
|
+
*
|
|
610
|
+
* Used to decide which advertised endpoints the browser will actually permit;
|
|
611
|
+
* a Node or native client has no such restriction and gets null.
|
|
612
|
+
*/
|
|
613
|
+
pageOrigin() {
|
|
614
|
+
if (typeof location === "undefined") return null;
|
|
615
|
+
const origin = location.origin;
|
|
616
|
+
return origin && origin !== "null" ? origin : null;
|
|
617
|
+
}
|
|
618
|
+
/** Connects to a previously paired app; returns the RPC client when online. */
|
|
619
|
+
async connect(appId) {
|
|
620
|
+
const record = appId ? this.appStore.get(appId) : this.appStore.list()[0];
|
|
621
|
+
if (!record) throw new Error("no paired app" + (appId ? ` for ${appId}` : ""));
|
|
622
|
+
if (this.link && this.link.currentState !== "offline" && this.link.currentState !== "connecting" && this.link.currentState !== "reconnecting") return this.rpc();
|
|
623
|
+
const hintsAll = this.hints.load({});
|
|
624
|
+
const hints = hintsAll[record.appId] ?? {};
|
|
625
|
+
let presence = null;
|
|
626
|
+
if (hints.signalingUrl) {
|
|
627
|
+
const doFetch = this.options.fetch ?? globalThis.fetch;
|
|
628
|
+
try {
|
|
629
|
+
const res = await doFetch(
|
|
630
|
+
`${toHttpUrl(hints.signalingUrl).replace(/\/$/, "")}/apps/${encodeURIComponent(record.appId)}`
|
|
631
|
+
);
|
|
632
|
+
if (res.ok) {
|
|
633
|
+
presence = await res.json();
|
|
634
|
+
hintsAll[record.appId] = { ...hints, ...presence };
|
|
635
|
+
this.hints.save(hintsAll);
|
|
636
|
+
}
|
|
637
|
+
} catch (err) {
|
|
638
|
+
this.log.debug("client.presence-lookup-failed", { appId: record.appId, error: err });
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
const relay = presence?.relay ?? hints.relay;
|
|
642
|
+
const lan = presence?.lan ?? hints.lan;
|
|
643
|
+
const candidates = [];
|
|
644
|
+
const seenUrls = /* @__PURE__ */ new Set();
|
|
645
|
+
const addDirect = (url, kind) => {
|
|
646
|
+
if (seenUrls.has(url)) return;
|
|
647
|
+
seenUrls.add(url);
|
|
648
|
+
if (filterEndpointsForOrigin([{ kind: "lan", url }], this.pageOrigin()).usable.length === 0) {
|
|
649
|
+
this.log.warn("client.candidate-blocked", { kind, url });
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
candidates.push({
|
|
653
|
+
// Both are direct sockets to the host; `lan` is the transport kind the
|
|
654
|
+
// protocol layer understands, and the endpoint kind is only about which
|
|
655
|
+
// network the address lives on.
|
|
656
|
+
kind: "lan",
|
|
657
|
+
connect: async () => {
|
|
658
|
+
const { ws: opened, ready } = openWs(url, (u) => this.ws(u), this.options.dialTimeoutMs ?? 1e4);
|
|
659
|
+
await ready;
|
|
660
|
+
return wsTransport(opened, "lan");
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
this.log.debug("client.candidate", { kind, url });
|
|
664
|
+
};
|
|
665
|
+
if (lan && lan.host) addDirect(`ws://${lan.host}:${lan.port}`, "lan");
|
|
666
|
+
for (const endpoint of filterEndpoints(hints.endpoints ?? [], ["lan", "wan", "tunnel"])) {
|
|
667
|
+
addDirect(toWebSocketUrl(endpoint.url), endpoint.kind === "wan" ? "wan" : "lan");
|
|
668
|
+
}
|
|
669
|
+
if (relay && this.options.networkMode !== "local-only") {
|
|
670
|
+
candidates.push({
|
|
671
|
+
kind: "crosslink-relayed",
|
|
672
|
+
connect: async () => {
|
|
673
|
+
const base = `${relay.url.replace(/^http/, "ws").replace(/\/$/, "")}/ws`;
|
|
674
|
+
const url = `${base}?channel=${encodeURIComponent(relay.channel)}&role=c` + (this.options.relayToken ? `&auth=${encodeURIComponent(this.options.relayToken)}` : "");
|
|
675
|
+
const { ws: opened, ready } = openWs(url, (u) => this.ws(u), this.options.dialTimeoutMs ?? 1e4);
|
|
676
|
+
await ready;
|
|
677
|
+
return wsTransport(opened, "crosslink-relayed");
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
if (candidates.length === 0) {
|
|
682
|
+
this.log.warn("client.no-candidates", { appId: record.appId });
|
|
683
|
+
throw new Error("no known transport for this app; re-pair or check host is online");
|
|
684
|
+
}
|
|
685
|
+
this.log.debug("client.connecting", {
|
|
686
|
+
appId: record.appId,
|
|
687
|
+
candidates: candidates.map((c) => c.kind)
|
|
688
|
+
});
|
|
689
|
+
this.link?.close();
|
|
690
|
+
const link = new ClientLink({
|
|
691
|
+
identity: this.identity,
|
|
692
|
+
appId: record.appId,
|
|
693
|
+
hostRecord: () => {
|
|
694
|
+
const rec = this.appStore.get(record.appId);
|
|
695
|
+
rec.lastConnected = Date.now();
|
|
696
|
+
return rec;
|
|
697
|
+
},
|
|
698
|
+
candidates,
|
|
699
|
+
autoReconnect: true,
|
|
700
|
+
requestTimeoutMs: this.options.requestTimeoutMs,
|
|
701
|
+
onStateChange: (state, detail) => this.publishState(state, detail),
|
|
702
|
+
logger: this.options.logger,
|
|
703
|
+
hybridPq: this.options.hybridPq ?? "disabled"
|
|
704
|
+
});
|
|
705
|
+
this.link = link;
|
|
706
|
+
await link.connect();
|
|
707
|
+
if (this.options.webrtc) {
|
|
708
|
+
this.tryWebrtcUpgrade(link);
|
|
709
|
+
}
|
|
710
|
+
return link.rpc;
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Attempts to upgrade a relayed session to a direct WebRTC DataChannel.
|
|
714
|
+
* Runs asynchronously — the relayed session stays active regardless.
|
|
715
|
+
*/
|
|
716
|
+
async tryWebrtcUpgrade(_link) {
|
|
717
|
+
if (!this.options.webrtc?.createPeer) return;
|
|
718
|
+
try {
|
|
719
|
+
const target = _link;
|
|
720
|
+
const ok = await tryUpgradeToWebrtc(target, {
|
|
721
|
+
createPeer: this.options.webrtc.createPeer,
|
|
722
|
+
timeoutMs: this.options.webrtc.timeoutMs
|
|
723
|
+
});
|
|
724
|
+
if (ok) {
|
|
725
|
+
this.log.info("client.webrtc-upgraded");
|
|
726
|
+
}
|
|
727
|
+
} catch (err) {
|
|
728
|
+
this.log.debug("client.webrtc-upgrade-failed", { error: err });
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
rpc() {
|
|
732
|
+
if (!this.link || !this.link.connected) throw new Error("not connected");
|
|
733
|
+
return this.link.rpc;
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Mints a single-use device-link continuation URI over the current
|
|
737
|
+
* connection, so this same identity can silently re-establish trust from a
|
|
738
|
+
* fresh, storage-isolated context (e.g. after "Add to Home Screen" on iOS,
|
|
739
|
+
* which does not share IndexedDB/localStorage with the Safari tab that
|
|
740
|
+
* paired). Requires an active, authorized connection.
|
|
741
|
+
*/
|
|
742
|
+
async createDeviceLink() {
|
|
743
|
+
return this.rpc().call(DEVICE_LINK_RPC_METHOD);
|
|
744
|
+
}
|
|
745
|
+
/** The live connection, exposed for adapters that upgrade the transport. */
|
|
746
|
+
get connection() {
|
|
747
|
+
return this.link;
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Convenience for the iOS / Add-to-Home-Screen flow: accepts the long
|
|
751
|
+
* `https://…#pair=<uri>` link a phone camera produces, unwraps it, and
|
|
752
|
+
* delegates to `pairFromQr`.
|
|
753
|
+
*/
|
|
754
|
+
async pairFromBootstrap(bootstrapUrl, requestedCaps, codeOverride) {
|
|
755
|
+
return this.pairFromQr(bootstrapUrl, requestedCaps, codeOverride);
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Explicit pairing method taking a target host URI/manifest and entered 9-digit code.
|
|
759
|
+
*/
|
|
760
|
+
async pairWithCode(targetUri, code, requestedCaps) {
|
|
761
|
+
return this.pairFromQr(normalPairingTarget(targetUri), requestedCaps, code);
|
|
762
|
+
}
|
|
763
|
+
/** True when the identity seed is encrypted at rest. */
|
|
764
|
+
get storageEncrypted() {
|
|
765
|
+
return this.storage.encrypted === true;
|
|
766
|
+
}
|
|
767
|
+
ws(url) {
|
|
768
|
+
return (this.options.webSocket ?? defaultWebSocket)(url);
|
|
769
|
+
}
|
|
770
|
+
get state() {
|
|
771
|
+
return this.link?.currentState ?? "offline";
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Subscribes to connection-state changes; returns an unsubscribe function.
|
|
775
|
+
*
|
|
776
|
+
* Framework bindings need this. Without it the only way to observe state is
|
|
777
|
+
* the `onStateChange` constructor option — a single callback fixed at
|
|
778
|
+
* construction, which a React provider cannot use without polling.
|
|
779
|
+
*/
|
|
780
|
+
onStateChange(listener) {
|
|
781
|
+
this.stateListeners.add(listener);
|
|
782
|
+
return () => {
|
|
783
|
+
this.stateListeners.delete(listener);
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
publishState(state, detail) {
|
|
787
|
+
this.options.onStateChange?.(state, detail);
|
|
788
|
+
for (const listener of [...this.stateListeners]) {
|
|
789
|
+
try {
|
|
790
|
+
listener(state, detail);
|
|
791
|
+
} catch (err) {
|
|
792
|
+
this.log.warn("client.state-listener-failed", { error: String(err) });
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
close() {
|
|
797
|
+
this.link?.close();
|
|
798
|
+
this.link = void 0;
|
|
799
|
+
}
|
|
800
|
+
};
|
|
801
|
+
function defaultWebSocket(url) {
|
|
802
|
+
const ctor = globalThis.WebSocket;
|
|
803
|
+
if (typeof ctor !== "function") {
|
|
804
|
+
throw new Error("WebSocket not available in this environment");
|
|
805
|
+
}
|
|
806
|
+
return new ctor(url);
|
|
807
|
+
}
|
|
808
|
+
function openWs(url, factory, timeoutMs) {
|
|
809
|
+
const ws = factory(url);
|
|
810
|
+
return { ws, ready: openWithTimeout(ws, timeoutMs) };
|
|
811
|
+
}
|
|
812
|
+
function base64ToBytesLocal(b64) {
|
|
813
|
+
const bin = atobSafe(b64);
|
|
814
|
+
const out = new Uint8Array(bin.length);
|
|
815
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
816
|
+
return out;
|
|
817
|
+
}
|
|
818
|
+
function atobSafe(b64) {
|
|
819
|
+
if (typeof atob === "function") return atob(b64);
|
|
820
|
+
return Buffer.from(b64, "base64").toString("binary");
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// src/index.ts
|
|
824
|
+
import {
|
|
825
|
+
consoleLogger,
|
|
826
|
+
createLogger,
|
|
827
|
+
noopLogger as noopLogger2,
|
|
828
|
+
MemoryLogSink
|
|
829
|
+
} from "@crosslink/core";
|
|
830
|
+
|
|
831
|
+
// src/mock-ws.ts
|
|
832
|
+
var CONNECTING = 0;
|
|
833
|
+
var OPEN = 1;
|
|
834
|
+
var CLOSING = 2;
|
|
835
|
+
var CLOSED = 3;
|
|
836
|
+
var MockSocket = class _MockSocket {
|
|
837
|
+
constructor(url, options = {}) {
|
|
838
|
+
this.url = url;
|
|
839
|
+
const open = () => {
|
|
840
|
+
if (this.readyState !== CONNECTING) return;
|
|
841
|
+
if (options.failToOpen) {
|
|
842
|
+
this.readyState = CLOSED;
|
|
843
|
+
this.emit("error", { type: "error" });
|
|
844
|
+
this.emit("close", { code: 1006, reason: "failed to open" });
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
this.readyState = OPEN;
|
|
848
|
+
this.emit("open", { type: "open" });
|
|
849
|
+
};
|
|
850
|
+
if (options.openDelayMs && options.openDelayMs > 0) {
|
|
851
|
+
setTimeout(open, options.openDelayMs);
|
|
852
|
+
} else {
|
|
853
|
+
queueMicrotask(open);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
url;
|
|
857
|
+
readyState = CONNECTING;
|
|
858
|
+
binaryType = "arraybuffer";
|
|
859
|
+
/** Everything this end has sent, in order. */
|
|
860
|
+
sent = [];
|
|
861
|
+
listeners = /* @__PURE__ */ new Map();
|
|
862
|
+
peer;
|
|
863
|
+
/** Joins two mock sockets so each one's sends arrive at the other. */
|
|
864
|
+
static pair(urlA = "ws://a", urlB = "ws://b") {
|
|
865
|
+
const a = new _MockSocket(urlA);
|
|
866
|
+
const b = new _MockSocket(urlB);
|
|
867
|
+
a.attach(b);
|
|
868
|
+
b.attach(a);
|
|
869
|
+
return [a, b];
|
|
870
|
+
}
|
|
871
|
+
attach(peer) {
|
|
872
|
+
this.peer = peer;
|
|
873
|
+
}
|
|
874
|
+
addEventListener(type, cb) {
|
|
875
|
+
let set = this.listeners.get(type);
|
|
876
|
+
if (!set) {
|
|
877
|
+
set = /* @__PURE__ */ new Set();
|
|
878
|
+
this.listeners.set(type, set);
|
|
879
|
+
}
|
|
880
|
+
set.add(cb);
|
|
881
|
+
}
|
|
882
|
+
removeEventListener(type, cb) {
|
|
883
|
+
this.listeners.get(type)?.delete(cb);
|
|
884
|
+
}
|
|
885
|
+
send(data) {
|
|
886
|
+
if (this.readyState !== OPEN) throw new Error("mock socket is not open");
|
|
887
|
+
this.sent.push(data);
|
|
888
|
+
queueMicrotask(() => this.peer?.deliver(data));
|
|
889
|
+
}
|
|
890
|
+
close(code = 1e3, reason = "") {
|
|
891
|
+
if (this.readyState === CLOSED || this.readyState === CLOSING) return;
|
|
892
|
+
this.readyState = CLOSED;
|
|
893
|
+
this.emit("close", { code, reason });
|
|
894
|
+
const peer = this.peer;
|
|
895
|
+
queueMicrotask(() => peer?.remoteClosed(code, reason));
|
|
896
|
+
}
|
|
897
|
+
/** Simulates a transport-level failure (not a clean close). */
|
|
898
|
+
fail(reason = "mock failure") {
|
|
899
|
+
if (this.readyState === CLOSED) return;
|
|
900
|
+
this.emit("error", { type: "error", reason });
|
|
901
|
+
this.readyState = CLOSED;
|
|
902
|
+
this.emit("close", { code: 1006, reason });
|
|
903
|
+
const peer = this.peer;
|
|
904
|
+
queueMicrotask(() => peer?.remoteClosed(1006, reason));
|
|
905
|
+
}
|
|
906
|
+
/** Pushes a message into this end as though the peer had sent it. */
|
|
907
|
+
deliver(data) {
|
|
908
|
+
if (this.readyState !== OPEN) return;
|
|
909
|
+
this.emit("message", { data });
|
|
910
|
+
}
|
|
911
|
+
remoteClosed(code, reason) {
|
|
912
|
+
if (this.readyState === CLOSED) return;
|
|
913
|
+
this.readyState = CLOSED;
|
|
914
|
+
this.emit("close", { code, reason });
|
|
915
|
+
}
|
|
916
|
+
emit(type, ev) {
|
|
917
|
+
for (const cb of [...this.listeners.get(type) ?? []]) {
|
|
918
|
+
try {
|
|
919
|
+
cb(ev);
|
|
920
|
+
} catch {
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
};
|
|
925
|
+
|
|
926
|
+
// src/notifications.ts
|
|
927
|
+
import { NOTIFICATION_EVENT } from "@crosslink/core";
|
|
928
|
+
var NotificationHandler = class {
|
|
929
|
+
constructor(options = {}) {
|
|
930
|
+
this.options = options;
|
|
931
|
+
}
|
|
932
|
+
options;
|
|
933
|
+
unsub;
|
|
934
|
+
seen = /* @__PURE__ */ new Set();
|
|
935
|
+
/**
|
|
936
|
+
* Begin listening for notifications over an RPC client.
|
|
937
|
+
* Returns an unsubscribe function.
|
|
938
|
+
*/
|
|
939
|
+
start(rpc) {
|
|
940
|
+
if (this.options.autoRequestPermission && typeof Notification !== "undefined") {
|
|
941
|
+
Notification.requestPermission();
|
|
942
|
+
}
|
|
943
|
+
this.unsub = rpc.subscribe(
|
|
944
|
+
NOTIFICATION_EVENT,
|
|
945
|
+
((payload) => {
|
|
946
|
+
if (!payload.id || this.seen.has(payload.id)) return;
|
|
947
|
+
this.seen.add(payload.id);
|
|
948
|
+
this.deliver(payload);
|
|
949
|
+
})
|
|
950
|
+
);
|
|
951
|
+
return () => this.stop();
|
|
952
|
+
}
|
|
953
|
+
stop() {
|
|
954
|
+
this.unsub?.();
|
|
955
|
+
this.unsub = void 0;
|
|
956
|
+
}
|
|
957
|
+
deliver(payload) {
|
|
958
|
+
if (this.options.onNotification) {
|
|
959
|
+
this.options.onNotification(payload);
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
if (typeof Notification === "undefined") return;
|
|
963
|
+
if (Notification.permission !== "granted") return;
|
|
964
|
+
const n = new Notification(payload.title, {
|
|
965
|
+
body: payload.body,
|
|
966
|
+
tag: payload.id,
|
|
967
|
+
icon: payload.url
|
|
968
|
+
});
|
|
969
|
+
n.onclick = () => {
|
|
970
|
+
this.options.onClick?.(payload);
|
|
971
|
+
if (payload.url) window.open(payload.url, "_blank");
|
|
972
|
+
n.close();
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
};
|
|
976
|
+
|
|
977
|
+
// src/ui/pairing-source.ts
|
|
978
|
+
var DEFAULT_BASE_PATH = "/__crosslink";
|
|
979
|
+
var CONTROL_ROUTES = {
|
|
980
|
+
pairing: "/pairing",
|
|
981
|
+
networkMode: "/network-mode",
|
|
982
|
+
devices: "/devices",
|
|
983
|
+
revoke: "/revoke",
|
|
984
|
+
events: "/events"
|
|
985
|
+
};
|
|
986
|
+
function joinPath(base, route) {
|
|
987
|
+
return `${base.replace(/\/+$/, "")}${route}`;
|
|
988
|
+
}
|
|
989
|
+
async function describeFailure(res) {
|
|
990
|
+
const raw = await res.text().catch(() => "");
|
|
991
|
+
let message = raw.trim();
|
|
992
|
+
try {
|
|
993
|
+
const parsed = JSON.parse(raw);
|
|
994
|
+
if (parsed?.error) message = parsed.error;
|
|
995
|
+
} catch {
|
|
996
|
+
}
|
|
997
|
+
const error = new Error(message || `Request failed with status ${res.status}`);
|
|
998
|
+
error.code = `CL-P${res.status}`;
|
|
999
|
+
return error;
|
|
1000
|
+
}
|
|
1001
|
+
function createHttpPairingSource(basePath = DEFAULT_BASE_PATH) {
|
|
1002
|
+
const base = basePath.replace(/\/+$/, "");
|
|
1003
|
+
return {
|
|
1004
|
+
devicesEndpoint: joinPath(base, CONTROL_ROUTES.devices),
|
|
1005
|
+
revokeEndpoint: joinPath(base, CONTROL_ROUTES.revoke),
|
|
1006
|
+
async getSession(mode) {
|
|
1007
|
+
const url = new URL(joinPath(base, CONTROL_ROUTES.pairing), location.href);
|
|
1008
|
+
if (mode) url.searchParams.set("mode", mode);
|
|
1009
|
+
const res = await fetch(url.toString(), { headers: { accept: "application/json" } });
|
|
1010
|
+
if (!res.ok) throw await describeFailure(res);
|
|
1011
|
+
return await res.json();
|
|
1012
|
+
},
|
|
1013
|
+
async setNetworkMode(mode) {
|
|
1014
|
+
const res = await fetch(joinPath(base, CONTROL_ROUTES.networkMode), {
|
|
1015
|
+
method: "POST",
|
|
1016
|
+
headers: { "content-type": "application/json" },
|
|
1017
|
+
body: JSON.stringify({ mode })
|
|
1018
|
+
});
|
|
1019
|
+
if (!res.ok) throw await describeFailure(res);
|
|
1020
|
+
},
|
|
1021
|
+
subscribe(listener) {
|
|
1022
|
+
if (typeof EventSource === "undefined") return () => {
|
|
1023
|
+
};
|
|
1024
|
+
const stream = new EventSource(joinPath(base, CONTROL_ROUTES.events));
|
|
1025
|
+
const invalidate = () => listener({ type: "invalidate" });
|
|
1026
|
+
const connected = (event) => {
|
|
1027
|
+
let deviceId;
|
|
1028
|
+
try {
|
|
1029
|
+
deviceId = JSON.parse(event.data).deviceId;
|
|
1030
|
+
} catch {
|
|
1031
|
+
}
|
|
1032
|
+
listener({ type: "connected", deviceId });
|
|
1033
|
+
};
|
|
1034
|
+
const disconnected = (event) => {
|
|
1035
|
+
let deviceId;
|
|
1036
|
+
try {
|
|
1037
|
+
deviceId = JSON.parse(event.data).deviceId;
|
|
1038
|
+
} catch {
|
|
1039
|
+
}
|
|
1040
|
+
listener({ type: "disconnected", deviceId });
|
|
1041
|
+
};
|
|
1042
|
+
stream.addEventListener("crosslink.pairing-invalidated", invalidate);
|
|
1043
|
+
stream.addEventListener("crosslink.device-connected", connected);
|
|
1044
|
+
stream.addEventListener("crosslink.device-disconnected", disconnected);
|
|
1045
|
+
return () => stream.close();
|
|
1046
|
+
}
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// src/ui/branding.ts
|
|
1051
|
+
var CROSSLINK_LOGO_VIEWBOX = "105 363 1060 222";
|
|
1052
|
+
var CROSSLINK_LOGO_PATH = "M233.73 383.42C254.47 380.94 275.68 386.3 293.3 397.22C298.36 400.36 310.34 407.06 306.92 414.39C305.05 418.41 299.18 424.66 294.5 424.16C290.7 423.75 283.6 416.8 279.92 414.57C271.58 409.53 262.05 406.61 252.45 405.27C210.49 399.39 171.23 433.96 172.83 476.5C174.51 521.08 215.67 550.01 258.38 541.73C267.91 539.88 276.87 535.81 284.85 530.38C288.81 527.68 292.08 522.77 297.47 523.95C299.49 524.39 307.14 531.68 307.85 533.67C308.39 535.19 308.34 536.9 307.92 538.44C307.04 541.58 302.8 544.02 300.34 545.88C285.61 557.02 267.9 563.4 249.5 564.79C194.73 568.93 147.61 523.68 151.02 468.5C152.07 451.5 158.16 436.04 167.48 421.97C173.81 412.42 182.42 403.72 192.26 397.74C205.14 389.9 218.77 385.21 233.73 383.42ZM791.59 387.27C795.36 386.55 802.6 386.03 805.44 389.07C807.56 391.34 807.21 394.63 807.24 397.5C807.3 404.17 807.27 410.83 807.25 417.5C807.15 451.5 807.23 485.5 807.25 519.5C807.26 529.5 807.29 539.5 807.27 549.5C807.26 554.33 807.43 559.18 801.47 559.66C797.97 559.94 791 560.91 788.25 558.23C786.05 556.09 786.66 552.28 786.64 549.5C786.6 542.17 786.66 534.83 786.64 527.5C786.54 494.5 786.59 461.5 786.65 428.5C786.67 418.17 786.7 407.83 786.62 397.5C786.59 393.48 786.61 388.22 791.59 387.27ZM1047.5 490.33C1049.96 490.3 1052.92 490.63 1055.26 489.73C1058.8 488.37 1065.75 479.23 1068.61 476.12C1077.64 466.3 1086.84 456.19 1096.5 447C1100.43 443.26 1119.54 442.69 1123.5 445.8C1123.66 451.35 1115.32 457.13 1111.53 461.04C1099.51 473.43 1087.04 485.6 1075.57 498.5C1077.87 503.34 1082.73 507.17 1086.35 511.17C1095.38 521.13 1104.32 531.19 1113.52 541C1115.75 543.37 1125.96 553.61 1126.66 555.81C1127.01 556.89 1126.75 557.43 1126.8 558.5C1123.25 560.55 1118.53 559.73 1114.5 559.69C1110.76 559.66 1106.1 560.51 1102.63 558.88C1098.64 557 1095.59 552.04 1092.65 548.84C1085.62 541.2 1078.7 533.45 1071.74 525.76C1063.43 516.58 1060.8 509.28 1046.79 512.5C1044.96 522.02 1046.59 534.66 1046.57 544.5C1046.57 548.23 1047.48 553.75 1045.59 557.14C1043.49 560.88 1026.82 562.35 1025.95 554.5C1024.58 542.11 1025.96 527.16 1025.95 514.5C1025.94 486.17 1025.87 457.83 1025.93 429.5C1025.95 418.83 1025.94 408.17 1025.9 397.5C1025.89 393.57 1025.68 388.29 1030.61 387.24C1032.49 386.84 1034.59 387.09 1036.5 387.09C1038.56 387.1 1040.88 386.81 1042.81 387.66C1048.5 390.17 1046.49 406.98 1046.51 412.56C1046.56 430.21 1046.47 447.85 1046.53 465.5C1046.56 473.31 1045.12 482.95 1047.5 490.33ZM844.74 395.34C862.3 390.41 870.21 415.48 853.62 421.22C835.59 427.47 827.09 400.31 844.74 395.34ZM463.67 441.41C471.92 440.68 480.11 442.01 487.94 444.53C494.65 446.69 500.51 450.23 506.18 454.35C510.98 457.84 514.79 462.48 518.32 467.22C546.74 505.4 518.83 560.06 472.5 563.08C464.08 563.63 455.77 562.3 447.8 559.63C440.61 557.23 433.85 553.39 428.12 548.4C387.12 512.7 410.35 446.13 463.67 441.41ZM938.73 441.43C964.07 438.8 988.77 454.5 996.81 478.63C1001.03 491.31 999.84 505.34 999.82 518.5C999.8 528.5 999.67 538.5 999.8 548.5C999.85 552.1 1000.71 557.37 996.66 559.21C994.79 560.06 992.49 559.69 990.5 559.67C987.56 559.65 983.54 560.44 981.08 558.44C978.66 556.48 979.13 553.29 979.15 550.5C979.17 544.5 979.16 538.5 979.16 532.5C979.15 506.82 985.32 474.05 954.7 463.73C950.67 462.38 946.75 461.96 942.5 462.04C938.21 462.13 934.22 463.12 930.27 464.75C909.54 473.31 908.83 490.84 908.83 510.5C908.83 519.5 908.79 528.5 908.78 537.5C908.78 543.14 909.77 549.73 908.6 555.26C907.36 561.17 892.64 561.43 889.51 557.98C887.34 555.6 888.08 551.45 888.05 548.5C887.98 539.83 888.06 531.17 888.07 522.5C888.08 510.07 886.92 496.94 889.34 484.69C894.17 460.27 914.52 443.93 938.73 441.43ZM370.81 444.44C378.29 443.39 385.96 443.92 393.5 443.91C396.8 443.91 400.83 443.49 403.26 446.23C405.44 448.69 404.78 452.49 404.76 455.5C404.75 457.24 404.95 459.11 404.32 460.76C401.95 466.94 387.89 464.88 382.5 464.89C368.82 464.9 356.04 469.36 350.54 483.06C345.38 495.93 347.67 523.81 347.69 538.5C347.7 543.73 349 550.79 347.34 555.78C345.69 560.75 334.22 560.97 330.2 559.33C326.24 557.72 327 552.92 327 549.5C327 540.5 326.98 531.5 327 522.5C327.02 510.28 326.04 497.62 328.16 485.54C331.95 463.92 349.33 447.47 370.81 444.44ZM632.82 448.5C630.99 452.96 625.86 456.37 622.98 460.46C622.01 461.84 620.99 464.11 619.29 464.68C614.01 466.44 601.31 464.28 594.65 465.33C578.59 467.86 566.31 480.49 563.75 496.48C562.92 501.66 563.12 506.77 564.57 511.83C566.29 517.82 569.39 523.27 573.67 527.82C584.18 538.98 597.17 538.99 611.5 539.08C626.5 539.17 640.93 539.33 651.61 527.14C656.22 521.88 659.71 515.44 660.73 508.46C661.28 504.62 660.22 499.75 661.65 496.17C663.77 490.82 677.12 490.22 680.29 494.22C682.78 497.37 681.9 502.8 681.67 506.5C680.91 518.95 675.64 530.73 667.69 540.21C651.84 559.1 633.43 559.88 610.5 559.75C595.85 559.66 582.93 559.16 570.01 551.47C529.05 527.1 536.59 463.2 580.88 447.43C593.12 443.07 605.72 444 618.5 443.95C623.82 443.92 630.4 442.77 632.82 448.5ZM672.17 556.5C672.3 555.53 672.1 554.9 672.51 553.96C673.37 551.95 676.41 550 677.9 548.38C680.43 545.62 682.15 542.33 684.5 539.46C697.35 538.01 711.53 541.69 723.35 534.86C748.05 520.57 750.19 485.59 725.32 470.15C716.44 464.64 706.56 464.92 696.5 464.91C681.72 464.89 667.28 463.66 655.36 473.89C648.93 479.41 644.84 487.35 643.27 495.62C642.39 500.25 644.05 506.55 640.99 510.49C636.17 516.71 623.36 514.7 622.14 506.47C618.6 482.48 637.65 455.23 659.96 447.48C671.89 443.34 684.08 443.97 696.5 443.96C712.53 443.94 727.02 445.31 740.35 455.09C776.51 481.63 768.28 540.25 726.62 556.17C714.11 560.95 700.63 559.64 687.5 559.74C682.23 559.78 675.73 561.11 672.17 556.5ZM843.71 444.37C848.02 443.53 856.53 442.92 858.52 447.96C860.51 453 859.14 467.45 859.13 473.5C859.09 492.83 859.08 512.17 859.13 531.5C859.15 538.17 859.16 544.83 859.1 551.5C859.07 555.16 858.76 558.98 854.37 559.61C850.62 560.16 841.54 561.12 839.26 557.25C836.93 553.29 838.56 539.46 838.55 534.5C838.47 514.17 838.44 493.83 838.52 473.5C838.55 467.55 837.15 452.54 839.22 447.7C839.97 445.93 841.87 444.73 843.71 444.37ZM461.77 462.42C418.77 469.25 416.23 530.16 458.13 541.44C464.19 543.07 470.34 543.03 476.49 541.92C517.97 534.45 519.66 475.76 479.83 463.58C474.06 461.82 467.74 461.46 461.77 462.42Z";
|
|
1053
|
+
var CROSSLINK_REPOSITORY = "https://github.com/jacobpowaza/crosslink";
|
|
1054
|
+
var CROSSLINK_ATTRIBUTION_TEXT = "Powered by";
|
|
1055
|
+
var CROSSLINK_ATTRIBUTION_LINK_TEXT = "Crosslink";
|
|
1056
|
+
var DEFAULT_ACCENT = "#38bdf8";
|
|
1057
|
+
var DEFAULT_DARK_BG = "#0b1120";
|
|
1058
|
+
var DEFAULT_LIGHT_BG = "#ffffff";
|
|
1059
|
+
var LOGO_MIN_CONTRAST = 3;
|
|
1060
|
+
var ATTRIBUTION_MIN_CONTRAST = 4.5;
|
|
1061
|
+
function parseColor(input) {
|
|
1062
|
+
if (!input) return null;
|
|
1063
|
+
const value = input.trim().toLowerCase();
|
|
1064
|
+
const hex = value.match(/^#([0-9a-f]{3,8})$/);
|
|
1065
|
+
if (hex) {
|
|
1066
|
+
let digits = hex[1];
|
|
1067
|
+
if (digits.length === 3 || digits.length === 4) {
|
|
1068
|
+
digits = digits.slice(0, 3).split("").map((c) => c + c).join("");
|
|
1069
|
+
}
|
|
1070
|
+
if (digits.length < 6) return null;
|
|
1071
|
+
return {
|
|
1072
|
+
r: parseInt(digits.slice(0, 2), 16),
|
|
1073
|
+
g: parseInt(digits.slice(2, 4), 16),
|
|
1074
|
+
b: parseInt(digits.slice(4, 6), 16)
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
const rgb = value.match(/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/);
|
|
1078
|
+
if (rgb) {
|
|
1079
|
+
return {
|
|
1080
|
+
r: Math.min(255, Math.max(0, Number(rgb[1]))),
|
|
1081
|
+
g: Math.min(255, Math.max(0, Number(rgb[2]))),
|
|
1082
|
+
b: Math.min(255, Math.max(0, Number(rgb[3])))
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
return null;
|
|
1086
|
+
}
|
|
1087
|
+
function toHex({ r, g, b }) {
|
|
1088
|
+
const part = (n) => Math.round(Math.min(255, Math.max(0, n))).toString(16).padStart(2, "0");
|
|
1089
|
+
return `#${part(r)}${part(g)}${part(b)}`;
|
|
1090
|
+
}
|
|
1091
|
+
function relativeLuminance({ r, g, b }) {
|
|
1092
|
+
const channel = (v) => {
|
|
1093
|
+
const s = v / 255;
|
|
1094
|
+
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
1095
|
+
};
|
|
1096
|
+
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
|
1097
|
+
}
|
|
1098
|
+
function contrastRatio(a, b) {
|
|
1099
|
+
const la = relativeLuminance(a);
|
|
1100
|
+
const lb = relativeLuminance(b);
|
|
1101
|
+
const [hi, lo] = la >= lb ? [la, lb] : [lb, la];
|
|
1102
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
1103
|
+
}
|
|
1104
|
+
function mix(a, b, amount) {
|
|
1105
|
+
return {
|
|
1106
|
+
r: a.r + (b.r - a.r) * amount,
|
|
1107
|
+
g: a.g + (b.g - a.g) * amount,
|
|
1108
|
+
b: a.b + (b.b - a.b) * amount
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
function ensureContrast(color, background, minRatio) {
|
|
1112
|
+
if (contrastRatio(color, background) >= minRatio) return color;
|
|
1113
|
+
const towards = relativeLuminance(background) > 0.4 ? { r: 0, g: 0, b: 0 } : { r: 255, g: 255, b: 255 };
|
|
1114
|
+
let best = color;
|
|
1115
|
+
for (let step = 1; step <= 20; step += 1) {
|
|
1116
|
+
const candidate = mix(color, towards, step / 20);
|
|
1117
|
+
best = candidate;
|
|
1118
|
+
if (contrastRatio(candidate, background) >= minRatio) return candidate;
|
|
1119
|
+
}
|
|
1120
|
+
return best;
|
|
1121
|
+
}
|
|
1122
|
+
function resolveCrosslinkTheme(theme = {}) {
|
|
1123
|
+
const requestedBg = parseColor(theme.backgroundColor);
|
|
1124
|
+
const appearance = theme.appearance === "light" || theme.appearance === "dark" ? theme.appearance : requestedBg ? relativeLuminance(requestedBg) > 0.4 ? "light" : "dark" : "dark";
|
|
1125
|
+
const background = requestedBg ?? parseColor(appearance === "light" ? DEFAULT_LIGHT_BG : DEFAULT_DARK_BG);
|
|
1126
|
+
const accent = parseColor(theme.accentColor) ?? parseColor(DEFAULT_ACCENT);
|
|
1127
|
+
const text = parseColor(theme.textColor) ?? (appearance === "light" ? { r: 15, g: 23, b: 42 } : { r: 226, g: 232, b: 240 });
|
|
1128
|
+
const surface = mix(background, appearance === "light" ? { r: 0, g: 0, b: 0 } : { r: 255, g: 255, b: 255 }, 0.06);
|
|
1129
|
+
const muted = mix(text, background, 0.42);
|
|
1130
|
+
const divider = mix(background, text, 0.18);
|
|
1131
|
+
return {
|
|
1132
|
+
appName: theme.appName?.trim() || "This app",
|
|
1133
|
+
appIcon: theme.appIcon?.trim() || null,
|
|
1134
|
+
accentColor: toHex(accent),
|
|
1135
|
+
backgroundColor: toHex(background),
|
|
1136
|
+
surfaceColor: toHex(surface),
|
|
1137
|
+
textColor: toHex(text),
|
|
1138
|
+
mutedColor: toHex(muted),
|
|
1139
|
+
dividerColor: toHex(divider),
|
|
1140
|
+
logoColor: toHex(ensureContrast(accent, background, LOGO_MIN_CONTRAST)),
|
|
1141
|
+
attributionColor: toHex(ensureContrast(muted, background, ATTRIBUTION_MIN_CONTRAST)),
|
|
1142
|
+
appearance
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
function cssSize(value, fallback) {
|
|
1146
|
+
if (typeof value === "number") return `${value}px`;
|
|
1147
|
+
return value || fallback;
|
|
1148
|
+
}
|
|
1149
|
+
function crosslinkLogoSvg(options = {}) {
|
|
1150
|
+
const width = cssSize(options.width, "140px");
|
|
1151
|
+
const style = `width:${width};height:auto;display:block;${options.color ? `color:${options.color};` : ""}${options.style ?? ""}`;
|
|
1152
|
+
const className = options.className ? ` class="${options.className}"` : "";
|
|
1153
|
+
const title = options.title ?? "Crosslink";
|
|
1154
|
+
return [
|
|
1155
|
+
`<svg viewBox="${CROSSLINK_LOGO_VIEWBOX}" fill="none" xmlns="http://www.w3.org/2000/svg"`,
|
|
1156
|
+
` role="img" aria-label="${title}"${className} style="${style}">`,
|
|
1157
|
+
`<title>${title}</title>`,
|
|
1158
|
+
`<path d="${CROSSLINK_LOGO_PATH}" fill="currentColor" fill-rule="evenodd"/>`,
|
|
1159
|
+
`</svg>`
|
|
1160
|
+
].join("");
|
|
1161
|
+
}
|
|
1162
|
+
function createCrosslinkLogo(options = {}) {
|
|
1163
|
+
const holder = document.createElement("span");
|
|
1164
|
+
holder.className = "cl-logo-holder";
|
|
1165
|
+
holder.style.cssText = "display:inline-flex;align-items:center";
|
|
1166
|
+
holder.innerHTML = crosslinkLogoSvg(options);
|
|
1167
|
+
return holder;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// src/ui/pairing-card.ts
|
|
1171
|
+
function resolvePairingCardTheme(theme) {
|
|
1172
|
+
return resolveCrosslinkTheme({
|
|
1173
|
+
...theme,
|
|
1174
|
+
backgroundColor: theme.backgroundColor ?? (theme.appearance === "light" ? "#ffffff" : "#000000")
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
var PAIRING_CARD_STYLES = `
|
|
1178
|
+
.cl-pair-card {
|
|
1179
|
+
--cl-bg: #000000;
|
|
1180
|
+
--cl-fg: #ffffff;
|
|
1181
|
+
--cl-muted: #9a9a9a;
|
|
1182
|
+
--cl-divider: #2a2a2a;
|
|
1183
|
+
--cl-pill: #e7e7ea;
|
|
1184
|
+
--cl-pill-text: #0a0a0a;
|
|
1185
|
+
--cl-border: 1px solid #000000;
|
|
1186
|
+
--cl-radius: 28px;
|
|
1187
|
+
--cl-accent: #38bdf8;
|
|
1188
|
+
position: relative;
|
|
1189
|
+
width: 100%;
|
|
1190
|
+
max-width: 100%;
|
|
1191
|
+
min-width: 0;
|
|
1192
|
+
background: var(--cl-bg);
|
|
1193
|
+
color: var(--cl-fg);
|
|
1194
|
+
border: var(--cl-border);
|
|
1195
|
+
border-radius: var(--cl-radius);
|
|
1196
|
+
padding: 28px 32px;
|
|
1197
|
+
margin: 20px 0;
|
|
1198
|
+
flex-shrink: 0;
|
|
1199
|
+
display: grid;
|
|
1200
|
+
grid-template-columns: 1.1fr auto 1fr auto 1fr;
|
|
1201
|
+
align-items: center;
|
|
1202
|
+
gap: 28px 28px;
|
|
1203
|
+
box-sizing: border-box;
|
|
1204
|
+
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
1205
|
+
text-align: left;
|
|
1206
|
+
}
|
|
1207
|
+
.cl-pair-card * {
|
|
1208
|
+
box-sizing: border-box;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/* \u2500\u2500 Route summary in the settings popover \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1212
|
+
.cl-route-summary {
|
|
1213
|
+
border-top: 1px solid var(--cl-divider);
|
|
1214
|
+
margin-top: 6px;
|
|
1215
|
+
padding: 8px 12px 4px;
|
|
1216
|
+
color: var(--cl-muted);
|
|
1217
|
+
font-size: 12px;
|
|
1218
|
+
line-height: 1.45;
|
|
1219
|
+
}
|
|
1220
|
+
.cl-route-summary p {
|
|
1221
|
+
margin: 0 0 4px;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/* \u2500\u2500 Cog Button \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1225
|
+
.cl-cog-btn {
|
|
1226
|
+
position: absolute;
|
|
1227
|
+
top: 14px;
|
|
1228
|
+
right: 16px;
|
|
1229
|
+
background: transparent;
|
|
1230
|
+
border: none;
|
|
1231
|
+
color: var(--cl-muted);
|
|
1232
|
+
cursor: pointer;
|
|
1233
|
+
padding: 6px;
|
|
1234
|
+
border-radius: 8px;
|
|
1235
|
+
display: flex;
|
|
1236
|
+
align-items: center;
|
|
1237
|
+
justify-content: center;
|
|
1238
|
+
transition: color 0.15s, background 0.15s;
|
|
1239
|
+
z-index: 20;
|
|
1240
|
+
}
|
|
1241
|
+
.cl-cog-btn:hover {
|
|
1242
|
+
color: var(--cl-fg);
|
|
1243
|
+
background: rgba(255, 255, 255, 0.1);
|
|
1244
|
+
}
|
|
1245
|
+
.cl-cog-btn svg {
|
|
1246
|
+
width: 17px;
|
|
1247
|
+
height: 17px;
|
|
1248
|
+
display: block;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/* \u2500\u2500 Small Dropdown Menu \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1252
|
+
/* \u2500\u2500 Pending state while a setting is applied \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1253
|
+
/* A mode change is a round trip to the host \u2014 it asks a router for a mapping,
|
|
1254
|
+
or re-derives which routes to advertise \u2014 so the popover says it is working
|
|
1255
|
+
rather than looking like the click did nothing. */
|
|
1256
|
+
.cl-spinner {
|
|
1257
|
+
width: 12px;
|
|
1258
|
+
height: 12px;
|
|
1259
|
+
border-radius: 50%;
|
|
1260
|
+
border: 2px solid currentColor;
|
|
1261
|
+
border-right-color: transparent;
|
|
1262
|
+
display: inline-block;
|
|
1263
|
+
flex-shrink: 0;
|
|
1264
|
+
animation: clSpin 0.7s linear infinite;
|
|
1265
|
+
}
|
|
1266
|
+
@keyframes clSpin {
|
|
1267
|
+
to { transform: rotate(360deg); }
|
|
1268
|
+
}
|
|
1269
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1270
|
+
.cl-spinner { animation-duration: 2.4s; }
|
|
1271
|
+
.cl-pair-refresh[data-busy="true"]::before { animation-duration: 2.4s; }
|
|
1272
|
+
}
|
|
1273
|
+
.cl-mode-pending {
|
|
1274
|
+
display: flex;
|
|
1275
|
+
align-items: center;
|
|
1276
|
+
gap: 8px;
|
|
1277
|
+
padding: 8px 12px;
|
|
1278
|
+
font-size: 12px;
|
|
1279
|
+
color: var(--cl-muted);
|
|
1280
|
+
}
|
|
1281
|
+
.cl-mode-pending[hidden] {
|
|
1282
|
+
display: none;
|
|
1283
|
+
}
|
|
1284
|
+
/* Inputs stay in the DOM and keep their checked state; they simply refuse a
|
|
1285
|
+
second change until the first one has an answer. */
|
|
1286
|
+
.cl-settings-dropdown[data-pending="true"] .cl-dropdown-item {
|
|
1287
|
+
opacity: 0.55;
|
|
1288
|
+
pointer-events: none;
|
|
1289
|
+
}
|
|
1290
|
+
.cl-pair-refresh[data-busy="true"]::before {
|
|
1291
|
+
content: "";
|
|
1292
|
+
width: 12px;
|
|
1293
|
+
height: 12px;
|
|
1294
|
+
margin-right: 8px;
|
|
1295
|
+
border: 2px solid currentColor;
|
|
1296
|
+
border-right-color: transparent;
|
|
1297
|
+
border-radius: 50%;
|
|
1298
|
+
display: inline-block;
|
|
1299
|
+
vertical-align: -2px;
|
|
1300
|
+
animation: clSpin 0.7s linear infinite;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
.cl-settings-dropdown {
|
|
1304
|
+
position: absolute;
|
|
1305
|
+
top: 44px;
|
|
1306
|
+
right: 14px;
|
|
1307
|
+
width: 300px;
|
|
1308
|
+
background: var(--cl-bg);
|
|
1309
|
+
border: 1px solid var(--cl-divider);
|
|
1310
|
+
border-radius: 12px;
|
|
1311
|
+
padding: 8px;
|
|
1312
|
+
z-index: 30;
|
|
1313
|
+
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.75), 0 0 0 1px rgba(255, 255, 255, 0.08);
|
|
1314
|
+
animation: clDropdownFade 0.12s ease-out;
|
|
1315
|
+
display: flex;
|
|
1316
|
+
flex-direction: column;
|
|
1317
|
+
gap: 2px;
|
|
1318
|
+
}
|
|
1319
|
+
.cl-settings-dropdown[hidden] {
|
|
1320
|
+
display: none;
|
|
1321
|
+
}
|
|
1322
|
+
@keyframes clDropdownFade {
|
|
1323
|
+
from { opacity: 0; transform: translateY(-4px) scale(0.98); }
|
|
1324
|
+
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
1325
|
+
}
|
|
1326
|
+
.cl-dropdown-header {
|
|
1327
|
+
font-size: 11px;
|
|
1328
|
+
font-weight: 700;
|
|
1329
|
+
text-transform: uppercase;
|
|
1330
|
+
letter-spacing: 0.05em;
|
|
1331
|
+
color: var(--cl-muted);
|
|
1332
|
+
padding: 6px 8px 4px 8px;
|
|
1333
|
+
}
|
|
1334
|
+
.cl-dropdown-item {
|
|
1335
|
+
display: flex;
|
|
1336
|
+
align-items: center;
|
|
1337
|
+
justify-content: space-between;
|
|
1338
|
+
padding: 8px 10px;
|
|
1339
|
+
border-radius: 8px;
|
|
1340
|
+
cursor: pointer;
|
|
1341
|
+
font-size: 13px;
|
|
1342
|
+
color: var(--cl-fg);
|
|
1343
|
+
transition: background 0.12s;
|
|
1344
|
+
position: relative;
|
|
1345
|
+
user-select: none;
|
|
1346
|
+
}
|
|
1347
|
+
.cl-dropdown-item:hover {
|
|
1348
|
+
background: rgba(255, 255, 255, 0.08);
|
|
1349
|
+
}
|
|
1350
|
+
.cl-dropdown-label {
|
|
1351
|
+
display: flex;
|
|
1352
|
+
align-items: center;
|
|
1353
|
+
gap: 8px;
|
|
1354
|
+
flex: 1;
|
|
1355
|
+
}
|
|
1356
|
+
.cl-dropdown-label input[type="radio"] {
|
|
1357
|
+
accent-color: var(--cl-accent);
|
|
1358
|
+
cursor: pointer;
|
|
1359
|
+
margin: 0;
|
|
1360
|
+
}
|
|
1361
|
+
.cl-info-knob-wrap {
|
|
1362
|
+
position: relative;
|
|
1363
|
+
display: flex;
|
|
1364
|
+
align-items: center;
|
|
1365
|
+
justify-content: center;
|
|
1366
|
+
}
|
|
1367
|
+
.cl-info-knob {
|
|
1368
|
+
width: 18px;
|
|
1369
|
+
height: 18px;
|
|
1370
|
+
border-radius: 50%;
|
|
1371
|
+
background: rgba(255, 255, 255, 0.12);
|
|
1372
|
+
color: var(--cl-muted);
|
|
1373
|
+
font-size: 11px;
|
|
1374
|
+
font-weight: 700;
|
|
1375
|
+
font-family: inherit;
|
|
1376
|
+
display: inline-flex;
|
|
1377
|
+
align-items: center;
|
|
1378
|
+
justify-content: center;
|
|
1379
|
+
border: none;
|
|
1380
|
+
cursor: pointer;
|
|
1381
|
+
padding: 0;
|
|
1382
|
+
transition: background 0.15s, color 0.15s;
|
|
1383
|
+
}
|
|
1384
|
+
.cl-info-knob:hover,
|
|
1385
|
+
.cl-info-knob:focus {
|
|
1386
|
+
background: var(--cl-accent);
|
|
1387
|
+
color: #082f49;
|
|
1388
|
+
}
|
|
1389
|
+
/* Tooltip on hover / focus */
|
|
1390
|
+
.cl-dropdown-tooltip {
|
|
1391
|
+
position: absolute;
|
|
1392
|
+
right: 0;
|
|
1393
|
+
top: calc(100% + 6px);
|
|
1394
|
+
width: 250px;
|
|
1395
|
+
background: #020617;
|
|
1396
|
+
border: 1px solid var(--cl-divider);
|
|
1397
|
+
border-radius: 8px;
|
|
1398
|
+
padding: 10px 12px;
|
|
1399
|
+
font-size: 11px;
|
|
1400
|
+
line-height: 1.45;
|
|
1401
|
+
color: #cbd5e1;
|
|
1402
|
+
box-shadow: 0 8px 24px rgba(0,0,0,0.6);
|
|
1403
|
+
pointer-events: none;
|
|
1404
|
+
opacity: 0;
|
|
1405
|
+
visibility: hidden;
|
|
1406
|
+
transform: translateY(-2px);
|
|
1407
|
+
transition: opacity 0.15s, transform 0.15s, visibility 0.15s;
|
|
1408
|
+
z-index: 50;
|
|
1409
|
+
}
|
|
1410
|
+
.cl-info-knob-wrap:hover .cl-dropdown-tooltip,
|
|
1411
|
+
.cl-info-knob-wrap:focus-within .cl-dropdown-tooltip {
|
|
1412
|
+
opacity: 1;
|
|
1413
|
+
visibility: visible;
|
|
1414
|
+
pointer-events: auto;
|
|
1415
|
+
transform: translateY(0);
|
|
1416
|
+
}
|
|
1417
|
+
.cl-dropdown-tooltip a {
|
|
1418
|
+
color: var(--cl-accent);
|
|
1419
|
+
text-decoration: underline;
|
|
1420
|
+
text-underline-offset: 2px;
|
|
1421
|
+
display: inline-block;
|
|
1422
|
+
margin-top: 6px;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
/* \u2500\u2500 Columns \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1426
|
+
.cl-pair-left {
|
|
1427
|
+
display: flex;
|
|
1428
|
+
flex-direction: column;
|
|
1429
|
+
align-items: flex-start;
|
|
1430
|
+
}
|
|
1431
|
+
.cl-pair-logo {
|
|
1432
|
+
height: 24px;
|
|
1433
|
+
width: auto;
|
|
1434
|
+
max-width: 140px;
|
|
1435
|
+
display: block;
|
|
1436
|
+
/* Contrast-corrected in resolveCrosslinkTheme; currentColor carries it
|
|
1437
|
+
into the SVG path so one variable tints the whole mark. */
|
|
1438
|
+
color: var(--cl-logo, var(--cl-fg));
|
|
1439
|
+
}
|
|
1440
|
+
.cl-pair-logo-wrap {
|
|
1441
|
+
margin-bottom: 10px;
|
|
1442
|
+
}
|
|
1443
|
+
.cl-pair-app {
|
|
1444
|
+
display: flex;
|
|
1445
|
+
align-items: center;
|
|
1446
|
+
justify-content: center;
|
|
1447
|
+
gap: 8px;
|
|
1448
|
+
margin-bottom: 10px;
|
|
1449
|
+
}
|
|
1450
|
+
.cl-pair-app[hidden] {
|
|
1451
|
+
display: none;
|
|
1452
|
+
}
|
|
1453
|
+
.cl-pair-app-icon {
|
|
1454
|
+
width: 20px;
|
|
1455
|
+
height: 20px;
|
|
1456
|
+
border-radius: 5px;
|
|
1457
|
+
object-fit: cover;
|
|
1458
|
+
display: block;
|
|
1459
|
+
flex-shrink: 0;
|
|
1460
|
+
}
|
|
1461
|
+
.cl-pair-app-icon svg {
|
|
1462
|
+
width: 20px;
|
|
1463
|
+
height: 20px;
|
|
1464
|
+
display: block;
|
|
1465
|
+
}
|
|
1466
|
+
.cl-pair-app-name {
|
|
1467
|
+
font-size: 14px;
|
|
1468
|
+
font-weight: 600;
|
|
1469
|
+
color: var(--cl-fg);
|
|
1470
|
+
letter-spacing: -0.01em;
|
|
1471
|
+
}
|
|
1472
|
+
.cl-pair-status {
|
|
1473
|
+
font-size: 11px;
|
|
1474
|
+
color: var(--cl-muted);
|
|
1475
|
+
margin: 8px 0 0 0;
|
|
1476
|
+
text-align: center;
|
|
1477
|
+
min-height: 14px;
|
|
1478
|
+
}
|
|
1479
|
+
.cl-pair-status[hidden] {
|
|
1480
|
+
display: none;
|
|
1481
|
+
}
|
|
1482
|
+
.cl-pair-status-on {
|
|
1483
|
+
color: #4ade80;
|
|
1484
|
+
font-weight: 600;
|
|
1485
|
+
}
|
|
1486
|
+
.cl-pair-blurb {
|
|
1487
|
+
font-size: 13px;
|
|
1488
|
+
line-height: 1.55;
|
|
1489
|
+
color: var(--cl-muted);
|
|
1490
|
+
max-width: 32ch;
|
|
1491
|
+
margin: 0;
|
|
1492
|
+
}
|
|
1493
|
+
.cl-pair-blurb strong {
|
|
1494
|
+
color: var(--cl-fg);
|
|
1495
|
+
font-weight: 600;
|
|
1496
|
+
}
|
|
1497
|
+
.cl-pair-refresh {
|
|
1498
|
+
appearance: none;
|
|
1499
|
+
background: none;
|
|
1500
|
+
border: none;
|
|
1501
|
+
color: var(--cl-muted);
|
|
1502
|
+
font: inherit;
|
|
1503
|
+
font-size: 12px;
|
|
1504
|
+
text-decoration: underline;
|
|
1505
|
+
text-underline-offset: 2px;
|
|
1506
|
+
cursor: pointer;
|
|
1507
|
+
padding: 0;
|
|
1508
|
+
margin-top: 14px;
|
|
1509
|
+
transition: color 0.15s;
|
|
1510
|
+
}
|
|
1511
|
+
.cl-pair-refresh:hover {
|
|
1512
|
+
color: var(--cl-fg);
|
|
1513
|
+
}
|
|
1514
|
+
.cl-pair-refresh:disabled {
|
|
1515
|
+
opacity: 0.5;
|
|
1516
|
+
cursor: default;
|
|
1517
|
+
}
|
|
1518
|
+
.cl-pair-divider {
|
|
1519
|
+
width: 1px;
|
|
1520
|
+
align-self: stretch;
|
|
1521
|
+
background: var(--cl-divider);
|
|
1522
|
+
}
|
|
1523
|
+
.cl-pair-label {
|
|
1524
|
+
font-size: 12px;
|
|
1525
|
+
font-weight: 700;
|
|
1526
|
+
text-transform: none;
|
|
1527
|
+
color: var(--cl-fg);
|
|
1528
|
+
margin: 0 0 14px 0;
|
|
1529
|
+
text-align: center;
|
|
1530
|
+
}
|
|
1531
|
+
.cl-pair-center,
|
|
1532
|
+
.cl-pair-right {
|
|
1533
|
+
display: flex;
|
|
1534
|
+
flex-direction: column;
|
|
1535
|
+
align-items: center;
|
|
1536
|
+
}
|
|
1537
|
+
.cl-qr-wrap {
|
|
1538
|
+
background: #ffffff;
|
|
1539
|
+
border-radius: 16px;
|
|
1540
|
+
padding: 12px;
|
|
1541
|
+
min-width: 176px;
|
|
1542
|
+
min-height: 176px;
|
|
1543
|
+
width: 176px;
|
|
1544
|
+
height: 176px;
|
|
1545
|
+
display: flex;
|
|
1546
|
+
align-items: center;
|
|
1547
|
+
justify-content: center;
|
|
1548
|
+
overflow: hidden;
|
|
1549
|
+
}
|
|
1550
|
+
.cl-qr-wrap svg {
|
|
1551
|
+
width: 152px;
|
|
1552
|
+
height: 152px;
|
|
1553
|
+
display: block;
|
|
1554
|
+
}
|
|
1555
|
+
.cl-qr-wrap img {
|
|
1556
|
+
width: 152px;
|
|
1557
|
+
height: 152px;
|
|
1558
|
+
display: block;
|
|
1559
|
+
border-radius: 8px;
|
|
1560
|
+
}
|
|
1561
|
+
.cl-qr-placeholder {
|
|
1562
|
+
color: #6b6b6b;
|
|
1563
|
+
font-size: 12px;
|
|
1564
|
+
text-align: center;
|
|
1565
|
+
max-width: 140px;
|
|
1566
|
+
line-height: 1.4;
|
|
1567
|
+
}
|
|
1568
|
+
.cl-qr-placeholder.cl-error {
|
|
1569
|
+
color: #f87171;
|
|
1570
|
+
}
|
|
1571
|
+
.cl-error-code {
|
|
1572
|
+
color: #991b1b;
|
|
1573
|
+
font: 700 18px/1.2 "SF Mono", "Fira Code", monospace;
|
|
1574
|
+
letter-spacing: 0.04em;
|
|
1575
|
+
}
|
|
1576
|
+
.cl-error-details-btn {
|
|
1577
|
+
appearance: none;
|
|
1578
|
+
border: 0;
|
|
1579
|
+
background: transparent;
|
|
1580
|
+
color: #334155;
|
|
1581
|
+
cursor: pointer;
|
|
1582
|
+
font: 600 11px/1.2 inherit;
|
|
1583
|
+
padding: 6px;
|
|
1584
|
+
text-decoration: underline;
|
|
1585
|
+
text-underline-offset: 2px;
|
|
1586
|
+
}
|
|
1587
|
+
.cl-skeleton {
|
|
1588
|
+
position: relative;
|
|
1589
|
+
overflow: hidden;
|
|
1590
|
+
background: #e2e8f0;
|
|
1591
|
+
}
|
|
1592
|
+
.cl-skeleton::after {
|
|
1593
|
+
content: "";
|
|
1594
|
+
position: absolute;
|
|
1595
|
+
inset: 0;
|
|
1596
|
+
transform: translateX(-100%);
|
|
1597
|
+
background: linear-gradient(90deg, transparent, rgba(255,255,255,.8), transparent);
|
|
1598
|
+
animation: clSkeletonSweep 1.35s ease-in-out infinite;
|
|
1599
|
+
}
|
|
1600
|
+
.cl-qr-skeleton {
|
|
1601
|
+
width: 132px;
|
|
1602
|
+
height: 132px;
|
|
1603
|
+
border-radius: 10px;
|
|
1604
|
+
}
|
|
1605
|
+
.cl-pill.cl-pill-skeleton {
|
|
1606
|
+
min-width: 44px;
|
|
1607
|
+
height: 52px;
|
|
1608
|
+
padding: 0;
|
|
1609
|
+
}
|
|
1610
|
+
@keyframes clSkeletonSweep {
|
|
1611
|
+
100% { transform: translateX(100%); }
|
|
1612
|
+
}
|
|
1613
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1614
|
+
.cl-skeleton::after { animation: none; }
|
|
1615
|
+
}
|
|
1616
|
+
.cl-pair-code-pills {
|
|
1617
|
+
display: grid;
|
|
1618
|
+
grid-template-columns: repeat(3, 1fr);
|
|
1619
|
+
gap: 8px;
|
|
1620
|
+
max-width: 200px;
|
|
1621
|
+
justify-content: center;
|
|
1622
|
+
min-height: 44px;
|
|
1623
|
+
align-items: center;
|
|
1624
|
+
}
|
|
1625
|
+
.cl-pair-code-pills .cl-pill {
|
|
1626
|
+
background: var(--cl-pill);
|
|
1627
|
+
color: var(--cl-pill-text);
|
|
1628
|
+
font-family: "SF Mono", "Fira Code", monospace;
|
|
1629
|
+
font-size: 24px;
|
|
1630
|
+
font-weight: 700;
|
|
1631
|
+
line-height: 1;
|
|
1632
|
+
border-radius: 12px;
|
|
1633
|
+
padding: 14px 8px;
|
|
1634
|
+
min-width: 44px;
|
|
1635
|
+
text-align: center;
|
|
1636
|
+
display: block;
|
|
1637
|
+
}
|
|
1638
|
+
.cl-pair-hint {
|
|
1639
|
+
font-size: 11px;
|
|
1640
|
+
color: var(--cl-muted);
|
|
1641
|
+
margin: 12px 0 0 0;
|
|
1642
|
+
text-align: center;
|
|
1643
|
+
}
|
|
1644
|
+
@media (max-width: 860px) {
|
|
1645
|
+
.cl-pair-card {
|
|
1646
|
+
grid-template-columns: 1fr;
|
|
1647
|
+
text-align: center;
|
|
1648
|
+
padding: 20px 24px;
|
|
1649
|
+
gap: 20px;
|
|
1650
|
+
}
|
|
1651
|
+
.cl-pair-left,
|
|
1652
|
+
.cl-pair-center,
|
|
1653
|
+
.cl-pair-right {
|
|
1654
|
+
min-width: 0;
|
|
1655
|
+
}
|
|
1656
|
+
.cl-pair-left {
|
|
1657
|
+
align-items: center;
|
|
1658
|
+
}
|
|
1659
|
+
.cl-pair-card .cl-pair-divider {
|
|
1660
|
+
width: 100%;
|
|
1661
|
+
height: 1px;
|
|
1662
|
+
}
|
|
1663
|
+
.cl-pair-logo {
|
|
1664
|
+
margin-left: auto;
|
|
1665
|
+
margin-right: auto;
|
|
1666
|
+
}
|
|
1667
|
+
.cl-pair-blurb {
|
|
1668
|
+
max-width: none;
|
|
1669
|
+
overflow-wrap: anywhere;
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
/* \u2500\u2500 Connected Devices Modal \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1674
|
+
.cl-connected-modal-backdrop {
|
|
1675
|
+
position: fixed;
|
|
1676
|
+
top: 0;
|
|
1677
|
+
left: 0;
|
|
1678
|
+
width: 100vw;
|
|
1679
|
+
height: 100vh;
|
|
1680
|
+
background: rgba(0, 0, 0, 0.75);
|
|
1681
|
+
z-index: 100;
|
|
1682
|
+
display: flex;
|
|
1683
|
+
align-items: center;
|
|
1684
|
+
justify-content: center;
|
|
1685
|
+
animation: clDropdownFade 0.15s ease-out;
|
|
1686
|
+
}
|
|
1687
|
+
.cl-error-modal {
|
|
1688
|
+
max-width: 480px;
|
|
1689
|
+
}
|
|
1690
|
+
.cl-error-modal code {
|
|
1691
|
+
color: #fca5a5;
|
|
1692
|
+
font-size: 12px;
|
|
1693
|
+
}
|
|
1694
|
+
.cl-error-modal p {
|
|
1695
|
+
color: #cbd5e1;
|
|
1696
|
+
font-size: 13px;
|
|
1697
|
+
line-height: 1.55;
|
|
1698
|
+
margin: 0;
|
|
1699
|
+
overflow-wrap: anywhere;
|
|
1700
|
+
}
|
|
1701
|
+
.cl-connected-modal {
|
|
1702
|
+
background: #0a0a0a;
|
|
1703
|
+
border: 1px solid var(--cl-divider);
|
|
1704
|
+
border-radius: 20px;
|
|
1705
|
+
width: 92vw;
|
|
1706
|
+
max-width: 640px;
|
|
1707
|
+
max-height: 85vh;
|
|
1708
|
+
padding: 24px 28px;
|
|
1709
|
+
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.9), 0 0 0 1px rgba(255, 255, 255, 0.06);
|
|
1710
|
+
overflow-y: auto;
|
|
1711
|
+
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
1712
|
+
color: var(--cl-fg);
|
|
1713
|
+
}
|
|
1714
|
+
.cl-modal-header {
|
|
1715
|
+
display: flex;
|
|
1716
|
+
align-items: center;
|
|
1717
|
+
justify-content: space-between;
|
|
1718
|
+
margin-bottom: 16px;
|
|
1719
|
+
gap: 16px;
|
|
1720
|
+
}
|
|
1721
|
+
.cl-modal-header h3 {
|
|
1722
|
+
margin: 0;
|
|
1723
|
+
font-size: 18px;
|
|
1724
|
+
font-weight: 700;
|
|
1725
|
+
letter-spacing: -0.02em;
|
|
1726
|
+
}
|
|
1727
|
+
.cl-modal-header button {
|
|
1728
|
+
background: rgba(255, 255, 255, 0.08);
|
|
1729
|
+
border: 1px solid var(--cl-divider);
|
|
1730
|
+
border-radius: 10px;
|
|
1731
|
+
color: var(--cl-fg);
|
|
1732
|
+
font-size: 20px;
|
|
1733
|
+
line-height: 1;
|
|
1734
|
+
padding: 4px 10px;
|
|
1735
|
+
cursor: pointer;
|
|
1736
|
+
transition: background 0.15s;
|
|
1737
|
+
}
|
|
1738
|
+
.cl-modal-header button:hover {
|
|
1739
|
+
background: rgba(255, 255, 255, 0.15);
|
|
1740
|
+
}
|
|
1741
|
+
.cl-modal-body {
|
|
1742
|
+
display: flex;
|
|
1743
|
+
flex-direction: column;
|
|
1744
|
+
gap: 10px;
|
|
1745
|
+
}
|
|
1746
|
+
.cl-modal-error {
|
|
1747
|
+
color: #f87171;
|
|
1748
|
+
font-size: 12px;
|
|
1749
|
+
padding: 8px 0;
|
|
1750
|
+
}
|
|
1751
|
+
.cl-device-card {
|
|
1752
|
+
background: #121212;
|
|
1753
|
+
border: 1px solid var(--cl-divider);
|
|
1754
|
+
border-radius: 14px;
|
|
1755
|
+
padding: 16px 18px;
|
|
1756
|
+
display: flex;
|
|
1757
|
+
align-items: flex-start;
|
|
1758
|
+
justify-content: space-between;
|
|
1759
|
+
gap: 16px;
|
|
1760
|
+
}
|
|
1761
|
+
.cl-device-info {
|
|
1762
|
+
flex: 1;
|
|
1763
|
+
min-width: 0;
|
|
1764
|
+
}
|
|
1765
|
+
.cl-device-name {
|
|
1766
|
+
font-weight: 600;
|
|
1767
|
+
font-size: 15px;
|
|
1768
|
+
margin-bottom: 4px;
|
|
1769
|
+
letter-spacing: -0.01em;
|
|
1770
|
+
}
|
|
1771
|
+
.cl-device-meta {
|
|
1772
|
+
font-size: 11px;
|
|
1773
|
+
color: var(--cl-muted);
|
|
1774
|
+
margin-bottom: 8px;
|
|
1775
|
+
text-transform: capitalize;
|
|
1776
|
+
}
|
|
1777
|
+
.cl-device-detail {
|
|
1778
|
+
font-size: 11px;
|
|
1779
|
+
line-height: 1.5;
|
|
1780
|
+
color: #c4c4c4;
|
|
1781
|
+
}
|
|
1782
|
+
.cl-device-detail strong {
|
|
1783
|
+
color: var(--cl-muted);
|
|
1784
|
+
font-weight: 600;
|
|
1785
|
+
}
|
|
1786
|
+
.cl-device-actions {
|
|
1787
|
+
flex-shrink: 0;
|
|
1788
|
+
}
|
|
1789
|
+
.cl-revoke-btn {
|
|
1790
|
+
background: #1a1a2e;
|
|
1791
|
+
border: 1px solid #2a2a3a;
|
|
1792
|
+
color: #e7e7ea;
|
|
1793
|
+
border-radius: 8px;
|
|
1794
|
+
padding: 7px 14px;
|
|
1795
|
+
font-size: 12px;
|
|
1796
|
+
font-weight: 600;
|
|
1797
|
+
cursor: pointer;
|
|
1798
|
+
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
|
1799
|
+
}
|
|
1800
|
+
.cl-revoke-btn:hover {
|
|
1801
|
+
background: #7f1d1d;
|
|
1802
|
+
border-color: #f87171;
|
|
1803
|
+
color: #f87171;
|
|
1804
|
+
}
|
|
1805
|
+
`.trim();
|
|
1806
|
+
var stylesInjected = false;
|
|
1807
|
+
function injectPairingCardStyles() {
|
|
1808
|
+
if (stylesInjected || typeof document === "undefined") return;
|
|
1809
|
+
const styleEl = document.createElement("style");
|
|
1810
|
+
styleEl.id = "crosslink-pairing-card-styles";
|
|
1811
|
+
styleEl.textContent = PAIRING_CARD_STYLES;
|
|
1812
|
+
document.head.appendChild(styleEl);
|
|
1813
|
+
stylesInjected = true;
|
|
1814
|
+
}
|
|
1815
|
+
var PairingCard = class {
|
|
1816
|
+
element;
|
|
1817
|
+
options;
|
|
1818
|
+
logoEl;
|
|
1819
|
+
blurbEl;
|
|
1820
|
+
refreshBtn;
|
|
1821
|
+
qrWrapEl;
|
|
1822
|
+
codePillsEl;
|
|
1823
|
+
hintEl;
|
|
1824
|
+
settingsPopover;
|
|
1825
|
+
routeSummaryEl;
|
|
1826
|
+
modePendingEl;
|
|
1827
|
+
appRowEl;
|
|
1828
|
+
statusEl;
|
|
1829
|
+
brand;
|
|
1830
|
+
currentMode;
|
|
1831
|
+
expiryTimer = null;
|
|
1832
|
+
source = null;
|
|
1833
|
+
refreshTimer = null;
|
|
1834
|
+
unsubscribeSource = null;
|
|
1835
|
+
destroyed = false;
|
|
1836
|
+
inFlight = null;
|
|
1837
|
+
refreshQueued = false;
|
|
1838
|
+
connectedDevices = /* @__PURE__ */ new Set();
|
|
1839
|
+
constructor(options = {}) {
|
|
1840
|
+
this.options = options;
|
|
1841
|
+
this.brand = resolvePairingCardTheme({
|
|
1842
|
+
appName: options.appName,
|
|
1843
|
+
appIcon: options.appIcon,
|
|
1844
|
+
...options.brand
|
|
1845
|
+
});
|
|
1846
|
+
this.currentMode = normalizeNetworkMode(options.networkMode);
|
|
1847
|
+
if (options.injectStyles !== false) {
|
|
1848
|
+
injectPairingCardStyles();
|
|
1849
|
+
}
|
|
1850
|
+
this.element = document.createElement("div");
|
|
1851
|
+
this.element.className = "cl-pair-card";
|
|
1852
|
+
this.applyBrand();
|
|
1853
|
+
this.applyTheme(options.theme);
|
|
1854
|
+
const cogBtn = document.createElement("button");
|
|
1855
|
+
cogBtn.className = "cl-cog-btn";
|
|
1856
|
+
cogBtn.title = "Connection Settings";
|
|
1857
|
+
cogBtn.setAttribute("aria-label", "Connection Settings");
|
|
1858
|
+
cogBtn.innerHTML = `
|
|
1859
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
1860
|
+
<circle cx="12" cy="12" r="3"/>
|
|
1861
|
+
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
|
1862
|
+
</svg>
|
|
1863
|
+
`;
|
|
1864
|
+
cogBtn.addEventListener("click", () => this.toggleSettings());
|
|
1865
|
+
this.settingsPopover = this.createSettingsPopover();
|
|
1866
|
+
const left = document.createElement("div");
|
|
1867
|
+
left.className = "cl-pair-left";
|
|
1868
|
+
this.logoEl = document.createElement("div");
|
|
1869
|
+
this.logoEl.className = "cl-pair-logo-wrap";
|
|
1870
|
+
this.renderBrandMark();
|
|
1871
|
+
this.appRowEl = document.createElement("div");
|
|
1872
|
+
this.appRowEl.className = "cl-pair-app";
|
|
1873
|
+
this.renderAppRow();
|
|
1874
|
+
this.blurbEl = document.createElement("p");
|
|
1875
|
+
this.blurbEl.className = "cl-pair-blurb";
|
|
1876
|
+
this.blurbEl.innerHTML = options.blurb ?? `<strong>Connect another device</strong> with Crosslink. The framework authenticates trusted devices and protects application data end to end across supported transports.`;
|
|
1877
|
+
this.refreshBtn = document.createElement("button");
|
|
1878
|
+
this.refreshBtn.className = "cl-pair-refresh";
|
|
1879
|
+
this.refreshBtn.textContent = "Refresh code";
|
|
1880
|
+
this.refreshBtn.addEventListener("click", () => void this.refresh());
|
|
1881
|
+
left.appendChild(this.logoEl);
|
|
1882
|
+
left.appendChild(this.blurbEl);
|
|
1883
|
+
left.appendChild(this.refreshBtn);
|
|
1884
|
+
const div1 = document.createElement("div");
|
|
1885
|
+
div1.className = "cl-pair-divider";
|
|
1886
|
+
const center = document.createElement("div");
|
|
1887
|
+
center.className = "cl-pair-center";
|
|
1888
|
+
const qrLabel = document.createElement("h3");
|
|
1889
|
+
qrLabel.className = "cl-pair-label";
|
|
1890
|
+
qrLabel.textContent = "Scan this on your device";
|
|
1891
|
+
this.qrWrapEl = document.createElement("div");
|
|
1892
|
+
this.qrWrapEl.className = "cl-qr-wrap";
|
|
1893
|
+
this.renderQr(options.qr);
|
|
1894
|
+
center.appendChild(this.appRowEl);
|
|
1895
|
+
center.appendChild(qrLabel);
|
|
1896
|
+
center.appendChild(this.qrWrapEl);
|
|
1897
|
+
const div2 = document.createElement("div");
|
|
1898
|
+
div2.className = "cl-pair-divider";
|
|
1899
|
+
const right = document.createElement("div");
|
|
1900
|
+
right.className = "cl-pair-right";
|
|
1901
|
+
const codeLabel = document.createElement("h3");
|
|
1902
|
+
codeLabel.className = "cl-pair-label";
|
|
1903
|
+
codeLabel.textContent = "Pairing Code";
|
|
1904
|
+
this.codePillsEl = document.createElement("div");
|
|
1905
|
+
this.codePillsEl.className = "cl-pair-code-pills";
|
|
1906
|
+
this.renderCode(options.code);
|
|
1907
|
+
this.hintEl = document.createElement("p");
|
|
1908
|
+
this.hintEl.className = "cl-pair-hint";
|
|
1909
|
+
this.renderExpiry(options.expiresAt);
|
|
1910
|
+
this.statusEl = document.createElement("p");
|
|
1911
|
+
this.statusEl.className = "cl-pair-status";
|
|
1912
|
+
this.statusEl.setAttribute("role", "status");
|
|
1913
|
+
this.statusEl.setAttribute("aria-live", "polite");
|
|
1914
|
+
this.renderStatus(options.status ?? null, false);
|
|
1915
|
+
right.appendChild(codeLabel);
|
|
1916
|
+
right.appendChild(this.codePillsEl);
|
|
1917
|
+
right.appendChild(this.hintEl);
|
|
1918
|
+
right.appendChild(this.statusEl);
|
|
1919
|
+
this.element.appendChild(cogBtn);
|
|
1920
|
+
this.element.appendChild(this.settingsPopover);
|
|
1921
|
+
this.element.appendChild(left);
|
|
1922
|
+
this.element.appendChild(div1);
|
|
1923
|
+
this.element.appendChild(center);
|
|
1924
|
+
this.element.appendChild(div2);
|
|
1925
|
+
this.element.appendChild(right);
|
|
1926
|
+
if (options.target) {
|
|
1927
|
+
this.mount(options.target);
|
|
1928
|
+
}
|
|
1929
|
+
if (typeof document !== "undefined" && typeof document.addEventListener === "function") {
|
|
1930
|
+
document.addEventListener("click", (e) => {
|
|
1931
|
+
if (!this.element.contains(e.target)) {
|
|
1932
|
+
this.toggleSettings(false);
|
|
1933
|
+
}
|
|
1934
|
+
});
|
|
1935
|
+
}
|
|
1936
|
+
if (options.source !== false) this.attachSource(options.source ?? true);
|
|
1937
|
+
}
|
|
1938
|
+
/* ------------------- self-driving session lifecycle ------------------ */
|
|
1939
|
+
/**
|
|
1940
|
+
* Connects the card to a session source and starts the loop.
|
|
1941
|
+
*
|
|
1942
|
+
* Called from the constructor when `options.source` is set. Split out so a
|
|
1943
|
+
* card built before its transport exists — an Electron renderer waiting for
|
|
1944
|
+
* a preload bridge, say — can start driving later without a second class.
|
|
1945
|
+
*/
|
|
1946
|
+
attachSource(source) {
|
|
1947
|
+
this.source = typeof source === "object" ? source : createHttpPairingSource(source === true ? void 0 : source);
|
|
1948
|
+
this.options.devicesEndpoint ??= this.source.devicesEndpoint;
|
|
1949
|
+
this.options.revokeEndpoint ??= this.source.revokeEndpoint;
|
|
1950
|
+
this.unsubscribeSource = this.source.subscribe?.((event) => this.handleSourceEvent(event)) ?? null;
|
|
1951
|
+
void this.refresh();
|
|
1952
|
+
}
|
|
1953
|
+
/**
|
|
1954
|
+
* Mints a fresh pairing session and renders it.
|
|
1955
|
+
*
|
|
1956
|
+
* Concurrent calls collapse onto the one in flight. The expiry timer, the
|
|
1957
|
+
* refresh button and a host-side invalidation can all fire inside the same
|
|
1958
|
+
* second, and three codes minted back to back would invalidate two of them
|
|
1959
|
+
* before anybody could finish scanning.
|
|
1960
|
+
*
|
|
1961
|
+
* Collapsing cannot simply drop the extra calls, though. A request that
|
|
1962
|
+
* arrives mid-flight may be the one that matters — the host reporting that a
|
|
1963
|
+
* device just redeemed the code being minted — so it is remembered and run
|
|
1964
|
+
* once the current mint settles, leaving the card showing a live code rather
|
|
1965
|
+
* than one that is already spent.
|
|
1966
|
+
*/
|
|
1967
|
+
async refresh() {
|
|
1968
|
+
if (!this.source) {
|
|
1969
|
+
await this.handleRefresh();
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
if (this.destroyed) return;
|
|
1973
|
+
if (this.inFlight) {
|
|
1974
|
+
this.refreshQueued = true;
|
|
1975
|
+
return this.inFlight;
|
|
1976
|
+
}
|
|
1977
|
+
this.inFlight = this.mintSession().finally(() => {
|
|
1978
|
+
this.inFlight = null;
|
|
1979
|
+
if (this.refreshQueued && !this.destroyed) {
|
|
1980
|
+
this.refreshQueued = false;
|
|
1981
|
+
void this.refresh();
|
|
1982
|
+
}
|
|
1983
|
+
});
|
|
1984
|
+
return this.inFlight;
|
|
1985
|
+
}
|
|
1986
|
+
async mintSession() {
|
|
1987
|
+
const source = this.source;
|
|
1988
|
+
if (!source) return;
|
|
1989
|
+
this.clearRefreshTimer();
|
|
1990
|
+
this.update({ loading: true, error: null });
|
|
1991
|
+
try {
|
|
1992
|
+
const session = await source.getSession(this.currentMode);
|
|
1993
|
+
if (this.destroyed) return;
|
|
1994
|
+
if (session.application) this.adoptHostApplication(session.application);
|
|
1995
|
+
if (session.networkMode) this.currentMode = normalizeNetworkMode(session.networkMode);
|
|
1996
|
+
this.update({
|
|
1997
|
+
loading: false,
|
|
1998
|
+
error: null,
|
|
1999
|
+
qr: session.qrSvg ?? null,
|
|
2000
|
+
code: session.code,
|
|
2001
|
+
expiresAt: session.expiresAt,
|
|
2002
|
+
networkMode: this.currentMode,
|
|
2003
|
+
endpoints: session.endpoints ?? null,
|
|
2004
|
+
remoteNote: session.remoteNote ?? null,
|
|
2005
|
+
status: this.sessionStatusText(),
|
|
2006
|
+
connected: this.connectedDevices.size > 0
|
|
2007
|
+
});
|
|
2008
|
+
this.scheduleSessionRefresh(session.expiresAt);
|
|
2009
|
+
this.options.onSession?.(session);
|
|
2010
|
+
} catch (err) {
|
|
2011
|
+
if (this.destroyed) return;
|
|
2012
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
2013
|
+
this.update({
|
|
2014
|
+
loading: false,
|
|
2015
|
+
error: error.message || "Crosslink could not create a pairing code.",
|
|
2016
|
+
errorCode: error.code ?? "CL-P001"
|
|
2017
|
+
});
|
|
2018
|
+
this.options.onError?.(error);
|
|
2019
|
+
this.refreshTimer = setTimeout(() => void this.refresh(), 5e3);
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
/**
|
|
2023
|
+
* Takes the application's identity from the host.
|
|
2024
|
+
*
|
|
2025
|
+
* `createCrosslinkServer` is already configured with an `application` block —
|
|
2026
|
+
* name, icon, accent, background — so a page that mounts the card needs to
|
|
2027
|
+
* repeat none of it. Options still win where they are given: an application
|
|
2028
|
+
* that wants the pairing screen to differ from its host metadata says so
|
|
2029
|
+
* explicitly, and nothing here overrides that.
|
|
2030
|
+
*/
|
|
2031
|
+
adoptHostApplication(app) {
|
|
2032
|
+
const explicit = {
|
|
2033
|
+
appName: this.options.appName,
|
|
2034
|
+
appIcon: this.options.appIcon,
|
|
2035
|
+
...this.options.brand
|
|
2036
|
+
};
|
|
2037
|
+
const merged = {
|
|
2038
|
+
appName: explicit.appName ?? app.name,
|
|
2039
|
+
appIcon: explicit.appIcon ?? app.icon ?? void 0,
|
|
2040
|
+
accentColor: explicit.accentColor ?? app.accentColor,
|
|
2041
|
+
backgroundColor: explicit.backgroundColor ?? app.backgroundColor,
|
|
2042
|
+
textColor: explicit.textColor ?? app.textColor,
|
|
2043
|
+
appearance: explicit.appearance ?? app.appearance
|
|
2044
|
+
};
|
|
2045
|
+
const resolved = resolvePairingCardTheme(merged);
|
|
2046
|
+
if (JSON.stringify(resolved) === JSON.stringify(this.brand)) return;
|
|
2047
|
+
this.brand = resolved;
|
|
2048
|
+
this.applyBrand();
|
|
2049
|
+
this.renderAppRow();
|
|
2050
|
+
}
|
|
2051
|
+
sessionStatusText() {
|
|
2052
|
+
const count = this.connectedDevices.size;
|
|
2053
|
+
if (count === 0) return "Waiting for a device to scan";
|
|
2054
|
+
return count === 1 ? "Device connected" : `${count} devices connected`;
|
|
2055
|
+
}
|
|
2056
|
+
scheduleSessionRefresh(expiresAt) {
|
|
2057
|
+
this.clearRefreshTimer();
|
|
2058
|
+
const lead = (this.options.refreshLeadSeconds ?? 15) * 1e3;
|
|
2059
|
+
const delay = Math.max(1e3, expiresAt - Date.now() - lead);
|
|
2060
|
+
this.refreshTimer = setTimeout(() => void this.refresh(), delay);
|
|
2061
|
+
}
|
|
2062
|
+
clearRefreshTimer() {
|
|
2063
|
+
if (this.refreshTimer !== null) {
|
|
2064
|
+
clearTimeout(this.refreshTimer);
|
|
2065
|
+
this.refreshTimer = null;
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
handleSourceEvent(event) {
|
|
2069
|
+
if (this.destroyed) return;
|
|
2070
|
+
switch (event.type) {
|
|
2071
|
+
case "invalidate":
|
|
2072
|
+
void this.refresh();
|
|
2073
|
+
break;
|
|
2074
|
+
case "connected":
|
|
2075
|
+
this.connectedDevices.add(event.deviceId ?? "device");
|
|
2076
|
+
this.update({ status: this.sessionStatusText(), connected: true });
|
|
2077
|
+
this.options.onDeviceConnected?.(event.deviceId);
|
|
2078
|
+
break;
|
|
2079
|
+
case "disconnected":
|
|
2080
|
+
this.connectedDevices.delete(event.deviceId ?? "device");
|
|
2081
|
+
this.update({
|
|
2082
|
+
status: this.sessionStatusText(),
|
|
2083
|
+
connected: this.connectedDevices.size > 0
|
|
2084
|
+
});
|
|
2085
|
+
break;
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
normalizeMode(mode) {
|
|
2089
|
+
return normalizeNetworkMode(mode);
|
|
2090
|
+
}
|
|
2091
|
+
createSettingsPopover() {
|
|
2092
|
+
const pop = document.createElement("div");
|
|
2093
|
+
pop.className = "cl-settings-dropdown";
|
|
2094
|
+
pop.hidden = true;
|
|
2095
|
+
const remoteUrl = this.options.remoteGuideUrl || "https://crosslink.mintlify.site/guides/remote-access";
|
|
2096
|
+
const secUrl = this.options.securityGuideUrl || "https://crosslink.mintlify.site/security/overview";
|
|
2097
|
+
const lanUrl = this.options.lanGuideUrl || "https://crosslink.mintlify.site/guides/connection-modes";
|
|
2098
|
+
const norm = this.normalizeMode(this.currentMode);
|
|
2099
|
+
pop.innerHTML = `
|
|
2100
|
+
<div class="cl-dropdown-header">Connection Mode</div>
|
|
2101
|
+
|
|
2102
|
+
<!-- Automatic (default) -->
|
|
2103
|
+
<label class="cl-dropdown-item">
|
|
2104
|
+
<div class="cl-dropdown-label">
|
|
2105
|
+
<input type="radio" name="cl-net-mode" value="auto" ${norm === "auto" ? "checked" : ""}>
|
|
2106
|
+
<span>Automatic</span>
|
|
2107
|
+
</div>
|
|
2108
|
+
<div class="cl-info-knob-wrap">
|
|
2109
|
+
<button type="button" class="cl-info-knob" aria-label="Info">ℹ</button>
|
|
2110
|
+
<div class="cl-dropdown-tooltip">
|
|
2111
|
+
<strong>How it works:</strong> The pairing payload carries every available route, and the connecting device picks the first one that answers — local first, then other confirmed routes.<br>
|
|
2112
|
+
<strong>Security:</strong> Crosslink authenticates devices and encrypts application data end to end whichever route wins.
|
|
2113
|
+
<a href="${lanUrl}" target="_blank" rel="noopener noreferrer">Mintlify docs →</a>
|
|
2114
|
+
</div>
|
|
2115
|
+
</div>
|
|
2116
|
+
</label>
|
|
2117
|
+
|
|
2118
|
+
<!-- Same network only -->
|
|
2119
|
+
<label class="cl-dropdown-item">
|
|
2120
|
+
<div class="cl-dropdown-label">
|
|
2121
|
+
<input type="radio" name="cl-net-mode" value="local-only" ${norm === "local-only" ? "checked" : ""}>
|
|
2122
|
+
<span>Same network only</span>
|
|
2123
|
+
</div>
|
|
2124
|
+
<div class="cl-info-knob-wrap">
|
|
2125
|
+
<button type="button" class="cl-info-knob" aria-label="Info">ℹ</button>
|
|
2126
|
+
<div class="cl-dropdown-tooltip">
|
|
2127
|
+
<strong>How it works:</strong> Direct connection on the current Wi-Fi or LAN. No remote transport is advertised.<br>
|
|
2128
|
+
<strong>Security:</strong> The same Crosslink authentication and end-to-end encryption apply on the local link.
|
|
2129
|
+
<a href="${lanUrl}" target="_blank" rel="noopener noreferrer">Mintlify docs →</a>
|
|
2130
|
+
</div>
|
|
2131
|
+
</div>
|
|
2132
|
+
</label>
|
|
2133
|
+
|
|
2134
|
+
<!-- LAN + relay -->
|
|
2135
|
+
<label class="cl-dropdown-item">
|
|
2136
|
+
<div class="cl-dropdown-label">
|
|
2137
|
+
<input type="radio" name="cl-net-mode" value="lan-and-relay" ${norm === "lan-and-relay" ? "checked" : ""}>
|
|
2138
|
+
<span>Same network + relay</span>
|
|
2139
|
+
</div>
|
|
2140
|
+
<div class="cl-info-knob-wrap">
|
|
2141
|
+
<button type="button" class="cl-info-knob" aria-label="Info">ℹ</button>
|
|
2142
|
+
<div class="cl-dropdown-tooltip">
|
|
2143
|
+
<strong>How it works:</strong> Adds a configured relay so another device can reach the host when a direct local route is unavailable.<br>
|
|
2144
|
+
<strong>Security:</strong> Crosslink encrypts application data before it reaches the relay and authenticates it at the destination.
|
|
2145
|
+
<a href="${secUrl}" target="_blank" rel="noopener noreferrer">Mintlify docs →</a>
|
|
2146
|
+
</div>
|
|
2147
|
+
</div>
|
|
2148
|
+
</label>
|
|
2149
|
+
|
|
2150
|
+
<!-- Remote -->
|
|
2151
|
+
<label class="cl-dropdown-item">
|
|
2152
|
+
<div class="cl-dropdown-label">
|
|
2153
|
+
<input type="radio" name="cl-net-mode" value="remote" ${norm === "remote" ? "checked" : ""}>
|
|
2154
|
+
<span>Reachable from anywhere</span>
|
|
2155
|
+
</div>
|
|
2156
|
+
<div class="cl-info-knob-wrap">
|
|
2157
|
+
<button type="button" class="cl-info-knob" aria-label="Info">ℹ</button>
|
|
2158
|
+
<div class="cl-dropdown-tooltip">
|
|
2159
|
+
<strong>How it works:</strong> Advertises a confirmed internet route, such as a router mapping or configured tunnel. If none is available, Crosslink reports that instead of claiming remote reachability.<br>
|
|
2160
|
+
<strong>Security:</strong> The framework keeps device authentication and end-to-end encryption in place on the public route.
|
|
2161
|
+
<a href="${remoteUrl}" target="_blank" rel="noopener noreferrer">Mintlify docs →</a>
|
|
2162
|
+
</div>
|
|
2163
|
+
</div>
|
|
2164
|
+
</label>
|
|
2165
|
+
`;
|
|
2166
|
+
this.modePendingEl = document.createElement("div");
|
|
2167
|
+
this.modePendingEl.className = "cl-mode-pending";
|
|
2168
|
+
this.modePendingEl.setAttribute("role", "status");
|
|
2169
|
+
this.modePendingEl.setAttribute("aria-live", "polite");
|
|
2170
|
+
this.modePendingEl.hidden = true;
|
|
2171
|
+
const spinner = document.createElement("span");
|
|
2172
|
+
spinner.className = "cl-spinner";
|
|
2173
|
+
const pendingText = document.createElement("span");
|
|
2174
|
+
pendingText.textContent = "Applying\u2026";
|
|
2175
|
+
this.modePendingEl.append(spinner, pendingText);
|
|
2176
|
+
pop.appendChild(this.modePendingEl);
|
|
2177
|
+
this.routeSummaryEl = document.createElement("div");
|
|
2178
|
+
this.routeSummaryEl.className = "cl-route-summary";
|
|
2179
|
+
this.routeSummaryEl.hidden = true;
|
|
2180
|
+
pop.appendChild(this.routeSummaryEl);
|
|
2181
|
+
const radios = pop.querySelectorAll('input[name="cl-net-mode"]');
|
|
2182
|
+
radios.forEach((r) => {
|
|
2183
|
+
r.addEventListener("change", (e) => {
|
|
2184
|
+
this.setNetworkMode(normalizeNetworkMode(e.target.value));
|
|
2185
|
+
});
|
|
2186
|
+
});
|
|
2187
|
+
const devicesHeader = document.createElement("div");
|
|
2188
|
+
devicesHeader.className = "cl-dropdown-header";
|
|
2189
|
+
devicesHeader.style.marginTop = "4px";
|
|
2190
|
+
devicesHeader.textContent = "Devices";
|
|
2191
|
+
pop.appendChild(devicesHeader);
|
|
2192
|
+
const devicesItem = document.createElement("label");
|
|
2193
|
+
devicesItem.className = "cl-dropdown-item";
|
|
2194
|
+
devicesItem.innerHTML = `
|
|
2195
|
+
<div class="cl-dropdown-label">
|
|
2196
|
+
<span>Connected Devices</span>
|
|
2197
|
+
</div>
|
|
2198
|
+
`;
|
|
2199
|
+
devicesItem.addEventListener("click", () => this.openConnectedDevicesModal());
|
|
2200
|
+
pop.appendChild(devicesItem);
|
|
2201
|
+
return pop;
|
|
2202
|
+
}
|
|
2203
|
+
toggleSettings(open) {
|
|
2204
|
+
const isHidden = this.settingsPopover.hidden;
|
|
2205
|
+
const shouldOpen = open !== void 0 ? open : isHidden;
|
|
2206
|
+
this.settingsPopover.hidden = !shouldOpen;
|
|
2207
|
+
}
|
|
2208
|
+
setNetworkMode(mode) {
|
|
2209
|
+
const norm = this.normalizeMode(mode);
|
|
2210
|
+
this.currentMode = norm;
|
|
2211
|
+
try {
|
|
2212
|
+
if (typeof localStorage !== "undefined") {
|
|
2213
|
+
localStorage.setItem("crosslink.networkMode", norm);
|
|
2214
|
+
}
|
|
2215
|
+
} catch {
|
|
2216
|
+
}
|
|
2217
|
+
const radio = this.settingsPopover.querySelector(`input[value="${norm}"]`);
|
|
2218
|
+
if (radio) radio.checked = true;
|
|
2219
|
+
if (this.source) {
|
|
2220
|
+
void this.applyNetworkMode(norm);
|
|
2221
|
+
return;
|
|
2222
|
+
}
|
|
2223
|
+
if (this.options.onNetworkModeChange) {
|
|
2224
|
+
this.options.onNetworkModeChange(norm);
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
async applyNetworkMode(mode) {
|
|
2228
|
+
this.setSettingsPending(true);
|
|
2229
|
+
try {
|
|
2230
|
+
await this.source?.setNetworkMode?.(mode);
|
|
2231
|
+
await this.options.onNetworkModeChange?.(mode);
|
|
2232
|
+
} catch (err) {
|
|
2233
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
2234
|
+
this.update({
|
|
2235
|
+
loading: false,
|
|
2236
|
+
error: error.message,
|
|
2237
|
+
errorCode: error.code ?? "CL-P001"
|
|
2238
|
+
});
|
|
2239
|
+
this.options.onError?.(error);
|
|
2240
|
+
this.setSettingsPending(false);
|
|
2241
|
+
return;
|
|
2242
|
+
}
|
|
2243
|
+
try {
|
|
2244
|
+
await this.refresh();
|
|
2245
|
+
} finally {
|
|
2246
|
+
this.setSettingsPending(false);
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
/**
|
|
2250
|
+
* Shows the popover as busy and refuses further changes until it settles.
|
|
2251
|
+
*
|
|
2252
|
+
* The radios keep their state rather than being re-rendered: the user has
|
|
2253
|
+
* already chosen, and the question is only whether the host agrees yet.
|
|
2254
|
+
*/
|
|
2255
|
+
setSettingsPending(pending) {
|
|
2256
|
+
this.modePendingEl.hidden = !pending;
|
|
2257
|
+
if (pending) this.settingsPopover.setAttribute("data-pending", "true");
|
|
2258
|
+
else this.settingsPopover.removeAttribute("data-pending");
|
|
2259
|
+
this.settingsPopover.querySelectorAll('input[name="cl-net-mode"]').forEach((radio) => {
|
|
2260
|
+
radio.disabled = pending;
|
|
2261
|
+
});
|
|
2262
|
+
}
|
|
2263
|
+
getNetworkMode() {
|
|
2264
|
+
return this.currentMode;
|
|
2265
|
+
}
|
|
2266
|
+
mount(target) {
|
|
2267
|
+
const container = typeof target === "string" ? document.querySelector(target) : target;
|
|
2268
|
+
if (!container) throw new Error(`PairingCard target element not found: ${String(target)}`);
|
|
2269
|
+
container.appendChild(this.element);
|
|
2270
|
+
return this;
|
|
2271
|
+
}
|
|
2272
|
+
update(state) {
|
|
2273
|
+
if (state.loading) {
|
|
2274
|
+
this.renderLoading();
|
|
2275
|
+
this.refreshBtn.disabled = true;
|
|
2276
|
+
this.refreshBtn.setAttribute("data-busy", "true");
|
|
2277
|
+
} else {
|
|
2278
|
+
this.refreshBtn.disabled = false;
|
|
2279
|
+
this.refreshBtn.removeAttribute("data-busy");
|
|
2280
|
+
}
|
|
2281
|
+
if (state.networkMode) {
|
|
2282
|
+
this.syncNetworkMode(normalizeNetworkMode(state.networkMode));
|
|
2283
|
+
}
|
|
2284
|
+
if (state.status !== void 0 || state.connected !== void 0) {
|
|
2285
|
+
this.renderStatus(state.status ?? null, state.connected === true);
|
|
2286
|
+
}
|
|
2287
|
+
if (state.endpoints !== void 0 || state.remoteNote !== void 0) {
|
|
2288
|
+
this.renderRoutes(state.endpoints ?? null, state.remoteNote ?? null);
|
|
2289
|
+
}
|
|
2290
|
+
if (state.error) {
|
|
2291
|
+
this.renderError(state.errorCode || "CL-P001", state.error);
|
|
2292
|
+
this.hintEl.textContent = "";
|
|
2293
|
+
return this;
|
|
2294
|
+
}
|
|
2295
|
+
if (state.qr !== void 0) {
|
|
2296
|
+
this.renderQr(state.qr);
|
|
2297
|
+
}
|
|
2298
|
+
if (state.code !== void 0) {
|
|
2299
|
+
this.renderCode(state.code);
|
|
2300
|
+
}
|
|
2301
|
+
if (state.expiresAt !== void 0) {
|
|
2302
|
+
this.renderExpiry(state.expiresAt);
|
|
2303
|
+
}
|
|
2304
|
+
return this;
|
|
2305
|
+
}
|
|
2306
|
+
applyTheme(theme) {
|
|
2307
|
+
if (!theme) return this;
|
|
2308
|
+
const style = this.element.style;
|
|
2309
|
+
if (theme.bg) style.setProperty("--cl-bg", theme.bg);
|
|
2310
|
+
if (theme.fg) style.setProperty("--cl-fg", theme.fg);
|
|
2311
|
+
if (theme.muted) style.setProperty("--cl-muted", theme.muted);
|
|
2312
|
+
if (theme.divider) style.setProperty("--cl-divider", theme.divider);
|
|
2313
|
+
if (theme.pill) style.setProperty("--cl-pill", theme.pill);
|
|
2314
|
+
if (theme.pillText) style.setProperty("--cl-pill-text", theme.pillText);
|
|
2315
|
+
if (theme.border) style.setProperty("--cl-border", theme.border);
|
|
2316
|
+
if (theme.radius) style.setProperty("--cl-radius", theme.radius);
|
|
2317
|
+
return this;
|
|
2318
|
+
}
|
|
2319
|
+
setBlurb(html) {
|
|
2320
|
+
this.blurbEl.innerHTML = html;
|
|
2321
|
+
return this;
|
|
2322
|
+
}
|
|
2323
|
+
syncNetworkMode(mode) {
|
|
2324
|
+
this.currentMode = mode;
|
|
2325
|
+
const radio = this.settingsPopover.querySelector(`input[value="${mode}"]`);
|
|
2326
|
+
if (radio) radio.checked = true;
|
|
2327
|
+
}
|
|
2328
|
+
/**
|
|
2329
|
+
* Draws the Crosslink mark. It takes no argument on purpose: the mark is the
|
|
2330
|
+
* one element of this card an application cannot swap out, so there is no
|
|
2331
|
+
* code path here that renders something else in its place.
|
|
2332
|
+
*/
|
|
2333
|
+
renderBrandMark() {
|
|
2334
|
+
this.logoEl.replaceChildren();
|
|
2335
|
+
this.logoEl.innerHTML = crosslinkLogoSvg({ className: "cl-pair-logo", width: "140px" });
|
|
2336
|
+
}
|
|
2337
|
+
/** The application's own icon and name, beside the framework mark. */
|
|
2338
|
+
renderAppRow() {
|
|
2339
|
+
this.appRowEl.replaceChildren();
|
|
2340
|
+
const icon = this.brand.appIcon;
|
|
2341
|
+
if (icon) {
|
|
2342
|
+
if (icon.trim().startsWith("<svg")) {
|
|
2343
|
+
const holder = document.createElement("span");
|
|
2344
|
+
holder.className = "cl-pair-app-icon";
|
|
2345
|
+
holder.innerHTML = icon;
|
|
2346
|
+
this.appRowEl.appendChild(holder);
|
|
2347
|
+
} else {
|
|
2348
|
+
const img = document.createElement("img");
|
|
2349
|
+
img.className = "cl-pair-app-icon";
|
|
2350
|
+
img.src = icon;
|
|
2351
|
+
img.alt = "";
|
|
2352
|
+
this.appRowEl.appendChild(img);
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2355
|
+
const name = document.createElement("span");
|
|
2356
|
+
name.className = "cl-pair-app-name";
|
|
2357
|
+
name.textContent = this.brand.appName;
|
|
2358
|
+
this.appRowEl.appendChild(name);
|
|
2359
|
+
this.appRowEl.hidden = !icon && this.brand.appName === "This app";
|
|
2360
|
+
}
|
|
2361
|
+
renderStatus(status, connected) {
|
|
2362
|
+
this.statusEl.textContent = status ?? "";
|
|
2363
|
+
this.statusEl.hidden = !status;
|
|
2364
|
+
this.statusEl.classList.toggle("cl-pair-status-on", connected);
|
|
2365
|
+
}
|
|
2366
|
+
/**
|
|
2367
|
+
* Applies the application palette to the card's CSS variables.
|
|
2368
|
+
*
|
|
2369
|
+
* The mark inherits `--cl-logo`. On this card that is the card's own
|
|
2370
|
+
* foreground rather than the application accent: the pairing card is a
|
|
2371
|
+
* Crosslink surface, and drawing the wordmark in the app's colour reads as
|
|
2372
|
+
* the application's logo instead of the framework's. The application accent
|
|
2373
|
+
* still drives the card's own accented elements. `resolveCrosslinkTheme`
|
|
2374
|
+
* derives the foreground from the background, so it clears contrast on a
|
|
2375
|
+
* light card as well as a dark one.
|
|
2376
|
+
*/
|
|
2377
|
+
applyBrand() {
|
|
2378
|
+
const style = this.element.style;
|
|
2379
|
+
style.setProperty("--cl-bg", this.brand.backgroundColor);
|
|
2380
|
+
style.setProperty("--cl-fg", this.brand.textColor);
|
|
2381
|
+
style.setProperty("--cl-muted", this.brand.mutedColor);
|
|
2382
|
+
style.setProperty("--cl-divider", this.brand.dividerColor);
|
|
2383
|
+
style.setProperty("--cl-accent", this.brand.accentColor);
|
|
2384
|
+
style.setProperty("--cl-logo", this.brand.textColor);
|
|
2385
|
+
style.setProperty("--cl-attribution", this.brand.attributionColor);
|
|
2386
|
+
this.applyTheme(this.options.theme);
|
|
2387
|
+
}
|
|
2388
|
+
/** Re-themes a mounted card; the mark and attribution are unaffected. */
|
|
2389
|
+
setBrand(brand) {
|
|
2390
|
+
this.brand = resolvePairingCardTheme({ ...this.options.brand, ...brand });
|
|
2391
|
+
this.applyBrand();
|
|
2392
|
+
this.renderAppRow();
|
|
2393
|
+
return this;
|
|
2394
|
+
}
|
|
2395
|
+
/** The palette actually in use, after contrast correction. */
|
|
2396
|
+
getBrand() {
|
|
2397
|
+
return this.brand;
|
|
2398
|
+
}
|
|
2399
|
+
renderQr(qr) {
|
|
2400
|
+
this.qrWrapEl.replaceChildren();
|
|
2401
|
+
if (!qr) {
|
|
2402
|
+
this.renderQrSkeleton();
|
|
2403
|
+
return;
|
|
2404
|
+
}
|
|
2405
|
+
if (qr.trim().startsWith("<svg")) {
|
|
2406
|
+
this.qrWrapEl.innerHTML = qr;
|
|
2407
|
+
} else {
|
|
2408
|
+
const img = document.createElement("img");
|
|
2409
|
+
img.src = qr;
|
|
2410
|
+
img.alt = "Scan to pair";
|
|
2411
|
+
this.qrWrapEl.appendChild(img);
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
renderCode(code) {
|
|
2415
|
+
this.codePillsEl.replaceChildren();
|
|
2416
|
+
if (!code) {
|
|
2417
|
+
this.renderCodeSkeleton();
|
|
2418
|
+
return;
|
|
2419
|
+
}
|
|
2420
|
+
const cleanDigits = String(code).replace(/\D/g, "");
|
|
2421
|
+
for (const ch of cleanDigits) {
|
|
2422
|
+
const span = document.createElement("span");
|
|
2423
|
+
span.className = "cl-pill";
|
|
2424
|
+
span.textContent = ch;
|
|
2425
|
+
this.codePillsEl.appendChild(span);
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
renderLoading() {
|
|
2429
|
+
this.renderQrSkeleton();
|
|
2430
|
+
this.renderCodeSkeleton();
|
|
2431
|
+
this.hintEl.textContent = "Generating a secure pairing code\u2026";
|
|
2432
|
+
}
|
|
2433
|
+
renderQrSkeleton() {
|
|
2434
|
+
this.qrWrapEl.replaceChildren();
|
|
2435
|
+
const skeleton = document.createElement("span");
|
|
2436
|
+
skeleton.className = "cl-skeleton cl-qr-skeleton";
|
|
2437
|
+
skeleton.setAttribute("aria-label", "Generating QR code");
|
|
2438
|
+
this.qrWrapEl.appendChild(skeleton);
|
|
2439
|
+
}
|
|
2440
|
+
renderCodeSkeleton() {
|
|
2441
|
+
this.codePillsEl.replaceChildren();
|
|
2442
|
+
for (let index = 0; index < 9; index++) {
|
|
2443
|
+
const skeleton = document.createElement("span");
|
|
2444
|
+
skeleton.className = "cl-pill cl-pill-skeleton cl-skeleton";
|
|
2445
|
+
skeleton.setAttribute("aria-hidden", "true");
|
|
2446
|
+
this.codePillsEl.appendChild(skeleton);
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
renderError(code, message) {
|
|
2450
|
+
this.qrWrapEl.replaceChildren();
|
|
2451
|
+
this.codePillsEl.replaceChildren();
|
|
2452
|
+
const shortCode = String(code).toUpperCase().replace(/[^A-Z0-9-]/g, "").slice(0, 12) || "CL-P001";
|
|
2453
|
+
const wrap = document.createElement("div");
|
|
2454
|
+
wrap.className = "cl-qr-placeholder cl-error";
|
|
2455
|
+
const codeEl = document.createElement("div");
|
|
2456
|
+
codeEl.className = "cl-error-code";
|
|
2457
|
+
codeEl.textContent = shortCode;
|
|
2458
|
+
const details = document.createElement("button");
|
|
2459
|
+
details.type = "button";
|
|
2460
|
+
details.className = "cl-error-details-btn";
|
|
2461
|
+
details.textContent = "View details";
|
|
2462
|
+
details.addEventListener("click", () => this.openErrorModal(shortCode, message));
|
|
2463
|
+
wrap.append(codeEl, details);
|
|
2464
|
+
this.qrWrapEl.appendChild(wrap);
|
|
2465
|
+
}
|
|
2466
|
+
openErrorModal(code, message) {
|
|
2467
|
+
const backdrop = document.createElement("div");
|
|
2468
|
+
backdrop.className = "cl-connected-modal-backdrop";
|
|
2469
|
+
const modal = document.createElement("div");
|
|
2470
|
+
modal.className = "cl-connected-modal cl-error-modal";
|
|
2471
|
+
modal.setAttribute("role", "dialog");
|
|
2472
|
+
modal.setAttribute("aria-modal", "true");
|
|
2473
|
+
const header = document.createElement("div");
|
|
2474
|
+
header.className = "cl-modal-header";
|
|
2475
|
+
const title = document.createElement("h3");
|
|
2476
|
+
title.textContent = "Pairing error";
|
|
2477
|
+
const close = document.createElement("button");
|
|
2478
|
+
close.type = "button";
|
|
2479
|
+
close.setAttribute("aria-label", "Close error details");
|
|
2480
|
+
close.innerHTML = "×";
|
|
2481
|
+
close.addEventListener("click", () => backdrop.remove());
|
|
2482
|
+
header.append(title, close);
|
|
2483
|
+
const body = document.createElement("div");
|
|
2484
|
+
body.className = "cl-modal-body";
|
|
2485
|
+
const codeEl = document.createElement("code");
|
|
2486
|
+
codeEl.textContent = code;
|
|
2487
|
+
const messageEl = document.createElement("p");
|
|
2488
|
+
messageEl.textContent = message;
|
|
2489
|
+
body.append(codeEl, messageEl);
|
|
2490
|
+
modal.append(header, body);
|
|
2491
|
+
backdrop.appendChild(modal);
|
|
2492
|
+
backdrop.addEventListener("click", (event) => {
|
|
2493
|
+
if (event.target === backdrop) backdrop.remove();
|
|
2494
|
+
});
|
|
2495
|
+
document.body.appendChild(backdrop);
|
|
2496
|
+
close.focus();
|
|
2497
|
+
}
|
|
2498
|
+
renderExpiry(expiresAt) {
|
|
2499
|
+
clearInterval(this.expiryTimer);
|
|
2500
|
+
if (!expiresAt) {
|
|
2501
|
+
this.hintEl.textContent = "";
|
|
2502
|
+
return;
|
|
2503
|
+
}
|
|
2504
|
+
if (typeof expiresAt === "string") {
|
|
2505
|
+
this.hintEl.textContent = expiresAt;
|
|
2506
|
+
return;
|
|
2507
|
+
}
|
|
2508
|
+
const updateCountdown = () => {
|
|
2509
|
+
const remainingSec = Math.max(0, Math.round((expiresAt - Date.now()) / 1e3));
|
|
2510
|
+
if (remainingSec <= 0) {
|
|
2511
|
+
this.hintEl.textContent = "code expired \u2014 click refresh";
|
|
2512
|
+
clearInterval(this.expiryTimer);
|
|
2513
|
+
} else {
|
|
2514
|
+
const mins = Math.floor(remainingSec / 60);
|
|
2515
|
+
const secs = remainingSec % 60;
|
|
2516
|
+
this.hintEl.textContent = mins > 0 ? `expires in ${mins}m ${secs}s` : `expires in ${secs}s`;
|
|
2517
|
+
}
|
|
2518
|
+
};
|
|
2519
|
+
updateCountdown();
|
|
2520
|
+
this.expiryTimer = setInterval(updateCountdown, 1e3);
|
|
2521
|
+
}
|
|
2522
|
+
/**
|
|
2523
|
+
* Shows the routes the current QR advertises.
|
|
2524
|
+
*
|
|
2525
|
+
* Naming them is the honest version of a connectivity indicator: if the host
|
|
2526
|
+
* asked for remote access and the router said no, there is simply no `wan`
|
|
2527
|
+
* route in the list, and the note says why.
|
|
2528
|
+
*/
|
|
2529
|
+
renderRoutes(endpoints, note) {
|
|
2530
|
+
const el = this.routeSummaryEl;
|
|
2531
|
+
const labels = {
|
|
2532
|
+
lan: "this network",
|
|
2533
|
+
wan: "the internet, directly",
|
|
2534
|
+
sig: "a signaling service",
|
|
2535
|
+
relay: "a relay service",
|
|
2536
|
+
tunnel: "a provider tunnel"
|
|
2537
|
+
};
|
|
2538
|
+
el.replaceChildren();
|
|
2539
|
+
const lines = [];
|
|
2540
|
+
if (endpoints && endpoints.length > 0) {
|
|
2541
|
+
lines.push(`Reachable over: ${endpoints.map((e) => labels[e.kind] ?? e.kind).join(", ")}.`);
|
|
2542
|
+
} else if (endpoints) {
|
|
2543
|
+
lines.push("This host currently advertises no route.");
|
|
2544
|
+
}
|
|
2545
|
+
if (note) lines.push(note);
|
|
2546
|
+
el.hidden = lines.length === 0;
|
|
2547
|
+
for (const line of lines) {
|
|
2548
|
+
const p = document.createElement("p");
|
|
2549
|
+
p.textContent = line;
|
|
2550
|
+
el.appendChild(p);
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
async handleRefresh() {
|
|
2554
|
+
if (this.options.onRefresh) {
|
|
2555
|
+
this.refreshBtn.disabled = true;
|
|
2556
|
+
try {
|
|
2557
|
+
await this.options.onRefresh();
|
|
2558
|
+
} finally {
|
|
2559
|
+
this.refreshBtn.disabled = false;
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
async openConnectedDevicesModal() {
|
|
2564
|
+
const endpoint = this.options.devicesEndpoint || "/api/devices";
|
|
2565
|
+
try {
|
|
2566
|
+
const res = await fetch(endpoint);
|
|
2567
|
+
if (!res.ok) throw new Error(`Failed to fetch devices: ${res.status}`);
|
|
2568
|
+
const data = await res.json();
|
|
2569
|
+
const devices = (data.devices || data || []).filter((device) => typeof device.revokedAt !== "number");
|
|
2570
|
+
this.renderConnectedDevicesModal(devices);
|
|
2571
|
+
} catch (err) {
|
|
2572
|
+
this.renderConnectedDevicesModal([], String(err?.message || err));
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
renderConnectedDevicesModal(devices, errorMsg) {
|
|
2576
|
+
const existingBackdrop = document.querySelector(".cl-connected-modal-backdrop");
|
|
2577
|
+
if (existingBackdrop) existingBackdrop.remove();
|
|
2578
|
+
const backdrop = document.createElement("div");
|
|
2579
|
+
backdrop.className = "cl-connected-modal-backdrop";
|
|
2580
|
+
backdrop.style.cssText = "position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(0,0,0,0.75);z-index:100;display:flex;align-items:center;justify-content:center;animation:clDropdownFade 0.15s ease-out;";
|
|
2581
|
+
const modal = document.createElement("div");
|
|
2582
|
+
modal.className = "cl-connected-modal";
|
|
2583
|
+
const header = document.createElement("div");
|
|
2584
|
+
header.className = "cl-modal-header";
|
|
2585
|
+
const title = document.createElement("h3");
|
|
2586
|
+
title.textContent = "Connected Devices";
|
|
2587
|
+
const closeBtn = document.createElement("button");
|
|
2588
|
+
closeBtn.type = "button";
|
|
2589
|
+
closeBtn.setAttribute("aria-label", "Close");
|
|
2590
|
+
closeBtn.innerHTML = "×";
|
|
2591
|
+
closeBtn.addEventListener("click", () => backdrop.remove());
|
|
2592
|
+
header.appendChild(title);
|
|
2593
|
+
header.appendChild(closeBtn);
|
|
2594
|
+
const body = document.createElement("div");
|
|
2595
|
+
body.className = "cl-modal-body";
|
|
2596
|
+
if (errorMsg) {
|
|
2597
|
+
const errorEl = document.createElement("div");
|
|
2598
|
+
errorEl.className = "cl-modal-error";
|
|
2599
|
+
errorEl.textContent = errorMsg;
|
|
2600
|
+
body.appendChild(errorEl);
|
|
2601
|
+
}
|
|
2602
|
+
if (devices.length === 0 && !errorMsg) {
|
|
2603
|
+
const emptyEl = document.createElement("p");
|
|
2604
|
+
emptyEl.style.cssText = "color:var(--cl-muted);text-align:center;padding:20px 0;";
|
|
2605
|
+
emptyEl.textContent = "No paired devices found.";
|
|
2606
|
+
body.appendChild(emptyEl);
|
|
2607
|
+
} else {
|
|
2608
|
+
for (const dev of devices) {
|
|
2609
|
+
const card = document.createElement("div");
|
|
2610
|
+
card.className = "cl-device-card";
|
|
2611
|
+
const statusText = dev.status || (dev.revokedAt ? "Revoked" : dev.lastConnected ? Date.now() - dev.lastConnected < 3e5 ? "Online" : "Offline" : "Unknown");
|
|
2612
|
+
const statusColor = statusText === "Online" ? "#4ade80" : statusText === "Revoked" ? "#f87171" : "#9a9a9a";
|
|
2613
|
+
const trustedText = dev.revokedAt ? "Not trusted" : "Trusted";
|
|
2614
|
+
const firstPaired = dev.firstPaired ? new Date(dev.firstPaired).toLocaleString() : "Unknown";
|
|
2615
|
+
const lastConnected = dev.lastConnected ? new Date(dev.lastConnected).toLocaleString() : "Never";
|
|
2616
|
+
const info = document.createElement("div");
|
|
2617
|
+
info.className = "cl-device-info";
|
|
2618
|
+
const name = document.createElement("div");
|
|
2619
|
+
name.className = "cl-device-name";
|
|
2620
|
+
name.textContent = dev.name || "Unnamed Device";
|
|
2621
|
+
const meta = document.createElement("div");
|
|
2622
|
+
meta.className = "cl-device-meta";
|
|
2623
|
+
meta.textContent = [dev.deviceType, dev.location].filter(Boolean).join(" \u2022 ");
|
|
2624
|
+
const detail = document.createElement("div");
|
|
2625
|
+
detail.className = "cl-device-detail";
|
|
2626
|
+
const rows = [
|
|
2627
|
+
["Device ID", String(dev.deviceId ?? "")],
|
|
2628
|
+
["IP", dev.ipAddress || "Not available"],
|
|
2629
|
+
["First paired", firstPaired],
|
|
2630
|
+
["Last connected", lastConnected],
|
|
2631
|
+
["Status", statusText, statusColor],
|
|
2632
|
+
["Trusted", trustedText]
|
|
2633
|
+
];
|
|
2634
|
+
for (const [label, value, color] of rows) {
|
|
2635
|
+
const line = document.createElement("div");
|
|
2636
|
+
const strong = document.createElement("strong");
|
|
2637
|
+
strong.textContent = `${label}: `;
|
|
2638
|
+
const val = document.createElement("span");
|
|
2639
|
+
val.textContent = value;
|
|
2640
|
+
if (color) val.style.cssText = `color:${color};font-weight:600;`;
|
|
2641
|
+
line.append(strong, val);
|
|
2642
|
+
detail.appendChild(line);
|
|
2643
|
+
}
|
|
2644
|
+
info.appendChild(name);
|
|
2645
|
+
info.appendChild(meta);
|
|
2646
|
+
info.appendChild(detail);
|
|
2647
|
+
const actions = document.createElement("div");
|
|
2648
|
+
actions.className = "cl-device-actions";
|
|
2649
|
+
const revokeBtn = document.createElement("button");
|
|
2650
|
+
revokeBtn.className = "cl-revoke-btn";
|
|
2651
|
+
revokeBtn.textContent = "Revoke Access";
|
|
2652
|
+
revokeBtn.addEventListener("click", async () => {
|
|
2653
|
+
revokeBtn.disabled = true;
|
|
2654
|
+
revokeBtn.textContent = "Revoking...";
|
|
2655
|
+
try {
|
|
2656
|
+
const revokeEndpoint = this.options.revokeEndpoint || "/api/revoke";
|
|
2657
|
+
const res = await fetch(revokeEndpoint, {
|
|
2658
|
+
method: "POST",
|
|
2659
|
+
headers: { "Content-Type": "application/json" },
|
|
2660
|
+
body: JSON.stringify({ deviceId: dev.deviceId })
|
|
2661
|
+
});
|
|
2662
|
+
const result = await res.json().catch(() => null);
|
|
2663
|
+
if (!res.ok || result?.ok !== true) throw new Error("Revoke failed");
|
|
2664
|
+
card.remove();
|
|
2665
|
+
} catch (e) {
|
|
2666
|
+
revokeBtn.textContent = "Failed";
|
|
2667
|
+
revokeBtn.style.background = "#7f1d1d";
|
|
2668
|
+
}
|
|
2669
|
+
});
|
|
2670
|
+
actions.appendChild(revokeBtn);
|
|
2671
|
+
card.appendChild(info);
|
|
2672
|
+
card.appendChild(actions);
|
|
2673
|
+
body.appendChild(card);
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
modal.appendChild(header);
|
|
2677
|
+
modal.appendChild(body);
|
|
2678
|
+
backdrop.appendChild(modal);
|
|
2679
|
+
document.body.appendChild(backdrop);
|
|
2680
|
+
backdrop.addEventListener("click", (e) => {
|
|
2681
|
+
if (e.target === backdrop) {
|
|
2682
|
+
backdrop.remove();
|
|
2683
|
+
}
|
|
2684
|
+
});
|
|
2685
|
+
}
|
|
2686
|
+
destroy() {
|
|
2687
|
+
this.destroyed = true;
|
|
2688
|
+
this.refreshQueued = false;
|
|
2689
|
+
clearInterval(this.expiryTimer);
|
|
2690
|
+
this.clearRefreshTimer();
|
|
2691
|
+
this.unsubscribeSource?.();
|
|
2692
|
+
this.unsubscribeSource = null;
|
|
2693
|
+
this.element.remove();
|
|
2694
|
+
}
|
|
2695
|
+
};
|
|
2696
|
+
function normalizeNetworkMode(mode) {
|
|
2697
|
+
switch (mode) {
|
|
2698
|
+
case "local":
|
|
2699
|
+
case "local-only":
|
|
2700
|
+
return "local-only";
|
|
2701
|
+
case "lan-and-relay":
|
|
2702
|
+
case "relay":
|
|
2703
|
+
return "lan-and-relay";
|
|
2704
|
+
case "remote":
|
|
2705
|
+
case "open-lan":
|
|
2706
|
+
case "open-lan-remote":
|
|
2707
|
+
return "remote";
|
|
2708
|
+
// ngrok/cloudflared were provider tunnels, which are now an opt-in host
|
|
2709
|
+
// configuration rather than a mode the user picks in the browser.
|
|
2710
|
+
case "ngrok":
|
|
2711
|
+
case "cloudflare":
|
|
2712
|
+
case "cloudflared":
|
|
2713
|
+
case "open-lan-cloudflared":
|
|
2714
|
+
return "auto";
|
|
2715
|
+
default:
|
|
2716
|
+
return "auto";
|
|
2717
|
+
}
|
|
2718
|
+
}
|
|
2719
|
+
function createPairingCard(options = {}) {
|
|
2720
|
+
return new PairingCard(options);
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
// src/ui/powered-by-crosslink.ts
|
|
2724
|
+
function cssLength(value, fallback) {
|
|
2725
|
+
if (typeof value === "number") return `${value}px`;
|
|
2726
|
+
return value || fallback;
|
|
2727
|
+
}
|
|
2728
|
+
var PoweredByCrosslink = class {
|
|
2729
|
+
element;
|
|
2730
|
+
options;
|
|
2731
|
+
constructor(options = {}) {
|
|
2732
|
+
this.options = options;
|
|
2733
|
+
this.element = document.createElement("div");
|
|
2734
|
+
this.render();
|
|
2735
|
+
if (options.target) this.mount(options.target);
|
|
2736
|
+
}
|
|
2737
|
+
mount(target = document.body) {
|
|
2738
|
+
const container = typeof target === "string" ? document.querySelector(target) : target;
|
|
2739
|
+
if (!container) throw new Error(`PoweredByCrosslink target not found: ${String(target)}`);
|
|
2740
|
+
if (this.element.parentElement !== container) container.appendChild(this.element);
|
|
2741
|
+
return this;
|
|
2742
|
+
}
|
|
2743
|
+
update(options) {
|
|
2744
|
+
this.options = { ...this.options, ...options };
|
|
2745
|
+
this.render();
|
|
2746
|
+
return this;
|
|
2747
|
+
}
|
|
2748
|
+
destroy() {
|
|
2749
|
+
this.element.remove();
|
|
2750
|
+
}
|
|
2751
|
+
render() {
|
|
2752
|
+
const placement = this.options.placement ?? "bottom-center";
|
|
2753
|
+
const offset = cssLength(this.options.offset, "8px");
|
|
2754
|
+
const size = cssLength(this.options.size, "11px");
|
|
2755
|
+
const position = [];
|
|
2756
|
+
if (placement !== "inline") {
|
|
2757
|
+
position.push("position:fixed", `z-index:${this.options.zIndex ?? 100002}`);
|
|
2758
|
+
if (placement.startsWith("top")) position.push(`top:calc(${offset} + env(safe-area-inset-top))`);
|
|
2759
|
+
else position.push(`bottom:calc(${offset} + env(safe-area-inset-bottom))`);
|
|
2760
|
+
if (placement.endsWith("left")) position.push(`left:${offset}`);
|
|
2761
|
+
else if (placement.endsWith("right")) position.push(`right:${offset}`);
|
|
2762
|
+
else position.push("left:50%", "transform:translateX(-50%)");
|
|
2763
|
+
}
|
|
2764
|
+
this.element.className = `cl-powered-by-crosslink${this.options.className ? ` ${this.options.className}` : ""}`;
|
|
2765
|
+
this.element.style.cssText = [
|
|
2766
|
+
...position,
|
|
2767
|
+
"display:inline-flex",
|
|
2768
|
+
"align-items:center",
|
|
2769
|
+
"gap:.32em",
|
|
2770
|
+
"max-width:calc(100vw - 24px)",
|
|
2771
|
+
"padding:4px 7px",
|
|
2772
|
+
"border-radius:999px",
|
|
2773
|
+
`background:${this.options.background ?? "rgba(0,0,0,.58)"}`,
|
|
2774
|
+
`color:${this.options.color ?? "#94a3b8"}`,
|
|
2775
|
+
`font:${size}/1.2 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif`,
|
|
2776
|
+
"white-space:nowrap",
|
|
2777
|
+
"box-sizing:border-box"
|
|
2778
|
+
].join(";");
|
|
2779
|
+
this.element.textContent = "";
|
|
2780
|
+
const brand = resolveCrosslinkTheme({
|
|
2781
|
+
accentColor: this.options.accentColor,
|
|
2782
|
+
backgroundColor: this.options.backgroundColor ?? this.options.background
|
|
2783
|
+
});
|
|
2784
|
+
const prefix = document.createElement("span");
|
|
2785
|
+
prefix.textContent = this.options.text ?? CROSSLINK_ATTRIBUTION_TEXT;
|
|
2786
|
+
const link = document.createElement("a");
|
|
2787
|
+
link.href = CROSSLINK_REPOSITORY;
|
|
2788
|
+
link.target = "_blank";
|
|
2789
|
+
link.rel = "noopener noreferrer";
|
|
2790
|
+
link.setAttribute("aria-label", CROSSLINK_ATTRIBUTION_LINK_TEXT);
|
|
2791
|
+
link.style.cssText = "color:inherit;display:inline-flex;align-items:center;text-decoration:none";
|
|
2792
|
+
link.innerHTML = crosslinkLogoSvg({
|
|
2793
|
+
width: cssLength(this.options.logoWidth, "58px"),
|
|
2794
|
+
color: this.options.accentColor ? brand.logoColor : void 0,
|
|
2795
|
+
title: CROSSLINK_ATTRIBUTION_LINK_TEXT
|
|
2796
|
+
});
|
|
2797
|
+
this.element.append(prefix, link);
|
|
2798
|
+
if (this.options.suffix) {
|
|
2799
|
+
const suffix = document.createElement("span");
|
|
2800
|
+
suffix.textContent = this.options.suffix;
|
|
2801
|
+
this.element.appendChild(suffix);
|
|
2802
|
+
}
|
|
2803
|
+
}
|
|
2804
|
+
};
|
|
2805
|
+
function createPoweredByCrosslink(options = {}) {
|
|
2806
|
+
return new PoweredByCrosslink(options);
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
// src/offline/offline-shell.ts
|
|
2810
|
+
import {
|
|
2811
|
+
toHttpUrl as toHttpUrl2
|
|
2812
|
+
} from "@crosslink/core";
|
|
2813
|
+
var DEFAULT_OFFLINE_CONFIG = {
|
|
2814
|
+
title: "Trying to reconnect",
|
|
2815
|
+
message: "Crosslink can't reach the app on your computer yet. We'll keep trying automatically.",
|
|
2816
|
+
icon: "",
|
|
2817
|
+
appName: "Crosslink",
|
|
2818
|
+
themeColor: "#000000",
|
|
2819
|
+
bgColor: "#000000",
|
|
2820
|
+
accentColor: "",
|
|
2821
|
+
textColor: "",
|
|
2822
|
+
appearance: "auto",
|
|
2823
|
+
debuggingUrl: "https://crosslink.mintlify.site/resources/debugging-mobile-reconnect"
|
|
2824
|
+
};
|
|
2825
|
+
var CrosslinkOfflineShell = class {
|
|
2826
|
+
client;
|
|
2827
|
+
options;
|
|
2828
|
+
currentState = "connecting";
|
|
2829
|
+
reachabilityTimer = null;
|
|
2830
|
+
reconnectTimer = null;
|
|
2831
|
+
visibilityHandler = null;
|
|
2832
|
+
onlineHandler = null;
|
|
2833
|
+
isPageVisible = true;
|
|
2834
|
+
attemptCount = 0;
|
|
2835
|
+
isAttempting = false;
|
|
2836
|
+
offlineElement = null;
|
|
2837
|
+
constructor(options) {
|
|
2838
|
+
this.options = {
|
|
2839
|
+
clientOptions: options.clientOptions,
|
|
2840
|
+
offline: { ...DEFAULT_OFFLINE_CONFIG, ...options.offline },
|
|
2841
|
+
onConnected: options.onConnected,
|
|
2842
|
+
onAuthRequired: options.onAuthRequired,
|
|
2843
|
+
onStateChange: options.onStateChange ?? (() => {
|
|
2844
|
+
}),
|
|
2845
|
+
minRetryDelay: options.minRetryDelay ?? 1e3,
|
|
2846
|
+
maxRetryDelay: options.maxRetryDelay ?? 3e4,
|
|
2847
|
+
reachabilityCheckInterval: options.reachabilityCheckInterval ?? 1e4,
|
|
2848
|
+
serviceWorkerUrl: options.serviceWorkerUrl ?? "/sw.js",
|
|
2849
|
+
autoRegisterServiceWorker: options.autoRegisterServiceWorker ?? true,
|
|
2850
|
+
autoMountOfflineUI: options.autoMountOfflineUI ?? true,
|
|
2851
|
+
container: options.container
|
|
2852
|
+
};
|
|
2853
|
+
if (options.client) {
|
|
2854
|
+
this.client = options.client;
|
|
2855
|
+
} else {
|
|
2856
|
+
const origOnStateChange = options.clientOptions?.onStateChange;
|
|
2857
|
+
const clientOpts = {
|
|
2858
|
+
...options.clientOptions,
|
|
2859
|
+
onStateChange: (state, detail) => {
|
|
2860
|
+
origOnStateChange?.(state, detail);
|
|
2861
|
+
this.handleClientStateChange(state, detail);
|
|
2862
|
+
}
|
|
2863
|
+
};
|
|
2864
|
+
this.client = new CrosslinkClient(clientOpts);
|
|
2865
|
+
}
|
|
2866
|
+
this.setupVisibilityHandlers();
|
|
2867
|
+
this.setupOnlineHandler();
|
|
2868
|
+
}
|
|
2869
|
+
/**
|
|
2870
|
+
* Initialize and start the offline shell. This should be called early
|
|
2871
|
+
* in the PWA lifecycle, before attempting any connection.
|
|
2872
|
+
*/
|
|
2873
|
+
async start() {
|
|
2874
|
+
if (this.options.autoRegisterServiceWorker && typeof navigator !== "undefined" && "serviceWorker" in navigator) {
|
|
2875
|
+
try {
|
|
2876
|
+
await navigator.serviceWorker.register(this.options.serviceWorkerUrl);
|
|
2877
|
+
} catch (e) {
|
|
2878
|
+
console.warn("[OfflineShell] Service worker registration notice:", e);
|
|
2879
|
+
}
|
|
2880
|
+
}
|
|
2881
|
+
const apps = this.client.listApps();
|
|
2882
|
+
if (apps.length === 0) {
|
|
2883
|
+
this.setState("authentication-required");
|
|
2884
|
+
await this.options.onAuthRequired();
|
|
2885
|
+
return;
|
|
2886
|
+
}
|
|
2887
|
+
await this.attemptSilentAuth(apps[0]);
|
|
2888
|
+
}
|
|
2889
|
+
/**
|
|
2890
|
+
* Attempt silent cryptographic authentication with the paired host.
|
|
2891
|
+
* This is the core "trusted device" flow - no user interaction required.
|
|
2892
|
+
*/
|
|
2893
|
+
async attemptSilentAuth(app) {
|
|
2894
|
+
if (this.isAttempting) return;
|
|
2895
|
+
this.isAttempting = true;
|
|
2896
|
+
if (this.currentState !== "reconnecting") {
|
|
2897
|
+
this.setState("connecting");
|
|
2898
|
+
}
|
|
2899
|
+
try {
|
|
2900
|
+
const rpc = await this.client.connect(app.appId);
|
|
2901
|
+
this.isAttempting = false;
|
|
2902
|
+
this.cancelTimers();
|
|
2903
|
+
this.unmountOfflineUI();
|
|
2904
|
+
this.setState("connected");
|
|
2905
|
+
await this.options.onConnected(rpc, this.client);
|
|
2906
|
+
} catch (err) {
|
|
2907
|
+
this.isAttempting = false;
|
|
2908
|
+
await this.handleConnectionError(err, app);
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
2911
|
+
/**
|
|
2912
|
+
* Handle connection state changes from underlying ClientLink.
|
|
2913
|
+
*/
|
|
2914
|
+
handleClientStateChange(state, detail) {
|
|
2915
|
+
if (state === "revoked" || state === "unauthorized") {
|
|
2916
|
+
this.unmountOfflineUI();
|
|
2917
|
+
this.cancelTimers();
|
|
2918
|
+
this.setState("authentication-required", detail);
|
|
2919
|
+
this.options.onAuthRequired();
|
|
2920
|
+
} else if (state === "offline" && this.currentState === "connected") {
|
|
2921
|
+
this.showHostOffline();
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2924
|
+
/**
|
|
2925
|
+
* Handle connection errors and determine the correct state.
|
|
2926
|
+
* Critical: distinguish between "host offline" vs "authentication required / revoked".
|
|
2927
|
+
*/
|
|
2928
|
+
async handleConnectionError(err, app) {
|
|
2929
|
+
const msg = String(err?.message ?? err ?? "").toLowerCase();
|
|
2930
|
+
const clientState = this.client.state;
|
|
2931
|
+
const isAuthFailure = msg.includes("revoked") || msg.includes("device_revoked") || msg.includes("device-revoked") || msg.includes("unauthorized") || msg.includes("not paired") || msg.includes("fingerprint") || msg.includes("signature invalid") || msg.includes("challenge nonce") || clientState === "revoked" || clientState === "unauthorized";
|
|
2932
|
+
if (isAuthFailure) {
|
|
2933
|
+
this.unmountOfflineUI();
|
|
2934
|
+
this.cancelTimers();
|
|
2935
|
+
this.setState("authentication-failed");
|
|
2936
|
+
await this.options.onAuthRequired();
|
|
2937
|
+
return;
|
|
2938
|
+
}
|
|
2939
|
+
this.showHostOffline();
|
|
2940
|
+
}
|
|
2941
|
+
/**
|
|
2942
|
+
* Show the host-offline UI and start periodic reconnection attempts.
|
|
2943
|
+
*/
|
|
2944
|
+
showHostOffline() {
|
|
2945
|
+
this.setState("host-offline");
|
|
2946
|
+
if (this.options.autoMountOfflineUI) {
|
|
2947
|
+
this.mountOfflineUI();
|
|
2948
|
+
}
|
|
2949
|
+
this.scheduleReconnect();
|
|
2950
|
+
this.startReachabilityChecks();
|
|
2951
|
+
}
|
|
2952
|
+
/**
|
|
2953
|
+
* Schedule a reconnection attempt with exponential backoff and jitter.
|
|
2954
|
+
*/
|
|
2955
|
+
scheduleReconnect() {
|
|
2956
|
+
if (this.reconnectTimer) return;
|
|
2957
|
+
const delay = Math.min(
|
|
2958
|
+
this.options.maxRetryDelay,
|
|
2959
|
+
this.options.minRetryDelay * Math.pow(1.5, Math.min(this.attemptCount, 8))
|
|
2960
|
+
);
|
|
2961
|
+
const jitter = delay * (0.8 + Math.random() * 0.4);
|
|
2962
|
+
const delaySec = Math.max(1, Math.round(jitter / 1e3));
|
|
2963
|
+
this.updateOfflineStatus(`Reconnecting in ${delaySec}s\u2026`);
|
|
2964
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
2965
|
+
this.reconnectTimer = null;
|
|
2966
|
+
this.attemptCount++;
|
|
2967
|
+
await this.reconnectNow();
|
|
2968
|
+
}, jitter);
|
|
2969
|
+
}
|
|
2970
|
+
/**
|
|
2971
|
+
* Immediately trigger a silent reconnection attempt.
|
|
2972
|
+
*/
|
|
2973
|
+
async reconnectNow() {
|
|
2974
|
+
if (this.currentState === "connected" || this.isAttempting) return;
|
|
2975
|
+
const apps = this.client.listApps();
|
|
2976
|
+
if (apps.length === 0) {
|
|
2977
|
+
this.setState("authentication-required");
|
|
2978
|
+
await this.options.onAuthRequired();
|
|
2979
|
+
return;
|
|
2980
|
+
}
|
|
2981
|
+
if (this.reconnectTimer) {
|
|
2982
|
+
clearTimeout(this.reconnectTimer);
|
|
2983
|
+
this.reconnectTimer = null;
|
|
2984
|
+
}
|
|
2985
|
+
this.setState("reconnecting", { attempt: this.attemptCount });
|
|
2986
|
+
this.updateOfflineStatus("Trying to reconnect\u2026");
|
|
2987
|
+
await this.attemptSilentAuth(apps[0]);
|
|
2988
|
+
}
|
|
2989
|
+
/**
|
|
2990
|
+
* Start periodic reachability checks while in host-offline state.
|
|
2991
|
+
*/
|
|
2992
|
+
startReachabilityChecks() {
|
|
2993
|
+
if (this.reachabilityTimer) return;
|
|
2994
|
+
const check = async () => {
|
|
2995
|
+
if (this.currentState !== "host-offline" && this.currentState !== "reconnecting") {
|
|
2996
|
+
this.cancelReachabilityChecks();
|
|
2997
|
+
return;
|
|
2998
|
+
}
|
|
2999
|
+
const apps = this.client.listApps();
|
|
3000
|
+
if (apps.length === 0) return;
|
|
3001
|
+
const result = await this.checkHostReachability(apps[0].appId);
|
|
3002
|
+
if (result.reachable) {
|
|
3003
|
+
this.cancelReachabilityChecks();
|
|
3004
|
+
await this.reconnectNow();
|
|
3005
|
+
}
|
|
3006
|
+
};
|
|
3007
|
+
this.reachabilityTimer = setInterval(check, this.options.reachabilityCheckInterval);
|
|
3008
|
+
}
|
|
3009
|
+
cancelReachabilityChecks() {
|
|
3010
|
+
if (this.reachabilityTimer) {
|
|
3011
|
+
clearInterval(this.reachabilityTimer);
|
|
3012
|
+
this.reachabilityTimer = null;
|
|
3013
|
+
}
|
|
3014
|
+
}
|
|
3015
|
+
/**
|
|
3016
|
+
* Check if the host signaling/relay is reachable.
|
|
3017
|
+
*/
|
|
3018
|
+
async checkHostReachability(appId) {
|
|
3019
|
+
const hints = this.getHints(appId);
|
|
3020
|
+
if (!hints) {
|
|
3021
|
+
return { reachable: false };
|
|
3022
|
+
}
|
|
3023
|
+
if (hints.signalingUrl) {
|
|
3024
|
+
try {
|
|
3025
|
+
const controller = new AbortController();
|
|
3026
|
+
const timeout = setTimeout(() => controller.abort(), 4e3);
|
|
3027
|
+
const fetchFn = this.options.clientOptions?.fetch ?? globalThis.fetch;
|
|
3028
|
+
const res = await fetchFn(`${toHttpUrl2(hints.signalingUrl).replace(/\/$/, "")}/apps/${encodeURIComponent(appId)}`, {
|
|
3029
|
+
signal: controller.signal,
|
|
3030
|
+
cache: "no-store"
|
|
3031
|
+
});
|
|
3032
|
+
clearTimeout(timeout);
|
|
3033
|
+
if (res.ok) {
|
|
3034
|
+
const data = await res.json().catch(() => ({}));
|
|
3035
|
+
return {
|
|
3036
|
+
reachable: true,
|
|
3037
|
+
hostInfo: {
|
|
3038
|
+
relay: data.relay,
|
|
3039
|
+
lan: data.lan,
|
|
3040
|
+
fingerprint: data.fingerprint
|
|
3041
|
+
}
|
|
3042
|
+
};
|
|
3043
|
+
}
|
|
3044
|
+
} catch {
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
3047
|
+
return { reachable: false };
|
|
3048
|
+
}
|
|
3049
|
+
cancelTimers() {
|
|
3050
|
+
this.cancelReachabilityChecks();
|
|
3051
|
+
if (this.reconnectTimer) {
|
|
3052
|
+
clearTimeout(this.reconnectTimer);
|
|
3053
|
+
this.reconnectTimer = null;
|
|
3054
|
+
}
|
|
3055
|
+
this.attemptCount = 0;
|
|
3056
|
+
}
|
|
3057
|
+
setState(state, detail) {
|
|
3058
|
+
if (this.currentState !== state) {
|
|
3059
|
+
this.currentState = state;
|
|
3060
|
+
this.options.onStateChange(state, detail);
|
|
3061
|
+
}
|
|
3062
|
+
}
|
|
3063
|
+
getHints(appId) {
|
|
3064
|
+
try {
|
|
3065
|
+
const hints = this.client.hints?.load?.({});
|
|
3066
|
+
return hints?.[appId] ?? null;
|
|
3067
|
+
} catch {
|
|
3068
|
+
return null;
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
3071
|
+
setupVisibilityHandlers() {
|
|
3072
|
+
if (typeof document === "undefined") return;
|
|
3073
|
+
this.visibilityHandler = () => {
|
|
3074
|
+
this.isPageVisible = !document.hidden;
|
|
3075
|
+
if (this.isPageVisible && (this.currentState === "host-offline" || this.currentState === "reconnecting")) {
|
|
3076
|
+
this.reconnectNow().catch(() => {
|
|
3077
|
+
});
|
|
3078
|
+
}
|
|
3079
|
+
};
|
|
3080
|
+
document.addEventListener("visibilitychange", this.visibilityHandler);
|
|
3081
|
+
}
|
|
3082
|
+
setupOnlineHandler() {
|
|
3083
|
+
if (typeof window === "undefined") return;
|
|
3084
|
+
this.onlineHandler = () => {
|
|
3085
|
+
if (this.currentState === "host-offline" || this.currentState === "reconnecting") {
|
|
3086
|
+
this.reconnectNow().catch(() => {
|
|
3087
|
+
});
|
|
3088
|
+
}
|
|
3089
|
+
};
|
|
3090
|
+
window.addEventListener("online", this.onlineHandler);
|
|
3091
|
+
}
|
|
3092
|
+
/**
|
|
3093
|
+
* Mount the offline UI into the DOM
|
|
3094
|
+
*/
|
|
3095
|
+
mountOfflineUI() {
|
|
3096
|
+
if (typeof document === "undefined") return;
|
|
3097
|
+
if (!this.offlineElement) {
|
|
3098
|
+
const fullConfig = {
|
|
3099
|
+
...DEFAULT_OFFLINE_CONFIG,
|
|
3100
|
+
...this.options.offline
|
|
3101
|
+
};
|
|
3102
|
+
this.offlineElement = createOfflineUI(fullConfig, () => this.forceReconnect());
|
|
3103
|
+
}
|
|
3104
|
+
const container = this.options.container ?? document.body;
|
|
3105
|
+
if (container && !container.contains(this.offlineElement)) {
|
|
3106
|
+
container.appendChild(this.offlineElement);
|
|
3107
|
+
}
|
|
3108
|
+
}
|
|
3109
|
+
/**
|
|
3110
|
+
* Unmount the offline UI from the DOM
|
|
3111
|
+
*/
|
|
3112
|
+
unmountOfflineUI() {
|
|
3113
|
+
if (this.offlineElement && this.offlineElement.parentElement) {
|
|
3114
|
+
this.offlineElement.remove();
|
|
3115
|
+
}
|
|
3116
|
+
this.offlineElement = null;
|
|
3117
|
+
}
|
|
3118
|
+
/**
|
|
3119
|
+
* Update the status text shown in the offline UI
|
|
3120
|
+
*/
|
|
3121
|
+
updateOfflineStatus(text) {
|
|
3122
|
+
if (typeof document === "undefined") return;
|
|
3123
|
+
const statusEl = document.getElementById("crosslink-offline-status");
|
|
3124
|
+
if (statusEl) {
|
|
3125
|
+
statusEl.textContent = text;
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
/**
|
|
3129
|
+
* Get current connection state
|
|
3130
|
+
*/
|
|
3131
|
+
getState() {
|
|
3132
|
+
return this.currentState;
|
|
3133
|
+
}
|
|
3134
|
+
/**
|
|
3135
|
+
* Force a reconnection attempt immediately (e.g. user taps "Retry")
|
|
3136
|
+
*/
|
|
3137
|
+
async forceReconnect() {
|
|
3138
|
+
this.attemptCount = 0;
|
|
3139
|
+
await this.reconnectNow();
|
|
3140
|
+
}
|
|
3141
|
+
/**
|
|
3142
|
+
* Clean up resources, event listeners, and timers
|
|
3143
|
+
*/
|
|
3144
|
+
destroy() {
|
|
3145
|
+
this.cancelTimers();
|
|
3146
|
+
this.unmountOfflineUI();
|
|
3147
|
+
if (typeof document !== "undefined" && this.visibilityHandler) {
|
|
3148
|
+
document.removeEventListener("visibilitychange", this.visibilityHandler);
|
|
3149
|
+
this.visibilityHandler = null;
|
|
3150
|
+
}
|
|
3151
|
+
if (typeof window !== "undefined" && this.onlineHandler) {
|
|
3152
|
+
window.removeEventListener("online", this.onlineHandler);
|
|
3153
|
+
this.onlineHandler = null;
|
|
3154
|
+
}
|
|
3155
|
+
}
|
|
3156
|
+
/**
|
|
3157
|
+
* Get the underlying CrosslinkClient
|
|
3158
|
+
*/
|
|
3159
|
+
getClient() {
|
|
3160
|
+
return this.client;
|
|
3161
|
+
}
|
|
3162
|
+
};
|
|
3163
|
+
function createOfflineUI(config, onRetry) {
|
|
3164
|
+
const resolved = { ...DEFAULT_OFFLINE_CONFIG, ...config };
|
|
3165
|
+
const brand = resolveCrosslinkTheme({
|
|
3166
|
+
appName: resolved.appName,
|
|
3167
|
+
appIcon: resolved.icon,
|
|
3168
|
+
accentColor: resolved.accentColor || void 0,
|
|
3169
|
+
backgroundColor: resolved.bgColor || void 0,
|
|
3170
|
+
textColor: resolved.textColor || void 0,
|
|
3171
|
+
appearance: resolved.appearance === "auto" ? void 0 : resolved.appearance
|
|
3172
|
+
});
|
|
3173
|
+
const container = document.createElement("div");
|
|
3174
|
+
container.id = "crosslink-offline-shell";
|
|
3175
|
+
container.style.cssText = `
|
|
3176
|
+
position: fixed;
|
|
3177
|
+
inset: 0;
|
|
3178
|
+
z-index: 99999;
|
|
3179
|
+
background: ${brand.backgroundColor};
|
|
3180
|
+
color: ${brand.textColor};
|
|
3181
|
+
display: flex;
|
|
3182
|
+
flex-direction: column;
|
|
3183
|
+
align-items: center;
|
|
3184
|
+
justify-content: center;
|
|
3185
|
+
padding: 32px 24px;
|
|
3186
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
3187
|
+
text-align: center;
|
|
3188
|
+
box-sizing: border-box;
|
|
3189
|
+
overflow: hidden;
|
|
3190
|
+
`;
|
|
3191
|
+
if (brand.appIcon) {
|
|
3192
|
+
const icon = document.createElement("img");
|
|
3193
|
+
icon.src = brand.appIcon;
|
|
3194
|
+
icon.alt = "";
|
|
3195
|
+
icon.style.cssText = "width:56px;height:56px;border-radius:14px;margin-bottom:18px;object-fit:cover";
|
|
3196
|
+
container.appendChild(icon);
|
|
3197
|
+
}
|
|
3198
|
+
const appName = document.createElement("div");
|
|
3199
|
+
appName.id = "crosslink-offline-appname";
|
|
3200
|
+
appName.textContent = brand.appName;
|
|
3201
|
+
appName.style.cssText = `font-size:13px;font-weight:600;letter-spacing:.02em;color:${brand.mutedColor};margin-bottom:16px`;
|
|
3202
|
+
container.appendChild(appName);
|
|
3203
|
+
const title = document.createElement("h1");
|
|
3204
|
+
title.textContent = resolved.title || `${brand.appName} is unavailable`;
|
|
3205
|
+
title.style.cssText = `
|
|
3206
|
+
font-size: 21px;
|
|
3207
|
+
font-weight: 700;
|
|
3208
|
+
margin: 0 0 10px 0;
|
|
3209
|
+
color: ${brand.textColor};
|
|
3210
|
+
letter-spacing: -0.02em;
|
|
3211
|
+
`;
|
|
3212
|
+
container.appendChild(title);
|
|
3213
|
+
const message = document.createElement("p");
|
|
3214
|
+
message.style.cssText = `
|
|
3215
|
+
color: ${brand.mutedColor};
|
|
3216
|
+
font-size: 14px;
|
|
3217
|
+
line-height: 1.55;
|
|
3218
|
+
max-width: 300px;
|
|
3219
|
+
margin: 0 0 28px 0;
|
|
3220
|
+
white-space: pre-line;
|
|
3221
|
+
`;
|
|
3222
|
+
message.textContent = resolved.message || `Crosslink isn't currently able to reach the computer running ${brand.appName}.`;
|
|
3223
|
+
container.appendChild(message);
|
|
3224
|
+
const statusWrap = document.createElement("div");
|
|
3225
|
+
statusWrap.style.cssText = `margin-bottom: 24px;`;
|
|
3226
|
+
const status = document.createElement("span");
|
|
3227
|
+
status.id = "crosslink-offline-status";
|
|
3228
|
+
status.style.cssText = `color:${brand.mutedColor};font-size:13px;font-weight:400`;
|
|
3229
|
+
status.textContent = "Trying to reconnect\u2026";
|
|
3230
|
+
statusWrap.appendChild(status);
|
|
3231
|
+
container.appendChild(statusWrap);
|
|
3232
|
+
if (onRetry) {
|
|
3233
|
+
const retryBtn = document.createElement("button");
|
|
3234
|
+
retryBtn.textContent = "Attempt reopening the app";
|
|
3235
|
+
retryBtn.style.cssText = `
|
|
3236
|
+
background: ${brand.surfaceColor};
|
|
3237
|
+
border: 1px solid ${brand.dividerColor};
|
|
3238
|
+
color: ${brand.textColor};
|
|
3239
|
+
padding: 8px 20px;
|
|
3240
|
+
border-radius: 999px;
|
|
3241
|
+
font-size: 13px;
|
|
3242
|
+
font-weight: 500;
|
|
3243
|
+
cursor: pointer;
|
|
3244
|
+
transition: background 0.15s ease;
|
|
3245
|
+
`;
|
|
3246
|
+
retryBtn.onclick = () => {
|
|
3247
|
+
onRetry();
|
|
3248
|
+
};
|
|
3249
|
+
container.appendChild(retryBtn);
|
|
3250
|
+
}
|
|
3251
|
+
const guide = document.createElement("a");
|
|
3252
|
+
guide.href = resolved.debuggingUrl;
|
|
3253
|
+
guide.target = "_blank";
|
|
3254
|
+
guide.rel = "noopener noreferrer";
|
|
3255
|
+
guide.textContent = "Still not working? Open the debugging guide";
|
|
3256
|
+
guide.style.cssText = `color:${brand.mutedColor};font-size:12px;margin-top:18px;text-underline-offset:3px`;
|
|
3257
|
+
container.appendChild(guide);
|
|
3258
|
+
const brandFooter = document.createElement("div");
|
|
3259
|
+
brandFooter.id = "crosslink-offline-brand";
|
|
3260
|
+
brandFooter.style.cssText = "position:absolute;left:0;right:0;bottom:calc(24px + env(safe-area-inset-bottom));display:flex;flex-direction:column;align-items:center;gap:8px";
|
|
3261
|
+
const logoContainer = document.createElement("div");
|
|
3262
|
+
logoContainer.style.cssText = "display:flex;justify-content:center;opacity:.95";
|
|
3263
|
+
logoContainer.innerHTML = crosslinkLogoSvg({ width: "132px", color: brand.logoColor });
|
|
3264
|
+
const attribution = document.createElement("div");
|
|
3265
|
+
attribution.style.cssText = `font-size:11px;color:${brand.attributionColor}`;
|
|
3266
|
+
const prefix = document.createElement("span");
|
|
3267
|
+
prefix.textContent = `${CROSSLINK_ATTRIBUTION_TEXT} `;
|
|
3268
|
+
const link = document.createElement("a");
|
|
3269
|
+
link.href = CROSSLINK_REPOSITORY;
|
|
3270
|
+
link.target = "_blank";
|
|
3271
|
+
link.rel = "noopener noreferrer";
|
|
3272
|
+
link.textContent = CROSSLINK_ATTRIBUTION_LINK_TEXT;
|
|
3273
|
+
link.style.cssText = "color:inherit;font-weight:700;text-underline-offset:2px";
|
|
3274
|
+
attribution.append(prefix, link);
|
|
3275
|
+
brandFooter.append(logoContainer, attribution);
|
|
3276
|
+
container.appendChild(brandFooter);
|
|
3277
|
+
return container;
|
|
3278
|
+
}
|
|
3279
|
+
function updateOfflineStatus(text) {
|
|
3280
|
+
if (typeof document === "undefined") return;
|
|
3281
|
+
const status = document.getElementById("crosslink-offline-status");
|
|
3282
|
+
if (status) status.textContent = text;
|
|
3283
|
+
}
|
|
3284
|
+
function removeOfflineUI() {
|
|
3285
|
+
if (typeof document === "undefined") return;
|
|
3286
|
+
const shell = document.getElementById("crosslink-offline-shell");
|
|
3287
|
+
shell?.remove();
|
|
3288
|
+
}
|
|
3289
|
+
|
|
3290
|
+
// src/offline/mobile-bootstrap.ts
|
|
3291
|
+
import {
|
|
3292
|
+
linkPairingTarget,
|
|
3293
|
+
normalPairingTarget as normalPairingTarget2,
|
|
3294
|
+
parsePairingUri as parsePairingUri2,
|
|
3295
|
+
unwrapBootstrapUri as unwrapBootstrapUri2,
|
|
3296
|
+
BOOTSTRAP_FRAGMENT_KEY
|
|
3297
|
+
} from "@crosslink/core";
|
|
3298
|
+
|
|
3299
|
+
// src/offline/environment.ts
|
|
3300
|
+
function isStandaloneDisplay() {
|
|
3301
|
+
if (typeof window === "undefined") return false;
|
|
3302
|
+
const iosStandalone = window.navigator.standalone === true;
|
|
3303
|
+
const displayMode = typeof window.matchMedia === "function" && window.matchMedia("(display-mode: standalone)").matches;
|
|
3304
|
+
return iosStandalone || displayMode;
|
|
3305
|
+
}
|
|
3306
|
+
function describeBootstrapEnvironment() {
|
|
3307
|
+
if (typeof window === "undefined" || typeof location === "undefined") {
|
|
3308
|
+
return {
|
|
3309
|
+
origin: null,
|
|
3310
|
+
secureContext: false,
|
|
3311
|
+
serviceWorkerAvailable: false,
|
|
3312
|
+
webCryptoAvailable: typeof crypto !== "undefined" && Boolean(crypto.subtle),
|
|
3313
|
+
standalone: false,
|
|
3314
|
+
installable: false,
|
|
3315
|
+
insecureTransportBlocked: false,
|
|
3316
|
+
limitations: []
|
|
3317
|
+
};
|
|
3318
|
+
}
|
|
3319
|
+
const origin = location.origin && location.origin !== "null" ? location.origin : null;
|
|
3320
|
+
const secureContext = window.isSecureContext === true;
|
|
3321
|
+
const serviceWorkerAvailable = secureContext && typeof navigator !== "undefined" && "serviceWorker" in navigator;
|
|
3322
|
+
const webCryptoAvailable = typeof crypto !== "undefined" && Boolean(crypto.subtle);
|
|
3323
|
+
const insecureTransportBlocked = location.protocol === "https:";
|
|
3324
|
+
const limitations = [];
|
|
3325
|
+
if (!secureContext) {
|
|
3326
|
+
limitations.push(
|
|
3327
|
+
`${origin ?? "This origin"} is not a secure context, so the browser will not register a service worker: this device can pair and use the app, but it cannot cache Crosslink's offline screen and an installed launch will show the browser's own error page when the host is unreachable.`
|
|
3328
|
+
);
|
|
3329
|
+
}
|
|
3330
|
+
if (!webCryptoAvailable) {
|
|
3331
|
+
limitations.push(
|
|
3332
|
+
"Web Crypto is unavailable on this origin, so this device's Crosslink identity is stored without encryption at rest. Anything with access to this browser profile can read it."
|
|
3333
|
+
);
|
|
3334
|
+
}
|
|
3335
|
+
if (insecureTransportBlocked) {
|
|
3336
|
+
limitations.push(
|
|
3337
|
+
"This page is served over https, so the browser blocks insecure ws:// routes as mixed content. The host must advertise a wss:// route \u2014 a relay or a tunnel \u2014 for this install to reach it."
|
|
3338
|
+
);
|
|
3339
|
+
}
|
|
3340
|
+
return {
|
|
3341
|
+
origin,
|
|
3342
|
+
secureContext,
|
|
3343
|
+
serviceWorkerAvailable,
|
|
3344
|
+
webCryptoAvailable,
|
|
3345
|
+
standalone: isStandaloneDisplay(),
|
|
3346
|
+
installable: secureContext && serviceWorkerAvailable,
|
|
3347
|
+
insecureTransportBlocked,
|
|
3348
|
+
limitations
|
|
3349
|
+
};
|
|
3350
|
+
}
|
|
3351
|
+
|
|
3352
|
+
// src/offline/mobile-bootstrap.ts
|
|
3353
|
+
var INSTALL_HANDOFF_QUERY_KEY = "crosslink_install";
|
|
3354
|
+
var INSTALL_HANDOFF_COOKIE = "crosslink_install";
|
|
3355
|
+
var INSTALL_HANDOFF_CONTEXT_COOKIE = "crosslink_install_context";
|
|
3356
|
+
function readCookie(name) {
|
|
3357
|
+
if (typeof document === "undefined") return null;
|
|
3358
|
+
const prefix = `${name}=`;
|
|
3359
|
+
for (const part of String(document.cookie ?? "").split(";")) {
|
|
3360
|
+
const trimmed = part.trim();
|
|
3361
|
+
if (trimmed.startsWith(prefix)) return decodeSafely(trimmed.slice(prefix.length));
|
|
3362
|
+
}
|
|
3363
|
+
return null;
|
|
3364
|
+
}
|
|
3365
|
+
function cookieAttributes(maxAge) {
|
|
3366
|
+
const secure = typeof location !== "undefined" && location.protocol === "https:" ? "; Secure" : "";
|
|
3367
|
+
return `; Path=/; Max-Age=${Math.max(0, Math.floor(maxAge))}; SameSite=Strict${secure}`;
|
|
3368
|
+
}
|
|
3369
|
+
function persistInstallHandoff(handoffId, targetUri, expiresAt) {
|
|
3370
|
+
if (typeof document === "undefined") return;
|
|
3371
|
+
const maxAge = Math.max(1, Math.ceil((expiresAt - Date.now()) / 1e3));
|
|
3372
|
+
document.cookie = `${INSTALL_HANDOFF_COOKIE}=${encodeURIComponent(handoffId)}${cookieAttributes(maxAge)}`;
|
|
3373
|
+
document.cookie = `${INSTALL_HANDOFF_CONTEXT_COOKIE}=${encodeURIComponent(JSON.stringify({ targetUri, expiresAt }))}${cookieAttributes(maxAge)}`;
|
|
3374
|
+
}
|
|
3375
|
+
function clearInstallCookies() {
|
|
3376
|
+
if (typeof document === "undefined") return;
|
|
3377
|
+
document.cookie = `${INSTALL_HANDOFF_COOKIE}=${cookieAttributes(0)}`;
|
|
3378
|
+
document.cookie = `${INSTALL_HANDOFF_CONTEXT_COOKIE}=${cookieAttributes(0)}`;
|
|
3379
|
+
}
|
|
3380
|
+
function decodeSafely(value) {
|
|
3381
|
+
try {
|
|
3382
|
+
return decodeURIComponent(value);
|
|
3383
|
+
} catch {
|
|
3384
|
+
return value;
|
|
3385
|
+
}
|
|
3386
|
+
}
|
|
3387
|
+
function isStandalone() {
|
|
3388
|
+
if (typeof window === "undefined") return false;
|
|
3389
|
+
return window.matchMedia?.("(display-mode: standalone)")?.matches === true || window.navigator?.standalone === true;
|
|
3390
|
+
}
|
|
3391
|
+
async function resetDeviceStorage() {
|
|
3392
|
+
if (typeof window === "undefined") return;
|
|
3393
|
+
try {
|
|
3394
|
+
localStorage.clear();
|
|
3395
|
+
sessionStorage.clear();
|
|
3396
|
+
if (window.indexedDB?.databases) {
|
|
3397
|
+
const dbs = await window.indexedDB.databases();
|
|
3398
|
+
for (const db of dbs) {
|
|
3399
|
+
if (db.name) window.indexedDB.deleteDatabase(db.name);
|
|
3400
|
+
}
|
|
3401
|
+
} else {
|
|
3402
|
+
window.indexedDB?.deleteDatabase("crosslink-secure-storage");
|
|
3403
|
+
}
|
|
3404
|
+
if ("caches" in window) {
|
|
3405
|
+
const keys = await caches.keys();
|
|
3406
|
+
for (const k of keys) await caches.delete(k);
|
|
3407
|
+
}
|
|
3408
|
+
} catch (e) {
|
|
3409
|
+
console.warn("[Crosslink] Storage reset warning:", e);
|
|
3410
|
+
}
|
|
3411
|
+
}
|
|
3412
|
+
var BOOTSTRAP_STYLES = `
|
|
3413
|
+
/* \u2500\u2500 Crosslink Mobile Framework Styles \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
3414
|
+
.cl-screen-overlay {
|
|
3415
|
+
position: fixed;
|
|
3416
|
+
inset: 0;
|
|
3417
|
+
z-index: 99999;
|
|
3418
|
+
background: #000000;
|
|
3419
|
+
color: #ffffff;
|
|
3420
|
+
display: flex;
|
|
3421
|
+
flex-direction: column;
|
|
3422
|
+
align-items: center;
|
|
3423
|
+
justify-content: center;
|
|
3424
|
+
padding: 32px 20px;
|
|
3425
|
+
box-sizing: border-box;
|
|
3426
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
3427
|
+
text-align: center;
|
|
3428
|
+
overflow-y: auto;
|
|
3429
|
+
-webkit-tap-highlight-color: transparent;
|
|
3430
|
+
}
|
|
3431
|
+
.cl-screen-overlay * {
|
|
3432
|
+
box-sizing: border-box;
|
|
3433
|
+
}
|
|
3434
|
+
|
|
3435
|
+
/* \u2500\u2500 Crosslink Logo \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
3436
|
+
.cl-crosslink-logo {
|
|
3437
|
+
width: 150px;
|
|
3438
|
+
height: auto;
|
|
3439
|
+
margin-bottom: 20px;
|
|
3440
|
+
display: block;
|
|
3441
|
+
opacity: 0.95;
|
|
3442
|
+
}
|
|
3443
|
+
|
|
3444
|
+
/* \u2500\u2500 Screen A: Pairing Screen \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
3445
|
+
.cl-pair-screen {
|
|
3446
|
+
background: #000000;
|
|
3447
|
+
gap: 20px;
|
|
3448
|
+
}
|
|
3449
|
+
.cl-pair-title {
|
|
3450
|
+
font-size: 22px;
|
|
3451
|
+
font-weight: 700;
|
|
3452
|
+
letter-spacing: -0.02em;
|
|
3453
|
+
margin: 0;
|
|
3454
|
+
color: #ffffff;
|
|
3455
|
+
}
|
|
3456
|
+
.cl-pair-desc {
|
|
3457
|
+
font-size: 14px;
|
|
3458
|
+
color: #a1a1aa;
|
|
3459
|
+
max-width: 290px;
|
|
3460
|
+
line-height: 1.5;
|
|
3461
|
+
margin: 0;
|
|
3462
|
+
}
|
|
3463
|
+
.cl-pair-grid {
|
|
3464
|
+
display: grid;
|
|
3465
|
+
grid-template-columns: repeat(3, 1fr);
|
|
3466
|
+
gap: 10px;
|
|
3467
|
+
max-width: 260px;
|
|
3468
|
+
margin: 8px 0;
|
|
3469
|
+
}
|
|
3470
|
+
.cl-pair-digit {
|
|
3471
|
+
width: 72px;
|
|
3472
|
+
height: 64px;
|
|
3473
|
+
font-size: 26px;
|
|
3474
|
+
text-align: center;
|
|
3475
|
+
border-radius: 14px;
|
|
3476
|
+
border: 1px solid #27272a;
|
|
3477
|
+
background: #111111;
|
|
3478
|
+
color: #ffffff;
|
|
3479
|
+
font-weight: 700;
|
|
3480
|
+
outline: none;
|
|
3481
|
+
font-family: inherit;
|
|
3482
|
+
transition: border-color 0.15s, box-shadow 0.15s;
|
|
3483
|
+
}
|
|
3484
|
+
.cl-pair-digit:focus {
|
|
3485
|
+
border-color: #ffffff;
|
|
3486
|
+
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.2);
|
|
3487
|
+
}
|
|
3488
|
+
.cl-pair-err {
|
|
3489
|
+
font-size: 13px;
|
|
3490
|
+
color: #f87171;
|
|
3491
|
+
min-height: 20px;
|
|
3492
|
+
margin: 0;
|
|
3493
|
+
line-height: 1.4;
|
|
3494
|
+
max-width: 280px;
|
|
3495
|
+
}
|
|
3496
|
+
.cl-pair-reset {
|
|
3497
|
+
margin-top: 12px;
|
|
3498
|
+
background: transparent;
|
|
3499
|
+
border: none;
|
|
3500
|
+
color: #71717a;
|
|
3501
|
+
font-size: 12px;
|
|
3502
|
+
cursor: pointer;
|
|
3503
|
+
padding: 6px 12px;
|
|
3504
|
+
border-radius: 6px;
|
|
3505
|
+
text-decoration: underline;
|
|
3506
|
+
transition: color 0.15s;
|
|
3507
|
+
}
|
|
3508
|
+
.cl-pair-reset:hover {
|
|
3509
|
+
color: #a1a1aa;
|
|
3510
|
+
}
|
|
3511
|
+
|
|
3512
|
+
/* \u2500\u2500 Screen B: Add to Home Screen (Screen B) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
3513
|
+
.cl-bootstrap-screen {
|
|
3514
|
+
background: #000000;
|
|
3515
|
+
justify-content: center;
|
|
3516
|
+
position: fixed;
|
|
3517
|
+
inset: 0;
|
|
3518
|
+
z-index: 100000;
|
|
3519
|
+
height: 100dvh;
|
|
3520
|
+
}
|
|
3521
|
+
.cl-bootstrap-appname {
|
|
3522
|
+
font-size: 21px;
|
|
3523
|
+
font-weight: 600;
|
|
3524
|
+
color: #ffffff;
|
|
3525
|
+
margin-top: 12px;
|
|
3526
|
+
letter-spacing: -0.01em;
|
|
3527
|
+
}
|
|
3528
|
+
.cl-continue-btn {
|
|
3529
|
+
margin-top: 20px;
|
|
3530
|
+
background: rgba(255, 255, 255, 0.15);
|
|
3531
|
+
border: 1px solid rgba(255, 255, 255, 0.25);
|
|
3532
|
+
color: #ffffff;
|
|
3533
|
+
padding: 11px 24px;
|
|
3534
|
+
border-radius: 999px;
|
|
3535
|
+
font-size: 14px;
|
|
3536
|
+
font-weight: 600;
|
|
3537
|
+
cursor: pointer;
|
|
3538
|
+
transition: background 0.15s;
|
|
3539
|
+
}
|
|
3540
|
+
.cl-continue-btn:hover {
|
|
3541
|
+
background: rgba(255, 255, 255, 0.25);
|
|
3542
|
+
}
|
|
3543
|
+
.cl-bootstrap-nudge {
|
|
3544
|
+
position: absolute;
|
|
3545
|
+
left: 0;
|
|
3546
|
+
right: 0;
|
|
3547
|
+
bottom: calc(4px + env(safe-area-inset-bottom));
|
|
3548
|
+
display: flex;
|
|
3549
|
+
flex-direction: column;
|
|
3550
|
+
align-items: center;
|
|
3551
|
+
gap: 2px;
|
|
3552
|
+
pointer-events: none;
|
|
3553
|
+
}
|
|
3554
|
+
.cl-bootstrap-nudge span {
|
|
3555
|
+
font-family: "Caveat", "Segoe Script", "Bradley Hand", cursive, sans-serif;
|
|
3556
|
+
font-size: 21px;
|
|
3557
|
+
color: #ffffff;
|
|
3558
|
+
opacity: 0.92;
|
|
3559
|
+
text-align: center;
|
|
3560
|
+
max-width: 280px;
|
|
3561
|
+
line-height: 1.2;
|
|
3562
|
+
}
|
|
3563
|
+
.cl-bootstrap-nudge svg {
|
|
3564
|
+
width: 34px;
|
|
3565
|
+
height: 52px;
|
|
3566
|
+
color: #ffffff;
|
|
3567
|
+
opacity: 0.92;
|
|
3568
|
+
}
|
|
3569
|
+
|
|
3570
|
+
/* \u2500\u2500 SAS Modal \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
3571
|
+
.cl-sas-modal {
|
|
3572
|
+
position: fixed;
|
|
3573
|
+
inset: 0;
|
|
3574
|
+
z-index: 100001;
|
|
3575
|
+
background: #000000;
|
|
3576
|
+
display: flex;
|
|
3577
|
+
flex-direction: column;
|
|
3578
|
+
align-items: center;
|
|
3579
|
+
justify-content: center;
|
|
3580
|
+
padding: 32px 20px;
|
|
3581
|
+
gap: 14px;
|
|
3582
|
+
text-align: center;
|
|
3583
|
+
box-sizing: border-box;
|
|
3584
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
3585
|
+
}
|
|
3586
|
+
.cl-sas-modal h2 { font-size: 18px; color: #fff; margin: 0; }
|
|
3587
|
+
.cl-sas-modal p { color: #a1a1aa; font-size: 13px; margin: 0; }
|
|
3588
|
+
.cl-sas-grid {
|
|
3589
|
+
display: grid;
|
|
3590
|
+
grid-template-columns: repeat(3, 1fr);
|
|
3591
|
+
gap: 8px;
|
|
3592
|
+
width: 100%;
|
|
3593
|
+
max-width: 240px;
|
|
3594
|
+
margin: 10px 0;
|
|
3595
|
+
}
|
|
3596
|
+
.cl-sas-grid span {
|
|
3597
|
+
display: grid;
|
|
3598
|
+
place-items: center;
|
|
3599
|
+
aspect-ratio: 1.5;
|
|
3600
|
+
background: #111111;
|
|
3601
|
+
border: 1px solid #27272a;
|
|
3602
|
+
border-radius: 10px;
|
|
3603
|
+
font-size: 24px;
|
|
3604
|
+
font-weight: 700;
|
|
3605
|
+
color: #ffffff;
|
|
3606
|
+
font-variant-numeric: tabular-nums;
|
|
3607
|
+
}
|
|
3608
|
+
.cl-sas-caps { color: #a1a1aa; font-size: 12px; }
|
|
3609
|
+
.cl-sas-actions { display: flex; gap: 12px; margin-top: 10px; }
|
|
3610
|
+
.cl-sas-actions button {
|
|
3611
|
+
padding: 10px 22px;
|
|
3612
|
+
border-radius: 999px;
|
|
3613
|
+
border: none;
|
|
3614
|
+
font: inherit;
|
|
3615
|
+
font-size: 14px;
|
|
3616
|
+
font-weight: 600;
|
|
3617
|
+
cursor: pointer;
|
|
3618
|
+
}
|
|
3619
|
+
.cl-sas-ok { background: #ffffff; color: #000000; }
|
|
3620
|
+
.cl-sas-no { background: #111111; color: #ffffff; border: 1px solid #27272a !important; }
|
|
3621
|
+
`.trim();
|
|
3622
|
+
var stylesInjected2 = false;
|
|
3623
|
+
function injectBootstrapStyles() {
|
|
3624
|
+
if (stylesInjected2 || typeof document === "undefined") return;
|
|
3625
|
+
const style = document.createElement("style");
|
|
3626
|
+
style.id = "crosslink-bootstrap-styles";
|
|
3627
|
+
style.textContent = BOOTSTRAP_STYLES;
|
|
3628
|
+
document.head.appendChild(style);
|
|
3629
|
+
stylesInjected2 = true;
|
|
3630
|
+
}
|
|
3631
|
+
var CrosslinkMobileBootstrap = class {
|
|
3632
|
+
client;
|
|
3633
|
+
options;
|
|
3634
|
+
state = "initializing";
|
|
3635
|
+
currentScreenElement = null;
|
|
3636
|
+
reconnectTimer = null;
|
|
3637
|
+
reachabilityTimer = null;
|
|
3638
|
+
visibilityHandler = null;
|
|
3639
|
+
onlineHandler = null;
|
|
3640
|
+
isAttempting = false;
|
|
3641
|
+
attemptCount = 0;
|
|
3642
|
+
activeRpc = null;
|
|
3643
|
+
targetPairingUri = null;
|
|
3644
|
+
poweredBy = null;
|
|
3645
|
+
environment = null;
|
|
3646
|
+
constructor(options) {
|
|
3647
|
+
this.options = options;
|
|
3648
|
+
injectBootstrapStyles();
|
|
3649
|
+
if (options.client) {
|
|
3650
|
+
this.client = options.client;
|
|
3651
|
+
} else {
|
|
3652
|
+
const clientOpts = {
|
|
3653
|
+
...options.clientOptions,
|
|
3654
|
+
...options.clientOptions?.storage ? {} : typeof localStorage !== "undefined" ? { storage: new LocalStorageSecureStorage(localStorage) } : {},
|
|
3655
|
+
deviceName: options.clientOptions?.deviceName ?? "mobile",
|
|
3656
|
+
onConfirmPairing: (req) => this.showSasConfirmation(req),
|
|
3657
|
+
onStateChange: (state, detail) => this.handleClientStateChange(state, detail)
|
|
3658
|
+
};
|
|
3659
|
+
this.client = new CrosslinkClient(clientOpts);
|
|
3660
|
+
}
|
|
3661
|
+
this.setupListeners();
|
|
3662
|
+
}
|
|
3663
|
+
/**
|
|
3664
|
+
* Start the authoritative bootstrap state machine.
|
|
3665
|
+
* This is the single entry point controlling what the mobile device sees.
|
|
3666
|
+
*/
|
|
3667
|
+
async start() {
|
|
3668
|
+
this.ensurePoweredBy();
|
|
3669
|
+
this.environment = describeBootstrapEnvironment();
|
|
3670
|
+
for (const limitation of this.environment.limitations) {
|
|
3671
|
+
console.warn(`[crosslink] ${limitation}`);
|
|
3672
|
+
}
|
|
3673
|
+
this.options.onEnvironment?.(this.environment);
|
|
3674
|
+
if (this.options.autoRegisterServiceWorker !== false && this.environment.serviceWorkerAvailable) {
|
|
3675
|
+
try {
|
|
3676
|
+
await navigator.serviceWorker.register(this.options.serviceWorkerUrl ?? "/sw.js");
|
|
3677
|
+
} catch (e) {
|
|
3678
|
+
console.warn("[crosslink] service worker registration failed", e);
|
|
3679
|
+
}
|
|
3680
|
+
}
|
|
3681
|
+
this.extractPairingUriFromLocation();
|
|
3682
|
+
if (typeof location !== "undefined") {
|
|
3683
|
+
const params = new URLSearchParams(location.search);
|
|
3684
|
+
if (params.has("reset") || location.hash.includes("reset")) {
|
|
3685
|
+
await resetDeviceStorage();
|
|
3686
|
+
if (this.targetPairingUri) {
|
|
3687
|
+
try {
|
|
3688
|
+
localStorage.setItem("crosslink.pendingPair", this.targetPairingUri);
|
|
3689
|
+
} catch {
|
|
3690
|
+
}
|
|
3691
|
+
}
|
|
3692
|
+
location.href = location.pathname;
|
|
3693
|
+
return;
|
|
3694
|
+
}
|
|
3695
|
+
}
|
|
3696
|
+
const installHandoff = await this.discoverInstallHandoff();
|
|
3697
|
+
if (this.client.listApps().length === 0 && installHandoff) {
|
|
3698
|
+
try {
|
|
3699
|
+
console.info("[crosslink/install] redeeming handoff", {
|
|
3700
|
+
standalone: isStandalone(),
|
|
3701
|
+
source: installHandoff.source
|
|
3702
|
+
});
|
|
3703
|
+
const uri = linkPairingTarget(installHandoff.targetUri, installHandoff.handoffId);
|
|
3704
|
+
await this.client.pairFromQr(uri, this.options.capabilities);
|
|
3705
|
+
this.targetPairingUri = installHandoff.targetUri;
|
|
3706
|
+
this.clearInstallState();
|
|
3707
|
+
console.info("[crosslink/install] linked device created; credentials persisted");
|
|
3708
|
+
} catch (err) {
|
|
3709
|
+
console.warn("[crosslink/install] handoff unavailable/expired; falling back to normal pairing", {
|
|
3710
|
+
error: String(err?.message ?? err)
|
|
3711
|
+
});
|
|
3712
|
+
this.targetPairingUri = installHandoff.targetUri;
|
|
3713
|
+
this.clearInstallState();
|
|
3714
|
+
}
|
|
3715
|
+
}
|
|
3716
|
+
const apps = this.client.listApps();
|
|
3717
|
+
if (apps.length === 0) {
|
|
3718
|
+
this.transitionTo("pairing-required");
|
|
3719
|
+
return;
|
|
3720
|
+
}
|
|
3721
|
+
await this.attemptSilentAuth(apps[0]);
|
|
3722
|
+
}
|
|
3723
|
+
extractPairingUriFromLocation() {
|
|
3724
|
+
if (this.options.pairingUri) {
|
|
3725
|
+
this.targetPairingUri = this.options.pairingUri;
|
|
3726
|
+
return;
|
|
3727
|
+
}
|
|
3728
|
+
if (typeof location === "undefined") return;
|
|
3729
|
+
const fromHash = new URLSearchParams(location.hash.replace(/^#/, "")).get(BOOTSTRAP_FRAGMENT_KEY);
|
|
3730
|
+
const fromQuery = new URLSearchParams(location.search).get(BOOTSTRAP_FRAGMENT_KEY);
|
|
3731
|
+
const pairParam = fromHash || fromQuery || "";
|
|
3732
|
+
if (pairParam) {
|
|
3733
|
+
this.targetPairingUri = unwrapBootstrapUri2(decodeSafely(pairParam));
|
|
3734
|
+
try {
|
|
3735
|
+
localStorage.setItem("crosslink.pendingPair", this.targetPairingUri);
|
|
3736
|
+
} catch {
|
|
3737
|
+
}
|
|
3738
|
+
} else {
|
|
3739
|
+
try {
|
|
3740
|
+
this.targetPairingUri = localStorage.getItem("crosslink.pendingPair");
|
|
3741
|
+
} catch {
|
|
3742
|
+
}
|
|
3743
|
+
}
|
|
3744
|
+
}
|
|
3745
|
+
async discoverInstallHandoff() {
|
|
3746
|
+
let urlId = "";
|
|
3747
|
+
if (typeof location !== "undefined") {
|
|
3748
|
+
urlId = new URLSearchParams(location.search).get(INSTALL_HANDOFF_QUERY_KEY) ?? "";
|
|
3749
|
+
}
|
|
3750
|
+
const cookieId = readCookie(INSTALL_HANDOFF_COOKIE) ?? "";
|
|
3751
|
+
const contextRaw = readCookie(INSTALL_HANDOFF_CONTEXT_COOKIE);
|
|
3752
|
+
let targetUri = "";
|
|
3753
|
+
let expiresAt = 0;
|
|
3754
|
+
if (contextRaw) {
|
|
3755
|
+
try {
|
|
3756
|
+
const context = JSON.parse(contextRaw);
|
|
3757
|
+
targetUri = typeof context.targetUri === "string" ? normalPairingTarget2(context.targetUri) : "";
|
|
3758
|
+
expiresAt = typeof context.expiresAt === "number" ? context.expiresAt : 0;
|
|
3759
|
+
} catch {
|
|
3760
|
+
}
|
|
3761
|
+
}
|
|
3762
|
+
const handoffId = cookieId || urlId;
|
|
3763
|
+
if (handoffId && targetUri && expiresAt > Date.now()) {
|
|
3764
|
+
return {
|
|
3765
|
+
handoffId,
|
|
3766
|
+
targetUri,
|
|
3767
|
+
expiresAt,
|
|
3768
|
+
source: cookieId ? "cookie" : "url"
|
|
3769
|
+
};
|
|
3770
|
+
}
|
|
3771
|
+
if (urlId && typeof fetch === "function") {
|
|
3772
|
+
try {
|
|
3773
|
+
const response = await fetch(`/__crosslink/install/${encodeURIComponent(urlId)}`, {
|
|
3774
|
+
credentials: "same-origin",
|
|
3775
|
+
cache: "no-store"
|
|
3776
|
+
});
|
|
3777
|
+
if (response.ok) {
|
|
3778
|
+
const recovered = await response.json();
|
|
3779
|
+
if (typeof recovered.uri === "string" && typeof recovered.expiresAt === "number") {
|
|
3780
|
+
return {
|
|
3781
|
+
handoffId: urlId,
|
|
3782
|
+
targetUri: normalPairingTarget2(recovered.uri),
|
|
3783
|
+
expiresAt: recovered.expiresAt,
|
|
3784
|
+
source: "url"
|
|
3785
|
+
};
|
|
3786
|
+
}
|
|
3787
|
+
}
|
|
3788
|
+
} catch {
|
|
3789
|
+
}
|
|
3790
|
+
}
|
|
3791
|
+
if (handoffId || contextRaw) {
|
|
3792
|
+
if (targetUri) this.targetPairingUri = targetUri;
|
|
3793
|
+
this.clearInstallState();
|
|
3794
|
+
}
|
|
3795
|
+
if (this.targetPairingUri) {
|
|
3796
|
+
try {
|
|
3797
|
+
const parsed = parsePairingUri2(unwrapBootstrapUri2(this.targetPairingUri));
|
|
3798
|
+
if (parsed.link && parsed.code) {
|
|
3799
|
+
return {
|
|
3800
|
+
handoffId: parsed.code,
|
|
3801
|
+
targetUri: normalPairingTarget2(this.targetPairingUri),
|
|
3802
|
+
expiresAt: Number.MAX_SAFE_INTEGER,
|
|
3803
|
+
source: "legacy-link"
|
|
3804
|
+
};
|
|
3805
|
+
}
|
|
3806
|
+
} catch {
|
|
3807
|
+
}
|
|
3808
|
+
}
|
|
3809
|
+
return null;
|
|
3810
|
+
}
|
|
3811
|
+
clearInstallState() {
|
|
3812
|
+
clearInstallCookies();
|
|
3813
|
+
if (typeof localStorage !== "undefined") localStorage.removeItem("crosslink.pendingPair");
|
|
3814
|
+
if (typeof location === "undefined" || typeof history === "undefined") return;
|
|
3815
|
+
try {
|
|
3816
|
+
const clean = new URL(location.href);
|
|
3817
|
+
clean.searchParams.delete(INSTALL_HANDOFF_QUERY_KEY);
|
|
3818
|
+
const hashParams = new URLSearchParams(clean.hash.replace(/^#/, ""));
|
|
3819
|
+
const pair = hashParams.get(BOOTSTRAP_FRAGMENT_KEY);
|
|
3820
|
+
if (pair) {
|
|
3821
|
+
try {
|
|
3822
|
+
if (parsePairingUri2(unwrapBootstrapUri2(decodeSafely(pair))).link) {
|
|
3823
|
+
hashParams.delete(BOOTSTRAP_FRAGMENT_KEY);
|
|
3824
|
+
}
|
|
3825
|
+
} catch {
|
|
3826
|
+
}
|
|
3827
|
+
}
|
|
3828
|
+
clean.hash = hashParams.toString() ? `#${hashParams.toString()}` : "";
|
|
3829
|
+
history.replaceState(history.state, "", `${clean.pathname}${clean.search}${clean.hash}`);
|
|
3830
|
+
} catch {
|
|
3831
|
+
}
|
|
3832
|
+
}
|
|
3833
|
+
getEffectiveAppId() {
|
|
3834
|
+
if (this.options.appId) return this.options.appId;
|
|
3835
|
+
const apps = this.client.listApps();
|
|
3836
|
+
if (apps.length > 0) return apps[0].appId;
|
|
3837
|
+
if (this.targetPairingUri) {
|
|
3838
|
+
try {
|
|
3839
|
+
const parsed = parsePairingUri2(this.targetPairingUri);
|
|
3840
|
+
if (parsed.appId) return parsed.appId;
|
|
3841
|
+
} catch {
|
|
3842
|
+
}
|
|
3843
|
+
}
|
|
3844
|
+
return "default";
|
|
3845
|
+
}
|
|
3846
|
+
completedOnboardingApps = /* @__PURE__ */ new Set();
|
|
3847
|
+
isOnboardingCompleted(appId) {
|
|
3848
|
+
if (isStandalone()) return true;
|
|
3849
|
+
if (this.completedOnboardingApps.has(appId)) return true;
|
|
3850
|
+
if (typeof localStorage !== "undefined") {
|
|
3851
|
+
return localStorage.getItem(`crosslink.onboarding.${appId}`) === "true";
|
|
3852
|
+
}
|
|
3853
|
+
return false;
|
|
3854
|
+
}
|
|
3855
|
+
markOnboardingCompleted(appId) {
|
|
3856
|
+
this.completedOnboardingApps.add(appId);
|
|
3857
|
+
if (typeof localStorage !== "undefined") {
|
|
3858
|
+
localStorage.setItem(`crosslink.onboarding.${appId}`, "true");
|
|
3859
|
+
}
|
|
3860
|
+
}
|
|
3861
|
+
clearOnboarding(appId) {
|
|
3862
|
+
this.completedOnboardingApps.delete(appId);
|
|
3863
|
+
if (typeof localStorage !== "undefined") {
|
|
3864
|
+
localStorage.removeItem(`crosslink.onboarding.${appId}`);
|
|
3865
|
+
}
|
|
3866
|
+
}
|
|
3867
|
+
/** Prepares the cookie-first Safari -> standalone install boundary. */
|
|
3868
|
+
async prepareDeviceLinkHandoff() {
|
|
3869
|
+
if (isStandalone() || typeof location === "undefined") return;
|
|
3870
|
+
try {
|
|
3871
|
+
const { handoffId, uri, expiresAt } = await this.client.createDeviceLink();
|
|
3872
|
+
const targetUri = normalPairingTarget2(uri);
|
|
3873
|
+
this.targetPairingUri = targetUri;
|
|
3874
|
+
persistInstallHandoff(handoffId, targetUri, expiresAt);
|
|
3875
|
+
const manifest = document.querySelector?.('link[rel="manifest"]');
|
|
3876
|
+
if (manifest?.href) {
|
|
3877
|
+
const manifestUrl = new URL(manifest.href, location.href);
|
|
3878
|
+
manifestUrl.searchParams.set(INSTALL_HANDOFF_QUERY_KEY, handoffId);
|
|
3879
|
+
manifestUrl.searchParams.set("v", String(expiresAt));
|
|
3880
|
+
manifest.href = manifestUrl.toString();
|
|
3881
|
+
}
|
|
3882
|
+
if (typeof history !== "undefined") {
|
|
3883
|
+
const launch = new URL(location.href);
|
|
3884
|
+
launch.searchParams.set(INSTALL_HANDOFF_QUERY_KEY, handoffId);
|
|
3885
|
+
launch.hash = "";
|
|
3886
|
+
history.replaceState(history.state, "", `${launch.pathname}${launch.search}`);
|
|
3887
|
+
}
|
|
3888
|
+
console.info("[crosslink/install] handoff prepared", {
|
|
3889
|
+
currentPath: location.pathname,
|
|
3890
|
+
manifestPath: manifest?.href ? new URL(manifest.href).pathname : null,
|
|
3891
|
+
launchHasHandoff: true,
|
|
3892
|
+
expiresAt
|
|
3893
|
+
});
|
|
3894
|
+
} catch (err) {
|
|
3895
|
+
console.warn("[crosslink/install] could not prepare handoff", {
|
|
3896
|
+
error: String(err?.message ?? err)
|
|
3897
|
+
});
|
|
3898
|
+
}
|
|
3899
|
+
}
|
|
3900
|
+
/**
|
|
3901
|
+
* Silent cryptographic authentication with stored credentials.
|
|
3902
|
+
*/
|
|
3903
|
+
async attemptSilentAuth(app) {
|
|
3904
|
+
if (this.isAttempting) return;
|
|
3905
|
+
this.isAttempting = true;
|
|
3906
|
+
try {
|
|
3907
|
+
const rpc = await this.client.connect(app.appId);
|
|
3908
|
+
this.activeRpc = rpc;
|
|
3909
|
+
this.isAttempting = false;
|
|
3910
|
+
this.cancelTimers();
|
|
3911
|
+
if (!this.isOnboardingCompleted(app.appId)) {
|
|
3912
|
+
await this.prepareDeviceLinkHandoff();
|
|
3913
|
+
this.transitionTo("add-to-home-screen");
|
|
3914
|
+
} else {
|
|
3915
|
+
this.transitionTo("authorized");
|
|
3916
|
+
}
|
|
3917
|
+
} catch (err) {
|
|
3918
|
+
this.isAttempting = false;
|
|
3919
|
+
await this.handleAuthError(err, app);
|
|
3920
|
+
}
|
|
3921
|
+
}
|
|
3922
|
+
async handleAuthError(err, app) {
|
|
3923
|
+
const msg = String(err?.message ?? err ?? "").toLowerCase();
|
|
3924
|
+
const clientState = this.client.state;
|
|
3925
|
+
const isAuthFailure = msg.includes("revoked") || msg.includes("device_revoked") || msg.includes("device-revoked") || msg.includes("unauthorized") || msg.includes("not paired") || msg.includes("signature invalid") || msg.includes("challenge nonce") || clientState === "revoked" || clientState === "unauthorized";
|
|
3926
|
+
if (isAuthFailure) {
|
|
3927
|
+
this.client.forget(app.appId);
|
|
3928
|
+
this.clearOnboarding(app.appId);
|
|
3929
|
+
this.transitionTo("pairing-required", { reason: "revoked" });
|
|
3930
|
+
return;
|
|
3931
|
+
}
|
|
3932
|
+
this.transitionTo("offline");
|
|
3933
|
+
this.scheduleReconnect();
|
|
3934
|
+
}
|
|
3935
|
+
handleClientStateChange(state, detail) {
|
|
3936
|
+
if (state === "revoked" || state === "unauthorized") {
|
|
3937
|
+
const appId = this.getEffectiveAppId();
|
|
3938
|
+
this.client.forget(appId);
|
|
3939
|
+
this.clearOnboarding(appId);
|
|
3940
|
+
this.transitionTo("pairing-required", { reason: "revoked", ...detail });
|
|
3941
|
+
} else if (state === "offline" && this.state === "authorized") {
|
|
3942
|
+
this.transitionTo("offline");
|
|
3943
|
+
this.scheduleReconnect();
|
|
3944
|
+
}
|
|
3945
|
+
}
|
|
3946
|
+
/**
|
|
3947
|
+
* Transition state machine to a new state and mount appropriate UI.
|
|
3948
|
+
*/
|
|
3949
|
+
transitionTo(newState, detail) {
|
|
3950
|
+
this.ensurePoweredBy();
|
|
3951
|
+
this.state = newState;
|
|
3952
|
+
this.options.onStateChange?.(newState, detail);
|
|
3953
|
+
this.unmountCurrentScreen();
|
|
3954
|
+
if (newState === "authorized") {
|
|
3955
|
+
let rpc = this.activeRpc;
|
|
3956
|
+
if (!rpc) {
|
|
3957
|
+
try {
|
|
3958
|
+
rpc = this.client.rpc();
|
|
3959
|
+
} catch {
|
|
3960
|
+
}
|
|
3961
|
+
}
|
|
3962
|
+
if (rpc) {
|
|
3963
|
+
this.options.onAuthorized(rpc, this.client);
|
|
3964
|
+
}
|
|
3965
|
+
return;
|
|
3966
|
+
}
|
|
3967
|
+
this.options.onUnauthorized?.();
|
|
3968
|
+
const container = this.options.container ?? (typeof document !== "undefined" ? document.body : null);
|
|
3969
|
+
if (!container) return;
|
|
3970
|
+
switch (newState) {
|
|
3971
|
+
case "pairing-required":
|
|
3972
|
+
case "pairing": {
|
|
3973
|
+
const pairingEl = this.createPairingScreen((code) => this.handlePairingSubmit(code));
|
|
3974
|
+
this.currentScreenElement = pairingEl;
|
|
3975
|
+
container.appendChild(pairingEl);
|
|
3976
|
+
break;
|
|
3977
|
+
}
|
|
3978
|
+
case "add-to-home-screen": {
|
|
3979
|
+
const appId = this.getEffectiveAppId();
|
|
3980
|
+
const bootstrapEl = this.createAddToHomeScreen(() => {
|
|
3981
|
+
this.markOnboardingCompleted(appId);
|
|
3982
|
+
this.transitionTo("authorized");
|
|
3983
|
+
});
|
|
3984
|
+
this.currentScreenElement = bootstrapEl;
|
|
3985
|
+
container.appendChild(bootstrapEl);
|
|
3986
|
+
break;
|
|
3987
|
+
}
|
|
3988
|
+
case "offline": {
|
|
3989
|
+
const offlineConfig = {
|
|
3990
|
+
...DEFAULT_OFFLINE_CONFIG,
|
|
3991
|
+
...this.options.offline,
|
|
3992
|
+
appName: this.options.appName || this.options.offline?.appName || "Crosslink"
|
|
3993
|
+
};
|
|
3994
|
+
const offlineEl = createOfflineUI(offlineConfig, () => this.forceReconnect());
|
|
3995
|
+
this.currentScreenElement = offlineEl;
|
|
3996
|
+
container.appendChild(offlineEl);
|
|
3997
|
+
break;
|
|
3998
|
+
}
|
|
3999
|
+
}
|
|
4000
|
+
}
|
|
4001
|
+
unmountCurrentScreen() {
|
|
4002
|
+
if (this.currentScreenElement && this.currentScreenElement.parentElement) {
|
|
4003
|
+
this.currentScreenElement.remove();
|
|
4004
|
+
}
|
|
4005
|
+
this.currentScreenElement = null;
|
|
4006
|
+
}
|
|
4007
|
+
ensurePoweredBy() {
|
|
4008
|
+
if (typeof document === "undefined") return;
|
|
4009
|
+
if (!this.poweredBy) this.poweredBy = new PoweredByCrosslink(this.options.poweredBy);
|
|
4010
|
+
const target = this.options.poweredBy?.target ?? document.body;
|
|
4011
|
+
this.poweredBy.mount(target);
|
|
4012
|
+
}
|
|
4013
|
+
/**
|
|
4014
|
+
* Handle user submitting pairing code on Pairing Screen (Screen A).
|
|
4015
|
+
*/
|
|
4016
|
+
async handlePairingSubmit(code) {
|
|
4017
|
+
const errEl = document.getElementById("cl-pair-err");
|
|
4018
|
+
if (errEl) {
|
|
4019
|
+
errEl.textContent = "Verifying pairing code\u2026";
|
|
4020
|
+
errEl.style.color = "#38bdf8";
|
|
4021
|
+
}
|
|
4022
|
+
try {
|
|
4023
|
+
const targetUri = this.targetPairingUri ?? (typeof localStorage !== "undefined" ? localStorage.getItem("crosslink.pendingPair") : null);
|
|
4024
|
+
if (!targetUri) {
|
|
4025
|
+
throw new Error("scan the QR code on your computer to start pairing");
|
|
4026
|
+
}
|
|
4027
|
+
const normalTarget = normalPairingTarget2(targetUri);
|
|
4028
|
+
this.targetPairingUri = normalTarget;
|
|
4029
|
+
this.clearInstallState();
|
|
4030
|
+
console.info("[crosslink/pairing] falling back to NORMAL pairing");
|
|
4031
|
+
await this.client.pairWithCode(normalTarget, code, this.options.capabilities ?? []);
|
|
4032
|
+
if (typeof localStorage !== "undefined") localStorage.removeItem("crosslink.pendingPair");
|
|
4033
|
+
const apps = this.client.listApps();
|
|
4034
|
+
if (apps.length > 0) {
|
|
4035
|
+
await this.attemptSilentAuth(apps[0]);
|
|
4036
|
+
}
|
|
4037
|
+
} catch (err) {
|
|
4038
|
+
if (errEl) {
|
|
4039
|
+
errEl.textContent = `Pairing failed: ${err.message || String(err)}`;
|
|
4040
|
+
errEl.style.color = "#f87171";
|
|
4041
|
+
}
|
|
4042
|
+
}
|
|
4043
|
+
}
|
|
4044
|
+
/**
|
|
4045
|
+
* Create prebuilt Pairing Screen UI (Screen A).
|
|
4046
|
+
*/
|
|
4047
|
+
createPairingScreen(onVerify) {
|
|
4048
|
+
const overlay = document.createElement("div");
|
|
4049
|
+
overlay.id = "crosslink-pairing-screen";
|
|
4050
|
+
overlay.className = "cl-screen-overlay cl-pair-screen";
|
|
4051
|
+
const logoContainer = document.createElement("div");
|
|
4052
|
+
logoContainer.style.cssText = "display:flex;justify-content:center";
|
|
4053
|
+
logoContainer.innerHTML = crosslinkLogoSvg({ width: "140px", className: "cl-crosslink-logo" });
|
|
4054
|
+
overlay.appendChild(logoContainer);
|
|
4055
|
+
const title = document.createElement("h2");
|
|
4056
|
+
title.className = "cl-pair-title";
|
|
4057
|
+
title.textContent = "Pairing Required";
|
|
4058
|
+
overlay.appendChild(title);
|
|
4059
|
+
const desc = document.createElement("p");
|
|
4060
|
+
desc.className = "cl-pair-desc";
|
|
4061
|
+
desc.textContent = "Type the 9-digit pairing code shown on your computer to connect.";
|
|
4062
|
+
overlay.appendChild(desc);
|
|
4063
|
+
const grid = document.createElement("div");
|
|
4064
|
+
grid.className = "cl-pair-grid";
|
|
4065
|
+
const inputs = [];
|
|
4066
|
+
for (let i = 0; i < 9; i++) {
|
|
4067
|
+
const input = document.createElement("input");
|
|
4068
|
+
input.type = "text";
|
|
4069
|
+
input.inputMode = "numeric";
|
|
4070
|
+
input.maxLength = 1;
|
|
4071
|
+
input.className = "cl-pair-digit";
|
|
4072
|
+
input.setAttribute("aria-label", `Digit ${i + 1}`);
|
|
4073
|
+
input.addEventListener("input", (e) => {
|
|
4074
|
+
const val = input.value.replace(/\D/g, "");
|
|
4075
|
+
input.value = val ? val[0] : "";
|
|
4076
|
+
if (val && i < 8) {
|
|
4077
|
+
inputs[i + 1].focus();
|
|
4078
|
+
}
|
|
4079
|
+
const fullCode = inputs.map((inp) => inp.value.replace(/\D/g, "")).join("");
|
|
4080
|
+
if (fullCode.length === 9) {
|
|
4081
|
+
onVerify(fullCode);
|
|
4082
|
+
}
|
|
4083
|
+
});
|
|
4084
|
+
input.addEventListener("keydown", (e) => {
|
|
4085
|
+
if (e.key === "Backspace" && !input.value && i > 0) {
|
|
4086
|
+
inputs[i - 1].focus();
|
|
4087
|
+
}
|
|
4088
|
+
});
|
|
4089
|
+
input.addEventListener("paste", (e) => {
|
|
4090
|
+
e.preventDefault();
|
|
4091
|
+
const text = e.clipboardData?.getData("text") || "";
|
|
4092
|
+
const digits = text.replace(/\D/g, "").slice(0, 9);
|
|
4093
|
+
for (let j = 0; j < digits.length; j++) {
|
|
4094
|
+
if (inputs[j]) inputs[j].value = digits[j];
|
|
4095
|
+
}
|
|
4096
|
+
if (digits.length === 9) {
|
|
4097
|
+
onVerify(digits);
|
|
4098
|
+
} else if (digits.length > 0 && inputs[digits.length]) {
|
|
4099
|
+
inputs[digits.length].focus();
|
|
4100
|
+
}
|
|
4101
|
+
});
|
|
4102
|
+
grid.appendChild(input);
|
|
4103
|
+
inputs.push(input);
|
|
4104
|
+
}
|
|
4105
|
+
overlay.appendChild(grid);
|
|
4106
|
+
const err = document.createElement("p");
|
|
4107
|
+
err.id = "cl-pair-err";
|
|
4108
|
+
err.className = "cl-pair-err";
|
|
4109
|
+
overlay.appendChild(err);
|
|
4110
|
+
const resetBtn = document.createElement("button");
|
|
4111
|
+
resetBtn.className = "cl-pair-reset";
|
|
4112
|
+
resetBtn.textContent = "Reset connection data";
|
|
4113
|
+
resetBtn.onclick = async () => {
|
|
4114
|
+
await resetDeviceStorage();
|
|
4115
|
+
location.reload();
|
|
4116
|
+
};
|
|
4117
|
+
overlay.appendChild(resetBtn);
|
|
4118
|
+
setTimeout(() => inputs[0]?.focus(), 100);
|
|
4119
|
+
return overlay;
|
|
4120
|
+
}
|
|
4121
|
+
/**
|
|
4122
|
+
* Create prebuilt Add to Home Screen UI (Screen B).
|
|
4123
|
+
*/
|
|
4124
|
+
createAddToHomeScreen(onContinue) {
|
|
4125
|
+
const overlay = document.createElement("div");
|
|
4126
|
+
overlay.id = "crosslink-bootstrap-screen";
|
|
4127
|
+
overlay.className = "cl-screen-overlay cl-bootstrap-screen";
|
|
4128
|
+
const logoContainer = document.createElement("div");
|
|
4129
|
+
logoContainer.style.cssText = "display:flex;justify-content:center";
|
|
4130
|
+
logoContainer.innerHTML = crosslinkLogoSvg({ width: "170px", className: "cl-crosslink-logo" });
|
|
4131
|
+
overlay.appendChild(logoContainer);
|
|
4132
|
+
const title = document.createElement("h2");
|
|
4133
|
+
title.className = "cl-bootstrap-appname";
|
|
4134
|
+
title.textContent = this.options.onboarding?.appName || this.options.appName || "Crosslink";
|
|
4135
|
+
overlay.appendChild(title);
|
|
4136
|
+
const btn = document.createElement("button");
|
|
4137
|
+
btn.className = "cl-continue-btn";
|
|
4138
|
+
btn.innerHTML = "Continue in browser →";
|
|
4139
|
+
btn.onclick = () => onContinue();
|
|
4140
|
+
overlay.appendChild(btn);
|
|
4141
|
+
const nudge = document.createElement("div");
|
|
4142
|
+
nudge.className = "cl-bootstrap-nudge";
|
|
4143
|
+
nudge.innerHTML = `
|
|
4144
|
+
<span>Add to home screen</span>
|
|
4145
|
+
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
4146
|
+
<path d="M24 5 C 24 16, 24 27, 24 39" stroke="currentColor" stroke-width="3" stroke-linecap="round" fill="none"/>
|
|
4147
|
+
<path d="M16 31 L 24 40 L 32 31" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
|
4148
|
+
</svg>
|
|
4149
|
+
`;
|
|
4150
|
+
overlay.appendChild(nudge);
|
|
4151
|
+
return overlay;
|
|
4152
|
+
}
|
|
4153
|
+
/**
|
|
4154
|
+
* Prebuilt SAS Confirmation Modal.
|
|
4155
|
+
*/
|
|
4156
|
+
showSasConfirmation(req) {
|
|
4157
|
+
return new Promise((resolve) => {
|
|
4158
|
+
const modal = document.createElement("div");
|
|
4159
|
+
modal.id = "crosslink-sas-modal";
|
|
4160
|
+
modal.className = "cl-sas-modal";
|
|
4161
|
+
const logoContainer = document.createElement("div");
|
|
4162
|
+
logoContainer.style.cssText = "display:flex;justify-content:center;margin-bottom:8px";
|
|
4163
|
+
logoContainer.innerHTML = crosslinkLogoSvg({ width: "120px", className: "cl-crosslink-logo" });
|
|
4164
|
+
modal.appendChild(logoContainer);
|
|
4165
|
+
const title = document.createElement("h2");
|
|
4166
|
+
title.textContent = "Verify Security Code";
|
|
4167
|
+
modal.appendChild(title);
|
|
4168
|
+
const p = document.createElement("p");
|
|
4169
|
+
p.textContent = "Confirm the numbers match on your computer:";
|
|
4170
|
+
modal.appendChild(p);
|
|
4171
|
+
const grid = document.createElement("div");
|
|
4172
|
+
grid.className = "cl-sas-grid";
|
|
4173
|
+
for (const ch of req.sas.replace(/\s/g, "")) {
|
|
4174
|
+
const span = document.createElement("span");
|
|
4175
|
+
span.textContent = ch;
|
|
4176
|
+
grid.appendChild(span);
|
|
4177
|
+
}
|
|
4178
|
+
modal.appendChild(grid);
|
|
4179
|
+
const caps = document.createElement("p");
|
|
4180
|
+
caps.className = "cl-sas-caps";
|
|
4181
|
+
caps.textContent = "Capabilities: " + (req.grantedCaps.join(", ") || "(none)");
|
|
4182
|
+
modal.appendChild(caps);
|
|
4183
|
+
const actions = document.createElement("div");
|
|
4184
|
+
actions.className = "cl-sas-actions";
|
|
4185
|
+
const okBtn = document.createElement("button");
|
|
4186
|
+
okBtn.className = "cl-sas-ok";
|
|
4187
|
+
okBtn.textContent = "They match";
|
|
4188
|
+
okBtn.onclick = () => {
|
|
4189
|
+
modal.remove();
|
|
4190
|
+
resolve(true);
|
|
4191
|
+
};
|
|
4192
|
+
const noBtn = document.createElement("button");
|
|
4193
|
+
noBtn.className = "cl-sas-no";
|
|
4194
|
+
noBtn.textContent = "Cancel";
|
|
4195
|
+
noBtn.onclick = () => {
|
|
4196
|
+
modal.remove();
|
|
4197
|
+
resolve(false);
|
|
4198
|
+
};
|
|
4199
|
+
actions.append(okBtn, noBtn);
|
|
4200
|
+
modal.appendChild(actions);
|
|
4201
|
+
document.body.appendChild(modal);
|
|
4202
|
+
});
|
|
4203
|
+
}
|
|
4204
|
+
scheduleReconnect() {
|
|
4205
|
+
if (this.reconnectTimer) return;
|
|
4206
|
+
const delay = Math.min(3e4, 1e3 * Math.pow(1.5, Math.min(this.attemptCount, 8)));
|
|
4207
|
+
const jitter = delay * (0.8 + Math.random() * 0.4);
|
|
4208
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
4209
|
+
this.reconnectTimer = null;
|
|
4210
|
+
this.attemptCount++;
|
|
4211
|
+
await this.forceReconnect();
|
|
4212
|
+
}, jitter);
|
|
4213
|
+
}
|
|
4214
|
+
async forceReconnect() {
|
|
4215
|
+
const apps = this.client.listApps();
|
|
4216
|
+
if (apps.length === 0) {
|
|
4217
|
+
this.transitionTo("pairing-required");
|
|
4218
|
+
return;
|
|
4219
|
+
}
|
|
4220
|
+
this.client.close();
|
|
4221
|
+
await this.attemptSilentAuth(apps[0]);
|
|
4222
|
+
}
|
|
4223
|
+
cancelTimers() {
|
|
4224
|
+
if (this.reconnectTimer) {
|
|
4225
|
+
clearTimeout(this.reconnectTimer);
|
|
4226
|
+
this.reconnectTimer = null;
|
|
4227
|
+
}
|
|
4228
|
+
if (this.reachabilityTimer) {
|
|
4229
|
+
clearInterval(this.reachabilityTimer);
|
|
4230
|
+
this.reachabilityTimer = null;
|
|
4231
|
+
}
|
|
4232
|
+
this.attemptCount = 0;
|
|
4233
|
+
}
|
|
4234
|
+
setupListeners() {
|
|
4235
|
+
if (typeof document === "undefined" || typeof window === "undefined") return;
|
|
4236
|
+
this.visibilityHandler = () => {
|
|
4237
|
+
if (!document.hidden && this.state === "offline") {
|
|
4238
|
+
this.forceReconnect().catch(() => {
|
|
4239
|
+
});
|
|
4240
|
+
}
|
|
4241
|
+
};
|
|
4242
|
+
document.addEventListener("visibilitychange", this.visibilityHandler);
|
|
4243
|
+
this.onlineHandler = () => {
|
|
4244
|
+
if (this.state === "offline") {
|
|
4245
|
+
this.forceReconnect().catch(() => {
|
|
4246
|
+
});
|
|
4247
|
+
}
|
|
4248
|
+
};
|
|
4249
|
+
window.addEventListener("online", this.onlineHandler);
|
|
4250
|
+
}
|
|
4251
|
+
/** What this origin permits, once `start()` has probed it. */
|
|
4252
|
+
getEnvironment() {
|
|
4253
|
+
return this.environment;
|
|
4254
|
+
}
|
|
4255
|
+
getState() {
|
|
4256
|
+
return this.state;
|
|
4257
|
+
}
|
|
4258
|
+
getClient() {
|
|
4259
|
+
return this.client;
|
|
4260
|
+
}
|
|
4261
|
+
destroy() {
|
|
4262
|
+
this.cancelTimers();
|
|
4263
|
+
this.unmountCurrentScreen();
|
|
4264
|
+
this.poweredBy?.destroy();
|
|
4265
|
+
this.poweredBy = null;
|
|
4266
|
+
if (typeof document !== "undefined" && this.visibilityHandler) {
|
|
4267
|
+
document.removeEventListener("visibilitychange", this.visibilityHandler);
|
|
4268
|
+
}
|
|
4269
|
+
if (typeof window !== "undefined" && this.onlineHandler) {
|
|
4270
|
+
window.removeEventListener("online", this.onlineHandler);
|
|
4271
|
+
}
|
|
4272
|
+
}
|
|
4273
|
+
};
|
|
4274
|
+
|
|
4275
|
+
// src/offline/service-worker.ts
|
|
4276
|
+
var DEFAULT_SERVICE_WORKER_CONFIG = {
|
|
4277
|
+
version: "2.0.0",
|
|
4278
|
+
precacheAssets: [
|
|
4279
|
+
"./mobile.html",
|
|
4280
|
+
"./bundle.js",
|
|
4281
|
+
"./manifest.webmanifest",
|
|
4282
|
+
"./crosslink-mark.svg",
|
|
4283
|
+
"./icon-192.png",
|
|
4284
|
+
"./icon-512.png"
|
|
4285
|
+
],
|
|
4286
|
+
navigationTimeoutMs: 4e3
|
|
4287
|
+
};
|
|
4288
|
+
function generateServiceWorker(config = {}) {
|
|
4289
|
+
const version = config.version || DEFAULT_SERVICE_WORKER_CONFIG.version;
|
|
4290
|
+
const precacheAssets = config.precacheAssets || DEFAULT_SERVICE_WORKER_CONFIG.precacheAssets;
|
|
4291
|
+
const navigationTimeoutMs = config.navigationTimeoutMs ?? DEFAULT_SERVICE_WORKER_CONFIG.navigationTimeoutMs ?? 4e3;
|
|
4292
|
+
const cacheName = `crosslink-shell-v${version}`;
|
|
4293
|
+
const assetsJson = JSON.stringify(precacheAssets, null, 2);
|
|
4294
|
+
return `/* Crosslink PWA Service Worker \u2014 Generated by Crosslink Framework */
|
|
4295
|
+
const CACHE_NAME = "${cacheName}";
|
|
4296
|
+
const PRECACHE_ASSETS = ${assetsJson};
|
|
4297
|
+
const NAVIGATION_TIMEOUT_MS = ${navigationTimeoutMs};
|
|
4298
|
+
|
|
4299
|
+
// Endpoints and patterns that must NEVER be cached (security, credentials, active RPC/presence)
|
|
4300
|
+
const NEVER_CACHE_PATTERNS = [
|
|
4301
|
+
"/api/",
|
|
4302
|
+
"/rpc/",
|
|
4303
|
+
"/ws",
|
|
4304
|
+
"/pair",
|
|
4305
|
+
"/verify-pair",
|
|
4306
|
+
"/challenge",
|
|
4307
|
+
"/session",
|
|
4308
|
+
"/__crosslink/install/",
|
|
4309
|
+
"/revoke",
|
|
4310
|
+
"/events"
|
|
4311
|
+
];
|
|
4312
|
+
|
|
4313
|
+
function isSecuritySensitive(url) {
|
|
4314
|
+
const path = url.pathname;
|
|
4315
|
+
return NEVER_CACHE_PATTERNS.some((pattern) => path.includes(pattern));
|
|
4316
|
+
}
|
|
4317
|
+
|
|
4318
|
+
async function fetchNavigation(request) {
|
|
4319
|
+
const controller = new AbortController();
|
|
4320
|
+
const timeout = setTimeout(() => controller.abort(), NAVIGATION_TIMEOUT_MS);
|
|
4321
|
+
try {
|
|
4322
|
+
const response = await fetch(request, { signal: controller.signal });
|
|
4323
|
+
if (!response || !response.ok) throw new Error("host navigation unavailable");
|
|
4324
|
+
return response;
|
|
4325
|
+
} finally {
|
|
4326
|
+
clearTimeout(timeout);
|
|
4327
|
+
}
|
|
4328
|
+
}
|
|
4329
|
+
|
|
4330
|
+
self.addEventListener("install", (event) => {
|
|
4331
|
+
event.waitUntil(
|
|
4332
|
+
caches.open(CACHE_NAME).then(async (cache) => {
|
|
4333
|
+
// Robust asset caching: fetch individually so one missing asset doesn't abort entire install
|
|
4334
|
+
for (const asset of PRECACHE_ASSETS) {
|
|
4335
|
+
try {
|
|
4336
|
+
const res = await fetch(asset, { cache: "no-cache" });
|
|
4337
|
+
if (res.ok) {
|
|
4338
|
+
await cache.put(asset, res);
|
|
4339
|
+
}
|
|
4340
|
+
} catch (err) {
|
|
4341
|
+
console.warn("[Crosslink SW] Precache missed:", asset);
|
|
4342
|
+
}
|
|
4343
|
+
}
|
|
4344
|
+
})
|
|
4345
|
+
);
|
|
4346
|
+
self.skipWaiting();
|
|
4347
|
+
});
|
|
4348
|
+
|
|
4349
|
+
self.addEventListener("activate", (event) => {
|
|
4350
|
+
event.waitUntil(
|
|
4351
|
+
caches.keys().then((keys) =>
|
|
4352
|
+
Promise.all(
|
|
4353
|
+
keys
|
|
4354
|
+
.filter((key) => key.startsWith("crosslink-") && key !== CACHE_NAME)
|
|
4355
|
+
.map((key) => caches.delete(key))
|
|
4356
|
+
)
|
|
4357
|
+
).then(() => self.clients.claim())
|
|
4358
|
+
);
|
|
4359
|
+
});
|
|
4360
|
+
|
|
4361
|
+
self.addEventListener("fetch", (event) => {
|
|
4362
|
+
const { request } = event;
|
|
4363
|
+
if (request.method !== "GET") return;
|
|
4364
|
+
|
|
4365
|
+
const url = new URL(request.url);
|
|
4366
|
+
// Ignore cross-origin requests
|
|
4367
|
+
if (url.origin !== self.location.origin) return;
|
|
4368
|
+
|
|
4369
|
+
// Never cache auth or API calls
|
|
4370
|
+
if (isSecuritySensitive(url)) {
|
|
4371
|
+
return;
|
|
4372
|
+
}
|
|
4373
|
+
|
|
4374
|
+
// Install-specific manifests and bootstrap navigations carry only an opaque
|
|
4375
|
+
// handoff id, but serving a cached response for another install would still
|
|
4376
|
+
// lose or cross-wire the handoff. Always go to the network for them.
|
|
4377
|
+
if (url.searchParams.has("crosslink_install")) {
|
|
4378
|
+
event.respondWith(fetch(request, { cache: "no-store" }));
|
|
4379
|
+
return;
|
|
4380
|
+
}
|
|
4381
|
+
|
|
4382
|
+
// 1. Navigation requests: network-first with offline fallback to cached shell
|
|
4383
|
+
if (request.mode === "navigate") {
|
|
4384
|
+
event.respondWith(
|
|
4385
|
+
fetchNavigation(request)
|
|
4386
|
+
.then((response) => {
|
|
4387
|
+
if (response && response.ok) {
|
|
4388
|
+
const copy = response.clone();
|
|
4389
|
+
caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
|
|
4390
|
+
}
|
|
4391
|
+
return response;
|
|
4392
|
+
})
|
|
4393
|
+
.catch(async () => {
|
|
4394
|
+
// Host is offline: return cached shell
|
|
4395
|
+
const cached =
|
|
4396
|
+
(await caches.match(request, { ignoreSearch: true })) ||
|
|
4397
|
+
(await caches.match("./mobile.html", { ignoreSearch: true })) ||
|
|
4398
|
+
(await caches.match("/mobile.html", { ignoreSearch: true })) ||
|
|
4399
|
+
(await caches.match("/", { ignoreSearch: true }));
|
|
4400
|
+
if (cached) return cached;
|
|
4401
|
+
return new Response("Application is offline", {
|
|
4402
|
+
status: 503,
|
|
4403
|
+
statusText: "Offline",
|
|
4404
|
+
headers: { "Content-Type": "text/plain" }
|
|
4405
|
+
});
|
|
4406
|
+
})
|
|
4407
|
+
);
|
|
4408
|
+
return;
|
|
4409
|
+
}
|
|
4410
|
+
|
|
4411
|
+
// 2. Static shell assets: cache-first with network fallback
|
|
4412
|
+
event.respondWith(
|
|
4413
|
+
caches.match(request, { ignoreSearch: true }).then((hit) => {
|
|
4414
|
+
if (hit) return hit;
|
|
4415
|
+
return fetch(request).then((response) => {
|
|
4416
|
+
if (response && response.ok) {
|
|
4417
|
+
const copy = response.clone();
|
|
4418
|
+
caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
|
|
4419
|
+
}
|
|
4420
|
+
return response;
|
|
4421
|
+
});
|
|
4422
|
+
})
|
|
4423
|
+
);
|
|
4424
|
+
});
|
|
4425
|
+
|
|
4426
|
+
self.addEventListener("message", (event) => {
|
|
4427
|
+
if (event.data === "skipWaiting") {
|
|
4428
|
+
self.skipWaiting();
|
|
4429
|
+
}
|
|
4430
|
+
});
|
|
4431
|
+
`;
|
|
4432
|
+
}
|
|
4433
|
+
var DEFAULT_SERVICE_WORKER = generateServiceWorker();
|
|
4434
|
+
function createServiceWorkerConfig(pwaConfig) {
|
|
4435
|
+
const assets = [
|
|
4436
|
+
pwaConfig.startUrl || "./mobile.html",
|
|
4437
|
+
"./bundle.js",
|
|
4438
|
+
pwaConfig.manifestPath || "./manifest.webmanifest",
|
|
4439
|
+
"./crosslink-mark.svg"
|
|
4440
|
+
];
|
|
4441
|
+
if (pwaConfig.icons) {
|
|
4442
|
+
for (const icon of pwaConfig.icons) {
|
|
4443
|
+
if (icon.src && !assets.includes(icon.src)) {
|
|
4444
|
+
assets.push(icon.src);
|
|
4445
|
+
}
|
|
4446
|
+
}
|
|
4447
|
+
}
|
|
4448
|
+
return {
|
|
4449
|
+
version: pwaConfig.version || "1.0.0",
|
|
4450
|
+
precacheAssets: assets
|
|
4451
|
+
};
|
|
4452
|
+
}
|
|
4453
|
+
|
|
4454
|
+
// src/index.ts
|
|
4455
|
+
function createCrosslinkClient(options = {}) {
|
|
4456
|
+
const storage = options.storage ?? (typeof localStorage !== "undefined" ? new LocalStorageSecureStorage(localStorage) : void 0);
|
|
4457
|
+
return new CrosslinkClient({ ...options, storage });
|
|
4458
|
+
}
|
|
4459
|
+
function createSecureCrosslinkClient(options = {}) {
|
|
4460
|
+
return CrosslinkClient.create(options);
|
|
4461
|
+
}
|
|
4462
|
+
export {
|
|
4463
|
+
ATTRIBUTION_MIN_CONTRAST,
|
|
4464
|
+
AsyncStorageAdapter,
|
|
4465
|
+
BrokeredPairingChannel,
|
|
4466
|
+
CONTROL_ROUTES,
|
|
4467
|
+
CROSSLINK_ATTRIBUTION_LINK_TEXT,
|
|
4468
|
+
CROSSLINK_ATTRIBUTION_TEXT,
|
|
4469
|
+
CROSSLINK_LOGO_PATH,
|
|
4470
|
+
CROSSLINK_LOGO_VIEWBOX,
|
|
4471
|
+
CROSSLINK_REPOSITORY,
|
|
4472
|
+
CrosslinkClient,
|
|
4473
|
+
CrosslinkMobileBootstrap,
|
|
4474
|
+
CrosslinkOfflineShell,
|
|
4475
|
+
DEFAULT_OFFLINE_CONFIG,
|
|
4476
|
+
DEFAULT_SERVICE_WORKER,
|
|
4477
|
+
DEFAULT_SERVICE_WORKER_CONFIG,
|
|
4478
|
+
DirectPairingChannel,
|
|
4479
|
+
HydratedSecureStorage,
|
|
4480
|
+
INSTALL_HANDOFF_CONTEXT_COOKIE,
|
|
4481
|
+
INSTALL_HANDOFF_COOKIE,
|
|
4482
|
+
INSTALL_HANDOFF_QUERY_KEY,
|
|
4483
|
+
IndexedDbSecureStorage,
|
|
4484
|
+
JsonStore,
|
|
4485
|
+
LOGO_MIN_CONTRAST,
|
|
4486
|
+
LocalStorageSecureStorage,
|
|
4487
|
+
MemoryLogSink,
|
|
4488
|
+
MemorySecureStorage,
|
|
4489
|
+
MockSocket,
|
|
4490
|
+
NotificationHandler,
|
|
4491
|
+
PairingCard,
|
|
4492
|
+
PoweredByCrosslink,
|
|
4493
|
+
SignalingPeer,
|
|
4494
|
+
consoleLogger,
|
|
4495
|
+
contrastRatio,
|
|
4496
|
+
createCrosslinkClient,
|
|
4497
|
+
createCrosslinkLogo,
|
|
4498
|
+
createHttpPairingSource,
|
|
4499
|
+
createLogger,
|
|
4500
|
+
createOfflineUI,
|
|
4501
|
+
createPairingCard,
|
|
4502
|
+
createPoweredByCrosslink,
|
|
4503
|
+
createSecureCrosslinkClient,
|
|
4504
|
+
createSecureStorage,
|
|
4505
|
+
createServiceWorkerConfig,
|
|
4506
|
+
crosslinkLogoSvg,
|
|
4507
|
+
describeBootstrapEnvironment,
|
|
4508
|
+
generateServiceWorker,
|
|
4509
|
+
injectBootstrapStyles,
|
|
4510
|
+
injectPairingCardStyles,
|
|
4511
|
+
isStandalone,
|
|
4512
|
+
noopLogger2 as noopLogger,
|
|
4513
|
+
normalizeNetworkMode,
|
|
4514
|
+
parseColor,
|
|
4515
|
+
removeOfflineUI,
|
|
4516
|
+
resetDeviceStorage,
|
|
4517
|
+
resolveCrosslinkTheme,
|
|
4518
|
+
updateOfflineStatus,
|
|
4519
|
+
wsTransport
|
|
4520
|
+
};
|