@modernman00/shared-js-lib 1.2.90 → 1.6.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/AddressAutocomplete.js +164 -15
- package/AiBillSplitter.js +230 -0
- package/AiEntertainmentPrompt.js +130 -0
- package/AiFlyerStudio.js +215 -0
- package/AiMediaGuard.js +95 -0
- package/AiPrivacyGuard.js +63 -0
- package/AiSemanticSearch.js +107 -0
- package/AiVoiceRecorder.js +196 -0
- package/CanvasPhysicsEngine.js +568 -0
- package/FileUpload.js +182 -1
- package/HyperReality3DEngine.js +404 -0
- package/Loader.js +6 -2
- package/PwaInstall.js +20 -2
- package/README.md +78 -0
- package/SovereignBroadcasterClient.js +325 -0
- package/VisualSupremacyRuntime.js +312 -0
- package/WebAuthnClient.js +346 -1
- package/WebPushClient.js +308 -0
- package/axiosWrapper.js +1 -0
- package/index.js +13 -1
- package/package.json +13 -1
- package/templates/sw.template.js +105 -57
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vse-canvas.js
|
|
3
|
+
*
|
|
4
|
+
* Next-Gen Visual Supremacy Engine (VSE) Client Runtime
|
|
5
|
+
*
|
|
6
|
+
* Features:
|
|
7
|
+
* - Dynamic P3 wide colour gamut and HDR capability detection
|
|
8
|
+
* - 2.5D tactile gyroscopic micro-parallax with spring damping
|
|
9
|
+
* - Dr. Viktor Kovács & Marcus security killswitch (suspends sensors on input focus)
|
|
10
|
+
* - Accessibility & reduced-motion invariant compliance
|
|
11
|
+
* - 120Hz ProMotion frame pacing via requestAnimationFrame
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
class VisualSupremacyRuntime {
|
|
17
|
+
constructor() {
|
|
18
|
+
this.isP3 = false;
|
|
19
|
+
this.isHDR = false;
|
|
20
|
+
this.reducedMotion = false;
|
|
21
|
+
this.isLowPowerMode = false;
|
|
22
|
+
this.isSensorActive = false;
|
|
23
|
+
this.isInputFocused = false;
|
|
24
|
+
this.orientation = { gamma: 0, beta: 0 };
|
|
25
|
+
this.targetOrientation = { gamma: 0, beta: 0 };
|
|
26
|
+
this.rafId = null;
|
|
27
|
+
this.activeCards = new Set();
|
|
28
|
+
this.intersectionObserver = null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
init() {
|
|
32
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 1. Telemetry: Wide Colour Gamut & Dynamic Range Detection
|
|
37
|
+
try {
|
|
38
|
+
this.isP3 = window.matchMedia && window.matchMedia('(color-gamut: p3)').matches;
|
|
39
|
+
this.isHDR = window.matchMedia && window.matchMedia('(dynamic-range: high)').matches;
|
|
40
|
+
this.reducedMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
41
|
+
|
|
42
|
+
const root = document.documentElement;
|
|
43
|
+
if (this.isP3) {
|
|
44
|
+
root.classList.add('vse-p3-capable');
|
|
45
|
+
}
|
|
46
|
+
if (this.isHDR) {
|
|
47
|
+
root.classList.add('vse-hdr-capable');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Watch for live reduced motion preference changes
|
|
51
|
+
if (window.matchMedia) {
|
|
52
|
+
window.matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', (e) => {
|
|
53
|
+
this.reducedMotion = e.matches;
|
|
54
|
+
if (this.reducedMotion) {
|
|
55
|
+
this.resetTransforms();
|
|
56
|
+
this.stopSensors();
|
|
57
|
+
} else if (!this.isLowPowerMode) {
|
|
58
|
+
this.startSensors();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
} catch (_) {}
|
|
63
|
+
|
|
64
|
+
if (this.reducedMotion) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 2. Segun & Victor PWA Battery-Saver & Low-Memory Killswitch
|
|
69
|
+
this.setupBatteryAndMemoryGuard();
|
|
70
|
+
|
|
71
|
+
// 3. Observe media cards using IntersectionObserver to only animate visible elements
|
|
72
|
+
this.setupCardObserver();
|
|
73
|
+
|
|
74
|
+
// 4. Mandatory Adversarial Security Gate (Dr. Viktor Kovács & Marcus): Input Focus Guard
|
|
75
|
+
this.setupSecurityGuard();
|
|
76
|
+
|
|
77
|
+
// 5. Desktop Pointer Parallax Fallback
|
|
78
|
+
this.setupDesktopPointerParallax();
|
|
79
|
+
|
|
80
|
+
// 6. Initialize Sensor Pipeline
|
|
81
|
+
this.startSensors();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
setupBatteryAndMemoryGuard() {
|
|
85
|
+
try {
|
|
86
|
+
// Low-end Android / memory-constrained device guard (< 2GB RAM)
|
|
87
|
+
if (typeof navigator !== 'undefined' && navigator.deviceMemory && navigator.deviceMemory < 2) {
|
|
88
|
+
this.isLowPowerMode = true;
|
|
89
|
+
this.stopSensors();
|
|
90
|
+
this.resetTransforms();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Battery Status API guard: Disable 2.5D sensors if battery <= 20% and not charging
|
|
95
|
+
if (typeof navigator !== 'undefined' && typeof navigator.getBattery === 'function') {
|
|
96
|
+
navigator.getBattery().then((battery) => {
|
|
97
|
+
const checkBattery = () => {
|
|
98
|
+
const low = !battery.charging && battery.level <= 0.20;
|
|
99
|
+
this.isLowPowerMode = low;
|
|
100
|
+
if (low) {
|
|
101
|
+
this.stopSensors();
|
|
102
|
+
this.resetTransforms();
|
|
103
|
+
} else if (!this.reducedMotion && !this.isInputFocused && !document.hidden) {
|
|
104
|
+
this.startSensors();
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
checkBattery();
|
|
108
|
+
battery.addEventListener('levelchange', checkBattery);
|
|
109
|
+
battery.addEventListener('chargingchange', checkBattery);
|
|
110
|
+
}).catch(() => {});
|
|
111
|
+
}
|
|
112
|
+
} catch (_) {}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
setupCardObserver() {
|
|
116
|
+
if (!('IntersectionObserver' in window)) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
this.intersectionObserver = new IntersectionObserver((entries) => {
|
|
121
|
+
entries.forEach((entry) => {
|
|
122
|
+
if (entry.isIntersecting) {
|
|
123
|
+
this.activeCards.add(entry.target);
|
|
124
|
+
} else {
|
|
125
|
+
this.activeCards.delete(entry.target);
|
|
126
|
+
// Reset transform when scrolling out of viewport to save GPU compositing
|
|
127
|
+
this.resetCardTransform(entry.target);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}, { threshold: 0.1 });
|
|
131
|
+
|
|
132
|
+
// Auto-observe post-media-stage containers
|
|
133
|
+
this.refreshObservedCards();
|
|
134
|
+
|
|
135
|
+
// Re-check for dynamically inserted posts (e.g. infinite scroll, new post submission)
|
|
136
|
+
const observer = new MutationObserver(() => this.refreshObservedCards());
|
|
137
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
refreshObservedCards() {
|
|
141
|
+
if (!this.intersectionObserver) return;
|
|
142
|
+
const stages = document.querySelectorAll('.post-media-stage:not([data-vse-observed])');
|
|
143
|
+
stages.forEach((el) => {
|
|
144
|
+
el.setAttribute('data-vse-observed', 'true');
|
|
145
|
+
this.intersectionObserver.observe(el);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
setupSecurityGuard() {
|
|
150
|
+
// Gyroscopic Timing Side-Channel Defense: Immediately detach listeners on active input
|
|
151
|
+
const pauseEvents = ['focusin', 'focus'];
|
|
152
|
+
const resumeEvents = ['focusout', 'blur'];
|
|
153
|
+
|
|
154
|
+
pauseEvents.forEach((ev) => {
|
|
155
|
+
document.addEventListener(ev, (e) => {
|
|
156
|
+
const tag = e.target && e.target.tagName ? e.target.tagName.toLowerCase() : '';
|
|
157
|
+
const isEditable = e.target && (e.target.isContentEditable || tag === 'input' || tag === 'textarea' || tag === 'select');
|
|
158
|
+
if (isEditable) {
|
|
159
|
+
this.isInputFocused = true;
|
|
160
|
+
this.stopSensors();
|
|
161
|
+
this.resetTransforms();
|
|
162
|
+
}
|
|
163
|
+
}, true);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
resumeEvents.forEach((ev) => {
|
|
167
|
+
document.addEventListener(ev, () => {
|
|
168
|
+
this.isInputFocused = false;
|
|
169
|
+
if (!document.hidden && !this.reducedMotion) {
|
|
170
|
+
this.startSensors();
|
|
171
|
+
}
|
|
172
|
+
}, true);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Tab Visibility Guard
|
|
176
|
+
document.addEventListener('visibilitychange', () => {
|
|
177
|
+
if (document.hidden) {
|
|
178
|
+
this.stopSensors();
|
|
179
|
+
this.resetTransforms();
|
|
180
|
+
} else if (!this.isInputFocused && !this.reducedMotion) {
|
|
181
|
+
this.startSensors();
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
startSensors() {
|
|
187
|
+
if (this.isSensorActive || this.reducedMotion || this.isInputFocused || this.isLowPowerMode) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (typeof window.DeviceOrientationEvent !== 'undefined') {
|
|
192
|
+
this.orientationHandler = (e) => this.handleDeviceOrientation(e);
|
|
193
|
+
window.addEventListener('deviceorientation', this.orientationHandler, { passive: true });
|
|
194
|
+
this.isSensorActive = true;
|
|
195
|
+
this.scheduleRenderLoop();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
stopSensors() {
|
|
200
|
+
if (!this.isSensorActive) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (this.orientationHandler) {
|
|
205
|
+
window.removeEventListener('deviceorientation', this.orientationHandler);
|
|
206
|
+
this.orientationHandler = null;
|
|
207
|
+
}
|
|
208
|
+
if (this.rafId) {
|
|
209
|
+
cancelAnimationFrame(this.rafId);
|
|
210
|
+
this.rafId = null;
|
|
211
|
+
}
|
|
212
|
+
this.isSensorActive = false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
handleDeviceOrientation(event) {
|
|
216
|
+
if (this.isInputFocused || this.reducedMotion || this.isLowPowerMode) return;
|
|
217
|
+
|
|
218
|
+
// Gamma: Left to Right (-90 to 90), Beta: Front to Back (-180 to 180)
|
|
219
|
+
let g = event.gamma || 0;
|
|
220
|
+
let b = event.beta || 0;
|
|
221
|
+
|
|
222
|
+
// Normalise resting hand angle: smartphones are typically held tilted forward by ~45 degrees
|
|
223
|
+
b = b - 45;
|
|
224
|
+
|
|
225
|
+
// Clamp to safe, subtle displacement (±15 degrees input, mapping to ±3.5 degrees visual rotation)
|
|
226
|
+
const clamp = (val, max) => Math.max(-max, Math.min(max, val));
|
|
227
|
+
this.targetOrientation.gamma = clamp(g, 15);
|
|
228
|
+
this.targetOrientation.beta = clamp(b, 15);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
setupDesktopPointerParallax() {
|
|
232
|
+
// For non-touch/pointer devices, provide a subtle interactive hover depth response
|
|
233
|
+
if (window.matchMedia && window.matchMedia('(hover: hover)').matches) {
|
|
234
|
+
document.addEventListener('mousemove', (e) => {
|
|
235
|
+
if (this.isInputFocused || this.reducedMotion || this.activeCards.size === 0) return;
|
|
236
|
+
|
|
237
|
+
this.activeCards.forEach((card) => {
|
|
238
|
+
const rect = card.getBoundingClientRect();
|
|
239
|
+
if (e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom) {
|
|
240
|
+
const relX = ((e.clientX - rect.left) / rect.width) * 2 - 1; // -1 to 1
|
|
241
|
+
const relY = ((e.clientY - rect.top) / rect.height) * 2 - 1; // -1 to 1
|
|
242
|
+
|
|
243
|
+
const rotX = -relY * 3.5;
|
|
244
|
+
const rotY = relX * 3.5;
|
|
245
|
+
const transX = relX * 4;
|
|
246
|
+
const transY = relY * 4;
|
|
247
|
+
|
|
248
|
+
const img = card.querySelector('.post-media-first-img');
|
|
249
|
+
if (img) {
|
|
250
|
+
img.style.transform = `perspective(800px) rotateX(${rotX.toFixed(2)}deg) rotateY(${rotY.toFixed(2)}deg) translate3d(${transX.toFixed(1)}px, ${transY.toFixed(1)}px, 6px) scale(1.02)`;
|
|
251
|
+
img.style.transition = 'transform 0.12s cubic-bezier(0.2, 0, 0, 1)';
|
|
252
|
+
}
|
|
253
|
+
} else {
|
|
254
|
+
this.resetCardTransform(card);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
}, { passive: true });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
scheduleRenderLoop() {
|
|
262
|
+
if (this.rafId) return;
|
|
263
|
+
|
|
264
|
+
const render = () => {
|
|
265
|
+
// Spring damping / low-pass filter for silky 120fps motion without jitter
|
|
266
|
+
const ease = 0.08;
|
|
267
|
+
this.orientation.gamma += (this.targetOrientation.gamma - this.orientation.gamma) * ease;
|
|
268
|
+
this.orientation.beta += (this.targetOrientation.beta - this.orientation.beta) * ease;
|
|
269
|
+
|
|
270
|
+
const rotY = (this.orientation.gamma / 15) * 3.2; // Max 3.2deg
|
|
271
|
+
const rotX = (-this.orientation.beta / 15) * 3.2; // Max 3.2deg
|
|
272
|
+
const transX = (this.orientation.gamma / 15) * 4.5; // Max 4.5px
|
|
273
|
+
const transY = (this.orientation.beta / 15) * 4.5; // Max 4.5px
|
|
274
|
+
|
|
275
|
+
if (this.activeCards.size > 0 && (Math.abs(rotY) > 0.05 || Math.abs(rotX) > 0.05)) {
|
|
276
|
+
this.activeCards.forEach((card) => {
|
|
277
|
+
const img = card.querySelector('.post-media-first-img');
|
|
278
|
+
if (img) {
|
|
279
|
+
img.style.transform = `perspective(800px) rotateX(${rotX.toFixed(2)}deg) rotateY(${rotY.toFixed(2)}deg) translate3d(${transX.toFixed(1)}px, ${transY.toFixed(1)}px, 5px) scale(1.015)`;
|
|
280
|
+
img.style.transformOrigin = 'center center';
|
|
281
|
+
img.style.willChange = 'transform';
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
this.rafId = requestAnimationFrame(render);
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
this.rafId = requestAnimationFrame(render);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
resetCardTransform(card) {
|
|
293
|
+
if (!card) return;
|
|
294
|
+
const img = card.querySelector('.post-media-first-img');
|
|
295
|
+
if (img) {
|
|
296
|
+
img.style.transform = '';
|
|
297
|
+
img.style.willChange = 'auto';
|
|
298
|
+
img.style.transition = 'transform 0.3s ease-out';
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
resetTransforms() {
|
|
303
|
+
this.activeCards.forEach((card) => this.resetCardTransform(card));
|
|
304
|
+
this.targetOrientation = { gamma: 0, beta: 0 };
|
|
305
|
+
this.orientation = { gamma: 0, beta: 0 };
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export const vseRuntime = new VisualSupremacyRuntime();
|
|
310
|
+
export function initVisualSupremacy() {
|
|
311
|
+
vseRuntime.init();
|
|
312
|
+
}
|
package/WebAuthnClient.js
CHANGED
|
@@ -62,6 +62,13 @@ export class WebAuthnClient {
|
|
|
62
62
|
}
|
|
63
63
|
};
|
|
64
64
|
|
|
65
|
+
if (Array.isArray(publicKey.excludeCredentials)) {
|
|
66
|
+
publicKey.excludeCredentials = publicKey.excludeCredentials.map(cred => ({
|
|
67
|
+
...cred,
|
|
68
|
+
id: typeof cred.id === 'string' ? this.base64URLStringToBuffer(cred.id) : cred.id
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
|
|
65
72
|
const credential = await navigator.credentials.create({ publicKey });
|
|
66
73
|
|
|
67
74
|
return {
|
|
@@ -76,7 +83,10 @@ export class WebAuthnClient {
|
|
|
76
83
|
} catch (error) {
|
|
77
84
|
// David's Rule: Defensive Degradation. If it fails or is canceled, log and swallow politely.
|
|
78
85
|
console.warn("WebAuthn Registration failed or was canceled:", error);
|
|
79
|
-
|
|
86
|
+
const wrapped = new Error("Passkey registration was canceled or failed.");
|
|
87
|
+
wrapped.code = error && error.name ? error.name : 'UnknownError';
|
|
88
|
+
wrapped.originalError = error;
|
|
89
|
+
throw wrapped;
|
|
80
90
|
}
|
|
81
91
|
}
|
|
82
92
|
|
|
@@ -122,4 +132,339 @@ export class WebAuthnClient {
|
|
|
122
132
|
throw new Error("Passkey login was canceled or failed. Please use your password.");
|
|
123
133
|
}
|
|
124
134
|
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Checks if a user-verifying platform authenticator (Face ID, Touch ID, Fingerprint, Windows Hello) is available.
|
|
138
|
+
* Uses PublicKeyCredential.getClientCapabilities() with fallback to isUserVerifyingPlatformAuthenticatorAvailable().
|
|
139
|
+
* @returns {Promise<boolean>}
|
|
140
|
+
*/
|
|
141
|
+
static async isPlatformAuthenticatorAvailable() {
|
|
142
|
+
if (!this.isSupported()) {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
if (typeof window.PublicKeyCredential.getClientCapabilities === 'function') {
|
|
147
|
+
const caps = await window.PublicKeyCredential.getClientCapabilities();
|
|
148
|
+
if (caps && typeof caps.userVerifyingPlatformAuthenticator === 'boolean') {
|
|
149
|
+
return caps.userVerifyingPlatformAuthenticator;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable === 'function') {
|
|
153
|
+
return await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
|
|
154
|
+
}
|
|
155
|
+
} catch (e) {
|
|
156
|
+
console.warn("WebAuthn capability check failed:", e);
|
|
157
|
+
}
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Detects the platform-appropriate biometric label in British English.
|
|
163
|
+
* @param {string} [ua]
|
|
164
|
+
* @returns {string}
|
|
165
|
+
*/
|
|
166
|
+
static detectBiometricLabel(ua = (typeof navigator !== 'undefined' ? navigator.userAgent : '')) {
|
|
167
|
+
const agent = String(ua || '');
|
|
168
|
+
if (/iPhone|iPad|iPod|Macintosh|Mac OS X/i.test(agent)) {
|
|
169
|
+
return 'Face ID or Touch ID';
|
|
170
|
+
}
|
|
171
|
+
if (/Android/i.test(agent)) {
|
|
172
|
+
return 'Fingerprint or Face Unlock';
|
|
173
|
+
}
|
|
174
|
+
if (/Windows/i.test(agent)) {
|
|
175
|
+
return 'Windows Hello';
|
|
176
|
+
}
|
|
177
|
+
return 'Biometric Passkey';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Determines if the post-login Biometric Passkey Activation UI should appear.
|
|
182
|
+
* @param {Object} state
|
|
183
|
+
* @returns {boolean}
|
|
184
|
+
*/
|
|
185
|
+
static shouldShowActivationPrompt(state) {
|
|
186
|
+
if (!state || state.authenticated !== true || state.hasPasskey === true) {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
const key = state.userKey || state.userId || 'default';
|
|
190
|
+
try {
|
|
191
|
+
if (localStorage.getItem('biometric_enrolled_' + key) === '1') {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
const snoozeUntil = Number(localStorage.getItem('biometric_snooze_' + key) || '0');
|
|
195
|
+
if (snoozeUntil > Date.now()) {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
} catch (_) {
|
|
199
|
+
// Ignore storage access errors in strict private modes
|
|
200
|
+
}
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Snoozes the activation prompt for the specified number of hours (default 72h).
|
|
206
|
+
* @param {string} userKey
|
|
207
|
+
* @param {number} [hours=72]
|
|
208
|
+
*/
|
|
209
|
+
static snoozeActivationPrompt(userKey, hours = 72) {
|
|
210
|
+
if (!userKey) return;
|
|
211
|
+
try {
|
|
212
|
+
const until = Date.now() + hours * 3600 * 1000;
|
|
213
|
+
localStorage.setItem('biometric_snooze_' + userKey, String(until));
|
|
214
|
+
} catch (_) {}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Marks the current device + user pair as enrolled in localStorage.
|
|
219
|
+
* @param {string} userKey
|
|
220
|
+
*/
|
|
221
|
+
static markEnrolled(userKey) {
|
|
222
|
+
if (!userKey) return;
|
|
223
|
+
try {
|
|
224
|
+
localStorage.setItem('biometric_enrolled_' + userKey, '1');
|
|
225
|
+
localStorage.removeItem('biometric_snooze_' + userKey);
|
|
226
|
+
} catch (_) {}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Renders and mounts the native Biometric Passkey Activation UI modal / bottom-sheet.
|
|
231
|
+
* @param {Object} state
|
|
232
|
+
* @param {Object} [options]
|
|
233
|
+
* @returns {HTMLElement|null}
|
|
234
|
+
*/
|
|
235
|
+
static renderPromptModal(state = {}, options = {}) {
|
|
236
|
+
if (typeof document === 'undefined' || !document.body) {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
const existing = document.getElementById('universal-passkey-prompt-backdrop');
|
|
240
|
+
if (existing) {
|
|
241
|
+
return existing;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const appName = state.appName || 'this app';
|
|
245
|
+
const userKey = state.userKey || state.userId || 'default';
|
|
246
|
+
const userEmail = state.userEmail || state.userName || 'Verified Account';
|
|
247
|
+
const bioLabel = this.detectBiometricLabel();
|
|
248
|
+
|
|
249
|
+
const backdrop = document.createElement('div');
|
|
250
|
+
backdrop.id = 'universal-passkey-prompt-backdrop';
|
|
251
|
+
backdrop.setAttribute('role', 'dialog');
|
|
252
|
+
backdrop.setAttribute('aria-modal', 'true');
|
|
253
|
+
backdrop.setAttribute('aria-labelledby', 'universal-passkey-prompt-title');
|
|
254
|
+
backdrop.style.cssText = 'position:fixed;inset:0;z-index:99998;background:rgba(15,23,42,0.68);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);display:flex;align-items:flex-end;justify-content:center;padding:0;opacity:0;transition:opacity 0.22s ease-out;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;';
|
|
255
|
+
|
|
256
|
+
const styleTag = document.createElement('style');
|
|
257
|
+
styleTag.textContent = `
|
|
258
|
+
@media (min-width: 640px) {
|
|
259
|
+
#universal-passkey-prompt-backdrop { align-items: center !important; padding: 16px !important; }
|
|
260
|
+
#universal-passkey-prompt-card { border-radius: 24px !important; max-width: 420px !important; transform: translateY(8px); }
|
|
261
|
+
}
|
|
262
|
+
@keyframes passkeySpin { to { transform: rotate(360deg); } }
|
|
263
|
+
`;
|
|
264
|
+
backdrop.appendChild(styleTag);
|
|
265
|
+
|
|
266
|
+
const card = document.createElement('div');
|
|
267
|
+
card.id = 'universal-passkey-prompt-card';
|
|
268
|
+
card.style.cssText = 'width:100%;max-width:480px;background:linear-gradient(180deg,#0f172a 0%,#1e293b 100%);color:#f8fafc;border:1px solid rgba(148,163,184,0.22);border-radius:24px 24px 0 0;padding:24px 24px calc(24px + env(safe-area-inset-bottom,0px));box-shadow:0 25px 50px -12px rgba(0,0,0,0.65);transform:translateY(16px);transition:transform 0.25s cubic-bezier(0.16,1,0.3,1);box-sizing:border-box;';
|
|
269
|
+
|
|
270
|
+
card.innerHTML = `
|
|
271
|
+
<div style="width:40px;height:4px;background:rgba(148,163,184,0.35);border-radius:999px;margin:0 auto 16px;"></div>
|
|
272
|
+
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
|
|
273
|
+
<div style="display:inline-flex;align-items:center;gap:10px;background:rgba(16,185,129,0.14);border:1px solid rgba(16,185,129,0.35);padding:6px 12px;border-radius:999px;">
|
|
274
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#34d399" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
275
|
+
<path d="M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4"/><path d="M14 13.12c0 2.38 0 6.38-1 8.88"/><path d="M17.29 21.02c.12-.6.43-2.3.5-3.02"/><path d="M2 12a10 10 0 0 1 18-6"/><path d="M2 16h.01"/><path d="M21.8 16c.2-2 .131-5.354 0-6"/><path d="M5 19.5C5.5 18 6 15 6 12a6 6 0 0 1 .34-2"/><path d="M8.65 22c.21-.66.45-1.32.57-2"/><path d="M9 6.8a6 6 0 0 1 9 5.2v2"/>
|
|
276
|
+
</svg>
|
|
277
|
+
<span style="font-size:12px;font-weight:700;letter-spacing:0.04em;text-transform:uppercase;color:#6ee7b7;white-space:nowrap;">Biometric Security</span>
|
|
278
|
+
</div>
|
|
279
|
+
<span id="universal-passkey-account-pill" style="font-size:12px;color:#94a3b8;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span>
|
|
280
|
+
</div>
|
|
281
|
+
<h2 id="universal-passkey-prompt-title" style="margin:0 0 8px;font-size:22px;line-height:1.25;font-weight:800;letter-spacing:-0.02em;color:#ffffff;">Activate Biometric Sign-In</h2>
|
|
282
|
+
<p id="universal-passkey-prompt-desc" style="margin:0 0 16px;font-size:14px;line-height:1.55;color:#cbd5e1;"></p>
|
|
283
|
+
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:18px;">
|
|
284
|
+
<div style="background:rgba(30,41,59,0.75);border:1px solid rgba(148,163,184,0.16);border-radius:12px;padding:10px 8px;text-align:center;">
|
|
285
|
+
<div style="font-size:12px;font-weight:700;color:#f8fafc;">1-Tap Access</div>
|
|
286
|
+
<div style="font-size:11px;color:#94a3b8;margin-top:2px;">Instant sign-in</div>
|
|
287
|
+
</div>
|
|
288
|
+
<div style="background:rgba(30,41,59,0.75);border:1px solid rgba(148,163,184,0.16);border-radius:12px;padding:10px 8px;text-align:center;">
|
|
289
|
+
<div style="font-size:12px;font-weight:700;color:#f8fafc;">Device-Bound</div>
|
|
290
|
+
<div style="font-size:11px;color:#94a3b8;margin-top:2px;">Private enclave</div>
|
|
291
|
+
</div>
|
|
292
|
+
<div style="background:rgba(30,41,59,0.75);border:1px solid rgba(148,163,184,0.16);border-radius:12px;padding:10px 8px;text-align:center;">
|
|
293
|
+
<div style="font-size:12px;font-weight:700;color:#f8fafc;">Phishing-Proof</div>
|
|
294
|
+
<div style="font-size:11px;color:#94a3b8;margin-top:2px;">FIDO2 standard</div>
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
<div id="universal-passkey-prompt-feedback" role="status" aria-live="polite" style="display:none;margin-bottom:14px;padding:10px 12px;border-radius:12px;font-size:13px;line-height:1.4;"></div>
|
|
298
|
+
<div style="display:flex;flex-direction:column;gap:10px;">
|
|
299
|
+
<button type="button" id="universal-passkey-activate-btn" style="min-height:48px;min-width:44px;width:100%;display:inline-flex;align-items:center;justify-content:center;gap:10px;padding:12px 20px;border-radius:14px;border:none;background:linear-gradient(135deg,#10b981 0%,#059669 100%);color:#ffffff;font-size:15px;font-weight:700;cursor:pointer;white-space:nowrap;flex-shrink:0;box-shadow:0 10px 20px -5px rgba(16,185,129,0.4);transition:transform 0.15s ease,filter 0.15s ease;">
|
|
300
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
301
|
+
<path d="M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4"/><path d="M14 13.12c0 2.38 0 6.38-1 8.88"/><path d="M2 12a10 10 0 0 1 18-6"/><path d="M5 19.5C5.5 18 6 15 6 12a6 6 0 0 1 .34-2"/><path d="M9 6.8a6 6 0 0 1 9 5.2v2"/>
|
|
302
|
+
</svg>
|
|
303
|
+
<span id="universal-passkey-activate-label"></span>
|
|
304
|
+
</button>
|
|
305
|
+
<button type="button" id="universal-passkey-later-btn" style="min-height:44px;min-width:44px;width:100%;display:inline-flex;align-items:center;justify-content:center;padding:10px 16px;border-radius:12px;border:1px solid rgba(148,163,184,0.22);background:transparent;color:#cbd5e1;font-size:14px;font-weight:600;cursor:pointer;white-space:nowrap;flex-shrink:0;transition:background 0.15s ease,color 0.15s ease;">
|
|
306
|
+
Maybe Later
|
|
307
|
+
</button>
|
|
308
|
+
</div>
|
|
309
|
+
`;
|
|
310
|
+
|
|
311
|
+
backdrop.appendChild(card);
|
|
312
|
+
document.body.appendChild(backdrop);
|
|
313
|
+
|
|
314
|
+
const accountPill = card.querySelector('#universal-passkey-account-pill');
|
|
315
|
+
if (accountPill) {
|
|
316
|
+
accountPill.textContent = userEmail;
|
|
317
|
+
accountPill.setAttribute('title', userEmail);
|
|
318
|
+
}
|
|
319
|
+
const descEl = card.querySelector('#universal-passkey-prompt-desc');
|
|
320
|
+
if (descEl) {
|
|
321
|
+
descEl.textContent = `Sign in to ${appName} instantly next time using your device's recognised biometric sensor (${bioLabel}). Your biometric data never leaves this device.`;
|
|
322
|
+
}
|
|
323
|
+
const activateLabelEl = card.querySelector('#universal-passkey-activate-label');
|
|
324
|
+
if (activateLabelEl) {
|
|
325
|
+
activateLabelEl.textContent = `Activate ${bioLabel}`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
requestAnimationFrame(() => {
|
|
329
|
+
backdrop.style.opacity = '1';
|
|
330
|
+
card.style.transform = 'translateY(0)';
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const closePrompt = () => {
|
|
334
|
+
backdrop.style.opacity = '0';
|
|
335
|
+
card.style.transform = 'translateY(16px)';
|
|
336
|
+
setTimeout(() => {
|
|
337
|
+
if (backdrop.parentNode) {
|
|
338
|
+
backdrop.parentNode.removeChild(backdrop);
|
|
339
|
+
}
|
|
340
|
+
}, 220);
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const showFeedback = (msg, isSuccess = false) => {
|
|
344
|
+
const fb = card.querySelector('#universal-passkey-prompt-feedback');
|
|
345
|
+
if (!fb) return;
|
|
346
|
+
fb.style.display = 'block';
|
|
347
|
+
fb.style.background = isSuccess ? 'rgba(16,185,129,0.18)' : 'rgba(245,158,11,0.16)';
|
|
348
|
+
fb.style.border = isSuccess ? '1px solid rgba(16,185,129,0.4)' : '1px solid rgba(245,158,11,0.38)';
|
|
349
|
+
fb.style.color = isSuccess ? '#a7f3d0' : '#fde68a';
|
|
350
|
+
fb.textContent = msg;
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const activateBtn = card.querySelector('#universal-passkey-activate-btn');
|
|
354
|
+
const laterBtn = card.querySelector('#universal-passkey-later-btn');
|
|
355
|
+
|
|
356
|
+
if (laterBtn) {
|
|
357
|
+
laterBtn.addEventListener('click', () => {
|
|
358
|
+
this.snoozeActivationPrompt(userKey, 72);
|
|
359
|
+
closePrompt();
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (activateBtn) {
|
|
364
|
+
const originalHTML = activateBtn.innerHTML;
|
|
365
|
+
activateBtn.addEventListener('click', async () => {
|
|
366
|
+
if (activateBtn.disabled) return;
|
|
367
|
+
activateBtn.disabled = true;
|
|
368
|
+
activateBtn.style.opacity = '0.85';
|
|
369
|
+
activateBtn.style.cursor = 'wait';
|
|
370
|
+
activateBtn.innerHTML = `
|
|
371
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" style="animation:passkeySpin 0.8s linear infinite;" aria-hidden="true">
|
|
372
|
+
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
|
373
|
+
</svg>
|
|
374
|
+
<span>Authorising Biometrics...</span>
|
|
375
|
+
`;
|
|
376
|
+
|
|
377
|
+
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
|
|
378
|
+
const headers = {
|
|
379
|
+
'Accept': 'application/json',
|
|
380
|
+
'Content-Type': 'application/json',
|
|
381
|
+
'X-Requested-With': 'XMLHttpRequest'
|
|
382
|
+
};
|
|
383
|
+
if (csrfMeta && csrfMeta.content) {
|
|
384
|
+
headers['X-CSRF-TOKEN'] = csrfMeta.content;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
try {
|
|
388
|
+
const optRes = await fetch(options.optionsEndpoint || '/webauthn/register/options', {
|
|
389
|
+
method: 'POST',
|
|
390
|
+
headers,
|
|
391
|
+
credentials: 'same-origin',
|
|
392
|
+
body: JSON.stringify({ role: state.userRole || 'users' })
|
|
393
|
+
});
|
|
394
|
+
const optJson = await optRes.json();
|
|
395
|
+
const creationOptions = optJson.token || optJson.data || optJson;
|
|
396
|
+
if (!optRes.ok || !creationOptions || !creationOptions.challenge) {
|
|
397
|
+
throw new Error(optJson.message || 'Unable to initialise biometric challenge.');
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const credentialPayload = await this.registerDevice(creationOptions);
|
|
401
|
+
credentialPayload.role = state.userRole || 'users';
|
|
402
|
+
credentialPayload.deviceName = bioLabel;
|
|
403
|
+
|
|
404
|
+
const verifyRes = await fetch(options.registerEndpoint || '/webauthn/register', {
|
|
405
|
+
method: 'POST',
|
|
406
|
+
headers,
|
|
407
|
+
credentials: 'same-origin',
|
|
408
|
+
body: JSON.stringify(credentialPayload)
|
|
409
|
+
});
|
|
410
|
+
const verifyJson = await verifyRes.json();
|
|
411
|
+
if (!verifyRes.ok && verifyJson.status !== 'success') {
|
|
412
|
+
throw new Error(verifyJson.message || 'Failed to verify biometric passkey.');
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
this.markEnrolled(userKey);
|
|
416
|
+
showFeedback('Biometric Passkey activated! You can now sign in with 1 tap.', true);
|
|
417
|
+
setTimeout(closePrompt, 1100);
|
|
418
|
+
} catch (err) {
|
|
419
|
+
if (err && err.code === 'InvalidStateError') {
|
|
420
|
+
// Self-healing: authenticator already holds a credential for this user/RP
|
|
421
|
+
this.markEnrolled(userKey);
|
|
422
|
+
showFeedback('Biometric Passkey is already active on this device.', true);
|
|
423
|
+
setTimeout(closePrompt, 1100);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
activateBtn.disabled = false;
|
|
427
|
+
activateBtn.style.opacity = '1';
|
|
428
|
+
activateBtn.style.cursor = 'pointer';
|
|
429
|
+
activateBtn.innerHTML = originalHTML;
|
|
430
|
+
const rehydratedLabel = activateBtn.querySelector('#universal-passkey-activate-label');
|
|
431
|
+
if (rehydratedLabel) {
|
|
432
|
+
rehydratedLabel.textContent = `Activate ${bioLabel}`;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
let msg = 'Biometric verification was cancelled or did not complete. Tap Activate whenever you are ready to try again.';
|
|
436
|
+
if (err) {
|
|
437
|
+
if (err.name === 'SecurityError' || err.code === 'SecurityError') {
|
|
438
|
+
msg = 'Passkey registration requires HTTPS or a valid domain origin.';
|
|
439
|
+
} else if (err.name === 'NotSupportedError' || err.code === 'NotSupportedError') {
|
|
440
|
+
msg = 'Biometric authentication is not supported or enabled on this browser.';
|
|
441
|
+
} else if (err.message && err.name !== 'NotAllowedError' && err.code !== 'NotAllowedError') {
|
|
442
|
+
msg = `${err.message}. Tap Activate to try again.`;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
showFeedback(msg, false);
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return backdrop;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Automatically evaluates post-login state and shows the Biometric Passkey Activation UI if eligible.
|
|
455
|
+
* @param {Object} [customState]
|
|
456
|
+
* @returns {Promise<boolean>}
|
|
457
|
+
*/
|
|
458
|
+
static async initPostLoginPrompt(customState = null) {
|
|
459
|
+
const state = customState || (typeof window !== 'undefined' ? window.__WEBAUTHN_PROMPT_STATE__ : null);
|
|
460
|
+
if (!this.shouldShowActivationPrompt(state)) {
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
463
|
+
const available = await this.isPlatformAuthenticatorAvailable();
|
|
464
|
+
if (!available) {
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
this.renderPromptModal(state);
|
|
468
|
+
return true;
|
|
469
|
+
}
|
|
125
470
|
}
|