@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/cli.cjs CHANGED
@@ -2,8 +2,8 @@
2
2
  "use strict";
3
3
 
4
4
  // src/cli.ts
5
- var import_node_fs2 = require("fs");
6
- var import_node_path2 = require("path");
5
+ var import_node_fs3 = require("fs");
6
+ var import_node_path3 = require("path");
7
7
 
8
8
  // src/cli-core.ts
9
9
  var import_node_crypto = require("crypto");
@@ -168,8 +168,16 @@ var BLOCKED_API_RULES = [
168
168
  message: "connect-src 'self' on a null origin blocks every network call. Request data through the bridge instead (e.g. BridgeClient.fetchSensorLog)."
169
169
  }
170
170
  ];
171
+ var DEV_HARNESS_MARKER = "__condorDevHarness__";
171
172
  function lintClientHtml(html) {
172
173
  const findings = [];
174
+ if (html.includes(DEV_HARNESS_MARKER)) {
175
+ findings.push({
176
+ severity: "error",
177
+ rule: "dev-harness-bundled",
178
+ message: "The bundle contains the development harness (@condorinst/mobile-bridge/dev). It belongs in the page that hosts the bundle during development, never in the bundle: remove the import."
179
+ });
180
+ }
173
181
  for (const { rule, pattern, message } of BLOCKED_API_RULES) {
174
182
  if (pattern.test(html)) findings.push({ severity: "warning", rule, message });
175
183
  }
@@ -277,6 +285,7 @@ function validateBundle(inputPath, maxBytes = DEFAULT_MAX_BYTES) {
277
285
  };
278
286
  }
279
287
  var DEFAULT_OUT = "dist/whitelabel-bundle.zip";
288
+ var DEFAULT_DEV_PORT = 5173;
280
289
  function parseArgs(argv) {
281
290
  const args = {
282
291
  command: "build",
@@ -284,6 +293,9 @@ function parseArgs(argv) {
284
293
  outPath: DEFAULT_OUT,
285
294
  maxBytes: DEFAULT_MAX_BYTES,
286
295
  strict: false,
296
+ scenario: "default",
297
+ port: DEFAULT_DEV_PORT,
298
+ nativeVersion: void 0,
287
299
  help: false
288
300
  };
289
301
  for (let i = 0; i < argv.length; i++) {
@@ -314,6 +326,18 @@ function parseArgs(argv) {
314
326
  case "validate":
315
327
  args.command = "validate";
316
328
  break;
329
+ case "dev":
330
+ args.command = "dev";
331
+ break;
332
+ case "--scenario":
333
+ args.scenario = argv[++i] ?? "default";
334
+ break;
335
+ case "--native-version":
336
+ args.nativeVersion = argv[++i] ?? "null";
337
+ break;
338
+ case "--port":
339
+ args.port = Number.parseInt(argv[++i] ?? "", 10) || DEFAULT_DEV_PORT;
340
+ break;
317
341
  default:
318
342
  if (token && !token.startsWith("-") && !args.htmlPath) args.htmlPath = token;
319
343
  }
@@ -325,25 +349,423 @@ var USAGE = `condor-mobile-bridge \u2014 tooling for Condor Canvas client bundle
325
349
  Usage:
326
350
  npx condor-mobile-bridge build -i <html> [-o <zip>]
327
351
  npx condor-mobile-bridge validate -i <zip|html> [--strict]
352
+ npx condor-mobile-bridge dev -i <html|zip|dir> [--scenario <name>] [--native-version <v|null>] [--port <n>]
328
353
 
329
354
  Commands:
330
355
  build Inline a dist/index.html into a self-contained .zip.
331
356
  validate Check a bundle against every rule the Canvas app enforces, plus
332
357
  the APIs the sandbox and the CSP block at runtime.
358
+ dev Serve the bundle in a browser harness that behaves like the app:
359
+ same sandbox, shell and policy, and a simulated native side.
360
+ Re-reads the input on every reload. Scenarios: default,
361
+ cold-start, no-devices, shared-serial, offline.
333
362
 
334
363
  Options:
335
364
  -i, --html <path> Input. build: dist/index.html. validate: .zip or .html. Required.
336
365
  -o, --out <path> build only: output .zip (default: ${DEFAULT_OUT}).
337
366
  --max-bytes <n> Size limit in bytes (default: ${DEFAULT_MAX_BYTES} = 2 MB).
338
367
  --strict validate only: exit 1 on warnings too, not just errors.
368
+ --scenario <n> dev only: device behaviour to simulate (default: default).
369
+ --port <n> dev only: HTTP port (default: ${DEFAULT_DEV_PORT}).
370
+ --native-version <v|null>
371
+ dev only: nativeVersion the harness reports (default
372
+ 0.17.0). 'null' reproduces a shell that could not
373
+ reach the native side.
339
374
  -h, --help Show this help.`;
340
375
 
376
+ // src/cli-dev.ts
377
+ var import_node_fs2 = require("fs");
378
+ var import_node_http = require("http");
379
+ var import_node_path2 = require("path");
380
+ var import_node_url = require("url");
381
+ var import_fflate2 = require("fflate");
382
+
383
+ // src/keys.ts
384
+ var BRIDGE_KEYS = {
385
+ pingPong: "pingPong",
386
+ userDevice: "userDevice",
387
+ deviceLightSensors: "deviceLightSensors",
388
+ syncTime: "syncTime",
389
+ realTime: "realTime",
390
+ deleteMemory: "deleteMemory",
391
+ deviceNotification: "deviceNotification",
392
+ deviceConnection: "deviceConnection",
393
+ sensorLog: "sensorLog",
394
+ dfu: "dfu",
395
+ systemStatus: "systemStatus",
396
+ /**
397
+ * Native → client: the OS back button was pressed and the client had
398
+ * reported internal history via `reportNavigation(true)`. Go back exactly
399
+ * ONE internal page. Emitted only when the native side decided to delegate
400
+ * the back gesture. See `BridgeClient.onBack`.
401
+ */
402
+ navigationBack: "navigationBack",
403
+ /**
404
+ * Native → client: the app's language state. Carries the raw device locale
405
+ * AND the language explicitly chosen in the app Settings, so the client can
406
+ * apply its own precedence rule — including rendering a language the native
407
+ * chrome does not support. Emitted on attach, after `handshake-ack`, and on
408
+ * every change in Settings. See `LanguagePayload`.
409
+ */
410
+ language: "language"
411
+ };
412
+
413
+ // src/dev/policy.ts
414
+ var CANVAS_POLICY = {
415
+ readOnlyKeys: [
416
+ BRIDGE_KEYS.userDevice,
417
+ BRIDGE_KEYS.deviceConnection,
418
+ BRIDGE_KEYS.deviceLightSensors,
419
+ BRIDGE_KEYS.deviceNotification,
420
+ BRIDGE_KEYS.pingPong,
421
+ BRIDGE_KEYS.realTime,
422
+ BRIDGE_KEYS.syncTime,
423
+ BRIDGE_KEYS.deleteMemory,
424
+ BRIDGE_KEYS.sensorLog,
425
+ BRIDGE_KEYS.systemStatus,
426
+ BRIDGE_KEYS.navigationBack,
427
+ BRIDGE_KEYS.language
428
+ ],
429
+ allowedCommands: [
430
+ "userDevice",
431
+ "realTime",
432
+ "openSettings",
433
+ "deviceConnection",
434
+ "ping",
435
+ "jsError",
436
+ "sensorLog",
437
+ "setStatusBarColor",
438
+ "requestPermission",
439
+ "navigationState",
440
+ "language"
441
+ ],
442
+ requiresConfirmation: ["syncTime", "deleteMemory", "logout"],
443
+ deniedCommands: ["dfu"]
444
+ };
445
+ 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';";
446
+
447
+ // src/dev/fixtures.ts
448
+ function config(spec) {
449
+ const item = {
450
+ deviceId: spec.deviceId,
451
+ serialNo: spec.serialNo,
452
+ deviceBodyPart: spec.bodyPart,
453
+ bleConnectionPeriod: 5,
454
+ autosync: true,
455
+ eraseDeviceMemory: false,
456
+ autoEraseDeviceMemory: false,
457
+ autoEraseDeviceMemoryThreshold: 80,
458
+ readSensorsMemory: false,
459
+ startDevice: false,
460
+ readFullMemory: false,
461
+ allowFirmwareUpdate: true
462
+ };
463
+ if (spec.modelId) item.modelId = spec.modelId;
464
+ return item;
465
+ }
466
+ function telemetry(spec, now) {
467
+ return {
468
+ serialNo: spec.serialNo,
469
+ deviceId: spec.deviceId,
470
+ updataDateTime: now - spec.syncedMinutesAgo * 6e4,
471
+ deviceName: spec.name,
472
+ deviceVersion: spec.modelId === "actlumus_plus" ? "3.0.2" : "2.1.0",
473
+ hardwareVersion: spec.modelId === "actlumus_plus" ? "B" : "A",
474
+ deviceDateTime: now - spec.syncedMinutesAgo * 6e4,
475
+ memoryDataUsage: 37.5,
476
+ memorySystemUsage: 4.2,
477
+ batteryLevel: spec.battery,
478
+ batteryVoltage: 3.7 + spec.battery / 1e3,
479
+ isCharging: spec.charging,
480
+ isOnDock: spec.charging,
481
+ syncErrors: spec.syncError ? [
482
+ {
483
+ serialNo: spec.serialNo,
484
+ attemptDate: now - 10 * 6e4,
485
+ message: spec.syncError,
486
+ messageCode: "SYNC_FAILED"
487
+ }
488
+ ] : []
489
+ };
490
+ }
491
+ function userConfig(spec) {
492
+ return {
493
+ serialNo: spec.serialNo,
494
+ deviceId: spec.deviceId,
495
+ syncTime: false,
496
+ realTime: false,
497
+ deleteMemory: false,
498
+ readAllMemory: false,
499
+ readAllSystemLog: false
500
+ };
501
+ }
502
+ function payloadOf(specs, now = Date.now()) {
503
+ return {
504
+ lastUpdate: now,
505
+ devicesConfig: specs.map(config),
506
+ devicesTelemetry: specs.map((s) => telemetry(s, now)),
507
+ devicesUserConfig: specs.map(userConfig)
508
+ };
509
+ }
510
+ function twoDevices(now = Date.now()) {
511
+ return payloadOf(
512
+ [
513
+ {
514
+ deviceId: 9001,
515
+ serialNo: 4534,
516
+ modelId: "actlumus",
517
+ bodyPart: "wrist",
518
+ name: "ActLumus 4534",
519
+ battery: 82,
520
+ charging: false,
521
+ syncedMinutesAgo: 12
522
+ },
523
+ {
524
+ deviceId: 9002,
525
+ serialNo: 5120,
526
+ modelId: "actlumus_plus",
527
+ bodyPart: "necklace",
528
+ name: "ActLumus Plus 5120",
529
+ battery: 41,
530
+ charging: true,
531
+ syncedMinutesAgo: 190,
532
+ syncError: "Could not read the device memory."
533
+ }
534
+ ],
535
+ now
536
+ );
537
+ }
538
+ function sharedSerial(now = Date.now()) {
539
+ return payloadOf(
540
+ [
541
+ {
542
+ deviceId: 9101,
543
+ serialNo: 7047,
544
+ bodyPart: "wrist",
545
+ name: "ActLumus 7047",
546
+ battery: 67,
547
+ charging: false,
548
+ syncedMinutesAgo: 30
549
+ },
550
+ {
551
+ deviceId: 9102,
552
+ serialNo: 7047,
553
+ modelId: "actlumus_plus",
554
+ bodyPart: "necklace",
555
+ name: "ActLumus Plus 7047",
556
+ battery: 93,
557
+ charging: false,
558
+ syncedMinutesAgo: 8
559
+ }
560
+ ],
561
+ now
562
+ );
563
+ }
564
+ function systemStatus(overrides = {}) {
565
+ return {
566
+ bluetooth: true,
567
+ internet: true,
568
+ permission: true,
569
+ hasDevices: true,
570
+ missingOptionalPermissions: [],
571
+ missingEssentialPermissions: [],
572
+ deviceSyncError: {},
573
+ deviceSyncErrorById: {},
574
+ ...overrides
575
+ };
576
+ }
577
+
578
+ // src/dev/scenarios.ts
579
+ var HARNESS_NATIVE_VERSION = "0.17.0";
580
+ var base = {
581
+ description: "Two devices, background service already running, realistic latency.",
582
+ nativeVersion: HARNESS_NATIVE_VERSION,
583
+ latencyMs: [80, 250],
584
+ serviceStartMs: 0,
585
+ userDeviceSilent: false,
586
+ logPeriodS: 60,
587
+ systemStatus: systemStatus(),
588
+ devices: () => twoDevices()
589
+ };
590
+ var SCENARIOS = {
591
+ default: base,
592
+ "cold-start": {
593
+ ...base,
594
+ 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.",
595
+ serviceStartMs: 4e3
596
+ },
597
+ "no-devices": {
598
+ ...base,
599
+ description: "Account with no linked device: userDevice never answers and systemStatus.hasDevices is false. Your bundle must show an empty state.",
600
+ userDeviceSilent: true,
601
+ systemStatus: systemStatus({ hasDevices: false })
602
+ },
603
+ "shared-serial": {
604
+ ...base,
605
+ description: "An ActLumus and an ActLumus Plus share serial number 7047. Only deviceId tells them apart; sensorLog cannot address one of them.",
606
+ devices: () => sharedSerial()
607
+ },
608
+ offline: {
609
+ ...base,
610
+ description: "No internet. systemStatus.internet is false and sensorLog fails; userDevice answers from the cache.",
611
+ sensorLogError: "Connection failed",
612
+ systemStatus: systemStatus({ internet: false })
613
+ }
614
+ };
615
+
616
+ // src/cli-dev.ts
617
+ var import_meta = {};
618
+ function readClientHtml(input) {
619
+ const path = (0, import_node_path2.resolve)(input);
620
+ if ((0, import_node_fs2.existsSync)(path) && (0, import_node_fs2.statSync)(path).isDirectory()) {
621
+ return inlineHtml((0, import_node_path2.join)(path, "index.html"));
622
+ }
623
+ if ((0, import_node_path2.extname)(path).toLowerCase() === ".zip") {
624
+ const files = (0, import_fflate2.unzipSync)(new Uint8Array((0, import_node_fs2.readFileSync)(path)));
625
+ const entry = files["index.html"];
626
+ if (!entry) throw new Error(`${input}: no index.html at the root of the zip.`);
627
+ return (0, import_fflate2.strFromU8)(entry);
628
+ }
629
+ return inlineHtml(path);
630
+ }
631
+ var escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
632
+ function harnessPage(scenario, nativeVersion) {
633
+ const options = Object.entries(SCENARIOS).map(
634
+ ([name, s]) => `<option value="${name}" title="${escapeHtml(s.description)}"${name === scenario ? " selected" : ""}>${name}</option>`
635
+ ).join("");
636
+ const description = escapeHtml(SCENARIOS[scenario]?.description ?? "");
637
+ const scenarioJson = JSON.stringify(scenario).replace(/</g, "\\u003c");
638
+ const nativeJson = JSON.stringify(
639
+ nativeVersion === void 0 ? void 0 : nativeVersion === "null" ? null : nativeVersion
640
+ )?.replace(/</g, "\\u003c") ?? "undefined";
641
+ const nativeLabel = escapeHtml(
642
+ nativeVersion === void 0 ? "" : `nativeVersion ${nativeVersion}`
643
+ );
644
+ return `<!doctype html>
645
+ <html lang="en">
646
+ <head>
647
+ <meta charset="utf-8">
648
+ <meta name="viewport" content="width=device-width, initial-scale=1">
649
+ <meta http-equiv="Content-Security-Policy" content="${CANVAS_CSP}">
650
+ <title>Condor Canvas \u2014 dev harness</title>
651
+ <style>
652
+ body { margin: 0; font: 13px system-ui, sans-serif; background: #e9ebef; color: #1b1f24; }
653
+ header { display: flex; gap: 12px; align-items: center; padding: 8px 16px; background: #1b1f24; color: #fff; flex-wrap: wrap; }
654
+ header p { margin: 0; opacity: .8; flex: 1 1 320px; }
655
+ main { display: flex; justify-content: center; padding: 16px; }
656
+ #device { width: 390px; height: 780px; max-width: 100%; border-radius: 28px; overflow: hidden; box-shadow: 0 10px 40px rgba(0,0,0,.25); background: #fff; }
657
+ </style>
658
+ </head>
659
+ <body>
660
+ <header>
661
+ <strong>Condor Canvas \xB7 dev harness</strong>
662
+ <label>Scenario <select id="scenario">${options}</select></label>
663
+ <button id="back" type="button" title="The OS back button: reaches the bundle only after it reported canGoBack true">\u25C0 OS back</button>
664
+ <span>${nativeLabel}</span>
665
+ <p>${description}</p>
666
+ </header>
667
+ <main><div id="device"></div></main>
668
+ <script src="/__condor/harness.js"></script>
669
+ <script>
670
+ var scenario = ${scenarioJson};
671
+ var nativeVersion = ${nativeJson};
672
+ var harness = null;
673
+ document.getElementById('scenario').addEventListener('change', function (e) {
674
+ var params = new URLSearchParams(location.search);
675
+ params.set('scenario', e.target.value);
676
+ location.search = params.toString();
677
+ });
678
+ document.getElementById('back').addEventListener('click', function () {
679
+ if (harness) harness.pressBack();
680
+ });
681
+ var chosen = CondorDevHarness.SCENARIOS[scenario];
682
+ if (nativeVersion !== undefined) chosen = Object.assign({}, chosen, { nativeVersion: nativeVersion });
683
+ fetch('/__condor/client.html')
684
+ .then(function (r) { if (!r.ok) throw new Error('could not read the bundle: HTTP ' + r.status); return r.text(); })
685
+ .then(function (html) {
686
+ return CondorDevHarness.createDevHarness({ html: html, scenario: chosen, mount: document.getElementById('device') });
687
+ })
688
+ .then(function (h) { harness = h; })
689
+ .catch(function (err) { document.getElementById('device').textContent = String(err); console.error(err); });
690
+ </script>
691
+ </body>
692
+ </html>`;
693
+ }
694
+ function harnessScriptPath() {
695
+ const here = typeof __dirname !== "undefined" ? __dirname : (0, import_node_path2.dirname)((0, import_node_url.fileURLToPath)(import_meta.url));
696
+ return (0, import_node_path2.join)(here, "dev-harness.global.js");
697
+ }
698
+ function startDevServer(opts) {
699
+ if (!(opts.scenario in SCENARIOS)) {
700
+ throw new Error(
701
+ `Unknown scenario "${opts.scenario}". Known: ${Object.keys(SCENARIOS).join(", ")}.`
702
+ );
703
+ }
704
+ readClientHtml(opts.input);
705
+ const server = (0, import_node_http.createServer)((req, res) => {
706
+ const url = new URL(req.url ?? "/", "http://localhost");
707
+ try {
708
+ if (url.pathname === "/__condor/client.html") {
709
+ res.writeHead(200, {
710
+ "content-type": "text/html; charset=utf-8",
711
+ "cache-control": "no-store"
712
+ });
713
+ res.end(readClientHtml(opts.input));
714
+ return;
715
+ }
716
+ if (url.pathname === "/__condor/harness.js") {
717
+ res.writeHead(200, {
718
+ "content-type": "text/javascript; charset=utf-8",
719
+ "cache-control": "no-store"
720
+ });
721
+ res.end((0, import_node_fs2.readFileSync)(harnessScriptPath()));
722
+ return;
723
+ }
724
+ if (url.pathname === "/") {
725
+ const scenario = url.searchParams.get("scenario") ?? opts.scenario;
726
+ res.writeHead(200, {
727
+ "content-type": "text/html; charset=utf-8",
728
+ "content-security-policy": CANVAS_CSP,
729
+ "cache-control": "no-store"
730
+ });
731
+ const nativeVersion = url.searchParams.get("nativeVersion") ?? opts.nativeVersion;
732
+ res.end(harnessPage(scenario in SCENARIOS ? scenario : opts.scenario, nativeVersion));
733
+ return;
734
+ }
735
+ res.writeHead(404).end();
736
+ } catch (err) {
737
+ res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
738
+ res.end(String(err instanceof Error ? err.message : err));
739
+ }
740
+ });
741
+ server.listen(opts.port, "127.0.0.1");
742
+ return server;
743
+ }
744
+
341
745
  // src/cli.ts
746
+ function runDev(input, scenario, port, nativeVersion) {
747
+ const server = startDevServer({ input, scenario, port, nativeVersion });
748
+ server.on("listening", () => {
749
+ const native = nativeVersion === void 0 ? "" : `&nativeVersion=${nativeVersion}`;
750
+ console.log(
751
+ `Condor Canvas dev harness: http://localhost:${port}/?scenario=${scenario}${native}`
752
+ );
753
+ console.log(` bundle: ${input} (re-read on every reload)`);
754
+ console.log(" sandbox, shell policy and CSP: same as the app");
755
+ console.log(" scenarios: default, cold-start, no-devices, shared-serial, offline");
756
+ console.log(' OS back button: the "\u25C0 OS back" button at the top');
757
+ console.log("\nA bundle is ready for a device only when it handles every scenario.");
758
+ });
759
+ server.on("error", (err) => {
760
+ console.error(`dev: ${err.message}`);
761
+ process.exit(1);
762
+ });
763
+ }
342
764
  function runBuild(htmlPath, outPath, maxBytes) {
343
765
  const result = buildBundle(htmlPath, maxBytes);
344
- const resolvedOut = (0, import_node_path2.resolve)(outPath);
345
- (0, import_node_fs2.mkdirSync)((0, import_node_path2.dirname)(resolvedOut), { recursive: true });
346
- (0, import_node_fs2.writeFileSync)(resolvedOut, result.zipBytes);
766
+ const resolvedOut = (0, import_node_path3.resolve)(outPath);
767
+ (0, import_node_fs3.mkdirSync)((0, import_node_path3.dirname)(resolvedOut), { recursive: true });
768
+ (0, import_node_fs3.writeFileSync)(resolvedOut, result.zipBytes);
347
769
  const kb = (Buffer.byteLength(result.html) / 1024).toFixed(1);
348
770
  console.log(`Built ${outPath}`);
349
771
  console.log(` entry: index.html (${kb} KB self-contained)`);
@@ -397,6 +819,15 @@ function main() {
397
819
  console.log(USAGE);
398
820
  process.exit(args.help ? 0 : 1);
399
821
  }
822
+ if (args.command === "dev") {
823
+ try {
824
+ runDev(args.htmlPath, args.scenario, args.port, args.nativeVersion);
825
+ } catch (err) {
826
+ console.error(`dev: ${err instanceof Error ? err.message : String(err)}`);
827
+ process.exit(1);
828
+ }
829
+ return;
830
+ }
400
831
  if (args.command === "validate") {
401
832
  const result = validateBundle(args.htmlPath, args.maxBytes);
402
833
  process.exit(reportValidation(args.htmlPath, result, args.strict));