@condorinst/mobile-bridge 0.21.0 → 0.23.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,16 @@
2
2
  "use strict";
3
3
 
4
4
  // src/cli.ts
5
- var import_node_fs3 = require("fs");
6
- var import_node_path3 = require("path");
5
+ var import_node_fs4 = require("fs");
6
+ var import_node_path4 = require("path");
7
+
8
+ // package.json
9
+ var version = "0.23.0";
10
+
11
+ // src/canvas-admin/command.ts
12
+ var import_node_crypto2 = require("crypto");
13
+ var import_node_fs2 = require("fs");
14
+ var import_node_path2 = require("path");
7
15
 
8
16
  // src/cli-core.ts
9
17
  var import_node_crypto = require("crypto");
@@ -157,6 +165,11 @@ var BLOCKED_API_RULES = [
157
165
  pattern: /window\s*\.\s*open\s*\(/,
158
166
  message: "The sandbox has no allow-popups; window.open() returns null."
159
167
  },
168
+ {
169
+ rule: "crypto-randomuuid",
170
+ pattern: /crypto\s*\.\s*randomUUID\b/,
171
+ message: "crypto.randomUUID exists only in a secure context, and the app's sandboxed iframe is probably not one: it can be undefined on the phone while it works in the harness. Use createRequestId() from the SDK, or crypto.getRandomValues."
172
+ },
160
173
  {
161
174
  rule: "top-navigation",
162
175
  pattern: /window\s*\.\s*top\b/,
@@ -296,6 +309,8 @@ function parseArgs(argv) {
296
309
  scenario: "default",
297
310
  port: DEFAULT_DEV_PORT,
298
311
  nativeVersion: void 0,
312
+ latencyMs: void 0,
313
+ panel: true,
299
314
  help: false
300
315
  };
301
316
  for (let i = 0; i < argv.length; i++) {
@@ -338,6 +353,14 @@ function parseArgs(argv) {
338
353
  case "--port":
339
354
  args.port = Number.parseInt(argv[++i] ?? "", 10) || DEFAULT_DEV_PORT;
340
355
  break;
356
+ case "--latency": {
357
+ const ms = Number.parseInt(argv[++i] ?? "", 10);
358
+ args.latencyMs = Number.isFinite(ms) && ms >= 0 ? ms : void 0;
359
+ break;
360
+ }
361
+ case "--no-panel":
362
+ args.panel = false;
363
+ break;
341
364
  default:
342
365
  if (token && !token.startsWith("-") && !args.htmlPath) args.htmlPath = token;
343
366
  }
@@ -349,12 +372,16 @@ var USAGE = `condor-mobile-bridge \u2014 tooling for Condor Canvas client bundle
349
372
  Usage:
350
373
  npx condor-mobile-bridge build -i <html> [-o <zip>]
351
374
  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>]
375
+ npx condor-mobile-bridge canvas publish|list|revoke ... (see: canvas --help)
376
+ npx condor-mobile-bridge dev -i <html|zip|dir> [--scenario <name|file.json>] [--native-version <v|null>]
377
+ [--port <n>] [--latency <ms>] [--no-panel]
353
378
 
354
379
  Commands:
355
380
  build Inline a dist/index.html into a self-contained .zip.
356
381
  validate Check a bundle against every rule the Canvas app enforces, plus
357
382
  the APIs the sandbox and the CSP block at runtime.
383
+ canvas Publish, list and revoke the bundle of your company in the
384
+ Condor Canvas app (interactive login). See: canvas --help.
358
385
  dev Serve the bundle in a browser harness that behaves like the app:
359
386
  same sandbox, shell and policy, and a simulated native side.
360
387
  Re-reads the input on every reload. Scenarios: default,
@@ -365,18 +392,488 @@ Options:
365
392
  -o, --out <path> build only: output .zip (default: ${DEFAULT_OUT}).
366
393
  --max-bytes <n> Size limit in bytes (default: ${DEFAULT_MAX_BYTES} = 2 MB).
367
394
  --strict validate only: exit 1 on warnings too, not just errors.
368
- --scenario <n> dev only: device behaviour to simulate (default: default).
395
+ --scenario <n> dev only: device behaviour to simulate (default: default),
396
+ or a .json file that extends one (see docs/device-testing.md).
369
397
  --port <n> dev only: HTTP port (default: ${DEFAULT_DEV_PORT}).
370
398
  --native-version <v|null>
371
399
  dev only: nativeVersion the harness reports (default
372
- 0.17.0). 'null' reproduces a shell that could not
400
+ 0.18.0). 'null' reproduces a shell that could not
373
401
  reach the native side.
402
+ --latency <ms> dev only: fixed reply latency (e.g. 0 for fast tests).
403
+ --no-panel dev only: hide the floating MOCK panel.
374
404
  -h, --help Show this help.`;
375
405
 
406
+ // src/canvas-admin/client.ts
407
+ var CANVAS_ADMIN_API = {
408
+ prod: "https://api.sleepdiary.condorapps.net",
409
+ dev: "https://dev.sleepdiary.condorapps.net"
410
+ };
411
+ var CanvasAdminError = class extends Error {
412
+ constructor(message, status, reason) {
413
+ super(message);
414
+ this.status = status;
415
+ this.reason = reason;
416
+ this.name = "CanvasAdminError";
417
+ }
418
+ };
419
+ function tokenFrom(headers) {
420
+ const values = typeof headers.getSetCookie === "function" ? headers.getSetCookie() : [headers.get("set-cookie") ?? ""];
421
+ for (const value of values) {
422
+ const match = /(?:^|[,;\s])token=([^;,\s]*)/.exec(value);
423
+ if (match?.[1]) return match[1];
424
+ }
425
+ return null;
426
+ }
427
+ async function reasonFrom(response) {
428
+ try {
429
+ const body = await response.json();
430
+ for (const key of ["error", "message", "description", "code"]) {
431
+ const value = body[key];
432
+ if (typeof value === "string" && value) return value;
433
+ }
434
+ } catch {
435
+ }
436
+ return null;
437
+ }
438
+ var CanvasAdminClient = class {
439
+ constructor(opts) {
440
+ this.token = null;
441
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
442
+ this.userAgent = opts.userAgent;
443
+ this.fetchImpl = opts.fetchImpl ?? fetch;
444
+ this.timeoutMs = opts.timeoutMs ?? 6e4;
445
+ }
446
+ async request(path, init = {}) {
447
+ const headers = new Headers(init.headers);
448
+ headers.set("user-agent", this.userAgent);
449
+ headers.set("accept", "application/json");
450
+ if (this.token) headers.set("cookie", `token=${this.token}`);
451
+ try {
452
+ return await this.fetchImpl(`${this.baseUrl}${path}`, {
453
+ ...init,
454
+ headers,
455
+ redirect: "manual",
456
+ signal: AbortSignal.timeout(this.timeoutMs)
457
+ });
458
+ } catch (err) {
459
+ throw new CanvasAdminError(
460
+ `Could not reach ${this.baseUrl}: ${err instanceof Error ? err.message : String(err)}`,
461
+ 0,
462
+ null
463
+ );
464
+ }
465
+ }
466
+ async fail(response, what) {
467
+ const reason = await reasonFrom(response);
468
+ throw new CanvasAdminError(
469
+ `${what} failed (HTTP ${response.status})${reason ? `: ${reason}` : ""}`,
470
+ response.status,
471
+ reason
472
+ );
473
+ }
474
+ /**
475
+ * Logs in. Resolves `{ needsCode: true }` when the company requires a
476
+ * two-factor code (sent by e-mail): call {@link verifyCode} next.
477
+ */
478
+ async login(creds) {
479
+ const response = await this.request("/auth/login", {
480
+ method: "POST",
481
+ headers: { "content-type": "application/json" },
482
+ body: JSON.stringify({
483
+ company_id: creds.companyId,
484
+ user_id: creds.userId,
485
+ password: creds.password
486
+ })
487
+ });
488
+ if (!response.ok) await this.fail(response, "Login");
489
+ const token = tokenFrom(response.headers);
490
+ if (!token) throw new CanvasAdminError("Login answered without a session cookie", 200, null);
491
+ this.token = token;
492
+ const body = await response.json().catch(() => ({}));
493
+ return { needsCode: body.completed_auth_flow === false };
494
+ }
495
+ /** Sends the two-factor (or recovery) code; the server swaps the session cookie. */
496
+ async verifyCode(code) {
497
+ const response = await this.request("/auth/verify_2fa", {
498
+ method: "POST",
499
+ headers: { "content-type": "application/json" },
500
+ body: JSON.stringify({ otp_code: code })
501
+ });
502
+ if (!response.ok) await this.fail(response, "Two-factor verification");
503
+ const token = tokenFrom(response.headers);
504
+ if (token) this.token = token;
505
+ }
506
+ /**
507
+ * Publishes a bundle. **It goes live at once** for every user of the
508
+ * company: there is no draft. A version can be published only once per
509
+ * company (HTTP 409).
510
+ */
511
+ async publish(input) {
512
+ const form = new FormData();
513
+ form.set(
514
+ "bundle",
515
+ new Blob([input.zipBytes], { type: "application/zip" }),
516
+ "bundle.zip"
517
+ );
518
+ form.set("version", input.version);
519
+ if (input.companyId !== void 0) form.set("company_id", String(input.companyId));
520
+ if (input.minAppVersion) form.set("min_app_version", input.minAppVersion);
521
+ const response = await this.request("/v2/condor_canvas/bundles", {
522
+ method: "POST",
523
+ body: form
524
+ });
525
+ if (!response.ok) await this.fail(response, "Publish");
526
+ const body = await response.json();
527
+ return body.data;
528
+ }
529
+ /** Lists the bundles of a company (a manager always sees their own). */
530
+ async list(companyId) {
531
+ const query = companyId === void 0 ? "" : `?company_id=${companyId}`;
532
+ const response = await this.request(`/v2/condor_canvas/bundles${query}`);
533
+ if (!response.ok) await this.fail(response, "List");
534
+ const body = await response.json();
535
+ return body.data;
536
+ }
537
+ /**
538
+ * Revokes a bundle. Without `fallbackVersion`, revoking the current bundle
539
+ * leaves the company with none: the app shows its default screen.
540
+ */
541
+ async revoke(bundleId, fallbackVersion) {
542
+ const response = await this.request(`/v2/condor_canvas/bundles/${bundleId}/revoke`, {
543
+ method: "POST",
544
+ headers: { "content-type": "application/json" },
545
+ body: JSON.stringify(fallbackVersion ? { fallbackVersion } : {})
546
+ });
547
+ if (!response.ok) await this.fail(response, "Revoke");
548
+ const body = await response.json();
549
+ return body.data;
550
+ }
551
+ };
552
+
553
+ // src/canvas-admin/command.ts
554
+ var CANVAS_USAGE = `condor-mobile-bridge canvas \u2014 publish a bundle to the Condor Canvas app.
555
+
556
+ Usage:
557
+ npx condor-mobile-bridge canvas publish <dist/index.html|dir|bundle.zip> --version <v> [options]
558
+ npx condor-mobile-bridge canvas list [options]
559
+ npx condor-mobile-bridge canvas revoke <bundleId> [--fallback-version <v>] [options]
560
+
561
+ Publishing goes LIVE AT ONCE for every user of the company \u2014 there is no draft.
562
+ Use a test company, and see docs/device-testing.md.
563
+
564
+ Options:
565
+ --company <id> Company id, as in the Condor web login (or CONDOR_CANVAS_COMPANY).
566
+ --user <id> Your user id, as in the Condor web login (or CONDOR_CANVAS_USER).
567
+ Both are asked when not given.
568
+ --version <v> publish: bundle version, unique per company.
569
+ --min-app-version <v> publish: minimum app build that may load it.
570
+ --fallback-version <v> revoke: VERSION to serve instead (the version column of
571
+ canvas list, e.g. 0.0.8 \u2014 not the id).
572
+ --env prod|dev API environment (default: prod). dev is for Condor staff.
573
+ --base-url <url> Override the API URL.
574
+ --yes Do not ask for confirmation.
575
+ --json Print results as JSON.
576
+
577
+ Your password (and the two-factor code, when your company uses one) is asked
578
+ interactively, without echo, and never stored. There is no non-interactive login.`;
579
+ var intOrUndefined = (value) => {
580
+ if (value === void 0) return void 0;
581
+ const n = Number(value);
582
+ return Number.isInteger(n) && n > 0 ? n : Number.NaN;
583
+ };
584
+ function parseCanvasArgs(argv, env = {}) {
585
+ const [action, ...rest] = argv;
586
+ if (action !== "publish" && action !== "list" && action !== "revoke") {
587
+ return { error: `Unknown canvas command "${action ?? ""}". Use publish, list or revoke.` };
588
+ }
589
+ const args = {
590
+ action,
591
+ env: "prod",
592
+ yes: false,
593
+ json: false,
594
+ companyId: intOrUndefined(env.CONDOR_CANVAS_COMPANY),
595
+ userId: intOrUndefined(env.CONDOR_CANVAS_USER)
596
+ };
597
+ const positional = [];
598
+ for (let i = 0; i < rest.length; i++) {
599
+ const token = rest[i] ?? "";
600
+ const next = () => rest[++i];
601
+ switch (token) {
602
+ case "--env": {
603
+ const env2 = next();
604
+ if (env2 !== "prod" && env2 !== "dev") return { error: "--env must be prod or dev." };
605
+ args.env = env2;
606
+ break;
607
+ }
608
+ case "--base-url":
609
+ args.baseUrl = next();
610
+ break;
611
+ case "--company":
612
+ args.companyId = intOrUndefined(next());
613
+ break;
614
+ case "--user":
615
+ args.userId = intOrUndefined(next());
616
+ break;
617
+ case "--version":
618
+ args.version = next();
619
+ break;
620
+ case "--min-app-version":
621
+ args.minAppVersion = next();
622
+ break;
623
+ case "--fallback-version":
624
+ args.fallbackVersion = next();
625
+ break;
626
+ case "--yes":
627
+ args.yes = true;
628
+ break;
629
+ case "--json":
630
+ args.json = true;
631
+ break;
632
+ case "--password":
633
+ return {
634
+ error: "There is no --password flag: the password is only read from the prompt."
635
+ };
636
+ default:
637
+ if (token.startsWith("-")) return { error: `Unknown option ${token}.` };
638
+ positional.push(token);
639
+ }
640
+ }
641
+ if (Number.isNaN(args.companyId) || Number.isNaN(args.userId)) {
642
+ return {
643
+ error: "--company and --user (or CONDOR_CANVAS_COMPANY / CONDOR_CANVAS_USER) take a positive integer id."
644
+ };
645
+ }
646
+ if (action === "publish") {
647
+ args.input = positional[0];
648
+ if (!args.input)
649
+ return { error: "canvas publish needs the bundle: dist/index.html, a dir or a .zip." };
650
+ if (!args.version) return { error: "canvas publish needs --version." };
651
+ }
652
+ if (action === "revoke") {
653
+ args.bundleId = intOrUndefined(positional[0]);
654
+ if (!args.bundleId || Number.isNaN(args.bundleId)) {
655
+ return { error: "canvas revoke needs the bundle id (see canvas list)." };
656
+ }
657
+ }
658
+ return args;
659
+ }
660
+ function loadZip(input) {
661
+ const path = (0, import_node_path2.resolve)(input);
662
+ if (!(0, import_node_fs2.existsSync)(path)) throw new Error(`${input}: not found.`);
663
+ let zipBytes;
664
+ if ((0, import_node_path2.extname)(path).toLowerCase() === ".zip") {
665
+ zipBytes = new Uint8Array((0, import_node_fs2.readFileSync)(path));
666
+ } else {
667
+ const html = (0, import_node_fs2.statSync)(path).isDirectory() ? (0, import_node_path2.join)(path, "index.html") : path;
668
+ zipBytes = buildBundle(html).zipBytes;
669
+ }
670
+ return { zipBytes, findings: validateZipBytes(zipBytes).findings };
671
+ }
672
+ async function askId(io, label, value) {
673
+ if (value !== void 0) return value;
674
+ const answer = (await io.ask(`${label}: `)).trim();
675
+ const n = Number(answer);
676
+ if (!Number.isInteger(n) || n <= 0) throw new Error(`${label} must be a positive integer.`);
677
+ return n;
678
+ }
679
+ async function confirm(io, args, question2) {
680
+ if (args.yes) return true;
681
+ const answer = (await io.ask(`${question2} [y/N] `)).trim().toLowerCase();
682
+ return answer === "y" || answer === "yes";
683
+ }
684
+ async function logIn(io, client, companyId, userId) {
685
+ io.print("");
686
+ io.print("Log in with your Condor web account (typing is hidden; press Enter when done).");
687
+ let password = "";
688
+ for (let attempt = 0; attempt < 3 && !password.trim(); attempt++) {
689
+ if (attempt > 0) io.printError("The password cannot be empty.");
690
+ password = await io.askSecret("Password: ");
691
+ }
692
+ if (!password.trim()) throw new Error("No password given. Nothing was sent.");
693
+ const { needsCode } = await client.login({ companyId, userId, password });
694
+ if (!needsCode) return;
695
+ io.print(
696
+ "Your company uses two-factor login: a code was sent to your e-mail. Type it below (hidden)."
697
+ );
698
+ for (let attempt = 1; attempt <= 2; attempt++) {
699
+ const code = (await io.askSecret("Code (or a recovery code): ")).trim();
700
+ try {
701
+ await client.verifyCode(code);
702
+ return;
703
+ } catch (err) {
704
+ if (attempt === 2 || !(err instanceof CanvasAdminError) || err.status !== 400) throw err;
705
+ io.printError("That code was not accepted. Try once more.");
706
+ }
707
+ }
708
+ }
709
+ function describeError(err) {
710
+ if (!(err instanceof CanvasAdminError)) return err instanceof Error ? err.message : String(err);
711
+ if (err.status === 400 && /fallback/i.test(err.reason ?? "")) {
712
+ return `${err.message}
713
+ --fallback-version takes the VERSION column of \`canvas list\` (e.g. 0.0.8), not the id.`;
714
+ }
715
+ switch (err.status) {
716
+ case 401:
717
+ return `${err.message}
718
+ Check the company id, user id and password.`;
719
+ case 403:
720
+ return `${err.message}
721
+ Your user cannot do this for that company: publishing needs a manager of the company.`;
722
+ case 409:
723
+ return `${err.message}
724
+ This version was already published for the company. Use a new --version.`;
725
+ case 413:
726
+ return `${err.message}
727
+ The .zip is over 2 MB.`;
728
+ default:
729
+ return err.message;
730
+ }
731
+ }
732
+ function table(rows) {
733
+ if (rows.length === 0) return ["No bundle published for this company."];
734
+ return [
735
+ "id version current revoked published_at min_app",
736
+ ...rows.map(
737
+ (r) => `${String(r.id).padEnd(8)}${r.version.padEnd(15)}${(r.is_current ? "yes" : "").padEnd(9)}${(r.revoked ? "yes" : "").padEnd(9)}${(r.published_at ?? "").padEnd(28)}${r.min_app_version ?? ""}`
738
+ )
739
+ ];
740
+ }
741
+ async function runCanvas(args, io, deps) {
742
+ const baseUrl = args.baseUrl ?? CANVAS_ADMIN_API[args.env];
743
+ const envLabel = args.baseUrl ? `custom (${baseUrl})` : `${args.env.toUpperCase()} (${baseUrl})`;
744
+ try {
745
+ let zip;
746
+ if (args.action === "publish") {
747
+ zip = loadZip(args.input ?? "");
748
+ const errors = zip.findings.filter((f) => f.severity === "error");
749
+ for (const f of zip.findings) {
750
+ (f.severity === "error" ? io.printError : io.print)(
751
+ `${f.severity.toUpperCase()} [${f.rule}] ${f.message}`
752
+ );
753
+ }
754
+ if (errors.length > 0) {
755
+ io.printError("The app would reject this bundle. Nothing was sent.");
756
+ return 1;
757
+ }
758
+ }
759
+ if (!io.interactive) {
760
+ io.printError(
761
+ "canvas needs an interactive terminal: the password is only read from a prompt."
762
+ );
763
+ return 1;
764
+ }
765
+ const companyId = await askId(io, "Company id", args.companyId);
766
+ const userId = await askId(io, "User id", args.userId);
767
+ if (args.action === "publish" && zip) {
768
+ const hash = `sha256-${(0, import_node_crypto2.createHash)("sha256").update(zip.zipBytes).digest("hex")}`;
769
+ io.print("");
770
+ io.print(`Environment: ${envLabel}`);
771
+ io.print(`Company: ${companyId}`);
772
+ io.print(
773
+ `Version: ${args.version}${args.minAppVersion ? ` (min app ${args.minAppVersion})` : ""}`
774
+ );
775
+ io.print(`Bundle: ${zip.zipBytes.length} bytes, ${hash}`);
776
+ io.print("It goes LIVE AT ONCE for every user of this company.");
777
+ if (!await confirm(io, args, "Publish?")) {
778
+ io.print("Cancelled. Nothing was sent.");
779
+ return 1;
780
+ }
781
+ }
782
+ if (args.action === "revoke") {
783
+ io.print("");
784
+ io.print(`Environment: ${envLabel}`);
785
+ io.print(`Revoke bundle ${args.bundleId} of company ${companyId}.`);
786
+ io.print(
787
+ args.fallbackVersion ? `The app switches to version ${args.fallbackVersion} (a version, not an id).` : "Without --fallback-version, revoking the current bundle leaves the company with none: the app shows its default screen."
788
+ );
789
+ if (!await confirm(io, args, "Revoke?")) {
790
+ io.print("Cancelled. Nothing was sent.");
791
+ return 1;
792
+ }
793
+ }
794
+ const client = new CanvasAdminClient({
795
+ baseUrl,
796
+ userAgent: deps.userAgent,
797
+ fetchImpl: deps.fetchImpl
798
+ });
799
+ await logIn(io, client, companyId, userId);
800
+ if (args.action === "publish" && zip) {
801
+ const record = await client.publish({
802
+ zipBytes: zip.zipBytes,
803
+ version: args.version ?? "",
804
+ companyId,
805
+ minAppVersion: args.minAppVersion
806
+ });
807
+ if (args.json) io.print(JSON.stringify(record));
808
+ else {
809
+ io.print(
810
+ `Published version ${record.version} (id ${record.id}) \u2014 current: ${record.is_current}.`
811
+ );
812
+ io.print(`bundleHash: ${record.bundle_hash}`);
813
+ io.print("Apps of this company load it the next time they come to the foreground.");
814
+ }
815
+ return 0;
816
+ }
817
+ if (args.action === "list") {
818
+ const rows = await client.list(companyId);
819
+ if (args.json) io.print(JSON.stringify(rows));
820
+ else for (const line of table(rows)) io.print(line);
821
+ return 0;
822
+ }
823
+ const result = await client.revoke(args.bundleId ?? 0, args.fallbackVersion);
824
+ io.print(args.json ? JSON.stringify(result) : `Revoked. ${JSON.stringify(result)}`);
825
+ return 0;
826
+ } catch (err) {
827
+ io.printError(describeError(err));
828
+ return 1;
829
+ }
830
+ }
831
+
832
+ // src/canvas-admin/tty.ts
833
+ var import_node_readline = require("readline");
834
+ var rl = null;
835
+ var mutedPrompt = null;
836
+ function reader() {
837
+ if (rl) return rl;
838
+ rl = (0, import_node_readline.createInterface)({ input: process.stdin, output: process.stderr, terminal: true });
839
+ const out = rl;
840
+ const write = out._writeToOutput.bind(rl);
841
+ out._writeToOutput = (s) => {
842
+ if (mutedPrompt === null || s.includes(mutedPrompt)) write(s);
843
+ };
844
+ return rl;
845
+ }
846
+ function question(prompt, secret) {
847
+ const r = reader();
848
+ return new Promise((resolve5) => {
849
+ mutedPrompt = secret ? prompt : null;
850
+ r.question(prompt, (answer) => {
851
+ if (secret) {
852
+ mutedPrompt = null;
853
+ process.stderr.write("\n");
854
+ }
855
+ resolve5(answer);
856
+ });
857
+ });
858
+ }
859
+ function terminalIo() {
860
+ return {
861
+ interactive: Boolean(process.stdin.isTTY),
862
+ ask: (q) => question(q, false),
863
+ askSecret: (q) => question(q, true),
864
+ print: (line) => console.log(line),
865
+ printError: (line) => console.error(line)
866
+ };
867
+ }
868
+ function closeTerminalIo() {
869
+ rl?.close();
870
+ rl = null;
871
+ }
872
+
376
873
  // src/cli-dev.ts
377
- var import_node_fs2 = require("fs");
874
+ var import_node_fs3 = require("fs");
378
875
  var import_node_http = require("http");
379
- var import_node_path2 = require("path");
876
+ var import_node_path3 = require("path");
380
877
  var import_node_url = require("url");
381
878
  var import_fflate2 = require("fflate");
382
879
 
@@ -483,7 +980,7 @@ function telemetry(spec, now) {
483
980
  serialNo: spec.serialNo,
484
981
  attemptDate: now - 10 * 6e4,
485
982
  message: spec.syncError,
486
- messageCode: "SYNC_FAILED"
983
+ messageCode: "send_data_log_error"
487
984
  }
488
985
  ] : []
489
986
  };
@@ -567,6 +1064,7 @@ function systemStatus(overrides = {}) {
567
1064
  internet: true,
568
1065
  permission: true,
569
1066
  hasDevices: true,
1067
+ hasDevicesKnown: true,
570
1068
  missingOptionalPermissions: [],
571
1069
  missingEssentialPermissions: [],
572
1070
  deviceSyncError: {},
@@ -576,28 +1074,27 @@ function systemStatus(overrides = {}) {
576
1074
  }
577
1075
 
578
1076
  // src/dev/scenarios.ts
579
- var HARNESS_NATIVE_VERSION = "0.17.0";
1077
+ var HARNESS_NATIVE_VERSION = "0.18.0";
580
1078
  var base = {
581
- description: "Two devices, background service already running, realistic latency.",
1079
+ description: "Two devices, background service already running, realistic latency. The first device starts a BLE session 3 s after load; the second stays between sessions.",
582
1080
  nativeVersion: HARNESS_NATIVE_VERSION,
583
1081
  latencyMs: [80, 250],
584
1082
  serviceStartMs: 0,
585
- userDeviceSilent: false,
586
1083
  logPeriodS: 60,
587
1084
  systemStatus: systemStatus(),
588
1085
  devices: () => twoDevices()
589
1086
  };
590
1087
  var SCENARIOS = {
591
- default: base,
1088
+ default: { ...base, firstSessionAfterMs: 3e3 },
592
1089
  "cold-start": {
593
1090
  ...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.",
1091
+ description: "App launched from cold: the background service takes 4 s to start, and every ping/userDevice sent before that is lost. From nativeVersion 0.18.0 the app re-sends the lost userDevice itself; below it, a bundle that asks once stays on its loading screen forever.",
595
1092
  serviceStartMs: 4e3
596
1093
  },
597
1094
  "no-devices": {
598
1095
  ...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,
1096
+ description: "Account with no linked device: userDevice answers with error NO_DEVICES (below nativeVersion 0.18.0: never answers) and systemStatus.hasDevices is false. Your bundle must show an empty state.",
1097
+ userDeviceError: "NO_DEVICES",
601
1098
  systemStatus: systemStatus({ hasDevices: false })
602
1099
  },
603
1100
  "shared-serial": {
@@ -607,21 +1104,20 @@ var SCENARIOS = {
607
1104
  },
608
1105
  offline: {
609
1106
  ...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 })
1107
+ description: "No internet. systemStatus.internet is false and sensorLog fails; userDevice answers from the cache. The internet comes back when a test calls window.condorDev.setStatus({ internet: true }).",
1108
+ systemStatus: systemStatus({ internet: false, hasDevicesKnown: false })
613
1109
  }
614
1110
  };
615
1111
 
616
1112
  // src/cli-dev.ts
617
1113
  var import_meta = {};
618
1114
  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"));
1115
+ const path = (0, import_node_path3.resolve)(input);
1116
+ if ((0, import_node_fs3.existsSync)(path) && (0, import_node_fs3.statSync)(path).isDirectory()) {
1117
+ return inlineHtml((0, import_node_path3.join)(path, "index.html"));
622
1118
  }
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)));
1119
+ if ((0, import_node_path3.extname)(path).toLowerCase() === ".zip") {
1120
+ const files = (0, import_fflate2.unzipSync)(new Uint8Array((0, import_node_fs3.readFileSync)(path)));
625
1121
  const entry = files["index.html"];
626
1122
  if (!entry) throw new Error(`${input}: no index.html at the root of the zip.`);
627
1123
  return (0, import_fflate2.strFromU8)(entry);
@@ -629,11 +1125,18 @@ function readClientHtml(input) {
629
1125
  return inlineHtml(path);
630
1126
  }
631
1127
  var escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
632
- function harnessPage(scenario, nativeVersion) {
1128
+ function harnessPage(scenario, nativeVersion, extra = {}) {
633
1129
  const options = Object.entries(SCENARIOS).map(
634
1130
  ([name, s]) => `<option value="${name}" title="${escapeHtml(s.description)}"${name === scenario ? " selected" : ""}>${name}</option>`
635
1131
  ).join("");
636
- const description = escapeHtml(SCENARIOS[scenario]?.description ?? "");
1132
+ const description = escapeHtml(
1133
+ extra.scenarioFile ? `Scenario file ${extra.scenarioFile}` : SCENARIOS[scenario]?.description ?? ""
1134
+ );
1135
+ const defaultsJson = JSON.stringify({
1136
+ latencyMs: extra.latencyMs ?? null,
1137
+ panel: extra.panel !== false,
1138
+ scenarioFile: Boolean(extra.scenarioFile)
1139
+ }).replace(/</g, "\\u003c");
637
1140
  const scenarioJson = JSON.stringify(scenario).replace(/</g, "\\u003c");
638
1141
  const nativeJson = JSON.stringify(
639
1142
  nativeVersion === void 0 ? void 0 : nativeVersion === "null" ? null : nativeVersion
@@ -669,6 +1172,11 @@ function harnessPage(scenario, nativeVersion) {
669
1172
  <script>
670
1173
  var scenario = ${scenarioJson};
671
1174
  var nativeVersion = ${nativeJson};
1175
+ var defaults = ${defaultsJson};
1176
+ var params = new URLSearchParams(location.search);
1177
+ var latency = params.has('latency') ? Number(params.get('latency')) : defaults.latencyMs;
1178
+ var panel = params.has('panel') ? params.get('panel') !== '0' : defaults.panel;
1179
+ var useFile = defaults.scenarioFile && !params.has('scenario');
672
1180
  var harness = null;
673
1181
  document.getElementById('scenario').addEventListener('change', function (e) {
674
1182
  var params = new URLSearchParams(location.search);
@@ -678,25 +1186,69 @@ function harnessPage(scenario, nativeVersion) {
678
1186
  document.getElementById('back').addEventListener('click', function () {
679
1187
  if (harness) harness.pressBack();
680
1188
  });
681
- var chosen = CondorDevHarness.SCENARIOS[scenario];
682
- if (nativeVersion !== undefined) chosen = Object.assign({}, chosen, { nativeVersion: nativeVersion });
683
- fetch('/__condor/client.html')
1189
+ // A scenario file extends a named scenario: its fields replace the base's,
1190
+ // systemStatus is merged, and devices is the userDevice payload to answer.
1191
+ function fromFile(file) {
1192
+ var base = CondorDevHarness.SCENARIOS[file.extends || 'default'];
1193
+ if (!base) throw new Error('scenario file: unknown "extends": ' + file.extends);
1194
+ var out = Object.assign({}, base, file);
1195
+ out.systemStatus = Object.assign({}, base.systemStatus, file.systemStatus || {});
1196
+ if (file.devices) {
1197
+ var devices = JSON.stringify(file.devices);
1198
+ out.devices = function () { return JSON.parse(devices); };
1199
+ } else out.devices = base.devices;
1200
+ return out;
1201
+ }
1202
+ var chosen = null;
1203
+ (useFile ? fetch('/__condor/scenario.json').then(function (r) { return r.json(); }).then(fromFile)
1204
+ : Promise.resolve(CondorDevHarness.SCENARIOS[scenario]))
1205
+ .then(function (s) {
1206
+ chosen = s;
1207
+ if (nativeVersion !== undefined) chosen = Object.assign({}, chosen, { nativeVersion: nativeVersion });
1208
+ if (latency !== null && latency >= 0) chosen = Object.assign({}, chosen, { latencyMs: latency });
1209
+ return fetch('/__condor/client.html');
1210
+ })
684
1211
  .then(function (r) { if (!r.ok) throw new Error('could not read the bundle: HTTP ' + r.status); return r.text(); })
685
1212
  .then(function (html) {
686
- return CondorDevHarness.createDevHarness({ html: html, scenario: chosen, mount: document.getElementById('device') });
1213
+ return CondorDevHarness.createDevHarness({ html: html, scenario: chosen, mount: document.getElementById('device'), devPanel: panel });
1214
+ })
1215
+ .then(function (h) {
1216
+ harness = h;
1217
+ var confirm = new URLSearchParams(location.search).get('confirm');
1218
+ if (confirm === 'accept' || confirm === 'decline') h.setConfirmations(confirm);
1219
+ // Scripted tests (Puppeteer, Playwright) drive the simulated app here.
1220
+ // See docs/device-testing.md, "Scripted tests".
1221
+ window.condorDev = {
1222
+ scenario: h.scenario,
1223
+ get log() { return h.log.slice(); },
1224
+ // true once the bundle is mounted AND its handshake was answered (or,
1225
+ // with nativeVersion null, left unanswered on purpose).
1226
+ get ready() {
1227
+ return h.log.some(function (e) {
1228
+ return (e.kind === 'reply' && e.key === 'handshake-ack') || /handshake unanswered/.test(e.detail);
1229
+ });
1230
+ },
1231
+ emit: function (key, payload) { h.emit(key, payload); },
1232
+ setStatus: function (patch) { h.setStatus(patch); },
1233
+ setConnected: function (deviceId, connected) { h.setConnected(deviceId, connected); },
1234
+ setConfirmations: function (mode) { h.setConfirmations(mode); },
1235
+ pressBack: function () { h.pressBack(); }
1236
+ };
687
1237
  })
688
- .then(function (h) { harness = h; })
689
1238
  .catch(function (err) { document.getElementById('device').textContent = String(err); console.error(err); });
690
1239
  </script>
691
1240
  </body>
692
1241
  </html>`;
693
1242
  }
694
1243
  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");
1244
+ const here = typeof __dirname !== "undefined" ? __dirname : (0, import_node_path3.dirname)((0, import_node_url.fileURLToPath)(import_meta.url));
1245
+ return (0, import_node_path3.join)(here, "dev-harness.global.js");
697
1246
  }
698
1247
  function startDevServer(opts) {
699
- if (!(opts.scenario in SCENARIOS)) {
1248
+ const scenarioFile = opts.scenario.endsWith(".json") ? (0, import_node_path3.resolve)(opts.scenario) : void 0;
1249
+ if (scenarioFile) {
1250
+ JSON.parse((0, import_node_fs3.readFileSync)(scenarioFile, "utf-8"));
1251
+ } else if (!(opts.scenario in SCENARIOS)) {
700
1252
  throw new Error(
701
1253
  `Unknown scenario "${opts.scenario}". Known: ${Object.keys(SCENARIOS).join(", ")}.`
702
1254
  );
@@ -713,23 +1265,38 @@ function startDevServer(opts) {
713
1265
  res.end(readClientHtml(opts.input));
714
1266
  return;
715
1267
  }
1268
+ if (url.pathname === "/__condor/scenario.json" && scenarioFile) {
1269
+ res.writeHead(200, {
1270
+ "content-type": "application/json; charset=utf-8",
1271
+ "cache-control": "no-store"
1272
+ });
1273
+ res.end((0, import_node_fs3.readFileSync)(scenarioFile));
1274
+ return;
1275
+ }
716
1276
  if (url.pathname === "/__condor/harness.js") {
717
1277
  res.writeHead(200, {
718
1278
  "content-type": "text/javascript; charset=utf-8",
719
1279
  "cache-control": "no-store"
720
1280
  });
721
- res.end((0, import_node_fs2.readFileSync)(harnessScriptPath()));
1281
+ res.end((0, import_node_fs3.readFileSync)(harnessScriptPath()));
722
1282
  return;
723
1283
  }
724
1284
  if (url.pathname === "/") {
725
- const scenario = url.searchParams.get("scenario") ?? opts.scenario;
1285
+ const named = url.searchParams.get("scenario");
1286
+ const scenario = named ?? (scenarioFile ? "default" : opts.scenario);
726
1287
  res.writeHead(200, {
727
1288
  "content-type": "text/html; charset=utf-8",
728
1289
  "content-security-policy": CANVAS_CSP,
729
1290
  "cache-control": "no-store"
730
1291
  });
731
1292
  const nativeVersion = url.searchParams.get("nativeVersion") ?? opts.nativeVersion;
732
- res.end(harnessPage(scenario in SCENARIOS ? scenario : opts.scenario, nativeVersion));
1293
+ res.end(
1294
+ harnessPage(scenario in SCENARIOS ? scenario : "default", nativeVersion, {
1295
+ latencyMs: opts.latencyMs,
1296
+ panel: opts.panel,
1297
+ scenarioFile: named ? void 0 : scenarioFile && opts.scenario
1298
+ })
1299
+ );
733
1300
  return;
734
1301
  }
735
1302
  res.writeHead(404).end();
@@ -743,12 +1310,13 @@ function startDevServer(opts) {
743
1310
  }
744
1311
 
745
1312
  // src/cli.ts
746
- function runDev(input, scenario, port, nativeVersion) {
747
- const server = startDevServer({ input, scenario, port, nativeVersion });
1313
+ function runDev(opts) {
1314
+ const { input, scenario, port, nativeVersion } = opts;
1315
+ const server = startDevServer(opts);
748
1316
  server.on("listening", () => {
749
1317
  const native = nativeVersion === void 0 ? "" : `&nativeVersion=${nativeVersion}`;
750
1318
  console.log(
751
- `Condor Canvas dev harness: http://localhost:${port}/?scenario=${scenario}${native}`
1319
+ `Condor Canvas dev harness: http://localhost:${port}/${scenario.endsWith(".json") ? "" : `?scenario=${scenario}`}${native}`
752
1320
  );
753
1321
  console.log(` bundle: ${input} (re-read on every reload)`);
754
1322
  console.log(" sandbox, shell policy and CSP: same as the app");
@@ -763,9 +1331,9 @@ function runDev(input, scenario, port, nativeVersion) {
763
1331
  }
764
1332
  function runBuild(htmlPath, outPath, maxBytes) {
765
1333
  const result = buildBundle(htmlPath, maxBytes);
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);
1334
+ const resolvedOut = (0, import_node_path4.resolve)(outPath);
1335
+ (0, import_node_fs4.mkdirSync)((0, import_node_path4.dirname)(resolvedOut), { recursive: true });
1336
+ (0, import_node_fs4.writeFileSync)(resolvedOut, result.zipBytes);
769
1337
  const kb = (Buffer.byteLength(result.html) / 1024).toFixed(1);
770
1338
  console.log(`Built ${outPath}`);
771
1339
  console.log(` entry: index.html (${kb} KB self-contained)`);
@@ -813,7 +1381,28 @@ ${errors.length} error(s), ${warnings.length} warning(s).` + (result.size === nu
813
1381
  );
814
1382
  return 0;
815
1383
  }
1384
+ async function runCanvasCommand(argv) {
1385
+ if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) {
1386
+ console.log(CANVAS_USAGE);
1387
+ process.exit(argv.length === 0 ? 1 : 0);
1388
+ }
1389
+ const parsed = parseCanvasArgs(argv, process.env);
1390
+ if ("error" in parsed) {
1391
+ console.error(parsed.error);
1392
+ console.error("Run `condor-mobile-bridge canvas --help`.");
1393
+ process.exit(1);
1394
+ }
1395
+ const code = await runCanvas(parsed, terminalIo(), {
1396
+ userAgent: `condor-mobile-bridge-cli/${version}`
1397
+ });
1398
+ closeTerminalIo();
1399
+ process.exit(code);
1400
+ }
816
1401
  function main() {
1402
+ if (process.argv[2] === "canvas") {
1403
+ void runCanvasCommand(process.argv.slice(3));
1404
+ return;
1405
+ }
817
1406
  const args = parseArgs(process.argv.slice(2));
818
1407
  if (args.help || !args.htmlPath) {
819
1408
  console.log(USAGE);
@@ -821,7 +1410,14 @@ function main() {
821
1410
  }
822
1411
  if (args.command === "dev") {
823
1412
  try {
824
- runDev(args.htmlPath, args.scenario, args.port, args.nativeVersion);
1413
+ runDev({
1414
+ input: args.htmlPath,
1415
+ scenario: args.scenario,
1416
+ port: args.port,
1417
+ nativeVersion: args.nativeVersion,
1418
+ latencyMs: args.latencyMs,
1419
+ panel: args.panel
1420
+ });
825
1421
  } catch (err) {
826
1422
  console.error(`dev: ${err instanceof Error ? err.message : String(err)}`);
827
1423
  process.exit(1);