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
data/lib/everywhere/ui.rb CHANGED
@@ -31,6 +31,24 @@ module Everywhere
31
31
  def cyan(t) = paint(t, 36)
32
32
  def gray(t) = paint(t, 90) # bright black — dimmer than dim() on most themes
33
33
 
34
+ # Display casing for os tokens. Target IDs ("macos-arm64") stay lowercase
35
+ # wherever they're typed or stored; use these only in human-facing output.
36
+ OS_LABELS = {
37
+ "macos" => "macOS",
38
+ "ios" => "iOS",
39
+ "android" => "Android",
40
+ "windows" => "Windows",
41
+ "linux" => "Linux"
42
+ }.freeze
43
+
44
+ def os_label(os) = OS_LABELS.fetch(os.to_s, os.to_s)
45
+
46
+ # "macos-arm64" → "macOS-arm64"
47
+ def target_label(target)
48
+ os, arch = target.to_s.split("-", 2)
49
+ [ os_label(os), arch ].compact.join("-")
50
+ end
51
+
34
52
  # --- output levels --------------------------------------------------------
35
53
  #
36
54
  # phase ● a major phase boundary (build / sign / notarize)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Everywhere
4
- VERSION = "0.1.15"
4
+ VERSION = "0.3.0"
5
5
 
6
6
  # Version of the @rubyeverywhere/bridge JS this gem ships. bridge/ in the
7
7
  # gem IS the npm package (served to Rails apps by Everywhere::Engine,
@@ -0,0 +1,6 @@
1
+ // Overwritten by `every build --target ios-*` on every stamp — do not edit by hand.
2
+ PRODUCT_BUNDLE_IDENTIFIER = com.rubyeverywhere.app
3
+ MARKETING_VERSION = 0.1.0
4
+ CURRENT_PROJECT_VERSION = 1
5
+ INFOPLIST_KEY_CFBundleDisplayName = RubyEverywhere
6
+ DEVELOPMENT_TEAM =
@@ -0,0 +1,164 @@
1
+ import HotwireNative
2
+ import UIKit
3
+ import UserNotifications
4
+ import WebKit
5
+
6
+ @main
7
+ class AppDelegate: UIResponder, UIApplicationDelegate {
8
+ func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
9
+ configureAppearance()
10
+ configureHotwire()
11
+ UNUserNotificationCenter.current().delegate = self
12
+ return true
13
+ }
14
+
15
+ // MARK: UISceneSession Lifecycle
16
+
17
+ func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
18
+ UISceneConfiguration(name: "Default", sessionRole: connectingSceneSession.role)
19
+ }
20
+
21
+ // MARK: Configuration
22
+
23
+ private func configureAppearance() {
24
+ // Make navigation bars opaque.
25
+ UINavigationBar.appearance().scrollEdgeAppearance = .init()
26
+ }
27
+
28
+ private func configureHotwire() {
29
+ let config = EverywhereConfig.shared
30
+
31
+ // The path configuration (rules + auth-gated tabs) is loaded by the
32
+ // SceneDelegate once a scene connects — it mirrors the web view's
33
+ // cookies into the shared store first, so the auth-gated tab list is
34
+ // fetched as the signed-in user (see SceneDelegate.loadPathConfiguration).
35
+
36
+ Hotwire.config.applicationUserAgentPrefix = config.userAgentPrefix
37
+ Hotwire.config.backButtonDisplayMode = .minimal
38
+ Hotwire.config.showDoneButtonOnModals = true
39
+ #if DEBUG
40
+ Hotwire.config.debugLoggingEnabled = true
41
+ #endif
42
+
43
+ Hotwire.registerBridgeComponents([
44
+ NotificationComponent.self,
45
+ HapticsComponent.self,
46
+ PermissionsComponent.self,
47
+ BiometricsComponent.self,
48
+ StorageComponent.self,
49
+ MenuComponent.self
50
+ ] + EverywhereExtensions.components)
51
+
52
+ // Handles Everywhere.reloadTabs() from the page (a dedicated message
53
+ // handler, so it works regardless of which bridge components are
54
+ // mounted). Posts a notification the SceneDelegate acts on.
55
+ let controlHandler = WebControlHandler()
56
+
57
+ // Mirrors the framework's default web view factory (WKWebView.debugInspectable):
58
+ // the configuration we receive already carries Hotwire's process pool and
59
+ // user agent, so we only add our user script before creating the web view.
60
+ Hotwire.config.makeCustomWebView = { configuration in
61
+ // Expose the app's identity to the page before any of its scripts
62
+ // run. Built here, per web view: webConfigJSON carries the
63
+ // instance override, which changes when a picker re-roots the app.
64
+ let configScript = WKUserScript(
65
+ source: "window.__EVERYWHERE_CONFIG__ = \(config.webConfigJSON);",
66
+ injectionTime: .atDocumentStart,
67
+ forMainFrameOnly: true
68
+ )
69
+ configuration.userContentController.addUserScript(configScript)
70
+ // Re-add defensively: a reused configuration would throw on a
71
+ // duplicate handler name.
72
+ configuration.userContentController.removeScriptMessageHandler(forName: "everywhereControl")
73
+ configuration.userContentController.add(controlHandler, name: "everywhereControl")
74
+ let webView = WKWebView(frame: .zero, configuration: configuration)
75
+ #if DEBUG
76
+ if #available(iOS 16.4, *) {
77
+ webView.isInspectable = true
78
+ }
79
+ #endif
80
+ return webView
81
+ }
82
+ }
83
+ }
84
+
85
+ // MARK: - Page → shell control messages
86
+
87
+ extension Notification.Name {
88
+ static let everywhereReloadConfig = Notification.Name("EverywhereReloadConfig")
89
+ static let everywhereResetApp = Notification.Name("EverywhereResetApp")
90
+ static let everywhereSetTabBadge = Notification.Name("EverywhereSetTabBadge")
91
+ static let everywhereSetInstance = Notification.Name("EverywhereSetInstance")
92
+ static let everywhereClearInstance = Notification.Name("EverywhereClearInstance")
93
+ /// Posted by `everywhereVisit(_:)` from native extension code.
94
+ static let everywhereNativeVisit = Notification.Name("EverywhereNativeVisit")
95
+ }
96
+
97
+ /// Receives `window.webkit.messageHandlers.everywhereControl` messages and
98
+ /// turns them into app-wide notifications. Holds no references to the web view
99
+ /// or delegates, so retaining it on the content controller can't cycle.
100
+ /// { action: "reloadConfig" } → refresh tabs/path config
101
+ /// { action: "reset", to: "/path" } → full app reset, then land on `to`
102
+ /// { action: "setBadge", count: 3 } → app icon badge (0 clears)
103
+ /// { action: "setTabBadge", path: "/x", count: 3 } → tab bar badge (0 clears)
104
+ /// { action: "setInstance", url: "https://…", to: "/path"? } → persist instance root + reset
105
+ /// { action: "clearInstance", to: "/path"? } → back to the stamped root + reset
106
+ final class WebControlHandler: NSObject, WKScriptMessageHandler {
107
+ func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
108
+ guard let body = message.body as? [String: Any],
109
+ let action = body["action"] as? String
110
+ else { return }
111
+
112
+ #if DEBUG
113
+ NSLog("everywhereControl: %@", String(describing: body))
114
+ #endif
115
+
116
+ switch action {
117
+ case "reloadConfig":
118
+ NotificationCenter.default.post(name: .everywhereReloadConfig, object: nil)
119
+ case "reset":
120
+ let info = (body["to"] as? String).map { ["to": $0] }
121
+ NotificationCenter.default.post(name: .everywhereResetApp, object: nil, userInfo: info)
122
+ case "setBadge":
123
+ setAppBadge((body["count"] as? NSNumber)?.intValue ?? 0)
124
+ case "setTabBadge":
125
+ guard let path = body["path"] as? String else { return }
126
+ NotificationCenter.default.post(
127
+ name: .everywhereSetTabBadge, object: nil,
128
+ userInfo: ["path": path, "count": (body["count"] as? NSNumber)?.intValue ?? 0])
129
+ case "setInstance":
130
+ guard let url = body["url"] as? String else { return }
131
+ var info: [String: Any] = ["url": url]
132
+ if let to = body["to"] as? String { info["to"] = to }
133
+ NotificationCenter.default.post(name: .everywhereSetInstance, object: nil, userInfo: info)
134
+ case "clearInstance":
135
+ let info = (body["to"] as? String).map { ["to": $0] }
136
+ NotificationCenter.default.post(name: .everywhereClearInstance, object: nil, userInfo: info)
137
+ default:
138
+ break
139
+ }
140
+ }
141
+
142
+ /// Provisional authorization is silent — no permission prompt for a badge.
143
+ private func setAppBadge(_ count: Int) {
144
+ let center = UNUserNotificationCenter.current()
145
+ center.requestAuthorization(options: [.badge, .provisional]) { granted, _ in
146
+ guard granted else { return }
147
+ if #available(iOS 16.0, *) {
148
+ center.setBadgeCount(count)
149
+ } else {
150
+ DispatchQueue.main.async {
151
+ UIApplication.shared.applicationIconBadgeNumber = count
152
+ }
153
+ }
154
+ }
155
+ }
156
+ }
157
+
158
+ // MARK: - UNUserNotificationCenterDelegate
159
+
160
+ extension AppDelegate: UNUserNotificationCenterDelegate {
161
+ func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
162
+ completionHandler([.banner, .sound])
163
+ }
164
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "colors" : [
3
+ {
4
+ "color" : {
5
+ "color-space" : "srgb",
6
+ "components" : {
7
+ "alpha" : "1.000",
8
+ "blue" : "0x2D",
9
+ "green" : "0x34",
10
+ "red" : "0xCC"
11
+ }
12
+ },
13
+ "idiom" : "universal"
14
+ }
15
+ ],
16
+ "info" : {
17
+ "author" : "xcode",
18
+ "version" : 1
19
+ }
20
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "images" : [
3
+ {
4
+ "filename" : "AppIcon.png",
5
+ "idiom" : "universal",
6
+ "platform" : "ios",
7
+ "size" : "1024x1024"
8
+ }
9
+ ],
10
+ "info" : {
11
+ "author" : "xcode",
12
+ "version" : 1
13
+ }
14
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "info" : {
3
+ "author" : "xcode",
4
+ "version" : 1
5
+ }
6
+ }
@@ -0,0 +1,38 @@
1
+ {
2
+ "colors" : [
3
+ {
4
+ "color" : {
5
+ "color-space" : "srgb",
6
+ "components" : {
7
+ "alpha" : "1.000",
8
+ "blue" : "0xF7",
9
+ "green" : "0xF9",
10
+ "red" : "0xFA"
11
+ }
12
+ },
13
+ "idiom" : "universal"
14
+ },
15
+ {
16
+ "appearances" : [
17
+ {
18
+ "appearance" : "luminosity",
19
+ "value" : "dark"
20
+ }
21
+ ],
22
+ "color" : {
23
+ "color-space" : "srgb",
24
+ "components" : {
25
+ "alpha" : "1.000",
26
+ "blue" : "0x1A",
27
+ "green" : "0x1B",
28
+ "red" : "0x1C"
29
+ }
30
+ },
31
+ "idiom" : "universal"
32
+ }
33
+ ],
34
+ "info" : {
35
+ "author" : "xcode",
36
+ "version" : 1
37
+ }
38
+ }
@@ -0,0 +1,32 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="22505" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
3
+ <device id="retina6_12" orientation="portrait" appearance="light"/>
4
+ <dependencies>
5
+ <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22504"/>
6
+ <capability name="Named colors" minToolsVersion="9.0"/>
7
+ <capability name="Safe area layout guides" minToolsVersion="9.0"/>
8
+ <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
9
+ </dependencies>
10
+ <scenes>
11
+ <!--View Controller-->
12
+ <scene sceneID="EHf-IW-A2E">
13
+ <objects>
14
+ <viewController id="01J-lp-oVM" sceneMemberID="viewController">
15
+ <view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
16
+ <rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
17
+ <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
18
+ <viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
19
+ <color key="backgroundColor" name="LaunchBackground"/>
20
+ </view>
21
+ </viewController>
22
+ <placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
23
+ </objects>
24
+ <point key="canvasLocation" x="53" y="375"/>
25
+ </scene>
26
+ </scenes>
27
+ <resources>
28
+ <namedColor name="LaunchBackground">
29
+ <color red="0.98039215686274506" green="0.97647058823529409" blue="0.96862745098039216" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
30
+ </namedColor>
31
+ </resources>
32
+ </document>
@@ -0,0 +1,276 @@
1
+ import Foundation
2
+ import HotwireNative
3
+ import LocalAuthentication
4
+ import Security
5
+
6
+ /// Bridge component backing `everywhere--biometrics`. Handles `query`
7
+ /// (availability + biometry type), `authenticate` (Face ID / Touch ID
8
+ /// prompt, optionally falling back to the device passcode), and the
9
+ /// `credential*` events (a biometric-protected keychain secret powering
10
+ /// "sign in with Face ID"), replying `{available, biometry, status}`,
11
+ /// `{authenticated, error?}`, and `{stored|token|enrolled|cleared, error?}`.
12
+ ///
13
+ /// Biometrics must be declared in everywhere.yml — that's what stamps
14
+ /// NSFaceIDUsageDescription, and evaluating a Face ID policy without it
15
+ /// crashes the app — so undeclared use short-circuits before any system API.
16
+ ///
17
+ /// This proves presence to the PAGE only (gate a screen, confirm an action);
18
+ /// it is not authentication the server can trust.
19
+ final class BiometricsComponent: BridgeComponent {
20
+ override nonisolated class var name: String { "everywhere--biometrics" }
21
+
22
+ override func onReceive(message: Message) {
23
+ let declared = EverywhereConfig.shared.declaresPermission("biometrics")
24
+
25
+ switch message.event {
26
+ case "query":
27
+ declared ? query(message)
28
+ : reply(message, QueryReply(available: false, biometry: "none", status: "undeclared"))
29
+ case "authenticate":
30
+ declared ? authenticate(message)
31
+ : reply(message, AuthReply(authenticated: false, error: "undeclared"))
32
+ case "credentialStore", "credentialGet", "credentialStatus", "credentialClear":
33
+ declared ? credential(message)
34
+ : reply(message, CredentialReply(error: "undeclared"))
35
+ default:
36
+ break
37
+ }
38
+ }
39
+
40
+ private func query(_ message: Message) {
41
+ let context = LAContext()
42
+ var error: NSError?
43
+ let available = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
44
+ // biometryType is only valid after canEvaluatePolicy; it still reports
45
+ // the hardware's type when the check fails (e.g. nothing enrolled).
46
+ reply(message, QueryReply(
47
+ available: available,
48
+ biometry: biometryName(context.biometryType),
49
+ status: available ? "available" : describe(error)))
50
+ }
51
+
52
+ private func authenticate(_ message: Message) {
53
+ let data: Payload? = message.data()
54
+ let policy: LAPolicy = data?.allowPasscode == true
55
+ ? .deviceOwnerAuthentication
56
+ : .deviceOwnerAuthenticationWithBiometrics
57
+ let reason = (data?.reason?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap { $0.isEmpty ? nil : $0 }
58
+ ?? "Confirm it's you."
59
+
60
+ let context = LAContext()
61
+ var error: NSError?
62
+ guard context.canEvaluatePolicy(policy, error: &error) else {
63
+ return reply(message, AuthReply(authenticated: false, error: describe(error)))
64
+ }
65
+
66
+ context.evaluatePolicy(policy, localizedReason: reason) { [weak self] success, error in
67
+ self?.reply(message, AuthReply(authenticated: success, error: success ? nil : self?.describe(error as NSError?)))
68
+ }
69
+ }
70
+
71
+ // MARK: Keychain credential (the "sign in with Face ID" token)
72
+ //
73
+ // A server-issued secret stored with SecAccessControl(.biometryCurrentSet):
74
+ // reading it IS the Face ID prompt, and iOS invalidates the item whenever
75
+ // biometric enrollment changes (a newly added face/finger kills it).
76
+ // Keychain calls that show UI block their thread, so everything runs off
77
+ // the main queue and replies through the nonisolated helper.
78
+
79
+ private func credential(_ message: Message) {
80
+ let event = message.event
81
+ let data: Payload? = message.data()
82
+
83
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
84
+ guard let self else { return }
85
+ switch event {
86
+ case "credentialStore":
87
+ self.reply(message, self.storeCredential(data?.token, reason: data?.reason ?? "Enable biometric sign-in"))
88
+ case "credentialGet":
89
+ self.reply(message, self.readCredential(reason: data?.reason ?? "Sign in"))
90
+ case "credentialStatus":
91
+ self.reply(message, self.credentialStatus())
92
+ default: // credentialClear
93
+ self.reply(message, self.clearCredential())
94
+ }
95
+ }
96
+ }
97
+
98
+ private nonisolated static let credentialService =
99
+ (Bundle.main.bundleIdentifier ?? "com.rubyeverywhere.app") + ".biometric-login"
100
+
101
+ private nonisolated var credentialQuery: [String: Any] {
102
+ [kSecClass as String: kSecClassGenericPassword,
103
+ kSecAttrService as String: Self.credentialService,
104
+ kSecAttrAccount as String: "login"]
105
+ }
106
+
107
+ /// Blocking biometric check on the calling (background) queue. Returns the
108
+ /// authenticated context on success — pass it into a keychain read so
109
+ /// hardware shows exactly one prompt — or the error string on failure.
110
+ /// Real devices enforce the item ACL regardless; the Simulator doesn't
111
+ /// honor keychain ACLs, so this explicit pass is what keeps sim and
112
+ /// device behavior aligned.
113
+ private nonisolated func biometricCheck(reason: String) -> (context: LAContext?, error: String?) {
114
+ let context = LAContext()
115
+ context.localizedReason = reason
116
+
117
+ var authError: NSError?
118
+ guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &authError) else {
119
+ return (nil, describe(authError))
120
+ }
121
+
122
+ let gate = DispatchSemaphore(value: 0)
123
+ var passed = false
124
+ var evalError: NSError?
125
+ context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { ok, error in
126
+ passed = ok
127
+ evalError = error as NSError?
128
+ gate.signal()
129
+ }
130
+ gate.wait()
131
+ return passed ? (context, nil) : (nil, describe(evalError))
132
+ }
133
+
134
+ /// Storing runs the check too: writes don't trigger the ACL, but enabling
135
+ /// a sign-in credential is a security change — confirm it's the owner.
136
+ private nonisolated func storeCredential(_ token: String?, reason: String) -> CredentialReply {
137
+ guard let token, !token.isEmpty, let data = token.data(using: .utf8) else {
138
+ return CredentialReply(stored: false, error: "failed")
139
+ }
140
+
141
+ if let error = biometricCheck(reason: reason).error {
142
+ return CredentialReply(stored: false, error: error)
143
+ }
144
+
145
+ SecItemDelete(credentialQuery as CFDictionary)
146
+
147
+ // WhenUnlocked (not WhenPasscodeSet): real devices can't enroll
148
+ // biometrics without a passcode, so protection is equivalent — but
149
+ // WhenPasscodeSet makes SecItemAdd fail on passcode-less simulators.
150
+ // .biometryCurrentSet is the actual gate either way.
151
+ var accessError: Unmanaged<CFError>?
152
+ guard let access = SecAccessControlCreateWithFlags(
153
+ nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, .biometryCurrentSet, &accessError)
154
+ else { return CredentialReply(stored: false, error: "notAvailable") }
155
+
156
+ var attrs = credentialQuery
157
+ attrs[kSecValueData as String] = data
158
+ attrs[kSecAttrAccessControl as String] = access
159
+
160
+ let status = SecItemAdd(attrs as CFDictionary, nil)
161
+ return status == errSecSuccess
162
+ ? CredentialReply(stored: true)
163
+ : CredentialReply(stored: false, error: keychainError(status), code: status)
164
+ }
165
+
166
+ private nonisolated func readCredential(reason: String) -> CredentialReply {
167
+ let check = biometricCheck(reason: reason)
168
+ guard let context = check.context else {
169
+ return CredentialReply(error: check.error ?? "failed")
170
+ }
171
+
172
+ var query = credentialQuery
173
+ query[kSecReturnData as String] = true
174
+ query[kSecUseAuthenticationContext as String] = context
175
+
176
+ var result: CFTypeRef?
177
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
178
+ guard status == errSecSuccess, let data = result as? Data,
179
+ let token = String(data: data, encoding: .utf8)
180
+ else { return CredentialReply(error: keychainError(status), code: status) }
181
+
182
+ return CredentialReply(token: token)
183
+ }
184
+
185
+ /// Whether a credential exists, without triggering Face ID:
186
+ /// interactionNotAllowed means "it's there but needs auth" — enrolled.
187
+ private nonisolated func credentialStatus() -> CredentialReply {
188
+ let context = LAContext()
189
+ context.interactionNotAllowed = true
190
+
191
+ var query = credentialQuery
192
+ query[kSecUseAuthenticationContext as String] = context
193
+
194
+ let status = SecItemCopyMatching(query as CFDictionary, nil)
195
+ return CredentialReply(enrolled: status == errSecSuccess || status == errSecInteractionNotAllowed)
196
+ }
197
+
198
+ private nonisolated func clearCredential() -> CredentialReply {
199
+ let status = SecItemDelete(credentialQuery as CFDictionary)
200
+ let cleared = status == errSecSuccess || status == errSecItemNotFound
201
+ return CredentialReply(cleared: cleared, error: cleared ? nil : keychainError(status))
202
+ }
203
+
204
+ private nonisolated func keychainError(_ status: OSStatus) -> String {
205
+ switch status {
206
+ case errSecUserCanceled: "canceled"
207
+ case errSecItemNotFound: "notEnrolled"
208
+ case errSecAuthFailed: "failed"
209
+ default: "failed"
210
+ }
211
+ }
212
+
213
+ /// Replies on the exact message that asked (same rationale as
214
+ /// PermissionsComponent: concurrent messages for one event make
215
+ /// `reply(to: event)` target only the last-received message).
216
+ /// nonisolated so LAContext's background completion can call it directly;
217
+ /// the actual reply still hops to the main actor.
218
+ private nonisolated func reply(_ message: Message, _ payload: Encodable) {
219
+ Task { @MainActor in
220
+ self.reply(with: message.replacing(data: payload))
221
+ }
222
+ }
223
+
224
+ private func biometryName(_ type: LABiometryType) -> String {
225
+ switch type {
226
+ case .faceID: return "faceID"
227
+ case .touchID: return "touchID"
228
+ default:
229
+ if #available(iOS 17.0, *), type == .opticID { return "opticID" }
230
+ return "none"
231
+ }
232
+ }
233
+
234
+ private nonisolated func describe(_ error: NSError?) -> String {
235
+ guard let error, error.domain == LAErrorDomain,
236
+ let code = LAError.Code(rawValue: error.code)
237
+ else { return "failed" }
238
+
239
+ switch code {
240
+ case .userCancel, .systemCancel, .appCancel: return "canceled"
241
+ case .userFallback: return "fallback"
242
+ case .biometryLockout: return "lockout"
243
+ case .biometryNotEnrolled: return "notEnrolled"
244
+ case .biometryNotAvailable: return "notAvailable"
245
+ case .passcodeNotSet: return "passcodeNotSet"
246
+ default: return "failed"
247
+ }
248
+ }
249
+
250
+ private struct Payload: Decodable {
251
+ let reason: String?
252
+ let allowPasscode: Bool?
253
+ let token: String?
254
+ }
255
+
256
+ private struct QueryReply: Encodable {
257
+ let available: Bool
258
+ let biometry: String
259
+ let status: String
260
+ }
261
+
262
+ private struct AuthReply: Encodable {
263
+ let authenticated: Bool
264
+ let error: String?
265
+ }
266
+
267
+ private struct CredentialReply: Encodable {
268
+ var stored: Bool? = nil
269
+ var token: String? = nil
270
+ var enrolled: Bool? = nil
271
+ var cleared: Bool? = nil
272
+ var error: String? = nil
273
+ /// Raw OSStatus on keychain failures — diagnosis, not API.
274
+ var code: Int32? = nil
275
+ }
276
+ }
@@ -0,0 +1,47 @@
1
+ import Foundation
2
+ import HotwireNative
3
+ import UIKit
4
+
5
+ /// Bridge component backing `everywhere--haptics`: plays impact, notification,
6
+ /// and selection feedback for `Everywhere.haptics.*` calls from the page.
7
+ final class HapticsComponent: BridgeComponent {
8
+ override nonisolated class var name: String { "everywhere--haptics" }
9
+
10
+ override func onReceive(message: Message) {
11
+ switch message.event {
12
+ case "impact":
13
+ let style: Payload? = message.data()
14
+ UIImpactFeedbackGenerator(style: impactStyle(style?.style)).impactOccurred()
15
+ case "notification":
16
+ let type: Payload? = message.data()
17
+ UINotificationFeedbackGenerator().notificationOccurred(notificationType(type?.type))
18
+ case "selection":
19
+ UISelectionFeedbackGenerator().selectionChanged()
20
+ default:
21
+ break
22
+ }
23
+ }
24
+
25
+ private func impactStyle(_ name: String?) -> UIImpactFeedbackGenerator.FeedbackStyle {
26
+ switch name {
27
+ case "light": .light
28
+ case "heavy": .heavy
29
+ case "soft": .soft
30
+ case "rigid": .rigid
31
+ default: .medium
32
+ }
33
+ }
34
+
35
+ private func notificationType(_ name: String?) -> UINotificationFeedbackGenerator.FeedbackType {
36
+ switch name {
37
+ case "warning": .warning
38
+ case "error": .error
39
+ default: .success
40
+ }
41
+ }
42
+
43
+ private struct Payload: Decodable {
44
+ let style: String?
45
+ let type: String?
46
+ }
47
+ }