@monimesl/dot-simulator 0.1.0-alpha.15 → 0.1.0-alpha.16

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.
@@ -76,6 +76,49 @@
76
76
  "warning",
77
77
  "danger"
78
78
  ];
79
+ const TEXT_TONES = {
80
+ words: [...TONES, "onPrimary"],
81
+ or: "themeColor"
82
+ };
83
+ const BACKGROUNDS = {
84
+ words: [
85
+ "background",
86
+ "surface",
87
+ "primary",
88
+ "muted",
89
+ "info",
90
+ "success",
91
+ "warning",
92
+ "danger"
93
+ ],
94
+ or: "themeColor"
95
+ };
96
+ const BORDERS = [
97
+ "none",
98
+ "subtle",
99
+ "strong"
100
+ ];
101
+ const RADII = [
102
+ "none",
103
+ "small",
104
+ "medium",
105
+ "large",
106
+ "full"
107
+ ];
108
+ const LENGTH = {
109
+ words: ["fill"],
110
+ or: "number"
111
+ };
112
+ const BOX_PROPS = {
113
+ background: BACKGROUNDS,
114
+ border: BORDERS,
115
+ radius: RADII,
116
+ width: LENGTH,
117
+ height: LENGTH,
118
+ minHeight: "number",
119
+ maxWidth: "number",
120
+ aspect: ASPECTS
121
+ };
79
122
  const STATUS_TONES = [
80
123
  "default",
81
124
  "info",
@@ -462,11 +505,13 @@
462
505
  name: "Text",
463
506
  props: {
464
507
  variant: [
508
+ "display",
465
509
  "title",
510
+ "subtitle",
466
511
  "body",
467
512
  "caption"
468
513
  ],
469
- tone: TONES,
514
+ tone: TEXT_TONES,
470
515
  weight: [
471
516
  "regular",
472
517
  "medium",
@@ -478,6 +523,12 @@
478
523
  "end"
479
524
  ],
480
525
  maxLines: "number",
526
+ decoration: [
527
+ "none",
528
+ "underline",
529
+ "strikethrough"
530
+ ],
531
+ numeric: "boolean",
481
532
  ...PADDING_PROPS
482
533
  },
483
534
  children: "text",
@@ -528,6 +579,46 @@
528
579
  events: ["change"],
529
580
  children: "elements"
530
581
  });
582
+ const Radio = defineComponent({
583
+ name: "Radio",
584
+ props: {
585
+ value: "string",
586
+ label: "string",
587
+ disabled: "boolean"
588
+ },
589
+ children: "none"
590
+ });
591
+ const View = defineComponent({
592
+ name: "View",
593
+ props: {
594
+ gap: SPACES,
595
+ direction: ["column", "row"],
596
+ align: [
597
+ "stretch",
598
+ "start",
599
+ "center",
600
+ "end"
601
+ ],
602
+ justify: [
603
+ "start",
604
+ "center",
605
+ "end",
606
+ "between"
607
+ ],
608
+ wrap: "boolean",
609
+ grow: "boolean",
610
+ ...PADDING_PROPS,
611
+ variant: ["plain", "card"],
612
+ ...BOX_PROPS
613
+ },
614
+ events: ["tap"],
615
+ children: "elements",
616
+ overrides: [{
617
+ prop: "aspect",
618
+ ignores: "height",
619
+ because: "the aspect sets the height"
620
+ }]
621
+ });
531
622
  const builtinComponents = {
532
623
  AmountInput,
533
624
  Badge,
@@ -552,52 +643,35 @@
552
643
  Option,
553
644
  ProgressBar,
554
645
  QRCode,
555
- Radio: defineComponent({
556
- name: "Radio",
557
- props: {
558
- value: "string",
559
- label: "string",
560
- disabled: "boolean"
561
- },
562
- children: "none"
563
- }),
646
+ Radio,
564
647
  RadioGroup,
565
648
  Scroll,
566
649
  Select,
567
650
  Sheet,
568
651
  Skeleton,
569
652
  Spinner,
653
+ Stack: defineComponent({
654
+ name: "Stack",
655
+ props: {
656
+ radius: BOX_PROPS.radius,
657
+ width: BOX_PROPS.width,
658
+ height: BOX_PROPS.height,
659
+ aspect: BOX_PROPS.aspect
660
+ },
661
+ children: "elements",
662
+ overrides: [{
663
+ prop: "aspect",
664
+ ignores: "height",
665
+ because: "the aspect sets the height"
666
+ }]
667
+ }),
570
668
  Switch,
571
669
  Tab,
572
670
  Tabs,
573
671
  Text,
574
672
  TextArea,
575
673
  Toast,
576
- View: defineComponent({
577
- name: "View",
578
- props: {
579
- gap: SPACES,
580
- direction: ["column", "row"],
581
- align: [
582
- "stretch",
583
- "start",
584
- "center",
585
- "end"
586
- ],
587
- justify: [
588
- "start",
589
- "center",
590
- "end",
591
- "between"
592
- ],
593
- wrap: "boolean",
594
- grow: "boolean",
595
- ...PADDING_PROPS,
596
- variant: ["plain", "card"]
597
- },
598
- events: ["tap"],
599
- children: "elements"
600
- })
674
+ View
601
675
  };
602
676
  componentRegistry(Object.values(builtinComponents));
603
677
  var HostError = class extends Error {
@@ -678,7 +752,7 @@
678
752
  limit;
679
753
  #pages = [];
680
754
  #nextInstanceId = 1;
681
- constructor(limit = 8) {
755
+ constructor(limit = 16) {
682
756
  this.limit = limit;
683
757
  }
684
758
  get pages() {
@@ -779,7 +853,8 @@
779
853
  this.#succeed(id, await entry(call));
780
854
  return;
781
855
  }
782
- await this.#permissions.check(call.method, entry.permission, call.signal);
856
+ const permission = typeof entry.permission === "function" ? entry.permission(call.params) : entry.permission;
857
+ await this.#permissions.check(call.method, permission, call.signal);
783
858
  this.#succeed(id, await entry.handle(call));
784
859
  } catch (error) {
785
860
  this.#fail(id, error);
@@ -979,7 +1054,7 @@
979
1054
  }
980
1055
  //#endregion
981
1056
  //#region src/versions.ts
982
- const SIMULATOR_VERSIONS = { runtimeVersion: "0.1.0-alpha.15" };
1057
+ const SIMULATOR_VERSIONS = { runtimeVersion: "0.1.0-alpha.16" };
983
1058
  //#endregion
984
1059
  //#region src/paths.ts
985
1060
  const CLIENT_PATH = "/__dot/";
@@ -988,6 +1063,7 @@
988
1063
  const FILE_PATH = `${CLIENT_PATH}storage/file/`;
989
1064
  const CACHE_PATH = `${CLIENT_PATH}storage/cache/`;
990
1065
  const SECRET_PATH = `${CLIENT_PATH}storage/secret/`;
1066
+ const MEDIA_PATH = `${CLIENT_PATH}media/`;
991
1067
  const DOT_PATH = "/dot/";
992
1068
  const WEBVIEW_URL = `${DOT_PATH}ui/index.html`;
993
1069
  const MANIFEST_URL = `${DOT_PATH}manifest.json`;
@@ -1003,6 +1079,196 @@
1003
1079
  if ("error" in answer) throw new HostError(answer.error.code, answer.error.message);
1004
1080
  return answer.result;
1005
1081
  }
1082
+ function tooLargeMessage(name) {
1083
+ return `"${name}" is larger than 25 MB`;
1084
+ }
1085
+ //#endregion
1086
+ //#region src/client/dom.ts
1087
+ function element(selector) {
1088
+ const node = document.querySelector(selector);
1089
+ if (!node) throw new Error(`The simulator page is missing ${selector}`);
1090
+ return node;
1091
+ }
1092
+ function createElement(tag, className, text) {
1093
+ const node = document.createElement(tag);
1094
+ node.className = className;
1095
+ if (text !== void 0) node.textContent = text;
1096
+ return node;
1097
+ }
1098
+ //#endregion
1099
+ //#region src/client/dash/ui.ts
1100
+ const DEFAULT_ACTIONS = [{
1101
+ id: "ok",
1102
+ label: "OK"
1103
+ }];
1104
+ function alert(phone, options, signal, field) {
1105
+ return new Promise((resolve, reject) => {
1106
+ const layer = createElement("div", "dialog-layer");
1107
+ const dialog = createElement("div", "dialog");
1108
+ dialog.setAttribute("role", "alertdialog");
1109
+ const body = createElement("div", "dialog-body");
1110
+ body.append(createElement("div", "dialog-title", options.title));
1111
+ if (options.message) body.append(createElement("div", "dialog-message", options.message));
1112
+ if (field) body.append(field);
1113
+ const actions = options.actions ?? DEFAULT_ACTIONS;
1114
+ const buttons = createElement("div", actions.length === 2 ? "dialog-actions" : "dialog-actions stacked");
1115
+ for (const action of actions) {
1116
+ const button = createElement("button", action.role ?? "default", action.label);
1117
+ button.addEventListener("click", () => {
1118
+ layer.remove();
1119
+ resolve({ action: action.id });
1120
+ });
1121
+ buttons.append(button);
1122
+ }
1123
+ dialog.append(body, buttons);
1124
+ layer.append(dialog);
1125
+ field?.addEventListener("keydown", (event) => {
1126
+ if (event.key === "Enter") buttons.lastElementChild?.click();
1127
+ });
1128
+ signal.addEventListener("abort", () => {
1129
+ layer.remove();
1130
+ reject(new HostError("cancelled", "The alert was dismissed"));
1131
+ });
1132
+ phone.append(layer);
1133
+ field?.focus();
1134
+ });
1135
+ }
1136
+ //#endregion
1137
+ //#region src/client/dash/media.ts
1138
+ const MAX_PHOTO_SIDE = 2048;
1139
+ const JPEG_QUALITY = .85;
1140
+ function photoPermission(params) {
1141
+ return photoSource(params) === "camera" ? "camera" : "photoLibrary";
1142
+ }
1143
+ async function pickPhoto(phone, note, params, signal) {
1144
+ const source = photoSource(params);
1145
+ await allowPicker(phone, source === "camera" ? "Take a photo" : "Choose a photo", signal);
1146
+ const file = await chooseFile("image/*", signal);
1147
+ const jpeg = await toJpeg(file);
1148
+ const name = jpegName(file.name);
1149
+ note(`photo (${source}): ${name}`);
1150
+ return keepOnDevServer("photo", {
1151
+ name,
1152
+ data: await base64(jpeg)
1153
+ }, signal);
1154
+ }
1155
+ async function pickFile(phone, note, params, signal) {
1156
+ const types = fileTypes(params);
1157
+ await allowPicker(phone, "Choose a file", signal);
1158
+ const file = await chooseFile(types.join(","), signal);
1159
+ if (file.size > 26214400) throw new HostError("invalid", tooLargeMessage(file.name));
1160
+ note(`file: ${file.name}`);
1161
+ const contentType = file.type || "application/octet-stream";
1162
+ return keepOnDevServer("file", {
1163
+ name: file.name,
1164
+ contentType,
1165
+ data: await base64(file)
1166
+ }, signal);
1167
+ }
1168
+ async function scanCode(phone, note, signal) {
1169
+ const field = createElement("input", "dialog-input");
1170
+ field.placeholder = "https://monime.io/pay/…";
1171
+ const { action } = await alert(phone, {
1172
+ title: "Scan a QR code",
1173
+ message: "Type or paste the text the code holds.",
1174
+ actions: [{
1175
+ id: "cancel",
1176
+ label: "Cancel",
1177
+ role: "cancel"
1178
+ }, {
1179
+ id: "scan",
1180
+ label: "Scan"
1181
+ }]
1182
+ }, signal, field);
1183
+ const text = field.value.trim();
1184
+ if (action !== "scan" || text === "") throw new HostError("cancelled", "No code was scanned");
1185
+ note(`scanned: ${text}`);
1186
+ return {
1187
+ text,
1188
+ format: "qr"
1189
+ };
1190
+ }
1191
+ function jpegName(fileName) {
1192
+ const dot = fileName.lastIndexOf(".");
1193
+ return `${(dot > 0 ? fileName.slice(0, dot) : fileName) || "photo"}.jpg`;
1194
+ }
1195
+ function photoSource(params) {
1196
+ const source = params?.source;
1197
+ if (source !== "library" && source !== "camera") throw new HostError("invalid", "\"source\" must be \"library\" or \"camera\"");
1198
+ return source;
1199
+ }
1200
+ function fileTypes(params) {
1201
+ const types = params?.types ?? [];
1202
+ if (!Array.isArray(types) || types.some((type) => typeof type !== "string" || !type.includes("/"))) throw new HostError("invalid", "\"types\" must list content types, such as \"application/pdf\" or \"image/*\"");
1203
+ return types;
1204
+ }
1205
+ async function allowPicker(phone, title, signal) {
1206
+ if (navigator.userActivation.isActive) return;
1207
+ const { action } = await alert(phone, {
1208
+ title,
1209
+ message: "The simulator picks a file from this computer.",
1210
+ actions: [{
1211
+ id: "cancel",
1212
+ label: "Cancel",
1213
+ role: "cancel"
1214
+ }, {
1215
+ id: "choose",
1216
+ label: "Choose"
1217
+ }]
1218
+ }, signal);
1219
+ if (action !== "choose") throw new HostError("cancelled", "Nothing was chosen");
1220
+ }
1221
+ function chooseFile(accept, signal) {
1222
+ return new Promise((resolve, reject) => {
1223
+ const input = document.createElement("input");
1224
+ input.type = "file";
1225
+ input.accept = accept;
1226
+ input.addEventListener("change", () => {
1227
+ const file = input.files?.[0];
1228
+ if (file) resolve(file);
1229
+ else reject(new HostError("cancelled", "Nothing was chosen"));
1230
+ });
1231
+ input.addEventListener("cancel", () => {
1232
+ reject(new HostError("cancelled", "Nothing was chosen"));
1233
+ });
1234
+ signal.addEventListener("abort", () => {
1235
+ reject(new HostError("cancelled", "The call ended before anything was chosen"));
1236
+ });
1237
+ input.click();
1238
+ });
1239
+ }
1240
+ async function toJpeg(file) {
1241
+ let image;
1242
+ try {
1243
+ image = await createImageBitmap(file, { imageOrientation: "from-image" });
1244
+ } catch {
1245
+ throw new HostError("failed", `"${file.name}" isn't a photo the simulator can read`);
1246
+ }
1247
+ const scale = Math.min(1, MAX_PHOTO_SIDE / Math.max(image.width, image.height));
1248
+ const canvas = new OffscreenCanvas(Math.round(image.width * scale), Math.round(image.height * scale));
1249
+ const context = canvas.getContext("2d");
1250
+ if (!context) throw new HostError("failed", "The browser couldn't draw the photo");
1251
+ context.drawImage(image, 0, 0, canvas.width, canvas.height);
1252
+ image.close();
1253
+ return canvas.convertToBlob({
1254
+ type: "image/jpeg",
1255
+ quality: JPEG_QUALITY
1256
+ });
1257
+ }
1258
+ function keepOnDevServer(kind, params, signal) {
1259
+ return callDevServer(`${MEDIA_PATH}${kind}`, params, signal);
1260
+ }
1261
+ function base64(blob) {
1262
+ return new Promise((resolve, reject) => {
1263
+ const reader = new FileReader();
1264
+ reader.addEventListener("load", () => {
1265
+ const url = reader.result;
1266
+ resolve(url.slice(url.indexOf(",") + 1));
1267
+ });
1268
+ reader.addEventListener("error", () => reject(new HostError("failed", "The file couldn't be read")));
1269
+ reader.readAsDataURL(blob);
1270
+ });
1271
+ }
1006
1272
  //#endregion
1007
1273
  //#region src/client/dash/system.ts
1008
1274
  const SHAKE_DURATION_MS = 300;
@@ -1059,52 +1325,6 @@
1059
1325
  });
1060
1326
  }
1061
1327
  //#endregion
1062
- //#region src/client/dom.ts
1063
- function element(selector) {
1064
- const node = document.querySelector(selector);
1065
- if (!node) throw new Error(`The simulator page is missing ${selector}`);
1066
- return node;
1067
- }
1068
- function createElement(tag, className, text) {
1069
- const node = document.createElement(tag);
1070
- node.className = className;
1071
- if (text !== void 0) node.textContent = text;
1072
- return node;
1073
- }
1074
- //#endregion
1075
- //#region src/client/dash/ui.ts
1076
- const DEFAULT_ACTIONS = [{
1077
- id: "ok",
1078
- label: "OK"
1079
- }];
1080
- function alert(phone, options, signal) {
1081
- return new Promise((resolve, reject) => {
1082
- const layer = createElement("div", "dialog-layer");
1083
- const dialog = createElement("div", "dialog");
1084
- dialog.setAttribute("role", "alertdialog");
1085
- const body = createElement("div", "dialog-body");
1086
- body.append(createElement("div", "dialog-title", options.title));
1087
- if (options.message) body.append(createElement("div", "dialog-message", options.message));
1088
- const actions = options.actions ?? DEFAULT_ACTIONS;
1089
- const buttons = createElement("div", actions.length === 2 ? "dialog-actions" : "dialog-actions stacked");
1090
- for (const action of actions) {
1091
- const button = createElement("button", action.role ?? "default", action.label);
1092
- button.addEventListener("click", () => {
1093
- layer.remove();
1094
- resolve({ action: action.id });
1095
- });
1096
- buttons.append(button);
1097
- }
1098
- dialog.append(body, buttons);
1099
- layer.append(dialog);
1100
- signal.addEventListener("abort", () => {
1101
- layer.remove();
1102
- reject(new HostError("cancelled", "The alert was dismissed"));
1103
- });
1104
- phone.append(layer);
1105
- });
1106
- }
1107
- //#endregion
1108
1328
  //#region src/client/dash/index.ts
1109
1329
  const HTTP_METHODS = [
1110
1330
  "request",
@@ -1147,6 +1367,18 @@
1147
1367
  "dash.system.share": ({ params }) => share(note, params),
1148
1368
  "dash.system.openUrl": ({ params }) => openUrl(note, params.url),
1149
1369
  "dash.system.delay": ({ params, signal }) => delay(params.milliseconds, signal),
1370
+ "dash.media.pickPhoto": {
1371
+ permission: photoPermission,
1372
+ handle: ({ params, signal }) => pickPhoto(phone, note, params, signal)
1373
+ },
1374
+ "dash.media.scanCode": {
1375
+ permission: "camera",
1376
+ handle: ({ signal }) => scanCode(phone, note, signal)
1377
+ },
1378
+ "dash.storage.file.pick": {
1379
+ permission: "files",
1380
+ handle: ({ params, signal }) => pickFile(phone, note, params, signal)
1381
+ },
1150
1382
  ...devServerHandlers("dash.network.http", HTTP_PATH, HTTP_METHODS),
1151
1383
  ...devServerHandlers("dash.storage.file", FILE_PATH, FILE_OPERATIONS),
1152
1384
  ...devServerHandlers("dash.storage.cache", CACHE_PATH, CACHE_OPERATIONS),
@@ -1158,7 +1390,12 @@
1158
1390
  }
1159
1391
  //#endregion
1160
1392
  //#region src/client/dash/permissions.ts
1161
- const PERMISSION_SUMMARIES = { account: "see your Dash account: your name, and your email or phone" };
1393
+ const PERMISSION_SUMMARIES = {
1394
+ account: "see your Dash account: your name, and your email or phone",
1395
+ camera: "use your camera to take photos and scan codes",
1396
+ photoLibrary: "use photos you choose from your library",
1397
+ files: "use files you choose from your phone"
1398
+ };
1162
1399
  async function askPermission(phone, note, request, signal) {
1163
1400
  const { action } = await alert(phone, {
1164
1401
  title: `Allow “${request.dotName}” to ${PERMISSION_SUMMARIES[request.permission]}?`,
@@ -1205,8 +1442,11 @@
1205
1442
  //#endregion
1206
1443
  //#region src/client/simulator.ts
1207
1444
  const MAX_LOG_ENTRIES = 500;
1445
+ const MESSAGES_SHOWN_KEY = "dot-simulator-messages";
1208
1446
  const screen = element("#screen");
1209
1447
  const log = element("#log");
1448
+ const messagesPanel = element("#messages-panel");
1449
+ const messagesButton = element("#messages");
1210
1450
  const errorBanner = element("#error");
1211
1451
  const phone = element(".phone");
1212
1452
  const schemeSelect = element("#scheme");
@@ -1369,14 +1609,34 @@
1369
1609
  hotReload();
1370
1610
  });
1371
1611
  events.addEventListener("build-error", (event) => showError(String(event.data)));
1612
+ function showMessages(shown) {
1613
+ messagesPanel.hidden = !shown;
1614
+ messagesButton.setAttribute("aria-pressed", String(shown));
1615
+ }
1616
+ function toggleMessages() {
1617
+ const shown = messagesPanel.hidden;
1618
+ showMessages(shown);
1619
+ try {
1620
+ localStorage.setItem(MESSAGES_SHOWN_KEY, String(shown));
1621
+ } catch {}
1622
+ }
1623
+ function messagesShownLastTime() {
1624
+ try {
1625
+ return localStorage.getItem(MESSAGES_SHOWN_KEY) !== "false";
1626
+ } catch {
1627
+ return true;
1628
+ }
1629
+ }
1372
1630
  element("#reload").addEventListener("click", () => restart());
1373
1631
  element("#back").addEventListener("click", goBack);
1374
1632
  navigationBack.addEventListener("click", goBack);
1375
1633
  element("#clear").addEventListener("click", () => log.replaceChildren());
1634
+ messagesButton.addEventListener("click", toggleMessages);
1376
1635
  schemeSelect.addEventListener("change", () => changeScheme(schemeSelect.value));
1377
1636
  browserDark.addEventListener("change", () => changeScheme(browserScheme()));
1378
1637
  document.addEventListener("visibilitychange", syncSystemContext);
1379
1638
  changeScheme(browserScheme());
1639
+ showMessages(messagesShownLastTime());
1380
1640
  restart();
1381
1641
  //#endregion
1382
1642
  })();