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