ruby_everywhere 0.1.15 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/bridge/README.md +105 -2
  3. data/bridge/everywhere/bridge.js +1131 -9
  4. data/bridge/everywhere/native.css +203 -0
  5. data/bridge/package.json +6 -3
  6. data/lib/everywhere/builders/ios.rb +396 -0
  7. data/lib/everywhere/cli.rb +2 -0
  8. data/lib/everywhere/commands/build.rb +17 -1
  9. data/lib/everywhere/commands/clean.rb +19 -10
  10. data/lib/everywhere/commands/dev.rb +182 -19
  11. data/lib/everywhere/commands/doctor.rb +45 -3
  12. data/lib/everywhere/commands/icon.rb +14 -0
  13. data/lib/everywhere/commands/install.rb +37 -6
  14. data/lib/everywhere/commands/logs.rb +56 -0
  15. data/lib/everywhere/commands/platform/build.rb +3 -3
  16. data/lib/everywhere/commands/platform/runner.rb +1 -1
  17. data/lib/everywhere/commands/release.rb +1 -1
  18. data/lib/everywhere/commands/shell_dir.rb +11 -4
  19. data/lib/everywhere/config.rb +469 -1
  20. data/lib/everywhere/engine.rb +42 -1
  21. data/lib/everywhere/icon.rb +50 -0
  22. data/lib/everywhere/log_filter.rb +28 -2
  23. data/lib/everywhere/mobile_config_endpoint.rb +88 -0
  24. data/lib/everywhere/mobile_configs_controller.rb +64 -0
  25. data/lib/everywhere/native_helper.rb +352 -0
  26. data/lib/everywhere/paths.rb +41 -0
  27. data/lib/everywhere/raster.rb +17 -0
  28. data/lib/everywhere/shellout.rb +3 -1
  29. data/lib/everywhere/simulator.rb +74 -0
  30. data/lib/everywhere/ui.rb +18 -0
  31. data/lib/everywhere/version.rb +1 -1
  32. data/support/mobile/ios/App/App.xcconfig +6 -0
  33. data/support/mobile/ios/App/AppDelegate.swift +164 -0
  34. data/support/mobile/ios/App/Assets.xcassets/AccentColor.colorset/Contents.json +20 -0
  35. data/support/mobile/ios/App/Assets.xcassets/AppIcon.appiconset/AppIcon.png +0 -0
  36. data/support/mobile/ios/App/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
  37. data/support/mobile/ios/App/Assets.xcassets/Contents.json +6 -0
  38. data/support/mobile/ios/App/Assets.xcassets/LaunchBackground.colorset/Contents.json +38 -0
  39. data/support/mobile/ios/App/Base.lproj/LaunchScreen.storyboard +32 -0
  40. data/support/mobile/ios/App/Bridge/BiometricsComponent.swift +276 -0
  41. data/support/mobile/ios/App/Bridge/HapticsComponent.swift +47 -0
  42. data/support/mobile/ios/App/Bridge/MenuComponent.swift +192 -0
  43. data/support/mobile/ios/App/Bridge/NotificationComponent.swift +56 -0
  44. data/support/mobile/ios/App/Bridge/PermissionsComponent.swift +142 -0
  45. data/support/mobile/ios/App/Bridge/StorageComponent.swift +63 -0
  46. data/support/mobile/ios/App/ErrorViewController.swift +64 -0
  47. data/support/mobile/ios/App/EverywhereConfig.swift +289 -0
  48. data/support/mobile/ios/App/EverywhereHost.swift +34 -0
  49. data/support/mobile/ios/App/Extensions/EverywhereExtensions.swift +32 -0
  50. data/support/mobile/ios/App/Info.plist +28 -0
  51. data/support/mobile/ios/App/Resources/everywhere.json +8 -0
  52. data/support/mobile/ios/App/Resources/path-configuration.json +19 -0
  53. data/support/mobile/ios/App/SceneDelegate.swift +484 -0
  54. data/support/mobile/ios/App.xcodeproj/project.pbxproj +458 -0
  55. data/support/mobile/ios/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  56. data/support/mobile/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +14 -0
  57. data/support/mobile/ios/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme +77 -0
  58. data/support/mobile/ios/NativeExtensions/Package.swift +26 -0
  59. data/support/mobile/ios/NativeExtensions/Sources/NativeExtensions/Exports.swift +5 -0
  60. data/support/mobile/ios/README.md +73 -0
  61. metadata +37 -1
@@ -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
+ }