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.
- checksums.yaml +4 -4
- data/bridge/README.md +105 -2
- data/bridge/everywhere/bridge.js +1131 -9
- data/bridge/everywhere/native.css +203 -0
- data/bridge/package.json +6 -3
- data/lib/everywhere/builders/ios.rb +396 -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 +469 -1
- data/lib/everywhere/engine.rb +42 -1
- data/lib/everywhere/icon.rb +50 -0
- data/lib/everywhere/log_filter.rb +28 -2
- data/lib/everywhere/mobile_config_endpoint.rb +88 -0
- data/lib/everywhere/mobile_configs_controller.rb +64 -0
- data/lib/everywhere/native_helper.rb +352 -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 +164 -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/MenuComponent.swift +192 -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 +289 -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 +484 -0
- data/support/mobile/ios/App.xcodeproj/project.pbxproj +458 -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 +73 -0
- 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)
|
data/lib/everywhere/version.rb
CHANGED
|
@@ -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,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
|
+
}
|