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