ruby_everywhere 0.1.15 → 0.2.0

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