@modernman00/shared-js-lib 1.2.82 → 1.2.83
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/CardCanvasGenerator.js +367 -0
- package/OfflineQueue.js +278 -0
- package/PwaInstall.js +165 -0
- package/PwaManager.js +224 -0
- package/ShareManager.js +462 -0
- package/config/version.php +1 -1
- package/index.js +6 -1
- package/package.json +9 -2
- package/release-history.md +15 -0
- package/templates/sw.template.js +239 -0
package/PwaInstall.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 📱 PwaInstall — Smart A2HS Banner & iOS Safari Installation Engine
|
|
3
|
+
* Part of @modernman00/shared-js-lib
|
|
4
|
+
*
|
|
5
|
+
* Implements:
|
|
6
|
+
* 1. Native beforeinstallprompt interception for Chromium
|
|
7
|
+
* 2. iOS Safari step-by-step walkthrough bottom-sheet
|
|
8
|
+
* 3. Configurable app name, icon, and brand colors
|
|
9
|
+
* 4. 14-day persistent dismissal window
|
|
10
|
+
* 5. Safe-area notch padding & 44px minimum tap target compliance
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export class PwaInstall {
|
|
14
|
+
constructor(options = {}) {
|
|
15
|
+
this.appName = options.appName || 'Application';
|
|
16
|
+
this.appIcon = options.appIcon || '/public/img/favicon/apple-touch-icon.png';
|
|
17
|
+
this.brandColor = options.brandColor || '#2563eb';
|
|
18
|
+
this.dismissKey = options.dismissKey || `${this.appName.toLowerCase().replace(/[^a-z0-9]/g, '_')}_pwa_dismissed`;
|
|
19
|
+
this.dismissDurationMs = (options.dismissDays || 14) * 24 * 60 * 60 * 1000;
|
|
20
|
+
this.delayMs = options.delayMs || 3000;
|
|
21
|
+
this.deferredPrompt = null;
|
|
22
|
+
this.isInitialized = false;
|
|
23
|
+
|
|
24
|
+
if (typeof window !== 'undefined') {
|
|
25
|
+
this.init();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
init() {
|
|
30
|
+
if (this.isInitialized || typeof window === 'undefined') return;
|
|
31
|
+
this.isInitialized = true;
|
|
32
|
+
|
|
33
|
+
// Check if running in standalone mode (already installed)
|
|
34
|
+
const isStandalone = (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches)
|
|
35
|
+
|| window.navigator.standalone === true;
|
|
36
|
+
|
|
37
|
+
if (isStandalone) return;
|
|
38
|
+
|
|
39
|
+
// Check if dismissed recently
|
|
40
|
+
const dismissedAt = localStorage.getItem(this.dismissKey);
|
|
41
|
+
if (dismissedAt && (Date.now() - parseInt(dismissedAt, 10) < this.dismissDurationMs)) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
|
|
46
|
+
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
|
47
|
+
|
|
48
|
+
// 1. Chromium native beforeinstallprompt
|
|
49
|
+
window.addEventListener('beforeinstallprompt', (e) => {
|
|
50
|
+
e.preventDefault();
|
|
51
|
+
this.deferredPrompt = e;
|
|
52
|
+
this.showBanner('native');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// 2. iOS Safari instructions
|
|
56
|
+
if (isIOS && isSafari && !isStandalone) {
|
|
57
|
+
if (document.readyState === 'complete') {
|
|
58
|
+
setTimeout(() => this.showBanner('ios'), this.delayMs);
|
|
59
|
+
} else {
|
|
60
|
+
window.addEventListener('DOMContentLoaded', () => {
|
|
61
|
+
setTimeout(() => this.showBanner('ios'), this.delayMs);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
showBanner(type) {
|
|
68
|
+
if (document.getElementById('pwaUniversalInstallBanner')) return;
|
|
69
|
+
|
|
70
|
+
const banner = document.createElement('div');
|
|
71
|
+
banner.id = 'pwaUniversalInstallBanner';
|
|
72
|
+
banner.setAttribute('role', 'dialog');
|
|
73
|
+
banner.setAttribute('aria-label', `Install ${this.appName}`);
|
|
74
|
+
|
|
75
|
+
// Styling with glassmorphism and safe-area compliance
|
|
76
|
+
banner.style.position = 'fixed';
|
|
77
|
+
banner.style.bottom = 'max(16px, env(safe-area-inset-bottom, 16px))';
|
|
78
|
+
banner.style.left = '16px';
|
|
79
|
+
banner.style.right = '16px';
|
|
80
|
+
banner.style.margin = '0 auto';
|
|
81
|
+
banner.style.maxWidth = '480px';
|
|
82
|
+
banner.style.backgroundColor = 'rgba(23, 23, 28, 0.94)';
|
|
83
|
+
banner.style.backdropFilter = 'blur(12px)';
|
|
84
|
+
banner.style.webkitBackdropFilter = 'blur(12px)';
|
|
85
|
+
banner.style.border = '1px solid rgba(255, 255, 255, 0.12)';
|
|
86
|
+
banner.style.borderRadius = '16px';
|
|
87
|
+
banner.style.boxShadow = '0 12px 36px rgba(0, 0, 0, 0.45)';
|
|
88
|
+
banner.style.padding = '14px 16px';
|
|
89
|
+
banner.style.display = 'flex';
|
|
90
|
+
banner.style.alignItems = 'center';
|
|
91
|
+
banner.style.justifyContent = 'space-between';
|
|
92
|
+
banner.style.gap = '12px';
|
|
93
|
+
banner.style.zIndex = '99999';
|
|
94
|
+
banner.style.color = '#ffffff';
|
|
95
|
+
banner.style.fontFamily = 'system-ui, -apple-system, sans-serif';
|
|
96
|
+
banner.style.transition = 'all 0.3s cubic-bezier(0.16, 1, 0.3, 1)';
|
|
97
|
+
|
|
98
|
+
const iconHtml = `<img src="${this.appIcon}" alt="${this.appName} Logo" style="width: 44px; height: 44px; border-radius: 10px; object-fit: cover; flex-shrink: 0;">`;
|
|
99
|
+
|
|
100
|
+
if (type === 'native') {
|
|
101
|
+
banner.innerHTML = `
|
|
102
|
+
<div style="display: flex; align-items: center; gap: 12px; min-width: 0;">
|
|
103
|
+
${iconHtml}
|
|
104
|
+
<div style="min-width: 0;">
|
|
105
|
+
<strong style="display: block; font-size: 15px; font-weight: 600; line-height: 1.2;">Install ${this.appName}</strong>
|
|
106
|
+
<span style="display: block; font-size: 12px; color: #a1a1aa; margin-top: 2px;">Add to home screen for fast, offline access</span>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
<div style="display: flex; align-items: center; gap: 8px; flex-shrink: 0;">
|
|
110
|
+
<button id="btnPwaInstallAction" type="button" style="background-color: ${this.brandColor}; color: #ffffff; border: none; border-radius: 8px; padding: 8px 14px; font-size: 13px; font-weight: 600; cursor: pointer; min-height: 44px; min-width: 44px;">
|
|
111
|
+
Install
|
|
112
|
+
</button>
|
|
113
|
+
<button id="btnPwaDismissAction" type="button" aria-label="Dismiss" style="background: none; border: none; color: #a1a1aa; font-size: 20px; cursor: pointer; padding: 8px; min-height: 44px; min-width: 44px;">
|
|
114
|
+
×
|
|
115
|
+
</button>
|
|
116
|
+
</div>
|
|
117
|
+
`;
|
|
118
|
+
} else {
|
|
119
|
+
banner.innerHTML = `
|
|
120
|
+
<div style="display: flex; align-items: center; gap: 12px; min-width: 0;">
|
|
121
|
+
${iconHtml}
|
|
122
|
+
<div style="min-width: 0;">
|
|
123
|
+
<strong style="display: block; font-size: 15px; font-weight: 600; line-height: 1.2;">Install on iPhone</strong>
|
|
124
|
+
<span style="display: block; font-size: 12px; color: #a1a1aa; margin-top: 2px;">Tap <strong>Share</strong>, then <strong>'Add to Home Screen'</strong></span>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
<div style="display: flex; align-items: center; gap: 8px; flex-shrink: 0;">
|
|
128
|
+
<button id="btnPwaDismissAction" type="button" aria-label="Dismiss" style="background: none; border: none; color: #a1a1aa; font-size: 20px; cursor: pointer; padding: 8px; min-height: 44px; min-width: 44px;">
|
|
129
|
+
×
|
|
130
|
+
</button>
|
|
131
|
+
</div>
|
|
132
|
+
`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
document.body.appendChild(banner);
|
|
136
|
+
|
|
137
|
+
const installBtn = document.getElementById('btnPwaInstallAction');
|
|
138
|
+
if (installBtn && this.deferredPrompt) {
|
|
139
|
+
installBtn.addEventListener('click', async () => {
|
|
140
|
+
banner.style.display = 'none';
|
|
141
|
+
this.deferredPrompt.prompt();
|
|
142
|
+
const { outcome } = await this.deferredPrompt.userChoice;
|
|
143
|
+
if (outcome === 'accepted') {
|
|
144
|
+
if (typeof window !== 'undefined' && window.dispatchEvent) {
|
|
145
|
+
window.dispatchEvent(new CustomEvent('pwa:installed', { detail: { appName: this.appName } }));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
this.deferredPrompt = null;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const dismissBtn = document.getElementById('btnPwaDismissAction');
|
|
153
|
+
if (dismissBtn) {
|
|
154
|
+
dismissBtn.addEventListener('click', () => {
|
|
155
|
+
banner.style.opacity = '0';
|
|
156
|
+
banner.style.transform = 'translateY(16px)';
|
|
157
|
+
setTimeout(() => banner.remove(), 300);
|
|
158
|
+
localStorage.setItem(this.dismissKey, Date.now().toString());
|
|
159
|
+
if (typeof window !== 'undefined' && window.dispatchEvent) {
|
|
160
|
+
window.dispatchEvent(new CustomEvent('pwa:install-dismissed', { detail: { appName: this.appName } }));
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
package/PwaManager.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
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
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return this.registration;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Drops legacy or misconfigured workers from older architecture iterations
|
|
94
|
+
*/
|
|
95
|
+
static async purgeLegacyWorkers() {
|
|
96
|
+
try {
|
|
97
|
+
const registrations = await navigator.serviceWorker.getRegistrations();
|
|
98
|
+
for (const reg of registrations) {
|
|
99
|
+
const scriptURL = (reg.active || reg.waiting || reg.installing || {}).scriptURL || '';
|
|
100
|
+
// If worker is scoped to /public/ or named /service-worker.js, unregister it
|
|
101
|
+
if (scriptURL.endsWith('/public/sw.js') || scriptURL.endsWith('/service-worker.js')) {
|
|
102
|
+
console.warn('[PwaManager] Unregistering legacy orphaned worker:', scriptURL);
|
|
103
|
+
await reg.unregister();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.warn('[PwaManager] Legacy cleanup check error:', err);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Handles update notification
|
|
113
|
+
*/
|
|
114
|
+
static handleUpdateAvailable(worker) {
|
|
115
|
+
if (typeof window !== 'undefined' && window.dispatchEvent) {
|
|
116
|
+
window.dispatchEvent(new CustomEvent('pwa:update-available', {
|
|
117
|
+
detail: { worker, appName: this.options.appName }
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (typeof this.options.onUpdateAvailable === 'function') {
|
|
122
|
+
this.options.onUpdateAvailable(worker);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Sends skipWaiting postMessage to the waiting worker and triggers clean reload
|
|
128
|
+
*/
|
|
129
|
+
static applyUpdate(worker = null) {
|
|
130
|
+
const targetWorker = worker || (this.registration ? this.registration.waiting : null);
|
|
131
|
+
if (targetWorker) {
|
|
132
|
+
targetWorker.postMessage({ action: 'skipWaiting' });
|
|
133
|
+
} else if (navigator.serviceWorker.controller) {
|
|
134
|
+
navigator.serviceWorker.controller.postMessage({ action: 'skipWaiting' });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
|
138
|
+
window.location.reload();
|
|
139
|
+
}, { once: true });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Purges sensitive API and user caches from the Service Worker upon user logout
|
|
144
|
+
*/
|
|
145
|
+
static async clearUserCacheOnLogout() {
|
|
146
|
+
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
|
|
147
|
+
navigator.serviceWorker.controller.postMessage({ action: 'clearUserCache' });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (typeof caches !== 'undefined') {
|
|
151
|
+
try {
|
|
152
|
+
const keys = await caches.keys();
|
|
153
|
+
await Promise.all(
|
|
154
|
+
keys.filter(k => k.includes('api-cache') || k.includes('user-cache')).map(k => caches.delete(k))
|
|
155
|
+
);
|
|
156
|
+
console.log('[PwaManager] Client-side sensitive user caches purged.');
|
|
157
|
+
} catch (e) {
|
|
158
|
+
console.warn('[PwaManager] Failed to purge user caches:', e);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Sets app badge counter on the phone's home screen icon (Web Badging API)
|
|
165
|
+
*
|
|
166
|
+
* @param {number|string} count
|
|
167
|
+
* @returns {Promise<void>}
|
|
168
|
+
*/
|
|
169
|
+
static setAppBadge(count) {
|
|
170
|
+
if (typeof navigator !== 'undefined' && 'setAppBadge' in navigator) {
|
|
171
|
+
const num = Math.max(0, parseInt(count, 10) || 0);
|
|
172
|
+
return (num > 0 ? navigator.setAppBadge(num) : navigator.clearAppBadge()).catch(() => {});
|
|
173
|
+
}
|
|
174
|
+
return Promise.resolve();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Clears app badge counter from the phone's home screen icon
|
|
179
|
+
*
|
|
180
|
+
* @returns {Promise<void>}
|
|
181
|
+
*/
|
|
182
|
+
static clearAppBadge() {
|
|
183
|
+
if (typeof navigator !== 'undefined' && 'clearAppBadge' in navigator) {
|
|
184
|
+
return navigator.clearAppBadge().catch(() => {});
|
|
185
|
+
}
|
|
186
|
+
return Promise.resolve();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Synchronizes app badge with the server's unread notifications endpoint
|
|
191
|
+
*
|
|
192
|
+
* @param {string|null} endpoint
|
|
193
|
+
* @returns {Promise<void>}
|
|
194
|
+
*/
|
|
195
|
+
static async syncAppBadge(endpoint = null) {
|
|
196
|
+
const url = endpoint || this.options.badgeEndpoint || '/api/notifications/unread';
|
|
197
|
+
if (typeof navigator === 'undefined' || !('setAppBadge' in navigator)) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
const res = await fetch(url, { headers: { Accept: 'application/json' } });
|
|
203
|
+
if (!res.ok) {
|
|
204
|
+
// Release the stream before bailing. An abandoned response body stays
|
|
205
|
+
// "in flight" in Chromium indefinitely, which keeps the tab from ever
|
|
206
|
+
// reaching network idle — it silently wedges Playwright's
|
|
207
|
+
// `networkidle` waits and any load-state detection for the whole page.
|
|
208
|
+
await res.body?.cancel();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const data = await res.json();
|
|
212
|
+
if (data && data.status === 'success' && Array.isArray(data.message)) {
|
|
213
|
+
const count = data.message.length;
|
|
214
|
+
if (count > 0) {
|
|
215
|
+
await this.setAppBadge(count);
|
|
216
|
+
} else {
|
|
217
|
+
await this.clearAppBadge();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
} catch (e) {
|
|
221
|
+
// Harmless no-op on network failure or offline mode
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|