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.
- package/README.md +3 -0
- package/dist/android/src/index.d.ts +3 -0
- package/dist/android/src/index.js +22 -3
- package/dist/android/templates/AndroidManifest.xml.template +1 -1
- package/dist/android/templates/CraftNativeFlatList.kt.template +151 -7
- package/dist/android/templates/CraftNativeRefreshableFlatList.kt.template +29 -0
- package/dist/android/templates/MainActivity.kt.template +17 -0
- package/dist/android/templates/MainActivityNative.kt.template +1085 -84
- package/dist/android/templates/build.gradle.kts.app.template +1 -0
- package/dist/api/mobile.d.ts +250 -2
- package/dist/cli.js +3 -3
- package/dist/index.cjs +22 -1
- package/dist/index.js +22 -1
- package/dist/ios/src/index.d.ts +125 -3
- package/dist/ios/src/index.js +284 -47
- package/dist/ios/templates/CraftApp.swift +1186 -69
- package/dist/ios/templates/CraftHybrid.swift +1196 -0
- package/dist/ios/templates/CraftNativeActions.swift +310 -10
- package/dist/ios/templates/CraftNativeFlatList.swift +226 -17
- package/dist/ios/templates/CraftNativeScreen.swift +2217 -220
- package/dist/ios/templates/CraftNativeUI.swift +562 -0
- package/dist/ios/templates/Info.plist.template +2 -1
- package/dist/mobile.js +204 -0
- package/package.json +1 -1
|
@@ -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
|
-
<
|
|
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>
|