@modernman00/shared-js-lib 1.2.82 → 1.2.87
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/AddressAutocomplete.js +473 -0
- package/CardCanvasGenerator.js +367 -0
- package/OfflineQueue.js +278 -0
- package/OtpInputManager.js +429 -0
- package/PwaInstall.js +165 -0
- package/PwaManager.js +342 -0
- package/ShareManager.js +462 -0
- package/config/version.php +1 -1
- package/index.js +9 -1
- package/package.json +12 -3
- package/release-history.md +37 -0
- package/templates/sw.template.js +239 -0
package/PwaManager.js
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 📱 PwaManager — Universal Cross-App Service Worker & PWA Controller
|
|
3
|
+
* Part of @modernman00/shared-js-lib
|
|
4
|
+
*
|
|
5
|
+
* Implements:
|
|
6
|
+
* 1. Clean root /sw.js registration with scope '/'
|
|
7
|
+
* 2. Purging of legacy orphaned workers (/public/sw.js, /service-worker.js)
|
|
8
|
+
* 3. User-prompted updates via 'postMessage' with zero jarring auto-reloads
|
|
9
|
+
* 4. Safe cache purging on user logout (clears API & session caches)
|
|
10
|
+
* 5. Custom event dispatching for seamless Blade UI integration
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export class PwaManager {
|
|
14
|
+
static registration = null;
|
|
15
|
+
static options = {
|
|
16
|
+
swUrl: '/sw.js',
|
|
17
|
+
scope: '/',
|
|
18
|
+
appName: 'App',
|
|
19
|
+
enableUpdateToast: true,
|
|
20
|
+
enableBadgingSync: true,
|
|
21
|
+
badgeEndpoint: '/api/notifications/unread',
|
|
22
|
+
onUpdateAvailable: null
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Initializes Service Worker registration and lifecycle monitoring
|
|
27
|
+
*/
|
|
28
|
+
static async init(userOptions = {}) {
|
|
29
|
+
if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
this.options = { ...this.options, ...userOptions };
|
|
34
|
+
|
|
35
|
+
window.addEventListener('load', async () => {
|
|
36
|
+
try {
|
|
37
|
+
await this.purgeLegacyWorkers();
|
|
38
|
+
this.registration = await navigator.serviceWorker.register(this.options.swUrl, {
|
|
39
|
+
scope: this.options.scope
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
console.log(`[PwaManager] ${this.options.appName} ServiceWorker active with scope:`, this.registration.scope);
|
|
43
|
+
|
|
44
|
+
// Listen for new workers entering 'installing' or 'waiting' states
|
|
45
|
+
this.registration.addEventListener('updatefound', () => {
|
|
46
|
+
const installingWorker = this.registration.installing;
|
|
47
|
+
if (!installingWorker) return;
|
|
48
|
+
|
|
49
|
+
installingWorker.addEventListener('statechange', () => {
|
|
50
|
+
if (installingWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
|
51
|
+
console.log(`[PwaManager] New version of ${this.options.appName} installed and waiting.`);
|
|
52
|
+
this.handleUpdateAvailable(installingWorker);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Detect if a worker was already waiting prior to page load
|
|
58
|
+
if (this.registration.waiting && navigator.serviceWorker.controller) {
|
|
59
|
+
this.handleUpdateAvailable(this.registration.waiting);
|
|
60
|
+
}
|
|
61
|
+
} catch (error) {
|
|
62
|
+
console.error(`[PwaManager] ServiceWorker registration failed:`, error);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Listen for controller changes (when skipWaiting activates new worker)
|
|
67
|
+
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
|
68
|
+
console.log('[PwaManager] ServiceWorker controller changed. Page ready for reload.');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Automatic Home Screen Badging Synchronization
|
|
72
|
+
if (this.options.enableBadgingSync && typeof window !== 'undefined') {
|
|
73
|
+
this.syncAppBadge();
|
|
74
|
+
|
|
75
|
+
window.addEventListener('focus', () => this.syncAppBadge());
|
|
76
|
+
document.addEventListener('visibilitychange', () => {
|
|
77
|
+
if (document.visibilityState === 'visible') {
|
|
78
|
+
this.syncAppBadge();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
navigator.serviceWorker.addEventListener('message', (event) => {
|
|
83
|
+
if (event.data?.type === 'ON_APP_NOTIFICATION') {
|
|
84
|
+
this.syncAppBadge();
|
|
85
|
+
if (typeof window !== 'undefined' && window.dispatchEvent) {
|
|
86
|
+
window.dispatchEvent(new CustomEvent('pwa:notification-received', { detail: event.data.payload }));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return this.registration;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Drops legacy or misconfigured workers from older architecture iterations
|
|
97
|
+
*/
|
|
98
|
+
static async purgeLegacyWorkers() {
|
|
99
|
+
try {
|
|
100
|
+
const registrations = await navigator.serviceWorker.getRegistrations();
|
|
101
|
+
for (const reg of registrations) {
|
|
102
|
+
const scriptURL = (reg.active || reg.waiting || reg.installing || {}).scriptURL || '';
|
|
103
|
+
// If worker is scoped to /public/ or named /service-worker.js, unregister it
|
|
104
|
+
if (scriptURL.endsWith('/public/sw.js') || scriptURL.endsWith('/service-worker.js')) {
|
|
105
|
+
console.warn('[PwaManager] Unregistering legacy orphaned worker:', scriptURL);
|
|
106
|
+
await reg.unregister();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
} catch (err) {
|
|
110
|
+
console.warn('[PwaManager] Legacy cleanup check error:', err);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Handles update notification
|
|
116
|
+
*/
|
|
117
|
+
static handleUpdateAvailable(worker) {
|
|
118
|
+
if (typeof window !== 'undefined' && window.dispatchEvent) {
|
|
119
|
+
window.dispatchEvent(new CustomEvent('pwa:update-available', {
|
|
120
|
+
detail: { worker, appName: this.options.appName }
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (this.options.enableUpdateToast) {
|
|
125
|
+
this.showUpdateToast(worker);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (typeof this.options.onUpdateAvailable === 'function') {
|
|
129
|
+
this.options.onUpdateAvailable(worker);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Renders a discreet, glassmorphic floating update pill to prompt user refresh
|
|
135
|
+
*/
|
|
136
|
+
static showUpdateToast(worker) {
|
|
137
|
+
if (typeof document === 'undefined' || document.getElementById('pwaUniversalUpdateToast')) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const toast = document.createElement('div');
|
|
142
|
+
toast.id = 'pwaUniversalUpdateToast';
|
|
143
|
+
toast.setAttribute('role', 'status');
|
|
144
|
+
toast.setAttribute('aria-live', 'polite');
|
|
145
|
+
toast.setAttribute('aria-label', `${this.options.appName || 'App'} update available`);
|
|
146
|
+
|
|
147
|
+
toast.style.position = 'fixed';
|
|
148
|
+
toast.style.bottom = 'max(16px, env(safe-area-inset-bottom, 16px))';
|
|
149
|
+
toast.style.left = '50%';
|
|
150
|
+
toast.style.transform = 'translateX(-50%)';
|
|
151
|
+
toast.style.zIndex = '99999';
|
|
152
|
+
toast.style.display = 'flex';
|
|
153
|
+
toast.style.alignItems = 'center';
|
|
154
|
+
toast.style.gap = '12px';
|
|
155
|
+
toast.style.padding = '8px 14px 8px 16px';
|
|
156
|
+
toast.style.backgroundColor = 'rgba(15, 23, 42, 0.94)';
|
|
157
|
+
toast.style.backdropFilter = 'blur(12px)';
|
|
158
|
+
toast.style.webkitBackdropFilter = 'blur(12px)';
|
|
159
|
+
toast.style.border = '1px solid rgba(255, 255, 255, 0.12)';
|
|
160
|
+
toast.style.borderRadius = '9999px';
|
|
161
|
+
toast.style.boxShadow = '0 12px 32px rgba(0, 0, 0, 0.45)';
|
|
162
|
+
toast.style.color = '#ffffff';
|
|
163
|
+
toast.style.fontFamily = 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
|
164
|
+
toast.style.fontSize = '14px';
|
|
165
|
+
toast.style.lineHeight = '1.4';
|
|
166
|
+
toast.style.maxWidth = 'calc(100vw - 32px)';
|
|
167
|
+
toast.style.boxSizing = 'border-box';
|
|
168
|
+
toast.style.transition = 'all 0.25s cubic-bezier(0.16, 1, 0.3, 1)';
|
|
169
|
+
|
|
170
|
+
const appTitle = this.options.appName || 'App';
|
|
171
|
+
|
|
172
|
+
toast.innerHTML = `
|
|
173
|
+
<div style="display: flex; align-items: center; gap: 8px; white-space: nowrap;">
|
|
174
|
+
<span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background-color: #10b981; box-shadow: 0 0 8px #10b981;"></span>
|
|
175
|
+
<span style="font-weight: 500;">${appTitle} updated</span>
|
|
176
|
+
</div>
|
|
177
|
+
<button id="pwaUpdateActionBtn" type="button" style="
|
|
178
|
+
background: #2563eb;
|
|
179
|
+
color: #ffffff;
|
|
180
|
+
border: none;
|
|
181
|
+
padding: 6px 14px;
|
|
182
|
+
border-radius: 9999px;
|
|
183
|
+
font-size: 13px;
|
|
184
|
+
font-weight: 600;
|
|
185
|
+
cursor: pointer;
|
|
186
|
+
display: inline-flex;
|
|
187
|
+
align-items: center;
|
|
188
|
+
justify-content: center;
|
|
189
|
+
min-height: 36px;
|
|
190
|
+
transition: background 0.2s;
|
|
191
|
+
white-space: nowrap;
|
|
192
|
+
">Refresh</button>
|
|
193
|
+
<button id="pwaUpdateDismissBtn" type="button" style="
|
|
194
|
+
background: transparent;
|
|
195
|
+
color: #94a3b8;
|
|
196
|
+
border: none;
|
|
197
|
+
font-size: 16px;
|
|
198
|
+
cursor: pointer;
|
|
199
|
+
padding: 6px;
|
|
200
|
+
min-width: 36px;
|
|
201
|
+
min-height: 36px;
|
|
202
|
+
display: inline-flex;
|
|
203
|
+
align-items: center;
|
|
204
|
+
justify-content: center;
|
|
205
|
+
line-height: 1;
|
|
206
|
+
" aria-label="Dismiss">✕</button>
|
|
207
|
+
`;
|
|
208
|
+
|
|
209
|
+
document.body.appendChild(toast);
|
|
210
|
+
|
|
211
|
+
const updateBtn = document.getElementById('pwaUpdateActionBtn');
|
|
212
|
+
if (updateBtn) {
|
|
213
|
+
updateBtn.addEventListener('click', () => {
|
|
214
|
+
toast.remove();
|
|
215
|
+
this.applyUpdate(worker);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const dismissBtn = document.getElementById('pwaUpdateDismissBtn');
|
|
220
|
+
if (dismissBtn) {
|
|
221
|
+
dismissBtn.addEventListener('click', () => {
|
|
222
|
+
toast.remove();
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Sends skipWaiting postMessage to the waiting worker and triggers clean reload
|
|
229
|
+
*/
|
|
230
|
+
static applyUpdate(worker = null) {
|
|
231
|
+
const targetWorker = worker || (this.registration ? this.registration.waiting : null);
|
|
232
|
+
if (targetWorker) {
|
|
233
|
+
targetWorker.postMessage({ action: 'skipWaiting' });
|
|
234
|
+
} else if (navigator.serviceWorker.controller) {
|
|
235
|
+
navigator.serviceWorker.controller.postMessage({ action: 'skipWaiting' });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
|
239
|
+
window.location.reload();
|
|
240
|
+
}, { once: true });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Purges sensitive API and user caches from the Service Worker upon user logout
|
|
245
|
+
*/
|
|
246
|
+
static async clearUserCacheOnLogout() {
|
|
247
|
+
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
|
|
248
|
+
navigator.serviceWorker.controller.postMessage({ action: 'clearUserCache' });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (typeof caches !== 'undefined') {
|
|
252
|
+
try {
|
|
253
|
+
const keys = await caches.keys();
|
|
254
|
+
await Promise.all(
|
|
255
|
+
keys.filter(k => k.includes('api-cache') || k.includes('user-cache')).map(k => caches.delete(k))
|
|
256
|
+
);
|
|
257
|
+
console.log('[PwaManager] Client-side sensitive user caches purged.');
|
|
258
|
+
} catch (e) {
|
|
259
|
+
console.warn('[PwaManager] Failed to purge user caches:', e);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Sets app badge counter on the phone's home screen icon (Web Badging API)
|
|
266
|
+
*
|
|
267
|
+
* @param {number|string} count
|
|
268
|
+
* @returns {Promise<void>}
|
|
269
|
+
*/
|
|
270
|
+
static setAppBadge(count) {
|
|
271
|
+
const num = Math.max(0, parseInt(count, 10) || 0);
|
|
272
|
+
if (typeof window !== 'undefined' && window.dispatchEvent) {
|
|
273
|
+
window.dispatchEvent(new CustomEvent('pwa:badge-updated', { detail: { count: num } }));
|
|
274
|
+
}
|
|
275
|
+
if (typeof navigator !== 'undefined' && 'setAppBadge' in navigator) {
|
|
276
|
+
return (num > 0 ? navigator.setAppBadge(num) : navigator.clearAppBadge()).catch(() => {});
|
|
277
|
+
}
|
|
278
|
+
return Promise.resolve();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Clears app badge counter from the phone's home screen icon
|
|
283
|
+
*
|
|
284
|
+
* @returns {Promise<void>}
|
|
285
|
+
*/
|
|
286
|
+
static clearAppBadge() {
|
|
287
|
+
if (typeof window !== 'undefined' && window.dispatchEvent) {
|
|
288
|
+
window.dispatchEvent(new CustomEvent('pwa:badge-updated', { detail: { count: 0 } }));
|
|
289
|
+
}
|
|
290
|
+
if (typeof navigator !== 'undefined' && 'clearAppBadge' in navigator) {
|
|
291
|
+
return navigator.clearAppBadge().catch(() => {});
|
|
292
|
+
}
|
|
293
|
+
return Promise.resolve();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Synchronizes app badge with the server's unread notifications endpoint
|
|
298
|
+
*
|
|
299
|
+
* @param {string|null} endpoint
|
|
300
|
+
* @returns {Promise<void>}
|
|
301
|
+
*/
|
|
302
|
+
static async syncAppBadge(endpoint = null) {
|
|
303
|
+
const url = endpoint || this.options.badgeEndpoint || '/api/notifications/unread';
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
const res = await fetch(url, { headers: { Accept: 'application/json' } });
|
|
307
|
+
if (!res.ok) {
|
|
308
|
+
// Release the stream before bailing. An abandoned response body stays
|
|
309
|
+
// "in flight" in Chromium indefinitely, which keeps the tab from ever
|
|
310
|
+
// reaching network idle — it silently wedges Playwright's
|
|
311
|
+
// `networkidle` waits and any load-state detection for the whole page.
|
|
312
|
+
await res.body?.cancel();
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const data = await res.json();
|
|
316
|
+
let count = 0;
|
|
317
|
+
if (data) {
|
|
318
|
+
if (typeof data.unread_count === 'number') {
|
|
319
|
+
count = data.unread_count;
|
|
320
|
+
} else if (typeof data.count === 'number') {
|
|
321
|
+
count = data.count;
|
|
322
|
+
} else if (typeof data.unreadCount === 'number') {
|
|
323
|
+
count = data.unreadCount;
|
|
324
|
+
} else if (data.status === 'success' && Array.isArray(data.message)) {
|
|
325
|
+
count = data.message.length;
|
|
326
|
+
} else if (Array.isArray(data.data)) {
|
|
327
|
+
count = data.data.length;
|
|
328
|
+
} else if (Array.isArray(data.notifications)) {
|
|
329
|
+
count = data.notifications.length;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const num = Math.max(0, parseInt(count, 10) || 0);
|
|
333
|
+
if (num > 0) {
|
|
334
|
+
await this.setAppBadge(num);
|
|
335
|
+
} else {
|
|
336
|
+
await this.clearAppBadge();
|
|
337
|
+
}
|
|
338
|
+
} catch (e) {
|
|
339
|
+
// Harmless no-op on network failure or offline mode
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|