@swiftbrowser/native 0.18.3 → 0.21.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.
- package/README.md +53 -11
- package/bin/swiftbrowser-native.mjs +47 -0
- package/dist/blob.d.ts +85 -11
- package/dist/blob.d.ts.map +1 -1
- package/dist/blob.js +184 -34
- package/dist/blob.js.map +1 -1
- package/dist/bridge.d.ts +66 -8
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js.map +1 -1
- package/dist/encoding.d.ts +13 -0
- package/dist/encoding.d.ts.map +1 -1
- package/dist/encoding.js +24 -0
- package/dist/encoding.js.map +1 -1
- package/dist/fetch.d.ts +27 -13
- package/dist/fetch.d.ts.map +1 -1
- package/dist/fetch.js +117 -45
- package/dist/fetch.js.map +1 -1
- package/dist/hostRequests.d.ts +24 -6
- package/dist/hostRequests.d.ts.map +1 -1
- package/dist/hostRequests.js +108 -15
- package/dist/hostRequests.js.map +1 -1
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -2
- package/dist/index.js.map +1 -1
- package/dist/ios.d.ts +79 -0
- package/dist/ios.d.ts.map +1 -0
- package/dist/ios.js +181 -0
- package/dist/ios.js.map +1 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +16 -7
- package/dist/runtime.js.map +1 -1
- package/dist/subtle.d.ts +1 -1
- package/dist/subtle.d.ts.map +1 -1
- package/dist/subtle.js +9 -5
- package/dist/subtle.js.map +1 -1
- package/dist/webSocket.d.ts +12 -5
- package/dist/webSocket.d.ts.map +1 -1
- package/dist/webSocket.js +37 -11
- package/dist/webSocket.js.map +1 -1
- package/package.json +7 -3
- package/src/blob.ts +215 -34
- package/src/bridge.ts +56 -8
- package/src/encoding.ts +22 -0
- package/src/fetch.ts +111 -48
- package/src/hostRequests.ts +129 -22
- package/src/index.ts +1 -2
- package/src/ios.ts +253 -0
- package/src/runtime.ts +23 -8
- package/src/subtle.ts +10 -6
- package/src/webSocket.ts +37 -13
- package/swift/Packages/SwiftBrowserDSP/Package.swift +31 -0
- package/swift/Packages/SwiftBrowserDSP/README.md +42 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ProgramNode.swift +306 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ScopeView.swift +79 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Machine.swift +1561 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Program.swift +483 -0
- package/swift/Packages/SwiftBrowserLocalModel/Package.swift +35 -0
- package/swift/Packages/SwiftBrowserLocalModel/README.md +66 -0
- package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LlamaEngine.swift +223 -0
- package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LocalLanguageModel.swift +409 -0
- package/swift/Packages/SwiftBrowserShader/Package.swift +31 -0
- package/swift/Packages/SwiftBrowserShader/README.md +45 -0
- package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShader/ShaderRenderer.swift +103 -0
- package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShader/ShaderView.swift +168 -0
- package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShaderCore/ShaderProps.swift +120 -0
- package/swift/Sources/SwiftBrowserNative/Host/AudioEngineBridge.swift +921 -0
- package/swift/Sources/SwiftBrowserNative/Host/AudioEngineNodes.swift +70 -0
- package/swift/Sources/SwiftBrowserNative/Host/BodyStream.swift +166 -0
- package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +144 -10
- package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +151 -27
- package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +139 -40
- package/swift/Sources/SwiftBrowserNative/Host/JSHost.swift +116 -0
- package/swift/Sources/SwiftBrowserNative/Host/JavaScriptCoreHost.swift +141 -2
- package/swift/Sources/SwiftBrowserNative/Host/LanguageModelBridge.swift +511 -0
- package/swift/Sources/SwiftBrowserNative/Host/LanguageModelProvider.swift +195 -0
- package/swift/Sources/SwiftBrowserNative/Host/WebSocketBridge.swift +24 -11
- package/swift/Sources/SwiftBrowserNative/Host/WorkerHost.swift +3 -2
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeFunctions.swift +1 -1
- package/swift/Sources/SwiftBrowserNative/Runtime/ViewRegistry.swift +57 -0
- package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +12 -7
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +18 -1
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Drawing.swift +378 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Lists.swift +41 -1
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/More.swift +88 -17
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +87 -21
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/PinchTracker.swift +200 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/PopTap.swift +103 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +3 -2
- package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +146 -0
- package/swift/Sources/SwiftBrowserProtocol/Drawing.swift +306 -0
- package/swift/Sources/SwiftBrowserProtocol/Props.swift +31 -2
- package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +24 -3
- package/swift/Sources/SwiftBrowserProtocol/Style.swift +87 -0
- package/swift/Sources/SwiftBrowserTree/OpTree.swift +25 -0
- package/wasm/SwiftBrowserNative.wasm +2 -2
- package/dist/native.d.ts +0 -19
- package/dist/native.d.ts.map +0 -1
- package/dist/native.js +0 -17
- package/dist/native.js.map +0 -1
- package/src/native.ts +0 -35
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeRegistry.swift +0 -53
|
@@ -27,7 +27,10 @@ import IOKit.pwr_mgt
|
|
|
27
27
|
/// | `log(level, message)` | the console |
|
|
28
28
|
/// | `now()` | milliseconds, monotonic |
|
|
29
29
|
/// | `setTimer(id, delayMs, repeats)`, `clearTimer(id)` | timers on the main run loop; fire `__sbTimerFired(id)` |
|
|
30
|
-
/// | `
|
|
30
|
+
/// | `hostRequestBytes(id, kind, json, ...views)` | `hostRequest` with typed arrays beside the JSON, which names each as `{ "$bytes": n }` (docs/notes/bytes.md); only where the engine carries bytes (JavaScriptCore), and an answer's bytes follow `__sbHostResponse`'s JSON the same way |
|
|
31
|
+
/// | `fetch(id, requestJSON, ...views)` | `URLSession`; answers `__sbFetchResult(id, resultJSON, ...bytes)`: a body as a typed array beside the JSON where the engine carries bytes and the request says `bytes: true`, else base64 |
|
|
32
|
+
/// | `fetchCancel(id)` | cancels the request's task (an `AbortController`'s abort); it answers nothing |
|
|
33
|
+
/// | `fetchStreams()` | true: `fetch` takes `bodyParts`, typed arrays beside the request and ranges of files on disk, streamed in order (BodyStream.swift) |
|
|
31
34
|
/// | `probeImage(id, url)` | the image's pixel size through the image loader (ImageLoader.swift), which keeps the bytes for the `image` that follows; answers `__sbImageProbed(id, {width, height} or {error})`. The runtime's `Image` (a page's `new Image()`). Apple platforms only |
|
|
32
35
|
/// | `storageGet/Set/Remove/Keys/Clear` | `localStorage` over `UserDefaults` |
|
|
33
36
|
/// | `randomBytes(count)` | base64 of random bytes (`crypto.getRandomValues`) |
|
|
@@ -38,7 +41,8 @@ import IOKit.pwr_mgt
|
|
|
38
41
|
/// | `appDirectories()` | the app's standard directories as `file:` URLs, a JSON object `{ documents, applicationSupport, caches, temporary }`, for `fileManager` (FilesBridge.swift); its `files` requests go through `hostRequest`. Apple platforms only |
|
|
39
42
|
/// | `mediaSession(update)` | `navigator.mediaSession`: the lock screen's now-playing info and the remote commands the app handles, on MediaPlayer (NowPlayingBridge.swift); a command the viewer gives comes to `__sbMediaSessionCommand(json)` |
|
|
40
43
|
/// | `notificationsReady()`, `notificationPermission(id, request)`, `notificationShow(json)`, `notificationClose(json)`, `setBadge(count)`, `pushRegister(id, key?)` | notifications on UserNotifications (NotificationBridge.swift): the permission answers `__sbNotificationPermission(id, state)`, a registration `__sbPushRegistered(id, result)`, and what the viewer opens or what arrives comes to `__sbNotification(json)` |
|
|
41
|
-
/// | `wsOpen(id, url, protocols)`, `wsSend(id, json)`, `wsClose(id, code, reason)` | `WebSocket` on `URLSessionWebSocketTask` (WebSocketBridge.swift); what a socket receives comes to `__sbWebSocketEvent(id, json)` |
|
|
44
|
+
/// | `wsOpen(id, url, protocols, bytes)`, `wsSend(id, json, ...views)`, `wsClose(id, code, reason)` | `WebSocket` on `URLSessionWebSocketTask` (WebSocketBridge.swift); what a socket receives comes to `__sbWebSocketEvent(id, json, ...bytes)`, binary as a `Uint8Array` where `wsOpen` said `bytes` |
|
|
45
|
+
/// | `randomValues(count)`, `digestBytes(algorithm, data)`, `hmacBytes(algorithm, key, data)`, `decodeUTF8(bytes)`, `encodeUTF8(text)` | `randomBytes`, `digest` and `hmac` with typed arrays in and out, and UTF-8 for encoding.ts; only where the engine carries bytes |
|
|
42
46
|
/// | `digest(algorithm, base64)`, `hmac(algorithm, key, base64)` | `crypto.subtle` on CryptoKit, synchronously: the base64 digest or HMAC, or nothing when the algorithm is not one of SHA-1, SHA-256, SHA-384 and SHA-512 |
|
|
43
47
|
/// | `haptic(kind)` | `navigator.vibrate`: a feedback generator's `impact`, `selection`, `success`, `warning` or `error` |
|
|
44
48
|
/// | `geolocation(id, action, json)` | `navigator.geolocation` on `CLLocationManager` (GeolocationBridge.swift): `current` for one fix, `watch` for every fix until `clear`; each fix or the failure comes to `__sbGeolocation(id, json)`. Apple platforms only |
|
|
@@ -63,7 +67,15 @@ final class HostBridge {
|
|
|
63
67
|
private var workers: [Int: any WorkerEngine] = [:]
|
|
64
68
|
#endif
|
|
65
69
|
private let logger = Logger(subsystem: "swiftbrowser", category: "js")
|
|
70
|
+
#if canImport(FoundationBridge)
|
|
66
71
|
private let session = URLSession(configuration: .default)
|
|
72
|
+
#else
|
|
73
|
+
/// Streamed bodies are rebuilt from their parts when a redirect or a challenge asks for the body again (`needNewBodyStream`).
|
|
74
|
+
private let bodyStreams = BodyStreams()
|
|
75
|
+
private lazy var session = URLSession(configuration: .default, delegate: bodyStreams, delegateQueue: nil)
|
|
76
|
+
#endif
|
|
77
|
+
/// The requests under way, by id, for `fetchCancel`.
|
|
78
|
+
private var fetchTasks: [Int: URLSessionDataTask] = [:]
|
|
67
79
|
#if canImport(MediaPlayer)
|
|
68
80
|
private var nowPlaying: NowPlayingBridge?
|
|
69
81
|
#endif
|
|
@@ -192,13 +204,28 @@ final class HostBridge {
|
|
|
192
204
|
return nil
|
|
193
205
|
}
|
|
194
206
|
|
|
195
|
-
|
|
207
|
+
// A body as a typed array beside the request (`{ "$bytes": 0 }`), and
|
|
208
|
+
// the response's as one too where the request says `bytes: true`.
|
|
209
|
+
host.installBytes("fetch") { [weak self] arguments in
|
|
210
|
+
guard let self, case .json(let first)? = arguments[safe: 0], let id = first.int else { return nil }
|
|
211
|
+
var request = JSONValue.null
|
|
212
|
+
if case .json(let second)? = arguments[safe: 1] { request = second.string.flatMap { try? JSONValue.parse($0) } ?? second }
|
|
213
|
+
self.fetch(id: id, request: request, bytes: Self.bytes(in: arguments.dropFirst(2)))
|
|
214
|
+
return nil
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// An `AbortController`'s abort: the task is cancelled and answers nothing.
|
|
218
|
+
host.install("fetchCancel") { [weak self] arguments in
|
|
196
219
|
guard let self, let id = arguments[safe: 0]?.int else { return nil }
|
|
197
|
-
|
|
198
|
-
self.fetch(id: id, request: request)
|
|
220
|
+
self.fetchTasks.removeValue(forKey: id)?.cancel()
|
|
199
221
|
return nil
|
|
200
222
|
}
|
|
201
223
|
|
|
224
|
+
#if !canImport(FoundationBridge)
|
|
225
|
+
// A body as parts (`bodyParts`): typed arrays beside the request and ranges of files on disk, streamed in order.
|
|
226
|
+
host.install("fetchStreams") { _ in .bool(true) }
|
|
227
|
+
#endif
|
|
228
|
+
|
|
202
229
|
#if !canImport(FoundationBridge)
|
|
203
230
|
host.install("probeImage") { [weak self] arguments in
|
|
204
231
|
guard let self, let id = arguments[safe: 0]?.int, let string = arguments[safe: 1]?.string else { return nil }
|
|
@@ -244,10 +271,7 @@ final class HostBridge {
|
|
|
244
271
|
}
|
|
245
272
|
|
|
246
273
|
host.install("randomBytes") { arguments in
|
|
247
|
-
|
|
248
|
-
var generator = SystemRandomNumberGenerator()
|
|
249
|
-
let bytes = (0..<count).map { _ in UInt8.random(in: .min ... .max, using: &generator) }
|
|
250
|
-
return .string(Data(bytes).base64EncodedString())
|
|
274
|
+
.string(Self.random(arguments.first?.int ?? 0).base64EncodedString())
|
|
251
275
|
}
|
|
252
276
|
|
|
253
277
|
host.install("openURL") { arguments in
|
|
@@ -375,12 +399,14 @@ final class HostBridge {
|
|
|
375
399
|
host.install("wsOpen") { [weak self] arguments in
|
|
376
400
|
guard let self, let id = arguments[safe: 0]?.int, let url = arguments[safe: 1]?.string else { return nil }
|
|
377
401
|
let protocols = arguments[safe: 2]?.array?.compactMap(\.string) ?? []
|
|
378
|
-
|
|
402
|
+
// A fourth argument `true`: the app reads binary messages as bytes (and the engine carries them).
|
|
403
|
+
webSocketBridge.open(id: id, url: url, protocols: protocols, bytes: host.carriesBytes && arguments[safe: 3]?.bool == true)
|
|
379
404
|
return nil
|
|
380
405
|
}
|
|
381
|
-
host.
|
|
382
|
-
guard let self, let
|
|
383
|
-
|
|
406
|
+
host.installBytes("wsSend") { [weak self] arguments in
|
|
407
|
+
guard let self, case .json(let first)? = arguments[safe: 0], let id = first.int,
|
|
408
|
+
case .json(let second)? = arguments[safe: 1], let json = second.string else { return nil }
|
|
409
|
+
webSocketBridge.send(id: id, json: json, bytes: Self.bytes(in: arguments.dropFirst(2)))
|
|
384
410
|
return nil
|
|
385
411
|
}
|
|
386
412
|
host.install("wsClose") { [weak self] arguments in
|
|
@@ -415,15 +441,46 @@ final class HostBridge {
|
|
|
415
441
|
|
|
416
442
|
host.install("hostRequest") { [weak self] arguments in
|
|
417
443
|
guard let self, let id = arguments[safe: 0]?.int, let kind = arguments[safe: 1]?.string else { return nil }
|
|
418
|
-
|
|
419
|
-
hostRequests = HostRequestBridge(
|
|
420
|
-
answer: { [weak self] id, result in self?.callIn("__sbHostResponse", [.number(Double(id)), .string(result.json)]) },
|
|
421
|
-
filesChanged: { [weak self] urls in self?.callIn("__sbFilesChanged", [.string(JSONValue.array(urls.map(JSONValue.string)).json)]) }
|
|
422
|
-
)
|
|
423
|
-
}
|
|
424
|
-
hostRequests?.perform(id: id, kind: kind, json: arguments[safe: 2]?.string ?? "{}")
|
|
444
|
+
requestBridge().perform(id: id, kind: kind, json: arguments[safe: 2]?.string ?? "{}")
|
|
425
445
|
return nil
|
|
426
446
|
}
|
|
447
|
+
// The same, with typed arrays beside the JSON where it says `{ "$bytes": n }` (docs/notes/bytes.md).
|
|
448
|
+
if host.carriesBytes {
|
|
449
|
+
host.installBytes("hostRequestBytes") { [weak self] arguments in
|
|
450
|
+
guard let self, case .json(let first)? = arguments[safe: 0], let id = first.int,
|
|
451
|
+
case .json(let second)? = arguments[safe: 1], let kind = second.string else { return nil }
|
|
452
|
+
var json = "{}"
|
|
453
|
+
if case .json(let third)? = arguments[safe: 2], let text = third.string { json = text }
|
|
454
|
+
requestBridge().perform(id: id, kind: kind, json: json, bytes: Self.bytes(in: arguments.dropFirst(3)), answersBytes: true)
|
|
455
|
+
return nil
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Bytes back as typed arrays: random bytes, digests and HMACs
|
|
459
|
+
// (`crypto`, subtle.ts), and UTF-8 both ways for `TextDecoder`,
|
|
460
|
+
// `TextEncoder` and a response's text (encoding.ts), which in
|
|
461
|
+
// JavaScript would be a loop over every byte.
|
|
462
|
+
host.installBytes("randomValues") { arguments in
|
|
463
|
+
guard case .json(let count)? = arguments.first else { return nil }
|
|
464
|
+
return .bytes(HostBytes(Self.random(count.int ?? 0), kind: .uint8))
|
|
465
|
+
}
|
|
466
|
+
host.installBytes("digestBytes") { arguments in
|
|
467
|
+
guard case .json(let algorithm)? = arguments[safe: 0], let name = algorithm.string, case .bytes(let data)? = arguments[safe: 1] else { return nil }
|
|
468
|
+
return Self.digest(name, data.data).map { .bytes(HostBytes($0, kind: .uint8)) }
|
|
469
|
+
}
|
|
470
|
+
host.installBytes("hmacBytes") { arguments in
|
|
471
|
+
guard case .json(let algorithm)? = arguments[safe: 0], let name = algorithm.string,
|
|
472
|
+
case .bytes(let key)? = arguments[safe: 1], case .bytes(let data)? = arguments[safe: 2] else { return nil }
|
|
473
|
+
return Self.hmac(name, key: key.data, data.data).map { .bytes(HostBytes($0, kind: .uint8)) }
|
|
474
|
+
}
|
|
475
|
+
host.installBytes("decodeUTF8") { arguments in
|
|
476
|
+
guard case .bytes(let bytes)? = arguments.first else { return .json(.string("")) }
|
|
477
|
+
return .json(.string(String(decoding: bytes.data, as: UTF8.self)))
|
|
478
|
+
}
|
|
479
|
+
host.installBytes("encodeUTF8") { arguments in
|
|
480
|
+
guard case .json(let text)? = arguments.first, let string = text.string else { return nil }
|
|
481
|
+
return .bytes(HostBytes(Data(string.utf8), kind: .uint8))
|
|
482
|
+
}
|
|
483
|
+
}
|
|
427
484
|
|
|
428
485
|
#if !canImport(FoundationBridge)
|
|
429
486
|
host.install("undoState") { [weak self] arguments in
|
|
@@ -526,11 +583,27 @@ final class HostBridge {
|
|
|
526
583
|
/// The WebSocket bridge, made when the app opens its first socket.
|
|
527
584
|
private var webSocketBridge: WebSocketBridge {
|
|
528
585
|
if let webSockets { return webSockets }
|
|
529
|
-
let bridge = WebSocketBridge(session: session) { [weak self] id, event
|
|
586
|
+
let bridge = WebSocketBridge(session: session) { [weak self] id, event, bytes in
|
|
587
|
+
self?.callIn("__sbWebSocketEvent", values: [.json(.number(Double(id))), .json(.string(event.json))] + bytes.map(HostValue.bytes))
|
|
588
|
+
}
|
|
530
589
|
webSockets = bridge
|
|
531
590
|
return bridge
|
|
532
591
|
}
|
|
533
592
|
|
|
593
|
+
/// The typed arrays among a call's arguments, in order.
|
|
594
|
+
private static func bytes(in arguments: ArraySlice<HostValue>) -> [HostBytes] {
|
|
595
|
+
arguments.compactMap { value in
|
|
596
|
+
if case .bytes(let bytes) = value { return bytes }
|
|
597
|
+
return nil
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/// `count` random bytes (at most 64 KiB, as `crypto.getRandomValues` allows).
|
|
602
|
+
private static func random(_ count: Int) -> Data {
|
|
603
|
+
var generator = SystemRandomNumberGenerator()
|
|
604
|
+
return Data((0..<max(0, min(count, 65536))).map { _ in UInt8.random(in: .min ... .max, using: &generator) })
|
|
605
|
+
}
|
|
606
|
+
|
|
534
607
|
// MARK: crypto.subtle and haptics
|
|
535
608
|
|
|
536
609
|
static func digest(_ algorithm: String, _ data: Data) -> Data? {
|
|
@@ -604,11 +677,38 @@ final class HostBridge {
|
|
|
604
677
|
}
|
|
605
678
|
}
|
|
606
679
|
|
|
680
|
+
private func callIn(_ function: String, values: [HostValue]) {
|
|
681
|
+
do {
|
|
682
|
+
try host.call(function, values: values)
|
|
683
|
+
} catch {
|
|
684
|
+
logger.error("\(function): \(String(describing: error), privacy: .public)")
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/// The host requests' bridge, made at the first request. An answer with
|
|
689
|
+
/// bytes goes back as typed arrays after the JSON.
|
|
690
|
+
private func requestBridge() -> HostRequestBridge {
|
|
691
|
+
if let hostRequests { return hostRequests }
|
|
692
|
+
let bridge = HostRequestBridge(
|
|
693
|
+
answer: { [weak self] id, result, bytes in
|
|
694
|
+
self?.callIn("__sbHostResponse", values: [.json(.number(Double(id))), .json(.string(result.json))] + bytes.map(HostValue.bytes))
|
|
695
|
+
},
|
|
696
|
+
filesChanged: { [weak self] urls in self?.callIn("__sbFilesChanged", [.string(JSONValue.array(urls.map(JSONValue.string)).json)]) }
|
|
697
|
+
)
|
|
698
|
+
hostRequests = bridge
|
|
699
|
+
return bridge
|
|
700
|
+
}
|
|
701
|
+
|
|
607
702
|
// MARK: fetch
|
|
608
703
|
|
|
609
|
-
/// `{ url, method, headers: {name: value}, body
|
|
610
|
-
/// `{
|
|
611
|
-
|
|
704
|
+
/// `{ url, method, headers: {name: value}, body, bytes }` in, the body
|
|
705
|
+
/// base64 or `{ "$bytes": 0 }` naming a view in `bytes`, or `bodyParts`
|
|
706
|
+
/// (`fetchStreams`): views and ranges of files on disk, streamed in
|
|
707
|
+
/// order without reading the files into memory;
|
|
708
|
+
/// `{ status, statusText, headers, url, body }` or `{ error }` out, the
|
|
709
|
+
/// body base64, or a `Uint8Array` after the JSON where the request said
|
|
710
|
+
/// `bytes: true` (docs/notes/bytes.md).
|
|
711
|
+
private func fetch(id: Int, request: JSONValue, bytes: [HostBytes] = []) {
|
|
612
712
|
guard let urlString = request["url"].string, let url = URL(string: urlString) else {
|
|
613
713
|
callIn("__sbFetchResult", [.number(Double(id)), .object(["error": .string("invalid URL")])])
|
|
614
714
|
return
|
|
@@ -618,10 +718,28 @@ final class HostBridge {
|
|
|
618
718
|
for (name, value) in request["headers"].object ?? [:] {
|
|
619
719
|
if let value = value.string { urlRequest.setValue(value, forHTTPHeaderField: name) }
|
|
620
720
|
}
|
|
621
|
-
if let
|
|
721
|
+
if let data = request["body"].bytes(from: bytes) {
|
|
622
722
|
urlRequest.httpBody = data
|
|
623
723
|
}
|
|
724
|
+
#if !canImport(FoundationBridge)
|
|
725
|
+
var streamedParts: [BodyPart]?
|
|
726
|
+
if let parts = request["bodyParts"].array {
|
|
727
|
+
do {
|
|
728
|
+
streamedParts = try parts.map { try BodyPart(part: $0, bytes: bytes) }
|
|
729
|
+
} catch {
|
|
730
|
+
callIn("__sbFetchResult", [.number(Double(id)), .object(["error": .string(error.localizedDescription)])])
|
|
731
|
+
return
|
|
732
|
+
}
|
|
733
|
+
urlRequest.httpBodyStream = PartsInputStream(parts: streamedParts!)
|
|
734
|
+
if urlRequest.value(forHTTPHeaderField: "Content-Length") == nil {
|
|
735
|
+
urlRequest.setValue(String(streamedParts!.reduce(0) { $0 + $1.length }), forHTTPHeaderField: "Content-Length")
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
#endif
|
|
739
|
+
let answersBytes = host.carriesBytes && request["bytes"].bool == true
|
|
624
740
|
let task = session.dataTask(with: urlRequest) { [weak self] data, response, error in
|
|
741
|
+
// Cancelled (`fetchCancel`): the app rejected its promise already and hears nothing more.
|
|
742
|
+
if let error, (error as NSError).code == NSURLErrorCancelled, (error as NSError).domain == NSURLErrorDomain { return }
|
|
625
743
|
let result: JSONValue
|
|
626
744
|
if let error {
|
|
627
745
|
result = .object(["error": .string(error.localizedDescription)])
|
|
@@ -636,15 +754,21 @@ final class HostBridge {
|
|
|
636
754
|
"statusText": .string(http.map { HTTPURLResponse.localizedString(forStatusCode: $0.statusCode) } ?? ""),
|
|
637
755
|
"headers": .object(headers),
|
|
638
756
|
"url": .string(response?.url?.absoluteString ?? urlString),
|
|
639
|
-
"body": .string((data ?? Data()).base64EncodedString()),
|
|
757
|
+
"body": answersBytes ? .object(["$bytes": .number(0)]) : .string((data ?? Data()).base64EncodedString()),
|
|
640
758
|
])
|
|
641
759
|
}
|
|
760
|
+
let body = error == nil && answersBytes ? [HostValue.bytes(HostBytes(data ?? Data(), kind: .uint8))] : []
|
|
642
761
|
DispatchQueue.main.async { [weak self] in
|
|
643
762
|
MainActor.assumeIsolated {
|
|
644
|
-
self?.
|
|
763
|
+
self?.fetchTasks.removeValue(forKey: id)
|
|
764
|
+
self?.callIn("__sbFetchResult", values: [.json(.number(Double(id))), .json(result)] + body)
|
|
645
765
|
}
|
|
646
766
|
}
|
|
647
767
|
}
|
|
768
|
+
fetchTasks[id] = task
|
|
769
|
+
#if !canImport(FoundationBridge)
|
|
770
|
+
if let streamedParts { bodyStreams.register(task, parts: streamedParts) }
|
|
771
|
+
#endif
|
|
648
772
|
task.resume()
|
|
649
773
|
}
|
|
650
774
|
}
|
|
@@ -28,19 +28,25 @@ import UniformTypeIdentifiers
|
|
|
28
28
|
/// are the web renderer's (docs/ops-protocol.md): `share` (`title`, `text`,
|
|
29
29
|
/// `url`, `files`: a share sheet; `{ outcome }`), `pickFiles` (`accept`,
|
|
30
30
|
/// `multiple`: the photo picker for pictures and videos, else the document
|
|
31
|
-
/// picker
|
|
31
|
+
/// picker, or with `public.folder` a folder's files, named by their paths in
|
|
32
|
+
/// it; `{ files: [{ name, type, data }] }`), `clipboard` (`write` with
|
|
32
33
|
/// `text` or `files`, `readText`; `{ text }`), `pickColor` (`color`: the
|
|
33
34
|
/// color picker; `{ color }`), `webAuth` (`url`, `callbackScheme`,
|
|
34
35
|
/// `ephemeral`: `ASWebAuthenticationSession`; `{ url }`) and, on iOS,
|
|
35
36
|
/// `audioRecord` (`action`: the microphone with `AVAudioRecorder`,
|
|
36
|
-
/// AudioRecorderBridge.swift)
|
|
37
|
+
/// AudioRecorderBridge.swift); `audioEngine` (the app's `AVAudioEngine`
|
|
38
|
+
/// commands, AudioEngineBridge.swift) on iOS and macOS, handled as each
|
|
39
|
+
/// arrives rather than on a later turn, so they keep their order and a note
|
|
40
|
+
/// sounds at once; `languageModel` (the app's `LanguageModelSession`s on
|
|
41
|
+
/// FoundationModels where Apple Intelligence answers, else on the app's
|
|
42
|
+
/// `LanguageModels.fallback`; LanguageModelBridge.swift). `app:<name>` is a function the Swift app
|
|
37
43
|
/// registered with `NativeFunctions` (Runtime/NativeFunctions.swift), the
|
|
38
44
|
/// escape hatch's function half, on every platform. Against the shim
|
|
39
45
|
/// (the round trip) the page answers the first four itself, and the sign-in
|
|
40
46
|
/// runs on the shim's session; the rest answers `unsupported`.
|
|
41
47
|
@MainActor
|
|
42
48
|
final class HostRequestBridge {
|
|
43
|
-
private let answer: (Int, JSONValue) -> Void
|
|
49
|
+
private let answer: (Int, JSONValue, [HostBytes]) -> Void
|
|
44
50
|
/// Delegates and sessions kept alive until they answer.
|
|
45
51
|
private var active: [ObjectIdentifier: AnyObject] = [:]
|
|
46
52
|
#if os(iOS) && !canImport(FoundationBridge)
|
|
@@ -52,16 +58,20 @@ final class HostRequestBridge {
|
|
|
52
58
|
private lazy var appleSignIn = AppleSignInBridge()
|
|
53
59
|
#endif
|
|
54
60
|
#if !canImport(FoundationBridge)
|
|
61
|
+
/// The app's audio engines (`audioEngine`).
|
|
62
|
+
private lazy var audioEngine = AudioEngineBridge()
|
|
55
63
|
/// The app's files (`files`) for `fileManager`.
|
|
56
64
|
private let files = FilesBridge()
|
|
57
65
|
/// iCloud Drive's changes, watched once the app asks for the container (ICloudBridge.swift).
|
|
58
66
|
private var iCloud: ICloudWatcher?
|
|
67
|
+
/// The language model's sessions (`languageModel`, LanguageModelBridge.swift).
|
|
68
|
+
private lazy var languageModel = LanguageModelBridge()
|
|
59
69
|
#endif
|
|
60
70
|
/// Hands the URLs iCloud Drive changed to the app (`__sbFilesChanged`).
|
|
61
71
|
private let filesChanged: ([String]) -> Void
|
|
62
72
|
|
|
63
73
|
/// `answer` hands a request's result to the app; `filesChanged` the URLs another device changed in iCloud Drive.
|
|
64
|
-
init(answer: @escaping (Int, JSONValue) -> Void, filesChanged: @escaping ([String]) -> Void = { _ in }) {
|
|
74
|
+
init(answer: @escaping (Int, JSONValue, [HostBytes]) -> Void, filesChanged: @escaping ([String]) -> Void = { _ in }) {
|
|
65
75
|
self.answer = answer
|
|
66
76
|
self.filesChanged = filesChanged
|
|
67
77
|
}
|
|
@@ -71,16 +81,36 @@ final class HostRequestBridge {
|
|
|
71
81
|
#if !canImport(FoundationBridge)
|
|
72
82
|
iCloud?.stop()
|
|
73
83
|
iCloud = nil
|
|
84
|
+
audioEngine.stop()
|
|
85
|
+
languageModel.stop()
|
|
74
86
|
#endif
|
|
75
87
|
}
|
|
76
88
|
|
|
77
|
-
|
|
89
|
+
/// Performs the request; `bytes` are the typed arrays sent beside the
|
|
90
|
+
/// JSON (`hostRequestBytes`), which it names as `{ "$bytes": n }`, and
|
|
91
|
+
/// `answersBytes` says the app reads an answer's bytes the same way.
|
|
92
|
+
func perform(id: Int, kind: String, json: String, bytes: [HostBytes] = [], answersBytes: Bool = false) {
|
|
78
93
|
let params = (try? JSONValue.parse(json))?.object ?? [:]
|
|
94
|
+
#if !canImport(FoundationBridge)
|
|
95
|
+
if kind == "audioEngine" {
|
|
96
|
+
audioEngine.perform(params, bytes: bytes) { [weak self] result in
|
|
97
|
+
switch result {
|
|
98
|
+
case .success(let value): self?.answer(id, .object(["value": value]), [])
|
|
99
|
+
case .failure(let error): self?.answer(id, .object(["error": .string("\(error)")]), [])
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
#endif
|
|
105
|
+
let carried = RequestBytes(incoming: bytes, answers: answersBytes)
|
|
79
106
|
Task { @MainActor in
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
107
|
+
await RequestBytes.$current.withValue(carried) {
|
|
108
|
+
do {
|
|
109
|
+
let value = try await handle(kind, params)
|
|
110
|
+
answer(id, .object(["value": value]), carried.outgoing)
|
|
111
|
+
} catch {
|
|
112
|
+
answer(id, .object(["error": .string("\(error)")]), [])
|
|
113
|
+
}
|
|
84
114
|
}
|
|
85
115
|
}
|
|
86
116
|
}
|
|
@@ -94,10 +124,18 @@ final class HostRequestBridge {
|
|
|
94
124
|
case "exportFiles": return try await exportFiles(params)
|
|
95
125
|
case "clipboard": return try clipboard(params)
|
|
96
126
|
case "pickColor": return try await pickColor(params)
|
|
127
|
+
case "languageModel": return try await languageModel.handle(params)
|
|
97
128
|
case "files":
|
|
98
129
|
let value = try await files.handle(params)
|
|
99
130
|
// The container resolved: from now on its changes reach the app.
|
|
100
131
|
if params["action"]?.string == "iCloud", value != .null, iCloud == nil { iCloud = ICloudWatcher(send: filesChanged) }
|
|
132
|
+
// What the app itself wrote, made, moved or removed: `fileManager.watch` hears it, as the page's host tells its watchers.
|
|
133
|
+
switch params["action"]?.string {
|
|
134
|
+
case "write", "remove", "createDirectory": if let url = params["url"]?.string { filesChanged([url]) }
|
|
135
|
+
case "move", "copy": filesChanged([params["from"]?.string, params["to"]?.string].compactMap { $0 })
|
|
136
|
+
case "importItem": if let url = params["to"]?.string { filesChanged([url]) }
|
|
137
|
+
default: break
|
|
138
|
+
}
|
|
101
139
|
return value
|
|
102
140
|
#endif
|
|
103
141
|
#if canImport(AuthenticationServices) && !canImport(FoundationBridge)
|
|
@@ -172,9 +210,14 @@ final class HostRequestBridge {
|
|
|
172
210
|
let folder = FileManager.default.temporaryDirectory.appendingPathComponent("swiftbrowser-share-\(UUID().uuidString)", isDirectory: true)
|
|
173
211
|
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
|
174
212
|
return try files.compactMap { file -> URL? in
|
|
175
|
-
guard let encoded = file["data"].string, let data = Data(base64Encoded: encoded) else { return nil }
|
|
176
213
|
let name = file["name"].string.flatMap { $0.isEmpty ? nil : $0 } ?? "file"
|
|
177
214
|
let url = folder.appendingPathComponent(name.components(separatedBy: "/").last ?? name)
|
|
215
|
+
// A picked file still on disk (its `url`) is copied; bytes are written.
|
|
216
|
+
if let source = file["url"].string.flatMap(URL.init(string:)), source.isFileURL, PickedFiles.shared.contains(source) {
|
|
217
|
+
try PickedFiles.shared.accessing(source) { try FileManager.default.copyItem(at: source, to: url) }
|
|
218
|
+
return url
|
|
219
|
+
}
|
|
220
|
+
guard let data = RequestBytes.data(file["data"]) else { return nil }
|
|
178
221
|
try data.write(to: url)
|
|
179
222
|
return url
|
|
180
223
|
}
|
|
@@ -184,8 +227,43 @@ final class HostRequestBridge {
|
|
|
184
227
|
UTType(filenameExtension: url.pathExtension)?.preferredMIMEType ?? "application/octet-stream"
|
|
185
228
|
}
|
|
186
229
|
|
|
187
|
-
|
|
188
|
-
|
|
230
|
+
/// A file still on disk, as the page's `LazyFile` reads it: its URL and size, no bytes (docs/notes/bytes.md).
|
|
231
|
+
static func fileReference(name: String, url: URL) -> JSONValue {
|
|
232
|
+
let values = try? url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey])
|
|
233
|
+
var value: JSONObject = [
|
|
234
|
+
"name": .string(name),
|
|
235
|
+
"type": .string(mimeType(of: url)),
|
|
236
|
+
"url": .string(url.standardizedFileURL.absoluteString),
|
|
237
|
+
"size": .number(Double(values?.fileSize ?? 0)),
|
|
238
|
+
]
|
|
239
|
+
if let modified = values?.contentModificationDate { value["lastModified"] = .number((modified.timeIntervalSince1970 * 1000).rounded()) }
|
|
240
|
+
return .object(value)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/// A chosen folder's files, at any depth, that `types` allow (any when none), each named by its path from the
|
|
244
|
+
/// folder, the folder's own name first (`Bank/Leads/Saw.fxp`), as the page's folder chooser names them. The
|
|
245
|
+
/// files stay on disk: the folder's security scope is kept open (PickedFiles) and the page reads what it needs.
|
|
246
|
+
static func folderFiles(_ folder: URL, types: [UTType]) -> [JSONValue] {
|
|
247
|
+
let scoped = folder.startAccessingSecurityScopedResource()
|
|
248
|
+
defer { if scoped { folder.stopAccessingSecurityScopedResource() } }
|
|
249
|
+
let root = folder.resolvingSymlinksInPath().standardizedFileURL.pathComponents
|
|
250
|
+
guard let walker = FileManager.default.enumerator(at: folder, includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) else { return [] }
|
|
251
|
+
var files: [JSONValue] = []
|
|
252
|
+
var urls: [URL] = []
|
|
253
|
+
for case let url as URL in walker {
|
|
254
|
+
guard (try? url.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true else { continue }
|
|
255
|
+
if !types.isEmpty {
|
|
256
|
+
guard let type = UTType(filenameExtension: url.pathExtension), types.contains(where: { type.conforms(to: $0) }) else { continue }
|
|
257
|
+
}
|
|
258
|
+
let components = url.resolvingSymlinksInPath().standardizedFileURL.pathComponents
|
|
259
|
+
guard components.count > root.count, Array(components.prefix(root.count)) == root else { continue }
|
|
260
|
+
let path = ([folder.lastPathComponent] + components.dropFirst(root.count)).joined(separator: "/")
|
|
261
|
+
files.append(fileReference(name: path, url: url))
|
|
262
|
+
urls.append(url)
|
|
263
|
+
}
|
|
264
|
+
// Scoped again for as long as the app reads the files: `accessing` opens it per read, `release` ends it.
|
|
265
|
+
PickedFiles.shared.register(urls, scoped: scoped ? folder : nil)
|
|
266
|
+
return files
|
|
189
267
|
}
|
|
190
268
|
|
|
191
269
|
#if canImport(UIKit)
|
|
@@ -255,16 +333,18 @@ final class HostRequestBridge {
|
|
|
255
333
|
continuation.resume(returning: urls)
|
|
256
334
|
}
|
|
257
335
|
keep(delegate)
|
|
258
|
-
|
|
259
|
-
picker
|
|
336
|
+
// A folder is opened in place (a folder cannot be copied in), its files read while its scope is open.
|
|
337
|
+
let picker = accept.folder
|
|
338
|
+
? UIDocumentPickerViewController(forOpeningContentTypes: [.folder], asCopy: false)
|
|
339
|
+
: UIDocumentPickerViewController(forOpeningContentTypes: accept.types.isEmpty ? [.item] : accept.types, asCopy: true)
|
|
340
|
+
picker.allowsMultipleSelection = multiple && !accept.folder
|
|
260
341
|
picker.delegate = delegate
|
|
261
342
|
presenter.present(picker, animated: true)
|
|
262
343
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
}
|
|
267
|
-
return .object(["files": .array(files)])
|
|
344
|
+
if accept.folder { return .object(["files": .array(urls.first.map { Self.folderFiles($0, types: accept.types) } ?? [])]) }
|
|
345
|
+
// Copied into the app's tmp by the picker (`asCopy: true`): the page gets their URLs and reads them a piece at a time.
|
|
346
|
+
PickedFiles.shared.register(urls)
|
|
347
|
+
return .object(["files": .array(urls.map { Self.fileReference(name: $0.lastPathComponent, url: $0) })])
|
|
268
348
|
}
|
|
269
349
|
|
|
270
350
|
/// `fileExporter`: the files are written to the temporary directory and the
|
|
@@ -295,7 +375,7 @@ final class HostRequestBridge {
|
|
|
295
375
|
pasteboard.string = text
|
|
296
376
|
return .object([:])
|
|
297
377
|
}
|
|
298
|
-
guard let file = params["files"]?.array?.first, let
|
|
378
|
+
guard let file = params["files"]?.array?.first, let data = RequestBytes.data(file["data"]) else {
|
|
299
379
|
throw HostRequestFailure("clipboard: nothing to copy")
|
|
300
380
|
}
|
|
301
381
|
if let image = UIImage(data: data) {
|
|
@@ -336,16 +416,15 @@ final class HostRequestBridge {
|
|
|
336
416
|
private func pickFiles(_ params: JSONObject) async throws -> JSONValue {
|
|
337
417
|
let accept = Self.parseAccept(params["accept"]?.string ?? "")
|
|
338
418
|
let panel = NSOpenPanel()
|
|
339
|
-
panel.allowsMultipleSelection = params["multiple"]?.bool ?? false
|
|
340
|
-
panel.canChooseDirectories =
|
|
341
|
-
|
|
419
|
+
panel.allowsMultipleSelection = !accept.folder && (params["multiple"]?.bool ?? false)
|
|
420
|
+
panel.canChooseDirectories = accept.folder
|
|
421
|
+
panel.canChooseFiles = !accept.folder
|
|
422
|
+
if !accept.folder, !accept.types.isEmpty { panel.allowedContentTypes = accept.types }
|
|
342
423
|
let response = await panel.begin()
|
|
343
424
|
guard response == .OK else { return .object(["files": .array([])]) }
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
}
|
|
348
|
-
return .object(["files": .array(files)])
|
|
425
|
+
if accept.folder { return .object(["files": .array(panel.urls.first.map { Self.folderFiles($0, types: accept.types) } ?? [])]) }
|
|
426
|
+
PickedFiles.shared.register(panel.urls)
|
|
427
|
+
return .object(["files": .array(panel.urls.map { Self.fileReference(name: $0.lastPathComponent, url: $0) })])
|
|
349
428
|
}
|
|
350
429
|
|
|
351
430
|
/// `fileExporter`: a save panel per file; answers `{ outcome: "saved", urls }`, or `cancelled` at the first panel dismissed.
|
|
@@ -354,7 +433,7 @@ final class HostRequestBridge {
|
|
|
354
433
|
guard !files.isEmpty else { throw HostRequestFailure("exportFiles: nothing to export") }
|
|
355
434
|
var saved: [JSONValue] = []
|
|
356
435
|
for file in files {
|
|
357
|
-
guard let
|
|
436
|
+
guard let data = RequestBytes.data(file["data"]) else { continue }
|
|
358
437
|
let panel = NSSavePanel()
|
|
359
438
|
let name = file["name"].string.flatMap { $0.isEmpty ? nil : $0 } ?? "file"
|
|
360
439
|
panel.nameFieldStringValue = name.components(separatedBy: "/").last ?? name
|
|
@@ -375,7 +454,7 @@ final class HostRequestBridge {
|
|
|
375
454
|
pasteboard.setString(text, forType: .string)
|
|
376
455
|
return .object([:])
|
|
377
456
|
}
|
|
378
|
-
guard let file = params["files"]?.array?.first, let
|
|
457
|
+
guard let file = params["files"]?.array?.first, let data = RequestBytes.data(file["data"]) else {
|
|
379
458
|
throw HostRequestFailure("clipboard: nothing to copy")
|
|
380
459
|
}
|
|
381
460
|
if let image = NSImage(data: data) {
|
|
@@ -391,12 +470,15 @@ final class HostRequestBridge {
|
|
|
391
470
|
}
|
|
392
471
|
#endif
|
|
393
472
|
|
|
394
|
-
/// What a chooser's `accept` asks for: the content types, and whether they are all pictures or videos (the
|
|
395
|
-
|
|
396
|
-
|
|
473
|
+
/// What a chooser's `accept` asks for: the content types, and whether they are all pictures or videos (the
|
|
474
|
+
/// photo library's), or a folder (`public.folder`, `UTType.folder`), its files then those the other types allow.
|
|
475
|
+
static func parseAccept(_ accept: String) -> (types: [UTType], photos: Bool, images: Bool, videos: Bool, folder: Bool) {
|
|
476
|
+
var entries = accept.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
|
|
477
|
+
let folder = entries.contains(UTType.folder.identifier)
|
|
478
|
+
entries.removeAll { $0 == UTType.folder.identifier }
|
|
397
479
|
let images = entries.contains { $0.hasPrefix("image/") }
|
|
398
480
|
let videos = entries.contains { $0.hasPrefix("video/") }
|
|
399
|
-
let photos = !entries.isEmpty && entries.allSatisfy { $0.hasPrefix("image/") || $0.hasPrefix("video/") }
|
|
481
|
+
let photos = !folder && !entries.isEmpty && entries.allSatisfy { $0.hasPrefix("image/") || $0.hasPrefix("video/") }
|
|
400
482
|
let types = entries.compactMap { entry -> UTType? in
|
|
401
483
|
if entry.hasPrefix(".") { return UTType(filenameExtension: String(entry.dropFirst())) }
|
|
402
484
|
switch entry {
|
|
@@ -406,7 +488,7 @@ final class HostRequestBridge {
|
|
|
406
488
|
default: return UTType(mimeType: entry)
|
|
407
489
|
}
|
|
408
490
|
}
|
|
409
|
-
return (types, photos, images, videos)
|
|
491
|
+
return (types, photos, images, videos, folder)
|
|
410
492
|
}
|
|
411
493
|
|
|
412
494
|
/// A picked photo or video as a file: its first picture (or movie) representation, named after the item.
|
|
@@ -416,14 +498,31 @@ final class HostRequestBridge {
|
|
|
416
498
|
?? identifiers.first(where: { UTType($0)?.conforms(to: .movie) == true })
|
|
417
499
|
?? identifiers.first else { return nil }
|
|
418
500
|
let type = UTType(identifier)
|
|
419
|
-
let data: Data? = await withCheckedContinuation { continuation in
|
|
420
|
-
_ = provider.loadDataRepresentation(forTypeIdentifier: identifier) { data, _ in continuation.resume(returning: data) }
|
|
421
|
-
}
|
|
422
|
-
guard let data else { return nil }
|
|
423
501
|
let ext = type?.preferredFilenameExtension ?? "bin"
|
|
424
502
|
let suggested = provider.suggestedName ?? "photo"
|
|
425
503
|
let name = suggested.contains(".") ? suggested : "\(suggested).\(ext)"
|
|
426
|
-
|
|
504
|
+
// The file the library hands over lives only while the completion runs: copied into a tmp folder of
|
|
505
|
+
// its own, registered (PickedFiles) and answered as a reference, so the page reads it a piece at a time.
|
|
506
|
+
let folder = FileManager.default.temporaryDirectory.appendingPathComponent("swiftbrowser-picks-\(UUID().uuidString)", isDirectory: true)
|
|
507
|
+
let copied: URL? = await withCheckedContinuation { continuation in
|
|
508
|
+
_ = provider.loadFileRepresentation(forTypeIdentifier: identifier) { url, _ in
|
|
509
|
+
guard let url else {
|
|
510
|
+
continuation.resume(returning: nil)
|
|
511
|
+
return
|
|
512
|
+
}
|
|
513
|
+
do {
|
|
514
|
+
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
|
515
|
+
let target = folder.appendingPathComponent(name)
|
|
516
|
+
try FileManager.default.copyItem(at: url, to: target)
|
|
517
|
+
continuation.resume(returning: target)
|
|
518
|
+
} catch {
|
|
519
|
+
continuation.resume(returning: nil)
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
guard let copied else { return nil }
|
|
524
|
+
PickedFiles.shared.register([copied], temporary: folder)
|
|
525
|
+
return fileReference(name: name, url: copied)
|
|
427
526
|
}
|
|
428
527
|
#endif
|
|
429
528
|
}
|