@webqit/webflo 0.20.56 → 0.20.57

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.
@@ -1,150 +1,396 @@
1
- import { Observer } from '@webqit/observer';
1
+ import { Observer, ListenerRegistry, Descriptor } from '@webqit/observer';
2
2
 
3
3
  export class DeviceCapabilities {
4
4
 
5
- #runtime;
6
5
  #params;
7
6
 
8
7
  #exposed = {};
9
8
  get exposed() { return this.#exposed; }
10
9
 
11
- #cleanups = [];
10
+ static async recognizesPermission(q) {
11
+ try {
12
+ await navigator.permissions.query(this.resolveQuery(q));
13
+ return true;
14
+ } catch (e) {
15
+ return false;
16
+ }
17
+ }
12
18
 
13
- static async initialize(runtime, params) {
14
- const instance = new this;
19
+ static resolveQuery(q) {
20
+ if (typeof q === 'string') {
21
+ q = { name: q };
22
+ } else q = { ...q };
23
+ if (q.name === 'push' && !q.userVisibleOnly) {
24
+ q = { ...q, userVisibleOnly: true };
25
+ }
26
+ if (q.name === 'top-level-storage-access' && !q.requestedOrigin) {
27
+ q = { ...q, requestedOrigin: window.location.origin };
28
+ }
29
+ return q;
30
+ }
15
31
 
16
- instance.#runtime = runtime;
17
- instance.#params = params;
32
+ constructor(params = {}) {
33
+ this.#params = params;
18
34
 
19
- // --------
20
- // Custom install
21
- const onbeforeinstallprompt = (e) => {
22
- if (instance.#params.custom_install && instance.#exposed.custom_install !== 'granted') {
23
- e.preventDefault();
24
- Observer.set(instance.#exposed, 'custom_install', e);
35
+ if (Array.isArray(this.#params.exposed) && this.#params.exposed.length) {
36
+ for (const capName of this.#params.exposed) {
37
+ const queryName = capName.trim().replace(/_/g, '-');
38
+ const capInstance = this.query(queryName);
39
+ this.#exposed[capName] = capInstance;
25
40
  }
41
+ }
42
+ }
43
+
44
+ close() {
45
+ this.#capInstances.forEach((c) => c.dispose());
46
+ }
47
+
48
+ #capInstances = new Map;
49
+
50
+ query(query) {
51
+ const _q = this.constructor.resolveQuery(query);
52
+
53
+ if (this.#capInstances.has(_q.name)) {
54
+ return this.#capInstances.get(_q.name);
55
+ }
56
+
57
+ let capInstance;
58
+ switch (_q.name) {
59
+ case 'pwa-install':
60
+ capInstance = new PWAInstallCapability;
61
+ case 'geolocation':
62
+ capInstance = new GeolocationCapability;
63
+ case 'notification':
64
+ //capInstance = new NotificationCapability;
65
+ case 'push':
66
+ //capInstance = new PushCapability;
67
+ case 'storage-access':
68
+ //capInstance = new StorageAccessCapability;
69
+ }
70
+
71
+ this.#capInstances.set(_q.name, capInstance);
72
+
73
+ return capInstance;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * DeviceCapability
79
+ */
80
+
81
+ class DeviceCapability {
82
+
83
+ #listenersRegistry;
84
+ _cleanups = [];
85
+
86
+ constructor() {
87
+ this.#listenersRegistry = ListenerRegistry.getInstance(this, true);
88
+ }
89
+
90
+ _fire(props, exec) {
91
+ const oldValues = {};
92
+ for (const prop of props) {
93
+ oldValues[prop] = this[prop];
94
+ }
95
+
96
+ if (exec) exec();
97
+
98
+ const descriptors = props.map((prop) => {
99
+ return new Descriptor(this, {
100
+ type: 'set',
101
+ key: prop,
102
+ value: this[prop],
103
+ oldValue: oldValues[prop],
104
+ isUpdate: true,
105
+ related: props.slice(),
106
+ operation: 'set',
107
+ detail: null,
108
+ });
109
+ });
110
+
111
+ this.#listenersRegistry.emit(descriptors);
112
+ }
113
+
114
+ dispose() {
115
+ this._cleanups.forEach((c) => c());
116
+ }
117
+ }
118
+
119
+ /**
120
+ * PWAInstallCapability
121
+ */
122
+
123
+ class PWAInstallCapability extends DeviceCapability {
124
+
125
+ #status = 'unknown';
126
+ #displayMode = null;
127
+
128
+ get status() { return this.#status; }
129
+ get displayMode() { return this.#displayMode; }
130
+
131
+ #pwaInstallEvent;
132
+
133
+ constructor() {
134
+ super();
135
+
136
+ // --------------- beforeinstallprompt event
137
+
138
+ const onbeforeinstallprompt = (e) => {
139
+ this.#pwaInstallEvent = e;
140
+ this.#pwaInstallEvent.preventDefault();
141
+ this._fire(['status'], () => {
142
+ this.#status = 'prompt-available';
143
+ });
26
144
  };
145
+
27
146
  window.addEventListener('beforeinstallprompt', onbeforeinstallprompt);
28
- instance.#cleanups.push(() => window.removeEventListener('beforeinstallprompt', onbeforeinstallprompt));
29
- // --------
30
- // Webhooks
31
- if (instance.#runtime.env('GENERIC_PUBLIC_WEBHOOK_URL')) {
32
- // --------
33
- // app.installed
34
- const onappinstalled = () => {
35
- fetch(instance.#runtime.env('GENERIC_PUBLIC_WEBHOOK_URL'), {
36
- method: 'POST',
37
- headers: { 'Content-Type': 'application/json' },
38
- body: JSON.stringify({ type: 'app.installed', data: true })
39
- }).catch(() => {});
147
+ this._cleanups.push(() => window.removeEventListener('beforeinstallprompt', onbeforeinstallprompt));
148
+
149
+ // --------------- appinstalled event
150
+
151
+ const onappinstalled = async () => {
152
+ this._fire(['status'], () => {
153
+ this.#status = 'installed';
154
+ });
155
+ };
156
+
157
+ window.addEventListener('appinstalled', onappinstalled);
158
+ this._cleanups.push(() => window.removeEventListener('appinstalled', onappinstalled));
159
+
160
+ // --------------- displayMode
161
+
162
+ const _setDisplayMode = (dm) => {
163
+ this._fire(['displayMode'], () => {
164
+ this.#displayMode = dm;
165
+ });
166
+ };
167
+
168
+ const handleDisplayMode = () => {
169
+ if (document.referrer.startsWith('android-app://')) {
170
+ _setDisplayMode('twa');
171
+ return;
172
+ }
173
+
174
+ for (const dm of ['browser', 'standalone', 'minimal-ui', 'fullscreen', 'window-controls-overlay']) {
175
+ const mediaQuery = window.matchMedia(`(display-mode: ${dm})`);
176
+
177
+ if (mediaQuery.matches) {
178
+ _setDisplayMode(dm);
179
+
180
+ mediaQuery.addEventListener('change', handleDisplayMode, { once: true });
181
+ this._cleanups.push(() => mediaQuery.removeEventListener('change', handleDisplayMode));
182
+
183
+ return;
184
+ }
185
+ }
186
+ };
187
+
188
+ handleDisplayMode();
189
+ }
190
+
191
+ async prompt() {
192
+ if (!this.#pwaInstallEvent) return;
193
+
194
+ await this.#pwaInstallEvent.prompt?.();
195
+ const { outcome } = await this.#pwaInstallEvent.userChoice;
196
+
197
+ this._fire(['status'], () => {
198
+ this.#status = outcome;
199
+ });
200
+
201
+ this.#pwaInstallEvent = null;
202
+ }
203
+ }
204
+
205
+ /**
206
+ * GeolocationCapability
207
+ */
208
+
209
+ class GeolocationCapability extends DeviceCapability {
210
+
211
+ #status = !navigator.geolocation ? 'unsupported' : 'unknown';
212
+ get status() { return this.#status; }
213
+
214
+ constructor() {
215
+ super();
216
+ if (!navigator.geolocation) return;
217
+
218
+ navigator.permissions.query({ name: 'geolocation' }).then((permissionStatus) => {
219
+ const handleChange = () => {
220
+ this._fire(['status'], () => {
221
+ this.#status = permissionStatus.state;
222
+ });
223
+ if (permissionStatus.state === 'granted') {
224
+ this.#initEnqueued();
225
+ }
40
226
  };
41
- window.addEventListener('appinstalled', onappinstalled);
42
- instance.#cleanups.push(() => window.removeEventListener('appinstalled', onappinstalled));
43
- // --------
44
- // push.subscribe/unsubscribe
45
- if (instance.#params.webpush) {
46
- try {
47
- const pushPermissionStatus = await navigator.permissions.query({ name: 'push', userVisibleOnly: true });
48
- const pushPermissionStatusHandler = async () => {
49
- const pushManager = (await navigator.serviceWorker.getRegistration()).pushManager;
50
- const eventPayload = pushPermissionStatus.state === 'granted'
51
- ? { type: 'push.subscribe', data: await pushManager.getSubscription() }
52
- : { type: 'push.unsubscribe' };
53
- if (eventPayload.type === 'push.subscribe' && !eventPayload.data) {
54
- return window.queueMicrotask(pushPermissionStatusHandler);
55
- }
56
- fetch(instance.#runtime.env('GENERIC_PUBLIC_WEBHOOK_URL'), {
57
- method: 'POST',
58
- headers: { 'Content-Type': 'application/json' },
59
- body: JSON.stringify(eventPayload)
60
- }).catch(() => {});
227
+
228
+ handleChange();
229
+ permissionStatus.addEventListener('change', handleChange);
230
+ this._cleanups.push(() => permissionStatus.removeEventListener('change', handleChange));
231
+ });
232
+ }
233
+
234
+ #queryQueue = new Set;
235
+
236
+ #initEnqueued() {
237
+ this.#queryQueue.forEach((cb) => cb());
238
+ this.#queryQueue.clear();
239
+ }
240
+
241
+ async read({ live = false, ...options } = {}) {
242
+ const _options = {
243
+ enableHighAccuracy: true,
244
+ ...options
245
+ };
246
+
247
+ const process = () => {
248
+ return new Promise((res) => {
249
+ const locationData = {};
250
+ let resolved = false;
251
+
252
+ const successCallback = (pos) => {
253
+ let coords = pos.coords.toJSON();
254
+
255
+ if (!options.detailed) {
256
+ coords = {
257
+ lng: coords.longitude,
258
+ lat: coords.latitude
259
+ };
61
260
  }
62
- pushPermissionStatus.addEventListener('change', pushPermissionStatusHandler);
63
- instance.#cleanups.push(() => pushPermissionStatus.removeEventListener('change', pushPermissionStatusHandler));
64
- } catch(e) {}
65
- }
66
- }
67
- // --------
68
- // Exposure
69
- if (Array.isArray(instance.#params.exposed) && instance.#params.exposed.length) {
70
- const [permissions, cleanup] = await instance.query(instance.#params.exposed.map((s) => s.trim()), true);
71
- instance.#exposed = permissions;
72
- instance.#cleanups.push(cleanup);
73
- }
74
-
75
- return instance;
76
- }
77
-
78
- async query(query, live = false) {
79
- const permissions = {}, cleanups = [];
80
- for (let q of [].concat(query)) {
81
- q = this.resolveQuery(q);
82
- // ------
83
- // Display mode
84
- if (q.name === 'display-mode') {
85
- const handleDisplayMode = () => {
86
- if (document.referrer.startsWith('android-app://')) {
87
- Observer.set(permissions, 'display_mode', 'twa');
88
- return;
261
+
262
+ if (!Object.keys(coords).every((n) => coords[n] === locationData[n])) {
263
+ Observer.set(locationData, coords);
89
264
  }
90
- for (const dm of ['browser', 'standalone', 'minimal-ui', 'fullscreen', 'window-controls-overlay']) {
91
- const mediaQuery = window.matchMedia(`(display-mode: ${dm})`);
92
- if (mediaQuery.matches) {
93
- Observer.set(permissions, 'display_mode', dm);
94
- if (live) {
95
- mediaQuery.addEventListener('change', handleDisplayMode, { once: true });
96
- cleanups.push(() => mediaQuery.removeEventListener('change', handleDisplayMode));
97
- }
98
- return;
99
- }
265
+
266
+ if (!resolved) {
267
+ res(locationData);
268
+ resolved = true;
100
269
  }
101
270
  };
102
- handleDisplayMode();
103
- continue;
104
- }
105
- // ------
106
- // Others
107
- try {
108
- const permissionStatus = await navigator.permissions.query(q);
109
- permissions[permissionStatus.name.replace(/-/g, '_')] = permissionStatus.state;
271
+
272
+ const errrorCallback = (e) => {
273
+ console.error(e);
274
+ };
275
+
110
276
  if (live) {
111
- const onchange = () => {
112
- Observer.set(permissions, permissionStatus.name.replace(/-/g, '_'), permissionStatus.state);
113
- };
114
- permissionStatus.addEventListener('change', onchange);
115
- cleanups.push(() => permissionStatus.removeEventListener('change', onchange));
277
+ const watchID = navigator.geolocation.watchPosition(
278
+ successCallback,
279
+ errrorCallback,
280
+ _options
281
+ );
282
+
283
+ this._cleanups.push(() => navigator.geolocation.clearWatch(watchID));
284
+ if (_options.signal) {
285
+ _options.signal.addEventListener('abort', () => navigator.geolocation.clearWatch(watchID));
286
+ }
287
+ } else {
288
+ navigator.geolocation.getCurrentPosition(
289
+ successCallback,
290
+ errrorCallback,
291
+ _options
292
+ );
116
293
  }
117
- } catch(e) {
118
- permissions[q.name.replace(/-/g, '_')] = 'unsupported';
119
- console.log(e);
294
+ });
295
+ };
296
+
297
+ if (this.status !== 'granted') {
298
+ if (live) {
299
+ return new Promise((res) => {
300
+ const handle = async () => {
301
+ const liveData = await process();
302
+ res(liveData);
303
+ };
304
+
305
+ this.#queryQueue.add(handle);
306
+ });
120
307
  }
308
+
309
+ return null;
121
310
  }
122
- if (live) {
123
- return [
124
- permissions,
125
- () => cleanups.forEach((c) => c())
126
- ];
127
- }
128
- return permissions;
129
- }
130
-
131
- async request(name, params = {}) {
132
- params = this.resolveRequest(name, params);
133
- // ------
134
- // install
135
- if (name === 'install') {
136
- let returnValue;
137
- if (this.#exposed.custom_install === 'granted') return;
138
- if (this.#exposed.custom_install) {
139
- returnValue = await this.#exposed.custom_install.prompt?.();
140
- const { outcome } = await this.#exposed.custom_install.userChoice;
141
- if (outcome === 'dismissed') return;
311
+
312
+ return await process();
313
+ }
314
+
315
+ prompt() {
316
+ if (this.status === 'granted') return;
317
+
318
+ navigator.geolocation.getCurrentPosition(
319
+ () => { },
320
+ () => { },
321
+ );
322
+ }
323
+
324
+ dispose() {
325
+ this.#queryQueue.clear();
326
+ super.dispose();
327
+ }
328
+ }
329
+
330
+
331
+
332
+
333
+
334
+
335
+
336
+
337
+
338
+
339
+
340
+ /*
341
+
342
+
343
+
344
+ // Public base64 to Uint
345
+ function urlBase64ToUint8Array(base64String) {
346
+ const padding = '='.repeat((4 - base64String.length % 4) % 4);
347
+ const base64 = (base64String + padding)
348
+ .replace(/\-/g, '+')
349
+ .replace(/_/g, '/');
350
+ const rawData = window.atob(base64);
351
+ const outputArray = new Uint8Array(rawData.length);
352
+ for (let i = 0; i < rawData.length; ++i) {
353
+ outputArray[i] = rawData.charCodeAt(i);
354
+ }
355
+ return outputArray;
356
+ }
357
+
358
+
359
+
360
+
361
+
362
+ async #initWebpush() {
363
+ if (!this.#runtime?.env('GENERIC_PUBLIC_WEBHOOK_URL')) return;
364
+
365
+ try {
366
+ const pushPermissionStatus = await navigator.permissions.query({ name: 'push', userVisibleOnly: true });
367
+ const pushPermissionStatusHandler = async () => {
368
+ const reg = await navigator.serviceWorker.ready;
369
+ const pushManager = reg.pushManager;
370
+
371
+ const eventPayload = pushPermissionStatus.state === 'granted'
372
+ ? { type: 'push.subscribe', data: await pushManager.getSubscription() }
373
+ : { type: 'push.unsubscribe' };
374
+
375
+ if (eventPayload.type === 'push.subscribe' && !eventPayload.data) {
376
+ return window.queueMicrotask(pushPermissionStatusHandler);
142
377
  }
143
- Observer.set(this.#exposed, 'custom_install', 'granted');
144
- return returnValue;
378
+
379
+ await fetch(this.#runtime.env('GENERIC_PUBLIC_WEBHOOK_URL'), {
380
+ method: 'POST',
381
+ headers: { 'Content-Type': 'application/json' },
382
+ body: JSON.stringify(eventPayload),
383
+ }).catch(() => { });
145
384
  }
146
- // ------
147
- // notification
385
+
386
+ pushPermissionStatus.addEventListener('change', pushPermissionStatusHandler);
387
+ this.#cleanups.push(() => pushPermissionStatus.removeEventListener('change', pushPermissionStatusHandler));
388
+ } catch (e) { }
389
+ }
390
+
391
+
392
+
393
+ // notification
148
394
  if (name === 'notification') {
149
395
  return await new Promise(async (resolve, reject) => {
150
396
  const permissionResult = Notification.requestPermission(resolve);
@@ -153,36 +399,16 @@ export class DeviceCapabilities {
153
399
  }
154
400
  });
155
401
  }
156
- // ------
157
- // push
402
+
403
+ // webpush
158
404
  if (name === 'push') {
159
- const pushManager = (await navigator.serviceWorker.getRegistration()).pushManager;
405
+ const reg = await navigator.serviceWorker.ready;
406
+ const pushManager = reg.pushManager;
160
407
  const subscription = (await pushManager.getSubscription()) || await pushManager.subscribe(params);
161
408
  return subscription;
162
409
  }
163
- }
164
410
 
165
- async supports(q) {
166
- try {
167
- await navigator.permissions.query(this.resolveQuery(q));
168
- return true;
169
- } catch(e) {
170
- return false;
171
- }
172
- }
173
411
 
174
- resolveQuery(q) {
175
- if (typeof q === 'string') {
176
- q = { name: q };
177
- }
178
- if (q.name === 'push' && !q.userVisibleOnly) {
179
- q = { ...q, userVisibleOnly: true };
180
- }
181
- if (q.name === 'top-level-storage-access' && !q.requestedOrigin) {
182
- q = { ...q, requestedOrigin: window.location.origin };
183
- }
184
- return q;
185
- }
186
412
 
187
413
  resolveRequest(name, params = {}) {
188
414
  if (name === 'push') {
@@ -195,22 +421,4 @@ export class DeviceCapabilities {
195
421
  }
196
422
  return params;
197
423
  }
198
-
199
- close() {
200
- this.#cleanups.forEach((c) => c());
201
- }
202
- }
203
-
204
- // Public base64 to Uint
205
- function urlBase64ToUint8Array(base64String) {
206
- const padding = '='.repeat((4 - base64String.length % 4) % 4);
207
- const base64 = (base64String + padding)
208
- .replace(/\-/g, '+')
209
- .replace(/_/g, '/');
210
- const rawData = window.atob(base64);
211
- const outputArray = new Uint8Array(rawData.length);
212
- for (let i = 0; i < rawData.length; ++i) {
213
- outputArray[i] = rawData.charCodeAt(i);
214
- }
215
- return outputArray;
216
- }
424
+ */
@@ -55,7 +55,11 @@ export class WebfloClient extends AppRuntime {
55
55
  rel: 'unrelated',
56
56
  phase: 0
57
57
  };
58
- this.#background = new StarPort({ handshake: 1, autoClose: false });
58
+ this.#background = new StarPort({
59
+ handshake: 1,
60
+ postAwaitsOpen: false/* new additions shouldn't inherit old, initial messages */,
61
+ autoClose: false
62
+ });
59
63
  }
60
64
 
61
65
  async initialize() {
@@ -9,12 +9,6 @@ import { WebfloHMR } from './webflo-devmode.js';
9
9
 
10
10
  export class WebfloRootClientA extends WebfloClient {
11
11
 
12
- static get Workport() { return ClientSideWorkport; }
13
-
14
- static get DeviceCapabilities() { return DeviceCapabilities; }
15
-
16
- static get DeviceViewport() { return DeviceViewport; }
17
-
18
12
  static create(bootstrap, host) {
19
13
  return new this(bootstrap, host);
20
14
  }
@@ -77,16 +71,15 @@ export class WebfloRootClientA extends WebfloClient {
77
71
  const cleanups = [];
78
72
  instanceController.signal.addEventListener('abort', () => cleanups.forEach((c) => c()), { once: true });
79
73
 
80
- // DeviceViewport, DeviceCapabilities, & Service Worker
81
-
82
- this.#viewport = new this.constructor.DeviceViewport();
74
+ this.#viewport = new DeviceViewport();
83
75
 
84
- this.#capabilities = await this.constructor.DeviceCapabilities.initialize(this, this.config.CLIENT.capabilities);
76
+ this.#capabilities = new DeviceCapabilities(this.config.CLIENT.capabilities, this);
77
+ await this.#capabilities.ready;
85
78
  cleanups.push(() => this.#capabilities.close());
86
79
 
87
80
  if (this.config.CLIENT.capabilities?.service_worker) {
88
81
  const { filename, ...restServiceWorkerParams } = this.config.WORKER;
89
- this.constructor.Workport.initialize(null, filename, restServiceWorkerParams).then((workport) => {
82
+ ClientSideWorkport.initialize(null, filename, restServiceWorkerParams).then((workport) => {
90
83
  this.#workport = workport;
91
84
  cleanups.push(() => this.#workport.close());
92
85
  });
@@ -5,8 +5,10 @@ export class WebfloRootClientB extends WebfloRootClientA {
5
5
 
6
6
  control() {
7
7
  const instanceController = super.controlSuper/*IMPORTANT*/();
8
+
8
9
  // Detect source elements
9
10
  let navigationOrigins = [];
11
+
10
12
  // Capture all link-clicks
11
13
  const clickHandler = (e) => {
12
14
  if (!this._canIntercept(e) || e.defaultPrevented) return;
@@ -14,11 +16,13 @@ export class WebfloRootClientB extends WebfloRootClientA {
14
16
  if (!anchorEl || !anchorEl.href || anchorEl.target) return;
15
17
  navigationOrigins = [anchorEl, null, anchorEl.closest('[navigationcontext]')];
16
18
  };
19
+
17
20
  // Capture all form-submits
18
21
  const submitHandler = (e) => {
19
22
  if (!this._canIntercept(e) || e.defaultPrevented) return;
20
23
  navigationOrigins = [e.submitter, e.target.closest('form'), e.target.closest('[navigationcontext]')];
21
24
  };
25
+
22
26
  // Handle navigation event which happens after the above
23
27
  const navigateHandler = (e) => {
24
28
  if (!e.canIntercept
@@ -47,6 +51,7 @@ export class WebfloRootClientB extends WebfloRootClientA {
47
51
  info
48
52
  };
49
53
  navigationOrigins = [];
54
+
50
55
  // Traversal?
51
56
  // Push
52
57
  const url = new URL(destination.url, this.location.href);
@@ -55,6 +60,7 @@ export class WebfloRootClientB extends WebfloRootClientA {
55
60
  body: formData,
56
61
  //signal TODO: auto-aborts on a redirect response which thus fails to parse
57
62
  };
63
+
58
64
  const runtime = this;
59
65
  e.intercept({
60
66
  scroll: 'after-transition',
@@ -64,10 +70,12 @@ export class WebfloRootClientB extends WebfloRootClientA {
64
70
  },
65
71
  });
66
72
  };
73
+
67
74
  window.addEventListener('click', clickHandler, { signal: instanceController.signal });
68
75
  window.addEventListener('submit', submitHandler, { signal: instanceController.signal });
69
76
  window.navigation.addEventListener('navigate', navigateHandler, { signal: instanceController.signal });
70
- return instanceController;
77
+
78
+ return instanceController;
71
79
  }
72
80
 
73
81
  reload(params) {