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.
- checksums.yaml +4 -4
- data/bridge/README.md +57 -2
- data/bridge/everywhere/bridge.js +803 -9
- data/bridge/package.json +1 -1
- data/lib/everywhere/builders/ios.rb +364 -0
- data/lib/everywhere/cli.rb +2 -0
- data/lib/everywhere/commands/build.rb +17 -1
- data/lib/everywhere/commands/clean.rb +19 -10
- data/lib/everywhere/commands/dev.rb +182 -19
- data/lib/everywhere/commands/doctor.rb +45 -3
- data/lib/everywhere/commands/icon.rb +14 -0
- data/lib/everywhere/commands/install.rb +37 -6
- data/lib/everywhere/commands/logs.rb +56 -0
- data/lib/everywhere/commands/platform/build.rb +3 -3
- data/lib/everywhere/commands/platform/runner.rb +1 -1
- data/lib/everywhere/commands/release.rb +1 -1
- data/lib/everywhere/commands/shell_dir.rb +11 -4
- data/lib/everywhere/config.rb +366 -1
- data/lib/everywhere/engine.rb +33 -1
- data/lib/everywhere/icon.rb +50 -0
- data/lib/everywhere/log_filter.rb +28 -2
- data/lib/everywhere/mobile_config_endpoint.rb +75 -0
- data/lib/everywhere/mobile_configs_controller.rb +46 -0
- data/lib/everywhere/native_helper.rb +156 -0
- data/lib/everywhere/paths.rb +41 -0
- data/lib/everywhere/raster.rb +17 -0
- data/lib/everywhere/shellout.rb +3 -1
- data/lib/everywhere/simulator.rb +74 -0
- data/lib/everywhere/ui.rb +18 -0
- data/lib/everywhere/version.rb +1 -1
- data/support/mobile/ios/App/App.xcconfig +6 -0
- data/support/mobile/ios/App/AppDelegate.swift +163 -0
- data/support/mobile/ios/App/Assets.xcassets/AccentColor.colorset/Contents.json +20 -0
- data/support/mobile/ios/App/Assets.xcassets/AppIcon.appiconset/AppIcon.png +0 -0
- data/support/mobile/ios/App/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
- data/support/mobile/ios/App/Assets.xcassets/Contents.json +6 -0
- data/support/mobile/ios/App/Assets.xcassets/LaunchBackground.colorset/Contents.json +38 -0
- data/support/mobile/ios/App/Base.lproj/LaunchScreen.storyboard +32 -0
- data/support/mobile/ios/App/Bridge/BiometricsComponent.swift +276 -0
- data/support/mobile/ios/App/Bridge/HapticsComponent.swift +47 -0
- data/support/mobile/ios/App/Bridge/NotificationComponent.swift +56 -0
- data/support/mobile/ios/App/Bridge/PermissionsComponent.swift +142 -0
- data/support/mobile/ios/App/Bridge/StorageComponent.swift +63 -0
- data/support/mobile/ios/App/ErrorViewController.swift +64 -0
- data/support/mobile/ios/App/EverywhereConfig.swift +268 -0
- data/support/mobile/ios/App/EverywhereHost.swift +34 -0
- data/support/mobile/ios/App/Extensions/EverywhereExtensions.swift +32 -0
- data/support/mobile/ios/App/Info.plist +28 -0
- data/support/mobile/ios/App/Resources/everywhere.json +8 -0
- data/support/mobile/ios/App/Resources/path-configuration.json +19 -0
- data/support/mobile/ios/App/SceneDelegate.swift +443 -0
- data/support/mobile/ios/App.xcodeproj/project.pbxproj +454 -0
- data/support/mobile/ios/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- data/support/mobile/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +14 -0
- data/support/mobile/ios/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme +77 -0
- data/support/mobile/ios/NativeExtensions/Package.swift +26 -0
- data/support/mobile/ios/NativeExtensions/Sources/NativeExtensions/Exports.swift +5 -0
- data/support/mobile/ios/README.md +66 -0
- 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
|
+
}
|