@swiftbrowser/native 0.17.7 → 0.18.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 (100) hide show
  1. package/README.md +99 -11
  2. package/dist/blob.d.ts +60 -0
  3. package/dist/blob.d.ts.map +1 -0
  4. package/dist/blob.js +153 -0
  5. package/dist/blob.js.map +1 -0
  6. package/dist/bridge.d.ts +65 -1
  7. package/dist/bridge.d.ts.map +1 -1
  8. package/dist/bridge.js.map +1 -1
  9. package/dist/fetch.d.ts +11 -3
  10. package/dist/fetch.d.ts.map +1 -1
  11. package/dist/fetch.js +26 -2
  12. package/dist/fetch.js.map +1 -1
  13. package/dist/hostRequests.d.ts +55 -0
  14. package/dist/hostRequests.d.ts.map +1 -0
  15. package/dist/hostRequests.js +196 -0
  16. package/dist/hostRequests.js.map +1 -0
  17. package/dist/index.d.ts +8 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +8 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/matchMedia.d.ts +43 -0
  22. package/dist/matchMedia.d.ts.map +1 -0
  23. package/dist/matchMedia.js +63 -0
  24. package/dist/matchMedia.js.map +1 -0
  25. package/dist/notifications.d.ts +108 -0
  26. package/dist/notifications.d.ts.map +1 -0
  27. package/dist/notifications.js +251 -0
  28. package/dist/notifications.js.map +1 -0
  29. package/dist/run.d.ts.map +1 -1
  30. package/dist/run.js +23 -2
  31. package/dist/run.js.map +1 -1
  32. package/dist/runtime.d.ts +2 -0
  33. package/dist/runtime.d.ts.map +1 -1
  34. package/dist/runtime.js +69 -0
  35. package/dist/runtime.js.map +1 -1
  36. package/dist/secureStorage.d.ts +20 -0
  37. package/dist/secureStorage.d.ts.map +1 -0
  38. package/dist/secureStorage.js +28 -0
  39. package/dist/secureStorage.js.map +1 -0
  40. package/dist/site.d.ts +3 -3
  41. package/dist/site.d.ts.map +1 -1
  42. package/dist/site.js +1 -1
  43. package/dist/site.js.map +1 -1
  44. package/dist/subtle.d.ts +38 -0
  45. package/dist/subtle.d.ts.map +1 -0
  46. package/dist/subtle.js +72 -0
  47. package/dist/subtle.js.map +1 -0
  48. package/dist/undo.d.ts +23 -0
  49. package/dist/undo.d.ts.map +1 -0
  50. package/dist/undo.js +44 -0
  51. package/dist/undo.js.map +1 -0
  52. package/dist/webSocket.d.ts +73 -0
  53. package/dist/webSocket.d.ts.map +1 -0
  54. package/dist/webSocket.js +145 -0
  55. package/dist/webSocket.js.map +1 -0
  56. package/package.json +3 -3
  57. package/src/blob.ts +186 -0
  58. package/src/bridge.ts +74 -1
  59. package/src/fetch.ts +29 -3
  60. package/src/hostRequests.ts +216 -0
  61. package/src/index.ts +8 -0
  62. package/src/matchMedia.ts +79 -0
  63. package/src/notifications.ts +317 -0
  64. package/src/run.ts +21 -2
  65. package/src/runtime.ts +71 -0
  66. package/src/secureStorage.ts +39 -0
  67. package/src/site.ts +3 -3
  68. package/src/subtle.ts +87 -0
  69. package/src/undo.ts +47 -0
  70. package/src/webSocket.ts +175 -0
  71. package/swift/Sources/SwiftBrowserNative/Host/AudioRecorderBridge.swift +199 -0
  72. package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +154 -0
  73. package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +211 -0
  74. package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +451 -0
  75. package/swift/Sources/SwiftBrowserNative/Host/NotificationBridge.swift +304 -0
  76. package/swift/Sources/SwiftBrowserNative/Host/SecureStorage.swift +60 -0
  77. package/swift/Sources/SwiftBrowserNative/Host/ShimAliases.swift +1 -0
  78. package/swift/Sources/SwiftBrowserNative/Host/TextMeasureBridge.swift +139 -0
  79. package/swift/Sources/SwiftBrowserNative/Host/UndoBridge.swift +170 -0
  80. package/swift/Sources/SwiftBrowserNative/Host/WebSocketBridge.swift +118 -0
  81. package/swift/Sources/SwiftBrowserNative/Runtime/NativeApp.swift +56 -0
  82. package/swift/Sources/SwiftBrowserNative/Runtime/NativeAppDelegate.swift +36 -0
  83. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +362 -16
  84. package/swift/Sources/SwiftBrowserNative/Views/Kinds/DeleteBackward.swift +147 -0
  85. package/swift/Sources/SwiftBrowserNative/Views/Kinds/KeyboardBar.swift +84 -0
  86. package/swift/Sources/SwiftBrowserNative/Views/Kinds/More.swift +116 -11
  87. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +12 -1
  88. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Shapes.swift +30 -9
  89. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +163 -10
  90. package/swift/Sources/SwiftBrowserNative/Views/Kinds/TextAndImages.swift +17 -4
  91. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Video.swift +8 -3
  92. package/swift/Sources/SwiftBrowserNative/Views/NativeRootView.swift +8 -0
  93. package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +41 -0
  94. package/swift/Sources/SwiftBrowserProtocol/Op.swift +27 -4
  95. package/swift/Sources/SwiftBrowserProtocol/Props.swift +110 -3
  96. package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +47 -0
  97. package/swift/Sources/SwiftBrowserProtocol/SVGPath.swift +260 -0
  98. package/swift/Sources/SwiftBrowserProtocol/Style.swift +58 -0
  99. package/swift/Sources/SwiftBrowserTree/OpTree.swift +22 -2
  100. package/wasm/SwiftBrowserNative.wasm +2 -2
@@ -0,0 +1,451 @@
1
+ #if canImport(SwiftUI)
2
+ import Foundation
3
+ import SwiftBrowserProtocol
4
+ import SwiftUI
5
+ #if canImport(UIKit)
6
+ import UIKit
7
+ #elseif canImport(AppKit)
8
+ import AppKit
9
+ #endif
10
+ #if canImport(FoundationBridge)
11
+ import FoundationBridge
12
+ #endif
13
+ #if canImport(AuthenticationServices)
14
+ import AuthenticationServices
15
+ #endif
16
+ #if !canImport(FoundationBridge)
17
+ #if canImport(PhotosUI)
18
+ import PhotosUI
19
+ #endif
20
+ import UniformTypeIdentifiers
21
+ #endif
22
+
23
+ /// The host requests the web renderer's environment answers for a React app
24
+ /// (`ShareLink`, `fileImporter`, the clipboard, a color chooser, a sign-in),
25
+ /// answered here with the system's own (packages/native/src/hostRequests.ts):
26
+ /// `hostRequest(id, kind, json)` performs one and `__sbHostResponse(id,
27
+ /// { value } | { error })` answers it. The kinds and their params and values
28
+ /// are the web renderer's (docs/ops-protocol.md): `share` (`title`, `text`,
29
+ /// `url`, `files`: a share sheet; `{ outcome }`), `pickFiles` (`accept`,
30
+ /// `multiple`: the photo picker for pictures and videos, else the document
31
+ /// picker; `{ files: [{ name, type, data }] }`), `clipboard` (`write` with
32
+ /// `text` or `files`, `readText`; `{ text }`), `pickColor` (`color`: the
33
+ /// color picker; `{ color }`), `webAuth` (`url`, `callbackScheme`,
34
+ /// `ephemeral`: `ASWebAuthenticationSession`; `{ url }`) and, on iOS,
35
+ /// `audioRecord` (`action`: the microphone with `AVAudioRecorder`,
36
+ /// AudioRecorderBridge.swift). Against the shim
37
+ /// (the round trip) the page answers the first four itself, and the sign-in
38
+ /// runs on the shim's session; the rest answers `unsupported`.
39
+ @MainActor
40
+ final class HostRequestBridge {
41
+ private let answer: (Int, JSONValue) -> Void
42
+ /// Delegates and sessions kept alive until they answer.
43
+ private var active: [ObjectIdentifier: AnyObject] = [:]
44
+ #if os(iOS) && !canImport(FoundationBridge)
45
+ /// The microphone (`audioRecord`), one recording at a time.
46
+ private lazy var recorder = AudioRecorderBridge()
47
+ #endif
48
+ #if !canImport(FoundationBridge)
49
+ /// The app's files (`files`) for `fileManager`.
50
+ private let files = FilesBridge()
51
+ #endif
52
+
53
+ /// `answer` hands a request's result to the app.
54
+ init(answer: @escaping (Int, JSONValue) -> Void) {
55
+ self.answer = answer
56
+ }
57
+
58
+ func perform(id: Int, kind: String, json: String) {
59
+ let params = (try? JSONValue.parse(json))?.object ?? [:]
60
+ Task { @MainActor in
61
+ do {
62
+ answer(id, .object(["value": try await handle(kind, params)]))
63
+ } catch {
64
+ answer(id, .object(["error": .string("\(error)")]))
65
+ }
66
+ }
67
+ }
68
+
69
+ private func handle(_ kind: String, _ params: JSONObject) async throws -> JSONValue {
70
+ switch kind {
71
+ case "webAuth": try await webAuth(params)
72
+ #if !canImport(FoundationBridge)
73
+ case "share": try await share(params)
74
+ case "pickFiles": try await pickFiles(params)
75
+ case "clipboard": try clipboard(params)
76
+ case "pickColor": try await pickColor(params)
77
+ case "files": try await files.handle(params)
78
+ #endif
79
+ #if os(iOS) && !canImport(FoundationBridge)
80
+ case "audioRecord": try await recorder.handle(params)
81
+ #endif
82
+ default: throw HostRequestFailure("unsupported: the native app cannot answer a \(kind) request")
83
+ }
84
+ }
85
+
86
+ private func keep(_ object: AnyObject) {
87
+ active[ObjectIdentifier(object)] = object
88
+ }
89
+
90
+ private func release(_ object: AnyObject) {
91
+ active[ObjectIdentifier(object)] = nil
92
+ }
93
+
94
+ // MARK: Sign-in
95
+
96
+ private func webAuth(_ params: JSONObject) async throws -> JSONValue {
97
+ #if canImport(AuthenticationServices)
98
+ guard let string = params["url"]?.string, let url = URL(string: string) else { throw HostRequestFailure("webAuth: expected a url") }
99
+ let scheme = params["callbackScheme"]?.string
100
+ let ephemeral = params["ephemeral"]?.bool ?? false
101
+ return try await withCheckedThrowingContinuation { continuation in
102
+ let presentation = AuthPresentation()
103
+ keep(presentation)
104
+ let session = ASWebAuthenticationSession(url: url, callbackURLScheme: scheme) { [weak self] callback, error in
105
+ let result: Result<JSONValue, any Error>
106
+ if let callback {
107
+ result = .success(.object(["url": .string(callback.absoluteString)]))
108
+ } else if let error, Self.isCancellation(error) {
109
+ result = .failure(HostRequestFailure("cancelled: the viewer closed the sign-in"))
110
+ } else {
111
+ result = .failure(HostRequestFailure("webAuth: \(error?.localizedDescription ?? "the sign-in ended without a URL")"))
112
+ }
113
+ Task { @MainActor [weak self] in
114
+ self?.release(presentation)
115
+ continuation.resume(with: result)
116
+ }
117
+ }
118
+ session.prefersEphemeralWebBrowserSession = ephemeral
119
+ #if !canImport(FoundationBridge)
120
+ session.presentationContextProvider = presentation
121
+ #endif
122
+ presentation.session = session
123
+ _ = session.start()
124
+ }
125
+ #else
126
+ throw HostRequestFailure("unsupported: no AuthenticationServices")
127
+ #endif
128
+ }
129
+
130
+ nonisolated private static func isCancellation(_ error: any Error) -> Bool {
131
+ #if canImport(AuthenticationServices) && !canImport(FoundationBridge)
132
+ if let failure = error as? ASWebAuthenticationSessionError, failure.code == .canceledLogin { return true }
133
+ #endif
134
+ return "\(error)".lowercased().contains("cancel")
135
+ }
136
+
137
+ #if !canImport(FoundationBridge)
138
+ // MARK: The share sheet, the pickers and the pasteboard (Apple platforms)
139
+
140
+ /// The files a request carries, written to a folder of their own, for the share sheet.
141
+ private func writeFiles(_ files: [JSONValue]) throws -> [URL] {
142
+ let folder = FileManager.default.temporaryDirectory.appendingPathComponent("swiftbrowser-share-\(UUID().uuidString)", isDirectory: true)
143
+ try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
144
+ return try files.compactMap { file -> URL? in
145
+ guard let encoded = file["data"].string, let data = Data(base64Encoded: encoded) else { return nil }
146
+ let name = file["name"].string.flatMap { $0.isEmpty ? nil : $0 } ?? "file"
147
+ let url = folder.appendingPathComponent(name.components(separatedBy: "/").last ?? name)
148
+ try data.write(to: url)
149
+ return url
150
+ }
151
+ }
152
+
153
+ private static func mimeType(of url: URL) -> String {
154
+ UTType(filenameExtension: url.pathExtension)?.preferredMIMEType ?? "application/octet-stream"
155
+ }
156
+
157
+ private static func fileValue(name: String, type: String, data: Data) -> JSONValue {
158
+ .object(["name": .string(name), "type": .string(type), "data": .string(data.base64EncodedString())])
159
+ }
160
+
161
+ #if canImport(UIKit)
162
+ /// The view controller to present from: the key window's root, or whatever it presented last.
163
+ private func presenter() throws -> UIViewController {
164
+ let windows = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.flatMap(\.windows)
165
+ guard var controller = (windows.first { $0.isKeyWindow } ?? windows.first)?.rootViewController else {
166
+ throw HostRequestFailure("unsupported: no window to present from")
167
+ }
168
+ while let presented = controller.presentedViewController { controller = presented }
169
+ return controller
170
+ }
171
+
172
+ private func share(_ params: JSONObject) async throws -> JSONValue {
173
+ var items: [Any] = []
174
+ if let text = params["text"]?.string, !text.isEmpty { items.append(text) }
175
+ if let url = params["url"]?.string.flatMap(URL.init(string:)) { items.append(url) }
176
+ items.append(contentsOf: try writeFiles(params["files"]?.array ?? []))
177
+ guard !items.isEmpty else { throw HostRequestFailure("share: nothing to share") }
178
+ let presenter = try presenter()
179
+ return await withCheckedContinuation { continuation in
180
+ let controller = UIActivityViewController(activityItems: items, applicationActivities: nil)
181
+ if let title = params["title"]?.string, !title.isEmpty { controller.title = title }
182
+ if let popover = controller.popoverPresentationController {
183
+ popover.sourceView = presenter.view
184
+ popover.sourceRect = CGRect(x: presenter.view.bounds.midX, y: presenter.view.bounds.midY, width: 0, height: 0)
185
+ popover.permittedArrowDirections = []
186
+ }
187
+ controller.completionWithItemsHandler = { _, completed, _, _ in
188
+ continuation.resume(returning: .object(["outcome": .string(completed ? "shared" : "cancelled")]))
189
+ }
190
+ presenter.present(controller, animated: true)
191
+ }
192
+ }
193
+
194
+ private func pickFiles(_ params: JSONObject) async throws -> JSONValue {
195
+ let accept = Self.parseAccept(params["accept"]?.string ?? "")
196
+ let multiple = params["multiple"]?.bool ?? false
197
+ let presenter = try presenter()
198
+ #if canImport(PhotosUI)
199
+ if accept.photos {
200
+ var configuration = PHPickerConfiguration(photoLibrary: .shared())
201
+ configuration.selectionLimit = multiple ? 0 : 1
202
+ configuration.filter = accept.videos && accept.images ? .any(of: [.images, .videos]) : accept.videos ? .videos : .images
203
+ let results: [PHPickerResult] = await withCheckedContinuation { continuation in
204
+ let delegate = PhotoPickerDelegate { [weak self] delegate, results in
205
+ self?.release(delegate)
206
+ // The results are read on the main actor only, here and after the await; the compiler cannot see that.
207
+ nonisolated(unsafe) let picked = results
208
+ continuation.resume(returning: picked)
209
+ }
210
+ keep(delegate)
211
+ let picker = PHPickerViewController(configuration: configuration)
212
+ picker.delegate = delegate
213
+ presenter.present(picker, animated: true)
214
+ }
215
+ var files: [JSONValue] = []
216
+ for result in results {
217
+ if let file = await Self.load(result.itemProvider) { files.append(file) }
218
+ }
219
+ return .object(["files": .array(files)])
220
+ }
221
+ #endif
222
+ let urls: [URL] = await withCheckedContinuation { continuation in
223
+ let delegate = DocumentPickerDelegate { [weak self] delegate, urls in
224
+ self?.release(delegate)
225
+ continuation.resume(returning: urls)
226
+ }
227
+ keep(delegate)
228
+ let picker = UIDocumentPickerViewController(forOpeningContentTypes: accept.types.isEmpty ? [.item] : accept.types, asCopy: true)
229
+ picker.allowsMultipleSelection = multiple
230
+ picker.delegate = delegate
231
+ presenter.present(picker, animated: true)
232
+ }
233
+ let files = urls.compactMap { url -> JSONValue? in
234
+ guard let data = try? Data(contentsOf: url) else { return nil }
235
+ return Self.fileValue(name: url.lastPathComponent, type: Self.mimeType(of: url), data: data)
236
+ }
237
+ return .object(["files": .array(files)])
238
+ }
239
+
240
+ private func clipboard(_ params: JSONObject) throws -> JSONValue {
241
+ let pasteboard = UIPasteboard.general
242
+ if params["action"]?.string == "readText" { return .object(["text": .string(pasteboard.string ?? "")]) }
243
+ if let text = params["text"]?.string {
244
+ pasteboard.string = text
245
+ return .object([:])
246
+ }
247
+ guard let file = params["files"]?.array?.first, let encoded = file["data"].string, let data = Data(base64Encoded: encoded) else {
248
+ throw HostRequestFailure("clipboard: nothing to copy")
249
+ }
250
+ if let image = UIImage(data: data) {
251
+ pasteboard.image = image
252
+ } else {
253
+ pasteboard.setData(data, forPasteboardType: UTType(mimeType: file["type"].string ?? "")?.identifier ?? UTType.data.identifier)
254
+ }
255
+ return .object([:])
256
+ }
257
+
258
+ private func pickColor(_ params: JSONObject) async throws -> JSONValue {
259
+ let presenter = try presenter()
260
+ return await withCheckedContinuation { continuation in
261
+ let delegate = ColorPickerDelegate { [weak self] delegate, color in
262
+ self?.release(delegate)
263
+ continuation.resume(returning: color.map { .object(["color": .string($0)]) } ?? .object([:]))
264
+ }
265
+ keep(delegate)
266
+ let picker = UIColorPickerViewController()
267
+ picker.supportsAlpha = false
268
+ if let hex = params["color"]?.string, let color = UIColor(hex: hex) { picker.selectedColor = color }
269
+ picker.delegate = delegate
270
+ presenter.present(picker, animated: true)
271
+ }
272
+ }
273
+ #elseif canImport(AppKit)
274
+ private func share(_ params: JSONObject) async throws -> JSONValue {
275
+ var items: [Any] = []
276
+ if let text = params["text"]?.string, !text.isEmpty { items.append(text) }
277
+ if let url = params["url"]?.string.flatMap(URL.init(string:)) { items.append(url) }
278
+ items.append(contentsOf: try writeFiles(params["files"]?.array ?? []))
279
+ guard !items.isEmpty else { throw HostRequestFailure("share: nothing to share") }
280
+ guard let view = NSApplication.shared.keyWindow?.contentView else { throw HostRequestFailure("unsupported: no window to present from") }
281
+ NSSharingServicePicker(items: items).show(relativeTo: .zero, of: view, preferredEdge: .minY)
282
+ return .object(["outcome": .string("shared")])
283
+ }
284
+
285
+ private func pickFiles(_ params: JSONObject) async throws -> JSONValue {
286
+ let accept = Self.parseAccept(params["accept"]?.string ?? "")
287
+ let panel = NSOpenPanel()
288
+ panel.allowsMultipleSelection = params["multiple"]?.bool ?? false
289
+ panel.canChooseDirectories = false
290
+ if !accept.types.isEmpty { panel.allowedContentTypes = accept.types }
291
+ let response = await panel.begin()
292
+ guard response == .OK else { return .object(["files": .array([])]) }
293
+ let files = panel.urls.compactMap { url -> JSONValue? in
294
+ guard let data = try? Data(contentsOf: url) else { return nil }
295
+ return Self.fileValue(name: url.lastPathComponent, type: Self.mimeType(of: url), data: data)
296
+ }
297
+ return .object(["files": .array(files)])
298
+ }
299
+
300
+ private func clipboard(_ params: JSONObject) throws -> JSONValue {
301
+ let pasteboard = NSPasteboard.general
302
+ if params["action"]?.string == "readText" { return .object(["text": .string(pasteboard.string(forType: .string) ?? "")]) }
303
+ pasteboard.clearContents()
304
+ if let text = params["text"]?.string {
305
+ pasteboard.setString(text, forType: .string)
306
+ return .object([:])
307
+ }
308
+ guard let file = params["files"]?.array?.first, let encoded = file["data"].string, let data = Data(base64Encoded: encoded) else {
309
+ throw HostRequestFailure("clipboard: nothing to copy")
310
+ }
311
+ if let image = NSImage(data: data) {
312
+ pasteboard.writeObjects([image])
313
+ } else {
314
+ pasteboard.setData(data, forType: NSPasteboard.PasteboardType(UTType(mimeType: file["type"].string ?? "")?.identifier ?? UTType.data.identifier))
315
+ }
316
+ return .object([:])
317
+ }
318
+
319
+ private func pickColor(_ params: JSONObject) async throws -> JSONValue {
320
+ throw HostRequestFailure("unsupported: no color picker on macOS yet")
321
+ }
322
+ #endif
323
+
324
+ /// What a chooser's `accept` asks for: the content types, and whether they are all pictures or videos (the photo library's).
325
+ static func parseAccept(_ accept: String) -> (types: [UTType], photos: Bool, images: Bool, videos: Bool) {
326
+ let entries = accept.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
327
+ let images = entries.contains { $0.hasPrefix("image/") }
328
+ let videos = entries.contains { $0.hasPrefix("video/") }
329
+ let photos = !entries.isEmpty && entries.allSatisfy { $0.hasPrefix("image/") || $0.hasPrefix("video/") }
330
+ let types = entries.compactMap { entry -> UTType? in
331
+ if entry.hasPrefix(".") { return UTType(filenameExtension: String(entry.dropFirst())) }
332
+ switch entry {
333
+ case "image/*": return .image
334
+ case "video/*": return .movie
335
+ case "audio/*": return .audio
336
+ default: return UTType(mimeType: entry)
337
+ }
338
+ }
339
+ return (types, photos, images, videos)
340
+ }
341
+
342
+ /// A picked photo or video as a file: its first picture (or movie) representation, named after the item.
343
+ static func load(_ provider: NSItemProvider) async -> JSONValue? {
344
+ let identifiers = provider.registeredTypeIdentifiers
345
+ guard let identifier = identifiers.first(where: { UTType($0)?.conforms(to: .image) == true })
346
+ ?? identifiers.first(where: { UTType($0)?.conforms(to: .movie) == true })
347
+ ?? identifiers.first else { return nil }
348
+ let type = UTType(identifier)
349
+ let data: Data? = await withCheckedContinuation { continuation in
350
+ _ = provider.loadDataRepresentation(forTypeIdentifier: identifier) { data, _ in continuation.resume(returning: data) }
351
+ }
352
+ guard let data else { return nil }
353
+ let ext = type?.preferredFilenameExtension ?? "bin"
354
+ let suggested = provider.suggestedName ?? "photo"
355
+ let name = suggested.contains(".") ? suggested : "\(suggested).\(ext)"
356
+ return fileValue(name: name, type: type?.preferredMIMEType ?? "application/octet-stream", data: data)
357
+ }
358
+ #endif
359
+ }
360
+
361
+ struct HostRequestFailure: Error, CustomStringConvertible {
362
+ let description: String
363
+ init(_ description: String) { self.description = description }
364
+ }
365
+
366
+ #if canImport(AuthenticationServices)
367
+ /// Where the sign-in sheet appears: over the key window. Keeps the session
368
+ /// alive until it answers.
369
+ private final class AuthPresentation: NSObject, @unchecked Sendable {
370
+ var session: ASWebAuthenticationSession?
371
+ }
372
+
373
+ #if !canImport(FoundationBridge)
374
+ extension AuthPresentation: ASWebAuthenticationPresentationContextProviding {
375
+ func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
376
+ MainActor.assumeIsolated {
377
+ #if canImport(UIKit)
378
+ let windows = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.flatMap(\.windows)
379
+ return windows.first { $0.isKeyWindow } ?? windows.first ?? ASPresentationAnchor()
380
+ #else
381
+ return NSApplication.shared.keyWindow ?? ASPresentationAnchor()
382
+ #endif
383
+ }
384
+ }
385
+ }
386
+ #endif
387
+ #endif
388
+
389
+ #if canImport(UIKit) && !canImport(FoundationBridge)
390
+ #if canImport(PhotosUI)
391
+ private final class PhotoPickerDelegate: NSObject, PHPickerViewControllerDelegate, @unchecked Sendable {
392
+ private let done: @MainActor (PhotoPickerDelegate, [PHPickerResult]) -> Void
393
+ init(done: @escaping @MainActor (PhotoPickerDelegate, [PHPickerResult]) -> Void) { self.done = done }
394
+
395
+ // UIKit calls its delegates on the main thread; the protocols do not say so.
396
+ nonisolated func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
397
+ // Handed over on the main thread, and read there only.
398
+ nonisolated(unsafe) let picked = results
399
+ MainActor.assumeIsolated {
400
+ picker.dismiss(animated: true)
401
+ done(self, picked)
402
+ }
403
+ }
404
+ }
405
+ #endif
406
+
407
+ private final class DocumentPickerDelegate: NSObject, UIDocumentPickerDelegate, @unchecked Sendable {
408
+ private let done: @MainActor (DocumentPickerDelegate, [URL]) -> Void
409
+ init(done: @escaping @MainActor (DocumentPickerDelegate, [URL]) -> Void) { self.done = done }
410
+
411
+ nonisolated func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
412
+ MainActor.assumeIsolated { done(self, urls) }
413
+ }
414
+
415
+ nonisolated func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
416
+ MainActor.assumeIsolated { done(self, []) }
417
+ }
418
+ }
419
+
420
+ private final class ColorPickerDelegate: NSObject, UIColorPickerViewControllerDelegate, @unchecked Sendable {
421
+ private let done: @MainActor (ColorPickerDelegate, String?) -> Void
422
+ private var finished = false
423
+ init(done: @escaping @MainActor (ColorPickerDelegate, String?) -> Void) { self.done = done }
424
+
425
+ nonisolated func colorPickerViewControllerDidFinish(_ viewController: UIColorPickerViewController) {
426
+ MainActor.assumeIsolated {
427
+ guard !finished else { return }
428
+ finished = true
429
+ done(self, viewController.selectedColor.hexString)
430
+ }
431
+ }
432
+ }
433
+
434
+ extension UIColor {
435
+ /// `#rrggbb`, else nil.
436
+ convenience init?(hex: String) {
437
+ var text = hex.trimmingCharacters(in: .whitespaces)
438
+ if text.hasPrefix("#") { text.removeFirst() }
439
+ guard text.count == 6, let value = UInt32(text, radix: 16) else { return nil }
440
+ self.init(red: CGFloat((value >> 16) & 0xff) / 255, green: CGFloat((value >> 8) & 0xff) / 255, blue: CGFloat(value & 0xff) / 255, alpha: 1)
441
+ }
442
+
443
+ var hexString: String {
444
+ var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0
445
+ getRed(&red, green: &green, blue: &blue, alpha: &alpha)
446
+ let component = { (value: CGFloat) in String(format: "%02x", Int((max(0, min(1, value)) * 255).rounded())) }
447
+ return "#\(component(red))\(component(green))\(component(blue))"
448
+ }
449
+ }
450
+ #endif
451
+ #endif