@condorinst/mobile-bridge 0.19.2 → 0.21.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/dist/dev.js ADDED
@@ -0,0 +1,1611 @@
1
+ // src/dev/fixtures.ts
2
+ var DEFAULT_LOG_PERIOD_S = 60;
3
+ var pad = (n) => String(n).padStart(2, "0");
4
+ function formatLocalTimestamp(date) {
5
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
6
+ }
7
+ function parseLocalTimestamp(value) {
8
+ const m = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/.exec(value);
9
+ if (!m) return Number.NaN;
10
+ const [, y, mo, d, h, mi, s] = m.map(Number);
11
+ return new Date(y ?? 0, (mo ?? 1) - 1, d ?? 1, h ?? 0, mi ?? 0, s ?? 0).getTime();
12
+ }
13
+ function config(spec) {
14
+ const item = {
15
+ deviceId: spec.deviceId,
16
+ serialNo: spec.serialNo,
17
+ deviceBodyPart: spec.bodyPart,
18
+ bleConnectionPeriod: 5,
19
+ autosync: true,
20
+ eraseDeviceMemory: false,
21
+ autoEraseDeviceMemory: false,
22
+ autoEraseDeviceMemoryThreshold: 80,
23
+ readSensorsMemory: false,
24
+ startDevice: false,
25
+ readFullMemory: false,
26
+ allowFirmwareUpdate: true
27
+ };
28
+ if (spec.modelId) item.modelId = spec.modelId;
29
+ return item;
30
+ }
31
+ function telemetry(spec, now) {
32
+ return {
33
+ serialNo: spec.serialNo,
34
+ deviceId: spec.deviceId,
35
+ updataDateTime: now - spec.syncedMinutesAgo * 6e4,
36
+ deviceName: spec.name,
37
+ deviceVersion: spec.modelId === "actlumus_plus" ? "3.0.2" : "2.1.0",
38
+ hardwareVersion: spec.modelId === "actlumus_plus" ? "B" : "A",
39
+ deviceDateTime: now - spec.syncedMinutesAgo * 6e4,
40
+ memoryDataUsage: 37.5,
41
+ memorySystemUsage: 4.2,
42
+ batteryLevel: spec.battery,
43
+ batteryVoltage: 3.7 + spec.battery / 1e3,
44
+ isCharging: spec.charging,
45
+ isOnDock: spec.charging,
46
+ syncErrors: spec.syncError ? [
47
+ {
48
+ serialNo: spec.serialNo,
49
+ attemptDate: now - 10 * 6e4,
50
+ message: spec.syncError,
51
+ messageCode: "SYNC_FAILED"
52
+ }
53
+ ] : []
54
+ };
55
+ }
56
+ function userConfig(spec) {
57
+ return {
58
+ serialNo: spec.serialNo,
59
+ deviceId: spec.deviceId,
60
+ syncTime: false,
61
+ realTime: false,
62
+ deleteMemory: false,
63
+ readAllMemory: false,
64
+ readAllSystemLog: false
65
+ };
66
+ }
67
+ function payloadOf(specs, now = Date.now()) {
68
+ return {
69
+ lastUpdate: now,
70
+ devicesConfig: specs.map(config),
71
+ devicesTelemetry: specs.map((s) => telemetry(s, now)),
72
+ devicesUserConfig: specs.map(userConfig)
73
+ };
74
+ }
75
+ function twoDevices(now = Date.now()) {
76
+ return payloadOf(
77
+ [
78
+ {
79
+ deviceId: 9001,
80
+ serialNo: 4534,
81
+ modelId: "actlumus",
82
+ bodyPart: "wrist",
83
+ name: "ActLumus 4534",
84
+ battery: 82,
85
+ charging: false,
86
+ syncedMinutesAgo: 12
87
+ },
88
+ {
89
+ deviceId: 9002,
90
+ serialNo: 5120,
91
+ modelId: "actlumus_plus",
92
+ bodyPart: "necklace",
93
+ name: "ActLumus Plus 5120",
94
+ battery: 41,
95
+ charging: true,
96
+ syncedMinutesAgo: 190,
97
+ syncError: "Could not read the device memory."
98
+ }
99
+ ],
100
+ now
101
+ );
102
+ }
103
+ function sharedSerial(now = Date.now()) {
104
+ return payloadOf(
105
+ [
106
+ {
107
+ deviceId: 9101,
108
+ serialNo: 7047,
109
+ bodyPart: "wrist",
110
+ name: "ActLumus 7047",
111
+ battery: 67,
112
+ charging: false,
113
+ syncedMinutesAgo: 30
114
+ },
115
+ {
116
+ deviceId: 9102,
117
+ serialNo: 7047,
118
+ modelId: "actlumus_plus",
119
+ bodyPart: "necklace",
120
+ name: "ActLumus Plus 7047",
121
+ battery: 93,
122
+ charging: false,
123
+ syncedMinutesAgo: 8
124
+ }
125
+ ],
126
+ now
127
+ );
128
+ }
129
+ function systemStatus(overrides = {}) {
130
+ return {
131
+ bluetooth: true,
132
+ internet: true,
133
+ permission: true,
134
+ hasDevices: true,
135
+ missingOptionalPermissions: [],
136
+ missingEssentialPermissions: [],
137
+ deviceSyncError: {},
138
+ deviceSyncErrorById: {},
139
+ ...overrides
140
+ };
141
+ }
142
+ function lightAt(epochMs, serialNo) {
143
+ const d = new Date(epochMs);
144
+ const hour = d.getHours() + d.getMinutes() / 60;
145
+ const daylight = Math.max(0, Math.sin((hour - 6) / 12 * Math.PI));
146
+ const noise = (epochMs / 6e4 + serialNo) % 7 / 7;
147
+ const photopic = Math.round((daylight * 1800 + noise * 40) * 10) / 10;
148
+ return { photopic, melanopic: Math.round(photopic * 0.62 * 10) / 10 };
149
+ }
150
+ function logAt(epochMs, serialNo, deviceId) {
151
+ const { photopic, melanopic } = lightAt(epochMs, serialNo);
152
+ return {
153
+ id: Math.floor(epochMs / 1e3) * 10 + serialNo % 10,
154
+ log_time: formatLocalTimestamp(new Date(epochMs)),
155
+ log_type: 1,
156
+ body_part: "wrist",
157
+ temperature: 31.5,
158
+ lux_photopic: photopic,
159
+ lux_melanopic: melanopic,
160
+ device_id: deviceId,
161
+ act_data_pim: 120
162
+ };
163
+ }
164
+ var HISTORY_DAYS = 14;
165
+ var encodeCursor = (epochMs) => `mock:${epochMs}`;
166
+ var decodeCursor = (cursor) => Number(cursor.slice("mock:".length));
167
+ function sensorLogPage(query, opts = {}) {
168
+ const now = opts.now ?? Date.now();
169
+ const periodMs = (opts.periodS ?? DEFAULT_LOG_PERIOD_S) * 1e3;
170
+ const limit = query.limit ?? 10;
171
+ if (limit < 1 || limit > 1e3) {
172
+ return { requestId: query.requestId, success: false, error: "INVALID_LIMIT" };
173
+ }
174
+ if (query.startingAfter && query.endingBefore) {
175
+ return { requestId: query.requestId, success: false, error: "CONFLICTING_CURSORS" };
176
+ }
177
+ const newest = Math.floor(now / periodMs) * periodMs;
178
+ const oldest = newest - HISTORY_DAYS * 864e5;
179
+ const rangeStart = query.startTime ? parseLocalTimestamp(query.startTime) : oldest;
180
+ const rangeEnd = query.endTime ? parseLocalTimestamp(query.endTime) - 1 : newest;
181
+ if (Number.isNaN(rangeStart) || Number.isNaN(rangeEnd) || rangeStart > rangeEnd + 1) {
182
+ return { requestId: query.requestId, success: false, error: "INVALID_TIME_RANGE" };
183
+ }
184
+ const first = Math.max(oldest, Math.ceil(rangeStart / periodMs) * periodMs);
185
+ const last = Math.min(newest, Math.floor(rangeEnd / periodMs) * periodMs);
186
+ let from;
187
+ let to;
188
+ if (query.endingBefore) {
189
+ to = Math.min(last, decodeCursor(query.endingBefore) - periodMs);
190
+ from = Math.max(first, to - (limit - 1) * periodMs);
191
+ } else if (query.startingAfter) {
192
+ from = Math.max(first, decodeCursor(query.startingAfter) + periodMs);
193
+ to = Math.min(last, from + (limit - 1) * periodMs);
194
+ } else if (query.startTime) {
195
+ from = first;
196
+ to = Math.min(last, from + (limit - 1) * periodMs);
197
+ } else {
198
+ to = last;
199
+ from = Math.max(first, to - (limit - 1) * periodMs);
200
+ }
201
+ const logs = [];
202
+ for (let t = from; t <= to; t += periodMs) {
203
+ logs.push(logAt(t, query.serialNo, opts.deviceId ?? 0));
204
+ }
205
+ return {
206
+ requestId: query.requestId,
207
+ success: true,
208
+ logs,
209
+ startingAfter: logs.length ? encodeCursor(to) : null,
210
+ endingBefore: logs.length ? encodeCursor(from) : null,
211
+ hasMore: logs.length > 0 && to < last,
212
+ hasPrevious: logs.length > 0 && from > first
213
+ };
214
+ }
215
+
216
+ // src/keys.ts
217
+ var BRIDGE_KEYS = {
218
+ pingPong: "pingPong",
219
+ userDevice: "userDevice",
220
+ deviceLightSensors: "deviceLightSensors",
221
+ syncTime: "syncTime",
222
+ realTime: "realTime",
223
+ deleteMemory: "deleteMemory",
224
+ deviceNotification: "deviceNotification",
225
+ deviceConnection: "deviceConnection",
226
+ sensorLog: "sensorLog",
227
+ dfu: "dfu",
228
+ systemStatus: "systemStatus",
229
+ /**
230
+ * Native → client: the OS back button was pressed and the client had
231
+ * reported internal history via `reportNavigation(true)`. Go back exactly
232
+ * ONE internal page. Emitted only when the native side decided to delegate
233
+ * the back gesture. See `BridgeClient.onBack`.
234
+ */
235
+ navigationBack: "navigationBack",
236
+ /**
237
+ * Native → client: the app's language state. Carries the raw device locale
238
+ * AND the language explicitly chosen in the app Settings, so the client can
239
+ * apply its own precedence rule — including rendering a language the native
240
+ * chrome does not support. Emitted on attach, after `handshake-ack`, and on
241
+ * every change in Settings. See `LanguagePayload`.
242
+ */
243
+ language: "language"
244
+ };
245
+
246
+ // src/internal/dev-panel.ts
247
+ var PANEL_ID = "condor-mock-panel-root";
248
+ var REFRESH_INTERVAL_MS = 500;
249
+ var DEFAULTS = {
250
+ position: "bottom-right",
251
+ maxMessages: 20,
252
+ theme: "dark",
253
+ startCollapsed: false,
254
+ startAutoRespondEnabled: false,
255
+ autoRespondDelayMs: 50
256
+ };
257
+ function resolveOptions(opts) {
258
+ return {
259
+ position: opts?.position ?? DEFAULTS.position,
260
+ maxMessages: opts?.maxMessages ?? DEFAULTS.maxMessages,
261
+ theme: opts?.theme ?? DEFAULTS.theme,
262
+ startCollapsed: opts?.startCollapsed ?? DEFAULTS.startCollapsed,
263
+ startAutoRespondEnabled: opts?.startAutoRespondEnabled ?? DEFAULTS.startAutoRespondEnabled,
264
+ autoRespondDelayMs: opts?.autoRespondDelayMs ?? DEFAULTS.autoRespondDelayMs
265
+ };
266
+ }
267
+ function applyPanelStyles(el, opts) {
268
+ const isDark = opts.theme === "dark";
269
+ const positions = {
270
+ "bottom-right": { bottom: "12px", right: "12px" },
271
+ "bottom-left": { bottom: "12px", left: "12px" },
272
+ "top-right": { top: "12px", right: "12px" },
273
+ "top-left": { top: "12px", left: "12px" }
274
+ };
275
+ Object.assign(el.style, {
276
+ position: "fixed",
277
+ width: "340px",
278
+ maxHeight: "420px",
279
+ zIndex: "2147483647",
280
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
281
+ fontSize: "12px",
282
+ color: isDark ? "#e0e0e0" : "#222",
283
+ background: isDark ? "#1a1a1aee" : "#ffffffee",
284
+ border: isDark ? "1px solid #444" : "1px solid #ccc",
285
+ borderRadius: "6px",
286
+ boxShadow: "0 4px 12px rgba(0,0,0,0.3)",
287
+ overflow: "hidden",
288
+ display: "flex",
289
+ flexDirection: "column",
290
+ ...positions[opts.position]
291
+ });
292
+ }
293
+ function buildHeader(badge, counter, toggle) {
294
+ const header = document.createElement("div");
295
+ header.className = "condor-mock-panel-header";
296
+ Object.assign(header.style, {
297
+ padding: "6px 10px",
298
+ display: "flex",
299
+ alignItems: "center",
300
+ gap: "8px",
301
+ background: "#0a84ff",
302
+ color: "#fff",
303
+ fontWeight: "600",
304
+ cursor: "pointer"
305
+ });
306
+ Object.assign(badge.style, {
307
+ background: "#fff",
308
+ color: "#0a84ff",
309
+ padding: "1px 6px",
310
+ borderRadius: "3px",
311
+ fontSize: "10px",
312
+ letterSpacing: "0.5px"
313
+ });
314
+ badge.textContent = "MOCK";
315
+ Object.assign(counter.style, {
316
+ marginLeft: "auto",
317
+ fontSize: "11px",
318
+ opacity: "0.9"
319
+ });
320
+ Object.assign(toggle.style, {
321
+ background: "transparent",
322
+ border: "1px solid rgba(255,255,255,0.5)",
323
+ color: "#fff",
324
+ cursor: "pointer",
325
+ padding: "0 6px",
326
+ borderRadius: "3px",
327
+ fontFamily: "inherit"
328
+ });
329
+ header.append(badge, document.createTextNode("condor-bridge"), counter, toggle);
330
+ return header;
331
+ }
332
+ function buildSentSection() {
333
+ const container = document.createElement("div");
334
+ container.className = "condor-mock-panel-sent";
335
+ Object.assign(container.style, {
336
+ padding: "6px 10px",
337
+ borderBottom: "1px solid rgba(255,255,255,0.08)",
338
+ maxHeight: "180px",
339
+ overflowY: "auto"
340
+ });
341
+ const title = document.createElement("div");
342
+ title.textContent = "Sent";
343
+ Object.assign(title.style, { opacity: "0.7", marginBottom: "4px", fontSize: "11px" });
344
+ const list = document.createElement("div");
345
+ list.className = "condor-mock-panel-sent-list";
346
+ container.append(title, list);
347
+ return { container, list };
348
+ }
349
+ function buildTriggerSection(bridgeKeys, onEmit, onClear) {
350
+ const container = document.createElement("div");
351
+ container.className = "condor-mock-panel-trigger";
352
+ Object.assign(container.style, {
353
+ padding: "6px 10px",
354
+ display: "flex",
355
+ flexDirection: "column",
356
+ gap: "4px"
357
+ });
358
+ const title = document.createElement("div");
359
+ title.textContent = "Trigger emit";
360
+ Object.assign(title.style, { opacity: "0.7", fontSize: "11px" });
361
+ const select = document.createElement("select");
362
+ select.className = "condor-mock-panel-key-select";
363
+ Object.assign(select.style, {
364
+ background: "transparent",
365
+ color: "inherit",
366
+ border: "1px solid rgba(255,255,255,0.2)",
367
+ padding: "4px",
368
+ borderRadius: "3px",
369
+ fontFamily: "inherit",
370
+ fontSize: "11px"
371
+ });
372
+ for (const key of bridgeKeys) {
373
+ const opt = document.createElement("option");
374
+ opt.value = key;
375
+ opt.textContent = key;
376
+ select.appendChild(opt);
377
+ }
378
+ const ackOpt = document.createElement("option");
379
+ ackOpt.value = "handshake-ack";
380
+ ackOpt.textContent = "handshake-ack";
381
+ select.appendChild(ackOpt);
382
+ const textarea = document.createElement("textarea");
383
+ textarea.className = "condor-mock-panel-payload";
384
+ textarea.placeholder = '{ "data": "pong" }';
385
+ Object.assign(textarea.style, {
386
+ background: "transparent",
387
+ color: "inherit",
388
+ border: "1px solid rgba(255,255,255,0.2)",
389
+ padding: "4px",
390
+ borderRadius: "3px",
391
+ fontFamily: "inherit",
392
+ fontSize: "11px",
393
+ minHeight: "50px",
394
+ resize: "vertical"
395
+ });
396
+ const buttons = document.createElement("div");
397
+ Object.assign(buttons.style, { display: "flex", gap: "6px" });
398
+ const emitBtn = document.createElement("button");
399
+ emitBtn.type = "button";
400
+ emitBtn.textContent = "Emit";
401
+ emitBtn.className = "condor-mock-panel-emit-btn";
402
+ Object.assign(emitBtn.style, {
403
+ background: "#0a84ff",
404
+ color: "#fff",
405
+ border: "none",
406
+ padding: "4px 10px",
407
+ borderRadius: "3px",
408
+ cursor: "pointer",
409
+ fontFamily: "inherit",
410
+ fontSize: "11px",
411
+ flex: "1"
412
+ });
413
+ emitBtn.addEventListener("click", () => {
414
+ onEmit(select.value, textarea.value);
415
+ });
416
+ const clearBtn = document.createElement("button");
417
+ clearBtn.type = "button";
418
+ clearBtn.textContent = "Clear sent";
419
+ clearBtn.className = "condor-mock-panel-clear-btn";
420
+ Object.assign(clearBtn.style, {
421
+ background: "transparent",
422
+ color: "inherit",
423
+ border: "1px solid rgba(255,255,255,0.3)",
424
+ padding: "4px 10px",
425
+ borderRadius: "3px",
426
+ cursor: "pointer",
427
+ fontFamily: "inherit",
428
+ fontSize: "11px"
429
+ });
430
+ clearBtn.addEventListener("click", onClear);
431
+ buttons.append(emitBtn, clearBtn);
432
+ container.append(title, select, textarea, buttons);
433
+ return container;
434
+ }
435
+ function buildAutoRespondSection(initialEnabled, onChange) {
436
+ const container = document.createElement("div");
437
+ container.className = "condor-mock-panel-auto-respond";
438
+ Object.assign(container.style, {
439
+ padding: "6px 10px",
440
+ borderBottom: "1px solid rgba(255,255,255,0.08)",
441
+ display: "flex",
442
+ flexDirection: "column",
443
+ gap: "2px"
444
+ });
445
+ const title = document.createElement("div");
446
+ title.textContent = "Auto-respond";
447
+ Object.assign(title.style, { opacity: "0.7", fontSize: "11px" });
448
+ const row = document.createElement("label");
449
+ Object.assign(row.style, {
450
+ display: "flex",
451
+ alignItems: "center",
452
+ gap: "8px",
453
+ cursor: "pointer",
454
+ fontSize: "12px"
455
+ });
456
+ const checkbox = document.createElement("input");
457
+ checkbox.type = "checkbox";
458
+ checkbox.className = "condor-mock-panel-auto-respond-toggle";
459
+ checkbox.checked = initialEnabled;
460
+ Object.assign(checkbox.style, {
461
+ cursor: "pointer"
462
+ });
463
+ const label = document.createElement("span");
464
+ label.textContent = "Enabled \u2014 mockEmit em resposta a cada send (policy do cliente)";
465
+ checkbox.addEventListener("change", () => {
466
+ onChange(checkbox.checked);
467
+ });
468
+ row.append(checkbox, label);
469
+ container.append(title, row);
470
+ return container;
471
+ }
472
+ function renderSentList(list, adapter, max) {
473
+ list.innerHTML = "";
474
+ const all = adapter.getMessages();
475
+ const tail = all.slice(-max).reverse();
476
+ for (const msg of tail) {
477
+ const row = document.createElement("div");
478
+ Object.assign(row.style, {
479
+ padding: "3px 0",
480
+ borderBottom: "1px solid rgba(255,255,255,0.06)",
481
+ wordBreak: "break-all"
482
+ });
483
+ const ts = new Date(msg.timestamp).toLocaleTimeString();
484
+ const payload = msg.payload;
485
+ const type = payload?.type ?? "?";
486
+ row.textContent = `${ts} \u2192 ${type} ${JSON.stringify(msg.payload).slice(0, 80)}`;
487
+ list.appendChild(row);
488
+ }
489
+ }
490
+ var currentPanel = null;
491
+ function installDevPanel(params) {
492
+ if (typeof document === "undefined") {
493
+ throw new Error("condor-mobile-bridge dev panel: document is undefined (Node-only env).");
494
+ }
495
+ if (currentPanel) {
496
+ currentPanel.uninstall();
497
+ currentPanel = null;
498
+ }
499
+ const opts = resolveOptions(params.options);
500
+ const root = document.createElement("div");
501
+ root.id = PANEL_ID;
502
+ applyPanelStyles(root, opts);
503
+ const badge = document.createElement("span");
504
+ const counter = document.createElement("span");
505
+ const toggle = document.createElement("button");
506
+ toggle.type = "button";
507
+ toggle.textContent = opts.startCollapsed ? "+" : "\u2212";
508
+ const header = buildHeader(badge, counter, toggle);
509
+ const body = document.createElement("div");
510
+ Object.assign(body.style, {
511
+ display: opts.startCollapsed ? "none" : "flex",
512
+ flexDirection: "column",
513
+ overflow: "hidden"
514
+ });
515
+ const { container: sentContainer, list: sentList } = buildSentSection();
516
+ const policy = params.options?.onSendForAutoRespond;
517
+ let autoRespondEnabled = opts.startAutoRespondEnabled;
518
+ let unsubscribeSendListener = null;
519
+ let autoRespondSection = null;
520
+ if (policy) {
521
+ autoRespondSection = buildAutoRespondSection(autoRespondEnabled, (next) => {
522
+ autoRespondEnabled = next;
523
+ });
524
+ unsubscribeSendListener = params.adapter.onCallHandler((msg) => {
525
+ if (!autoRespondEnabled) return;
526
+ let response;
527
+ try {
528
+ response = policy(msg);
529
+ } catch (err) {
530
+ console.error("[condor-mobile-bridge dev-panel] onSendForAutoRespond threw:", err);
531
+ return;
532
+ }
533
+ if (!response) return;
534
+ setTimeout(() => {
535
+ try {
536
+ params.adapter.triggerEmit(response.key, response.payload);
537
+ } catch (err) {
538
+ console.error(
539
+ "[condor-mobile-bridge dev-panel] triggerEmit failed in auto-respond:",
540
+ err
541
+ );
542
+ }
543
+ }, opts.autoRespondDelayMs);
544
+ });
545
+ }
546
+ const trigger = buildTriggerSection(
547
+ Object.values(BRIDGE_KEYS),
548
+ (key, payloadJson) => {
549
+ let payload = {};
550
+ const trimmed = payloadJson.trim();
551
+ if (trimmed.length > 0) {
552
+ try {
553
+ payload = JSON.parse(trimmed);
554
+ } catch (err) {
555
+ console.error("[condor-mobile-bridge dev-panel] invalid JSON payload", err);
556
+ return;
557
+ }
558
+ }
559
+ params.adapter.triggerEmit(key, payload);
560
+ },
561
+ () => {
562
+ params.adapter.clearMessages();
563
+ renderSentList(sentList, params.adapter, opts.maxMessages);
564
+ counter.textContent = "0 sent";
565
+ }
566
+ );
567
+ if (autoRespondSection) {
568
+ body.append(sentContainer, autoRespondSection, trigger);
569
+ } else {
570
+ body.append(sentContainer, trigger);
571
+ }
572
+ root.append(header, body);
573
+ toggle.addEventListener("click", (e) => {
574
+ e.stopPropagation();
575
+ const collapsed = body.style.display === "none";
576
+ body.style.display = collapsed ? "flex" : "none";
577
+ toggle.textContent = collapsed ? "\u2212" : "+";
578
+ });
579
+ document.body.appendChild(root);
580
+ const refresh = () => {
581
+ const count = params.adapter.getMessages().length;
582
+ counter.textContent = `${count} sent`;
583
+ renderSentList(sentList, params.adapter, opts.maxMessages);
584
+ };
585
+ refresh();
586
+ const interval = setInterval(refresh, REFRESH_INTERVAL_MS);
587
+ const panel = {
588
+ uninstall() {
589
+ clearInterval(interval);
590
+ unsubscribeSendListener?.();
591
+ unsubscribeSendListener = null;
592
+ root.remove();
593
+ if (currentPanel === panel) currentPanel = null;
594
+ },
595
+ refresh
596
+ };
597
+ currentPanel = panel;
598
+ return panel;
599
+ }
600
+
601
+ // src/internal/mock.ts
602
+ var MAX_BUFFER = 100;
603
+ function createMockAdapter() {
604
+ const messages = [];
605
+ const sendListeners = /* @__PURE__ */ new Set();
606
+ return {
607
+ isMock: true,
608
+ callHandler(handlerName, payload) {
609
+ const msg = { handlerName, payload, timestamp: Date.now() };
610
+ messages.push(msg);
611
+ if (messages.length > MAX_BUFFER) {
612
+ messages.shift();
613
+ }
614
+ console.debug("[condor-mobile-bridge mock] \u2192", handlerName, payload);
615
+ for (const listener of sendListeners) {
616
+ try {
617
+ listener(msg);
618
+ } catch (err) {
619
+ console.error("[condor-mobile-bridge mock] send listener threw:", err);
620
+ }
621
+ }
622
+ },
623
+ getMessages() {
624
+ return messages;
625
+ },
626
+ clearMessages() {
627
+ messages.length = 0;
628
+ },
629
+ triggerEmit(key, payload) {
630
+ const bridge = typeof window !== "undefined" ? window.CondorBridge : void 0;
631
+ if (!bridge || typeof bridge.emit !== "function") {
632
+ throw new Error(
633
+ "condor-mobile-bridge mock: window.CondorBridge not bootstrapped; cannot emit."
634
+ );
635
+ }
636
+ bridge.emit(key, payload);
637
+ },
638
+ onCallHandler(listener) {
639
+ sendListeners.add(listener);
640
+ return () => {
641
+ sendListeners.delete(listener);
642
+ };
643
+ }
644
+ };
645
+ }
646
+
647
+ // src/internal/native.ts
648
+ var outgoingStamp = null;
649
+ function setOutgoingStamp(stamp) {
650
+ outgoingStamp = stamp;
651
+ }
652
+ var activeAdapter = null;
653
+ function createChannelAdapter(channel) {
654
+ return {
655
+ isMock: false,
656
+ // Single channel: `handlerName` does not travel — the native side routes
657
+ // on `type`.
658
+ callHandler(_handlerName, payload) {
659
+ channel.postMessage(JSON.stringify(payload));
660
+ }
661
+ };
662
+ }
663
+ function createLegacyAdapter(host) {
664
+ return {
665
+ isMock: false,
666
+ callHandler(handlerName, payload) {
667
+ void host.callHandler(handlerName, payload);
668
+ }
669
+ };
670
+ }
671
+ function detectAdapter() {
672
+ if (typeof window === "undefined") {
673
+ return createMockAdapter();
674
+ }
675
+ const channel = window.CondorNative;
676
+ if (channel && typeof channel.postMessage === "function") {
677
+ return createChannelAdapter(channel);
678
+ }
679
+ const host = window.flutter_inappwebview;
680
+ if (host && typeof host.callHandler === "function") {
681
+ return createLegacyAdapter(host);
682
+ }
683
+ return createMockAdapter();
684
+ }
685
+ function getActiveAdapter() {
686
+ if (!activeAdapter) {
687
+ activeAdapter = detectAdapter();
688
+ }
689
+ return activeAdapter;
690
+ }
691
+ function callNative(handlerName, payload) {
692
+ const stamped = outgoingStamp ? outgoingStamp(payload) : payload;
693
+ getActiveAdapter().callHandler(handlerName, stamped);
694
+ }
695
+
696
+ // src/internal/subscribers.ts
697
+ var subscribers = /* @__PURE__ */ new Map();
698
+ function addSubscriber(key, cb) {
699
+ let set = subscribers.get(key);
700
+ if (!set) {
701
+ set = /* @__PURE__ */ new Set();
702
+ subscribers.set(key, set);
703
+ }
704
+ set.add(cb);
705
+ return () => {
706
+ const current = subscribers.get(key);
707
+ if (!current) return;
708
+ current.delete(cb);
709
+ if (current.size === 0) {
710
+ subscribers.delete(key);
711
+ }
712
+ };
713
+ }
714
+ function dispatchToSubscribers(key, payload) {
715
+ const set = subscribers.get(key);
716
+ if (!set) return;
717
+ for (const cb of set) {
718
+ try {
719
+ cb(payload);
720
+ } catch (err) {
721
+ console.error(`[condor-mobile-bridge] subscriber for "${key}" threw:`, err);
722
+ }
723
+ }
724
+ }
725
+ function getSubscriberKeys() {
726
+ return Array.from(subscribers.keys());
727
+ }
728
+
729
+ // src/bridge.ts
730
+ var HANDSHAKE_ACK_KEY = "handshake-ack";
731
+ var NATIVE_HANDLER = "CondorBridge";
732
+ var PROTOCOL_VERSION = "0.1.0";
733
+ var pendingHandshake = null;
734
+ var bridgeBootstrapped = false;
735
+ function emit(key, payload) {
736
+ if (key === HANDSHAKE_ACK_KEY && pendingHandshake) {
737
+ const ack = payload ?? {};
738
+ const resolver = pendingHandshake;
739
+ pendingHandshake = null;
740
+ resolver.resolve({
741
+ nativeVersion: ack.nativeVersion ?? "unknown",
742
+ accepted: ack.accepted ?? false
743
+ });
744
+ return;
745
+ }
746
+ dispatchToSubscribers(key, payload);
747
+ }
748
+ function bootstrapWindowBridge() {
749
+ if (bridgeBootstrapped) return;
750
+ if (typeof window === "undefined") return;
751
+ bridgeBootstrapped = true;
752
+ if (window.CondorBridge && typeof window.CondorBridge.emit === "function") {
753
+ window.CondorBridge.emit = emit;
754
+ } else {
755
+ window.CondorBridge = { emit };
756
+ }
757
+ }
758
+ bootstrapWindowBridge();
759
+ function requireMockAdapter() {
760
+ const adapter = getActiveAdapter();
761
+ if (!adapter.isMock) {
762
+ throw new Error(
763
+ "condor-mobile-bridge: this API is available only in mock mode (no native host detected)."
764
+ );
765
+ }
766
+ return adapter;
767
+ }
768
+ var Bridge = {
769
+ get isMock() {
770
+ return getActiveAdapter().isMock;
771
+ },
772
+ handshake(opts) {
773
+ bootstrapWindowBridge();
774
+ if (pendingHandshake) {
775
+ return Promise.reject(
776
+ new Error(
777
+ "condor-mobile-bridge: handshake already in flight. Await the previous call before issuing another."
778
+ )
779
+ );
780
+ }
781
+ return new Promise((resolve, reject) => {
782
+ pendingHandshake = { resolve, reject };
783
+ try {
784
+ callNative(NATIVE_HANDLER, {
785
+ type: "handshake",
786
+ version: PROTOCOL_VERSION,
787
+ payload: { clientVersion: opts.clientVersion }
788
+ });
789
+ } catch (err) {
790
+ pendingHandshake = null;
791
+ reject(err instanceof Error ? err : new Error(String(err)));
792
+ }
793
+ });
794
+ },
795
+ on(key, cb) {
796
+ bootstrapWindowBridge();
797
+ return addSubscriber(key, cb);
798
+ },
799
+ send(type, payload) {
800
+ callNative(NATIVE_HANDLER, {
801
+ type,
802
+ version: PROTOCOL_VERSION,
803
+ payload: payload ?? {}
804
+ });
805
+ },
806
+ openSettings() {
807
+ this.send("openSettings");
808
+ },
809
+ setOutgoingStamp(stamp) {
810
+ setOutgoingStamp(stamp);
811
+ },
812
+ mockEmit(key, payload) {
813
+ requireMockAdapter().triggerEmit(key, payload ?? {});
814
+ },
815
+ getMockState() {
816
+ const adapter = requireMockAdapter();
817
+ return {
818
+ isMock: true,
819
+ messages: adapter.getMessages(),
820
+ subscriberKeys: getSubscriberKeys()
821
+ };
822
+ },
823
+ installDevPanel(options) {
824
+ const adapter = requireMockAdapter();
825
+ installDevPanel({ adapter, options });
826
+ }
827
+ };
828
+
829
+ // src/bridge-shell.ts
830
+ import * as v2 from "valibot";
831
+
832
+ // src/shell-messages.ts
833
+ import * as v from "valibot";
834
+ var BridgeKeySchema = v.picklist(Object.values(BRIDGE_KEYS));
835
+ var SubscribeMessage = v.object({
836
+ type: v.literal("subscribe"),
837
+ key: BridgeKeySchema
838
+ });
839
+ var UnsubscribeMessage = v.object({
840
+ type: v.literal("unsubscribe"),
841
+ key: BridgeKeySchema
842
+ });
843
+ var SendMessage = v.object({
844
+ type: v.literal("send"),
845
+ command: v.string(),
846
+ args: v.optional(v.record(v.string(), v.unknown()), {})
847
+ });
848
+ var HandshakeMessage = v.object({
849
+ type: v.literal("handshake"),
850
+ payload: v.object({ clientVersion: v.string() })
851
+ });
852
+ var JsErrorMessage = v.object({
853
+ type: v.literal("jsError"),
854
+ payload: v.object({
855
+ message: v.string(),
856
+ stack: v.optional(v.string()),
857
+ source: v.optional(v.string()),
858
+ lineno: v.optional(v.number()),
859
+ colno: v.optional(v.number())
860
+ })
861
+ });
862
+ var SandboxVerifyResponseMessage = v.object({
863
+ type: v.literal("sandbox-verify-response"),
864
+ flutterBridgeReachable: v.boolean(),
865
+ condorBridgeReachable: v.boolean(),
866
+ // Optional: clients on SDK < 0.18.0 do not send it.
867
+ nativeChannelReachable: v.optional(v.boolean())
868
+ });
869
+ var ClientToShellMessageSchema = v.variant("type", [
870
+ SubscribeMessage,
871
+ UnsubscribeMessage,
872
+ SendMessage,
873
+ HandshakeMessage,
874
+ JsErrorMessage,
875
+ SandboxVerifyResponseMessage
876
+ ]);
877
+ var EventMessage = v.object({
878
+ type: v.literal("event"),
879
+ key: BridgeKeySchema,
880
+ payload: v.unknown()
881
+ });
882
+ var HandshakeAckMessage = v.object({
883
+ type: v.literal("handshake-ack"),
884
+ ack: v.object({
885
+ shellVersion: v.string(),
886
+ /**
887
+ * Schema version declared by the **native** side, relayed by the shell.
888
+ *
889
+ * `null` when the native side has not answered, or does not exist (browser
890
+ * mock mode). The client runs inside a sandboxed iframe and never talks to
891
+ * the native side directly, so this is the **only** way it can learn which
892
+ * capabilities are available.
893
+ */
894
+ nativeVersion: v.nullable(v.string()),
895
+ accepted: v.boolean()
896
+ })
897
+ });
898
+ var NackMessage = v.object({
899
+ type: v.literal("nack"),
900
+ key: v.optional(v.string()),
901
+ command: v.optional(v.string()),
902
+ reason: v.string()
903
+ });
904
+ var VerifySandboxMessage = v.object({
905
+ type: v.literal("verify-sandbox")
906
+ });
907
+ var ShellToClientMessageSchema = v.variant("type", [
908
+ EventMessage,
909
+ HandshakeAckMessage,
910
+ NackMessage,
911
+ VerifySandboxMessage
912
+ ]);
913
+
914
+ // src/bridge-shell.ts
915
+ var SHELL_VERSION = "0.6.0";
916
+ var NATIVE_HANDSHAKE_TIMEOUT_MS = 2e3;
917
+ var VERIFY_TIMEOUT_MS = 2e3;
918
+ var OVERRIDE_PREFIX = `<script>
919
+ (function(){
920
+ try { delete window.flutter_inappwebview; } catch (_) {}
921
+ try { delete window.CondorBridge; } catch (_) {}
922
+ try { delete window.CondorNative; } catch (_) {}
923
+ try {
924
+ Object.defineProperty(window, 'flutter_inappwebview', {
925
+ get: function(){ return undefined; },
926
+ set: function(_){ /* no-op: blocks the plugin injecting into the iframe */ },
927
+ configurable: false,
928
+ });
929
+ } catch (_) {}
930
+ try {
931
+ Object.defineProperty(window, 'CondorNative', {
932
+ get: function(){ return undefined; },
933
+ set: function(_){ /* no-op: bloqueia o canal do webview_flutter no iframe */ },
934
+ configurable: false,
935
+ });
936
+ } catch (_) {}
937
+ try {
938
+ Object.defineProperty(window, 'CondorBridge', {
939
+ get: function(){ return undefined; },
940
+ set: function(_){ /* no-op: bloqueia bootstrap do package legado */ },
941
+ configurable: false,
942
+ });
943
+ } catch (_) {}
944
+ try {
945
+ // Neutralize navigator.sendBeacon: an exfiltration channel that CSP
946
+ // connect-src does not reliably block in some WebViews (pen test PT-10).
947
+ // The client communicates only through BridgeClient/postMessage, so it has
948
+ // no legitimate use for sendBeacon. The no-op getter returns a function
949
+ // that answers false; the no-op setter ignores attempts to restore it.
950
+ Object.defineProperty(navigator, 'sendBeacon', {
951
+ get: function(){ return function(){ return false; }; },
952
+ set: function(_){ /* no-op */ },
953
+ configurable: false,
954
+ });
955
+ } catch (_) {}
956
+ })();
957
+ </script>`;
958
+ var BridgeShell = class {
959
+ constructor(opts) {
960
+ this.subscriptions = /* @__PURE__ */ new Map();
961
+ this.detached = false;
962
+ /**
963
+ * The native version, fetched once and cached. `null` until the native side
964
+ * answers, and permanently `null` in a browser with no native host.
965
+ */
966
+ this.nativeVersion = null;
967
+ /** In-flight native handshake; guarantees a single call. */
968
+ this.nativeHandshake = null;
969
+ // Pending `verify-sandbox` round trips, keyed by a generated nonce.
970
+ this.pendingVerifies = /* @__PURE__ */ new Map();
971
+ this.onMessage = async (event) => {
972
+ if (this.detached) return;
973
+ if (event.source !== this.iframe.contentWindow) return;
974
+ const parsed = v2.safeParse(ClientToShellMessageSchema, event.data);
975
+ if (!parsed.success) {
976
+ if (typeof console !== "undefined") {
977
+ console.debug("[BridgeShell] dropped malformed message:", parsed.issues?.[0]?.message);
978
+ }
979
+ return;
980
+ }
981
+ const msg = parsed.output;
982
+ switch (msg.type) {
983
+ case "subscribe":
984
+ await this.handleSubscribe(msg.key);
985
+ break;
986
+ case "unsubscribe":
987
+ this.handleUnsubscribe(msg.key);
988
+ break;
989
+ case "send":
990
+ await this.handleSend(msg.command, msg.args ?? {});
991
+ break;
992
+ case "handshake":
993
+ void this.resolveNativeVersion().then((nativeVersion) => {
994
+ this.postToClient({
995
+ type: "handshake-ack",
996
+ ack: { shellVersion: SHELL_VERSION, nativeVersion, accepted: true }
997
+ });
998
+ });
999
+ break;
1000
+ case "jsError":
1001
+ Bridge.send("jsError", {
1002
+ origin: "iframe",
1003
+ message: msg.payload.message,
1004
+ stack: msg.payload.stack,
1005
+ source: msg.payload.source,
1006
+ lineno: msg.payload.lineno,
1007
+ colno: msg.payload.colno
1008
+ });
1009
+ break;
1010
+ case "sandbox-verify-response": {
1011
+ const first = this.pendingVerifies.keys().next().value;
1012
+ if (first !== void 0) {
1013
+ const pending = this.pendingVerifies.get(first);
1014
+ if (pending) {
1015
+ clearTimeout(pending.timer);
1016
+ this.pendingVerifies.delete(first);
1017
+ const nativeChannelReachable = msg.nativeChannelReachable ?? false;
1018
+ const ok = !msg.flutterBridgeReachable && !msg.condorBridgeReachable && !nativeChannelReachable;
1019
+ pending.resolve({
1020
+ flutterBridgeReachable: msg.flutterBridgeReachable,
1021
+ condorBridgeReachable: msg.condorBridgeReachable,
1022
+ nativeChannelReachable,
1023
+ ok
1024
+ });
1025
+ }
1026
+ }
1027
+ break;
1028
+ }
1029
+ }
1030
+ };
1031
+ this.iframe = opts.iframe;
1032
+ this.policy = opts.policy;
1033
+ this.onConfirm = opts.onConfirmationRequest;
1034
+ if (typeof window !== "undefined") {
1035
+ window.addEventListener("message", this.onMessage);
1036
+ }
1037
+ }
1038
+ /**
1039
+ * **Layer 1.** Bootstraps by fetching a URL.
1040
+ *
1041
+ * A shortcut for the common HTTP/HTTPS case: it runs `fetch(clientPath)` and
1042
+ * delegates to `bootstrapClientIframeWithHtml`.
1043
+ *
1044
+ * **It does not work on the `file://` scheme** — Android's Chromium WebView
1045
+ * blocks it with "URL scheme 'file' is not supported". For an asset bundled
1046
+ * in the APK, or a bundle inlined at build time, use
1047
+ * `bootstrapClientIframeWithHtml(htmlString)` instead.
1048
+ *
1049
+ * **Call it once at shell boot**, before any other interaction. After it
1050
+ * returns, invoke `verifySandbox()` for attestation (Layer 2).
1051
+ */
1052
+ async bootstrapClientIframe(clientPath = "client.html") {
1053
+ const res = await fetch(clientPath);
1054
+ if (!res.ok) throw new Error(`BridgeShell: failed to load ${clientPath} (${res.status})`);
1055
+ const clientHtml = await res.text();
1056
+ await this.bootstrapClientIframeWithHtml(clientHtml);
1057
+ }
1058
+ /**
1059
+ * **Layer 1.** Bootstraps from an HTML string.
1060
+ *
1061
+ * Takes the client HTML directly — already read from a file, embedded at
1062
+ * build time, read from storage — and sets it as the iframe's `srcdoc`,
1063
+ * prefixed by the override script, which runs at parse time before any
1064
+ * client script.
1065
+ *
1066
+ * Use this when `fetch` does not apply: an asset bundled under `file://`, a
1067
+ * bundle inlined at build time, any scenario without network. Android
1068
+ * WebView blocks `fetch` on the `file://` scheme, so this is the path the
1069
+ * app actually takes. For HTTP, use `bootstrapClientIframe(clientPath)`.
1070
+ *
1071
+ * **Call it once at shell boot**, before any other interaction. After it
1072
+ * returns, invoke `verifySandbox()` for attestation (Layer 2).
1073
+ */
1074
+ async bootstrapClientIframeWithHtml(clientHtml) {
1075
+ this.iframe.srcdoc = OVERRIDE_PREFIX + clientHtml;
1076
+ await this.waitForIframeLoad();
1077
+ }
1078
+ waitForIframeLoad() {
1079
+ return new Promise((resolve) => {
1080
+ const onLoad = () => {
1081
+ this.iframe.removeEventListener("load", onLoad);
1082
+ resolve();
1083
+ };
1084
+ this.iframe.addEventListener("load", onLoad);
1085
+ });
1086
+ }
1087
+ /**
1088
+ * **Layer 2.** A `postMessage` round trip asking the iframe for an
1089
+ * authoritative attestation of whether the native globals are reachable.
1090
+ * Times out after 2 s.
1091
+ *
1092
+ * `ok` is `true` only when none of them is reachable. If it comes back
1093
+ * `false`, **the shell must abort the app boot**: Layer 1 failed, and client
1094
+ * code would run with a path to the native layer.
1095
+ */
1096
+ verifySandbox() {
1097
+ if (!this.iframe.contentWindow) {
1098
+ return Promise.reject(new Error("BridgeShell: iframe has no contentWindow"));
1099
+ }
1100
+ return new Promise((resolve, reject) => {
1101
+ const nonce = Math.random().toString(36).slice(2);
1102
+ const timer = setTimeout(() => {
1103
+ this.pendingVerifies.delete(nonce);
1104
+ reject(new Error(`BridgeShell: verify-sandbox timed out after ${VERIFY_TIMEOUT_MS}ms`));
1105
+ }, VERIFY_TIMEOUT_MS);
1106
+ this.pendingVerifies.set(nonce, { resolve, reject, timer });
1107
+ this.postToClient({ type: "verify-sandbox" });
1108
+ });
1109
+ }
1110
+ /**
1111
+ * Tears the shell down: removes the `message` listener, cancels pending
1112
+ * subscriptions on the native `Bridge`, and clears internal state.
1113
+ */
1114
+ detach() {
1115
+ if (this.detached) return;
1116
+ this.detached = true;
1117
+ if (typeof window !== "undefined") {
1118
+ window.removeEventListener("message", this.onMessage);
1119
+ }
1120
+ for (const unsub of this.subscriptions.values()) unsub();
1121
+ this.subscriptions.clear();
1122
+ for (const pending of this.pendingVerifies.values()) {
1123
+ clearTimeout(pending.timer);
1124
+ pending.reject(new Error("BridgeShell: detached"));
1125
+ }
1126
+ this.pendingVerifies.clear();
1127
+ }
1128
+ async handleSubscribe(key) {
1129
+ if (!this.policy.readOnlyKeys.includes(key)) {
1130
+ this.postToClient({ type: "nack", key, reason: "not-read-only" });
1131
+ return;
1132
+ }
1133
+ if (this.subscriptions.has(key)) return;
1134
+ const unsub = Bridge.on(key, (payload) => {
1135
+ if (this.detached) return;
1136
+ this.postToClient({ type: "event", key, payload });
1137
+ });
1138
+ this.subscriptions.set(key, unsub);
1139
+ }
1140
+ handleUnsubscribe(key) {
1141
+ const unsub = this.subscriptions.get(key);
1142
+ if (!unsub) return;
1143
+ unsub();
1144
+ this.subscriptions.delete(key);
1145
+ }
1146
+ async handleSend(command, args) {
1147
+ if (this.policy.deniedCommands.includes(command)) {
1148
+ this.postToClient({ type: "nack", command, reason: "denied" });
1149
+ return;
1150
+ }
1151
+ if (this.policy.requiresConfirmation.includes(command)) {
1152
+ if (!this.onConfirm) {
1153
+ this.postToClient({ type: "nack", command, reason: "no-confirmation-handler" });
1154
+ return;
1155
+ }
1156
+ let approved = false;
1157
+ try {
1158
+ approved = await this.onConfirm({ command, args });
1159
+ } catch (err) {
1160
+ this.postToClient({
1161
+ type: "nack",
1162
+ command,
1163
+ reason: `confirmation-error: ${err instanceof Error ? err.message : String(err)}`
1164
+ });
1165
+ return;
1166
+ }
1167
+ if (!approved) {
1168
+ this.postToClient({ type: "nack", command, reason: "user-rejected" });
1169
+ return;
1170
+ }
1171
+ } else if (!this.policy.allowedCommands.includes(command)) {
1172
+ this.postToClient({ type: "nack", command, reason: "not-allowed" });
1173
+ return;
1174
+ }
1175
+ Bridge.send(command, args);
1176
+ }
1177
+ /**
1178
+ * Resolves `nativeVersion`, firing the native handshake the first time and
1179
+ * caching the result.
1180
+ *
1181
+ * ⚠️ `Bridge.handshake` has **no timeout**: its promise only settles when the
1182
+ * native side emits `handshake-ack`. In a browser with no native host it
1183
+ * never settles, so the race below is what guarantees the client always gets
1184
+ * an answer — `null` — instead of hanging.
1185
+ */
1186
+ async resolveNativeVersion() {
1187
+ if (this.nativeVersion !== null) return this.nativeVersion;
1188
+ if (!this.nativeHandshake) {
1189
+ this.nativeHandshake = Bridge.handshake({ clientVersion: SHELL_VERSION }).then((ack) => {
1190
+ this.nativeVersion = ack.nativeVersion;
1191
+ }).catch(() => {
1192
+ });
1193
+ }
1194
+ await Promise.race([
1195
+ this.nativeHandshake,
1196
+ new Promise((resolve) => setTimeout(resolve, NATIVE_HANDSHAKE_TIMEOUT_MS))
1197
+ ]);
1198
+ return this.nativeVersion;
1199
+ }
1200
+ postToClient(msg) {
1201
+ if (this.detached) return;
1202
+ this.iframe.contentWindow?.postMessage(msg, "*");
1203
+ }
1204
+ };
1205
+
1206
+ // src/dev/policy.ts
1207
+ var CANVAS_POLICY = {
1208
+ readOnlyKeys: [
1209
+ BRIDGE_KEYS.userDevice,
1210
+ BRIDGE_KEYS.deviceConnection,
1211
+ BRIDGE_KEYS.deviceLightSensors,
1212
+ BRIDGE_KEYS.deviceNotification,
1213
+ BRIDGE_KEYS.pingPong,
1214
+ BRIDGE_KEYS.realTime,
1215
+ BRIDGE_KEYS.syncTime,
1216
+ BRIDGE_KEYS.deleteMemory,
1217
+ BRIDGE_KEYS.sensorLog,
1218
+ BRIDGE_KEYS.systemStatus,
1219
+ BRIDGE_KEYS.navigationBack,
1220
+ BRIDGE_KEYS.language
1221
+ ],
1222
+ allowedCommands: [
1223
+ "userDevice",
1224
+ "realTime",
1225
+ "openSettings",
1226
+ "deviceConnection",
1227
+ "ping",
1228
+ "jsError",
1229
+ "sensorLog",
1230
+ "setStatusBarColor",
1231
+ "requestPermission",
1232
+ "navigationState",
1233
+ "language"
1234
+ ],
1235
+ requiresConfirmation: ["syncTime", "deleteMemory", "logout"],
1236
+ deniedCommands: ["dfu"]
1237
+ };
1238
+ var CANVAS_CSP = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none';";
1239
+
1240
+ // src/dev/scenarios.ts
1241
+ var HARNESS_NATIVE_VERSION = "0.17.0";
1242
+ var base = {
1243
+ description: "Two devices, background service already running, realistic latency.",
1244
+ nativeVersion: HARNESS_NATIVE_VERSION,
1245
+ latencyMs: [80, 250],
1246
+ serviceStartMs: 0,
1247
+ userDeviceSilent: false,
1248
+ logPeriodS: 60,
1249
+ systemStatus: systemStatus(),
1250
+ devices: () => twoDevices()
1251
+ };
1252
+ var SCENARIOS = {
1253
+ default: base,
1254
+ "cold-start": {
1255
+ ...base,
1256
+ description: "App launched from cold: the background service takes 4 s to start, and every ping/userDevice sent before that is lost. A bundle that asks once stays on its loading screen forever.",
1257
+ serviceStartMs: 4e3
1258
+ },
1259
+ "no-devices": {
1260
+ ...base,
1261
+ description: "Account with no linked device: userDevice never answers and systemStatus.hasDevices is false. Your bundle must show an empty state.",
1262
+ userDeviceSilent: true,
1263
+ systemStatus: systemStatus({ hasDevices: false })
1264
+ },
1265
+ "shared-serial": {
1266
+ ...base,
1267
+ description: "An ActLumus and an ActLumus Plus share serial number 7047. Only deviceId tells them apart; sensorLog cannot address one of them.",
1268
+ devices: () => sharedSerial()
1269
+ },
1270
+ offline: {
1271
+ ...base,
1272
+ description: "No internet. systemStatus.internet is false and sensorLog fails; userDevice answers from the cache.",
1273
+ sensorLogError: "Connection failed",
1274
+ systemStatus: systemStatus({ internet: false })
1275
+ }
1276
+ };
1277
+ function resolveScenario(scenario) {
1278
+ if (scenario === void 0) return SCENARIOS.default;
1279
+ if (typeof scenario === "string") {
1280
+ const found = SCENARIOS[scenario];
1281
+ if (!found) {
1282
+ throw new Error(
1283
+ `condor-mobile-bridge dev: unknown scenario "${scenario}". Known: ${Object.keys(SCENARIOS).join(", ")}.`
1284
+ );
1285
+ }
1286
+ return found;
1287
+ }
1288
+ return scenario;
1289
+ }
1290
+
1291
+ // src/dev/harness.ts
1292
+ var DEV_HARNESS_MARKER = "__condorDevHarness__";
1293
+ function createSimulatedNative(adapter, scenario, hooks = {}) {
1294
+ const now = hooks.now ?? Date.now;
1295
+ const startedAt = now();
1296
+ const log = [];
1297
+ const timers = /* @__PURE__ */ new Set();
1298
+ let status = { ...scenario.systemStatus };
1299
+ let canGoBack = false;
1300
+ const record2 = (entry) => {
1301
+ const full = { at: now(), ...entry };
1302
+ log.push(full);
1303
+ if (log.length > 500) log.shift();
1304
+ hooks.onLog?.(full);
1305
+ if (typeof console !== "undefined") console.info(`[condor dev] ${full.kind}: ${full.detail}`);
1306
+ };
1307
+ const latency = () => {
1308
+ const l = scenario.latencyMs;
1309
+ return Array.isArray(l) ? l[0] + Math.random() * (l[1] - l[0]) : l;
1310
+ };
1311
+ const reply = (key, payload, delay = latency()) => {
1312
+ const timer = setTimeout(() => {
1313
+ timers.delete(timer);
1314
+ record2({ kind: "reply", key, detail: `${key} \u2192 bundle` });
1315
+ adapter.triggerEmit(key, payload);
1316
+ }, delay);
1317
+ timers.add(timer);
1318
+ };
1319
+ const serviceUp = () => now() - startedAt >= scenario.serviceStartMs;
1320
+ const language = () => {
1321
+ const deviceLocale = typeof navigator !== "undefined" ? navigator.language : "en-US";
1322
+ const lang = deviceLocale.slice(0, 2);
1323
+ return {
1324
+ deviceLocale,
1325
+ appLanguage: null,
1326
+ resolvedLanguage: ["en", "pt", "de", "fr"].includes(lang) ? lang : "en"
1327
+ };
1328
+ };
1329
+ const handle = (msg) => {
1330
+ const env = msg.payload ?? {};
1331
+ const command = env.type ?? "";
1332
+ const args = env.payload ?? {};
1333
+ if (command !== "handshake") {
1334
+ const shown = JSON.stringify(args);
1335
+ const detail = shown === "{}" ? command : `${command} ${shown.length > 160 ? `${shown.slice(0, 157)}...` : shown}`;
1336
+ record2({ kind: "sent", command, detail });
1337
+ }
1338
+ switch (command) {
1339
+ case "handshake":
1340
+ if (scenario.nativeVersion === null) {
1341
+ record2({
1342
+ kind: "info",
1343
+ detail: "handshake unanswered: the bundle gets nativeVersion null"
1344
+ });
1345
+ return;
1346
+ }
1347
+ reply("handshake-ack", { nativeVersion: scenario.nativeVersion, accepted: true });
1348
+ reply("systemStatus", status, latency() + 20);
1349
+ reply("language", language(), latency() + 30);
1350
+ return;
1351
+ case "ping":
1352
+ if (!serviceUp()) {
1353
+ record2({
1354
+ kind: "dropped",
1355
+ command,
1356
+ detail: "ping lost: the background service is not running yet"
1357
+ });
1358
+ return;
1359
+ }
1360
+ reply("pingPong", { data: "pong" });
1361
+ return;
1362
+ case "userDevice":
1363
+ if (!serviceUp()) {
1364
+ record2({
1365
+ kind: "dropped",
1366
+ command,
1367
+ detail: "userDevice lost: the background service is not running yet. Ask again."
1368
+ });
1369
+ return;
1370
+ }
1371
+ if (scenario.userDeviceSilent) {
1372
+ record2({
1373
+ kind: "dropped",
1374
+ command,
1375
+ detail: "userDevice unanswered: the account has no linked device (the app replies only on success)"
1376
+ });
1377
+ return;
1378
+ }
1379
+ reply("userDevice", scenario.devices());
1380
+ return;
1381
+ case "sensorLog": {
1382
+ const query = args;
1383
+ if (scenario.sensorLogError) {
1384
+ reply("sensorLog", {
1385
+ requestId: query.requestId,
1386
+ success: false,
1387
+ error: scenario.sensorLogError
1388
+ });
1389
+ return;
1390
+ }
1391
+ const matches = scenario.devices().devicesConfig.filter((d) => d.serialNo === query.serialNo);
1392
+ if (matches.length > 1) {
1393
+ reply("sensorLog", {
1394
+ requestId: query.requestId,
1395
+ success: false,
1396
+ error: "DEVICE_SERIAL_AMBIGUOUS"
1397
+ });
1398
+ return;
1399
+ }
1400
+ const device = matches[0];
1401
+ reply(
1402
+ "sensorLog",
1403
+ sensorLogPage(query, {
1404
+ now: now(),
1405
+ periodS: scenario.logPeriodS,
1406
+ deviceId: device?.deviceId
1407
+ })
1408
+ );
1409
+ return;
1410
+ }
1411
+ case "realTime":
1412
+ reply("realTime", {
1413
+ serialNo: args.serialNo,
1414
+ ...args.deviceId !== void 0 ? { deviceId: args.deviceId } : {},
1415
+ success: true,
1416
+ value: args.value === true
1417
+ });
1418
+ return;
1419
+ case "syncTime":
1420
+ case "deleteMemory":
1421
+ reply(command, {
1422
+ serialNo: args.serialNo,
1423
+ ...args.deviceId !== void 0 ? { deviceId: args.deviceId } : {},
1424
+ success: true
1425
+ });
1426
+ return;
1427
+ case "deviceConnection":
1428
+ if (args.type === "add") {
1429
+ reply("deviceConnection", {
1430
+ serialNo: args.serialNo,
1431
+ ...args.deviceId !== void 0 ? { deviceId: args.deviceId } : {},
1432
+ isConnect: true
1433
+ });
1434
+ }
1435
+ return;
1436
+ case "language":
1437
+ reply("language", language());
1438
+ return;
1439
+ case "requestPermission": {
1440
+ const name = String(args.permission);
1441
+ status = {
1442
+ ...status,
1443
+ missingEssentialPermissions: status.missingEssentialPermissions.filter((p) => p !== name),
1444
+ missingOptionalPermissions: status.missingOptionalPermissions.filter((p) => p !== name)
1445
+ };
1446
+ reply("systemStatus", status, latency() + 400);
1447
+ return;
1448
+ }
1449
+ case "jsError":
1450
+ record2({
1451
+ kind: "info",
1452
+ command,
1453
+ detail: `jsError from the bundle: ${String(args.message)}`
1454
+ });
1455
+ return;
1456
+ case "logout":
1457
+ record2({
1458
+ kind: "info",
1459
+ command,
1460
+ detail: "logout confirmed: the app would return to its login screen"
1461
+ });
1462
+ return;
1463
+ case "navigationState":
1464
+ canGoBack = args.canGoBack === true;
1465
+ return;
1466
+ default:
1467
+ return;
1468
+ }
1469
+ };
1470
+ const unsubscribe = adapter.onCallHandler(handle);
1471
+ if (scenario.serviceStartMs > 0) {
1472
+ const timer = setTimeout(() => {
1473
+ timers.delete(timer);
1474
+ record2({ kind: "info", detail: "background service is up: ping and userDevice now answer" });
1475
+ }, scenario.serviceStartMs);
1476
+ timers.add(timer);
1477
+ }
1478
+ return {
1479
+ log,
1480
+ pressBack() {
1481
+ if (!canGoBack) {
1482
+ record2({
1483
+ kind: "info",
1484
+ detail: "OS back: the bundle reported canGoBack false, so the app keeps the Home"
1485
+ });
1486
+ return;
1487
+ }
1488
+ reply("navigationBack", {}, 0);
1489
+ },
1490
+ dispose() {
1491
+ unsubscribe();
1492
+ for (const t of timers) clearTimeout(t);
1493
+ timers.clear();
1494
+ }
1495
+ };
1496
+ }
1497
+ function confirmInPage(req) {
1498
+ return new Promise((resolve) => {
1499
+ const overlay = document.createElement("div");
1500
+ overlay.setAttribute(
1501
+ "style",
1502
+ "position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;z-index:2147483646;font:14px system-ui,sans-serif"
1503
+ );
1504
+ const box = document.createElement("div");
1505
+ box.setAttribute(
1506
+ "style",
1507
+ "background:#fff;color:#111;border-radius:12px;padding:20px;max-width:320px;box-shadow:0 8px 32px rgba(0,0,0,.3)"
1508
+ );
1509
+ const text = document.createElement("p");
1510
+ text.textContent = `The app asks the person to confirm "${req.command}". On a device this is a Condor dialog; if they decline, your bundle receives nothing.`;
1511
+ const buttons = document.createElement("div");
1512
+ buttons.setAttribute("style", "display:flex;gap:8px;justify-content:flex-end;margin-top:12px");
1513
+ const done = (value) => {
1514
+ overlay.remove();
1515
+ resolve(value);
1516
+ };
1517
+ for (const [label, value] of [
1518
+ ["Decline", false],
1519
+ ["Confirm", true]
1520
+ ]) {
1521
+ const button = document.createElement("button");
1522
+ button.textContent = label;
1523
+ button.addEventListener("click", () => done(value));
1524
+ buttons.appendChild(button);
1525
+ }
1526
+ box.append(text, buttons);
1527
+ overlay.appendChild(box);
1528
+ document.body.appendChild(overlay);
1529
+ });
1530
+ }
1531
+ async function createDevHarness(opts) {
1532
+ if (typeof document === "undefined") {
1533
+ throw new Error(
1534
+ "createDevHarness: needs a DOM (run it in the browser page that hosts the bundle)."
1535
+ );
1536
+ }
1537
+ const adapter = getActiveAdapter();
1538
+ if (!adapter.isMock) {
1539
+ throw new Error(
1540
+ "createDevHarness: a native host is present; the harness only runs in a plain browser."
1541
+ );
1542
+ }
1543
+ globalThis[DEV_HARNESS_MARKER] = true;
1544
+ const scenario = resolveScenario(opts.scenario);
1545
+ const native = createSimulatedNative(adapter, scenario, { onLog: opts.onLog });
1546
+ const iframe = document.createElement("iframe");
1547
+ iframe.setAttribute("sandbox", "allow-scripts");
1548
+ iframe.setAttribute("title", "Condor Canvas bundle");
1549
+ iframe.setAttribute("style", "border:0;width:100%;height:100%;display:block;background:#fff");
1550
+ (opts.mount ?? document.body).appendChild(iframe);
1551
+ const shell = new BridgeShell({
1552
+ iframe,
1553
+ policy: CANVAS_POLICY,
1554
+ onConfirmationRequest: opts.onConfirmationRequest ?? confirmInPage
1555
+ });
1556
+ if (opts.devPanel !== false) {
1557
+ installDevPanel({ adapter, options: { startCollapsed: true } });
1558
+ }
1559
+ await shell.bootstrapClientIframeWithHtml(opts.html);
1560
+ try {
1561
+ const result = await shell.verifySandbox();
1562
+ if (!result.ok) {
1563
+ console.error(
1564
+ "[condor dev] sandbox verification failed: the app would abort the boot.",
1565
+ result
1566
+ );
1567
+ }
1568
+ } catch (err) {
1569
+ console.warn(
1570
+ "[condor dev] the bundle did not answer the sandbox probe: its script did not run. Check the console of the iframe for errors, that the page imports BridgeClient, and that the input is self-contained (or let the harness inline it: pass the dist/index.html your bundler wrote).",
1571
+ err
1572
+ );
1573
+ }
1574
+ return {
1575
+ scenario,
1576
+ iframe,
1577
+ shell,
1578
+ get log() {
1579
+ return native.log;
1580
+ },
1581
+ emit(key, payload) {
1582
+ Bridge.mockEmit(key, payload);
1583
+ },
1584
+ pressBack() {
1585
+ native.pressBack();
1586
+ },
1587
+ destroy() {
1588
+ native.dispose();
1589
+ shell.detach();
1590
+ iframe.remove();
1591
+ }
1592
+ };
1593
+ }
1594
+ export {
1595
+ CANVAS_CSP,
1596
+ CANVAS_POLICY,
1597
+ DEFAULT_LOG_PERIOD_S,
1598
+ DEV_HARNESS_MARKER,
1599
+ HARNESS_NATIVE_VERSION,
1600
+ SCENARIOS,
1601
+ createDevHarness,
1602
+ createSimulatedNative,
1603
+ formatLocalTimestamp,
1604
+ parseLocalTimestamp,
1605
+ resolveScenario,
1606
+ sensorLogPage,
1607
+ sharedSerial,
1608
+ systemStatus,
1609
+ twoDevices
1610
+ };
1611
+ //# sourceMappingURL=dev.js.map