@condorinst/mobile-bridge 0.20.0 → 0.22.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/AGENTS.md +109 -42
- package/CHANGELOG.md +99 -0
- package/README.md +80 -14
- package/contract/bridge-contract.json +59 -14
- package/dist/cli.cjs +933 -3
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +932 -3
- package/dist/cli.js.map +1 -1
- package/dist/dev-harness.global.js +2110 -0
- package/dist/dev.cjs +1662 -0
- package/dist/dev.cjs.map +1 -0
- package/dist/dev.d.cts +683 -0
- package/dist/dev.d.ts +683 -0
- package/dist/dev.js +1611 -0
- package/dist/dev.js.map +1 -0
- package/dist/index.cjs +96 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +236 -14
- package/dist/index.d.ts +236 -14
- package/dist/index.js +94 -3
- package/dist/index.js.map +1 -1
- package/docs/agent-guide.md +75 -24
- package/docs/bridge-contract.md +121 -20
- package/docs/client-guide.md +42 -29
- package/docs/device-testing.md +114 -0
- package/docs/pilot-onboarding.md +31 -13
- package/docs/troubleshooting.md +79 -18
- package/examples/minimal-home/AGENTS.md +9 -6
- package/examples/minimal-home/README.md +10 -5
- package/examples/minimal-home/build.mjs +2 -1
- package/examples/minimal-home/index.html +2 -0
- package/examples/minimal-home/package.json +3 -2
- package/examples/minimal-home/src/main.ts +151 -73
- package/llms-full.txt +2086 -0
- package/llms.txt +15 -13
- package/package.json +9 -3
package/dist/cli.cjs
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
4
|
// src/cli.ts
|
|
5
|
+
var import_node_fs4 = require("fs");
|
|
6
|
+
var import_node_path4 = require("path");
|
|
7
|
+
|
|
8
|
+
// package.json
|
|
9
|
+
var version = "0.22.0";
|
|
10
|
+
|
|
11
|
+
// src/canvas-admin/command.ts
|
|
12
|
+
var import_node_crypto2 = require("crypto");
|
|
5
13
|
var import_node_fs2 = require("fs");
|
|
6
14
|
var import_node_path2 = require("path");
|
|
7
15
|
|
|
@@ -168,8 +176,16 @@ var BLOCKED_API_RULES = [
|
|
|
168
176
|
message: "connect-src 'self' on a null origin blocks every network call. Request data through the bridge instead (e.g. BridgeClient.fetchSensorLog)."
|
|
169
177
|
}
|
|
170
178
|
];
|
|
179
|
+
var DEV_HARNESS_MARKER = "__condorDevHarness__";
|
|
171
180
|
function lintClientHtml(html) {
|
|
172
181
|
const findings = [];
|
|
182
|
+
if (html.includes(DEV_HARNESS_MARKER)) {
|
|
183
|
+
findings.push({
|
|
184
|
+
severity: "error",
|
|
185
|
+
rule: "dev-harness-bundled",
|
|
186
|
+
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."
|
|
187
|
+
});
|
|
188
|
+
}
|
|
173
189
|
for (const { rule, pattern, message } of BLOCKED_API_RULES) {
|
|
174
190
|
if (pattern.test(html)) findings.push({ severity: "warning", rule, message });
|
|
175
191
|
}
|
|
@@ -277,6 +293,7 @@ function validateBundle(inputPath, maxBytes = DEFAULT_MAX_BYTES) {
|
|
|
277
293
|
};
|
|
278
294
|
}
|
|
279
295
|
var DEFAULT_OUT = "dist/whitelabel-bundle.zip";
|
|
296
|
+
var DEFAULT_DEV_PORT = 5173;
|
|
280
297
|
function parseArgs(argv) {
|
|
281
298
|
const args = {
|
|
282
299
|
command: "build",
|
|
@@ -284,6 +301,9 @@ function parseArgs(argv) {
|
|
|
284
301
|
outPath: DEFAULT_OUT,
|
|
285
302
|
maxBytes: DEFAULT_MAX_BYTES,
|
|
286
303
|
strict: false,
|
|
304
|
+
scenario: "default",
|
|
305
|
+
port: DEFAULT_DEV_PORT,
|
|
306
|
+
nativeVersion: void 0,
|
|
287
307
|
help: false
|
|
288
308
|
};
|
|
289
309
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -314,6 +334,18 @@ function parseArgs(argv) {
|
|
|
314
334
|
case "validate":
|
|
315
335
|
args.command = "validate";
|
|
316
336
|
break;
|
|
337
|
+
case "dev":
|
|
338
|
+
args.command = "dev";
|
|
339
|
+
break;
|
|
340
|
+
case "--scenario":
|
|
341
|
+
args.scenario = argv[++i] ?? "default";
|
|
342
|
+
break;
|
|
343
|
+
case "--native-version":
|
|
344
|
+
args.nativeVersion = argv[++i] ?? "null";
|
|
345
|
+
break;
|
|
346
|
+
case "--port":
|
|
347
|
+
args.port = Number.parseInt(argv[++i] ?? "", 10) || DEFAULT_DEV_PORT;
|
|
348
|
+
break;
|
|
317
349
|
default:
|
|
318
350
|
if (token && !token.startsWith("-") && !args.htmlPath) args.htmlPath = token;
|
|
319
351
|
}
|
|
@@ -325,25 +357,893 @@ var USAGE = `condor-mobile-bridge \u2014 tooling for Condor Canvas client bundle
|
|
|
325
357
|
Usage:
|
|
326
358
|
npx condor-mobile-bridge build -i <html> [-o <zip>]
|
|
327
359
|
npx condor-mobile-bridge validate -i <zip|html> [--strict]
|
|
360
|
+
npx condor-mobile-bridge canvas publish|list|revoke ... (see: canvas --help)
|
|
361
|
+
npx condor-mobile-bridge dev -i <html|zip|dir> [--scenario <name>] [--native-version <v|null>] [--port <n>]
|
|
328
362
|
|
|
329
363
|
Commands:
|
|
330
364
|
build Inline a dist/index.html into a self-contained .zip.
|
|
331
365
|
validate Check a bundle against every rule the Canvas app enforces, plus
|
|
332
366
|
the APIs the sandbox and the CSP block at runtime.
|
|
367
|
+
canvas Publish, list and revoke the bundle of your company in the
|
|
368
|
+
Condor Canvas app (interactive login). See: canvas --help.
|
|
369
|
+
dev Serve the bundle in a browser harness that behaves like the app:
|
|
370
|
+
same sandbox, shell and policy, and a simulated native side.
|
|
371
|
+
Re-reads the input on every reload. Scenarios: default,
|
|
372
|
+
cold-start, no-devices, shared-serial, offline.
|
|
333
373
|
|
|
334
374
|
Options:
|
|
335
375
|
-i, --html <path> Input. build: dist/index.html. validate: .zip or .html. Required.
|
|
336
376
|
-o, --out <path> build only: output .zip (default: ${DEFAULT_OUT}).
|
|
337
377
|
--max-bytes <n> Size limit in bytes (default: ${DEFAULT_MAX_BYTES} = 2 MB).
|
|
338
378
|
--strict validate only: exit 1 on warnings too, not just errors.
|
|
379
|
+
--scenario <n> dev only: device behaviour to simulate (default: default).
|
|
380
|
+
--port <n> dev only: HTTP port (default: ${DEFAULT_DEV_PORT}).
|
|
381
|
+
--native-version <v|null>
|
|
382
|
+
dev only: nativeVersion the harness reports (default
|
|
383
|
+
0.17.0). 'null' reproduces a shell that could not
|
|
384
|
+
reach the native side.
|
|
339
385
|
-h, --help Show this help.`;
|
|
340
386
|
|
|
387
|
+
// src/canvas-admin/client.ts
|
|
388
|
+
var CANVAS_ADMIN_API = {
|
|
389
|
+
prod: "https://api.sleepdiary.condorapps.net",
|
|
390
|
+
dev: "https://dev.sleepdiary.condorapps.net"
|
|
391
|
+
};
|
|
392
|
+
var CanvasAdminError = class extends Error {
|
|
393
|
+
constructor(message, status, reason) {
|
|
394
|
+
super(message);
|
|
395
|
+
this.status = status;
|
|
396
|
+
this.reason = reason;
|
|
397
|
+
this.name = "CanvasAdminError";
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
function tokenFrom(headers) {
|
|
401
|
+
const values = typeof headers.getSetCookie === "function" ? headers.getSetCookie() : [headers.get("set-cookie") ?? ""];
|
|
402
|
+
for (const value of values) {
|
|
403
|
+
const match = /(?:^|[,;\s])token=([^;,\s]*)/.exec(value);
|
|
404
|
+
if (match?.[1]) return match[1];
|
|
405
|
+
}
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
async function reasonFrom(response) {
|
|
409
|
+
try {
|
|
410
|
+
const body = await response.json();
|
|
411
|
+
for (const key of ["error", "message", "description", "code"]) {
|
|
412
|
+
const value = body[key];
|
|
413
|
+
if (typeof value === "string" && value) return value;
|
|
414
|
+
}
|
|
415
|
+
} catch {
|
|
416
|
+
}
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
var CanvasAdminClient = class {
|
|
420
|
+
constructor(opts) {
|
|
421
|
+
this.token = null;
|
|
422
|
+
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
423
|
+
this.userAgent = opts.userAgent;
|
|
424
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
425
|
+
this.timeoutMs = opts.timeoutMs ?? 6e4;
|
|
426
|
+
}
|
|
427
|
+
async request(path, init = {}) {
|
|
428
|
+
const headers = new Headers(init.headers);
|
|
429
|
+
headers.set("user-agent", this.userAgent);
|
|
430
|
+
headers.set("accept", "application/json");
|
|
431
|
+
if (this.token) headers.set("cookie", `token=${this.token}`);
|
|
432
|
+
try {
|
|
433
|
+
return await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
434
|
+
...init,
|
|
435
|
+
headers,
|
|
436
|
+
redirect: "manual",
|
|
437
|
+
signal: AbortSignal.timeout(this.timeoutMs)
|
|
438
|
+
});
|
|
439
|
+
} catch (err) {
|
|
440
|
+
throw new CanvasAdminError(
|
|
441
|
+
`Could not reach ${this.baseUrl}: ${err instanceof Error ? err.message : String(err)}`,
|
|
442
|
+
0,
|
|
443
|
+
null
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
async fail(response, what) {
|
|
448
|
+
const reason = await reasonFrom(response);
|
|
449
|
+
throw new CanvasAdminError(
|
|
450
|
+
`${what} failed (HTTP ${response.status})${reason ? `: ${reason}` : ""}`,
|
|
451
|
+
response.status,
|
|
452
|
+
reason
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Logs in. Resolves `{ needsCode: true }` when the company requires a
|
|
457
|
+
* two-factor code (sent by e-mail): call {@link verifyCode} next.
|
|
458
|
+
*/
|
|
459
|
+
async login(creds) {
|
|
460
|
+
const response = await this.request("/auth/login", {
|
|
461
|
+
method: "POST",
|
|
462
|
+
headers: { "content-type": "application/json" },
|
|
463
|
+
body: JSON.stringify({
|
|
464
|
+
company_id: creds.companyId,
|
|
465
|
+
user_id: creds.userId,
|
|
466
|
+
password: creds.password
|
|
467
|
+
})
|
|
468
|
+
});
|
|
469
|
+
if (!response.ok) await this.fail(response, "Login");
|
|
470
|
+
const token = tokenFrom(response.headers);
|
|
471
|
+
if (!token) throw new CanvasAdminError("Login answered without a session cookie", 200, null);
|
|
472
|
+
this.token = token;
|
|
473
|
+
const body = await response.json().catch(() => ({}));
|
|
474
|
+
return { needsCode: body.completed_auth_flow === false };
|
|
475
|
+
}
|
|
476
|
+
/** Sends the two-factor (or recovery) code; the server swaps the session cookie. */
|
|
477
|
+
async verifyCode(code) {
|
|
478
|
+
const response = await this.request("/auth/verify_2fa", {
|
|
479
|
+
method: "POST",
|
|
480
|
+
headers: { "content-type": "application/json" },
|
|
481
|
+
body: JSON.stringify({ otp_code: code })
|
|
482
|
+
});
|
|
483
|
+
if (!response.ok) await this.fail(response, "Two-factor verification");
|
|
484
|
+
const token = tokenFrom(response.headers);
|
|
485
|
+
if (token) this.token = token;
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Publishes a bundle. **It goes live at once** for every user of the
|
|
489
|
+
* company: there is no draft. A version can be published only once per
|
|
490
|
+
* company (HTTP 409).
|
|
491
|
+
*/
|
|
492
|
+
async publish(input) {
|
|
493
|
+
const form = new FormData();
|
|
494
|
+
form.set(
|
|
495
|
+
"bundle",
|
|
496
|
+
new Blob([input.zipBytes], { type: "application/zip" }),
|
|
497
|
+
"bundle.zip"
|
|
498
|
+
);
|
|
499
|
+
form.set("version", input.version);
|
|
500
|
+
if (input.companyId !== void 0) form.set("company_id", String(input.companyId));
|
|
501
|
+
if (input.minAppVersion) form.set("min_app_version", input.minAppVersion);
|
|
502
|
+
const response = await this.request("/v2/condor_canvas/bundles", {
|
|
503
|
+
method: "POST",
|
|
504
|
+
body: form
|
|
505
|
+
});
|
|
506
|
+
if (!response.ok) await this.fail(response, "Publish");
|
|
507
|
+
const body = await response.json();
|
|
508
|
+
return body.data;
|
|
509
|
+
}
|
|
510
|
+
/** Lists the bundles of a company (a manager always sees their own). */
|
|
511
|
+
async list(companyId) {
|
|
512
|
+
const query = companyId === void 0 ? "" : `?company_id=${companyId}`;
|
|
513
|
+
const response = await this.request(`/v2/condor_canvas/bundles${query}`);
|
|
514
|
+
if (!response.ok) await this.fail(response, "List");
|
|
515
|
+
const body = await response.json();
|
|
516
|
+
return body.data;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Revokes a bundle. Without `fallbackVersion`, revoking the current bundle
|
|
520
|
+
* leaves the company with none: the app shows its default screen.
|
|
521
|
+
*/
|
|
522
|
+
async revoke(bundleId, fallbackVersion) {
|
|
523
|
+
const response = await this.request(`/v2/condor_canvas/bundles/${bundleId}/revoke`, {
|
|
524
|
+
method: "POST",
|
|
525
|
+
headers: { "content-type": "application/json" },
|
|
526
|
+
body: JSON.stringify(fallbackVersion ? { fallbackVersion } : {})
|
|
527
|
+
});
|
|
528
|
+
if (!response.ok) await this.fail(response, "Revoke");
|
|
529
|
+
const body = await response.json();
|
|
530
|
+
return body.data;
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
// src/canvas-admin/command.ts
|
|
535
|
+
var CANVAS_USAGE = `condor-mobile-bridge canvas \u2014 publish a bundle to the Condor Canvas app.
|
|
536
|
+
|
|
537
|
+
Usage:
|
|
538
|
+
npx condor-mobile-bridge canvas publish <dist/index.html|dir|bundle.zip> --version <v> [options]
|
|
539
|
+
npx condor-mobile-bridge canvas list [options]
|
|
540
|
+
npx condor-mobile-bridge canvas revoke <bundleId> [--fallback-version <v>] [options]
|
|
541
|
+
|
|
542
|
+
Publishing goes LIVE AT ONCE for every user of the company \u2014 there is no draft.
|
|
543
|
+
Use a test company, and see docs/device-testing.md.
|
|
544
|
+
|
|
545
|
+
Options:
|
|
546
|
+
--company <id> Company id, as in the Condor web login (or CONDOR_CANVAS_COMPANY).
|
|
547
|
+
--user <id> Your user id, as in the Condor web login (or CONDOR_CANVAS_USER).
|
|
548
|
+
Both are asked when not given.
|
|
549
|
+
--version <v> publish: bundle version, unique per company.
|
|
550
|
+
--min-app-version <v> publish: minimum app build that may load it.
|
|
551
|
+
--fallback-version <v> revoke: VERSION to serve instead (the version column of
|
|
552
|
+
canvas list, e.g. 0.0.8 \u2014 not the id).
|
|
553
|
+
--env prod|dev API environment (default: prod). dev is for Condor staff.
|
|
554
|
+
--base-url <url> Override the API URL.
|
|
555
|
+
--yes Do not ask for confirmation.
|
|
556
|
+
--json Print results as JSON.
|
|
557
|
+
|
|
558
|
+
Your password (and the two-factor code, when your company uses one) is asked
|
|
559
|
+
interactively, without echo, and never stored. There is no non-interactive login.`;
|
|
560
|
+
var intOrUndefined = (value) => {
|
|
561
|
+
if (value === void 0) return void 0;
|
|
562
|
+
const n = Number(value);
|
|
563
|
+
return Number.isInteger(n) && n > 0 ? n : Number.NaN;
|
|
564
|
+
};
|
|
565
|
+
function parseCanvasArgs(argv, env = {}) {
|
|
566
|
+
const [action, ...rest] = argv;
|
|
567
|
+
if (action !== "publish" && action !== "list" && action !== "revoke") {
|
|
568
|
+
return { error: `Unknown canvas command "${action ?? ""}". Use publish, list or revoke.` };
|
|
569
|
+
}
|
|
570
|
+
const args = {
|
|
571
|
+
action,
|
|
572
|
+
env: "prod",
|
|
573
|
+
yes: false,
|
|
574
|
+
json: false,
|
|
575
|
+
companyId: intOrUndefined(env.CONDOR_CANVAS_COMPANY),
|
|
576
|
+
userId: intOrUndefined(env.CONDOR_CANVAS_USER)
|
|
577
|
+
};
|
|
578
|
+
const positional = [];
|
|
579
|
+
for (let i = 0; i < rest.length; i++) {
|
|
580
|
+
const token = rest[i] ?? "";
|
|
581
|
+
const next = () => rest[++i];
|
|
582
|
+
switch (token) {
|
|
583
|
+
case "--env": {
|
|
584
|
+
const env2 = next();
|
|
585
|
+
if (env2 !== "prod" && env2 !== "dev") return { error: "--env must be prod or dev." };
|
|
586
|
+
args.env = env2;
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
case "--base-url":
|
|
590
|
+
args.baseUrl = next();
|
|
591
|
+
break;
|
|
592
|
+
case "--company":
|
|
593
|
+
args.companyId = intOrUndefined(next());
|
|
594
|
+
break;
|
|
595
|
+
case "--user":
|
|
596
|
+
args.userId = intOrUndefined(next());
|
|
597
|
+
break;
|
|
598
|
+
case "--version":
|
|
599
|
+
args.version = next();
|
|
600
|
+
break;
|
|
601
|
+
case "--min-app-version":
|
|
602
|
+
args.minAppVersion = next();
|
|
603
|
+
break;
|
|
604
|
+
case "--fallback-version":
|
|
605
|
+
args.fallbackVersion = next();
|
|
606
|
+
break;
|
|
607
|
+
case "--yes":
|
|
608
|
+
args.yes = true;
|
|
609
|
+
break;
|
|
610
|
+
case "--json":
|
|
611
|
+
args.json = true;
|
|
612
|
+
break;
|
|
613
|
+
case "--password":
|
|
614
|
+
return {
|
|
615
|
+
error: "There is no --password flag: the password is only read from the prompt."
|
|
616
|
+
};
|
|
617
|
+
default:
|
|
618
|
+
if (token.startsWith("-")) return { error: `Unknown option ${token}.` };
|
|
619
|
+
positional.push(token);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
if (Number.isNaN(args.companyId) || Number.isNaN(args.userId)) {
|
|
623
|
+
return {
|
|
624
|
+
error: "--company and --user (or CONDOR_CANVAS_COMPANY / CONDOR_CANVAS_USER) take a positive integer id."
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
if (action === "publish") {
|
|
628
|
+
args.input = positional[0];
|
|
629
|
+
if (!args.input)
|
|
630
|
+
return { error: "canvas publish needs the bundle: dist/index.html, a dir or a .zip." };
|
|
631
|
+
if (!args.version) return { error: "canvas publish needs --version." };
|
|
632
|
+
}
|
|
633
|
+
if (action === "revoke") {
|
|
634
|
+
args.bundleId = intOrUndefined(positional[0]);
|
|
635
|
+
if (!args.bundleId || Number.isNaN(args.bundleId)) {
|
|
636
|
+
return { error: "canvas revoke needs the bundle id (see canvas list)." };
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
return args;
|
|
640
|
+
}
|
|
641
|
+
function loadZip(input) {
|
|
642
|
+
const path = (0, import_node_path2.resolve)(input);
|
|
643
|
+
if (!(0, import_node_fs2.existsSync)(path)) throw new Error(`${input}: not found.`);
|
|
644
|
+
let zipBytes;
|
|
645
|
+
if ((0, import_node_path2.extname)(path).toLowerCase() === ".zip") {
|
|
646
|
+
zipBytes = new Uint8Array((0, import_node_fs2.readFileSync)(path));
|
|
647
|
+
} else {
|
|
648
|
+
const html = (0, import_node_fs2.statSync)(path).isDirectory() ? (0, import_node_path2.join)(path, "index.html") : path;
|
|
649
|
+
zipBytes = buildBundle(html).zipBytes;
|
|
650
|
+
}
|
|
651
|
+
return { zipBytes, findings: validateZipBytes(zipBytes).findings };
|
|
652
|
+
}
|
|
653
|
+
async function askId(io, label, value) {
|
|
654
|
+
if (value !== void 0) return value;
|
|
655
|
+
const answer = (await io.ask(`${label}: `)).trim();
|
|
656
|
+
const n = Number(answer);
|
|
657
|
+
if (!Number.isInteger(n) || n <= 0) throw new Error(`${label} must be a positive integer.`);
|
|
658
|
+
return n;
|
|
659
|
+
}
|
|
660
|
+
async function confirm(io, args, question2) {
|
|
661
|
+
if (args.yes) return true;
|
|
662
|
+
const answer = (await io.ask(`${question2} [y/N] `)).trim().toLowerCase();
|
|
663
|
+
return answer === "y" || answer === "yes";
|
|
664
|
+
}
|
|
665
|
+
async function logIn(io, client, companyId, userId) {
|
|
666
|
+
io.print("");
|
|
667
|
+
io.print("Log in with your Condor web account (typing is hidden; press Enter when done).");
|
|
668
|
+
let password = "";
|
|
669
|
+
for (let attempt = 0; attempt < 3 && !password.trim(); attempt++) {
|
|
670
|
+
if (attempt > 0) io.printError("The password cannot be empty.");
|
|
671
|
+
password = await io.askSecret("Password: ");
|
|
672
|
+
}
|
|
673
|
+
if (!password.trim()) throw new Error("No password given. Nothing was sent.");
|
|
674
|
+
const { needsCode } = await client.login({ companyId, userId, password });
|
|
675
|
+
if (!needsCode) return;
|
|
676
|
+
io.print(
|
|
677
|
+
"Your company uses two-factor login: a code was sent to your e-mail. Type it below (hidden)."
|
|
678
|
+
);
|
|
679
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
680
|
+
const code = (await io.askSecret("Code (or a recovery code): ")).trim();
|
|
681
|
+
try {
|
|
682
|
+
await client.verifyCode(code);
|
|
683
|
+
return;
|
|
684
|
+
} catch (err) {
|
|
685
|
+
if (attempt === 2 || !(err instanceof CanvasAdminError) || err.status !== 400) throw err;
|
|
686
|
+
io.printError("That code was not accepted. Try once more.");
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
function describeError(err) {
|
|
691
|
+
if (!(err instanceof CanvasAdminError)) return err instanceof Error ? err.message : String(err);
|
|
692
|
+
if (err.status === 400 && /fallback/i.test(err.reason ?? "")) {
|
|
693
|
+
return `${err.message}
|
|
694
|
+
--fallback-version takes the VERSION column of \`canvas list\` (e.g. 0.0.8), not the id.`;
|
|
695
|
+
}
|
|
696
|
+
switch (err.status) {
|
|
697
|
+
case 401:
|
|
698
|
+
return `${err.message}
|
|
699
|
+
Check the company id, user id and password.`;
|
|
700
|
+
case 403:
|
|
701
|
+
return `${err.message}
|
|
702
|
+
Your user cannot do this for that company: publishing needs a manager of the company.`;
|
|
703
|
+
case 409:
|
|
704
|
+
return `${err.message}
|
|
705
|
+
This version was already published for the company. Use a new --version.`;
|
|
706
|
+
case 413:
|
|
707
|
+
return `${err.message}
|
|
708
|
+
The .zip is over 2 MB.`;
|
|
709
|
+
default:
|
|
710
|
+
return err.message;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
function table(rows) {
|
|
714
|
+
if (rows.length === 0) return ["No bundle published for this company."];
|
|
715
|
+
return [
|
|
716
|
+
"id version current revoked published_at min_app",
|
|
717
|
+
...rows.map(
|
|
718
|
+
(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 ?? ""}`
|
|
719
|
+
)
|
|
720
|
+
];
|
|
721
|
+
}
|
|
722
|
+
async function runCanvas(args, io, deps) {
|
|
723
|
+
const baseUrl = args.baseUrl ?? CANVAS_ADMIN_API[args.env];
|
|
724
|
+
const envLabel = args.baseUrl ? `custom (${baseUrl})` : `${args.env.toUpperCase()} (${baseUrl})`;
|
|
725
|
+
try {
|
|
726
|
+
let zip;
|
|
727
|
+
if (args.action === "publish") {
|
|
728
|
+
zip = loadZip(args.input ?? "");
|
|
729
|
+
const errors = zip.findings.filter((f) => f.severity === "error");
|
|
730
|
+
for (const f of zip.findings) {
|
|
731
|
+
(f.severity === "error" ? io.printError : io.print)(
|
|
732
|
+
`${f.severity.toUpperCase()} [${f.rule}] ${f.message}`
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
if (errors.length > 0) {
|
|
736
|
+
io.printError("The app would reject this bundle. Nothing was sent.");
|
|
737
|
+
return 1;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
if (!io.interactive) {
|
|
741
|
+
io.printError(
|
|
742
|
+
"canvas needs an interactive terminal: the password is only read from a prompt."
|
|
743
|
+
);
|
|
744
|
+
return 1;
|
|
745
|
+
}
|
|
746
|
+
const companyId = await askId(io, "Company id", args.companyId);
|
|
747
|
+
const userId = await askId(io, "User id", args.userId);
|
|
748
|
+
if (args.action === "publish" && zip) {
|
|
749
|
+
const hash = `sha256-${(0, import_node_crypto2.createHash)("sha256").update(zip.zipBytes).digest("hex")}`;
|
|
750
|
+
io.print("");
|
|
751
|
+
io.print(`Environment: ${envLabel}`);
|
|
752
|
+
io.print(`Company: ${companyId}`);
|
|
753
|
+
io.print(
|
|
754
|
+
`Version: ${args.version}${args.minAppVersion ? ` (min app ${args.minAppVersion})` : ""}`
|
|
755
|
+
);
|
|
756
|
+
io.print(`Bundle: ${zip.zipBytes.length} bytes, ${hash}`);
|
|
757
|
+
io.print("It goes LIVE AT ONCE for every user of this company.");
|
|
758
|
+
if (!await confirm(io, args, "Publish?")) {
|
|
759
|
+
io.print("Cancelled. Nothing was sent.");
|
|
760
|
+
return 1;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
if (args.action === "revoke") {
|
|
764
|
+
io.print("");
|
|
765
|
+
io.print(`Environment: ${envLabel}`);
|
|
766
|
+
io.print(`Revoke bundle ${args.bundleId} of company ${companyId}.`);
|
|
767
|
+
io.print(
|
|
768
|
+
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."
|
|
769
|
+
);
|
|
770
|
+
if (!await confirm(io, args, "Revoke?")) {
|
|
771
|
+
io.print("Cancelled. Nothing was sent.");
|
|
772
|
+
return 1;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
const client = new CanvasAdminClient({
|
|
776
|
+
baseUrl,
|
|
777
|
+
userAgent: deps.userAgent,
|
|
778
|
+
fetchImpl: deps.fetchImpl
|
|
779
|
+
});
|
|
780
|
+
await logIn(io, client, companyId, userId);
|
|
781
|
+
if (args.action === "publish" && zip) {
|
|
782
|
+
const record = await client.publish({
|
|
783
|
+
zipBytes: zip.zipBytes,
|
|
784
|
+
version: args.version ?? "",
|
|
785
|
+
companyId,
|
|
786
|
+
minAppVersion: args.minAppVersion
|
|
787
|
+
});
|
|
788
|
+
if (args.json) io.print(JSON.stringify(record));
|
|
789
|
+
else {
|
|
790
|
+
io.print(
|
|
791
|
+
`Published version ${record.version} (id ${record.id}) \u2014 current: ${record.is_current}.`
|
|
792
|
+
);
|
|
793
|
+
io.print(`bundleHash: ${record.bundle_hash}`);
|
|
794
|
+
io.print("Apps of this company load it the next time they come to the foreground.");
|
|
795
|
+
}
|
|
796
|
+
return 0;
|
|
797
|
+
}
|
|
798
|
+
if (args.action === "list") {
|
|
799
|
+
const rows = await client.list(companyId);
|
|
800
|
+
if (args.json) io.print(JSON.stringify(rows));
|
|
801
|
+
else for (const line of table(rows)) io.print(line);
|
|
802
|
+
return 0;
|
|
803
|
+
}
|
|
804
|
+
const result = await client.revoke(args.bundleId ?? 0, args.fallbackVersion);
|
|
805
|
+
io.print(args.json ? JSON.stringify(result) : `Revoked. ${JSON.stringify(result)}`);
|
|
806
|
+
return 0;
|
|
807
|
+
} catch (err) {
|
|
808
|
+
io.printError(describeError(err));
|
|
809
|
+
return 1;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// src/canvas-admin/tty.ts
|
|
814
|
+
var import_node_readline = require("readline");
|
|
815
|
+
var rl = null;
|
|
816
|
+
var mutedPrompt = null;
|
|
817
|
+
function reader() {
|
|
818
|
+
if (rl) return rl;
|
|
819
|
+
rl = (0, import_node_readline.createInterface)({ input: process.stdin, output: process.stderr, terminal: true });
|
|
820
|
+
const out = rl;
|
|
821
|
+
const write = out._writeToOutput.bind(rl);
|
|
822
|
+
out._writeToOutput = (s) => {
|
|
823
|
+
if (mutedPrompt === null || s.includes(mutedPrompt)) write(s);
|
|
824
|
+
};
|
|
825
|
+
return rl;
|
|
826
|
+
}
|
|
827
|
+
function question(prompt, secret) {
|
|
828
|
+
const r = reader();
|
|
829
|
+
return new Promise((resolve5) => {
|
|
830
|
+
mutedPrompt = secret ? prompt : null;
|
|
831
|
+
r.question(prompt, (answer) => {
|
|
832
|
+
if (secret) {
|
|
833
|
+
mutedPrompt = null;
|
|
834
|
+
process.stderr.write("\n");
|
|
835
|
+
}
|
|
836
|
+
resolve5(answer);
|
|
837
|
+
});
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
function terminalIo() {
|
|
841
|
+
return {
|
|
842
|
+
interactive: Boolean(process.stdin.isTTY),
|
|
843
|
+
ask: (q) => question(q, false),
|
|
844
|
+
askSecret: (q) => question(q, true),
|
|
845
|
+
print: (line) => console.log(line),
|
|
846
|
+
printError: (line) => console.error(line)
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
function closeTerminalIo() {
|
|
850
|
+
rl?.close();
|
|
851
|
+
rl = null;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// src/cli-dev.ts
|
|
855
|
+
var import_node_fs3 = require("fs");
|
|
856
|
+
var import_node_http = require("http");
|
|
857
|
+
var import_node_path3 = require("path");
|
|
858
|
+
var import_node_url = require("url");
|
|
859
|
+
var import_fflate2 = require("fflate");
|
|
860
|
+
|
|
861
|
+
// src/keys.ts
|
|
862
|
+
var BRIDGE_KEYS = {
|
|
863
|
+
pingPong: "pingPong",
|
|
864
|
+
userDevice: "userDevice",
|
|
865
|
+
deviceLightSensors: "deviceLightSensors",
|
|
866
|
+
syncTime: "syncTime",
|
|
867
|
+
realTime: "realTime",
|
|
868
|
+
deleteMemory: "deleteMemory",
|
|
869
|
+
deviceNotification: "deviceNotification",
|
|
870
|
+
deviceConnection: "deviceConnection",
|
|
871
|
+
sensorLog: "sensorLog",
|
|
872
|
+
dfu: "dfu",
|
|
873
|
+
systemStatus: "systemStatus",
|
|
874
|
+
/**
|
|
875
|
+
* Native → client: the OS back button was pressed and the client had
|
|
876
|
+
* reported internal history via `reportNavigation(true)`. Go back exactly
|
|
877
|
+
* ONE internal page. Emitted only when the native side decided to delegate
|
|
878
|
+
* the back gesture. See `BridgeClient.onBack`.
|
|
879
|
+
*/
|
|
880
|
+
navigationBack: "navigationBack",
|
|
881
|
+
/**
|
|
882
|
+
* Native → client: the app's language state. Carries the raw device locale
|
|
883
|
+
* AND the language explicitly chosen in the app Settings, so the client can
|
|
884
|
+
* apply its own precedence rule — including rendering a language the native
|
|
885
|
+
* chrome does not support. Emitted on attach, after `handshake-ack`, and on
|
|
886
|
+
* every change in Settings. See `LanguagePayload`.
|
|
887
|
+
*/
|
|
888
|
+
language: "language"
|
|
889
|
+
};
|
|
890
|
+
|
|
891
|
+
// src/dev/policy.ts
|
|
892
|
+
var CANVAS_POLICY = {
|
|
893
|
+
readOnlyKeys: [
|
|
894
|
+
BRIDGE_KEYS.userDevice,
|
|
895
|
+
BRIDGE_KEYS.deviceConnection,
|
|
896
|
+
BRIDGE_KEYS.deviceLightSensors,
|
|
897
|
+
BRIDGE_KEYS.deviceNotification,
|
|
898
|
+
BRIDGE_KEYS.pingPong,
|
|
899
|
+
BRIDGE_KEYS.realTime,
|
|
900
|
+
BRIDGE_KEYS.syncTime,
|
|
901
|
+
BRIDGE_KEYS.deleteMemory,
|
|
902
|
+
BRIDGE_KEYS.sensorLog,
|
|
903
|
+
BRIDGE_KEYS.systemStatus,
|
|
904
|
+
BRIDGE_KEYS.navigationBack,
|
|
905
|
+
BRIDGE_KEYS.language
|
|
906
|
+
],
|
|
907
|
+
allowedCommands: [
|
|
908
|
+
"userDevice",
|
|
909
|
+
"realTime",
|
|
910
|
+
"openSettings",
|
|
911
|
+
"deviceConnection",
|
|
912
|
+
"ping",
|
|
913
|
+
"jsError",
|
|
914
|
+
"sensorLog",
|
|
915
|
+
"setStatusBarColor",
|
|
916
|
+
"requestPermission",
|
|
917
|
+
"navigationState",
|
|
918
|
+
"language"
|
|
919
|
+
],
|
|
920
|
+
requiresConfirmation: ["syncTime", "deleteMemory", "logout"],
|
|
921
|
+
deniedCommands: ["dfu"]
|
|
922
|
+
};
|
|
923
|
+
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';";
|
|
924
|
+
|
|
925
|
+
// src/dev/fixtures.ts
|
|
926
|
+
function config(spec) {
|
|
927
|
+
const item = {
|
|
928
|
+
deviceId: spec.deviceId,
|
|
929
|
+
serialNo: spec.serialNo,
|
|
930
|
+
deviceBodyPart: spec.bodyPart,
|
|
931
|
+
bleConnectionPeriod: 5,
|
|
932
|
+
autosync: true,
|
|
933
|
+
eraseDeviceMemory: false,
|
|
934
|
+
autoEraseDeviceMemory: false,
|
|
935
|
+
autoEraseDeviceMemoryThreshold: 80,
|
|
936
|
+
readSensorsMemory: false,
|
|
937
|
+
startDevice: false,
|
|
938
|
+
readFullMemory: false,
|
|
939
|
+
allowFirmwareUpdate: true
|
|
940
|
+
};
|
|
941
|
+
if (spec.modelId) item.modelId = spec.modelId;
|
|
942
|
+
return item;
|
|
943
|
+
}
|
|
944
|
+
function telemetry(spec, now) {
|
|
945
|
+
return {
|
|
946
|
+
serialNo: spec.serialNo,
|
|
947
|
+
deviceId: spec.deviceId,
|
|
948
|
+
updataDateTime: now - spec.syncedMinutesAgo * 6e4,
|
|
949
|
+
deviceName: spec.name,
|
|
950
|
+
deviceVersion: spec.modelId === "actlumus_plus" ? "3.0.2" : "2.1.0",
|
|
951
|
+
hardwareVersion: spec.modelId === "actlumus_plus" ? "B" : "A",
|
|
952
|
+
deviceDateTime: now - spec.syncedMinutesAgo * 6e4,
|
|
953
|
+
memoryDataUsage: 37.5,
|
|
954
|
+
memorySystemUsage: 4.2,
|
|
955
|
+
batteryLevel: spec.battery,
|
|
956
|
+
batteryVoltage: 3.7 + spec.battery / 1e3,
|
|
957
|
+
isCharging: spec.charging,
|
|
958
|
+
isOnDock: spec.charging,
|
|
959
|
+
syncErrors: spec.syncError ? [
|
|
960
|
+
{
|
|
961
|
+
serialNo: spec.serialNo,
|
|
962
|
+
attemptDate: now - 10 * 6e4,
|
|
963
|
+
message: spec.syncError,
|
|
964
|
+
messageCode: "SYNC_FAILED"
|
|
965
|
+
}
|
|
966
|
+
] : []
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
function userConfig(spec) {
|
|
970
|
+
return {
|
|
971
|
+
serialNo: spec.serialNo,
|
|
972
|
+
deviceId: spec.deviceId,
|
|
973
|
+
syncTime: false,
|
|
974
|
+
realTime: false,
|
|
975
|
+
deleteMemory: false,
|
|
976
|
+
readAllMemory: false,
|
|
977
|
+
readAllSystemLog: false
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
function payloadOf(specs, now = Date.now()) {
|
|
981
|
+
return {
|
|
982
|
+
lastUpdate: now,
|
|
983
|
+
devicesConfig: specs.map(config),
|
|
984
|
+
devicesTelemetry: specs.map((s) => telemetry(s, now)),
|
|
985
|
+
devicesUserConfig: specs.map(userConfig)
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
function twoDevices(now = Date.now()) {
|
|
989
|
+
return payloadOf(
|
|
990
|
+
[
|
|
991
|
+
{
|
|
992
|
+
deviceId: 9001,
|
|
993
|
+
serialNo: 4534,
|
|
994
|
+
modelId: "actlumus",
|
|
995
|
+
bodyPart: "wrist",
|
|
996
|
+
name: "ActLumus 4534",
|
|
997
|
+
battery: 82,
|
|
998
|
+
charging: false,
|
|
999
|
+
syncedMinutesAgo: 12
|
|
1000
|
+
},
|
|
1001
|
+
{
|
|
1002
|
+
deviceId: 9002,
|
|
1003
|
+
serialNo: 5120,
|
|
1004
|
+
modelId: "actlumus_plus",
|
|
1005
|
+
bodyPart: "necklace",
|
|
1006
|
+
name: "ActLumus Plus 5120",
|
|
1007
|
+
battery: 41,
|
|
1008
|
+
charging: true,
|
|
1009
|
+
syncedMinutesAgo: 190,
|
|
1010
|
+
syncError: "Could not read the device memory."
|
|
1011
|
+
}
|
|
1012
|
+
],
|
|
1013
|
+
now
|
|
1014
|
+
);
|
|
1015
|
+
}
|
|
1016
|
+
function sharedSerial(now = Date.now()) {
|
|
1017
|
+
return payloadOf(
|
|
1018
|
+
[
|
|
1019
|
+
{
|
|
1020
|
+
deviceId: 9101,
|
|
1021
|
+
serialNo: 7047,
|
|
1022
|
+
bodyPart: "wrist",
|
|
1023
|
+
name: "ActLumus 7047",
|
|
1024
|
+
battery: 67,
|
|
1025
|
+
charging: false,
|
|
1026
|
+
syncedMinutesAgo: 30
|
|
1027
|
+
},
|
|
1028
|
+
{
|
|
1029
|
+
deviceId: 9102,
|
|
1030
|
+
serialNo: 7047,
|
|
1031
|
+
modelId: "actlumus_plus",
|
|
1032
|
+
bodyPart: "necklace",
|
|
1033
|
+
name: "ActLumus Plus 7047",
|
|
1034
|
+
battery: 93,
|
|
1035
|
+
charging: false,
|
|
1036
|
+
syncedMinutesAgo: 8
|
|
1037
|
+
}
|
|
1038
|
+
],
|
|
1039
|
+
now
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
function systemStatus(overrides = {}) {
|
|
1043
|
+
return {
|
|
1044
|
+
bluetooth: true,
|
|
1045
|
+
internet: true,
|
|
1046
|
+
permission: true,
|
|
1047
|
+
hasDevices: true,
|
|
1048
|
+
missingOptionalPermissions: [],
|
|
1049
|
+
missingEssentialPermissions: [],
|
|
1050
|
+
deviceSyncError: {},
|
|
1051
|
+
deviceSyncErrorById: {},
|
|
1052
|
+
...overrides
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// src/dev/scenarios.ts
|
|
1057
|
+
var HARNESS_NATIVE_VERSION = "0.17.0";
|
|
1058
|
+
var base = {
|
|
1059
|
+
description: "Two devices, background service already running, realistic latency.",
|
|
1060
|
+
nativeVersion: HARNESS_NATIVE_VERSION,
|
|
1061
|
+
latencyMs: [80, 250],
|
|
1062
|
+
serviceStartMs: 0,
|
|
1063
|
+
userDeviceSilent: false,
|
|
1064
|
+
logPeriodS: 60,
|
|
1065
|
+
systemStatus: systemStatus(),
|
|
1066
|
+
devices: () => twoDevices()
|
|
1067
|
+
};
|
|
1068
|
+
var SCENARIOS = {
|
|
1069
|
+
default: base,
|
|
1070
|
+
"cold-start": {
|
|
1071
|
+
...base,
|
|
1072
|
+
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.",
|
|
1073
|
+
serviceStartMs: 4e3
|
|
1074
|
+
},
|
|
1075
|
+
"no-devices": {
|
|
1076
|
+
...base,
|
|
1077
|
+
description: "Account with no linked device: userDevice never answers and systemStatus.hasDevices is false. Your bundle must show an empty state.",
|
|
1078
|
+
userDeviceSilent: true,
|
|
1079
|
+
systemStatus: systemStatus({ hasDevices: false })
|
|
1080
|
+
},
|
|
1081
|
+
"shared-serial": {
|
|
1082
|
+
...base,
|
|
1083
|
+
description: "An ActLumus and an ActLumus Plus share serial number 7047. Only deviceId tells them apart; sensorLog cannot address one of them.",
|
|
1084
|
+
devices: () => sharedSerial()
|
|
1085
|
+
},
|
|
1086
|
+
offline: {
|
|
1087
|
+
...base,
|
|
1088
|
+
description: "No internet. systemStatus.internet is false and sensorLog fails; userDevice answers from the cache.",
|
|
1089
|
+
sensorLogError: "Connection failed",
|
|
1090
|
+
systemStatus: systemStatus({ internet: false })
|
|
1091
|
+
}
|
|
1092
|
+
};
|
|
1093
|
+
|
|
1094
|
+
// src/cli-dev.ts
|
|
1095
|
+
var import_meta = {};
|
|
1096
|
+
function readClientHtml(input) {
|
|
1097
|
+
const path = (0, import_node_path3.resolve)(input);
|
|
1098
|
+
if ((0, import_node_fs3.existsSync)(path) && (0, import_node_fs3.statSync)(path).isDirectory()) {
|
|
1099
|
+
return inlineHtml((0, import_node_path3.join)(path, "index.html"));
|
|
1100
|
+
}
|
|
1101
|
+
if ((0, import_node_path3.extname)(path).toLowerCase() === ".zip") {
|
|
1102
|
+
const files = (0, import_fflate2.unzipSync)(new Uint8Array((0, import_node_fs3.readFileSync)(path)));
|
|
1103
|
+
const entry = files["index.html"];
|
|
1104
|
+
if (!entry) throw new Error(`${input}: no index.html at the root of the zip.`);
|
|
1105
|
+
return (0, import_fflate2.strFromU8)(entry);
|
|
1106
|
+
}
|
|
1107
|
+
return inlineHtml(path);
|
|
1108
|
+
}
|
|
1109
|
+
var escapeHtml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1110
|
+
function harnessPage(scenario, nativeVersion) {
|
|
1111
|
+
const options = Object.entries(SCENARIOS).map(
|
|
1112
|
+
([name, s]) => `<option value="${name}" title="${escapeHtml(s.description)}"${name === scenario ? " selected" : ""}>${name}</option>`
|
|
1113
|
+
).join("");
|
|
1114
|
+
const description = escapeHtml(SCENARIOS[scenario]?.description ?? "");
|
|
1115
|
+
const scenarioJson = JSON.stringify(scenario).replace(/</g, "\\u003c");
|
|
1116
|
+
const nativeJson = JSON.stringify(
|
|
1117
|
+
nativeVersion === void 0 ? void 0 : nativeVersion === "null" ? null : nativeVersion
|
|
1118
|
+
)?.replace(/</g, "\\u003c") ?? "undefined";
|
|
1119
|
+
const nativeLabel = escapeHtml(
|
|
1120
|
+
nativeVersion === void 0 ? "" : `nativeVersion ${nativeVersion}`
|
|
1121
|
+
);
|
|
1122
|
+
return `<!doctype html>
|
|
1123
|
+
<html lang="en">
|
|
1124
|
+
<head>
|
|
1125
|
+
<meta charset="utf-8">
|
|
1126
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1127
|
+
<meta http-equiv="Content-Security-Policy" content="${CANVAS_CSP}">
|
|
1128
|
+
<title>Condor Canvas \u2014 dev harness</title>
|
|
1129
|
+
<style>
|
|
1130
|
+
body { margin: 0; font: 13px system-ui, sans-serif; background: #e9ebef; color: #1b1f24; }
|
|
1131
|
+
header { display: flex; gap: 12px; align-items: center; padding: 8px 16px; background: #1b1f24; color: #fff; flex-wrap: wrap; }
|
|
1132
|
+
header p { margin: 0; opacity: .8; flex: 1 1 320px; }
|
|
1133
|
+
main { display: flex; justify-content: center; padding: 16px; }
|
|
1134
|
+
#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; }
|
|
1135
|
+
</style>
|
|
1136
|
+
</head>
|
|
1137
|
+
<body>
|
|
1138
|
+
<header>
|
|
1139
|
+
<strong>Condor Canvas \xB7 dev harness</strong>
|
|
1140
|
+
<label>Scenario <select id="scenario">${options}</select></label>
|
|
1141
|
+
<button id="back" type="button" title="The OS back button: reaches the bundle only after it reported canGoBack true">\u25C0 OS back</button>
|
|
1142
|
+
<span>${nativeLabel}</span>
|
|
1143
|
+
<p>${description}</p>
|
|
1144
|
+
</header>
|
|
1145
|
+
<main><div id="device"></div></main>
|
|
1146
|
+
<script src="/__condor/harness.js"></script>
|
|
1147
|
+
<script>
|
|
1148
|
+
var scenario = ${scenarioJson};
|
|
1149
|
+
var nativeVersion = ${nativeJson};
|
|
1150
|
+
var harness = null;
|
|
1151
|
+
document.getElementById('scenario').addEventListener('change', function (e) {
|
|
1152
|
+
var params = new URLSearchParams(location.search);
|
|
1153
|
+
params.set('scenario', e.target.value);
|
|
1154
|
+
location.search = params.toString();
|
|
1155
|
+
});
|
|
1156
|
+
document.getElementById('back').addEventListener('click', function () {
|
|
1157
|
+
if (harness) harness.pressBack();
|
|
1158
|
+
});
|
|
1159
|
+
var chosen = CondorDevHarness.SCENARIOS[scenario];
|
|
1160
|
+
if (nativeVersion !== undefined) chosen = Object.assign({}, chosen, { nativeVersion: nativeVersion });
|
|
1161
|
+
fetch('/__condor/client.html')
|
|
1162
|
+
.then(function (r) { if (!r.ok) throw new Error('could not read the bundle: HTTP ' + r.status); return r.text(); })
|
|
1163
|
+
.then(function (html) {
|
|
1164
|
+
return CondorDevHarness.createDevHarness({ html: html, scenario: chosen, mount: document.getElementById('device') });
|
|
1165
|
+
})
|
|
1166
|
+
.then(function (h) { harness = h; })
|
|
1167
|
+
.catch(function (err) { document.getElementById('device').textContent = String(err); console.error(err); });
|
|
1168
|
+
</script>
|
|
1169
|
+
</body>
|
|
1170
|
+
</html>`;
|
|
1171
|
+
}
|
|
1172
|
+
function harnessScriptPath() {
|
|
1173
|
+
const here = typeof __dirname !== "undefined" ? __dirname : (0, import_node_path3.dirname)((0, import_node_url.fileURLToPath)(import_meta.url));
|
|
1174
|
+
return (0, import_node_path3.join)(here, "dev-harness.global.js");
|
|
1175
|
+
}
|
|
1176
|
+
function startDevServer(opts) {
|
|
1177
|
+
if (!(opts.scenario in SCENARIOS)) {
|
|
1178
|
+
throw new Error(
|
|
1179
|
+
`Unknown scenario "${opts.scenario}". Known: ${Object.keys(SCENARIOS).join(", ")}.`
|
|
1180
|
+
);
|
|
1181
|
+
}
|
|
1182
|
+
readClientHtml(opts.input);
|
|
1183
|
+
const server = (0, import_node_http.createServer)((req, res) => {
|
|
1184
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
1185
|
+
try {
|
|
1186
|
+
if (url.pathname === "/__condor/client.html") {
|
|
1187
|
+
res.writeHead(200, {
|
|
1188
|
+
"content-type": "text/html; charset=utf-8",
|
|
1189
|
+
"cache-control": "no-store"
|
|
1190
|
+
});
|
|
1191
|
+
res.end(readClientHtml(opts.input));
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
if (url.pathname === "/__condor/harness.js") {
|
|
1195
|
+
res.writeHead(200, {
|
|
1196
|
+
"content-type": "text/javascript; charset=utf-8",
|
|
1197
|
+
"cache-control": "no-store"
|
|
1198
|
+
});
|
|
1199
|
+
res.end((0, import_node_fs3.readFileSync)(harnessScriptPath()));
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
if (url.pathname === "/") {
|
|
1203
|
+
const scenario = url.searchParams.get("scenario") ?? opts.scenario;
|
|
1204
|
+
res.writeHead(200, {
|
|
1205
|
+
"content-type": "text/html; charset=utf-8",
|
|
1206
|
+
"content-security-policy": CANVAS_CSP,
|
|
1207
|
+
"cache-control": "no-store"
|
|
1208
|
+
});
|
|
1209
|
+
const nativeVersion = url.searchParams.get("nativeVersion") ?? opts.nativeVersion;
|
|
1210
|
+
res.end(harnessPage(scenario in SCENARIOS ? scenario : opts.scenario, nativeVersion));
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
res.writeHead(404).end();
|
|
1214
|
+
} catch (err) {
|
|
1215
|
+
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
|
|
1216
|
+
res.end(String(err instanceof Error ? err.message : err));
|
|
1217
|
+
}
|
|
1218
|
+
});
|
|
1219
|
+
server.listen(opts.port, "127.0.0.1");
|
|
1220
|
+
return server;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
341
1223
|
// src/cli.ts
|
|
1224
|
+
function runDev(input, scenario, port, nativeVersion) {
|
|
1225
|
+
const server = startDevServer({ input, scenario, port, nativeVersion });
|
|
1226
|
+
server.on("listening", () => {
|
|
1227
|
+
const native = nativeVersion === void 0 ? "" : `&nativeVersion=${nativeVersion}`;
|
|
1228
|
+
console.log(
|
|
1229
|
+
`Condor Canvas dev harness: http://localhost:${port}/?scenario=${scenario}${native}`
|
|
1230
|
+
);
|
|
1231
|
+
console.log(` bundle: ${input} (re-read on every reload)`);
|
|
1232
|
+
console.log(" sandbox, shell policy and CSP: same as the app");
|
|
1233
|
+
console.log(" scenarios: default, cold-start, no-devices, shared-serial, offline");
|
|
1234
|
+
console.log(' OS back button: the "\u25C0 OS back" button at the top');
|
|
1235
|
+
console.log("\nA bundle is ready for a device only when it handles every scenario.");
|
|
1236
|
+
});
|
|
1237
|
+
server.on("error", (err) => {
|
|
1238
|
+
console.error(`dev: ${err.message}`);
|
|
1239
|
+
process.exit(1);
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
342
1242
|
function runBuild(htmlPath, outPath, maxBytes) {
|
|
343
1243
|
const result = buildBundle(htmlPath, maxBytes);
|
|
344
|
-
const resolvedOut = (0,
|
|
345
|
-
(0,
|
|
346
|
-
(0,
|
|
1244
|
+
const resolvedOut = (0, import_node_path4.resolve)(outPath);
|
|
1245
|
+
(0, import_node_fs4.mkdirSync)((0, import_node_path4.dirname)(resolvedOut), { recursive: true });
|
|
1246
|
+
(0, import_node_fs4.writeFileSync)(resolvedOut, result.zipBytes);
|
|
347
1247
|
const kb = (Buffer.byteLength(result.html) / 1024).toFixed(1);
|
|
348
1248
|
console.log(`Built ${outPath}`);
|
|
349
1249
|
console.log(` entry: index.html (${kb} KB self-contained)`);
|
|
@@ -391,12 +1291,42 @@ ${errors.length} error(s), ${warnings.length} warning(s).` + (result.size === nu
|
|
|
391
1291
|
);
|
|
392
1292
|
return 0;
|
|
393
1293
|
}
|
|
1294
|
+
async function runCanvasCommand(argv) {
|
|
1295
|
+
if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) {
|
|
1296
|
+
console.log(CANVAS_USAGE);
|
|
1297
|
+
process.exit(argv.length === 0 ? 1 : 0);
|
|
1298
|
+
}
|
|
1299
|
+
const parsed = parseCanvasArgs(argv, process.env);
|
|
1300
|
+
if ("error" in parsed) {
|
|
1301
|
+
console.error(parsed.error);
|
|
1302
|
+
console.error("Run `condor-mobile-bridge canvas --help`.");
|
|
1303
|
+
process.exit(1);
|
|
1304
|
+
}
|
|
1305
|
+
const code = await runCanvas(parsed, terminalIo(), {
|
|
1306
|
+
userAgent: `condor-mobile-bridge-cli/${version}`
|
|
1307
|
+
});
|
|
1308
|
+
closeTerminalIo();
|
|
1309
|
+
process.exit(code);
|
|
1310
|
+
}
|
|
394
1311
|
function main() {
|
|
1312
|
+
if (process.argv[2] === "canvas") {
|
|
1313
|
+
void runCanvasCommand(process.argv.slice(3));
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
395
1316
|
const args = parseArgs(process.argv.slice(2));
|
|
396
1317
|
if (args.help || !args.htmlPath) {
|
|
397
1318
|
console.log(USAGE);
|
|
398
1319
|
process.exit(args.help ? 0 : 1);
|
|
399
1320
|
}
|
|
1321
|
+
if (args.command === "dev") {
|
|
1322
|
+
try {
|
|
1323
|
+
runDev(args.htmlPath, args.scenario, args.port, args.nativeVersion);
|
|
1324
|
+
} catch (err) {
|
|
1325
|
+
console.error(`dev: ${err instanceof Error ? err.message : String(err)}`);
|
|
1326
|
+
process.exit(1);
|
|
1327
|
+
}
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
400
1330
|
if (args.command === "validate") {
|
|
401
1331
|
const result = validateBundle(args.htmlPath, args.maxBytes);
|
|
402
1332
|
process.exit(reportValidation(args.htmlPath, result, args.strict));
|