@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.
Files changed (102) hide show
  1. package/README.md +53 -11
  2. package/bin/swiftbrowser-native.mjs +47 -0
  3. package/dist/blob.d.ts +85 -11
  4. package/dist/blob.d.ts.map +1 -1
  5. package/dist/blob.js +184 -34
  6. package/dist/blob.js.map +1 -1
  7. package/dist/bridge.d.ts +66 -8
  8. package/dist/bridge.d.ts.map +1 -1
  9. package/dist/bridge.js.map +1 -1
  10. package/dist/encoding.d.ts +13 -0
  11. package/dist/encoding.d.ts.map +1 -1
  12. package/dist/encoding.js +24 -0
  13. package/dist/encoding.js.map +1 -1
  14. package/dist/fetch.d.ts +27 -13
  15. package/dist/fetch.d.ts.map +1 -1
  16. package/dist/fetch.js +117 -45
  17. package/dist/fetch.js.map +1 -1
  18. package/dist/hostRequests.d.ts +24 -6
  19. package/dist/hostRequests.d.ts.map +1 -1
  20. package/dist/hostRequests.js +108 -15
  21. package/dist/hostRequests.js.map +1 -1
  22. package/dist/index.d.ts +1 -2
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +1 -2
  25. package/dist/index.js.map +1 -1
  26. package/dist/ios.d.ts +79 -0
  27. package/dist/ios.d.ts.map +1 -0
  28. package/dist/ios.js +181 -0
  29. package/dist/ios.js.map +1 -0
  30. package/dist/runtime.d.ts.map +1 -1
  31. package/dist/runtime.js +16 -7
  32. package/dist/runtime.js.map +1 -1
  33. package/dist/subtle.d.ts +1 -1
  34. package/dist/subtle.d.ts.map +1 -1
  35. package/dist/subtle.js +9 -5
  36. package/dist/subtle.js.map +1 -1
  37. package/dist/webSocket.d.ts +12 -5
  38. package/dist/webSocket.d.ts.map +1 -1
  39. package/dist/webSocket.js +37 -11
  40. package/dist/webSocket.js.map +1 -1
  41. package/package.json +7 -3
  42. package/src/blob.ts +215 -34
  43. package/src/bridge.ts +56 -8
  44. package/src/encoding.ts +22 -0
  45. package/src/fetch.ts +111 -48
  46. package/src/hostRequests.ts +129 -22
  47. package/src/index.ts +1 -2
  48. package/src/ios.ts +253 -0
  49. package/src/runtime.ts +23 -8
  50. package/src/subtle.ts +10 -6
  51. package/src/webSocket.ts +37 -13
  52. package/swift/Packages/SwiftBrowserDSP/Package.swift +31 -0
  53. package/swift/Packages/SwiftBrowserDSP/README.md +42 -0
  54. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ProgramNode.swift +306 -0
  55. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ScopeView.swift +79 -0
  56. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Machine.swift +1561 -0
  57. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Program.swift +483 -0
  58. package/swift/Packages/SwiftBrowserLocalModel/Package.swift +35 -0
  59. package/swift/Packages/SwiftBrowserLocalModel/README.md +66 -0
  60. package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LlamaEngine.swift +223 -0
  61. package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LocalLanguageModel.swift +409 -0
  62. package/swift/Packages/SwiftBrowserShader/Package.swift +31 -0
  63. package/swift/Packages/SwiftBrowserShader/README.md +45 -0
  64. package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShader/ShaderRenderer.swift +103 -0
  65. package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShader/ShaderView.swift +168 -0
  66. package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShaderCore/ShaderProps.swift +120 -0
  67. package/swift/Sources/SwiftBrowserNative/Host/AudioEngineBridge.swift +921 -0
  68. package/swift/Sources/SwiftBrowserNative/Host/AudioEngineNodes.swift +70 -0
  69. package/swift/Sources/SwiftBrowserNative/Host/BodyStream.swift +166 -0
  70. package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +144 -10
  71. package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +151 -27
  72. package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +139 -40
  73. package/swift/Sources/SwiftBrowserNative/Host/JSHost.swift +116 -0
  74. package/swift/Sources/SwiftBrowserNative/Host/JavaScriptCoreHost.swift +141 -2
  75. package/swift/Sources/SwiftBrowserNative/Host/LanguageModelBridge.swift +511 -0
  76. package/swift/Sources/SwiftBrowserNative/Host/LanguageModelProvider.swift +195 -0
  77. package/swift/Sources/SwiftBrowserNative/Host/WebSocketBridge.swift +24 -11
  78. package/swift/Sources/SwiftBrowserNative/Host/WorkerHost.swift +3 -2
  79. package/swift/Sources/SwiftBrowserNative/Runtime/NativeFunctions.swift +1 -1
  80. package/swift/Sources/SwiftBrowserNative/Runtime/ViewRegistry.swift +57 -0
  81. package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +12 -7
  82. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +18 -1
  83. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Drawing.swift +378 -0
  84. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Lists.swift +41 -1
  85. package/swift/Sources/SwiftBrowserNative/Views/Kinds/More.swift +88 -17
  86. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +87 -21
  87. package/swift/Sources/SwiftBrowserNative/Views/Kinds/PinchTracker.swift +200 -0
  88. package/swift/Sources/SwiftBrowserNative/Views/Kinds/PopTap.swift +103 -0
  89. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +3 -2
  90. package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +146 -0
  91. package/swift/Sources/SwiftBrowserProtocol/Drawing.swift +306 -0
  92. package/swift/Sources/SwiftBrowserProtocol/Props.swift +31 -2
  93. package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +24 -3
  94. package/swift/Sources/SwiftBrowserProtocol/Style.swift +87 -0
  95. package/swift/Sources/SwiftBrowserTree/OpTree.swift +25 -0
  96. package/wasm/SwiftBrowserNative.wasm +2 -2
  97. package/dist/native.d.ts +0 -19
  98. package/dist/native.d.ts.map +0 -1
  99. package/dist/native.js +0 -17
  100. package/dist/native.js.map +0 -1
  101. package/src/native.ts +0 -35
  102. 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
- /// | `fetch(id, requestJSON)` | `URLSession`; answers `__sbFetchResult(id, resultJSON)` |
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
- host.install("fetch") { [weak self] arguments in
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
- let request = arguments[safe: 1] ?? .null
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
- let count = max(0, min(arguments.first?.int ?? 0, 65536))
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
- webSocketBridge.open(id: id, url: url, protocols: protocols)
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.install("wsSend") { [weak self] arguments in
382
- guard let self, let id = arguments[safe: 0]?.int, let json = arguments[safe: 1]?.string else { return nil }
383
- webSocketBridge.send(id: id, json: json)
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
- if hostRequests == nil {
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 in self?.callIn("__sbWebSocketEvent", [.number(Double(id)), .string(event.json)]) }
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: base64 | null }` in,
610
- /// `{ status, statusText, headers, url, body: base64 }` or `{ error }` out.
611
- private func fetch(id: Int, request: JSONValue) {
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 body = request["body"].string, let data = Data(base64Encoded: body) {
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?.callIn("__sbFetchResult", [.number(Double(id)), result])
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; `{ files: [{ name, type, data }] }`), `clipboard` (`write` with
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). `app:<name>` is a function the Swift app
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
- func perform(id: Int, kind: String, json: String) {
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
- do {
81
- answer(id, .object(["value": try await handle(kind, params)]))
82
- } catch {
83
- answer(id, .object(["error": .string("\(error)")]))
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
- private static func fileValue(name: String, type: String, data: Data) -> JSONValue {
188
- .object(["name": .string(name), "type": .string(type), "data": .string(data.base64EncodedString())])
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
- let picker = UIDocumentPickerViewController(forOpeningContentTypes: accept.types.isEmpty ? [.item] : accept.types, asCopy: true)
259
- picker.allowsMultipleSelection = multiple
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
- let files = urls.compactMap { url -> JSONValue? in
264
- guard let data = try? Data(contentsOf: url) else { return nil }
265
- return Self.fileValue(name: url.lastPathComponent, type: Self.mimeType(of: url), data: data)
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 encoded = file["data"].string, let data = Data(base64Encoded: encoded) else {
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 = false
341
- if !accept.types.isEmpty { panel.allowedContentTypes = accept.types }
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
- let files = panel.urls.compactMap { url -> JSONValue? in
345
- guard let data = try? Data(contentsOf: url) else { return nil }
346
- return Self.fileValue(name: url.lastPathComponent, type: Self.mimeType(of: url), data: data)
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 encoded = file["data"].string, let data = Data(base64Encoded: encoded) else { continue }
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 encoded = file["data"].string, let data = Data(base64Encoded: encoded) else {
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 photo library's).
395
- static func parseAccept(_ accept: String) -> (types: [UTType], photos: Bool, images: Bool, videos: Bool) {
396
- let entries = accept.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
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
- return fileValue(name: name, type: type?.preferredMIMEType ?? "application/octet-stream", data: data)
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
  }