@monimesl/dot-simulator 0.1.0-alpha.2
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/README.md +20 -0
- package/dist/client/service-bridge.js +123 -0
- package/dist/client/service-bridge.js.map +1 -0
- package/dist/client/simulator.js +690 -0
- package/dist/client/simulator.js.map +1 -0
- package/dist/client/webview-bridge.js +32 -0
- package/dist/client/webview-bridge.js.map +1 -0
- package/dist/node/index.d.ts +93 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +493 -0
- package/dist/node/index.js.map +1 -0
- package/package.json +48 -0
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
(function() {
|
|
2
|
+
//#region ../../core/dist/index.js
|
|
3
|
+
function defineComponent(manifest) {
|
|
4
|
+
return {
|
|
5
|
+
props: {},
|
|
6
|
+
events: [],
|
|
7
|
+
...manifest
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
function componentRegistry(manifests) {
|
|
11
|
+
const registry = {};
|
|
12
|
+
for (const manifest of manifests) {
|
|
13
|
+
if (manifest.name in registry) throw new Error(`Component <${manifest.name}> is defined twice`);
|
|
14
|
+
registry[manifest.name] = manifest;
|
|
15
|
+
}
|
|
16
|
+
return registry;
|
|
17
|
+
}
|
|
18
|
+
const builtinComponents = {
|
|
19
|
+
Button: defineComponent({
|
|
20
|
+
name: "Button",
|
|
21
|
+
props: {
|
|
22
|
+
variant: ["primary", "secondary"],
|
|
23
|
+
disabled: "boolean"
|
|
24
|
+
},
|
|
25
|
+
events: ["click"],
|
|
26
|
+
children: "text"
|
|
27
|
+
}),
|
|
28
|
+
Image: defineComponent({
|
|
29
|
+
name: "Image",
|
|
30
|
+
props: {
|
|
31
|
+
src: "string",
|
|
32
|
+
alt: "string",
|
|
33
|
+
width: "number",
|
|
34
|
+
height: "number"
|
|
35
|
+
},
|
|
36
|
+
events: ["load", "error"],
|
|
37
|
+
children: "none"
|
|
38
|
+
}),
|
|
39
|
+
Input: defineComponent({
|
|
40
|
+
name: "Input",
|
|
41
|
+
props: {
|
|
42
|
+
value: "string",
|
|
43
|
+
placeholder: "string",
|
|
44
|
+
label: "string",
|
|
45
|
+
type: [
|
|
46
|
+
"text",
|
|
47
|
+
"number",
|
|
48
|
+
"email",
|
|
49
|
+
"phone",
|
|
50
|
+
"password"
|
|
51
|
+
],
|
|
52
|
+
maxLength: "number",
|
|
53
|
+
disabled: "boolean"
|
|
54
|
+
},
|
|
55
|
+
events: ["change", "submit"],
|
|
56
|
+
children: "none"
|
|
57
|
+
}),
|
|
58
|
+
Text: defineComponent({
|
|
59
|
+
name: "Text",
|
|
60
|
+
props: { variant: [
|
|
61
|
+
"title",
|
|
62
|
+
"body",
|
|
63
|
+
"caption"
|
|
64
|
+
] },
|
|
65
|
+
children: "text",
|
|
66
|
+
inline: true
|
|
67
|
+
}),
|
|
68
|
+
View: defineComponent({
|
|
69
|
+
name: "View",
|
|
70
|
+
props: {
|
|
71
|
+
direction: ["column", "row"],
|
|
72
|
+
gap: [
|
|
73
|
+
"none",
|
|
74
|
+
"small",
|
|
75
|
+
"medium",
|
|
76
|
+
"large"
|
|
77
|
+
],
|
|
78
|
+
align: [
|
|
79
|
+
"stretch",
|
|
80
|
+
"start",
|
|
81
|
+
"center",
|
|
82
|
+
"end"
|
|
83
|
+
],
|
|
84
|
+
variant: ["plain", "card"]
|
|
85
|
+
},
|
|
86
|
+
events: ["tap"],
|
|
87
|
+
children: "elements"
|
|
88
|
+
})
|
|
89
|
+
};
|
|
90
|
+
componentRegistry(Object.values(builtinComponents));
|
|
91
|
+
var HostError = class extends Error {
|
|
92
|
+
name = "HostError";
|
|
93
|
+
code;
|
|
94
|
+
constructor(code, message) {
|
|
95
|
+
super(message);
|
|
96
|
+
this.code = code;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
|
|
100
|
+
function majorVersion(version) {
|
|
101
|
+
return parseVersion(version).major;
|
|
102
|
+
}
|
|
103
|
+
function isVersionAtLeast(version, minimum) {
|
|
104
|
+
return compareVersions(version, minimum) >= 0;
|
|
105
|
+
}
|
|
106
|
+
function compareVersions(left, right) {
|
|
107
|
+
const leftVersion = parseVersion(left);
|
|
108
|
+
const rightVersion = parseVersion(right);
|
|
109
|
+
const differences = [
|
|
110
|
+
leftVersion.major - rightVersion.major,
|
|
111
|
+
leftVersion.minor - rightVersion.minor,
|
|
112
|
+
leftVersion.patch - rightVersion.patch
|
|
113
|
+
];
|
|
114
|
+
for (const difference of differences) if (difference !== 0) return difference;
|
|
115
|
+
return comparePrereleases(leftVersion.prerelease, rightVersion.prerelease);
|
|
116
|
+
}
|
|
117
|
+
function parseVersion(version) {
|
|
118
|
+
const match = SEMVER_PATTERN.exec(version);
|
|
119
|
+
if (!match) throw new Error(`"${version}" is not a semantic version`);
|
|
120
|
+
const [, major, minor, patch, prerelease] = match;
|
|
121
|
+
return {
|
|
122
|
+
major: Number(major),
|
|
123
|
+
minor: Number(minor),
|
|
124
|
+
patch: Number(patch),
|
|
125
|
+
prerelease: prerelease === void 0 ? [] : prerelease.split(".")
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function comparePrereleases(left, right) {
|
|
129
|
+
if (left.length === 0 || right.length === 0) return right.length - left.length;
|
|
130
|
+
const shared = Math.min(left.length, right.length);
|
|
131
|
+
for (let index = 0; index < shared; index++) {
|
|
132
|
+
const order = compareIdentifiers(left[index], right[index]);
|
|
133
|
+
if (order !== 0) return order;
|
|
134
|
+
}
|
|
135
|
+
return left.length - right.length;
|
|
136
|
+
}
|
|
137
|
+
function compareIdentifiers(left, right) {
|
|
138
|
+
const leftIsNumber = /^\d+$/.test(left);
|
|
139
|
+
const rightIsNumber = /^\d+$/.test(right);
|
|
140
|
+
if (leftIsNumber && rightIsNumber) return Number(left) - Number(right);
|
|
141
|
+
if (leftIsNumber) return -1;
|
|
142
|
+
if (rightIsNumber) return 1;
|
|
143
|
+
if (left < right) return -1;
|
|
144
|
+
if (left > right) return 1;
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
function isReactCompatible(hostVersion, dotVersion) {
|
|
148
|
+
return majorVersion(hostVersion) === majorVersion(dotVersion) && isVersionAtLeast(hostVersion, dotVersion);
|
|
149
|
+
}
|
|
150
|
+
function createAdaptorTransport(adapter) {
|
|
151
|
+
return {
|
|
152
|
+
post: (message) => adapter.postMessage(message),
|
|
153
|
+
listen(listener) {
|
|
154
|
+
const onMessage = (event) => listener(event.data);
|
|
155
|
+
adapter.addEventListener("message", onMessage);
|
|
156
|
+
return () => adapter.removeEventListener("message", onMessage);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
var NavigationStack = class {
|
|
161
|
+
limit;
|
|
162
|
+
#pages = [];
|
|
163
|
+
#nextInstanceId = 1;
|
|
164
|
+
constructor(limit = 8) {
|
|
165
|
+
this.limit = limit;
|
|
166
|
+
}
|
|
167
|
+
get pages() {
|
|
168
|
+
return this.#pages;
|
|
169
|
+
}
|
|
170
|
+
get top() {
|
|
171
|
+
return this.#pages.at(-1);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Applies a navigation, or refuses it and returns false: a push onto a full stack, or going back
|
|
175
|
+
* from the first page (which the host handles itself, e.g. by closing the dot).
|
|
176
|
+
*/
|
|
177
|
+
apply(navigation) {
|
|
178
|
+
switch (navigation.action) {
|
|
179
|
+
case "push":
|
|
180
|
+
if (this.#pages.length >= this.limit) return false;
|
|
181
|
+
break;
|
|
182
|
+
case "replace":
|
|
183
|
+
this.#pages.pop();
|
|
184
|
+
break;
|
|
185
|
+
case "reset":
|
|
186
|
+
this.#pages = [];
|
|
187
|
+
break;
|
|
188
|
+
case "back": {
|
|
189
|
+
const count = Math.min(navigation.delta, this.#pages.length - 1);
|
|
190
|
+
if (count < 1) return false;
|
|
191
|
+
this.#pages = this.#pages.slice(0, -count);
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const { page, options } = navigation;
|
|
196
|
+
this.#pages = [...this.#pages, {
|
|
197
|
+
instanceId: this.#nextInstanceId++,
|
|
198
|
+
page,
|
|
199
|
+
options
|
|
200
|
+
}];
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
//#endregion
|
|
205
|
+
//#region src/compatibility.ts
|
|
206
|
+
var IncompatibleDotError = class extends Error {
|
|
207
|
+
name = "IncompatibleDotError";
|
|
208
|
+
reason;
|
|
209
|
+
constructor(reason, message) {
|
|
210
|
+
super(message);
|
|
211
|
+
this.reason = reason;
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
function checkCompatibility(manifest, { dashVersion, minRuntimeVersion, reactVersions }) {
|
|
215
|
+
if (!isVersionAtLeast(manifest.runtime, minRuntimeVersion)) throw new IncompatibleDotError("runtimeTooOld", `This dot was built with Dot runtime ${manifest.runtime}, but this host runs ${minRuntimeVersion} or newer. Rebuild it with a newer @monimesl/dot-runtime.`);
|
|
216
|
+
const minDashVersion = manifest.dash?.minVersion;
|
|
217
|
+
if (minDashVersion !== void 0 && !isVersionAtLeast(dashVersion, minDashVersion)) throw new IncompatibleDotError("dashTooOld", `This dot needs Dash ${minDashVersion} or newer, and this is Dash ${dashVersion}.`);
|
|
218
|
+
if (reactVersions !== void 0 && !reactVersions.some((version) => isReactCompatible(version, manifest.react))) throw new IncompatibleDotError("reactUnavailable", `This dot needs React ${manifest.react} or a newer ${majorVersion(manifest.react)}.x, which this host doesn't ship.`);
|
|
219
|
+
}
|
|
220
|
+
//#endregion
|
|
221
|
+
//#region src/host-calls.ts
|
|
222
|
+
const UI_METHOD_PREFIX = "dash.ui.";
|
|
223
|
+
var HostCallRunner = class {
|
|
224
|
+
#handlers;
|
|
225
|
+
#permissions;
|
|
226
|
+
#answer;
|
|
227
|
+
#running = /* @__PURE__ */ new Map();
|
|
228
|
+
constructor(handlers, permissions, answer) {
|
|
229
|
+
this.#handlers = handlers;
|
|
230
|
+
this.#permissions = permissions;
|
|
231
|
+
this.#answer = answer;
|
|
232
|
+
}
|
|
233
|
+
run({ id, method, params, timeout }) {
|
|
234
|
+
const entry = this.#handlers[method];
|
|
235
|
+
if (!entry) {
|
|
236
|
+
this.#answer({
|
|
237
|
+
id,
|
|
238
|
+
error: {
|
|
239
|
+
code: "unsupported",
|
|
240
|
+
message: `This host has no "${method}"`
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const call = {
|
|
246
|
+
method,
|
|
247
|
+
controller: new AbortController()
|
|
248
|
+
};
|
|
249
|
+
if (timeout !== void 0) call.timer = setTimeout(() => {
|
|
250
|
+
this.#fail(id, new HostError("timeout", `"${method}" took longer than ${timeout}ms`));
|
|
251
|
+
}, timeout);
|
|
252
|
+
this.#running.set(id, call);
|
|
253
|
+
this.#invoke(id, entry, {
|
|
254
|
+
method,
|
|
255
|
+
params,
|
|
256
|
+
signal: call.controller.signal
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
cancel(id) {
|
|
260
|
+
this.#end(id)?.controller.abort();
|
|
261
|
+
}
|
|
262
|
+
dismissUI() {
|
|
263
|
+
for (const [id, call] of this.#running) if (call.method.startsWith(UI_METHOD_PREFIX)) this.#fail(id, new HostError("cancelled", "The page it was shown on is no longer on top"));
|
|
264
|
+
}
|
|
265
|
+
async #invoke(id, entry, call) {
|
|
266
|
+
try {
|
|
267
|
+
if (typeof entry === "function") {
|
|
268
|
+
this.#succeed(id, await entry(call));
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
await this.#permissions.check(call.method, entry.permission, call.signal);
|
|
272
|
+
this.#succeed(id, await entry.handle(call));
|
|
273
|
+
} catch (error) {
|
|
274
|
+
this.#fail(id, error);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
#succeed(id, value) {
|
|
278
|
+
if (this.#end(id)) this.#answer({
|
|
279
|
+
id,
|
|
280
|
+
value
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
#fail(id, error) {
|
|
284
|
+
const call = this.#end(id);
|
|
285
|
+
if (!call) return;
|
|
286
|
+
call.controller.abort();
|
|
287
|
+
this.#answer({
|
|
288
|
+
id,
|
|
289
|
+
error: describeError(error)
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
#end(id) {
|
|
293
|
+
const call = this.#running.get(id);
|
|
294
|
+
if (call) {
|
|
295
|
+
this.#running.delete(id);
|
|
296
|
+
clearTimeout(call.timer);
|
|
297
|
+
}
|
|
298
|
+
return call;
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
function describeError(error) {
|
|
302
|
+
if (error instanceof HostError) return {
|
|
303
|
+
code: error.code,
|
|
304
|
+
message: error.message
|
|
305
|
+
};
|
|
306
|
+
return {
|
|
307
|
+
code: "failed",
|
|
308
|
+
message: error instanceof Error ? error.message : String(error)
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
//#endregion
|
|
312
|
+
//#region src/permissions.ts
|
|
313
|
+
var PermissionGate = class {
|
|
314
|
+
#dotName;
|
|
315
|
+
#declared;
|
|
316
|
+
#ask;
|
|
317
|
+
#answers = /* @__PURE__ */ new Map();
|
|
318
|
+
constructor(dotName, declared, ask) {
|
|
319
|
+
this.#dotName = dotName;
|
|
320
|
+
this.#declared = declared;
|
|
321
|
+
this.#ask = ask;
|
|
322
|
+
}
|
|
323
|
+
async check(method, permission, signal) {
|
|
324
|
+
const reason = this.#declared[permission];
|
|
325
|
+
if (reason === void 0) throw new HostError("denied", `"${method}" needs the "${permission}" permission. Add it to dot.json's permissions, with the reason Dash shows the person.`);
|
|
326
|
+
let allowed = this.#answers.get(permission);
|
|
327
|
+
if (allowed === void 0) {
|
|
328
|
+
allowed = await this.#ask({
|
|
329
|
+
dotName: this.#dotName,
|
|
330
|
+
permission,
|
|
331
|
+
reason
|
|
332
|
+
}, signal);
|
|
333
|
+
this.#answers.set(permission, allowed);
|
|
334
|
+
}
|
|
335
|
+
if (!allowed) throw new HostError("denied", `The person did not allow "${permission}"`);
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
//#endregion
|
|
339
|
+
//#region src/host.ts
|
|
340
|
+
function startHost({ logic, ui, manifest, dashVersion, minRuntimeVersion, systemContext, handlers = {}, askPermission, observe }) {
|
|
341
|
+
checkCompatibility(manifest, {
|
|
342
|
+
dashVersion,
|
|
343
|
+
minRuntimeVersion
|
|
344
|
+
});
|
|
345
|
+
const stack = new NavigationStack();
|
|
346
|
+
let context = systemContext;
|
|
347
|
+
const calls = new HostCallRunner(handlers, new PermissionGate(manifest.name, manifest.permissions ?? {}, askPermission), (result) => {
|
|
348
|
+
const message = {
|
|
349
|
+
type: "hostResult",
|
|
350
|
+
payload: result
|
|
351
|
+
};
|
|
352
|
+
observe?.("fromHost", message);
|
|
353
|
+
logic.post(message);
|
|
354
|
+
});
|
|
355
|
+
const broadcast = (message) => {
|
|
356
|
+
observe?.("fromHost", message);
|
|
357
|
+
ui.post(message);
|
|
358
|
+
logic.post(message);
|
|
359
|
+
};
|
|
360
|
+
const syncStack = () => {
|
|
361
|
+
const message = {
|
|
362
|
+
type: "syncStack",
|
|
363
|
+
payload: {
|
|
364
|
+
pages: stack.pages,
|
|
365
|
+
limit: stack.limit
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
broadcast(message);
|
|
369
|
+
};
|
|
370
|
+
const syncSystemContext = () => {
|
|
371
|
+
broadcast({
|
|
372
|
+
type: "syncSystemContext",
|
|
373
|
+
payload: context
|
|
374
|
+
});
|
|
375
|
+
};
|
|
376
|
+
const navigate = (navigation) => {
|
|
377
|
+
if (navigation.action !== "back" && !manifest.pages.includes(navigation.page)) return false;
|
|
378
|
+
const previousTop = stack.top;
|
|
379
|
+
if (!stack.apply(navigation)) return false;
|
|
380
|
+
syncStack();
|
|
381
|
+
if (stack.top !== previousTop) calls.dismissUI();
|
|
382
|
+
return true;
|
|
383
|
+
};
|
|
384
|
+
const navigateFromPage = ({ from, ...navigation }) => {
|
|
385
|
+
if (from === stack.top?.instanceId) navigate(navigation);
|
|
386
|
+
};
|
|
387
|
+
const hostMessageHandlers = /* @__PURE__ */ new Map([
|
|
388
|
+
["navigate", (payload) => navigateFromPage(payload)],
|
|
389
|
+
["hostCall", (payload) => calls.run(payload)],
|
|
390
|
+
["hostCancel", (payload) => calls.cancel(payload.id)]
|
|
391
|
+
]);
|
|
392
|
+
const stopService = logic.listen((raw) => {
|
|
393
|
+
const message = raw;
|
|
394
|
+
const handleHostMessage = hostMessageHandlers.get(message.type);
|
|
395
|
+
if (handleHostMessage) {
|
|
396
|
+
observe?.("toHost", message);
|
|
397
|
+
handleHostMessage(message.payload);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
observe?.("toWebview", message);
|
|
401
|
+
ui.post(message);
|
|
402
|
+
});
|
|
403
|
+
const stopWebview = ui.listen((raw) => {
|
|
404
|
+
const message = raw;
|
|
405
|
+
observe?.("toService", message);
|
|
406
|
+
logic.post(message);
|
|
407
|
+
if (message.type === "connect") {
|
|
408
|
+
syncSystemContext();
|
|
409
|
+
navigate({
|
|
410
|
+
action: "reset",
|
|
411
|
+
page: manifest.entry,
|
|
412
|
+
options: {}
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
return {
|
|
417
|
+
back: () => navigate({
|
|
418
|
+
action: "back",
|
|
419
|
+
delta: 1
|
|
420
|
+
}),
|
|
421
|
+
setSystemContext: (next) => {
|
|
422
|
+
context = next;
|
|
423
|
+
syncSystemContext();
|
|
424
|
+
},
|
|
425
|
+
stop: () => {
|
|
426
|
+
stopService();
|
|
427
|
+
stopWebview();
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
const SIMULATOR_VERSIONS = {
|
|
432
|
+
dashVersion: "1.0.0",
|
|
433
|
+
minRuntimeVersion: "0.1.0-alpha.1"
|
|
434
|
+
};
|
|
435
|
+
//#endregion
|
|
436
|
+
//#region src/paths.ts
|
|
437
|
+
const CLIENT_PATH = "/__dot/";
|
|
438
|
+
const EVENTS_PATH = `${CLIENT_PATH}events`;
|
|
439
|
+
const DOT_PATH = "/dot/";
|
|
440
|
+
const WEBVIEW_URL = `${DOT_PATH}ui/index.html`;
|
|
441
|
+
const MANIFEST_URL = `${DOT_PATH}manifest.json`;
|
|
442
|
+
//#endregion
|
|
443
|
+
//#region src/client/dash/system.ts
|
|
444
|
+
const SHAKE_DURATION_MS = 300;
|
|
445
|
+
const SHAKE = [
|
|
446
|
+
{ transform: "translateX(0)" },
|
|
447
|
+
{ transform: "translateX(-5px)" },
|
|
448
|
+
{ transform: "translateX(5px)" },
|
|
449
|
+
{ transform: "translateX(-3px)" },
|
|
450
|
+
{ transform: "translateX(3px)" },
|
|
451
|
+
{ transform: "translateX(0)" }
|
|
452
|
+
];
|
|
453
|
+
async function vibrate(phone, note, options) {
|
|
454
|
+
note(`vibrated: ${options.style}`);
|
|
455
|
+
await phone.animate(SHAKE, { duration: SHAKE_DURATION_MS }).finished;
|
|
456
|
+
}
|
|
457
|
+
function torch(phone, note, state) {
|
|
458
|
+
phone.dataset.torch = state;
|
|
459
|
+
note(`torch: ${state}`);
|
|
460
|
+
}
|
|
461
|
+
//#endregion
|
|
462
|
+
//#region src/client/dom.ts
|
|
463
|
+
function element(selector) {
|
|
464
|
+
const node = document.querySelector(selector);
|
|
465
|
+
if (!node) throw new Error(`Simulator page is missing ${selector}`);
|
|
466
|
+
return node;
|
|
467
|
+
}
|
|
468
|
+
function createElement(tag, className, text) {
|
|
469
|
+
const node = document.createElement(tag);
|
|
470
|
+
node.className = className;
|
|
471
|
+
if (text !== void 0) node.textContent = text;
|
|
472
|
+
return node;
|
|
473
|
+
}
|
|
474
|
+
//#endregion
|
|
475
|
+
//#region src/client/dash/ui.ts
|
|
476
|
+
const DEFAULT_ACTIONS = [{
|
|
477
|
+
id: "ok",
|
|
478
|
+
label: "OK"
|
|
479
|
+
}];
|
|
480
|
+
function alert(phone, options, signal) {
|
|
481
|
+
return new Promise((resolve, reject) => {
|
|
482
|
+
const layer = createElement("div", "dialog-layer");
|
|
483
|
+
const dialog = createElement("div", "dialog");
|
|
484
|
+
dialog.setAttribute("role", "alertdialog");
|
|
485
|
+
const body = createElement("div", "dialog-body");
|
|
486
|
+
body.append(createElement("div", "dialog-title", options.title));
|
|
487
|
+
if (options.message) body.append(createElement("div", "dialog-message", options.message));
|
|
488
|
+
const actions = options.actions ?? DEFAULT_ACTIONS;
|
|
489
|
+
const buttons = createElement("div", actions.length === 2 ? "dialog-actions" : "dialog-actions stacked");
|
|
490
|
+
for (const action of actions) {
|
|
491
|
+
const button = createElement("button", action.role ?? "default", action.label);
|
|
492
|
+
button.addEventListener("click", () => {
|
|
493
|
+
layer.remove();
|
|
494
|
+
resolve({ action: action.id });
|
|
495
|
+
});
|
|
496
|
+
buttons.append(button);
|
|
497
|
+
}
|
|
498
|
+
dialog.append(body, buttons);
|
|
499
|
+
layer.append(dialog);
|
|
500
|
+
signal.addEventListener("abort", () => {
|
|
501
|
+
layer.remove();
|
|
502
|
+
reject(new HostError("cancelled", "The alert was dismissed"));
|
|
503
|
+
});
|
|
504
|
+
phone.append(layer);
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
//#endregion
|
|
508
|
+
//#region src/client/dash/index.ts
|
|
509
|
+
function dashHandlers(phone, note) {
|
|
510
|
+
return {
|
|
511
|
+
"dash.ui.alert": ({ params, signal }) => alert(phone, params, signal),
|
|
512
|
+
"dash.system.vibrate": ({ params }) => vibrate(phone, note, params),
|
|
513
|
+
"dash.system.torch": ({ params }) => torch(phone, note, params.state)
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
//#endregion
|
|
517
|
+
//#region src/client/dash/permissions.ts
|
|
518
|
+
const PERMISSION_SUMMARIES = { account: "see your Dash account: your name, and your email or phone" };
|
|
519
|
+
async function askPermission(phone, note, request, signal) {
|
|
520
|
+
const { action } = await alert(phone, {
|
|
521
|
+
title: `Allow “${request.dotName}” to ${PERMISSION_SUMMARIES[request.permission]}?`,
|
|
522
|
+
message: request.reason,
|
|
523
|
+
actions: [{
|
|
524
|
+
id: "deny",
|
|
525
|
+
label: "Don't Allow",
|
|
526
|
+
role: "cancel"
|
|
527
|
+
}, {
|
|
528
|
+
id: "allow",
|
|
529
|
+
label: "Allow"
|
|
530
|
+
}]
|
|
531
|
+
}, signal);
|
|
532
|
+
const allowed = action === "allow";
|
|
533
|
+
if (allowed) note(`permission ${request.permission}: allowed`);
|
|
534
|
+
else note(`permission ${request.permission}: not allowed`);
|
|
535
|
+
return allowed;
|
|
536
|
+
}
|
|
537
|
+
//#endregion
|
|
538
|
+
//#region src/client/wire.ts
|
|
539
|
+
function isWire(data) {
|
|
540
|
+
return typeof data?.dot === "string";
|
|
541
|
+
}
|
|
542
|
+
function wireTransport(channel) {
|
|
543
|
+
return {
|
|
544
|
+
post: (message) => channel.post({ dot: JSON.stringify(message) }),
|
|
545
|
+
listen: (listener) => channel.listen((data) => {
|
|
546
|
+
if (isWire(data)) listener(JSON.parse(data.dot));
|
|
547
|
+
})
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
function windowChannel(other) {
|
|
551
|
+
return {
|
|
552
|
+
post: (message) => other()?.postMessage(message, location.origin),
|
|
553
|
+
listen(listener) {
|
|
554
|
+
const onMessage = (event) => {
|
|
555
|
+
if (event.source === other()) listener(event.data);
|
|
556
|
+
};
|
|
557
|
+
window.addEventListener("message", onMessage);
|
|
558
|
+
return () => window.removeEventListener("message", onMessage);
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
//#endregion
|
|
563
|
+
//#region src/client/simulator.ts
|
|
564
|
+
const MAX_LOG_ENTRIES = 500;
|
|
565
|
+
const screen = element("#screen");
|
|
566
|
+
const log = element("#log");
|
|
567
|
+
const errorBanner = element("#error");
|
|
568
|
+
const phone = element(".phone");
|
|
569
|
+
const schemeSelect = element("#scheme");
|
|
570
|
+
const browserDark = matchMedia("(prefers-color-scheme: dark)");
|
|
571
|
+
let runningApp = null;
|
|
572
|
+
let launchCount = 0;
|
|
573
|
+
async function launch() {
|
|
574
|
+
stopRunningApp();
|
|
575
|
+
launchCount += 1;
|
|
576
|
+
const thisLaunch = launchCount;
|
|
577
|
+
if (log.childElementCount > 0) note("dot restarted");
|
|
578
|
+
let manifest;
|
|
579
|
+
try {
|
|
580
|
+
manifest = await (await fetch(MANIFEST_URL)).json();
|
|
581
|
+
} catch {
|
|
582
|
+
showError(`Nothing to run: ${MANIFEST_URL} is missing. Did the build fail?`);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if (thisLaunch !== launchCount) return;
|
|
586
|
+
try {
|
|
587
|
+
checkCompatibility(manifest, SIMULATOR_VERSIONS);
|
|
588
|
+
} catch (error) {
|
|
589
|
+
screen.src = "about:blank";
|
|
590
|
+
showError(error.message);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const worker = new Worker(`${CLIENT_PATH}service-bridge.js`);
|
|
594
|
+
const host = startHost({
|
|
595
|
+
logic: wireTransport(createAdaptorTransport(worker)),
|
|
596
|
+
ui: wireTransport(windowChannel(() => screen.contentWindow)),
|
|
597
|
+
manifest,
|
|
598
|
+
...SIMULATOR_VERSIONS,
|
|
599
|
+
systemContext: systemContext(),
|
|
600
|
+
handlers: dashHandlers(phone, note),
|
|
601
|
+
askPermission: (request, signal) => askPermission(phone, note, request, signal),
|
|
602
|
+
observe: record
|
|
603
|
+
});
|
|
604
|
+
worker.addEventListener("error", (event) => showError(`Service crashed: ${event.message}`));
|
|
605
|
+
screen.src = WEBVIEW_URL;
|
|
606
|
+
runningApp = {
|
|
607
|
+
host,
|
|
608
|
+
worker
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
function stopRunningApp() {
|
|
612
|
+
if (!runningApp) return;
|
|
613
|
+
runningApp.host.stop();
|
|
614
|
+
runningApp.worker.terminate();
|
|
615
|
+
runningApp = null;
|
|
616
|
+
}
|
|
617
|
+
function goBack() {
|
|
618
|
+
if (runningApp && !runningApp.host.back()) note("back on the first page: a device would close the dot");
|
|
619
|
+
}
|
|
620
|
+
const ARROWS = {
|
|
621
|
+
toHost: "both --> host",
|
|
622
|
+
fromHost: "host --> both",
|
|
623
|
+
toService: "ui --> logic",
|
|
624
|
+
toWebview: "logic <-- ui"
|
|
625
|
+
};
|
|
626
|
+
function note(text) {
|
|
627
|
+
log.append(createElement("li", "note", text));
|
|
628
|
+
}
|
|
629
|
+
function record(direction, message) {
|
|
630
|
+
const entry = createElement("li", direction);
|
|
631
|
+
const details = document.createElement("details");
|
|
632
|
+
const summary = document.createElement("summary");
|
|
633
|
+
const time = (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour12: false });
|
|
634
|
+
summary.append(createElement("span", "time", time), createElement("span", "arrow", ARROWS[direction]), createElement("span", "type", message.type));
|
|
635
|
+
const payload = document.createElement("pre");
|
|
636
|
+
payload.textContent = JSON.stringify(message.payload, null, 2);
|
|
637
|
+
details.append(summary, payload);
|
|
638
|
+
entry.append(details);
|
|
639
|
+
if (message.type === "error") entry.classList.add("failed");
|
|
640
|
+
log.append(entry);
|
|
641
|
+
while (log.childElementCount > MAX_LOG_ENTRIES) log.firstElementChild?.remove();
|
|
642
|
+
log.scrollTop = log.scrollHeight;
|
|
643
|
+
}
|
|
644
|
+
function syncLaunch() {
|
|
645
|
+
launch().catch((error) => {
|
|
646
|
+
showError(`Could not start the dot: ${String(error)}`);
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
function showError(message) {
|
|
650
|
+
errorBanner.hidden = message === null;
|
|
651
|
+
errorBanner.textContent = message ?? "";
|
|
652
|
+
}
|
|
653
|
+
function systemContext() {
|
|
654
|
+
return {
|
|
655
|
+
scheme: schemeSelect.value,
|
|
656
|
+
appState: appState()
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
function appState() {
|
|
660
|
+
if (document.visibilityState === "hidden") return "background";
|
|
661
|
+
return "active";
|
|
662
|
+
}
|
|
663
|
+
function browserScheme() {
|
|
664
|
+
return browserDark.matches ? "dark" : "light";
|
|
665
|
+
}
|
|
666
|
+
function changeScheme(scheme) {
|
|
667
|
+
schemeSelect.value = scheme;
|
|
668
|
+
phone.dataset.scheme = scheme;
|
|
669
|
+
runningApp?.host.setSystemContext(systemContext());
|
|
670
|
+
}
|
|
671
|
+
const events = new EventSource(EVENTS_PATH);
|
|
672
|
+
events.addEventListener("reload", () => {
|
|
673
|
+
showError(null);
|
|
674
|
+
syncLaunch();
|
|
675
|
+
});
|
|
676
|
+
events.addEventListener("build-error", (event) => showError(String(event.data)));
|
|
677
|
+
element("#reload").addEventListener("click", syncLaunch);
|
|
678
|
+
element("#back").addEventListener("click", goBack);
|
|
679
|
+
element("#clear").addEventListener("click", () => log.replaceChildren());
|
|
680
|
+
schemeSelect.addEventListener("change", () => changeScheme(schemeSelect.value));
|
|
681
|
+
browserDark.addEventListener("change", () => changeScheme(browserScheme()));
|
|
682
|
+
document.addEventListener("visibilitychange", () => {
|
|
683
|
+
runningApp?.host.setSystemContext(systemContext());
|
|
684
|
+
});
|
|
685
|
+
changeScheme(browserScheme());
|
|
686
|
+
syncLaunch();
|
|
687
|
+
//#endregion
|
|
688
|
+
})();
|
|
689
|
+
|
|
690
|
+
//# sourceMappingURL=simulator.js.map
|