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
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import HotwireNative
|
|
3
|
+
import UIKit
|
|
4
|
+
import WebKit
|
|
5
|
+
|
|
6
|
+
/// Bridge component backing `everywhere--menu`: the native chrome the page
|
|
7
|
+
/// declares with `data-everywhere-nav-*` / `data-everywhere-menu` (the
|
|
8
|
+
/// everywhere_nav_button / everywhere_nav_menu / everywhere_menu Rails helpers).
|
|
9
|
+
///
|
|
10
|
+
/// Two events, both replied to by echoing back the tapped item's id so the web
|
|
11
|
+
/// half can `.click()` the element it mirrors — behavior stays defined once, in
|
|
12
|
+
/// the page:
|
|
13
|
+
/// * `navItems` — install left/right `UIBarButtonItem`s on THIS screen's
|
|
14
|
+
/// navigation bar (plain buttons, and pull-down `UIMenu`s for overflow
|
|
15
|
+
/// menus). Native re-replies to the same message on every tap.
|
|
16
|
+
/// * `menu` — present a `UIAlertController` action sheet, replying once with
|
|
17
|
+
/// the selection (or a cancel).
|
|
18
|
+
///
|
|
19
|
+
/// Installs onto `delegate?.destination` — the visitable view controller this
|
|
20
|
+
/// component instance belongs to — so buttons land on the right screen and go
|
|
21
|
+
/// away with it, exactly like the framework's own per-page bridge components.
|
|
22
|
+
final class MenuComponent: BridgeComponent {
|
|
23
|
+
override nonisolated class var name: String { "everywhere--menu" }
|
|
24
|
+
|
|
25
|
+
override func onReceive(message: Message) {
|
|
26
|
+
switch message.event {
|
|
27
|
+
case "navItems":
|
|
28
|
+
handleNavItems(message: message)
|
|
29
|
+
case "menu":
|
|
30
|
+
handleMenu(message: message)
|
|
31
|
+
default:
|
|
32
|
+
break
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// MARK: Private
|
|
37
|
+
|
|
38
|
+
private var viewController: UIViewController? {
|
|
39
|
+
delegate?.destination as? UIViewController
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// MARK: Nav bar items
|
|
43
|
+
|
|
44
|
+
private func handleNavItems(message: Message) {
|
|
45
|
+
guard let viewController, let data: NavItemsData = message.data() else { return }
|
|
46
|
+
|
|
47
|
+
var left: [UIBarButtonItem] = []
|
|
48
|
+
var right: [UIBarButtonItem] = []
|
|
49
|
+
for item in data.items {
|
|
50
|
+
let barButton = barButtonItem(for: item)
|
|
51
|
+
if item.side == "left" { left.append(barButton) } else { right.append(barButton) }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Right-side items read right-to-left in a nav bar; reverse so the page
|
|
55
|
+
// order ("Edit", "Share") lands left-to-right as authored.
|
|
56
|
+
viewController.navigationItem.leftBarButtonItems = left.isEmpty ? nil : left
|
|
57
|
+
viewController.navigationItem.rightBarButtonItems = right.isEmpty ? nil : right.reversed()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private func barButtonItem(for item: NavItem) -> UIBarButtonItem {
|
|
61
|
+
let image = item.image.flatMap { UIImage(systemName: $0) }
|
|
62
|
+
|
|
63
|
+
// A pull-down / overflow menu: children become a native UIMenu, each
|
|
64
|
+
// echoing its own id back on selection.
|
|
65
|
+
if let children = item.children, !children.isEmpty {
|
|
66
|
+
let actions = children.map { child in
|
|
67
|
+
UIAction(
|
|
68
|
+
title: child.title,
|
|
69
|
+
image: child.image.flatMap { UIImage(systemName: $0) },
|
|
70
|
+
attributes: child.style == "destructive" ? .destructive : []
|
|
71
|
+
) { [weak self] _ in
|
|
72
|
+
self?.replyTap(id: child.id)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
let menu = UIMenu(title: item.title.isEmpty ? "" : item.title, children: actions)
|
|
76
|
+
let barButton = UIBarButtonItem(
|
|
77
|
+
title: item.title.isEmpty ? nil : item.title,
|
|
78
|
+
image: image ?? UIImage(systemName: "ellipsis.circle"),
|
|
79
|
+
menu: menu
|
|
80
|
+
)
|
|
81
|
+
return barButton
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// A plain button: echo its id back so the web half clicks the element.
|
|
85
|
+
let action = UIAction(title: item.title, image: image) { [weak self] _ in
|
|
86
|
+
self?.replyTap(id: item.id)
|
|
87
|
+
}
|
|
88
|
+
let barButton: UIBarButtonItem
|
|
89
|
+
if let image {
|
|
90
|
+
barButton = UIBarButtonItem(image: image, primaryAction: action)
|
|
91
|
+
} else {
|
|
92
|
+
barButton = UIBarButtonItem(title: item.title, primaryAction: action)
|
|
93
|
+
}
|
|
94
|
+
barButton.style = item.style == "done" ? .done : .plain
|
|
95
|
+
barButton.isEnabled = !(item.disabled ?? false)
|
|
96
|
+
return barButton
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private func replyTap(id: String) {
|
|
100
|
+
reply(to: "navItems", with: NavReply(action: "tap", id: id))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// MARK: Action sheet
|
|
104
|
+
|
|
105
|
+
private func handleMenu(message: Message) {
|
|
106
|
+
guard let viewController, let data: MenuData = message.data() else { return }
|
|
107
|
+
|
|
108
|
+
let sheet = UIAlertController(title: data.title, message: data.message, preferredStyle: .actionSheet)
|
|
109
|
+
for item in data.items {
|
|
110
|
+
let style: UIAlertAction.Style = item.style == "destructive" ? .destructive : .default
|
|
111
|
+
let action = UIAlertAction(title: item.title, style: style) { [weak self] _ in
|
|
112
|
+
self?.reply(to: "menu", with: MenuReply(action: "select", id: item.id))
|
|
113
|
+
}
|
|
114
|
+
action.isEnabled = !(item.disabled ?? false)
|
|
115
|
+
sheet.addAction(action)
|
|
116
|
+
}
|
|
117
|
+
sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel) { [weak self] _ in
|
|
118
|
+
self?.reply(to: "menu", with: MenuReply(action: "cancel", id: nil))
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
// iPad / iOS 26 present action sheets as popovers, which need an anchor.
|
|
122
|
+
// The web sends the trigger's rect in page coordinates; offset by the
|
|
123
|
+
// web view's top content inset (the nav bar) to reach view coordinates.
|
|
124
|
+
if let popover = sheet.popoverPresentationController {
|
|
125
|
+
popover.sourceView = viewController.view
|
|
126
|
+
if let source = data.source {
|
|
127
|
+
var y = source.y
|
|
128
|
+
if let visitable = viewController as? Visitable,
|
|
129
|
+
let webView = visitable.visitableView.webView {
|
|
130
|
+
y += Double(webView.scrollView.adjustedContentInset.top)
|
|
131
|
+
}
|
|
132
|
+
popover.sourceRect = CGRect(x: source.x, y: y, width: source.width, height: source.height)
|
|
133
|
+
} else {
|
|
134
|
+
popover.sourceRect = CGRect(x: viewController.view.bounds.midX,
|
|
135
|
+
y: viewController.view.bounds.midY, width: 0, height: 0)
|
|
136
|
+
popover.permittedArrowDirections = []
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
viewController.present(sheet, animated: true)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// MARK: - Message data
|
|
145
|
+
|
|
146
|
+
private extension MenuComponent {
|
|
147
|
+
struct NavItemsData: Decodable {
|
|
148
|
+
let items: [NavItem]
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
struct NavItem: Decodable {
|
|
152
|
+
let id: String
|
|
153
|
+
let title: String
|
|
154
|
+
let image: String?
|
|
155
|
+
let side: String?
|
|
156
|
+
let style: String?
|
|
157
|
+
let disabled: Bool?
|
|
158
|
+
let children: [MenuItem]?
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
struct MenuData: Decodable {
|
|
162
|
+
let title: String?
|
|
163
|
+
let message: String?
|
|
164
|
+
let items: [MenuItem]
|
|
165
|
+
let source: Source?
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
struct MenuItem: Decodable {
|
|
169
|
+
let id: String
|
|
170
|
+
let title: String
|
|
171
|
+
let image: String?
|
|
172
|
+
let style: String?
|
|
173
|
+
let disabled: Bool?
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
struct Source: Decodable {
|
|
177
|
+
let x: Double
|
|
178
|
+
let y: Double
|
|
179
|
+
let width: Double
|
|
180
|
+
let height: Double
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
struct NavReply: Encodable {
|
|
184
|
+
let action: String
|
|
185
|
+
let id: String
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
struct MenuReply: Encodable {
|
|
189
|
+
let action: String
|
|
190
|
+
let id: String?
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -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
|
+
}
|