craft-native 0.0.123 → 0.0.125

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.
@@ -0,0 +1,562 @@
1
+ import AuthenticationServices
2
+ import SafariServices
3
+ import UIKit
4
+ import WebKit
5
+
6
+ /// Where the shell's own UI is shown from: alerts, sheets, menus, the in-app
7
+ /// browser.
8
+ ///
9
+ /// Everything used to be presented from `windows.first?.rootViewController`.
10
+ /// UIKit refuses to present from a controller that is already presenting, so
11
+ /// a share sheet asked for while an alert, a picker or another sheet was up
12
+ /// did nothing at all, and the page's promise waited for an answer that could
13
+ /// not come. The controller on top is the one that can present.
14
+ enum CraftPresenter {
15
+ /// The window people are looking at: the foreground scene's key window.
16
+ static func keyWindow() -> UIWindow? {
17
+ let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
18
+ let scene = scenes.first { $0.activationState == .foregroundActive } ?? scenes.first
19
+ return scene?.windows.first { $0.isKeyWindow } ?? scene?.windows.first
20
+ }
21
+
22
+ /// The controller on top of everything already presented.
23
+ static func topViewController() -> UIViewController? {
24
+ var top = keyWindow()?.rootViewController
25
+ while let presented = top?.presentedViewController, !presented.isBeingDismissed {
26
+ top = presented
27
+ }
28
+ return top
29
+ }
30
+
31
+ /// Present over whatever is on screen. A controller that shows as a
32
+ /// popover on iPad (an action sheet, a share sheet) points at `anchor` in
33
+ /// `view`, or sits in the middle of it with no arrow; iPadOS raises an
34
+ /// exception for a popover with neither. False when nothing can present,
35
+ /// so the caller answers rather than waiting.
36
+ @discardableResult
37
+ static func present(_ controller: UIViewController, from view: UIView?, anchor: CGRect? = nil) -> Bool {
38
+ guard let top = topViewController() else { return false }
39
+ if let popover = controller.popoverPresentationController {
40
+ let source = view ?? top.view!
41
+ popover.sourceView = source
42
+ if let anchor {
43
+ popover.sourceRect = anchor
44
+ } else {
45
+ popover.sourceRect = CGRect(x: source.bounds.midX, y: source.bounds.midY, width: 0, height: 0)
46
+ popover.permittedArrowDirections = []
47
+ }
48
+ }
49
+ top.present(controller, animated: true)
50
+ return true
51
+ }
52
+
53
+ /// `{ x, y, width, height }` in the page's CSS pixels, which are the web
54
+ /// view's points, as a rect. Nil when it is not one.
55
+ static func rect(_ value: Any?) -> CGRect? {
56
+ guard let value = value as? [String: Any] else { return nil }
57
+ func number(_ key: String) -> CGFloat? { (value[key] as? NSNumber).map { CGFloat(truncating: $0) } }
58
+ guard let x = number("x"), let y = number("y") else { return nil }
59
+ return CGRect(x: x, y: y, width: max(0, number("width") ?? 0), height: max(0, number("height") ?? 0))
60
+ }
61
+
62
+ /// UIKit's own "OK" and "Cancel", in the phone's language. The page's
63
+ /// alerts and confirms read like the system's because they are.
64
+ static func systemString(_ key: String) -> String {
65
+ Bundle(for: UIApplication.self).localizedString(forKey: key, value: key, table: nil)
66
+ }
67
+ }
68
+
69
+ // MARK: - CSS colours
70
+
71
+ extension UIColor {
72
+ /// A colour as a page writes it in CSS: `#rgb`, `#rgba`, `#rrggbb`,
73
+ /// `#rrggbbaa`, `rgb()` and `rgba()` (numbers or percentages, commas or
74
+ /// spaces), `transparent`, and the handful of names a page passes to
75
+ /// native chrome. Nil for anything else, so a caller can say it did not
76
+ /// apply rather than paint the wrong colour.
77
+ convenience init?(css value: String) {
78
+ let text = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
79
+ if text.hasPrefix("#") {
80
+ let hex = String(text.dropFirst())
81
+ let digits: String
82
+ switch hex.count {
83
+ case 3, 4: digits = hex.map { "\($0)\($0)" }.joined()
84
+ case 6, 8: digits = hex
85
+ default: return nil
86
+ }
87
+ guard let packed = UInt64(digits, radix: 16) else { return nil }
88
+ let alpha = digits.count == 8
89
+ let shift: UInt64 = alpha ? 8 : 0
90
+ self.init(
91
+ red: CGFloat((packed >> (16 + shift)) & 0xFF) / 255,
92
+ green: CGFloat((packed >> (8 + shift)) & 0xFF) / 255,
93
+ blue: CGFloat((packed >> shift) & 0xFF) / 255,
94
+ alpha: alpha ? CGFloat(packed & 0xFF) / 255 : 1
95
+ )
96
+ return
97
+ }
98
+ if text.hasPrefix("rgb"), let open = text.firstIndex(of: "("), let close = text.lastIndex(of: ")"), open < close {
99
+ let parts = text[text.index(after: open)..<close]
100
+ .split(whereSeparator: { $0 == "," || $0 == " " || $0 == "/" })
101
+ .map(String.init)
102
+ guard parts.count == 3 || parts.count == 4 else { return nil }
103
+ func channel(_ part: String, scale: CGFloat) -> CGFloat? {
104
+ if part.hasSuffix("%") { return Double(part.dropLast()).map { CGFloat($0) / 100 } }
105
+ return Double(part).map { CGFloat($0) / scale }
106
+ }
107
+ guard let r = channel(parts[0], scale: 255), let g = channel(parts[1], scale: 255), let b = channel(parts[2], scale: 255) else { return nil }
108
+ let a = parts.count == 4 ? channel(parts[3], scale: 1) : 1
109
+ guard let a else { return nil }
110
+ self.init(red: min(max(r, 0), 1), green: min(max(g, 0), 1), blue: min(max(b, 0), 1), alpha: min(max(a, 0), 1))
111
+ return
112
+ }
113
+ let named: [String: (CGFloat, CGFloat, CGFloat, CGFloat)] = [
114
+ "transparent": (0, 0, 0, 0),
115
+ "black": (0, 0, 0, 1),
116
+ "white": (1, 1, 1, 1),
117
+ "red": (1, 0, 0, 1),
118
+ "green": (0, 0.5, 0, 1),
119
+ "blue": (0, 0, 1, 1),
120
+ "gray": (0.5, 0.5, 0.5, 1),
121
+ "grey": (0.5, 0.5, 0.5, 1),
122
+ ]
123
+ guard let (r, g, b, a) = named[text] else { return nil }
124
+ self.init(red: r, green: g, blue: b, alpha: a)
125
+ }
126
+ }
127
+
128
+ // MARK: - Dialogs
129
+
130
+ /// `craft.dialog`: the system's alert, confirmation and action sheet, with
131
+ /// the page's own words on them.
132
+ enum CraftDialogs {
133
+ private static func text(_ body: [String: Any], _ key: String) -> String? {
134
+ (body[key] as? String).flatMap { $0.isEmpty ? nil : $0 }
135
+ }
136
+
137
+ static func alert(_ body: [String: Any], from view: UIView?, completion: @escaping () -> Void) {
138
+ let alert = UIAlertController(title: text(body, "title"), message: text(body, "message"), preferredStyle: .alert)
139
+ alert.addAction(UIAlertAction(title: text(body, "okLabel") ?? CraftPresenter.systemString("OK"), style: .default) { _ in completion() })
140
+ if !CraftPresenter.present(alert, from: view) { completion() }
141
+ }
142
+
143
+ /// True when the person confirmed. `destructive` paints the confirming
144
+ /// button red, as iOS does for a delete.
145
+ static func confirm(_ body: [String: Any], from view: UIView?, completion: @escaping (Bool) -> Void) {
146
+ let alert = UIAlertController(title: text(body, "title"), message: text(body, "message"), preferredStyle: .alert)
147
+ alert.addAction(UIAlertAction(title: text(body, "cancelLabel") ?? CraftPresenter.systemString("Cancel"), style: .cancel) { _ in completion(false) })
148
+ let confirm = UIAlertAction(
149
+ title: text(body, "confirmLabel") ?? CraftPresenter.systemString("OK"),
150
+ style: body["destructive"] as? Bool == true ? .destructive : .default
151
+ ) { _ in completion(true) }
152
+ alert.addAction(confirm)
153
+ alert.preferredAction = body["destructive"] as? Bool == true ? nil : confirm
154
+ if !CraftPresenter.present(alert, from: view) { completion(false) }
155
+ }
156
+
157
+ /// The chosen action's id, or nil when the sheet was cancelled. A sheet
158
+ /// always has a way out: with no `cancel` action of the page's own, the
159
+ /// system's Cancel is added, which is also what a tap outside the iPad
160
+ /// popover answers with.
161
+ static func actionSheet(_ body: [String: Any], from view: UIView?, completion: @escaping (String?) -> Void) {
162
+ let actions = (body["actions"] as? [[String: Any]]) ?? []
163
+ let sheet = UIAlertController(title: text(body, "title"), message: text(body, "message"), preferredStyle: .actionSheet)
164
+ var answered = false
165
+ let answer: (String?) -> Void = { id in
166
+ guard !answered else { return }
167
+ answered = true
168
+ completion(id)
169
+ }
170
+ var hasCancel = false
171
+ for action in actions {
172
+ guard let title = action["title"] as? String else { continue }
173
+ let id = action["id"] as? String ?? title
174
+ let style: UIAlertAction.Style
175
+ switch action["style"] as? String {
176
+ case "destructive": style = .destructive
177
+ case "cancel" where !hasCancel:
178
+ style = .cancel
179
+ hasCancel = true
180
+ default: style = .default
181
+ }
182
+ // A cancel action answers nil whatever its id, so a page can
183
+ // tell "dismissed" from a choice the same way either way.
184
+ sheet.addAction(UIAlertAction(title: title, style: style) { _ in answer(style == .cancel ? nil : id) })
185
+ }
186
+ if !hasCancel {
187
+ sheet.addAction(UIAlertAction(title: CraftPresenter.systemString("Cancel"), style: .cancel) { _ in answer(nil) })
188
+ }
189
+ if !CraftPresenter.present(sheet, from: view, anchor: CraftPresenter.rect(body["anchor"])) { answer(nil) }
190
+ }
191
+ }
192
+
193
+ // MARK: - Context menu
194
+
195
+ /// `craft.contextMenu.show`: a native menu pointing at a rect of the page.
196
+ ///
197
+ /// A context menu normally opens only from a touch the system saw begin, and
198
+ /// the touch here began in the web page. iOS 17.4 gave UIButton
199
+ /// `performPrimaryAction()`, which opens a button's menu from code: so a
200
+ /// transparent button is laid over the rect, given the menu, and asked to
201
+ /// open it. That is the system's own menu, the list with symbols that a long
202
+ /// press shows anywhere else, anchored where the page asked. Between iOS 16
203
+ /// and 17.4 the public way to show a menu on request is UIEditMenuInteraction,
204
+ /// the horizontal edit bar; before 16 the items come up as an action sheet.
205
+ final class CraftContextMenu: NSObject {
206
+ /// The menu on screen, kept alive until it answers.
207
+ private static var current: CraftContextMenu?
208
+
209
+ private let overlay = UIView()
210
+ private let menu: UIMenu
211
+ private var completion: ((String?) -> Void)?
212
+
213
+ private init(menu: UIMenu, completion: @escaping (String?) -> Void) {
214
+ self.menu = menu
215
+ self.completion = completion
216
+ super.init()
217
+ }
218
+
219
+ static func show(_ body: [String: Any], in webView: WKWebView, completion: @escaping (String?) -> Void) {
220
+ let items = (body["items"] as? [[String: Any]]) ?? []
221
+ guard !items.isEmpty else { completion(nil); return }
222
+ let anchor = CraftPresenter.rect(body["anchor"])
223
+ ?? CGRect(x: webView.bounds.midX, y: webView.bounds.midY, width: 0, height: 0)
224
+
225
+ guard #available(iOS 16.0, *) else {
226
+ let actions: [[String: Any]] = items.map { item in
227
+ ["id": item["id"] ?? NSNull(), "title": item["title"] ?? "", "style": item["destructive"] as? Bool == true ? "destructive" : "default"]
228
+ }
229
+ CraftDialogs.actionSheet(["title": body["title"] ?? NSNull(), "actions": actions, "anchor": body["anchor"] ?? NSNull()], from: webView, completion: completion)
230
+ return
231
+ }
232
+
233
+ current?.finish(nil)
234
+ var presenter: CraftContextMenu?
235
+ let elements: [UIMenuElement] = items.compactMap { item in
236
+ guard let title = item["title"] as? String else { return nil }
237
+ let id = item["id"] as? String ?? title
238
+ var attributes: UIMenuElement.Attributes = []
239
+ if item["destructive"] as? Bool == true { attributes.insert(.destructive) }
240
+ if item["disabled"] as? Bool == true { attributes.insert(.disabled) }
241
+ let image = (item["symbol"] as? String).flatMap { UIImage(systemName: $0) }
242
+ return UIAction(title: title, image: image, attributes: attributes) { _ in presenter?.finish(id) }
243
+ }
244
+ let menu = UIMenu(title: body["title"] as? String ?? "", children: elements)
245
+ let shown = CraftContextMenu(menu: menu, completion: completion)
246
+ presenter = shown
247
+ current = shown
248
+ shown.present(in: webView, at: anchor)
249
+ }
250
+
251
+ @available(iOS 16.0, *)
252
+ private func present(in webView: WKWebView, at anchor: CGRect) {
253
+ overlay.frame = anchor.width > 0 && anchor.height > 0 ? anchor : CGRect(x: anchor.minX - 1, y: anchor.minY - 1, width: 2, height: 2)
254
+ overlay.backgroundColor = .clear
255
+ webView.addSubview(overlay)
256
+ if #available(iOS 17.4, *) {
257
+ let button = MenuButton(type: .custom)
258
+ button.frame = overlay.bounds
259
+ button.menu = menu
260
+ button.showsMenuAsPrimaryAction = true
261
+ button.onDismiss = { [weak self] in self?.finishSoon() }
262
+ overlay.addSubview(button)
263
+ button.performPrimaryAction()
264
+ return
265
+ }
266
+ let interaction = UIEditMenuInteraction(delegate: self)
267
+ overlay.addInteraction(interaction)
268
+ let configuration = UIEditMenuConfiguration(identifier: nil, sourcePoint: CGPoint(x: overlay.bounds.midX, y: overlay.bounds.minY))
269
+ configuration.preferredArrowDirection = .automatic
270
+ interaction.presentEditMenu(with: configuration)
271
+ }
272
+
273
+ /// The "nothing chosen" answer, a moment after the menu closes. A choice
274
+ /// calls its action around the dismissal, not necessarily before it, so
275
+ /// this loses to a choice that lands in the meantime.
276
+ fileprivate func finishSoon() {
277
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in self?.finish(nil) }
278
+ }
279
+
280
+ /// The button the iOS 17.4 menu hangs from; it says when the menu closes.
281
+ private final class MenuButton: UIButton {
282
+ var onDismiss: (() -> Void)?
283
+
284
+ override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willEndFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) {
285
+ super.contextMenuInteraction(interaction, willEndFor: configuration, animator: animator)
286
+ if let animator {
287
+ animator.addCompletion { [weak self] in self?.onDismiss?() }
288
+ } else {
289
+ onDismiss?()
290
+ }
291
+ }
292
+ }
293
+
294
+ fileprivate func finish(_ id: String?) {
295
+ guard let completion else { return }
296
+ self.completion = nil
297
+ overlay.removeFromSuperview()
298
+ if CraftContextMenu.current === self { CraftContextMenu.current = nil }
299
+ completion(id)
300
+ }
301
+ }
302
+
303
+ @available(iOS 16.0, *)
304
+ extension CraftContextMenu: UIEditMenuInteractionDelegate {
305
+ func editMenuInteraction(_ interaction: UIEditMenuInteraction, menuFor configuration: UIEditMenuConfiguration, suggestedActions: [UIMenuElement]) -> UIMenu? {
306
+ menu
307
+ }
308
+
309
+ func editMenuInteraction(_ interaction: UIEditMenuInteraction, targetRectFor configuration: UIEditMenuConfiguration) -> CGRect {
310
+ overlay.bounds
311
+ }
312
+
313
+ func editMenuInteraction(_ interaction: UIEditMenuInteraction, willDismissMenuFor configuration: UIEditMenuConfiguration, animator: UIEditMenuInteractionAnimating) {
314
+ animator.addCompletion { [weak self] in self?.finishSoon() }
315
+ }
316
+ }
317
+
318
+ // MARK: - In-app browser and web sign-in
319
+
320
+ /// `craft.browser.open`: a page somewhere else, without leaving the app.
321
+ ///
322
+ /// `safari` is SFSafariViewController, which shares Safari's cookies and
323
+ /// autofill and comes back to the app with Done. `auth` is
324
+ /// ASWebAuthenticationSession, the system's sign-in sheet, which answers with
325
+ /// the URL the provider redirected to on `callbackScheme`. Its session is not
326
+ /// ephemeral, so someone already signed in to the provider in Safari is not
327
+ /// asked again.
328
+ final class CraftBrowser: NSObject {
329
+ static let shared = CraftBrowser()
330
+
331
+ private var safariCompletion: (([String: Any]) -> Void)?
332
+ private var authSession: ASWebAuthenticationSession?
333
+
334
+ func open(_ url: URL, mode: String, callbackScheme: String?, from view: UIView?, completion: @escaping ([String: Any]) -> Void) {
335
+ if mode == "auth" {
336
+ authenticate(url, callbackScheme: callbackScheme, completion: completion)
337
+ return
338
+ }
339
+ guard url.scheme == "https" || url.scheme == "http" else {
340
+ completion(["cancelled": true, "error": "Only http and https pages open in the in-app browser"])
341
+ return
342
+ }
343
+ // One at a time: a second open answers the first.
344
+ safariCompletion?(["cancelled": false])
345
+ let safari = SFSafariViewController(url: url)
346
+ safari.dismissButtonStyle = .close
347
+ safari.delegate = self
348
+ safariCompletion = completion
349
+ if !CraftPresenter.present(safari, from: view) {
350
+ safariCompletion = nil
351
+ completion(["cancelled": true, "error": "Nothing could present the browser"])
352
+ }
353
+ }
354
+
355
+ private func authenticate(_ url: URL, callbackScheme: String?, completion: @escaping ([String: Any]) -> Void) {
356
+ authSession?.cancel()
357
+ let session = ASWebAuthenticationSession(url: url, callbackURLScheme: callbackScheme) { [weak self] callback, error in
358
+ DispatchQueue.main.async {
359
+ self?.authSession = nil
360
+ if let callback {
361
+ completion(["url": callback.absoluteString, "cancelled": false])
362
+ } else if let error = error as? ASWebAuthenticationSessionError, error.code == .canceledLogin {
363
+ completion(["cancelled": true])
364
+ } else {
365
+ completion(["cancelled": true, "error": error?.localizedDescription ?? "Sign-in did not finish"])
366
+ }
367
+ }
368
+ }
369
+ session.presentationContextProvider = self
370
+ session.prefersEphemeralWebBrowserSession = false
371
+ authSession = session
372
+ if !session.start() {
373
+ authSession = nil
374
+ completion(["cancelled": true, "error": "The sign-in sheet could not start"])
375
+ }
376
+ }
377
+ }
378
+
379
+ extension CraftBrowser: SFSafariViewControllerDelegate {
380
+ func safariViewControllerDidFinish(_ controller: SFSafariViewController) {
381
+ let completion = safariCompletion
382
+ safariCompletion = nil
383
+ completion?(["cancelled": false])
384
+ }
385
+ }
386
+
387
+ extension CraftBrowser: ASWebAuthenticationPresentationContextProviding {
388
+ func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
389
+ CraftPresenter.keyWindow() ?? ASPresentationAnchor()
390
+ }
391
+ }
392
+
393
+ // MARK: - SF Symbols
394
+
395
+ /// `craft.symbols.image`: an SF Symbol drawn to a PNG data URL, so a page can
396
+ /// show the same glyph the system's own controls use.
397
+ enum CraftSymbols {
398
+ static func image(named name: String, options: [String: Any]) -> String? {
399
+ let pointSize = (options["pointSize"] as? NSNumber).map { CGFloat(truncating: $0) } ?? 17
400
+ let weight: UIImage.SymbolWeight
401
+ switch options["weight"] as? String {
402
+ case "ultraLight": weight = .ultraLight
403
+ case "thin": weight = .thin
404
+ case "light": weight = .light
405
+ case "medium": weight = .medium
406
+ case "semibold": weight = .semibold
407
+ case "bold": weight = .bold
408
+ case "heavy": weight = .heavy
409
+ case "black": weight = .black
410
+ default: weight = .regular
411
+ }
412
+ let scale: UIImage.SymbolScale
413
+ switch options["scale"] as? String {
414
+ case "small": scale = .small
415
+ case "large": scale = .large
416
+ default: scale = .medium
417
+ }
418
+ let configuration = UIImage.SymbolConfiguration(pointSize: max(1, pointSize), weight: weight, scale: scale)
419
+ guard !name.isEmpty, let symbol = UIImage(systemName: name, withConfiguration: configuration) else { return nil }
420
+ // Resolved against the screen's own appearance: drawing a dynamic
421
+ // colour off screen would otherwise pick the light variant.
422
+ let traits = CraftPresenter.keyWindow()?.traitCollection ?? UITraitCollection.current
423
+ let color = ((options["color"] as? String).flatMap { UIColor(css: $0) } ?? .label).resolvedColor(with: traits)
424
+ let tinted = symbol.withTintColor(color, renderingMode: .alwaysOriginal)
425
+ let format = UIGraphicsImageRendererFormat()
426
+ format.scale = CraftPresenter.keyWindow()?.screen.scale ?? UIScreen.main.scale
427
+ format.opaque = false
428
+ let image = UIGraphicsImageRenderer(size: tinted.size, format: format).image { _ in tinted.draw(at: .zero) }
429
+ guard let data = image.pngData() else { return nil }
430
+ return "data:image/png;base64," + data.base64EncodedString()
431
+ }
432
+ }
433
+
434
+ // MARK: - Status bar
435
+
436
+ /// `craft.statusBar.setStyle`: light or dark status bar text, or the
437
+ /// system's choice for the app's appearance.
438
+ ///
439
+ /// The bar is view-controller based (UIViewControllerBasedStatusBarAppearance)
440
+ /// so the system's own sheets keep theirs. The root is SwiftUI's hosting
441
+ /// controller, which offers no way to say which style it wants, so the first
442
+ /// call teaches that controller's class to answer from here. Only that exact
443
+ /// class is touched, and only once.
444
+ enum CraftStatusBar {
445
+ static var style: UIStatusBarStyle = .default
446
+ private static var taught = Set<ObjectIdentifier>()
447
+
448
+ @discardableResult
449
+ static func setStyle(_ name: String) -> Bool {
450
+ switch name {
451
+ case "light": style = .lightContent
452
+ case "dark": style = .darkContent
453
+ default: style = .default
454
+ }
455
+ guard let root = CraftPresenter.keyWindow()?.rootViewController else { return false }
456
+ teach(root)
457
+ UIView.animate(withDuration: 0.2) { root.setNeedsStatusBarAppearanceUpdate() }
458
+ return true
459
+ }
460
+
461
+ private static func teach(_ controller: UIViewController) {
462
+ guard let cls = object_getClass(controller) else { return }
463
+ let key = ObjectIdentifier(cls)
464
+ guard !taught.contains(key) else { return }
465
+ taught.insert(key)
466
+ let preferred: @convention(block) (AnyObject) -> Int = { _ in CraftStatusBar.style.rawValue }
467
+ class_replaceMethod(cls, #selector(getter: UIViewController.preferredStatusBarStyle), imp_implementationWithBlock(preferred), "q@:")
468
+ // Asked first, and a child's answer would win over the one above.
469
+ let child: @convention(block) (AnyObject) -> UIViewController? = { _ in nil }
470
+ class_replaceMethod(cls, #selector(getter: UIViewController.childForStatusBarStyle), imp_implementationWithBlock(child), "@@:")
471
+ }
472
+ }
473
+
474
+ // MARK: - Keyboard accessory bar
475
+
476
+ /// The bar WebKit puts above the keyboard for a form field (the up and down
477
+ /// arrows and Done), which no native app's keyboard has.
478
+ ///
479
+ /// There is no public switch for it. The bar is the `inputAccessoryView` of
480
+ /// WKContentView, WebKit's private first responder inside the web view, so
481
+ /// this is the trick Cordova and Capacitor use: that one view's class is
482
+ /// swapped for a subclass, made at runtime, whose `inputAccessoryView` is nil.
483
+ /// Nothing is swizzled globally, and showing the bar again restores the view's
484
+ /// own class. If WebKit renames the view, this finds nothing and does nothing.
485
+ enum CraftKeyboardAccessory {
486
+ private static let subclassName = "CraftAccessoryFreeContentView"
487
+ private static var originalClass: AnyClass?
488
+
489
+ @discardableResult
490
+ static func setVisible(_ visible: Bool, in webView: WKWebView) -> Bool {
491
+ guard let content = webView.scrollView.subviews.first(where: { String(describing: type(of: $0)).hasPrefix("WKContentView") }),
492
+ let current = object_getClass(content) else { return false }
493
+ let isHidden = NSStringFromClass(current) == subclassName
494
+ if visible {
495
+ if isHidden, let originalClass { object_setClass(content, originalClass) }
496
+ } else if !isHidden {
497
+ guard let subclass = accessoryFreeSubclass(of: current) else { return false }
498
+ object_setClass(content, subclass)
499
+ }
500
+ if content.isFirstResponder { content.reloadInputViews() }
501
+ return true
502
+ }
503
+
504
+ private static func accessoryFreeSubclass(of base: AnyClass) -> AnyClass? {
505
+ if let existing = NSClassFromString(subclassName) { return existing }
506
+ guard let subclass = objc_allocateClassPair(base, subclassName, 0) else { return nil }
507
+ let none: @convention(block) (AnyObject) -> UIView? = { _ in nil }
508
+ class_addMethod(subclass, #selector(getter: UIResponder.inputAccessoryView), imp_implementationWithBlock(none), "@@:")
509
+ objc_registerClassPair(subclass)
510
+ originalClass = base
511
+ return subclass
512
+ }
513
+ }
514
+
515
+ // MARK: - Pull to refresh
516
+
517
+ /// `craft.refresh`: the system's pull-to-refresh on the web view's own scroll
518
+ /// view, so the spinner, the rubber band and the haptic are UIKit's. A pull
519
+ /// fires `craftRefresh` at the page, which calls `craft.refresh.end()` when it
520
+ /// has its new content. A page that never does is not left spinning forever.
521
+ final class CraftRefreshControl: NSObject {
522
+ private weak var scrollView: UIScrollView?
523
+ private var control: UIRefreshControl?
524
+ private var safety: DispatchWorkItem?
525
+ private let onRefresh: () -> Void
526
+
527
+ init(onRefresh: @escaping () -> Void) {
528
+ self.onRefresh = onRefresh
529
+ }
530
+
531
+ func enable(on scrollView: UIScrollView, tint: UIColor?) {
532
+ if control == nil || self.scrollView !== scrollView {
533
+ disable()
534
+ let control = UIRefreshControl()
535
+ control.addTarget(self, action: #selector(pulled), for: .valueChanged)
536
+ scrollView.refreshControl = control
537
+ self.control = control
538
+ self.scrollView = scrollView
539
+ }
540
+ control?.tintColor = tint
541
+ }
542
+
543
+ func disable() {
544
+ end()
545
+ if let scrollView, scrollView.refreshControl === control { scrollView.refreshControl = nil }
546
+ control = nil
547
+ }
548
+
549
+ func end() {
550
+ safety?.cancel()
551
+ safety = nil
552
+ control?.endRefreshing()
553
+ }
554
+
555
+ @objc private func pulled() {
556
+ onRefresh()
557
+ safety?.cancel()
558
+ let safety = DispatchWorkItem { [weak self] in self?.control?.endRefreshing() }
559
+ self.safety = safety
560
+ DispatchQueue.main.asyncAfter(deadline: .now() + 30, execute: safety)
561
+ }
562
+ }
@@ -41,11 +41,12 @@
41
41
  <key>UIStatusBarStyle</key>
42
42
  <string>{{STATUS_BAR_STYLE}}</string>
43
43
  <key>UIViewControllerBasedStatusBarAppearance</key>
44
- <false/>
44
+ <true/>
45
45
  {{USAGE_DESCRIPTIONS}}
46
46
  {{URL_TYPES}}
47
47
  {{APP_BOUND_DOMAINS}}
48
48
  {{BACKGROUND_MODES}}
49
+ {{BACKGROUND_TASK_IDENTIFIERS}}
49
50
  {{LIVE_ACTIVITY_SUPPORT}}
50
51
  <key>NSAppTransportSecurity</key>
51
52
  <dict>