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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/bridge/README.md +105 -2
  3. data/bridge/everywhere/bridge.js +1131 -9
  4. data/bridge/everywhere/native.css +203 -0
  5. data/bridge/package.json +6 -3
  6. data/lib/everywhere/builders/ios.rb +396 -0
  7. data/lib/everywhere/cli.rb +2 -0
  8. data/lib/everywhere/commands/build.rb +17 -1
  9. data/lib/everywhere/commands/clean.rb +19 -10
  10. data/lib/everywhere/commands/dev.rb +182 -19
  11. data/lib/everywhere/commands/doctor.rb +45 -3
  12. data/lib/everywhere/commands/icon.rb +14 -0
  13. data/lib/everywhere/commands/install.rb +37 -6
  14. data/lib/everywhere/commands/logs.rb +56 -0
  15. data/lib/everywhere/commands/platform/build.rb +3 -3
  16. data/lib/everywhere/commands/platform/runner.rb +1 -1
  17. data/lib/everywhere/commands/release.rb +1 -1
  18. data/lib/everywhere/commands/shell_dir.rb +11 -4
  19. data/lib/everywhere/config.rb +469 -1
  20. data/lib/everywhere/engine.rb +42 -1
  21. data/lib/everywhere/icon.rb +50 -0
  22. data/lib/everywhere/log_filter.rb +28 -2
  23. data/lib/everywhere/mobile_config_endpoint.rb +88 -0
  24. data/lib/everywhere/mobile_configs_controller.rb +64 -0
  25. data/lib/everywhere/native_helper.rb +352 -0
  26. data/lib/everywhere/paths.rb +41 -0
  27. data/lib/everywhere/raster.rb +17 -0
  28. data/lib/everywhere/shellout.rb +3 -1
  29. data/lib/everywhere/simulator.rb +74 -0
  30. data/lib/everywhere/ui.rb +18 -0
  31. data/lib/everywhere/version.rb +1 -1
  32. data/support/mobile/ios/App/App.xcconfig +6 -0
  33. data/support/mobile/ios/App/AppDelegate.swift +164 -0
  34. data/support/mobile/ios/App/Assets.xcassets/AccentColor.colorset/Contents.json +20 -0
  35. data/support/mobile/ios/App/Assets.xcassets/AppIcon.appiconset/AppIcon.png +0 -0
  36. data/support/mobile/ios/App/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
  37. data/support/mobile/ios/App/Assets.xcassets/Contents.json +6 -0
  38. data/support/mobile/ios/App/Assets.xcassets/LaunchBackground.colorset/Contents.json +38 -0
  39. data/support/mobile/ios/App/Base.lproj/LaunchScreen.storyboard +32 -0
  40. data/support/mobile/ios/App/Bridge/BiometricsComponent.swift +276 -0
  41. data/support/mobile/ios/App/Bridge/HapticsComponent.swift +47 -0
  42. data/support/mobile/ios/App/Bridge/MenuComponent.swift +192 -0
  43. data/support/mobile/ios/App/Bridge/NotificationComponent.swift +56 -0
  44. data/support/mobile/ios/App/Bridge/PermissionsComponent.swift +142 -0
  45. data/support/mobile/ios/App/Bridge/StorageComponent.swift +63 -0
  46. data/support/mobile/ios/App/ErrorViewController.swift +64 -0
  47. data/support/mobile/ios/App/EverywhereConfig.swift +289 -0
  48. data/support/mobile/ios/App/EverywhereHost.swift +34 -0
  49. data/support/mobile/ios/App/Extensions/EverywhereExtensions.swift +32 -0
  50. data/support/mobile/ios/App/Info.plist +28 -0
  51. data/support/mobile/ios/App/Resources/everywhere.json +8 -0
  52. data/support/mobile/ios/App/Resources/path-configuration.json +19 -0
  53. data/support/mobile/ios/App/SceneDelegate.swift +484 -0
  54. data/support/mobile/ios/App.xcodeproj/project.pbxproj +458 -0
  55. data/support/mobile/ios/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  56. data/support/mobile/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +14 -0
  57. data/support/mobile/ios/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme +77 -0
  58. data/support/mobile/ios/NativeExtensions/Package.swift +26 -0
  59. data/support/mobile/ios/NativeExtensions/Sources/NativeExtensions/Exports.swift +5 -0
  60. data/support/mobile/ios/README.md +73 -0
  61. metadata +37 -1
@@ -1,5 +1,5 @@
1
1
  // @rubyeverywhere/bridge — one API for the browser, the RubyEverywhere desktop
2
- // shell, and (soon) Hotwire Native mobile apps.
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 (stub) ------------------------------------------
193
- // Detection is real (Hotwire Native apps set their user agent); behavior falls
194
- // back to the browser adapter until the bridge-component adapter lands. The
195
- // native webview implements confirm() properly, so that fallback is already
196
- // correct on mobile.
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
- // TODO: notify via a Hotwire Native bridge component ("everywhere--notification")
201
- // TODO: menu/tab events dispatched as document CustomEvents ("everywhere:menu")
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