ruby_everywhere 0.1.15 → 0.2.0
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.
- checksums.yaml +4 -4
- data/bridge/README.md +57 -2
- data/bridge/everywhere/bridge.js +803 -9
- data/bridge/package.json +1 -1
- data/lib/everywhere/builders/ios.rb +364 -0
- data/lib/everywhere/cli.rb +2 -0
- data/lib/everywhere/commands/build.rb +17 -1
- data/lib/everywhere/commands/clean.rb +19 -10
- data/lib/everywhere/commands/dev.rb +182 -19
- data/lib/everywhere/commands/doctor.rb +45 -3
- data/lib/everywhere/commands/icon.rb +14 -0
- data/lib/everywhere/commands/install.rb +37 -6
- data/lib/everywhere/commands/logs.rb +56 -0
- data/lib/everywhere/commands/platform/build.rb +3 -3
- data/lib/everywhere/commands/platform/runner.rb +1 -1
- data/lib/everywhere/commands/release.rb +1 -1
- data/lib/everywhere/commands/shell_dir.rb +11 -4
- data/lib/everywhere/config.rb +366 -1
- data/lib/everywhere/engine.rb +33 -1
- data/lib/everywhere/icon.rb +50 -0
- data/lib/everywhere/log_filter.rb +28 -2
- data/lib/everywhere/mobile_config_endpoint.rb +75 -0
- data/lib/everywhere/mobile_configs_controller.rb +46 -0
- data/lib/everywhere/native_helper.rb +156 -0
- data/lib/everywhere/paths.rb +41 -0
- data/lib/everywhere/raster.rb +17 -0
- data/lib/everywhere/shellout.rb +3 -1
- data/lib/everywhere/simulator.rb +74 -0
- data/lib/everywhere/ui.rb +18 -0
- data/lib/everywhere/version.rb +1 -1
- data/support/mobile/ios/App/App.xcconfig +6 -0
- data/support/mobile/ios/App/AppDelegate.swift +163 -0
- data/support/mobile/ios/App/Assets.xcassets/AccentColor.colorset/Contents.json +20 -0
- data/support/mobile/ios/App/Assets.xcassets/AppIcon.appiconset/AppIcon.png +0 -0
- data/support/mobile/ios/App/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
- data/support/mobile/ios/App/Assets.xcassets/Contents.json +6 -0
- data/support/mobile/ios/App/Assets.xcassets/LaunchBackground.colorset/Contents.json +38 -0
- data/support/mobile/ios/App/Base.lproj/LaunchScreen.storyboard +32 -0
- data/support/mobile/ios/App/Bridge/BiometricsComponent.swift +276 -0
- data/support/mobile/ios/App/Bridge/HapticsComponent.swift +47 -0
- data/support/mobile/ios/App/Bridge/NotificationComponent.swift +56 -0
- data/support/mobile/ios/App/Bridge/PermissionsComponent.swift +142 -0
- data/support/mobile/ios/App/Bridge/StorageComponent.swift +63 -0
- data/support/mobile/ios/App/ErrorViewController.swift +64 -0
- data/support/mobile/ios/App/EverywhereConfig.swift +268 -0
- data/support/mobile/ios/App/EverywhereHost.swift +34 -0
- data/support/mobile/ios/App/Extensions/EverywhereExtensions.swift +32 -0
- data/support/mobile/ios/App/Info.plist +28 -0
- data/support/mobile/ios/App/Resources/everywhere.json +8 -0
- data/support/mobile/ios/App/Resources/path-configuration.json +19 -0
- data/support/mobile/ios/App/SceneDelegate.swift +443 -0
- data/support/mobile/ios/App.xcodeproj/project.pbxproj +454 -0
- data/support/mobile/ios/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- data/support/mobile/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +14 -0
- data/support/mobile/ios/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme +77 -0
- data/support/mobile/ios/NativeExtensions/Package.swift +26 -0
- data/support/mobile/ios/NativeExtensions/Sources/NativeExtensions/Exports.swift +5 -0
- data/support/mobile/ios/README.md +66 -0
- metadata +35 -1
data/bridge/everywhere/bridge.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @rubyeverywhere/bridge — one API for the browser, the RubyEverywhere desktop
|
|
2
|
-
// shell, and
|
|
2
|
+
// shell, and Hotwire Native mobile apps.
|
|
3
3
|
//
|
|
4
4
|
// App code writes to this surface only; platform differences live in the
|
|
5
5
|
// adapters below. Everything degrades: the same page works in a plain browser
|
|
@@ -15,6 +15,83 @@
|
|
|
15
15
|
// Everywhere.confirm("Sure?") // Promise<boolean>, native dialog when possible
|
|
16
16
|
// Everywhere.on("menu", handler) // shell events; returns unsubscribe fn
|
|
17
17
|
// Everywhere.visit("/settings") // Turbo.visit with location fallback
|
|
18
|
+
// Everywhere.reloadTabs() // re-fetch native tab/nav config (mobile)
|
|
19
|
+
//
|
|
20
|
+
// Everywhere.haptics.impact("medium") // light | medium | heavy | soft | rigid
|
|
21
|
+
// Everywhere.haptics.notification("success") // success | warning | error
|
|
22
|
+
// Everywhere.haptics.selection()
|
|
23
|
+
// Everywhere.badge.set(3) // app icon badge (mobile; PWA Badging API in browsers)
|
|
24
|
+
// Everywhere.badge.clear()
|
|
25
|
+
// Everywhere.badge.setTab("/inbox", 3) // native tab bar badge (mobile)
|
|
26
|
+
// Everywhere.badge.clearTab("/inbox")
|
|
27
|
+
//
|
|
28
|
+
// Everywhere.permissions.query("camera") // Promise<{name, status}>
|
|
29
|
+
// Everywhere.permissions.request("camera") // prompts when possible
|
|
30
|
+
// status: "granted" | "denied" | "prompt" | "undeclared" | "unsupported"
|
|
31
|
+
// (mobile permissions must be declared in everywhere.yml with a usage
|
|
32
|
+
// string — undeclared ones resolve "undeclared" and never prompt)
|
|
33
|
+
// Everywhere.permissions.openSettings() // the denied dead-end: iOS never
|
|
34
|
+
// // re-prompts, so send users here
|
|
35
|
+
//
|
|
36
|
+
// Everywhere.biometrics.available() // Promise<{available, biometry, status}>
|
|
37
|
+
// biometry: "faceID" | "touchID" | "opticID" | "none"
|
|
38
|
+
// status: "available" | "notEnrolled" | "notAvailable" | "lockout"
|
|
39
|
+
// | "passcodeNotSet" | "undeclared" | "unsupported"
|
|
40
|
+
// Everywhere.biometrics.authenticate({ reason: "Unlock your vault" })
|
|
41
|
+
// // Promise<{authenticated, error?}> — shows the Face ID / Touch ID sheet.
|
|
42
|
+
// // { allowPasscode: true } lets the device passcode satisfy the check.
|
|
43
|
+
// error: "canceled" | "fallback" | "lockout" | "notEnrolled"
|
|
44
|
+
// | "notAvailable" | "passcodeNotSet" | "failed" | "undeclared" | "unsupported"
|
|
45
|
+
// (mobile only, and must be declared in everywhere.yml — that's what stamps
|
|
46
|
+
// the Face ID usage string. This gates the PAGE, not the server: pair it
|
|
47
|
+
// with real session auth for anything the backend must trust.)
|
|
48
|
+
// Everywhere.biometrics.lockEnabled // device-local "require biometrics" pref
|
|
49
|
+
// Everywhere.biometrics.setLockEnabled(true) // Promise<{enabled, error?}>;
|
|
50
|
+
// // authenticates before flipping, in either direction
|
|
51
|
+
//
|
|
52
|
+
// Everywhere.biometrics.credential // biometric-protected keychain secret
|
|
53
|
+
// .store(token, {reason}) // Promise<{stored, error?}>
|
|
54
|
+
// .get({reason}) // Promise<{token?, error?}> — Face ID prompt
|
|
55
|
+
// .status() // Promise<{enrolled}> — no prompt
|
|
56
|
+
// .clear() // Promise<{cleared, error?}>
|
|
57
|
+
// ("sign in with Face ID": server issues a token, store() locks it behind
|
|
58
|
+
// biometrics, get() releases it at login to exchange for a session.
|
|
59
|
+
// Enrollment changes invalidate it → get() resolves {error: "notEnrolled"}.)
|
|
60
|
+
//
|
|
61
|
+
// Declarative gate (everywhere_biometric_lock / _toggle Rails helpers):
|
|
62
|
+
// <div data-everywhere-biometric-lock="key" data-everywhere-biometric-reason="…"
|
|
63
|
+
// data-everywhere-biometric-passcode>
|
|
64
|
+
// <div data-everywhere-biometric-content hidden>…</div>
|
|
65
|
+
// <div data-everywhere-biometric-locked hidden>
|
|
66
|
+
// <button data-everywhere-biometric-unlock>Unlock</button></div>
|
|
67
|
+
// </div>
|
|
68
|
+
// <input type="checkbox" hidden disabled data-everywhere-biometric-toggle>
|
|
69
|
+
// Content stays hidden until the check passes whenever lockEnabled is on in
|
|
70
|
+
// the mobile shell; elsewhere it reveals. Passes persist per JS session.
|
|
71
|
+
//
|
|
72
|
+
// Declarative (no JS): the bridge watches every page for
|
|
73
|
+
// <meta name="everywhere:badge" content="3">
|
|
74
|
+
// <meta name="everywhere:tab-badge" content='{"path":"/inbox","count":3}'>
|
|
75
|
+
// (the everywhere_badge / everywhere_tab_badge Rails helpers) and applies
|
|
76
|
+
// them on each Turbo visit, and plays haptics for taps on any element with
|
|
77
|
+
// data-everywhere-haptic (e.g. "light", "impact:heavy", "notification:error").
|
|
78
|
+
//
|
|
79
|
+
// Everywhere.storage.get("key") // Promise<any|null> — device-local settings
|
|
80
|
+
// Everywhere.storage.set("key", value) // Promise<boolean>; value is any JSON value
|
|
81
|
+
// Everywhere.storage.remove("key") // Promise<boolean>
|
|
82
|
+
// Everywhere.storage.clear() // Promise<boolean> — app keys only
|
|
83
|
+
// Persists in the mobile shell's UserDefaults (survives web view resets
|
|
84
|
+
// and cache clears — unlike localStorage); localStorage-backed elsewhere.
|
|
85
|
+
// Settings, not secrets: tokens belong in biometrics.credential.
|
|
86
|
+
//
|
|
87
|
+
// Everywhere.instance.supported // true when the shell allows instance switching
|
|
88
|
+
// Everywhere.instance.current // active instance URL, or null (= default root)
|
|
89
|
+
// Everywhere.instance.set(url, {to}) // persist + full reset onto the instance
|
|
90
|
+
// Everywhere.instance.clear({to}) // back to the built-in root (the picker)
|
|
91
|
+
// Multi-instance apps (remote.instances: true in everywhere.yml): the
|
|
92
|
+
// shell boots into remote.url — your hosted instance picker — until the
|
|
93
|
+
// picker calls set(); after that every launch boots the chosen instance
|
|
94
|
+
// until clear(). In a plain browser set() just navigates to the URL.
|
|
18
95
|
//
|
|
19
96
|
// Everywhere.updates.supported // true when the shell has an update feed
|
|
20
97
|
// Everywhere.updates.channel // effective channel ("stable", "beta", …)
|
|
@@ -173,6 +250,120 @@ const browser = {
|
|
|
173
250
|
return null
|
|
174
251
|
},
|
|
175
252
|
|
|
253
|
+
// Honest approximation: the Vibration API where it exists (Android
|
|
254
|
+
// browsers); silently nothing elsewhere (iOS Safari has no equivalent).
|
|
255
|
+
haptic(kind, value) {
|
|
256
|
+
if (!navigator.vibrate) return
|
|
257
|
+
if (kind === "impact") {
|
|
258
|
+
navigator.vibrate({ light: 8, medium: 15, heavy: 25, soft: 8, rigid: 15 }[value] || 15)
|
|
259
|
+
} else if (kind === "notification") {
|
|
260
|
+
navigator.vibrate(value === "error" ? [30, 60, 30] : value === "warning" ? [20, 40, 20] : [10, 30, 10])
|
|
261
|
+
} else {
|
|
262
|
+
navigator.vibrate(5)
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
|
|
266
|
+
// App icon badge via the PWA Badging API (installed PWAs only; rejects
|
|
267
|
+
// silently elsewhere). Tab badges have no browser equivalent.
|
|
268
|
+
badgeSet(count) {
|
|
269
|
+
if (!navigator.setAppBadge) return
|
|
270
|
+
const call = count > 0 ? navigator.setAppBadge(count) : navigator.clearAppBadge()
|
|
271
|
+
if (call && call.catch) call.catch(() => {})
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
badgeSetTab() {},
|
|
275
|
+
|
|
276
|
+
async permissionQuery(name) {
|
|
277
|
+
if (name === "notifications" && "Notification" in window) {
|
|
278
|
+
const map = { granted: "granted", denied: "denied", default: "prompt" }
|
|
279
|
+
return { name, status: map[Notification.permission] || "prompt" }
|
|
280
|
+
}
|
|
281
|
+
if (navigator.permissions) {
|
|
282
|
+
try {
|
|
283
|
+
const result = await navigator.permissions.query({
|
|
284
|
+
name: name === "location" ? "geolocation" : name
|
|
285
|
+
})
|
|
286
|
+
return { name, status: result.state } // granted | denied | prompt
|
|
287
|
+
} catch (_) {}
|
|
288
|
+
}
|
|
289
|
+
return { name, status: "unsupported" }
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
// Requests use each capability's own prompt-triggering API — there is no
|
|
293
|
+
// generic "request" in browsers.
|
|
294
|
+
async permissionRequest(name) {
|
|
295
|
+
if (name === "notifications" && "Notification" in window) {
|
|
296
|
+
const result = await Notification.requestPermission()
|
|
297
|
+
return { name, status: result === "default" ? "prompt" : result }
|
|
298
|
+
}
|
|
299
|
+
if (name === "camera" && navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
|
300
|
+
try {
|
|
301
|
+
const stream = await navigator.mediaDevices.getUserMedia({ video: true })
|
|
302
|
+
stream.getTracks().forEach((track) => track.stop())
|
|
303
|
+
return { name, status: "granted" }
|
|
304
|
+
} catch (_) {
|
|
305
|
+
return { name, status: "denied" }
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (name === "location" && navigator.geolocation) {
|
|
309
|
+
return new Promise((resolve) => {
|
|
310
|
+
navigator.geolocation.getCurrentPosition(
|
|
311
|
+
() => resolve({ name, status: "granted" }),
|
|
312
|
+
(error) => resolve({ name, status: error.code === 1 ? "denied" : "prompt" })
|
|
313
|
+
)
|
|
314
|
+
})
|
|
315
|
+
}
|
|
316
|
+
return this.permissionQuery(name)
|
|
317
|
+
},
|
|
318
|
+
|
|
319
|
+
permissionOpenSettings() {},
|
|
320
|
+
|
|
321
|
+
// Web pages can't invoke Face ID / Touch ID outside WebAuthn ceremonies
|
|
322
|
+
// (which need registered credentials), so the browser answer is honest:
|
|
323
|
+
// unsupported. Gate on `available` and fall back to your normal auth.
|
|
324
|
+
biometricsAvailable() {
|
|
325
|
+
return Promise.resolve({ available: false, biometry: "none", status: "unsupported" })
|
|
326
|
+
},
|
|
327
|
+
|
|
328
|
+
biometricsAuthenticate() {
|
|
329
|
+
return Promise.resolve({ authenticated: false, error: "unsupported" })
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
biometricsCredential(event) {
|
|
333
|
+
return Promise.resolve(
|
|
334
|
+
event === "credentialStatus" ? { enrolled: false } : { error: "unsupported" }
|
|
335
|
+
)
|
|
336
|
+
},
|
|
337
|
+
|
|
338
|
+
// Honest fallback: localStorage under a namespaced key. Same wire shape as
|
|
339
|
+
// the mobile component — value is a JSON-encoded string or null.
|
|
340
|
+
storage(event, data) {
|
|
341
|
+
const key = data && data.key != null ? "everywhere:app:" + data.key : null
|
|
342
|
+
try {
|
|
343
|
+
switch (event) {
|
|
344
|
+
case "get":
|
|
345
|
+
return Promise.resolve({ ok: !!key, value: key ? localStorage.getItem(key) : null })
|
|
346
|
+
case "set":
|
|
347
|
+
if (!key || typeof data.value !== "string") return Promise.resolve({ ok: false })
|
|
348
|
+
localStorage.setItem(key, data.value)
|
|
349
|
+
return Promise.resolve({ ok: true })
|
|
350
|
+
case "remove":
|
|
351
|
+
if (!key) return Promise.resolve({ ok: false })
|
|
352
|
+
localStorage.removeItem(key)
|
|
353
|
+
return Promise.resolve({ ok: true })
|
|
354
|
+
case "clear":
|
|
355
|
+
for (let i = localStorage.length - 1; i >= 0; i--) {
|
|
356
|
+
const name = localStorage.key(i)
|
|
357
|
+
if (name && name.startsWith("everywhere:app:")) localStorage.removeItem(name)
|
|
358
|
+
}
|
|
359
|
+
return Promise.resolve({ ok: true })
|
|
360
|
+
}
|
|
361
|
+
} catch (_) {
|
|
362
|
+
// localStorage can throw (privacy mode, quota) — report, don't break.
|
|
363
|
+
}
|
|
364
|
+
return Promise.resolve({ ok: false })
|
|
365
|
+
},
|
|
366
|
+
|
|
176
367
|
// Auto-updates only exist inside the shell; the browser tab is always "current".
|
|
177
368
|
updatesCheck() {
|
|
178
369
|
return Promise.resolve({ available: false, unsupported: true })
|
|
@@ -189,16 +380,266 @@ const browser = {
|
|
|
189
380
|
}
|
|
190
381
|
}
|
|
191
382
|
|
|
192
|
-
// --- mobile: Hotwire Native
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
383
|
+
// --- mobile: Hotwire Native --------------------------------------------------
|
|
384
|
+
// The native shell injects window.nativeBridge (the native adapter). The web
|
|
385
|
+
// half of the handshake is normally @hotwired/hotwire-native-bridge; apps that
|
|
386
|
+
// load it keep it (same first-loaded-wins guard the official package uses).
|
|
387
|
+
// Otherwise we install a minimal wire-compatible web bridge, enough for
|
|
388
|
+
// component messages with replies — which is all notify() needs.
|
|
389
|
+
//
|
|
390
|
+
// confirm()/clipboard fall back to the browser adapter on purpose: WKWebView
|
|
391
|
+
// renders window.confirm as a real native alert, and the clipboard API works.
|
|
392
|
+
|
|
393
|
+
const NOTIFY_COMPONENT = "everywhere--notification"
|
|
394
|
+
const HAPTICS_COMPONENT = "everywhere--haptics"
|
|
395
|
+
const PERMISSIONS_COMPONENT = "everywhere--permissions"
|
|
396
|
+
const BIOMETRICS_COMPONENT = "everywhere--biometrics"
|
|
397
|
+
const STORAGE_COMPONENT = "everywhere--storage"
|
|
398
|
+
|
|
399
|
+
function installWebBridge() {
|
|
400
|
+
if (window.HotwireNative) return
|
|
401
|
+
|
|
402
|
+
let adapter = null
|
|
403
|
+
let lastId = 0
|
|
404
|
+
const pending = []
|
|
405
|
+
const callbacks = new Map()
|
|
406
|
+
|
|
407
|
+
const web = {
|
|
408
|
+
supportsComponent(component) {
|
|
409
|
+
return adapter ? adapter.supportsComponent(component) : false
|
|
410
|
+
},
|
|
411
|
+
|
|
412
|
+
send({ component, event, data, callback }) {
|
|
413
|
+
if (!adapter) {
|
|
414
|
+
pending.push({ component, event, data, callback })
|
|
415
|
+
return null
|
|
416
|
+
}
|
|
417
|
+
if (!this.supportsComponent(component)) return null
|
|
418
|
+
|
|
419
|
+
const id = (++lastId).toString()
|
|
420
|
+
if (callback) callbacks.set(id, callback)
|
|
421
|
+
// metadata.url rides INSIDE data (the official web bridge's wire
|
|
422
|
+
// format); the native side drops messages whose metadata.url doesn't
|
|
423
|
+
// match the destination's location (Hotwire Native ≥ 1.3).
|
|
424
|
+
adapter.receive({
|
|
425
|
+
id, component, event,
|
|
426
|
+
data: { ...(data || {}), metadata: { url: window.location.href } }
|
|
427
|
+
})
|
|
428
|
+
return id
|
|
429
|
+
},
|
|
430
|
+
|
|
431
|
+
// Replies from native arrive here (nativeBridge.replyWith).
|
|
432
|
+
receive(message) {
|
|
433
|
+
const callback = callbacks.get(message.id)
|
|
434
|
+
if (callback) callback(message)
|
|
435
|
+
},
|
|
436
|
+
|
|
437
|
+
removeCallbackFor(id) {
|
|
438
|
+
callbacks.delete(id)
|
|
439
|
+
},
|
|
440
|
+
|
|
441
|
+
setAdapter(newAdapter) {
|
|
442
|
+
adapter = newAdapter
|
|
443
|
+
document.documentElement.dataset.bridgePlatform = adapter.platform
|
|
444
|
+
this.adapterDidUpdateSupportedComponents()
|
|
445
|
+
pending.splice(0).forEach((message) => this.send(message))
|
|
446
|
+
},
|
|
447
|
+
|
|
448
|
+
adapterDidUpdateSupportedComponents() {
|
|
449
|
+
if (adapter) {
|
|
450
|
+
document.documentElement.dataset.bridgeComponents =
|
|
451
|
+
adapter.supportedComponents.join(" ")
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
window.HotwireNative = { web }
|
|
457
|
+
document.dispatchEvent(new Event("web-bridge:ready"))
|
|
458
|
+
}
|
|
197
459
|
|
|
198
460
|
const mobile = {
|
|
199
|
-
...browser
|
|
200
|
-
|
|
201
|
-
//
|
|
461
|
+
...browser,
|
|
462
|
+
|
|
463
|
+
// Native local notification via the shell's "everywhere--notification"
|
|
464
|
+
// bridge component; web Notification fallback when the shell doesn't
|
|
465
|
+
// register it (an older shell, or before the handshake completes).
|
|
466
|
+
notify({ title, body }) {
|
|
467
|
+
const web = window.HotwireNative && window.HotwireNative.web
|
|
468
|
+
if (!web || !web.supportsComponent(NOTIFY_COMPONENT)) {
|
|
469
|
+
return browser.notify({ title, body })
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return new Promise((resolve) => {
|
|
473
|
+
const timer = setTimeout(() => resolve({ delivered: false, timedOut: true }), 10000)
|
|
474
|
+
const id = web.send({
|
|
475
|
+
component: NOTIFY_COMPONENT,
|
|
476
|
+
event: "notify",
|
|
477
|
+
data: { title, body },
|
|
478
|
+
callback: (message) => {
|
|
479
|
+
clearTimeout(timer)
|
|
480
|
+
if (id !== null && web.removeCallbackFor) web.removeCallbackFor(id)
|
|
481
|
+
resolve(message.data)
|
|
482
|
+
}
|
|
483
|
+
})
|
|
484
|
+
})
|
|
485
|
+
},
|
|
486
|
+
|
|
487
|
+
// Ask the shell to re-fetch its native navigation config (tabs + path
|
|
488
|
+
// rules) now, instead of waiting for the next app foreground. Call it after
|
|
489
|
+
// a change that affects which tabs should show — signing in or out — so the
|
|
490
|
+
// tab bar updates immediately. Uses a dedicated WKScriptMessageHandler, so
|
|
491
|
+
// it doesn't depend on a bridge component being mounted on the page.
|
|
492
|
+
reloadTabs() {
|
|
493
|
+
const control = nativeControlChannel()
|
|
494
|
+
if (control) {
|
|
495
|
+
control.postMessage({ action: "reloadConfig" })
|
|
496
|
+
} else {
|
|
497
|
+
console.debug("[everywhere] reloadTabs: no native control channel (older shell?)")
|
|
498
|
+
}
|
|
499
|
+
},
|
|
500
|
+
|
|
501
|
+
// Full app reset (fresh web views + re-fetched tabs), then land on `to`.
|
|
502
|
+
// Falls back to a plain navigation when there's no native channel.
|
|
503
|
+
resetApp(to) {
|
|
504
|
+
const target = to || window.location.pathname + window.location.search
|
|
505
|
+
const control = nativeControlChannel()
|
|
506
|
+
if (control) control.postMessage({ action: "reset", to: target })
|
|
507
|
+
else window.location.assign(target)
|
|
508
|
+
},
|
|
509
|
+
|
|
510
|
+
// Instance switching rides the control channel: it re-roots the whole app,
|
|
511
|
+
// which is shell-level state, not page state. The shell validates,
|
|
512
|
+
// persists, and resets; nothing to wait on.
|
|
513
|
+
instanceSet(url, to) {
|
|
514
|
+
const control = nativeControlChannel()
|
|
515
|
+
if (control) control.postMessage({ action: "setInstance", url, ...(to ? { to } : {}) })
|
|
516
|
+
else window.location.assign(url)
|
|
517
|
+
},
|
|
518
|
+
|
|
519
|
+
instanceClear(to) {
|
|
520
|
+
const control = nativeControlChannel()
|
|
521
|
+
if (control) control.postMessage({ action: "clearInstance", ...(to ? { to } : {}) })
|
|
522
|
+
},
|
|
523
|
+
|
|
524
|
+
// Real haptics via the shell's "everywhere--haptics" component; vibration
|
|
525
|
+
// fallback while the handshake completes or on an older shell.
|
|
526
|
+
haptic(kind, value) {
|
|
527
|
+
const web = window.HotwireNative && window.HotwireNative.web
|
|
528
|
+
if (!web || !web.supportsComponent(HAPTICS_COMPONENT)) {
|
|
529
|
+
return browser.haptic(kind, value)
|
|
530
|
+
}
|
|
531
|
+
const data = kind === "impact" ? { style: value }
|
|
532
|
+
: kind === "notification" ? { type: value }
|
|
533
|
+
: {}
|
|
534
|
+
web.send({ component: HAPTICS_COMPONENT, event: kind, data })
|
|
535
|
+
},
|
|
536
|
+
|
|
537
|
+
// Badges ride the control channel (like reloadTabs): they're app-level
|
|
538
|
+
// state, not tied to whichever page has bridge components mounted.
|
|
539
|
+
badgeSet(count) {
|
|
540
|
+
const control = nativeControlChannel()
|
|
541
|
+
if (control) control.postMessage({ action: "setBadge", count })
|
|
542
|
+
else browser.badgeSet(count)
|
|
543
|
+
},
|
|
544
|
+
|
|
545
|
+
badgeSetTab(path, count) {
|
|
546
|
+
const control = nativeControlChannel()
|
|
547
|
+
if (control) control.postMessage({ action: "setTabBadge", path, count })
|
|
548
|
+
},
|
|
549
|
+
|
|
550
|
+
permissionQuery(name) {
|
|
551
|
+
return this._permission("query", name)
|
|
552
|
+
},
|
|
553
|
+
|
|
554
|
+
permissionRequest(name) {
|
|
555
|
+
return this._permission("request", name)
|
|
556
|
+
},
|
|
557
|
+
|
|
558
|
+
permissionOpenSettings() {
|
|
559
|
+
const web = window.HotwireNative && window.HotwireNative.web
|
|
560
|
+
if (web && web.supportsComponent(PERMISSIONS_COMPONENT)) {
|
|
561
|
+
web.send({ component: PERMISSIONS_COMPONENT, event: "openSettings", data: {} })
|
|
562
|
+
}
|
|
563
|
+
},
|
|
564
|
+
|
|
565
|
+
_permission(event, name) {
|
|
566
|
+
const web = window.HotwireNative && window.HotwireNative.web
|
|
567
|
+
if (!web || !web.supportsComponent(PERMISSIONS_COMPONENT)) {
|
|
568
|
+
// Older shell without the component: answer honestly from the web APIs.
|
|
569
|
+
return event === "request" ? browser.permissionRequest(name) : browser.permissionQuery(name)
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return new Promise((resolve) => {
|
|
573
|
+
const id = web.send({
|
|
574
|
+
component: PERMISSIONS_COMPONENT,
|
|
575
|
+
event,
|
|
576
|
+
data: { name },
|
|
577
|
+
callback: (message) => {
|
|
578
|
+
if (id !== null && web.removeCallbackFor) web.removeCallbackFor(id)
|
|
579
|
+
resolve(message.data)
|
|
580
|
+
}
|
|
581
|
+
})
|
|
582
|
+
})
|
|
583
|
+
},
|
|
584
|
+
|
|
585
|
+
// Device-level persistence via the shell's "everywhere--storage" component
|
|
586
|
+
// (UserDefaults — survives web view resets and cache clears, unlike
|
|
587
|
+
// localStorage); localStorage fallback on an older shell.
|
|
588
|
+
storage(event, data) {
|
|
589
|
+
const web = window.HotwireNative && window.HotwireNative.web
|
|
590
|
+
if (!web || !web.supportsComponent(STORAGE_COMPONENT)) {
|
|
591
|
+
return browser.storage(event, data)
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return new Promise((resolve) => {
|
|
595
|
+
const id = web.send({
|
|
596
|
+
component: STORAGE_COMPONENT,
|
|
597
|
+
event,
|
|
598
|
+
data,
|
|
599
|
+
callback: (message) => {
|
|
600
|
+
if (id !== null && web.removeCallbackFor) web.removeCallbackFor(id)
|
|
601
|
+
resolve(message.data)
|
|
602
|
+
}
|
|
603
|
+
})
|
|
604
|
+
})
|
|
605
|
+
},
|
|
606
|
+
|
|
607
|
+
biometricsAvailable() {
|
|
608
|
+
return this._biometrics("query", {}, () => browser.biometricsAvailable())
|
|
609
|
+
},
|
|
610
|
+
|
|
611
|
+
// No timeout on purpose (same as permissions): the Face ID sheet blocks
|
|
612
|
+
// until the user answers, and the shell always replies on the message.
|
|
613
|
+
biometricsAuthenticate(options) {
|
|
614
|
+
return this._biometrics("authenticate", options || {}, () => browser.biometricsAuthenticate())
|
|
615
|
+
},
|
|
616
|
+
|
|
617
|
+
biometricsCredential(event, data) {
|
|
618
|
+
return this._biometrics(event, data || {}, () => browser.biometricsCredential(event))
|
|
619
|
+
},
|
|
620
|
+
|
|
621
|
+
_biometrics(event, data, fallback) {
|
|
622
|
+
const web = window.HotwireNative && window.HotwireNative.web
|
|
623
|
+
if (!web || !web.supportsComponent(BIOMETRICS_COMPONENT)) return fallback()
|
|
624
|
+
|
|
625
|
+
return new Promise((resolve) => {
|
|
626
|
+
const id = web.send({
|
|
627
|
+
component: BIOMETRICS_COMPONENT,
|
|
628
|
+
event,
|
|
629
|
+
data,
|
|
630
|
+
callback: (message) => {
|
|
631
|
+
if (id !== null && web.removeCallbackFor) web.removeCallbackFor(id)
|
|
632
|
+
resolve(message.data)
|
|
633
|
+
}
|
|
634
|
+
})
|
|
635
|
+
})
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function nativeControlChannel() {
|
|
640
|
+
return window.webkit &&
|
|
641
|
+
window.webkit.messageHandlers &&
|
|
642
|
+
window.webkit.messageHandlers.everywhereControl
|
|
202
643
|
}
|
|
203
644
|
|
|
204
645
|
// --- public surface ----------------------------------------------------------
|
|
@@ -208,9 +649,16 @@ const platform = detectPlatform()
|
|
|
208
649
|
const os = detectOS()
|
|
209
650
|
const adapter = adapters[platform]
|
|
210
651
|
|
|
652
|
+
// Complete the Hotwire Native handshake early so the shell's components are
|
|
653
|
+
// registered by the time app code first calls notify().
|
|
654
|
+
if (platform === "mobile") installWebBridge()
|
|
655
|
+
|
|
211
656
|
// Mutable so setChannel can keep updates.channel truthful without a reload.
|
|
212
657
|
let updatesChannel = (config.updates && config.updates.channel) || null
|
|
213
658
|
|
|
659
|
+
// Device-local "require biometrics" preference (see biometrics.lockEnabled).
|
|
660
|
+
const BIOMETRIC_LOCK_KEY = "everywhere:biometric-lock"
|
|
661
|
+
|
|
214
662
|
export const Everywhere = {
|
|
215
663
|
platform,
|
|
216
664
|
os,
|
|
@@ -241,6 +689,21 @@ export const Everywhere = {
|
|
|
241
689
|
else window.location.assign(path)
|
|
242
690
|
},
|
|
243
691
|
|
|
692
|
+
// Refresh the native tab bar / navigation config now (mobile only; a no-op
|
|
693
|
+
// in the browser and desktop shell, where there's nothing to refresh).
|
|
694
|
+
reloadTabs() {
|
|
695
|
+
if (adapter.reloadTabs) adapter.reloadTabs()
|
|
696
|
+
},
|
|
697
|
+
|
|
698
|
+
// Reset the native app after an auth change — clears cached web content and
|
|
699
|
+
// rebuilds tabs, then lands on `to` (defaults to the current path). In the
|
|
700
|
+
// browser / desktop it's a plain navigation. Usually you don't call this
|
|
701
|
+
// directly: redirect to /everywhere/reset (everywhere_auth_redirect) instead.
|
|
702
|
+
resetApp(to) {
|
|
703
|
+
if (adapter.resetApp) adapter.resetApp(to)
|
|
704
|
+
else this.visit(to || window.location.pathname)
|
|
705
|
+
},
|
|
706
|
+
|
|
244
707
|
clipboard: {
|
|
245
708
|
write(text) {
|
|
246
709
|
return adapter.clipboardWrite(text)
|
|
@@ -250,6 +713,184 @@ export const Everywhere = {
|
|
|
250
713
|
}
|
|
251
714
|
},
|
|
252
715
|
|
|
716
|
+
// Tactile feedback. Real haptics in the mobile shell, Vibration API where
|
|
717
|
+
// browsers have one, silently nothing elsewhere (including desktop).
|
|
718
|
+
haptics: {
|
|
719
|
+
impact(style = "medium") {
|
|
720
|
+
if (adapter.haptic) adapter.haptic("impact", style)
|
|
721
|
+
},
|
|
722
|
+
notification(type = "success") {
|
|
723
|
+
if (adapter.haptic) adapter.haptic("notification", type)
|
|
724
|
+
},
|
|
725
|
+
selection() {
|
|
726
|
+
if (adapter.haptic) adapter.haptic("selection")
|
|
727
|
+
}
|
|
728
|
+
},
|
|
729
|
+
|
|
730
|
+
// Native permission state. Mobile permissions must be declared in
|
|
731
|
+
// everywhere.yml (with the usage string iOS shows in its prompt) —
|
|
732
|
+
// undeclared ones resolve {status: "undeclared"} and never prompt. A
|
|
733
|
+
// "denied" status is final on iOS: offer openSettings() instead of asking again.
|
|
734
|
+
permissions: {
|
|
735
|
+
query(name) {
|
|
736
|
+
return adapter.permissionQuery
|
|
737
|
+
? adapter.permissionQuery(name)
|
|
738
|
+
: Promise.resolve({ name, status: "unsupported" })
|
|
739
|
+
},
|
|
740
|
+
request(name) {
|
|
741
|
+
return adapter.permissionRequest
|
|
742
|
+
? adapter.permissionRequest(name)
|
|
743
|
+
: Promise.resolve({ name, status: "unsupported" })
|
|
744
|
+
},
|
|
745
|
+
openSettings() {
|
|
746
|
+
if (adapter.permissionOpenSettings) adapter.permissionOpenSettings()
|
|
747
|
+
}
|
|
748
|
+
},
|
|
749
|
+
|
|
750
|
+
// Face ID / Touch ID. Mobile-shell only, and `biometrics` must be declared
|
|
751
|
+
// in everywhere.yml (that stamps the Face ID usage string). A passed check
|
|
752
|
+
// proves presence to the PAGE — gate a screen or confirm an action with it —
|
|
753
|
+
// but the server can't trust it; keep real session auth for the backend.
|
|
754
|
+
biometrics: {
|
|
755
|
+
available() {
|
|
756
|
+
return adapter.biometricsAvailable
|
|
757
|
+
? adapter.biometricsAvailable()
|
|
758
|
+
: Promise.resolve({ available: false, biometry: "none", status: "unsupported" })
|
|
759
|
+
},
|
|
760
|
+
authenticate(options = {}) {
|
|
761
|
+
return adapter.biometricsAuthenticate
|
|
762
|
+
? adapter.biometricsAuthenticate(options)
|
|
763
|
+
: Promise.resolve({ authenticated: false, error: "unsupported" })
|
|
764
|
+
},
|
|
765
|
+
|
|
766
|
+
// The device-local "require biometrics" preference honored by the
|
|
767
|
+
// declarative gate (data-everywhere-biometric-lock elements). Stored per
|
|
768
|
+
// device on purpose: locking your phone shouldn't lock your desktop.
|
|
769
|
+
get lockEnabled() {
|
|
770
|
+
try { return localStorage.getItem(BIOMETRIC_LOCK_KEY) === "1" } catch (_) { return false }
|
|
771
|
+
},
|
|
772
|
+
|
|
773
|
+
// Flip the preference — in either direction the biometric check runs
|
|
774
|
+
// first, so whoever toggles it must pass it.
|
|
775
|
+
async setLockEnabled(enabled, { reason } = {}) {
|
|
776
|
+
enabled = !!enabled
|
|
777
|
+
if (enabled === this.lockEnabled) return { enabled }
|
|
778
|
+
|
|
779
|
+
const probe = await this.available()
|
|
780
|
+
if (!probe.available) return { enabled: this.lockEnabled, error: probe.status || "unsupported" }
|
|
781
|
+
|
|
782
|
+
const check = await this.authenticate({
|
|
783
|
+
reason: reason || (enabled ? "Confirm to require biometric unlock." : "Confirm to remove the biometric lock."),
|
|
784
|
+
allowPasscode: true
|
|
785
|
+
})
|
|
786
|
+
if (!check.authenticated) return { enabled: this.lockEnabled, error: check.error || "failed" }
|
|
787
|
+
|
|
788
|
+
try {
|
|
789
|
+
if (enabled) localStorage.setItem(BIOMETRIC_LOCK_KEY, "1")
|
|
790
|
+
else localStorage.removeItem(BIOMETRIC_LOCK_KEY)
|
|
791
|
+
} catch (_) {}
|
|
792
|
+
return { enabled }
|
|
793
|
+
},
|
|
794
|
+
|
|
795
|
+
// A server-issued secret in the device keychain, readable only after
|
|
796
|
+
// Face ID / Touch ID passes — the storage half of "sign in with Face ID".
|
|
797
|
+
// The app's server still owns the other half: issue a token, store it
|
|
798
|
+
// here, and exchange it for a session at login. iOS invalidates the item
|
|
799
|
+
// when biometric enrollment changes, so a stale credential resolves
|
|
800
|
+
// {error: "notEnrolled"} — treat that as "offer password sign-in".
|
|
801
|
+
credential: {
|
|
802
|
+
store(token, options = {}) {
|
|
803
|
+
return adapter.biometricsCredential
|
|
804
|
+
? adapter.biometricsCredential("credentialStore", { ...options, token })
|
|
805
|
+
: Promise.resolve({ stored: false, error: "unsupported" })
|
|
806
|
+
},
|
|
807
|
+
get(options = {}) {
|
|
808
|
+
return adapter.biometricsCredential
|
|
809
|
+
? adapter.biometricsCredential("credentialGet", options)
|
|
810
|
+
: Promise.resolve({ error: "unsupported" })
|
|
811
|
+
},
|
|
812
|
+
status() {
|
|
813
|
+
return adapter.biometricsCredential
|
|
814
|
+
? adapter.biometricsCredential("credentialStatus", {})
|
|
815
|
+
: Promise.resolve({ enrolled: false })
|
|
816
|
+
},
|
|
817
|
+
clear() {
|
|
818
|
+
return adapter.biometricsCredential
|
|
819
|
+
? adapter.biometricsCredential("credentialClear", {})
|
|
820
|
+
: Promise.resolve({ cleared: true })
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
},
|
|
824
|
+
|
|
825
|
+
// Multi-instance apps (remote.instances: true in everywhere.yml): the shell
|
|
826
|
+
// boots into your hosted picker page until it calls set(url) with the
|
|
827
|
+
// chosen instance's root; that persists across launches until clear().
|
|
828
|
+
// Both trigger a full native reset (same as resetApp), landing on `to` or
|
|
829
|
+
// the instance's entry path. In a plain browser, set() just navigates —
|
|
830
|
+
// going there IS picking the instance on the web.
|
|
831
|
+
instance: {
|
|
832
|
+
get supported() {
|
|
833
|
+
return platform === "mobile" && config.instances === true
|
|
834
|
+
},
|
|
835
|
+
// The active instance root, or null when the app is on its built-in root.
|
|
836
|
+
get current() {
|
|
837
|
+
return config.instance || null
|
|
838
|
+
},
|
|
839
|
+
set(url, { to } = {}) {
|
|
840
|
+
if (adapter.instanceSet) adapter.instanceSet(String(url), to)
|
|
841
|
+
else window.location.assign(String(url))
|
|
842
|
+
},
|
|
843
|
+
clear({ to } = {}) {
|
|
844
|
+
if (adapter.instanceClear) adapter.instanceClear(to)
|
|
845
|
+
}
|
|
846
|
+
},
|
|
847
|
+
|
|
848
|
+
// Device-local key/value settings. In the mobile shell they live in
|
|
849
|
+
// UserDefaults — outliving web view resets, cache clears, and resetApp() —
|
|
850
|
+
// making this the right home for per-device preferences the server
|
|
851
|
+
// shouldn't own. localStorage-backed in browsers and the desktop shell.
|
|
852
|
+
// Any JSON value round-trips; get() resolves null for missing keys.
|
|
853
|
+
storage: {
|
|
854
|
+
_call(event, data) {
|
|
855
|
+
return adapter.storage ? adapter.storage(event, data) : browser.storage(event, data)
|
|
856
|
+
},
|
|
857
|
+
async get(key) {
|
|
858
|
+
const result = await this._call("get", { key: String(key) })
|
|
859
|
+
if (!result || result.value == null) return null
|
|
860
|
+
try { return JSON.parse(result.value) } catch (_) { return null }
|
|
861
|
+
},
|
|
862
|
+
async set(key, value) {
|
|
863
|
+
const encoded = JSON.stringify(value === undefined ? null : value)
|
|
864
|
+
const result = await this._call("set", { key: String(key), value: encoded })
|
|
865
|
+
return !!(result && result.ok)
|
|
866
|
+
},
|
|
867
|
+
async remove(key) {
|
|
868
|
+
const result = await this._call("remove", { key: String(key) })
|
|
869
|
+
return !!(result && result.ok)
|
|
870
|
+
},
|
|
871
|
+
async clear() {
|
|
872
|
+
const result = await this._call("clear", {})
|
|
873
|
+
return !!(result && result.ok)
|
|
874
|
+
}
|
|
875
|
+
},
|
|
876
|
+
|
|
877
|
+
// Badge counts. App icon badge on mobile (and installed PWAs); native tab
|
|
878
|
+
// bar badges on mobile, keyed by the tab's path from everywhere.yml.
|
|
879
|
+
badge: {
|
|
880
|
+
set(count) {
|
|
881
|
+
if (adapter.badgeSet) adapter.badgeSet(Math.max(0, count | 0))
|
|
882
|
+
},
|
|
883
|
+
clear() {
|
|
884
|
+
this.set(0)
|
|
885
|
+
},
|
|
886
|
+
setTab(path, count) {
|
|
887
|
+
if (adapter.badgeSetTab) adapter.badgeSetTab(path, Math.max(0, count | 0))
|
|
888
|
+
},
|
|
889
|
+
clearTab(path) {
|
|
890
|
+
this.setTab(path, 0)
|
|
891
|
+
}
|
|
892
|
+
},
|
|
893
|
+
|
|
253
894
|
updates: {
|
|
254
895
|
// True only when the shell actually has a feed to check (everywhere.yml
|
|
255
896
|
// updates: url + public_key made it into the shipped config).
|
|
@@ -290,4 +931,157 @@ export const Everywhere = {
|
|
|
290
931
|
}
|
|
291
932
|
}
|
|
292
933
|
|
|
934
|
+
// --- declarative page glue ---------------------------------------------------
|
|
935
|
+
// Server-rendered badge counts (the everywhere_badge / everywhere_tab_badge
|
|
936
|
+
// helpers emit meta tags — CSP-safe, no inline JS) applied on every Turbo
|
|
937
|
+
// visit, and haptics for taps on [data-everywhere-haptic] elements.
|
|
938
|
+
|
|
939
|
+
function applyBadgeMetas() {
|
|
940
|
+
const badge = document.querySelector('meta[name="everywhere:badge"]')
|
|
941
|
+
if (badge) Everywhere.badge.set(parseInt(badge.content, 10) || 0)
|
|
942
|
+
|
|
943
|
+
document.querySelectorAll('meta[name="everywhere:tab-badge"]').forEach((meta) => {
|
|
944
|
+
try {
|
|
945
|
+
const { path, count } = JSON.parse(meta.content)
|
|
946
|
+
if (path) Everywhere.badge.setTab(path, count || 0)
|
|
947
|
+
} catch (_) {}
|
|
948
|
+
})
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function playDeclaredHaptic(el) {
|
|
952
|
+
const [kind, value] = (el.dataset.everywhereHaptic || "impact").split(":")
|
|
953
|
+
if (kind === "notification") Everywhere.haptics.notification(value)
|
|
954
|
+
else if (kind === "selection") Everywhere.haptics.selection()
|
|
955
|
+
else if (kind === "impact") Everywhere.haptics.impact(value)
|
|
956
|
+
else Everywhere.haptics.impact(kind) // shorthand: data-everywhere-haptic="light"
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// Biometric gate. A [data-everywhere-biometric-lock] wrapper holds
|
|
960
|
+
// [data-everywhere-biometric-content] (server-rendered hidden) and a
|
|
961
|
+
// [data-everywhere-biometric-locked] overlay; when the device-local lock
|
|
962
|
+
// preference is on in the mobile shell, content stays hidden until Face ID /
|
|
963
|
+
// Touch ID passes. A pass is remembered for the JS session, so Turbo
|
|
964
|
+
// revisits don't re-prompt. [data-everywhere-biometric-unlock] buttons retry
|
|
965
|
+
// after a cancel. Everywhere else (browser, desktop, lock off) the content
|
|
966
|
+
// just reveals.
|
|
967
|
+
const unlockedGates = new Set()
|
|
968
|
+
|
|
969
|
+
function gateKey(el) {
|
|
970
|
+
return el.getAttribute("data-everywhere-biometric-lock") || window.location.pathname
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function setGateLocked(el, locked) {
|
|
974
|
+
const content = el.querySelector("[data-everywhere-biometric-content]")
|
|
975
|
+
const overlay = el.querySelector("[data-everywhere-biometric-locked]")
|
|
976
|
+
if (content) content.hidden = locked
|
|
977
|
+
if (overlay) overlay.hidden = !locked
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
async function resolveBiometricGate(el, { prompt } = {}) {
|
|
981
|
+
if (platform !== "mobile" || !Everywhere.biometrics.lockEnabled || unlockedGates.has(gateKey(el))) {
|
|
982
|
+
return setGateLocked(el, false)
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
const probe = await Everywhere.biometrics.available()
|
|
986
|
+
// A device that can't authenticate (nothing enrolled, older shell) reveals
|
|
987
|
+
// rather than bricking the page — except lockout, where authenticate()'s
|
|
988
|
+
// passcode path can still clear it.
|
|
989
|
+
if (!probe.available && probe.status !== "lockout") return setGateLocked(el, false)
|
|
990
|
+
|
|
991
|
+
setGateLocked(el, true)
|
|
992
|
+
if (!prompt) return
|
|
993
|
+
|
|
994
|
+
const result = await Everywhere.biometrics.authenticate({
|
|
995
|
+
reason: el.getAttribute("data-everywhere-biometric-reason") || "Unlock to continue",
|
|
996
|
+
allowPasscode: el.hasAttribute("data-everywhere-biometric-passcode")
|
|
997
|
+
})
|
|
998
|
+
if (result.authenticated) {
|
|
999
|
+
unlockedGates.add(gateKey(el))
|
|
1000
|
+
setGateLocked(el, false)
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// Auto-prompt only when this page is actually on screen. Hotwire Native
|
|
1005
|
+
// loads tab web views in the background at launch, so a gated page in a
|
|
1006
|
+
// non-selected tab must lock silently and prompt when its tab is opened
|
|
1007
|
+
// (the visibilitychange listener below) — not flash Face ID over another tab.
|
|
1008
|
+
function applyBiometricGates() {
|
|
1009
|
+
const prompt = document.visibilityState === "visible"
|
|
1010
|
+
document.querySelectorAll("[data-everywhere-biometric-lock]").forEach((el) => {
|
|
1011
|
+
resolveBiometricGate(el, { prompt })
|
|
1012
|
+
})
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// [data-everywhere-biometric-toggle] checkboxes (server-rendered hidden +
|
|
1016
|
+
// disabled) become live switches for the lock preference — revealed only in
|
|
1017
|
+
// the mobile shell with working biometrics, along with their closest
|
|
1018
|
+
// [data-everywhere-biometric-toggle-row] container.
|
|
1019
|
+
async function wireBiometricToggles() {
|
|
1020
|
+
const toggles = document.querySelectorAll("[data-everywhere-biometric-toggle]")
|
|
1021
|
+
if (!toggles.length) return
|
|
1022
|
+
|
|
1023
|
+
const available = platform === "mobile" && (await Everywhere.biometrics.available()).available
|
|
1024
|
+
if (!available) return
|
|
1025
|
+
|
|
1026
|
+
toggles.forEach((input) => {
|
|
1027
|
+
const row = input.closest("[data-everywhere-biometric-toggle-row]")
|
|
1028
|
+
if (row) row.hidden = false
|
|
1029
|
+
input.hidden = false
|
|
1030
|
+
input.disabled = false
|
|
1031
|
+
input.checked = Everywhere.biometrics.lockEnabled
|
|
1032
|
+
})
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function applyDeclarative() {
|
|
1036
|
+
applyBadgeMetas()
|
|
1037
|
+
applyBiometricGates()
|
|
1038
|
+
wireBiometricToggles()
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
if (typeof document !== "undefined") {
|
|
1042
|
+
document.addEventListener("turbo:load", applyDeclarative)
|
|
1043
|
+
if (document.readyState === "loading") {
|
|
1044
|
+
document.addEventListener("DOMContentLoaded", applyDeclarative)
|
|
1045
|
+
} else {
|
|
1046
|
+
applyDeclarative()
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// A gated page that loaded hidden (a background tab) prompts when it
|
|
1050
|
+
// actually comes on screen; already-unlocked gates resolve instantly.
|
|
1051
|
+
document.addEventListener("visibilitychange", () => {
|
|
1052
|
+
if (document.visibilityState === "visible") applyBiometricGates()
|
|
1053
|
+
})
|
|
1054
|
+
|
|
1055
|
+
// Reset gates to fully-hidden before Turbo snapshots the page, so a cached
|
|
1056
|
+
// copy never carries unlocked content; turbo:load re-resolves on restore.
|
|
1057
|
+
document.addEventListener("turbo:before-cache", () => {
|
|
1058
|
+
document.querySelectorAll("[data-everywhere-biometric-lock]").forEach((el) => {
|
|
1059
|
+
const content = el.querySelector("[data-everywhere-biometric-content]")
|
|
1060
|
+
const overlay = el.querySelector("[data-everywhere-biometric-locked]")
|
|
1061
|
+
if (content) content.hidden = true
|
|
1062
|
+
if (overlay) overlay.hidden = true
|
|
1063
|
+
})
|
|
1064
|
+
})
|
|
1065
|
+
|
|
1066
|
+
document.addEventListener("click", (event) => {
|
|
1067
|
+
if (!(event.target instanceof Element)) return
|
|
1068
|
+
const el = event.target.closest("[data-everywhere-haptic]")
|
|
1069
|
+
if (el) playDeclaredHaptic(el)
|
|
1070
|
+
|
|
1071
|
+
const unlock = event.target.closest("[data-everywhere-biometric-unlock]")
|
|
1072
|
+
const gate = unlock && unlock.closest("[data-everywhere-biometric-lock]")
|
|
1073
|
+
if (gate) resolveBiometricGate(gate, { prompt: true })
|
|
1074
|
+
})
|
|
1075
|
+
|
|
1076
|
+
document.addEventListener("change", async (event) => {
|
|
1077
|
+
const input = event.target
|
|
1078
|
+
if (!(input instanceof Element) || !input.hasAttribute("data-everywhere-biometric-toggle")) return
|
|
1079
|
+
|
|
1080
|
+
input.disabled = true
|
|
1081
|
+
const result = await Everywhere.biometrics.setLockEnabled(input.checked)
|
|
1082
|
+
input.checked = result.enabled
|
|
1083
|
+
input.disabled = false
|
|
1084
|
+
})
|
|
1085
|
+
}
|
|
1086
|
+
|
|
293
1087
|
export default Everywhere
|