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