@swiftbrowser/native 0.18.3 → 0.19.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 (73) hide show
  1. package/README.md +19 -0
  2. package/bin/swiftbrowser-native.mjs +47 -0
  3. package/dist/bridge.d.ts +34 -7
  4. package/dist/bridge.d.ts.map +1 -1
  5. package/dist/bridge.js.map +1 -1
  6. package/dist/encoding.d.ts +13 -0
  7. package/dist/encoding.d.ts.map +1 -1
  8. package/dist/encoding.js +24 -0
  9. package/dist/encoding.js.map +1 -1
  10. package/dist/fetch.d.ts +10 -4
  11. package/dist/fetch.d.ts.map +1 -1
  12. package/dist/fetch.js +19 -11
  13. package/dist/fetch.js.map +1 -1
  14. package/dist/hostRequests.d.ts +22 -6
  15. package/dist/hostRequests.d.ts.map +1 -1
  16. package/dist/hostRequests.js +65 -13
  17. package/dist/hostRequests.js.map +1 -1
  18. package/dist/ios.d.ts +79 -0
  19. package/dist/ios.d.ts.map +1 -0
  20. package/dist/ios.js +181 -0
  21. package/dist/ios.js.map +1 -0
  22. package/dist/runtime.d.ts.map +1 -1
  23. package/dist/runtime.js +16 -7
  24. package/dist/runtime.js.map +1 -1
  25. package/dist/subtle.d.ts +1 -1
  26. package/dist/subtle.d.ts.map +1 -1
  27. package/dist/subtle.js +9 -5
  28. package/dist/subtle.js.map +1 -1
  29. package/dist/webSocket.d.ts +12 -5
  30. package/dist/webSocket.d.ts.map +1 -1
  31. package/dist/webSocket.js +24 -10
  32. package/dist/webSocket.js.map +1 -1
  33. package/package.json +7 -3
  34. package/src/bridge.ts +30 -7
  35. package/src/encoding.ts +22 -0
  36. package/src/fetch.ts +18 -13
  37. package/src/hostRequests.ts +72 -19
  38. package/src/ios.ts +253 -0
  39. package/src/runtime.ts +23 -8
  40. package/src/subtle.ts +10 -6
  41. package/src/webSocket.ts +27 -12
  42. package/swift/Packages/SwiftBrowserDSP/Package.swift +31 -0
  43. package/swift/Packages/SwiftBrowserDSP/README.md +42 -0
  44. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ProgramNode.swift +203 -0
  45. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Machine.swift +523 -0
  46. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Program.swift +188 -0
  47. package/swift/Packages/SwiftBrowserLocalModel/Package.swift +35 -0
  48. package/swift/Packages/SwiftBrowserLocalModel/README.md +66 -0
  49. package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LlamaEngine.swift +223 -0
  50. package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LocalLanguageModel.swift +409 -0
  51. package/swift/Sources/SwiftBrowserNative/Host/AudioEngineBridge.swift +921 -0
  52. package/swift/Sources/SwiftBrowserNative/Host/AudioEngineNodes.swift +70 -0
  53. package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +3 -3
  54. package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +106 -28
  55. package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +43 -13
  56. package/swift/Sources/SwiftBrowserNative/Host/JSHost.swift +116 -0
  57. package/swift/Sources/SwiftBrowserNative/Host/JavaScriptCoreHost.swift +141 -2
  58. package/swift/Sources/SwiftBrowserNative/Host/LanguageModelBridge.swift +511 -0
  59. package/swift/Sources/SwiftBrowserNative/Host/LanguageModelProvider.swift +195 -0
  60. package/swift/Sources/SwiftBrowserNative/Host/WebSocketBridge.swift +24 -11
  61. package/swift/Sources/SwiftBrowserNative/Host/WorkerHost.swift +3 -2
  62. package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +1 -0
  63. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +18 -1
  64. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Lists.swift +41 -1
  65. package/swift/Sources/SwiftBrowserNative/Views/Kinds/More.swift +88 -17
  66. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +81 -20
  67. package/swift/Sources/SwiftBrowserNative/Views/Kinds/PinchTracker.swift +200 -0
  68. package/swift/Sources/SwiftBrowserNative/Views/Kinds/PopTap.swift +100 -0
  69. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +3 -2
  70. package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +4 -0
  71. package/swift/Sources/SwiftBrowserProtocol/Props.swift +26 -0
  72. package/swift/Sources/SwiftBrowserTree/OpTree.swift +25 -0
  73. package/wasm/SwiftBrowserNative.wasm +2 -2
@@ -0,0 +1,70 @@
1
+ #if canImport(SwiftUI) && !canImport(FoundationBridge)
2
+ import AVFoundation
3
+ import SwiftBrowserProtocol
4
+
5
+ /// A node of a kind a package adds to the app's `AVAudioEngine`
6
+ /// (AudioEngineBridge.swift): SwiftBrowserDSP's `program`, a synthesizer
7
+ /// rendered on the audio thread. The bridge attaches `node`, gives it the
8
+ /// values the app attaches it with and sets, and hands it the notes a
9
+ /// sampler would take; volume and pan go to the node's `AVAudioMixing`
10
+ /// first, as for the engine's own nodes. An app that adds no such package
11
+ /// pays nothing for this hook.
12
+ @MainActor
13
+ public protocol AudioEngineCustomNode: AnyObject {
14
+ /// What the engine attaches and connects: a source takes no input.
15
+ var node: AVAudioNode { get }
16
+
17
+ /// A property, from `attach`'s values or a `set`: false for one it does
18
+ /// not have. Throws a `HostRequestFailure`-shaped error (`kind: detail`)
19
+ /// for a value it cannot take.
20
+ func set(_ key: String, _ value: JSONValue) throws -> Bool
21
+
22
+ /// `startNote`, `stopNote`, `controller` or `pitchBend`, with the
23
+ /// command's params (`note`, `velocity`, `controller`, `value`,
24
+ /// `channel`): false for a node that takes no notes.
25
+ func note(_ action: String, _ params: JSONObject) throws -> Bool
26
+
27
+ /// The engine stopped or reset, or the node was detached: stop sounding.
28
+ func halt()
29
+ }
30
+
31
+ public extension AudioEngineCustomNode {
32
+ func note(_ action: String, _ params: JSONObject) throws -> Bool { false }
33
+ func halt() {}
34
+ }
35
+
36
+ /// What a package's node is made with.
37
+ public struct AudioEngineNodeContext {
38
+ /// The format to render in: stereo at the engine's output rate (or the
39
+ /// offline rendering's format), the one the engine's players send in.
40
+ public let format: AVAudioFormat
41
+ }
42
+
43
+ /// The node kinds packages add, by the name the app attaches them with
44
+ /// (@swiftbrowser/web's `registerAudioNodeKind` is the page's side). Register
45
+ /// at launch, before the bundle loads.
46
+ @MainActor
47
+ public enum AudioEngineNodes {
48
+ public typealias Make = @MainActor (AudioEngineNodeContext) throws -> any AudioEngineCustomNode
49
+
50
+ private static var kinds: [String: Make] = [:]
51
+
52
+ /// The engine's own kinds, which a package cannot replace.
53
+ private static let own: Set<String> = ["player", "sampler", "mixer", "reverb", "delay", "distortion", "eq", "timePitch"]
54
+
55
+ /// Adds `kind`, replacing one of the same name a package added before.
56
+ public static func register(_ kind: String, _ make: @escaping Make) {
57
+ precondition(!own.contains(kind), "\(kind) is one of the engine's own node kinds")
58
+ kinds[kind] = make
59
+ }
60
+
61
+ /// Whether a package added `kind`.
62
+ public static func isRegistered(_ kind: String) -> Bool {
63
+ kinds[kind] != nil
64
+ }
65
+
66
+ static func make(_ kind: String, _ context: AudioEngineNodeContext) throws -> (any AudioEngineCustomNode)? {
67
+ try kinds[kind].map { try $0(context) }
68
+ }
69
+ }
70
+ #endif
@@ -136,11 +136,11 @@ struct FilesBridge: Sendable {
136
136
  }
137
137
  try await ensureDownloaded(url)
138
138
  let data = try coordinated(url, writing: nil) { try Data(contentsOf: $0) }
139
- return .object(["data": .string(data.base64EncodedString())])
139
+ return .object(["data": RequestBytes.value(data)])
140
140
  case "write":
141
141
  let url = try url(params, "url")
142
- guard let data = Data(base64Encoded: params["data"]?.string ?? "") else {
143
- throw HostRequestFailure("unsupported: files: data is not base64")
142
+ guard let data = RequestBytes.data(params["data"]) else {
143
+ throw HostRequestFailure("unsupported: files: data is neither bytes nor base64")
144
144
  }
145
145
  if isDirectory(url) == true { throw HostRequestFailure("isDirectory: \(url.absoluteString) is a directory") }
146
146
  try requireDirectory(url.deletingLastPathComponent(), for: url)
@@ -27,7 +27,8 @@ 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 |
31
32
  /// | `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
33
  /// | `storageGet/Set/Remove/Keys/Clear` | `localStorage` over `UserDefaults` |
33
34
  /// | `randomBytes(count)` | base64 of random bytes (`crypto.getRandomValues`) |
@@ -38,7 +39,8 @@ import IOKit.pwr_mgt
38
39
  /// | `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
40
  /// | `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
41
  /// | `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)` |
42
+ /// | `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` |
43
+ /// | `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
44
  /// | `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
45
  /// | `haptic(kind)` | `navigator.vibrate`: a feedback generator's `impact`, `selection`, `success`, `warning` or `error` |
44
46
  /// | `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 |
@@ -192,10 +194,13 @@ final class HostBridge {
192
194
  return nil
193
195
  }
194
196
 
195
- host.install("fetch") { [weak self] arguments in
196
- 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)
197
+ // A body as a typed array beside the request (`{ "$bytes": 0 }`), and
198
+ // the response's as one too where the request says `bytes: true`.
199
+ host.installBytes("fetch") { [weak self] arguments in
200
+ guard let self, case .json(let first)? = arguments[safe: 0], let id = first.int else { return nil }
201
+ var request = JSONValue.null
202
+ if case .json(let second)? = arguments[safe: 1] { request = second.string.flatMap { try? JSONValue.parse($0) } ?? second }
203
+ self.fetch(id: id, request: request, bytes: Self.bytes(in: arguments.dropFirst(2)))
199
204
  return nil
200
205
  }
201
206
 
@@ -244,10 +249,7 @@ final class HostBridge {
244
249
  }
245
250
 
246
251
  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())
252
+ .string(Self.random(arguments.first?.int ?? 0).base64EncodedString())
251
253
  }
252
254
 
253
255
  host.install("openURL") { arguments in
@@ -375,12 +377,14 @@ final class HostBridge {
375
377
  host.install("wsOpen") { [weak self] arguments in
376
378
  guard let self, let id = arguments[safe: 0]?.int, let url = arguments[safe: 1]?.string else { return nil }
377
379
  let protocols = arguments[safe: 2]?.array?.compactMap(\.string) ?? []
378
- webSocketBridge.open(id: id, url: url, protocols: protocols)
380
+ // A fourth argument `true`: the app reads binary messages as bytes (and the engine carries them).
381
+ webSocketBridge.open(id: id, url: url, protocols: protocols, bytes: host.carriesBytes && arguments[safe: 3]?.bool == true)
379
382
  return nil
380
383
  }
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)
384
+ host.installBytes("wsSend") { [weak self] arguments in
385
+ guard let self, case .json(let first)? = arguments[safe: 0], let id = first.int,
386
+ case .json(let second)? = arguments[safe: 1], let json = second.string else { return nil }
387
+ webSocketBridge.send(id: id, json: json, bytes: Self.bytes(in: arguments.dropFirst(2)))
384
388
  return nil
385
389
  }
386
390
  host.install("wsClose") { [weak self] arguments in
@@ -415,15 +419,46 @@ final class HostBridge {
415
419
 
416
420
  host.install("hostRequest") { [weak self] arguments in
417
421
  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 ?? "{}")
422
+ requestBridge().perform(id: id, kind: kind, json: arguments[safe: 2]?.string ?? "{}")
425
423
  return nil
426
424
  }
425
+ // The same, with typed arrays beside the JSON where it says `{ "$bytes": n }` (docs/notes/bytes.md).
426
+ if host.carriesBytes {
427
+ host.installBytes("hostRequestBytes") { [weak self] arguments in
428
+ guard let self, case .json(let first)? = arguments[safe: 0], let id = first.int,
429
+ case .json(let second)? = arguments[safe: 1], let kind = second.string else { return nil }
430
+ var json = "{}"
431
+ if case .json(let third)? = arguments[safe: 2], let text = third.string { json = text }
432
+ requestBridge().perform(id: id, kind: kind, json: json, bytes: Self.bytes(in: arguments.dropFirst(3)), answersBytes: true)
433
+ return nil
434
+ }
435
+
436
+ // Bytes back as typed arrays: random bytes, digests and HMACs
437
+ // (`crypto`, subtle.ts), and UTF-8 both ways for `TextDecoder`,
438
+ // `TextEncoder` and a response's text (encoding.ts), which in
439
+ // JavaScript would be a loop over every byte.
440
+ host.installBytes("randomValues") { arguments in
441
+ guard case .json(let count)? = arguments.first else { return nil }
442
+ return .bytes(HostBytes(Self.random(count.int ?? 0), kind: .uint8))
443
+ }
444
+ host.installBytes("digestBytes") { arguments in
445
+ guard case .json(let algorithm)? = arguments[safe: 0], let name = algorithm.string, case .bytes(let data)? = arguments[safe: 1] else { return nil }
446
+ return Self.digest(name, data.data).map { .bytes(HostBytes($0, kind: .uint8)) }
447
+ }
448
+ host.installBytes("hmacBytes") { arguments in
449
+ guard case .json(let algorithm)? = arguments[safe: 0], let name = algorithm.string,
450
+ case .bytes(let key)? = arguments[safe: 1], case .bytes(let data)? = arguments[safe: 2] else { return nil }
451
+ return Self.hmac(name, key: key.data, data.data).map { .bytes(HostBytes($0, kind: .uint8)) }
452
+ }
453
+ host.installBytes("decodeUTF8") { arguments in
454
+ guard case .bytes(let bytes)? = arguments.first else { return .json(.string("")) }
455
+ return .json(.string(String(decoding: bytes.data, as: UTF8.self)))
456
+ }
457
+ host.installBytes("encodeUTF8") { arguments in
458
+ guard case .json(let text)? = arguments.first, let string = text.string else { return nil }
459
+ return .bytes(HostBytes(Data(string.utf8), kind: .uint8))
460
+ }
461
+ }
427
462
 
428
463
  #if !canImport(FoundationBridge)
429
464
  host.install("undoState") { [weak self] arguments in
@@ -526,11 +561,27 @@ final class HostBridge {
526
561
  /// The WebSocket bridge, made when the app opens its first socket.
527
562
  private var webSocketBridge: WebSocketBridge {
528
563
  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)]) }
564
+ let bridge = WebSocketBridge(session: session) { [weak self] id, event, bytes in
565
+ self?.callIn("__sbWebSocketEvent", values: [.json(.number(Double(id))), .json(.string(event.json))] + bytes.map(HostValue.bytes))
566
+ }
530
567
  webSockets = bridge
531
568
  return bridge
532
569
  }
533
570
 
571
+ /// The typed arrays among a call's arguments, in order.
572
+ private static func bytes(in arguments: ArraySlice<HostValue>) -> [HostBytes] {
573
+ arguments.compactMap { value in
574
+ if case .bytes(let bytes) = value { return bytes }
575
+ return nil
576
+ }
577
+ }
578
+
579
+ /// `count` random bytes (at most 64 KiB, as `crypto.getRandomValues` allows).
580
+ private static func random(_ count: Int) -> Data {
581
+ var generator = SystemRandomNumberGenerator()
582
+ return Data((0..<max(0, min(count, 65536))).map { _ in UInt8.random(in: .min ... .max, using: &generator) })
583
+ }
584
+
534
585
  // MARK: crypto.subtle and haptics
535
586
 
536
587
  static func digest(_ algorithm: String, _ data: Data) -> Data? {
@@ -604,11 +655,36 @@ final class HostBridge {
604
655
  }
605
656
  }
606
657
 
658
+ private func callIn(_ function: String, values: [HostValue]) {
659
+ do {
660
+ try host.call(function, values: values)
661
+ } catch {
662
+ logger.error("\(function): \(String(describing: error), privacy: .public)")
663
+ }
664
+ }
665
+
666
+ /// The host requests' bridge, made at the first request. An answer with
667
+ /// bytes goes back as typed arrays after the JSON.
668
+ private func requestBridge() -> HostRequestBridge {
669
+ if let hostRequests { return hostRequests }
670
+ let bridge = HostRequestBridge(
671
+ answer: { [weak self] id, result, bytes in
672
+ self?.callIn("__sbHostResponse", values: [.json(.number(Double(id))), .json(.string(result.json))] + bytes.map(HostValue.bytes))
673
+ },
674
+ filesChanged: { [weak self] urls in self?.callIn("__sbFilesChanged", [.string(JSONValue.array(urls.map(JSONValue.string)).json)]) }
675
+ )
676
+ hostRequests = bridge
677
+ return bridge
678
+ }
679
+
607
680
  // MARK: fetch
608
681
 
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) {
682
+ /// `{ url, method, headers: {name: value}, body, bytes }` in, the body
683
+ /// base64 or `{ "$bytes": 0 }` naming a view in `bytes`;
684
+ /// `{ status, statusText, headers, url, body }` or `{ error }` out, the
685
+ /// body base64, or a `Uint8Array` after the JSON where the request said
686
+ /// `bytes: true` (docs/notes/bytes.md).
687
+ private func fetch(id: Int, request: JSONValue, bytes: [HostBytes] = []) {
612
688
  guard let urlString = request["url"].string, let url = URL(string: urlString) else {
613
689
  callIn("__sbFetchResult", [.number(Double(id)), .object(["error": .string("invalid URL")])])
614
690
  return
@@ -618,9 +694,10 @@ final class HostBridge {
618
694
  for (name, value) in request["headers"].object ?? [:] {
619
695
  if let value = value.string { urlRequest.setValue(value, forHTTPHeaderField: name) }
620
696
  }
621
- if let body = request["body"].string, let data = Data(base64Encoded: body) {
697
+ if let data = request["body"].bytes(from: bytes) {
622
698
  urlRequest.httpBody = data
623
699
  }
700
+ let answersBytes = host.carriesBytes && request["bytes"].bool == true
624
701
  let task = session.dataTask(with: urlRequest) { [weak self] data, response, error in
625
702
  let result: JSONValue
626
703
  if let error {
@@ -636,12 +713,13 @@ final class HostBridge {
636
713
  "statusText": .string(http.map { HTTPURLResponse.localizedString(forStatusCode: $0.statusCode) } ?? ""),
637
714
  "headers": .object(headers),
638
715
  "url": .string(response?.url?.absoluteString ?? urlString),
639
- "body": .string((data ?? Data()).base64EncodedString()),
716
+ "body": answersBytes ? .object(["$bytes": .number(0)]) : .string((data ?? Data()).base64EncodedString()),
640
717
  ])
641
718
  }
719
+ let body = error == nil && answersBytes ? [HostValue.bytes(HostBytes(data ?? Data(), kind: .uint8))] : []
642
720
  DispatchQueue.main.async { [weak self] in
643
721
  MainActor.assumeIsolated {
644
- self?.callIn("__sbFetchResult", [.number(Double(id)), result])
722
+ self?.callIn("__sbFetchResult", values: [.json(.number(Double(id))), .json(result)] + body)
645
723
  }
646
724
  }
647
725
  }
@@ -33,14 +33,19 @@ import UniformTypeIdentifiers
33
33
  /// color picker; `{ color }`), `webAuth` (`url`, `callbackScheme`,
34
34
  /// `ephemeral`: `ASWebAuthenticationSession`; `{ url }`) and, on iOS,
35
35
  /// `audioRecord` (`action`: the microphone with `AVAudioRecorder`,
36
- /// AudioRecorderBridge.swift). `app:<name>` is a function the Swift app
36
+ /// AudioRecorderBridge.swift); `audioEngine` (the app's `AVAudioEngine`
37
+ /// commands, AudioEngineBridge.swift) on iOS and macOS, handled as each
38
+ /// arrives rather than on a later turn, so they keep their order and a note
39
+ /// sounds at once; `languageModel` (the app's `LanguageModelSession`s on
40
+ /// FoundationModels where Apple Intelligence answers, else on the app's
41
+ /// `LanguageModels.fallback`; LanguageModelBridge.swift). `app:<name>` is a function the Swift app
37
42
  /// registered with `NativeFunctions` (Runtime/NativeFunctions.swift), the
38
43
  /// escape hatch's function half, on every platform. Against the shim
39
44
  /// (the round trip) the page answers the first four itself, and the sign-in
40
45
  /// runs on the shim's session; the rest answers `unsupported`.
41
46
  @MainActor
42
47
  final class HostRequestBridge {
43
- private let answer: (Int, JSONValue) -> Void
48
+ private let answer: (Int, JSONValue, [HostBytes]) -> Void
44
49
  /// Delegates and sessions kept alive until they answer.
45
50
  private var active: [ObjectIdentifier: AnyObject] = [:]
46
51
  #if os(iOS) && !canImport(FoundationBridge)
@@ -52,16 +57,20 @@ final class HostRequestBridge {
52
57
  private lazy var appleSignIn = AppleSignInBridge()
53
58
  #endif
54
59
  #if !canImport(FoundationBridge)
60
+ /// The app's audio engines (`audioEngine`).
61
+ private lazy var audioEngine = AudioEngineBridge()
55
62
  /// The app's files (`files`) for `fileManager`.
56
63
  private let files = FilesBridge()
57
64
  /// iCloud Drive's changes, watched once the app asks for the container (ICloudBridge.swift).
58
65
  private var iCloud: ICloudWatcher?
66
+ /// The language model's sessions (`languageModel`, LanguageModelBridge.swift).
67
+ private lazy var languageModel = LanguageModelBridge()
59
68
  #endif
60
69
  /// Hands the URLs iCloud Drive changed to the app (`__sbFilesChanged`).
61
70
  private let filesChanged: ([String]) -> Void
62
71
 
63
72
  /// `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 }) {
73
+ init(answer: @escaping (Int, JSONValue, [HostBytes]) -> Void, filesChanged: @escaping ([String]) -> Void = { _ in }) {
65
74
  self.answer = answer
66
75
  self.filesChanged = filesChanged
67
76
  }
@@ -71,16 +80,36 @@ final class HostRequestBridge {
71
80
  #if !canImport(FoundationBridge)
72
81
  iCloud?.stop()
73
82
  iCloud = nil
83
+ audioEngine.stop()
84
+ languageModel.stop()
74
85
  #endif
75
86
  }
76
87
 
77
- func perform(id: Int, kind: String, json: String) {
88
+ /// Performs the request; `bytes` are the typed arrays sent beside the
89
+ /// JSON (`hostRequestBytes`), which it names as `{ "$bytes": n }`, and
90
+ /// `answersBytes` says the app reads an answer's bytes the same way.
91
+ func perform(id: Int, kind: String, json: String, bytes: [HostBytes] = [], answersBytes: Bool = false) {
78
92
  let params = (try? JSONValue.parse(json))?.object ?? [:]
93
+ #if !canImport(FoundationBridge)
94
+ if kind == "audioEngine" {
95
+ audioEngine.perform(params, bytes: bytes) { [weak self] result in
96
+ switch result {
97
+ case .success(let value): self?.answer(id, .object(["value": value]), [])
98
+ case .failure(let error): self?.answer(id, .object(["error": .string("\(error)")]), [])
99
+ }
100
+ }
101
+ return
102
+ }
103
+ #endif
104
+ let carried = RequestBytes(incoming: bytes, answers: answersBytes)
79
105
  Task { @MainActor in
80
- do {
81
- answer(id, .object(["value": try await handle(kind, params)]))
82
- } catch {
83
- answer(id, .object(["error": .string("\(error)")]))
106
+ await RequestBytes.$current.withValue(carried) {
107
+ do {
108
+ let value = try await handle(kind, params)
109
+ answer(id, .object(["value": value]), carried.outgoing)
110
+ } catch {
111
+ answer(id, .object(["error": .string("\(error)")]), [])
112
+ }
84
113
  }
85
114
  }
86
115
  }
@@ -94,6 +123,7 @@ final class HostRequestBridge {
94
123
  case "exportFiles": return try await exportFiles(params)
95
124
  case "clipboard": return try clipboard(params)
96
125
  case "pickColor": return try await pickColor(params)
126
+ case "languageModel": return try await languageModel.handle(params)
97
127
  case "files":
98
128
  let value = try await files.handle(params)
99
129
  // The container resolved: from now on its changes reach the app.
@@ -172,7 +202,7 @@ final class HostRequestBridge {
172
202
  let folder = FileManager.default.temporaryDirectory.appendingPathComponent("swiftbrowser-share-\(UUID().uuidString)", isDirectory: true)
173
203
  try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
174
204
  return try files.compactMap { file -> URL? in
175
- guard let encoded = file["data"].string, let data = Data(base64Encoded: encoded) else { return nil }
205
+ guard let data = RequestBytes.data(file["data"]) else { return nil }
176
206
  let name = file["name"].string.flatMap { $0.isEmpty ? nil : $0 } ?? "file"
177
207
  let url = folder.appendingPathComponent(name.components(separatedBy: "/").last ?? name)
178
208
  try data.write(to: url)
@@ -185,7 +215,7 @@ final class HostRequestBridge {
185
215
  }
186
216
 
187
217
  private static func fileValue(name: String, type: String, data: Data) -> JSONValue {
188
- .object(["name": .string(name), "type": .string(type), "data": .string(data.base64EncodedString())])
218
+ .object(["name": .string(name), "type": .string(type), "data": RequestBytes.value(data)])
189
219
  }
190
220
 
191
221
  #if canImport(UIKit)
@@ -295,7 +325,7 @@ final class HostRequestBridge {
295
325
  pasteboard.string = text
296
326
  return .object([:])
297
327
  }
298
- guard let file = params["files"]?.array?.first, let encoded = file["data"].string, let data = Data(base64Encoded: encoded) else {
328
+ guard let file = params["files"]?.array?.first, let data = RequestBytes.data(file["data"]) else {
299
329
  throw HostRequestFailure("clipboard: nothing to copy")
300
330
  }
301
331
  if let image = UIImage(data: data) {
@@ -354,7 +384,7 @@ final class HostRequestBridge {
354
384
  guard !files.isEmpty else { throw HostRequestFailure("exportFiles: nothing to export") }
355
385
  var saved: [JSONValue] = []
356
386
  for file in files {
357
- guard let encoded = file["data"].string, let data = Data(base64Encoded: encoded) else { continue }
387
+ guard let data = RequestBytes.data(file["data"]) else { continue }
358
388
  let panel = NSSavePanel()
359
389
  let name = file["name"].string.flatMap { $0.isEmpty ? nil : $0 } ?? "file"
360
390
  panel.nameFieldStringValue = name.components(separatedBy: "/").last ?? name
@@ -375,7 +405,7 @@ final class HostRequestBridge {
375
405
  pasteboard.setString(text, forType: .string)
376
406
  return .object([:])
377
407
  }
378
- guard let file = params["files"]?.array?.first, let encoded = file["data"].string, let data = Data(base64Encoded: encoded) else {
408
+ guard let file = params["files"]?.array?.first, let data = RequestBytes.data(file["data"]) else {
379
409
  throw HostRequestFailure("clipboard: nothing to copy")
380
410
  }
381
411
  if let image = NSImage(data: data) {
@@ -28,6 +28,122 @@ public protocol JSHost: AnyObject {
28
28
 
29
29
  /// Throws the loaded scripts away: a fresh global object for the next `evaluate`.
30
30
  func reset()
31
+
32
+ /// Whether `installBytes` and `call(_:values:)` carry bytes as typed
33
+ /// arrays. An engine without it is given base64 in JSON instead
34
+ /// (docs/notes/bytes.md): the bridge installs `hostRequestBytes` only here.
35
+ var carriesBytes: Bool { get }
36
+
37
+ /// `install`, the arguments as `HostValue`s: a typed array or an
38
+ /// `ArrayBuffer` arrives as `.bytes`, copied out, anything else as
39
+ /// `.json`; a `.bytes` result goes back as a typed array of its kind.
40
+ func installBytes(_ name: String, _ body: @escaping ([HostValue]) -> HostValue?)
41
+
42
+ /// `call`, a `.bytes` argument made into a typed array of its kind.
43
+ @discardableResult
44
+ func call(_ function: String, values: [HostValue]) throws -> JSONValue
45
+ }
46
+
47
+ /// A JavaScript value at the engine boundary: JSON, or bytes crossing as a
48
+ /// typed array (`Float32Array`, `Uint8Array`, …) or an `ArrayBuffer`.
49
+ public enum HostValue: Sendable {
50
+ case json(JSONValue)
51
+ case bytes(HostBytes)
52
+ }
53
+
54
+ /// Bytes crossing the bridge, and the view they came from or go back as.
55
+ public struct HostBytes: Sendable {
56
+ public var data: Data
57
+ public var kind: BytesKind
58
+ public init(_ data: Data, kind: BytesKind) {
59
+ self.data = data
60
+ self.kind = kind
61
+ }
62
+ }
63
+
64
+ /// The typed arrays JavaScript has, and a bare `ArrayBuffer`.
65
+ public enum BytesKind: String, Sendable {
66
+ case int8, uint8, uint8Clamped, int16, uint16, int32, uint32, float32, float64, bigInt64, bigUint64, arrayBuffer
67
+ }
68
+
69
+ extension JSHost {
70
+ public var carriesBytes: Bool { false }
71
+
72
+ /// An engine without bytes: the arguments are JSON, as `install(_:_:)`
73
+ /// gives them, and a `.bytes` result goes back as base64.
74
+ public func installBytes(_ name: String, _ body: @escaping ([HostValue]) -> HostValue?) {
75
+ install(name, { (arguments: [JSONValue]) -> JSONValue? in
76
+ switch body(arguments.map(HostValue.json)) {
77
+ case .json(let json)?: return json
78
+ case .bytes(let bytes)?: return .string(bytes.data.base64EncodedString())
79
+ case nil: return nil
80
+ }
81
+ })
82
+ }
83
+
84
+ /// An engine without bytes: `.bytes` goes as base64, the form JavaScript
85
+ /// reads where a host has no `hostRequestBytes`.
86
+ @discardableResult
87
+ public func call(_ function: String, values: [HostValue]) throws -> JSONValue {
88
+ try call(function, values.map { value in
89
+ switch value {
90
+ case .json(let json): return json
91
+ case .bytes(let bytes): return .string(bytes.data.base64EncodedString())
92
+ }
93
+ })
94
+ }
95
+ }
96
+
97
+ /// The bytes a host request carries beside its JSON, and those its answer
98
+ /// collects, for the request's task (`RequestBytes.$current`): a bridge reads
99
+ /// a byte field with `data(_:)` and writes one with `value(_:)` without being
100
+ /// handed either list. An app that sent no views (a host without
101
+ /// `hostRequestBytes`, an older bundle) is answered in base64, as before.
102
+ final class RequestBytes: @unchecked Sendable {
103
+ @TaskLocal static var current: RequestBytes?
104
+
105
+ let incoming: [HostBytes]
106
+ /// Whether the app reads an answer's bytes beside its JSON.
107
+ let answers: Bool
108
+ private let lock = NSLock()
109
+ private var collected: [HostBytes] = []
110
+
111
+ init(incoming: [HostBytes], answers: Bool) {
112
+ self.incoming = incoming
113
+ self.answers = answers
114
+ }
115
+
116
+ /// The bytes the answer collected, in the order its JSON names them.
117
+ var outgoing: [HostBytes] {
118
+ lock.lock()
119
+ defer { lock.unlock() }
120
+ return collected
121
+ }
122
+
123
+ /// A request field's bytes: a view sent beside it (`{ "$bytes": n }`), or base64.
124
+ static func data(_ value: JSONValue?) -> Data? {
125
+ value?.bytes(from: Self.current?.incoming ?? [])
126
+ }
127
+
128
+ /// Bytes for an answer's field: a view beside the JSON where the app reads them so, else base64.
129
+ static func value(_ data: Data) -> JSONValue {
130
+ guard let current = Self.current, current.answers else { return .string(data.base64EncodedString()) }
131
+ current.lock.lock()
132
+ defer { current.lock.unlock() }
133
+ current.collected.append(HostBytes(data, kind: .uint8))
134
+ return .object(["$bytes": .number(Double(current.collected.count - 1))])
135
+ }
136
+ }
137
+
138
+ extension JSONValue {
139
+ /// The bytes a request's field holds: `{ "$bytes": n }` names the nth
140
+ /// view sent beside the JSON (`hostRequestBytes`), and a string is base64,
141
+ /// the form a host without bytes is given.
142
+ public func bytes(from bytes: [HostBytes]) -> Data? {
143
+ if let index = self["$bytes"].int { return bytes.indices.contains(index) ? bytes[index].data : nil }
144
+ if let encoded = string { return Data(base64Encoded: encoded) }
145
+ return nil
146
+ }
31
147
  }
32
148
 
33
149
  public struct JSError: Error, CustomStringConvertible {