ruby_everywhere 0.1.15 → 0.3.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 +105 -2
- data/bridge/everywhere/bridge.js +1131 -9
- data/bridge/everywhere/native.css +203 -0
- data/bridge/package.json +6 -3
- data/lib/everywhere/builders/ios.rb +396 -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 +469 -1
- data/lib/everywhere/engine.rb +42 -1
- data/lib/everywhere/icon.rb +50 -0
- data/lib/everywhere/log_filter.rb +28 -2
- data/lib/everywhere/mobile_config_endpoint.rb +88 -0
- data/lib/everywhere/mobile_configs_controller.rb +64 -0
- data/lib/everywhere/native_helper.rb +352 -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 +164 -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/MenuComponent.swift +192 -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 +289 -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 +484 -0
- data/support/mobile/ios/App.xcodeproj/project.pbxproj +458 -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 +73 -0
- metadata +37 -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", …)
|
|
@@ -26,6 +103,50 @@
|
|
|
26
103
|
// notes is the markdown source; notesHtml is the same notes pre-rendered to
|
|
27
104
|
// HTML (from the signed update feed — your own content), ready for a
|
|
28
105
|
// changelog modal: el.innerHTML = notesHtml.
|
|
106
|
+
//
|
|
107
|
+
// Everywhere.menu({ title, message, items }) // Promise<item|null>
|
|
108
|
+
// items: [{ title, id?, style?, disabled? }] style: "destructive" | "cancel"
|
|
109
|
+
// Native action sheet in the mobile shell; a styled bottom sheet everywhere
|
|
110
|
+
// else. Resolves the chosen item (null on cancel/dismiss).
|
|
111
|
+
//
|
|
112
|
+
// Native chrome, declarative (no JS) — the bridge watches every page and,
|
|
113
|
+
// inside the mobile shell, lifts these into real native controls; in a
|
|
114
|
+
// browser (and the desktop shell) they stay as the plain HTML they already
|
|
115
|
+
// are, so the same markup works everywhere. Tapping a native control just
|
|
116
|
+
// .click()s the element it mirrors — a link navigates, a submit submits, a
|
|
117
|
+
// button fires its handler — so behavior is defined once, in the DOM.
|
|
118
|
+
//
|
|
119
|
+
// Nav bar button (right side by default):
|
|
120
|
+
// <a href="/notes/new" data-everywhere-nav-button
|
|
121
|
+
// data-everywhere-nav-title="New" data-everywhere-nav-icon="plus">New</a>
|
|
122
|
+
// Nav bar submit button (submits the form when tapped):
|
|
123
|
+
// <button type="submit" data-everywhere-nav-button
|
|
124
|
+
// data-everywhere-nav-title="Save" data-everywhere-nav-style="done">Save</button>
|
|
125
|
+
// Nav bar pull-down / overflow menu (defaults to the ⋯ ellipsis icon):
|
|
126
|
+
// <div data-everywhere-nav-menu data-everywhere-nav-title="More">
|
|
127
|
+
// <a href="/share" data-everywhere-menu-item data-everywhere-menu-title="Share"
|
|
128
|
+
// data-everywhere-menu-icon-ios="square.and.arrow.up"
|
|
129
|
+
// data-everywhere-menu-icon-android="share">Share</a>
|
|
130
|
+
// <button form="delete_1" data-everywhere-menu-item data-everywhere-menu-title="Delete"
|
|
131
|
+
// data-everywhere-menu-style="destructive">Delete</button>
|
|
132
|
+
// </div>
|
|
133
|
+
// In-content action sheet (a trigger + the items it opens):
|
|
134
|
+
// <div data-everywhere-menu>
|
|
135
|
+
// <button data-everywhere-menu-trigger>Options</button>
|
|
136
|
+
// <div data-everywhere-menu-items>
|
|
137
|
+
// <a href="/x/edit" data-everywhere-menu-item>Edit</a>
|
|
138
|
+
// <button form="del" data-everywhere-menu-item
|
|
139
|
+
// data-everywhere-menu-style="destructive">Delete</button>
|
|
140
|
+
// </div>
|
|
141
|
+
// </div>
|
|
142
|
+
// Icons are per-platform, like tabs: -icon-ios is an SF Symbol, -icon-android
|
|
143
|
+
// a Material name, and a plain -icon is the shared fallback; the bridge picks
|
|
144
|
+
// by the running os, so one page serves both shells. Icons are ignored on the
|
|
145
|
+
// web. side is "left" or "right" (default). Nav-bar sources are hidden in the
|
|
146
|
+
// shell (they live in the bar now) but shown in a browser; the action-sheet
|
|
147
|
+
// trigger toggles its items as an inline menu when there's no native shell.
|
|
148
|
+
// These are the everywhere_nav_button / everywhere_nav_menu /
|
|
149
|
+
// everywhere_submit_button / everywhere_menu Rails helpers.
|
|
29
150
|
|
|
30
151
|
const config =
|
|
31
152
|
(typeof window !== "undefined" && window.__EVERYWHERE_CONFIG__) || {}
|
|
@@ -173,6 +294,120 @@ const browser = {
|
|
|
173
294
|
return null
|
|
174
295
|
},
|
|
175
296
|
|
|
297
|
+
// Honest approximation: the Vibration API where it exists (Android
|
|
298
|
+
// browsers); silently nothing elsewhere (iOS Safari has no equivalent).
|
|
299
|
+
haptic(kind, value) {
|
|
300
|
+
if (!navigator.vibrate) return
|
|
301
|
+
if (kind === "impact") {
|
|
302
|
+
navigator.vibrate({ light: 8, medium: 15, heavy: 25, soft: 8, rigid: 15 }[value] || 15)
|
|
303
|
+
} else if (kind === "notification") {
|
|
304
|
+
navigator.vibrate(value === "error" ? [30, 60, 30] : value === "warning" ? [20, 40, 20] : [10, 30, 10])
|
|
305
|
+
} else {
|
|
306
|
+
navigator.vibrate(5)
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
|
|
310
|
+
// App icon badge via the PWA Badging API (installed PWAs only; rejects
|
|
311
|
+
// silently elsewhere). Tab badges have no browser equivalent.
|
|
312
|
+
badgeSet(count) {
|
|
313
|
+
if (!navigator.setAppBadge) return
|
|
314
|
+
const call = count > 0 ? navigator.setAppBadge(count) : navigator.clearAppBadge()
|
|
315
|
+
if (call && call.catch) call.catch(() => {})
|
|
316
|
+
},
|
|
317
|
+
|
|
318
|
+
badgeSetTab() {},
|
|
319
|
+
|
|
320
|
+
async permissionQuery(name) {
|
|
321
|
+
if (name === "notifications" && "Notification" in window) {
|
|
322
|
+
const map = { granted: "granted", denied: "denied", default: "prompt" }
|
|
323
|
+
return { name, status: map[Notification.permission] || "prompt" }
|
|
324
|
+
}
|
|
325
|
+
if (navigator.permissions) {
|
|
326
|
+
try {
|
|
327
|
+
const result = await navigator.permissions.query({
|
|
328
|
+
name: name === "location" ? "geolocation" : name
|
|
329
|
+
})
|
|
330
|
+
return { name, status: result.state } // granted | denied | prompt
|
|
331
|
+
} catch (_) {}
|
|
332
|
+
}
|
|
333
|
+
return { name, status: "unsupported" }
|
|
334
|
+
},
|
|
335
|
+
|
|
336
|
+
// Requests use each capability's own prompt-triggering API — there is no
|
|
337
|
+
// generic "request" in browsers.
|
|
338
|
+
async permissionRequest(name) {
|
|
339
|
+
if (name === "notifications" && "Notification" in window) {
|
|
340
|
+
const result = await Notification.requestPermission()
|
|
341
|
+
return { name, status: result === "default" ? "prompt" : result }
|
|
342
|
+
}
|
|
343
|
+
if (name === "camera" && navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
|
344
|
+
try {
|
|
345
|
+
const stream = await navigator.mediaDevices.getUserMedia({ video: true })
|
|
346
|
+
stream.getTracks().forEach((track) => track.stop())
|
|
347
|
+
return { name, status: "granted" }
|
|
348
|
+
} catch (_) {
|
|
349
|
+
return { name, status: "denied" }
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (name === "location" && navigator.geolocation) {
|
|
353
|
+
return new Promise((resolve) => {
|
|
354
|
+
navigator.geolocation.getCurrentPosition(
|
|
355
|
+
() => resolve({ name, status: "granted" }),
|
|
356
|
+
(error) => resolve({ name, status: error.code === 1 ? "denied" : "prompt" })
|
|
357
|
+
)
|
|
358
|
+
})
|
|
359
|
+
}
|
|
360
|
+
return this.permissionQuery(name)
|
|
361
|
+
},
|
|
362
|
+
|
|
363
|
+
permissionOpenSettings() {},
|
|
364
|
+
|
|
365
|
+
// Web pages can't invoke Face ID / Touch ID outside WebAuthn ceremonies
|
|
366
|
+
// (which need registered credentials), so the browser answer is honest:
|
|
367
|
+
// unsupported. Gate on `available` and fall back to your normal auth.
|
|
368
|
+
biometricsAvailable() {
|
|
369
|
+
return Promise.resolve({ available: false, biometry: "none", status: "unsupported" })
|
|
370
|
+
},
|
|
371
|
+
|
|
372
|
+
biometricsAuthenticate() {
|
|
373
|
+
return Promise.resolve({ authenticated: false, error: "unsupported" })
|
|
374
|
+
},
|
|
375
|
+
|
|
376
|
+
biometricsCredential(event) {
|
|
377
|
+
return Promise.resolve(
|
|
378
|
+
event === "credentialStatus" ? { enrolled: false } : { error: "unsupported" }
|
|
379
|
+
)
|
|
380
|
+
},
|
|
381
|
+
|
|
382
|
+
// Honest fallback: localStorage under a namespaced key. Same wire shape as
|
|
383
|
+
// the mobile component — value is a JSON-encoded string or null.
|
|
384
|
+
storage(event, data) {
|
|
385
|
+
const key = data && data.key != null ? "everywhere:app:" + data.key : null
|
|
386
|
+
try {
|
|
387
|
+
switch (event) {
|
|
388
|
+
case "get":
|
|
389
|
+
return Promise.resolve({ ok: !!key, value: key ? localStorage.getItem(key) : null })
|
|
390
|
+
case "set":
|
|
391
|
+
if (!key || typeof data.value !== "string") return Promise.resolve({ ok: false })
|
|
392
|
+
localStorage.setItem(key, data.value)
|
|
393
|
+
return Promise.resolve({ ok: true })
|
|
394
|
+
case "remove":
|
|
395
|
+
if (!key) return Promise.resolve({ ok: false })
|
|
396
|
+
localStorage.removeItem(key)
|
|
397
|
+
return Promise.resolve({ ok: true })
|
|
398
|
+
case "clear":
|
|
399
|
+
for (let i = localStorage.length - 1; i >= 0; i--) {
|
|
400
|
+
const name = localStorage.key(i)
|
|
401
|
+
if (name && name.startsWith("everywhere:app:")) localStorage.removeItem(name)
|
|
402
|
+
}
|
|
403
|
+
return Promise.resolve({ ok: true })
|
|
404
|
+
}
|
|
405
|
+
} catch (_) {
|
|
406
|
+
// localStorage can throw (privacy mode, quota) — report, don't break.
|
|
407
|
+
}
|
|
408
|
+
return Promise.resolve({ ok: false })
|
|
409
|
+
},
|
|
410
|
+
|
|
176
411
|
// Auto-updates only exist inside the shell; the browser tab is always "current".
|
|
177
412
|
updatesCheck() {
|
|
178
413
|
return Promise.resolve({ available: false, unsupported: true })
|
|
@@ -189,16 +424,288 @@ const browser = {
|
|
|
189
424
|
}
|
|
190
425
|
}
|
|
191
426
|
|
|
192
|
-
// --- mobile: Hotwire Native
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
427
|
+
// --- mobile: Hotwire Native --------------------------------------------------
|
|
428
|
+
// The native shell injects window.nativeBridge (the native adapter). The web
|
|
429
|
+
// half of the handshake is normally @hotwired/hotwire-native-bridge; apps that
|
|
430
|
+
// load it keep it (same first-loaded-wins guard the official package uses).
|
|
431
|
+
// Otherwise we install a minimal wire-compatible web bridge, enough for
|
|
432
|
+
// component messages with replies — which is all notify() needs.
|
|
433
|
+
//
|
|
434
|
+
// confirm()/clipboard fall back to the browser adapter on purpose: WKWebView
|
|
435
|
+
// renders window.confirm as a real native alert, and the clipboard API works.
|
|
436
|
+
|
|
437
|
+
const NOTIFY_COMPONENT = "everywhere--notification"
|
|
438
|
+
const HAPTICS_COMPONENT = "everywhere--haptics"
|
|
439
|
+
const PERMISSIONS_COMPONENT = "everywhere--permissions"
|
|
440
|
+
const BIOMETRICS_COMPONENT = "everywhere--biometrics"
|
|
441
|
+
const STORAGE_COMPONENT = "everywhere--storage"
|
|
442
|
+
const MENU_COMPONENT = "everywhere--menu"
|
|
443
|
+
|
|
444
|
+
function installWebBridge() {
|
|
445
|
+
if (window.HotwireNative) return
|
|
446
|
+
|
|
447
|
+
let adapter = null
|
|
448
|
+
let lastId = 0
|
|
449
|
+
const pending = []
|
|
450
|
+
const callbacks = new Map()
|
|
451
|
+
|
|
452
|
+
const web = {
|
|
453
|
+
supportsComponent(component) {
|
|
454
|
+
return adapter ? adapter.supportsComponent(component) : false
|
|
455
|
+
},
|
|
456
|
+
|
|
457
|
+
send({ component, event, data, callback }) {
|
|
458
|
+
if (!adapter) {
|
|
459
|
+
pending.push({ component, event, data, callback })
|
|
460
|
+
return null
|
|
461
|
+
}
|
|
462
|
+
if (!this.supportsComponent(component)) return null
|
|
463
|
+
|
|
464
|
+
const id = (++lastId).toString()
|
|
465
|
+
if (callback) callbacks.set(id, callback)
|
|
466
|
+
// metadata.url rides INSIDE data (the official web bridge's wire
|
|
467
|
+
// format); the native side drops messages whose metadata.url doesn't
|
|
468
|
+
// match the destination's location (Hotwire Native ≥ 1.3).
|
|
469
|
+
adapter.receive({
|
|
470
|
+
id, component, event,
|
|
471
|
+
data: { ...(data || {}), metadata: { url: window.location.href } }
|
|
472
|
+
})
|
|
473
|
+
return id
|
|
474
|
+
},
|
|
475
|
+
|
|
476
|
+
// Replies from native arrive here (nativeBridge.replyWith).
|
|
477
|
+
receive(message) {
|
|
478
|
+
const callback = callbacks.get(message.id)
|
|
479
|
+
if (callback) callback(message)
|
|
480
|
+
},
|
|
481
|
+
|
|
482
|
+
removeCallbackFor(id) {
|
|
483
|
+
callbacks.delete(id)
|
|
484
|
+
},
|
|
485
|
+
|
|
486
|
+
setAdapter(newAdapter) {
|
|
487
|
+
adapter = newAdapter
|
|
488
|
+
document.documentElement.dataset.bridgePlatform = adapter.platform
|
|
489
|
+
this.adapterDidUpdateSupportedComponents()
|
|
490
|
+
pending.splice(0).forEach((message) => this.send(message))
|
|
491
|
+
},
|
|
492
|
+
|
|
493
|
+
adapterDidUpdateSupportedComponents() {
|
|
494
|
+
if (adapter) {
|
|
495
|
+
document.documentElement.dataset.bridgeComponents =
|
|
496
|
+
adapter.supportedComponents.join(" ")
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
window.HotwireNative = { web }
|
|
502
|
+
document.dispatchEvent(new Event("web-bridge:ready"))
|
|
503
|
+
}
|
|
197
504
|
|
|
198
505
|
const mobile = {
|
|
199
|
-
...browser
|
|
200
|
-
|
|
201
|
-
//
|
|
506
|
+
...browser,
|
|
507
|
+
|
|
508
|
+
// Native local notification via the shell's "everywhere--notification"
|
|
509
|
+
// bridge component; web Notification fallback when the shell doesn't
|
|
510
|
+
// register it (an older shell, or before the handshake completes).
|
|
511
|
+
notify({ title, body }) {
|
|
512
|
+
const web = window.HotwireNative && window.HotwireNative.web
|
|
513
|
+
if (!web || !web.supportsComponent(NOTIFY_COMPONENT)) {
|
|
514
|
+
return browser.notify({ title, body })
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return new Promise((resolve) => {
|
|
518
|
+
const timer = setTimeout(() => resolve({ delivered: false, timedOut: true }), 10000)
|
|
519
|
+
const id = web.send({
|
|
520
|
+
component: NOTIFY_COMPONENT,
|
|
521
|
+
event: "notify",
|
|
522
|
+
data: { title, body },
|
|
523
|
+
callback: (message) => {
|
|
524
|
+
clearTimeout(timer)
|
|
525
|
+
if (id !== null && web.removeCallbackFor) web.removeCallbackFor(id)
|
|
526
|
+
resolve(message.data)
|
|
527
|
+
}
|
|
528
|
+
})
|
|
529
|
+
})
|
|
530
|
+
},
|
|
531
|
+
|
|
532
|
+
// Ask the shell to re-fetch its native navigation config (tabs + path
|
|
533
|
+
// rules) now, instead of waiting for the next app foreground. Call it after
|
|
534
|
+
// a change that affects which tabs should show — signing in or out — so the
|
|
535
|
+
// tab bar updates immediately. Uses a dedicated WKScriptMessageHandler, so
|
|
536
|
+
// it doesn't depend on a bridge component being mounted on the page.
|
|
537
|
+
reloadTabs() {
|
|
538
|
+
const control = nativeControlChannel()
|
|
539
|
+
if (control) {
|
|
540
|
+
control.postMessage({ action: "reloadConfig" })
|
|
541
|
+
} else {
|
|
542
|
+
console.debug("[everywhere] reloadTabs: no native control channel (older shell?)")
|
|
543
|
+
}
|
|
544
|
+
},
|
|
545
|
+
|
|
546
|
+
// Full app reset (fresh web views + re-fetched tabs), then land on `to`.
|
|
547
|
+
// Falls back to a plain navigation when there's no native channel.
|
|
548
|
+
resetApp(to) {
|
|
549
|
+
const target = to || window.location.pathname + window.location.search
|
|
550
|
+
const control = nativeControlChannel()
|
|
551
|
+
if (control) control.postMessage({ action: "reset", to: target })
|
|
552
|
+
else window.location.assign(target)
|
|
553
|
+
},
|
|
554
|
+
|
|
555
|
+
// Instance switching rides the control channel: it re-roots the whole app,
|
|
556
|
+
// which is shell-level state, not page state. The shell validates,
|
|
557
|
+
// persists, and resets; nothing to wait on.
|
|
558
|
+
instanceSet(url, to) {
|
|
559
|
+
const control = nativeControlChannel()
|
|
560
|
+
if (control) control.postMessage({ action: "setInstance", url, ...(to ? { to } : {}) })
|
|
561
|
+
else window.location.assign(url)
|
|
562
|
+
},
|
|
563
|
+
|
|
564
|
+
instanceClear(to) {
|
|
565
|
+
const control = nativeControlChannel()
|
|
566
|
+
if (control) control.postMessage({ action: "clearInstance", ...(to ? { to } : {}) })
|
|
567
|
+
},
|
|
568
|
+
|
|
569
|
+
// Real haptics via the shell's "everywhere--haptics" component; vibration
|
|
570
|
+
// fallback while the handshake completes or on an older shell.
|
|
571
|
+
haptic(kind, value) {
|
|
572
|
+
const web = window.HotwireNative && window.HotwireNative.web
|
|
573
|
+
if (!web || !web.supportsComponent(HAPTICS_COMPONENT)) {
|
|
574
|
+
return browser.haptic(kind, value)
|
|
575
|
+
}
|
|
576
|
+
const data = kind === "impact" ? { style: value }
|
|
577
|
+
: kind === "notification" ? { type: value }
|
|
578
|
+
: {}
|
|
579
|
+
web.send({ component: HAPTICS_COMPONENT, event: kind, data })
|
|
580
|
+
},
|
|
581
|
+
|
|
582
|
+
// Badges ride the control channel (like reloadTabs): they're app-level
|
|
583
|
+
// state, not tied to whichever page has bridge components mounted.
|
|
584
|
+
badgeSet(count) {
|
|
585
|
+
const control = nativeControlChannel()
|
|
586
|
+
if (control) control.postMessage({ action: "setBadge", count })
|
|
587
|
+
else browser.badgeSet(count)
|
|
588
|
+
},
|
|
589
|
+
|
|
590
|
+
badgeSetTab(path, count) {
|
|
591
|
+
const control = nativeControlChannel()
|
|
592
|
+
if (control) control.postMessage({ action: "setTabBadge", path, count })
|
|
593
|
+
},
|
|
594
|
+
|
|
595
|
+
permissionQuery(name) {
|
|
596
|
+
return this._permission("query", name)
|
|
597
|
+
},
|
|
598
|
+
|
|
599
|
+
permissionRequest(name) {
|
|
600
|
+
return this._permission("request", name)
|
|
601
|
+
},
|
|
602
|
+
|
|
603
|
+
permissionOpenSettings() {
|
|
604
|
+
const web = window.HotwireNative && window.HotwireNative.web
|
|
605
|
+
if (web && web.supportsComponent(PERMISSIONS_COMPONENT)) {
|
|
606
|
+
web.send({ component: PERMISSIONS_COMPONENT, event: "openSettings", data: {} })
|
|
607
|
+
}
|
|
608
|
+
},
|
|
609
|
+
|
|
610
|
+
_permission(event, name) {
|
|
611
|
+
const web = window.HotwireNative && window.HotwireNative.web
|
|
612
|
+
if (!web || !web.supportsComponent(PERMISSIONS_COMPONENT)) {
|
|
613
|
+
// Older shell without the component: answer honestly from the web APIs.
|
|
614
|
+
return event === "request" ? browser.permissionRequest(name) : browser.permissionQuery(name)
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return new Promise((resolve) => {
|
|
618
|
+
const id = web.send({
|
|
619
|
+
component: PERMISSIONS_COMPONENT,
|
|
620
|
+
event,
|
|
621
|
+
data: { name },
|
|
622
|
+
callback: (message) => {
|
|
623
|
+
if (id !== null && web.removeCallbackFor) web.removeCallbackFor(id)
|
|
624
|
+
resolve(message.data)
|
|
625
|
+
}
|
|
626
|
+
})
|
|
627
|
+
})
|
|
628
|
+
},
|
|
629
|
+
|
|
630
|
+
// Device-level persistence via the shell's "everywhere--storage" component
|
|
631
|
+
// (UserDefaults — survives web view resets and cache clears, unlike
|
|
632
|
+
// localStorage); localStorage fallback on an older shell.
|
|
633
|
+
storage(event, data) {
|
|
634
|
+
const web = window.HotwireNative && window.HotwireNative.web
|
|
635
|
+
if (!web || !web.supportsComponent(STORAGE_COMPONENT)) {
|
|
636
|
+
return browser.storage(event, data)
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
return new Promise((resolve) => {
|
|
640
|
+
const id = web.send({
|
|
641
|
+
component: STORAGE_COMPONENT,
|
|
642
|
+
event,
|
|
643
|
+
data,
|
|
644
|
+
callback: (message) => {
|
|
645
|
+
if (id !== null && web.removeCallbackFor) web.removeCallbackFor(id)
|
|
646
|
+
resolve(message.data)
|
|
647
|
+
}
|
|
648
|
+
})
|
|
649
|
+
})
|
|
650
|
+
},
|
|
651
|
+
|
|
652
|
+
biometricsAvailable() {
|
|
653
|
+
return this._biometrics("query", {}, () => browser.biometricsAvailable())
|
|
654
|
+
},
|
|
655
|
+
|
|
656
|
+
// No timeout on purpose (same as permissions): the Face ID sheet blocks
|
|
657
|
+
// until the user answers, and the shell always replies on the message.
|
|
658
|
+
biometricsAuthenticate(options) {
|
|
659
|
+
return this._biometrics("authenticate", options || {}, () => browser.biometricsAuthenticate())
|
|
660
|
+
},
|
|
661
|
+
|
|
662
|
+
biometricsCredential(event, data) {
|
|
663
|
+
return this._biometrics(event, data || {}, () => browser.biometricsCredential(event))
|
|
664
|
+
},
|
|
665
|
+
|
|
666
|
+
_biometrics(event, data, fallback) {
|
|
667
|
+
const web = window.HotwireNative && window.HotwireNative.web
|
|
668
|
+
if (!web || !web.supportsComponent(BIOMETRICS_COMPONENT)) return fallback()
|
|
669
|
+
|
|
670
|
+
return new Promise((resolve) => {
|
|
671
|
+
const id = web.send({
|
|
672
|
+
component: BIOMETRICS_COMPONENT,
|
|
673
|
+
event,
|
|
674
|
+
data,
|
|
675
|
+
callback: (message) => {
|
|
676
|
+
if (id !== null && web.removeCallbackFor) web.removeCallbackFor(id)
|
|
677
|
+
resolve(message.data)
|
|
678
|
+
}
|
|
679
|
+
})
|
|
680
|
+
})
|
|
681
|
+
},
|
|
682
|
+
|
|
683
|
+
// Native chrome (nav bar items + action sheets) via the shell's
|
|
684
|
+
// "everywhere--menu" component. Unlike the other components these replies are
|
|
685
|
+
// not one-shot: native re-replies to the SAME message on every tap, so the
|
|
686
|
+
// caller owns the callback's lifetime (menuRemove) rather than clearing it on
|
|
687
|
+
// first reply.
|
|
688
|
+
menuSupported() {
|
|
689
|
+
const web = window.HotwireNative && window.HotwireNative.web
|
|
690
|
+
return !!(web && web.supportsComponent(MENU_COMPONENT))
|
|
691
|
+
},
|
|
692
|
+
|
|
693
|
+
menuSend(event, data, callback) {
|
|
694
|
+
const web = window.HotwireNative && window.HotwireNative.web
|
|
695
|
+
if (!web || !web.supportsComponent(MENU_COMPONENT)) return null
|
|
696
|
+
return web.send({ component: MENU_COMPONENT, event, data, callback })
|
|
697
|
+
},
|
|
698
|
+
|
|
699
|
+
menuRemove(id) {
|
|
700
|
+
const web = window.HotwireNative && window.HotwireNative.web
|
|
701
|
+
if (web && id !== null && id !== undefined && web.removeCallbackFor) web.removeCallbackFor(id)
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function nativeControlChannel() {
|
|
706
|
+
return window.webkit &&
|
|
707
|
+
window.webkit.messageHandlers &&
|
|
708
|
+
window.webkit.messageHandlers.everywhereControl
|
|
202
709
|
}
|
|
203
710
|
|
|
204
711
|
// --- public surface ----------------------------------------------------------
|
|
@@ -208,9 +715,16 @@ const platform = detectPlatform()
|
|
|
208
715
|
const os = detectOS()
|
|
209
716
|
const adapter = adapters[platform]
|
|
210
717
|
|
|
718
|
+
// Complete the Hotwire Native handshake early so the shell's components are
|
|
719
|
+
// registered by the time app code first calls notify().
|
|
720
|
+
if (platform === "mobile") installWebBridge()
|
|
721
|
+
|
|
211
722
|
// Mutable so setChannel can keep updates.channel truthful without a reload.
|
|
212
723
|
let updatesChannel = (config.updates && config.updates.channel) || null
|
|
213
724
|
|
|
725
|
+
// Device-local "require biometrics" preference (see biometrics.lockEnabled).
|
|
726
|
+
const BIOMETRIC_LOCK_KEY = "everywhere:biometric-lock"
|
|
727
|
+
|
|
214
728
|
export const Everywhere = {
|
|
215
729
|
platform,
|
|
216
730
|
os,
|
|
@@ -241,6 +755,41 @@ export const Everywhere = {
|
|
|
241
755
|
else window.location.assign(path)
|
|
242
756
|
},
|
|
243
757
|
|
|
758
|
+
// A native action sheet in the mobile shell; a styled bottom sheet in the
|
|
759
|
+
// browser and desktop shell. Resolves the chosen item (the same object you
|
|
760
|
+
// passed), or null on cancel/dismiss:
|
|
761
|
+
//
|
|
762
|
+
// const choice = await Everywhere.menu({
|
|
763
|
+
// title: "Post",
|
|
764
|
+
// items: [{ id: "share", title: "Share" },
|
|
765
|
+
// { id: "delete", title: "Delete", style: "destructive" }]
|
|
766
|
+
// })
|
|
767
|
+
// if (choice?.id === "delete") …
|
|
768
|
+
menu(options = {}) {
|
|
769
|
+
const items = Array.isArray(options.items) ? options.items : []
|
|
770
|
+
return presentMenu({
|
|
771
|
+
title: options.title,
|
|
772
|
+
message: options.message,
|
|
773
|
+
items,
|
|
774
|
+
source: options.source
|
|
775
|
+
}).then((id) => (id == null ? null : items.find((item) => String(item.id) === String(id)) || null))
|
|
776
|
+
},
|
|
777
|
+
|
|
778
|
+
// Refresh the native tab bar / navigation config now (mobile only; a no-op
|
|
779
|
+
// in the browser and desktop shell, where there's nothing to refresh).
|
|
780
|
+
reloadTabs() {
|
|
781
|
+
if (adapter.reloadTabs) adapter.reloadTabs()
|
|
782
|
+
},
|
|
783
|
+
|
|
784
|
+
// Reset the native app after an auth change — clears cached web content and
|
|
785
|
+
// rebuilds tabs, then lands on `to` (defaults to the current path). In the
|
|
786
|
+
// browser / desktop it's a plain navigation. Usually you don't call this
|
|
787
|
+
// directly: redirect to /everywhere/reset (everywhere_auth_redirect) instead.
|
|
788
|
+
resetApp(to) {
|
|
789
|
+
if (adapter.resetApp) adapter.resetApp(to)
|
|
790
|
+
else this.visit(to || window.location.pathname)
|
|
791
|
+
},
|
|
792
|
+
|
|
244
793
|
clipboard: {
|
|
245
794
|
write(text) {
|
|
246
795
|
return adapter.clipboardWrite(text)
|
|
@@ -250,6 +799,184 @@ export const Everywhere = {
|
|
|
250
799
|
}
|
|
251
800
|
},
|
|
252
801
|
|
|
802
|
+
// Tactile feedback. Real haptics in the mobile shell, Vibration API where
|
|
803
|
+
// browsers have one, silently nothing elsewhere (including desktop).
|
|
804
|
+
haptics: {
|
|
805
|
+
impact(style = "medium") {
|
|
806
|
+
if (adapter.haptic) adapter.haptic("impact", style)
|
|
807
|
+
},
|
|
808
|
+
notification(type = "success") {
|
|
809
|
+
if (adapter.haptic) adapter.haptic("notification", type)
|
|
810
|
+
},
|
|
811
|
+
selection() {
|
|
812
|
+
if (adapter.haptic) adapter.haptic("selection")
|
|
813
|
+
}
|
|
814
|
+
},
|
|
815
|
+
|
|
816
|
+
// Native permission state. Mobile permissions must be declared in
|
|
817
|
+
// everywhere.yml (with the usage string iOS shows in its prompt) —
|
|
818
|
+
// undeclared ones resolve {status: "undeclared"} and never prompt. A
|
|
819
|
+
// "denied" status is final on iOS: offer openSettings() instead of asking again.
|
|
820
|
+
permissions: {
|
|
821
|
+
query(name) {
|
|
822
|
+
return adapter.permissionQuery
|
|
823
|
+
? adapter.permissionQuery(name)
|
|
824
|
+
: Promise.resolve({ name, status: "unsupported" })
|
|
825
|
+
},
|
|
826
|
+
request(name) {
|
|
827
|
+
return adapter.permissionRequest
|
|
828
|
+
? adapter.permissionRequest(name)
|
|
829
|
+
: Promise.resolve({ name, status: "unsupported" })
|
|
830
|
+
},
|
|
831
|
+
openSettings() {
|
|
832
|
+
if (adapter.permissionOpenSettings) adapter.permissionOpenSettings()
|
|
833
|
+
}
|
|
834
|
+
},
|
|
835
|
+
|
|
836
|
+
// Face ID / Touch ID. Mobile-shell only, and `biometrics` must be declared
|
|
837
|
+
// in everywhere.yml (that stamps the Face ID usage string). A passed check
|
|
838
|
+
// proves presence to the PAGE — gate a screen or confirm an action with it —
|
|
839
|
+
// but the server can't trust it; keep real session auth for the backend.
|
|
840
|
+
biometrics: {
|
|
841
|
+
available() {
|
|
842
|
+
return adapter.biometricsAvailable
|
|
843
|
+
? adapter.biometricsAvailable()
|
|
844
|
+
: Promise.resolve({ available: false, biometry: "none", status: "unsupported" })
|
|
845
|
+
},
|
|
846
|
+
authenticate(options = {}) {
|
|
847
|
+
return adapter.biometricsAuthenticate
|
|
848
|
+
? adapter.biometricsAuthenticate(options)
|
|
849
|
+
: Promise.resolve({ authenticated: false, error: "unsupported" })
|
|
850
|
+
},
|
|
851
|
+
|
|
852
|
+
// The device-local "require biometrics" preference honored by the
|
|
853
|
+
// declarative gate (data-everywhere-biometric-lock elements). Stored per
|
|
854
|
+
// device on purpose: locking your phone shouldn't lock your desktop.
|
|
855
|
+
get lockEnabled() {
|
|
856
|
+
try { return localStorage.getItem(BIOMETRIC_LOCK_KEY) === "1" } catch (_) { return false }
|
|
857
|
+
},
|
|
858
|
+
|
|
859
|
+
// Flip the preference — in either direction the biometric check runs
|
|
860
|
+
// first, so whoever toggles it must pass it.
|
|
861
|
+
async setLockEnabled(enabled, { reason } = {}) {
|
|
862
|
+
enabled = !!enabled
|
|
863
|
+
if (enabled === this.lockEnabled) return { enabled }
|
|
864
|
+
|
|
865
|
+
const probe = await this.available()
|
|
866
|
+
if (!probe.available) return { enabled: this.lockEnabled, error: probe.status || "unsupported" }
|
|
867
|
+
|
|
868
|
+
const check = await this.authenticate({
|
|
869
|
+
reason: reason || (enabled ? "Confirm to require biometric unlock." : "Confirm to remove the biometric lock."),
|
|
870
|
+
allowPasscode: true
|
|
871
|
+
})
|
|
872
|
+
if (!check.authenticated) return { enabled: this.lockEnabled, error: check.error || "failed" }
|
|
873
|
+
|
|
874
|
+
try {
|
|
875
|
+
if (enabled) localStorage.setItem(BIOMETRIC_LOCK_KEY, "1")
|
|
876
|
+
else localStorage.removeItem(BIOMETRIC_LOCK_KEY)
|
|
877
|
+
} catch (_) {}
|
|
878
|
+
return { enabled }
|
|
879
|
+
},
|
|
880
|
+
|
|
881
|
+
// A server-issued secret in the device keychain, readable only after
|
|
882
|
+
// Face ID / Touch ID passes — the storage half of "sign in with Face ID".
|
|
883
|
+
// The app's server still owns the other half: issue a token, store it
|
|
884
|
+
// here, and exchange it for a session at login. iOS invalidates the item
|
|
885
|
+
// when biometric enrollment changes, so a stale credential resolves
|
|
886
|
+
// {error: "notEnrolled"} — treat that as "offer password sign-in".
|
|
887
|
+
credential: {
|
|
888
|
+
store(token, options = {}) {
|
|
889
|
+
return adapter.biometricsCredential
|
|
890
|
+
? adapter.biometricsCredential("credentialStore", { ...options, token })
|
|
891
|
+
: Promise.resolve({ stored: false, error: "unsupported" })
|
|
892
|
+
},
|
|
893
|
+
get(options = {}) {
|
|
894
|
+
return adapter.biometricsCredential
|
|
895
|
+
? adapter.biometricsCredential("credentialGet", options)
|
|
896
|
+
: Promise.resolve({ error: "unsupported" })
|
|
897
|
+
},
|
|
898
|
+
status() {
|
|
899
|
+
return adapter.biometricsCredential
|
|
900
|
+
? adapter.biometricsCredential("credentialStatus", {})
|
|
901
|
+
: Promise.resolve({ enrolled: false })
|
|
902
|
+
},
|
|
903
|
+
clear() {
|
|
904
|
+
return adapter.biometricsCredential
|
|
905
|
+
? adapter.biometricsCredential("credentialClear", {})
|
|
906
|
+
: Promise.resolve({ cleared: true })
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
},
|
|
910
|
+
|
|
911
|
+
// Multi-instance apps (remote.instances: true in everywhere.yml): the shell
|
|
912
|
+
// boots into your hosted picker page until it calls set(url) with the
|
|
913
|
+
// chosen instance's root; that persists across launches until clear().
|
|
914
|
+
// Both trigger a full native reset (same as resetApp), landing on `to` or
|
|
915
|
+
// the instance's entry path. In a plain browser, set() just navigates —
|
|
916
|
+
// going there IS picking the instance on the web.
|
|
917
|
+
instance: {
|
|
918
|
+
get supported() {
|
|
919
|
+
return platform === "mobile" && config.instances === true
|
|
920
|
+
},
|
|
921
|
+
// The active instance root, or null when the app is on its built-in root.
|
|
922
|
+
get current() {
|
|
923
|
+
return config.instance || null
|
|
924
|
+
},
|
|
925
|
+
set(url, { to } = {}) {
|
|
926
|
+
if (adapter.instanceSet) adapter.instanceSet(String(url), to)
|
|
927
|
+
else window.location.assign(String(url))
|
|
928
|
+
},
|
|
929
|
+
clear({ to } = {}) {
|
|
930
|
+
if (adapter.instanceClear) adapter.instanceClear(to)
|
|
931
|
+
}
|
|
932
|
+
},
|
|
933
|
+
|
|
934
|
+
// Device-local key/value settings. In the mobile shell they live in
|
|
935
|
+
// UserDefaults — outliving web view resets, cache clears, and resetApp() —
|
|
936
|
+
// making this the right home for per-device preferences the server
|
|
937
|
+
// shouldn't own. localStorage-backed in browsers and the desktop shell.
|
|
938
|
+
// Any JSON value round-trips; get() resolves null for missing keys.
|
|
939
|
+
storage: {
|
|
940
|
+
_call(event, data) {
|
|
941
|
+
return adapter.storage ? adapter.storage(event, data) : browser.storage(event, data)
|
|
942
|
+
},
|
|
943
|
+
async get(key) {
|
|
944
|
+
const result = await this._call("get", { key: String(key) })
|
|
945
|
+
if (!result || result.value == null) return null
|
|
946
|
+
try { return JSON.parse(result.value) } catch (_) { return null }
|
|
947
|
+
},
|
|
948
|
+
async set(key, value) {
|
|
949
|
+
const encoded = JSON.stringify(value === undefined ? null : value)
|
|
950
|
+
const result = await this._call("set", { key: String(key), value: encoded })
|
|
951
|
+
return !!(result && result.ok)
|
|
952
|
+
},
|
|
953
|
+
async remove(key) {
|
|
954
|
+
const result = await this._call("remove", { key: String(key) })
|
|
955
|
+
return !!(result && result.ok)
|
|
956
|
+
},
|
|
957
|
+
async clear() {
|
|
958
|
+
const result = await this._call("clear", {})
|
|
959
|
+
return !!(result && result.ok)
|
|
960
|
+
}
|
|
961
|
+
},
|
|
962
|
+
|
|
963
|
+
// Badge counts. App icon badge on mobile (and installed PWAs); native tab
|
|
964
|
+
// bar badges on mobile, keyed by the tab's path from everywhere.yml.
|
|
965
|
+
badge: {
|
|
966
|
+
set(count) {
|
|
967
|
+
if (adapter.badgeSet) adapter.badgeSet(Math.max(0, count | 0))
|
|
968
|
+
},
|
|
969
|
+
clear() {
|
|
970
|
+
this.set(0)
|
|
971
|
+
},
|
|
972
|
+
setTab(path, count) {
|
|
973
|
+
if (adapter.badgeSetTab) adapter.badgeSetTab(path, Math.max(0, count | 0))
|
|
974
|
+
},
|
|
975
|
+
clearTab(path) {
|
|
976
|
+
this.setTab(path, 0)
|
|
977
|
+
}
|
|
978
|
+
},
|
|
979
|
+
|
|
253
980
|
updates: {
|
|
254
981
|
// True only when the shell actually has a feed to check (everywhere.yml
|
|
255
982
|
// updates: url + public_key made it into the shipped config).
|
|
@@ -290,4 +1017,399 @@ export const Everywhere = {
|
|
|
290
1017
|
}
|
|
291
1018
|
}
|
|
292
1019
|
|
|
1020
|
+
// --- declarative page glue ---------------------------------------------------
|
|
1021
|
+
// Server-rendered badge counts (the everywhere_badge / everywhere_tab_badge
|
|
1022
|
+
// helpers emit meta tags — CSP-safe, no inline JS) applied on every Turbo
|
|
1023
|
+
// visit, and haptics for taps on [data-everywhere-haptic] elements.
|
|
1024
|
+
|
|
1025
|
+
function applyBadgeMetas() {
|
|
1026
|
+
const badge = document.querySelector('meta[name="everywhere:badge"]')
|
|
1027
|
+
if (badge) Everywhere.badge.set(parseInt(badge.content, 10) || 0)
|
|
1028
|
+
|
|
1029
|
+
document.querySelectorAll('meta[name="everywhere:tab-badge"]').forEach((meta) => {
|
|
1030
|
+
try {
|
|
1031
|
+
const { path, count } = JSON.parse(meta.content)
|
|
1032
|
+
if (path) Everywhere.badge.setTab(path, count || 0)
|
|
1033
|
+
} catch (_) {}
|
|
1034
|
+
})
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function playDeclaredHaptic(el) {
|
|
1038
|
+
const [kind, value] = (el.dataset.everywhereHaptic || "impact").split(":")
|
|
1039
|
+
if (kind === "notification") Everywhere.haptics.notification(value)
|
|
1040
|
+
else if (kind === "selection") Everywhere.haptics.selection()
|
|
1041
|
+
else if (kind === "impact") Everywhere.haptics.impact(value)
|
|
1042
|
+
else Everywhere.haptics.impact(kind) // shorthand: data-everywhere-haptic="light"
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// Biometric gate. A [data-everywhere-biometric-lock] wrapper holds
|
|
1046
|
+
// [data-everywhere-biometric-content] (server-rendered hidden) and a
|
|
1047
|
+
// [data-everywhere-biometric-locked] overlay; when the device-local lock
|
|
1048
|
+
// preference is on in the mobile shell, content stays hidden until Face ID /
|
|
1049
|
+
// Touch ID passes. A pass is remembered for the JS session, so Turbo
|
|
1050
|
+
// revisits don't re-prompt. [data-everywhere-biometric-unlock] buttons retry
|
|
1051
|
+
// after a cancel. Everywhere else (browser, desktop, lock off) the content
|
|
1052
|
+
// just reveals.
|
|
1053
|
+
const unlockedGates = new Set()
|
|
1054
|
+
|
|
1055
|
+
function gateKey(el) {
|
|
1056
|
+
return el.getAttribute("data-everywhere-biometric-lock") || window.location.pathname
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function setGateLocked(el, locked) {
|
|
1060
|
+
const content = el.querySelector("[data-everywhere-biometric-content]")
|
|
1061
|
+
const overlay = el.querySelector("[data-everywhere-biometric-locked]")
|
|
1062
|
+
if (content) content.hidden = locked
|
|
1063
|
+
if (overlay) overlay.hidden = !locked
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
async function resolveBiometricGate(el, { prompt } = {}) {
|
|
1067
|
+
if (platform !== "mobile" || !Everywhere.biometrics.lockEnabled || unlockedGates.has(gateKey(el))) {
|
|
1068
|
+
return setGateLocked(el, false)
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
const probe = await Everywhere.biometrics.available()
|
|
1072
|
+
// A device that can't authenticate (nothing enrolled, older shell) reveals
|
|
1073
|
+
// rather than bricking the page — except lockout, where authenticate()'s
|
|
1074
|
+
// passcode path can still clear it.
|
|
1075
|
+
if (!probe.available && probe.status !== "lockout") return setGateLocked(el, false)
|
|
1076
|
+
|
|
1077
|
+
setGateLocked(el, true)
|
|
1078
|
+
if (!prompt) return
|
|
1079
|
+
|
|
1080
|
+
const result = await Everywhere.biometrics.authenticate({
|
|
1081
|
+
reason: el.getAttribute("data-everywhere-biometric-reason") || "Unlock to continue",
|
|
1082
|
+
allowPasscode: el.hasAttribute("data-everywhere-biometric-passcode")
|
|
1083
|
+
})
|
|
1084
|
+
if (result.authenticated) {
|
|
1085
|
+
unlockedGates.add(gateKey(el))
|
|
1086
|
+
setGateLocked(el, false)
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// Auto-prompt only when this page is actually on screen. Hotwire Native
|
|
1091
|
+
// loads tab web views in the background at launch, so a gated page in a
|
|
1092
|
+
// non-selected tab must lock silently and prompt when its tab is opened
|
|
1093
|
+
// (the visibilitychange listener below) — not flash Face ID over another tab.
|
|
1094
|
+
function applyBiometricGates() {
|
|
1095
|
+
const prompt = document.visibilityState === "visible"
|
|
1096
|
+
document.querySelectorAll("[data-everywhere-biometric-lock]").forEach((el) => {
|
|
1097
|
+
resolveBiometricGate(el, { prompt })
|
|
1098
|
+
})
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// [data-everywhere-biometric-toggle] checkboxes (server-rendered hidden +
|
|
1102
|
+
// disabled) become live switches for the lock preference — revealed only in
|
|
1103
|
+
// the mobile shell with working biometrics, along with their closest
|
|
1104
|
+
// [data-everywhere-biometric-toggle-row] container.
|
|
1105
|
+
async function wireBiometricToggles() {
|
|
1106
|
+
const toggles = document.querySelectorAll("[data-everywhere-biometric-toggle]")
|
|
1107
|
+
if (!toggles.length) return
|
|
1108
|
+
|
|
1109
|
+
const available = platform === "mobile" && (await Everywhere.biometrics.available()).available
|
|
1110
|
+
if (!available) return
|
|
1111
|
+
|
|
1112
|
+
toggles.forEach((input) => {
|
|
1113
|
+
const row = input.closest("[data-everywhere-biometric-toggle-row]")
|
|
1114
|
+
if (row) row.hidden = false
|
|
1115
|
+
input.hidden = false
|
|
1116
|
+
input.disabled = false
|
|
1117
|
+
input.checked = Everywhere.biometrics.lockEnabled
|
|
1118
|
+
})
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// --- native chrome (menus, nav bar items) -----------------------------------
|
|
1122
|
+
// Picks the native action sheet in the mobile shell and a DOM bottom sheet
|
|
1123
|
+
// (native.css .everywhere-sheet) everywhere else. Resolves the chosen item's
|
|
1124
|
+
// id, or null on cancel/dismiss.
|
|
1125
|
+
function presentMenu({ title, message, items, source }) {
|
|
1126
|
+
const list = (items || []).map((item, i) => ({
|
|
1127
|
+
id: item.id != null ? String(item.id) : String(i),
|
|
1128
|
+
title: item.title || "",
|
|
1129
|
+
image: item.image || null,
|
|
1130
|
+
style: item.style || "default",
|
|
1131
|
+
disabled: !!item.disabled
|
|
1132
|
+
}))
|
|
1133
|
+
|
|
1134
|
+
if (platform === "mobile" && adapter.menuSupported && adapter.menuSupported()) {
|
|
1135
|
+
return new Promise((resolve) => {
|
|
1136
|
+
let settled = false
|
|
1137
|
+
const id = adapter.menuSend("menu", { title, message, items: list, source }, (message) => {
|
|
1138
|
+
if (settled) return
|
|
1139
|
+
settled = true
|
|
1140
|
+
adapter.menuRemove(id)
|
|
1141
|
+
const data = (message && message.data) || {}
|
|
1142
|
+
resolve(data.action === "select" && data.id != null ? String(data.id) : null)
|
|
1143
|
+
})
|
|
1144
|
+
if (id === null) resolve(domMenu({ title, message, items: list }))
|
|
1145
|
+
})
|
|
1146
|
+
}
|
|
1147
|
+
return domMenu({ title, message, items: list })
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// The honest web/desktop fallback for Everywhere.menu(): a modal bottom sheet
|
|
1151
|
+
// built from the same item list. No inline scripts — pure DOM — so it clears CSP.
|
|
1152
|
+
function domMenu({ title, message, items }) {
|
|
1153
|
+
if (typeof document === "undefined") return Promise.resolve(null)
|
|
1154
|
+
|
|
1155
|
+
return new Promise((resolve) => {
|
|
1156
|
+
const overlay = document.createElement("div")
|
|
1157
|
+
overlay.className = "everywhere-sheet-overlay"
|
|
1158
|
+
const sheet = document.createElement("div")
|
|
1159
|
+
sheet.className = "everywhere-sheet"
|
|
1160
|
+
sheet.setAttribute("role", "menu")
|
|
1161
|
+
|
|
1162
|
+
if (title || message) {
|
|
1163
|
+
const header = document.createElement("div")
|
|
1164
|
+
header.className = "everywhere-sheet-header"
|
|
1165
|
+
if (title) {
|
|
1166
|
+
const el = document.createElement("div")
|
|
1167
|
+
el.className = "everywhere-sheet-title"
|
|
1168
|
+
el.textContent = title
|
|
1169
|
+
header.appendChild(el)
|
|
1170
|
+
}
|
|
1171
|
+
if (message) {
|
|
1172
|
+
const el = document.createElement("div")
|
|
1173
|
+
el.className = "everywhere-sheet-message"
|
|
1174
|
+
el.textContent = message
|
|
1175
|
+
header.appendChild(el)
|
|
1176
|
+
}
|
|
1177
|
+
sheet.appendChild(header)
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
let done = false
|
|
1181
|
+
const close = (value) => {
|
|
1182
|
+
if (done) return
|
|
1183
|
+
done = true
|
|
1184
|
+
document.removeEventListener("keydown", onKey)
|
|
1185
|
+
overlay.remove()
|
|
1186
|
+
resolve(value)
|
|
1187
|
+
}
|
|
1188
|
+
const onKey = (event) => { if (event.key === "Escape") close(null) }
|
|
1189
|
+
|
|
1190
|
+
items.forEach((item) => {
|
|
1191
|
+
const button = document.createElement("button")
|
|
1192
|
+
button.type = "button"
|
|
1193
|
+
button.className = "everywhere-sheet-item" +
|
|
1194
|
+
(item.style === "destructive" ? " everywhere-sheet-item-destructive" : "")
|
|
1195
|
+
button.textContent = item.title
|
|
1196
|
+
button.setAttribute("role", "menuitem")
|
|
1197
|
+
if (item.disabled) button.disabled = true
|
|
1198
|
+
else button.addEventListener("click", () => close(item.id))
|
|
1199
|
+
sheet.appendChild(button)
|
|
1200
|
+
})
|
|
1201
|
+
|
|
1202
|
+
const cancel = document.createElement("button")
|
|
1203
|
+
cancel.type = "button"
|
|
1204
|
+
cancel.className = "everywhere-sheet-item everywhere-sheet-cancel"
|
|
1205
|
+
cancel.textContent = "Cancel"
|
|
1206
|
+
cancel.addEventListener("click", () => close(null))
|
|
1207
|
+
sheet.appendChild(cancel)
|
|
1208
|
+
|
|
1209
|
+
overlay.addEventListener("click", (event) => { if (event.target === overlay) close(null) })
|
|
1210
|
+
document.addEventListener("keydown", onKey)
|
|
1211
|
+
overlay.appendChild(sheet)
|
|
1212
|
+
document.body.appendChild(overlay)
|
|
1213
|
+
})
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// Per-platform icon, resolved like tabs: `<base>-<os>` (SF Symbol on iOS, a
|
|
1217
|
+
// Material name on Android) then the shared `<base>`. One page serves both shells.
|
|
1218
|
+
function resolveNativeIcon(el, base) {
|
|
1219
|
+
return el.getAttribute(`${base}-${os}`) || el.getAttribute(base) || null
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function navText(el) {
|
|
1223
|
+
return (el.getAttribute("data-everywhere-nav-title") || el.textContent || "").trim()
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
function navSide(el) {
|
|
1227
|
+
return el.getAttribute("data-everywhere-nav-side") === "left" ? "left" : "right"
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function menuItemTitle(el) {
|
|
1231
|
+
return (el.getAttribute("data-everywhere-menu-title") || el.textContent || "").trim()
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// The nav bar items the current page declares, plus a fresh id→element map so
|
|
1235
|
+
// native taps can .click() the source element. Menu children get ids like
|
|
1236
|
+
// "n2.0" so a pull-down item routes back to the right element.
|
|
1237
|
+
let navMessageId = null
|
|
1238
|
+
const navElements = new Map()
|
|
1239
|
+
|
|
1240
|
+
function collectNavItems() {
|
|
1241
|
+
navElements.clear()
|
|
1242
|
+
const items = []
|
|
1243
|
+
let n = 0
|
|
1244
|
+
|
|
1245
|
+
document.querySelectorAll("[data-everywhere-nav-button]").forEach((el) => {
|
|
1246
|
+
const id = "n" + (n++)
|
|
1247
|
+
navElements.set(id, el)
|
|
1248
|
+
items.push({
|
|
1249
|
+
id,
|
|
1250
|
+
kind: "button",
|
|
1251
|
+
title: navText(el),
|
|
1252
|
+
image: resolveNativeIcon(el, "data-everywhere-nav-icon"),
|
|
1253
|
+
side: navSide(el),
|
|
1254
|
+
style: el.getAttribute("data-everywhere-nav-style") || "plain",
|
|
1255
|
+
disabled: el.disabled === true || el.hasAttribute("data-everywhere-nav-disabled")
|
|
1256
|
+
})
|
|
1257
|
+
})
|
|
1258
|
+
|
|
1259
|
+
document.querySelectorAll("[data-everywhere-nav-menu]").forEach((menuEl) => {
|
|
1260
|
+
const id = "n" + (n++)
|
|
1261
|
+
const children = []
|
|
1262
|
+
menuEl.querySelectorAll("[data-everywhere-menu-item]").forEach((child, i) => {
|
|
1263
|
+
const cid = id + "." + i
|
|
1264
|
+
navElements.set(cid, child)
|
|
1265
|
+
children.push({
|
|
1266
|
+
id: cid,
|
|
1267
|
+
title: menuItemTitle(child),
|
|
1268
|
+
image: resolveNativeIcon(child, "data-everywhere-menu-icon"),
|
|
1269
|
+
style: child.getAttribute("data-everywhere-menu-style") || "default"
|
|
1270
|
+
})
|
|
1271
|
+
})
|
|
1272
|
+
items.push({
|
|
1273
|
+
id,
|
|
1274
|
+
kind: "menu",
|
|
1275
|
+
title: navText(menuEl),
|
|
1276
|
+
image: resolveNativeIcon(menuEl, "data-everywhere-nav-icon") || "ellipsis.circle",
|
|
1277
|
+
side: navSide(menuEl),
|
|
1278
|
+
children
|
|
1279
|
+
})
|
|
1280
|
+
})
|
|
1281
|
+
|
|
1282
|
+
return items
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
// Send this page's nav bar items to the shell. Native re-replies on every tap,
|
|
1286
|
+
// so the callback lives until the next page swaps it out (menuRemove first).
|
|
1287
|
+
function applyNavItems() {
|
|
1288
|
+
if (platform !== "mobile" || !adapter.menuSupported || !adapter.menuSupported()) return
|
|
1289
|
+
if (navMessageId !== null) {
|
|
1290
|
+
adapter.menuRemove(navMessageId)
|
|
1291
|
+
navMessageId = null
|
|
1292
|
+
}
|
|
1293
|
+
const items = collectNavItems()
|
|
1294
|
+
if (!items.length) return
|
|
1295
|
+
|
|
1296
|
+
navMessageId = adapter.menuSend("navItems", { items }, (message) => {
|
|
1297
|
+
const data = (message && message.data) || {}
|
|
1298
|
+
if (data.action === "tap" && data.id != null) {
|
|
1299
|
+
const el = navElements.get(String(data.id))
|
|
1300
|
+
if (el) el.click()
|
|
1301
|
+
}
|
|
1302
|
+
})
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// A tap on an in-content [data-everywhere-menu-trigger]: a native action sheet
|
|
1306
|
+
// in the shell, an inline disclosure (toggling [data-everywhere-menu-open]) in
|
|
1307
|
+
// a browser. Either way, choosing an item .click()s the element it maps to.
|
|
1308
|
+
function handleMenuTrigger(event, trigger) {
|
|
1309
|
+
const container = trigger.closest("[data-everywhere-menu]") || trigger.parentElement
|
|
1310
|
+
if (!container) return
|
|
1311
|
+
const holder = container.querySelector("[data-everywhere-menu-items]") || container
|
|
1312
|
+
const elements = Array.from(holder.querySelectorAll("[data-everywhere-menu-item]"))
|
|
1313
|
+
|
|
1314
|
+
if (platform === "mobile" && adapter.menuSupported && adapter.menuSupported()) {
|
|
1315
|
+
event.preventDefault()
|
|
1316
|
+
const rect = trigger.getBoundingClientRect()
|
|
1317
|
+
const items = elements.map((el, i) => ({
|
|
1318
|
+
id: String(i),
|
|
1319
|
+
title: menuItemTitle(el),
|
|
1320
|
+
image: resolveNativeIcon(el, "data-everywhere-menu-icon"),
|
|
1321
|
+
style: el.getAttribute("data-everywhere-menu-style") || "default",
|
|
1322
|
+
disabled: el.disabled === true
|
|
1323
|
+
}))
|
|
1324
|
+
presentMenu({
|
|
1325
|
+
title: trigger.getAttribute("data-everywhere-menu-title") ||
|
|
1326
|
+
container.getAttribute("data-everywhere-menu-title") || null,
|
|
1327
|
+
items,
|
|
1328
|
+
source: { x: rect.left, y: rect.top, width: rect.width, height: rect.height }
|
|
1329
|
+
}).then((id) => {
|
|
1330
|
+
if (id == null) return
|
|
1331
|
+
const el = elements[parseInt(id, 10)]
|
|
1332
|
+
if (el) el.click()
|
|
1333
|
+
})
|
|
1334
|
+
} else {
|
|
1335
|
+
event.preventDefault()
|
|
1336
|
+
const open = container.hasAttribute("data-everywhere-menu-open")
|
|
1337
|
+
closeInlineMenus()
|
|
1338
|
+
if (!open) container.setAttribute("data-everywhere-menu-open", "")
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
function closeInlineMenus() {
|
|
1343
|
+
document.querySelectorAll("[data-everywhere-menu-open]")
|
|
1344
|
+
.forEach((el) => el.removeAttribute("data-everywhere-menu-open"))
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
function applyDeclarative() {
|
|
1348
|
+
applyBadgeMetas()
|
|
1349
|
+
applyBiometricGates()
|
|
1350
|
+
wireBiometricToggles()
|
|
1351
|
+
applyNavItems()
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
if (typeof document !== "undefined") {
|
|
1355
|
+
document.addEventListener("turbo:load", applyDeclarative)
|
|
1356
|
+
if (document.readyState === "loading") {
|
|
1357
|
+
document.addEventListener("DOMContentLoaded", applyDeclarative)
|
|
1358
|
+
} else {
|
|
1359
|
+
applyDeclarative()
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// A gated page that loaded hidden (a background tab) prompts when it
|
|
1363
|
+
// actually comes on screen; already-unlocked gates resolve instantly.
|
|
1364
|
+
document.addEventListener("visibilitychange", () => {
|
|
1365
|
+
if (document.visibilityState === "visible") applyBiometricGates()
|
|
1366
|
+
})
|
|
1367
|
+
|
|
1368
|
+
// Reset gates to fully-hidden before Turbo snapshots the page, so a cached
|
|
1369
|
+
// copy never carries unlocked content; turbo:load re-resolves on restore.
|
|
1370
|
+
document.addEventListener("turbo:before-cache", () => {
|
|
1371
|
+
document.querySelectorAll("[data-everywhere-biometric-lock]").forEach((el) => {
|
|
1372
|
+
const content = el.querySelector("[data-everywhere-biometric-content]")
|
|
1373
|
+
const overlay = el.querySelector("[data-everywhere-biometric-locked]")
|
|
1374
|
+
if (content) content.hidden = true
|
|
1375
|
+
if (overlay) overlay.hidden = true
|
|
1376
|
+
})
|
|
1377
|
+
// Never snapshot an open inline menu; the next page's applyNavItems()
|
|
1378
|
+
// replaces the nav callback, so drop this page's before it's cached.
|
|
1379
|
+
closeInlineMenus()
|
|
1380
|
+
if (navMessageId !== null) {
|
|
1381
|
+
adapter.menuRemove(navMessageId)
|
|
1382
|
+
navMessageId = null
|
|
1383
|
+
}
|
|
1384
|
+
})
|
|
1385
|
+
|
|
1386
|
+
document.addEventListener("click", (event) => {
|
|
1387
|
+
if (!(event.target instanceof Element)) return
|
|
1388
|
+
const el = event.target.closest("[data-everywhere-haptic]")
|
|
1389
|
+
if (el) playDeclaredHaptic(el)
|
|
1390
|
+
|
|
1391
|
+
const unlock = event.target.closest("[data-everywhere-biometric-unlock]")
|
|
1392
|
+
const gate = unlock && unlock.closest("[data-everywhere-biometric-lock]")
|
|
1393
|
+
if (gate) resolveBiometricGate(gate, { prompt: true })
|
|
1394
|
+
|
|
1395
|
+
const trigger = event.target.closest("[data-everywhere-menu-trigger]")
|
|
1396
|
+
if (trigger) {
|
|
1397
|
+
handleMenuTrigger(event, trigger)
|
|
1398
|
+
} else if (!event.target.closest("[data-everywhere-menu-open]")) {
|
|
1399
|
+
// A click anywhere outside an open inline menu dismisses it.
|
|
1400
|
+
closeInlineMenus()
|
|
1401
|
+
}
|
|
1402
|
+
})
|
|
1403
|
+
|
|
1404
|
+
document.addEventListener("change", async (event) => {
|
|
1405
|
+
const input = event.target
|
|
1406
|
+
if (!(input instanceof Element) || !input.hasAttribute("data-everywhere-biometric-toggle")) return
|
|
1407
|
+
|
|
1408
|
+
input.disabled = true
|
|
1409
|
+
const result = await Everywhere.biometrics.setLockEnabled(input.checked)
|
|
1410
|
+
input.checked = result.enabled
|
|
1411
|
+
input.disabled = false
|
|
1412
|
+
})
|
|
1413
|
+
}
|
|
1414
|
+
|
|
293
1415
|
export default Everywhere
|