@swiftbrowser/native 0.18.2 → 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.
- package/README.md +19 -0
- package/bin/swiftbrowser-native.mjs +47 -0
- package/dist/bridge.d.ts +34 -7
- 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 +10 -4
- package/dist/fetch.d.ts.map +1 -1
- package/dist/fetch.js +19 -11
- package/dist/fetch.js.map +1 -1
- package/dist/hostRequests.d.ts +22 -6
- package/dist/hostRequests.d.ts.map +1 -1
- package/dist/hostRequests.js +65 -13
- package/dist/hostRequests.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 +24 -10
- package/dist/webSocket.js.map +1 -1
- package/package.json +7 -3
- package/src/bridge.ts +30 -7
- package/src/encoding.ts +22 -0
- package/src/fetch.ts +18 -13
- package/src/hostRequests.ts +72 -19
- package/src/ios.ts +253 -0
- package/src/runtime.ts +23 -8
- package/src/subtle.ts +10 -6
- package/src/webSocket.ts +27 -12
- package/swift/Packages/SwiftBrowserDSP/Package.swift +31 -0
- package/swift/Packages/SwiftBrowserDSP/README.md +42 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ProgramNode.swift +203 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Machine.swift +523 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Program.swift +188 -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/Sources/SwiftBrowserNative/Host/AudioEngineBridge.swift +921 -0
- package/swift/Sources/SwiftBrowserNative/Host/AudioEngineNodes.swift +70 -0
- package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +3 -3
- package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +106 -28
- package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +43 -13
- 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/Views/ElementView.swift +1 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +18 -1
- 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 +81 -20
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/PinchTracker.swift +200 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/PopTap.swift +100 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +3 -2
- package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +4 -0
- package/swift/Sources/SwiftBrowserProtocol/Props.swift +26 -0
- package/swift/Sources/SwiftBrowserTree/OpTree.swift +25 -0
- 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": .
|
|
139
|
+
return .object(["data": RequestBytes.value(data)])
|
|
140
140
|
case "write":
|
|
141
141
|
let url = try url(params, "url")
|
|
142
|
-
guard let data =
|
|
143
|
-
throw HostRequestFailure("unsupported: files: data is
|
|
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
|
-
/// | `
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
self.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
382
|
-
guard let self, let
|
|
383
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
610
|
-
/// `{
|
|
611
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
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": .
|
|
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
|
|
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
|
|
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
|
|
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 {
|