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
@@ -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
+ }
@@ -0,0 +1,56 @@
1
+ import Foundation
2
+ import HotwireNative
3
+ import UserNotifications
4
+
5
+ /// Bridge component backing the `everywhere--notification` web component.
6
+ /// Handles `notify` events by requesting authorization (if needed) and
7
+ /// delivering an immediate local notification, then replies `{delivered: Bool}`.
8
+ final class NotificationComponent: BridgeComponent {
9
+ override nonisolated class var name: String { "everywhere--notification" }
10
+
11
+ override func onReceive(message: Message) {
12
+ guard message.event == "notify" else { return }
13
+ guard let data: NotifyMessage = message.data() else {
14
+ reply(to: "notify", with: NotifyReply(delivered: false))
15
+ return
16
+ }
17
+ deliver(data)
18
+ }
19
+
20
+ private func deliver(_ data: NotifyMessage) {
21
+ let center = UNUserNotificationCenter.current()
22
+ center.requestAuthorization(options: [.alert, .sound, .badge]) { [weak self] granted, _ in
23
+ guard granted else {
24
+ self?.replyOnMain(delivered: false)
25
+ return
26
+ }
27
+
28
+ let content = UNMutableNotificationContent()
29
+ content.title = data.title
30
+ if let body = data.body {
31
+ content.body = body
32
+ }
33
+ content.sound = .default
34
+
35
+ let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
36
+ center.add(request) { error in
37
+ self?.replyOnMain(delivered: error == nil)
38
+ }
39
+ }
40
+ }
41
+
42
+ private nonisolated func replyOnMain(delivered: Bool) {
43
+ Task { @MainActor in
44
+ self.reply(to: "notify", with: NotifyReply(delivered: delivered))
45
+ }
46
+ }
47
+
48
+ private struct NotifyMessage: Decodable {
49
+ let title: String
50
+ let body: String?
51
+ }
52
+
53
+ private struct NotifyReply: Encodable {
54
+ let delivered: Bool
55
+ }
56
+ }
@@ -0,0 +1,142 @@
1
+ import AVFoundation
2
+ import CoreLocation
3
+ import Foundation
4
+ import HotwireNative
5
+ import UIKit
6
+ import UserNotifications
7
+
8
+ /// Bridge component backing `everywhere--permissions`. Handles `query` and
9
+ /// `request` for notifications / camera / location, replying
10
+ /// `{name, status}` with granted | denied | prompt | undeclared | unsupported,
11
+ /// and `openSettings` for the denied dead-end (iOS never re-prompts).
12
+ ///
13
+ /// Camera and location must be declared in everywhere.yml — that's what stamps
14
+ /// their Info.plist usage strings, and requesting without one crashes the app —
15
+ /// so undeclared permissions short-circuit to `undeclared` before any system API.
16
+ final class PermissionsComponent: BridgeComponent {
17
+ override nonisolated class var name: String { "everywhere--permissions" }
18
+
19
+ private lazy var location = LocationPermission()
20
+
21
+ override func onReceive(message: Message) {
22
+ switch message.event {
23
+ case "query", "request":
24
+ guard let data: Payload = message.data() else { return }
25
+ handle(message, permission: data.name)
26
+ case "openSettings":
27
+ if let url = URL(string: UIApplication.openSettingsURLString) {
28
+ UIApplication.shared.open(url)
29
+ }
30
+ default:
31
+ break
32
+ }
33
+ }
34
+
35
+ private func handle(_ message: Message, permission: String) {
36
+ // Notifications carry no usage string, so they can't crash undeclared.
37
+ guard permission == "notifications" || EverywhereConfig.shared.declaresPermission(permission) else {
38
+ return reply(message, permission, "undeclared")
39
+ }
40
+
41
+ let event = message.event
42
+ switch permission {
43
+ case "notifications":
44
+ notifications(event) { [weak self] status in self?.reply(message, permission, status) }
45
+ case "camera":
46
+ camera(event) { [weak self] status in self?.reply(message, permission, status) }
47
+ case "location":
48
+ location.resolve(event) { [weak self] status in self?.reply(message, permission, status) }
49
+ default:
50
+ reply(message, permission, "unsupported")
51
+ }
52
+ }
53
+
54
+ /// Replies on the exact message that asked. Concurrent messages for the
55
+ /// same event (every permission row queries on page load at once) make
56
+ /// `reply(to: event)` unusable — it targets only the last-received message.
57
+ private func reply(_ message: Message, _ name: String, _ status: String) {
58
+ Task { @MainActor in
59
+ self.reply(with: message.replacing(data: StatusReply(name: name, status: status)))
60
+ }
61
+ }
62
+
63
+ // MARK: Notifications
64
+
65
+ private func notifications(_ event: String, completion: @escaping (String) -> Void) {
66
+ let center = UNUserNotificationCenter.current()
67
+ center.getNotificationSettings { settings in
68
+ switch settings.authorizationStatus {
69
+ case .authorized, .ephemeral:
70
+ completion("granted")
71
+ case .denied:
72
+ completion("denied")
73
+ default:
74
+ // .provisional (acquired silently, e.g. for badges) still
75
+ // counts as promptable — the user has never been asked, and
76
+ // a full request from it shows the real dialog.
77
+ guard event == "request" else { return completion("prompt") }
78
+ center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
79
+ completion(granted ? "granted" : "denied")
80
+ }
81
+ }
82
+ }
83
+ }
84
+
85
+ // MARK: Camera
86
+
87
+ private func camera(_ event: String, completion: @escaping (String) -> Void) {
88
+ switch AVCaptureDevice.authorizationStatus(for: .video) {
89
+ case .authorized:
90
+ completion("granted")
91
+ case .denied, .restricted:
92
+ completion("denied")
93
+ default:
94
+ guard event == "request" else { return completion("prompt") }
95
+ AVCaptureDevice.requestAccess(for: .video) { granted in
96
+ completion(granted ? "granted" : "denied")
97
+ }
98
+ }
99
+ }
100
+
101
+ private struct Payload: Decodable {
102
+ let name: String
103
+ }
104
+
105
+ private struct StatusReply: Encodable {
106
+ let name: String
107
+ let status: String
108
+ }
109
+ }
110
+
111
+ /// When-in-use location authorization. The prompt answers through the
112
+ /// manager's delegate, so the pending completion is held until
113
+ /// `locationManagerDidChangeAuthorization` reports a decision.
114
+ private final class LocationPermission: NSObject, CLLocationManagerDelegate {
115
+ private lazy var manager = CLLocationManager()
116
+ private var pending: ((String) -> Void)?
117
+
118
+ func resolve(_ event: String, completion: @escaping (String) -> Void) {
119
+ guard manager.authorizationStatus == .notDetermined else {
120
+ return completion(describe(manager.authorizationStatus))
121
+ }
122
+ guard event == "request" else { return completion("prompt") }
123
+
124
+ pending = completion
125
+ manager.delegate = self
126
+ manager.requestWhenInUseAuthorization()
127
+ }
128
+
129
+ func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
130
+ guard manager.authorizationStatus != .notDetermined, let pending else { return }
131
+ self.pending = nil
132
+ pending(describe(manager.authorizationStatus))
133
+ }
134
+
135
+ private func describe(_ status: CLAuthorizationStatus) -> String {
136
+ switch status {
137
+ case .authorizedWhenInUse, .authorizedAlways: "granted"
138
+ case .denied, .restricted: "denied"
139
+ default: "prompt"
140
+ }
141
+ }
142
+ }
@@ -0,0 +1,63 @@
1
+ import Foundation
2
+ import HotwireNative
3
+
4
+ /// Bridge component backing `everywhere--storage`: device-local key/value
5
+ /// settings persisted in UserDefaults for `Everywhere.storage.*`. Values ride
6
+ /// the wire as JSON-encoded strings (the JS side owns encoding/decoding), so
7
+ /// any JSON value round-trips without a typed schema here.
8
+ ///
9
+ /// Keys are namespaced under "everywhere.app." so page data can never collide
10
+ /// with the shell's own defaults (dev URL, instance override). Settings, not
11
+ /// secrets: server tokens belong in the keychain via biometrics.credential.
12
+ final class StorageComponent: BridgeComponent {
13
+ override nonisolated class var name: String { "everywhere--storage" }
14
+
15
+ private static let prefix = "everywhere.app."
16
+
17
+ override func onReceive(message: Message) {
18
+ let data: Payload? = message.data()
19
+ let defaults = UserDefaults.standard
20
+
21
+ switch message.event {
22
+ case "get":
23
+ guard let key = namespaced(data) else { return reply(message, Reply(ok: false)) }
24
+ reply(message, Reply(ok: true, value: defaults.string(forKey: key)))
25
+ case "set":
26
+ guard let key = namespaced(data), let value = data?.value else {
27
+ return reply(message, Reply(ok: false))
28
+ }
29
+ defaults.set(value, forKey: key)
30
+ reply(message, Reply(ok: true))
31
+ case "remove":
32
+ guard let key = namespaced(data) else { return reply(message, Reply(ok: false)) }
33
+ defaults.removeObject(forKey: key)
34
+ reply(message, Reply(ok: true))
35
+ case "clear":
36
+ for key in defaults.dictionaryRepresentation().keys where key.hasPrefix(Self.prefix) {
37
+ defaults.removeObject(forKey: key)
38
+ }
39
+ reply(message, Reply(ok: true))
40
+ default:
41
+ break
42
+ }
43
+ }
44
+
45
+ private func namespaced(_ data: Payload?) -> String? {
46
+ guard let key = data?.key, !key.isEmpty else { return nil }
47
+ return Self.prefix + key
48
+ }
49
+
50
+ private func reply(_ message: Message, _ payload: Reply) {
51
+ reply(with: message.replacing(data: payload))
52
+ }
53
+
54
+ private struct Payload: Decodable {
55
+ let key: String?
56
+ let value: String?
57
+ }
58
+
59
+ private struct Reply: Encodable {
60
+ let ok: Bool
61
+ var value: String? = nil
62
+ }
63
+ }
@@ -0,0 +1,64 @@
1
+ import UIKit
2
+
3
+ /// A full-screen error page with a retry button, presented when a visit fails
4
+ /// (e.g. the server is unreachable).
5
+ final class ErrorViewController: UIViewController {
6
+ private let error: Error
7
+ private let retryHandler: (() -> Void)?
8
+
9
+ init(error: Error, retryHandler: (() -> Void)?) {
10
+ self.error = error
11
+ self.retryHandler = retryHandler
12
+ super.init(nibName: nil, bundle: nil)
13
+ }
14
+
15
+ @available(*, unavailable)
16
+ required init?(coder: NSCoder) {
17
+ fatalError("init(coder:) has not been implemented")
18
+ }
19
+
20
+ override func viewDidLoad() {
21
+ super.viewDidLoad()
22
+ view.backgroundColor = .systemBackground
23
+
24
+ let imageView = UIImageView(image: UIImage(systemName: "wifi.exclamationmark"))
25
+ imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 48, weight: .medium)
26
+ imageView.tintColor = .secondaryLabel
27
+ imageView.contentMode = .scaleAspectFit
28
+
29
+ let messageLabel = UILabel()
30
+ messageLabel.text = error.localizedDescription
31
+ messageLabel.font = .preferredFont(forTextStyle: .body)
32
+ messageLabel.textColor = .secondaryLabel
33
+ messageLabel.textAlignment = .center
34
+ messageLabel.numberOfLines = 0
35
+
36
+ var buttonConfiguration = UIButton.Configuration.filled()
37
+ buttonConfiguration.title = "Retry"
38
+ buttonConfiguration.buttonSize = .large
39
+ let retryButton = UIButton(configuration: buttonConfiguration, primaryAction: UIAction { [weak self] _ in
40
+ self?.retry()
41
+ })
42
+ retryButton.isHidden = retryHandler == nil
43
+
44
+ let stackView = UIStackView(arrangedSubviews: [imageView, messageLabel, retryButton])
45
+ stackView.axis = .vertical
46
+ stackView.alignment = .center
47
+ stackView.spacing = 16
48
+ stackView.setCustomSpacing(24, after: messageLabel)
49
+ stackView.translatesAutoresizingMaskIntoConstraints = false
50
+
51
+ view.addSubview(stackView)
52
+ NSLayoutConstraint.activate([
53
+ stackView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
54
+ stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
55
+ stackView.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor),
56
+ stackView.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor)
57
+ ])
58
+ }
59
+
60
+ private func retry() {
61
+ retryHandler?()
62
+ dismiss(animated: true)
63
+ }
64
+ }