@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
@@ -0,0 +1,195 @@
1
+ #if canImport(SwiftUI) && !canImport(FoundationBridge)
2
+ import Foundation
3
+ import SwiftBrowserProtocol
4
+
5
+ /// A language model an app brings for `LanguageModelSession`
6
+ /// (LanguageModelBridge.swift) where Apple Intelligence cannot answer: set
7
+ /// `LanguageModels.fallback` at launch, and the app's sessions use it
8
+ /// whenever the system model is unavailable for a reason other than still
9
+ /// downloading. SwiftBrowserLocalModel (Native/Packages) is one, running an
10
+ /// open model with llama.cpp; an app that adds no such package pays nothing
11
+ /// for this hook.
12
+ @MainActor
13
+ public protocol LanguageModelProvider: AnyObject {
14
+ /// Whether it can answer now, asked each time the app asks. A provider
15
+ /// whose model is not on the device yet answers `.unavailable(.modelNotReady)`
16
+ /// and says how its download stands in `downloadState`.
17
+ func availability() async -> LanguageModelAvailability
18
+
19
+ /// For a model the app fetches: its size, whether it is on the device,
20
+ /// and how far a download under way has come. Nil (the default) for a
21
+ /// model that is simply there.
22
+ var downloadState: LanguageModelDownload? { get }
23
+
24
+ /// Starts fetching the model, which the viewer asked for (the app shows
25
+ /// the size first). Nothing for a model without a download.
26
+ func startDownload()
27
+
28
+ /// Stops a download under way and removes the model from the device,
29
+ /// freeing its space; it can be fetched again.
30
+ func deleteDownload() async throws
31
+
32
+ /// A conversation with `instructions` followed throughout, and `tools`
33
+ /// the model may call while it answers. Making one should be cheap: load
34
+ /// what it needs on `prewarm` or the first prompt.
35
+ func makeSession(instructions: String?, tools: [LanguageModelTool]) throws -> any LanguageModelProviderSession
36
+
37
+ /// The models it can run, for the app to offer: empty (the default) for
38
+ /// a provider of one model.
39
+ var choices: [LanguageModelChoice] { get }
40
+
41
+ /// The choice that answers, by its `id`.
42
+ var selectedChoice: String? { get }
43
+
44
+ /// Makes the choice with this `id` the one that answers, from the next session on.
45
+ func select(_ id: String)
46
+ }
47
+
48
+ /// One conversation: each prompt follows the ones before it and their responses.
49
+ @MainActor
50
+ public protocol LanguageModelProviderSession: AnyObject {
51
+ /// Loads what the first response needs, ahead of it.
52
+ func prewarm()
53
+
54
+ /// The response to `prompt`, each element the whole response so far.
55
+ /// Ending the iteration (or cancelling the task iterating it) stops the
56
+ /// model. Failures are best thrown as `LanguageModelError`s, whose kinds
57
+ /// the app reads as `GenerationError` cases.
58
+ func streamResponse(to prompt: String, options: LanguageModelOptions) -> AsyncThrowingStream<String, Error>
59
+
60
+ /// The app is done with the conversation.
61
+ func close()
62
+ }
63
+
64
+ @MainActor
65
+ extension LanguageModelProvider {
66
+ /// A conversation without tools.
67
+ public func makeSession(instructions: String?) throws -> any LanguageModelProviderSession {
68
+ try makeSession(instructions: instructions, tools: [])
69
+ }
70
+
71
+ public var choices: [LanguageModelChoice] { [] }
72
+ public var selectedChoice: String? { nil }
73
+ public func select(_ id: String) {}
74
+ public var downloadState: LanguageModelDownload? { nil }
75
+ public func startDownload() {}
76
+ public func deleteDownload() async throws {}
77
+ }
78
+
79
+ /// A function the model may call while it answers (FoundationModels' `Tool`):
80
+ /// its name and what it does, its arguments as a JSON schema (an object of
81
+ /// properties; strings, numbers, integers, booleans, arrays and enums), and
82
+ /// `call`, which runs it (in the app) and answers what the model reads.
83
+ public struct LanguageModelTool: Sendable {
84
+ public let name: String
85
+ public let description: String
86
+ public let parameters: JSONValue
87
+ public let call: @Sendable (JSONValue) async throws -> String
88
+
89
+ public init(name: String, description: String, parameters: JSONValue, call: @escaping @Sendable (JSONValue) async throws -> String) {
90
+ self.name = name
91
+ self.description = description
92
+ self.parameters = parameters
93
+ self.call = call
94
+ }
95
+ }
96
+
97
+ /// A model the app can offer to choose (`LanguageModelProvider.choices`).
98
+ public struct LanguageModelChoice: Sendable, Equatable {
99
+ public var id: String
100
+ public var name: String
101
+ /// A line about it: what it is good at.
102
+ public var summary: String?
103
+ /// Where its download stands, for a model the app fetches.
104
+ public var download: LanguageModelDownload?
105
+
106
+ public init(id: String, name: String, summary: String? = nil, download: LanguageModelDownload? = nil) {
107
+ self.id = id
108
+ self.name = name
109
+ self.summary = summary
110
+ self.download = download
111
+ }
112
+ }
113
+
114
+ /// Where a model the app fetches stands.
115
+ public struct LanguageModelDownload: Sendable, Equatable {
116
+ /// The file's size in bytes, where known.
117
+ public var size: Int64?
118
+ /// Whether the model is on the device.
119
+ public var isDownloaded: Bool
120
+ /// From 0 to 1 while a download is under way; nil when none is.
121
+ public var progress: Double?
122
+ /// Why the last download failed, until the next one starts.
123
+ public var failure: String?
124
+
125
+ public init(size: Int64? = nil, isDownloaded: Bool, progress: Double? = nil, failure: String? = nil) {
126
+ self.size = size
127
+ self.isDownloaded = isDownloaded
128
+ self.progress = progress
129
+ self.failure = failure
130
+ }
131
+ }
132
+
133
+ /// Whether a model can answer, as `SystemLanguageModel.Availability`.
134
+ public enum LanguageModelAvailability: Sendable, Equatable {
135
+ case available
136
+ case unavailable(Reason)
137
+
138
+ public enum Reason: String, Sendable {
139
+ case deviceNotEligible
140
+ case appleIntelligenceNotEnabled
141
+ case modelNotReady
142
+ }
143
+ }
144
+
145
+ /// `GenerationOptions`, as the app sends them.
146
+ public struct LanguageModelOptions: Sendable, Equatable {
147
+ public var temperature: Double?
148
+ public var maximumResponseTokens: Int?
149
+ /// Always the likeliest token, rather than sampling.
150
+ public var greedy: Bool
151
+
152
+ public init(temperature: Double? = nil, maximumResponseTokens: Int? = nil, greedy: Bool = false) {
153
+ self.temperature = temperature
154
+ self.maximumResponseTokens = maximumResponseTokens
155
+ self.greedy = greedy
156
+ }
157
+ }
158
+
159
+ /// A failed response, by the `GenerationError` case the app reads it as.
160
+ public struct LanguageModelError: Error, CustomStringConvertible, Sendable {
161
+ public enum Kind: String, Sendable {
162
+ /// `assetsUnavailable`
163
+ case unavailable
164
+ /// `exceededContextWindowSize`
165
+ case context
166
+ /// `guardrailViolation`
167
+ case guardrail
168
+ /// `unsupportedLanguageOrLocale`
169
+ case language
170
+ /// `concurrentRequests`
171
+ case concurrent
172
+ case rateLimited
173
+ case refusal
174
+ }
175
+
176
+ public let kind: Kind
177
+ public let message: String
178
+
179
+ public init(_ kind: Kind, _ message: String) {
180
+ self.kind = kind
181
+ self.message = message
182
+ }
183
+
184
+ /// The tagged message the app reads the case from (`context: …`).
185
+ public var description: String { "\(kind.rawValue): \(message)" }
186
+ }
187
+
188
+ /// The models `LanguageModelSession` can use besides Apple Intelligence's.
189
+ public enum LanguageModels {
190
+ /// Used where the system model is unavailable for a reason other than
191
+ /// still downloading (an ineligible device, Apple Intelligence off, a
192
+ /// system before iOS 26). Set it at launch, before the app asks.
193
+ @MainActor public static var fallback: (any LanguageModelProvider)?
194
+ }
195
+ #endif
@@ -10,22 +10,26 @@ import FoundationBridge
10
10
  /// first ping is answered (the handshake is done by then), `wsSend` sends
11
11
  /// text or bytes, `wsClose` closes it. What a socket receives, and how it
12
12
  /// ends, goes to the app as `__sbWebSocketEvent(id, json)`: `open`,
13
- /// `message` (`text` or base64 `binary`), `error` and `close` (`code`,
14
- /// `reason`, `wasClean`). Against the shim (the round trip) the task is the
13
+ /// `message` (`text` or `binary`), `error` and `close` (`code`, `reason`,
14
+ /// `wasClean`). Binary crosses as base64, or, for a socket the app opened
15
+ /// saying it reads bytes, as a `Uint8Array` after the JSON, which names it
16
+ /// `{ "$bytes": 0 }` (docs/notes/bytes.md); `wsSend` takes it either way. Against the shim (the round trip) the task is the
15
17
  /// shim's, over the page's WebSocket.
16
18
  @MainActor
17
19
  final class WebSocketBridge {
18
20
  private let session: URLSession
19
- private let send: (Int, JSONValue) -> Void
21
+ private let send: (Int, JSONValue, [HostBytes]) -> Void
20
22
  private var tasks: [Int: URLSessionWebSocketTask] = [:]
23
+ /// The sockets whose app reads binary messages as bytes.
24
+ private var bytesSockets: Set<Int> = []
21
25
 
22
- /// `send` hands an event for socket `id` to the app.
23
- init(session: URLSession, send: @escaping (Int, JSONValue) -> Void) {
26
+ /// `send` hands an event for socket `id` to the app, with the bytes its JSON names.
27
+ init(session: URLSession, send: @escaping (Int, JSONValue, [HostBytes]) -> Void) {
24
28
  self.session = session
25
29
  self.send = send
26
30
  }
27
31
 
28
- func open(id: Int, url string: String, protocols: [String]) {
32
+ func open(id: Int, url string: String, protocols: [String], bytes: Bool = false) {
29
33
  guard let url = URL(string: string) else {
30
34
  emit(id, ["type": .string("error"), "message": .string("invalid URL")])
31
35
  emit(id, ["type": .string("close"), "code": .number(1006), "reason": .string(""), "wasClean": .bool(false)])
@@ -33,6 +37,7 @@ final class WebSocketBridge {
33
37
  }
34
38
  let task = protocols.isEmpty ? session.webSocketTask(with: url) : session.webSocketTask(with: url, protocols: protocols)
35
39
  tasks[id] = task
40
+ if bytes { bytesSockets.insert(id) }
36
41
  task.resume()
37
42
  task.sendPing { [weak self] error in
38
43
  Task { @MainActor [weak self] in
@@ -47,13 +52,13 @@ final class WebSocketBridge {
47
52
  }
48
53
  }
49
54
 
50
- /// `{ text }` or `{ binary }` (base64).
51
- func send(id: Int, json: String) {
55
+ /// `{ text }` or `{ binary }`: base64, or `{ "$bytes": 0 }` and the view in `bytes`.
56
+ func send(id: Int, json: String, bytes: [HostBytes] = []) {
52
57
  guard let task = tasks[id], let fields = (try? JSONValue.parse(json))?.object else { return }
53
58
  let message: URLSessionWebSocketTask.Message
54
59
  if let text = fields["text"]?.string {
55
60
  message = .string(text)
56
- } else if let encoded = fields["binary"]?.string, let data = Data(base64Encoded: encoded) {
61
+ } else if let data = fields["binary"]?.bytes(from: bytes) {
57
62
  message = .data(data)
58
63
  } else {
59
64
  return
@@ -69,6 +74,7 @@ final class WebSocketBridge {
69
74
 
70
75
  func close(id: Int, code: Int, reason: String) {
71
76
  guard let task = tasks.removeValue(forKey: id) else { return }
77
+ defer { bytesSockets.remove(id) }
72
78
  task.cancel(with: URLSessionWebSocketTask.CloseCode(rawValue: code) ?? .normalClosure, reason: reason.isEmpty ? nil : Data(reason.utf8))
73
79
  emit(id, ["type": .string("close"), "code": .number(Double(code)), "reason": .string(reason), "wasClean": .bool(true)])
74
80
  }
@@ -77,6 +83,7 @@ final class WebSocketBridge {
77
83
  func invalidate() {
78
84
  for task in tasks.values { task.cancel(with: .goingAway, reason: nil) }
79
85
  tasks.removeAll()
86
+ bytesSockets.removeAll()
80
87
  }
81
88
 
82
89
  private func receive(id: Int, task: URLSessionWebSocketTask) {
@@ -87,7 +94,12 @@ final class WebSocketBridge {
87
94
  case .success(let message):
88
95
  switch message {
89
96
  case .string(let text): self.emit(id, ["type": .string("message"), "text": .string(text)])
90
- case .data(let data): self.emit(id, ["type": .string("message"), "binary": .string(data.base64EncodedString())])
97
+ case .data(let data):
98
+ if self.bytesSockets.contains(id) {
99
+ self.send(id, .object(["type": .string("message"), "binary": .object(["$bytes": .number(0)])]), [HostBytes(data, kind: .uint8)])
100
+ } else {
101
+ self.emit(id, ["type": .string("message"), "binary": .string(data.base64EncodedString())])
102
+ }
91
103
  @unknown default: break
92
104
  }
93
105
  self.receive(id: id, task: task)
@@ -101,6 +113,7 @@ final class WebSocketBridge {
101
113
  /// The socket ended: a close the server sent (its code and reason), else an error and an unclean close.
102
114
  private func fail(id: Int, task: URLSessionWebSocketTask, error: any Error) {
103
115
  tasks[id] = nil
116
+ bytesSockets.remove(id)
104
117
  let code = task.closeCode
105
118
  if code != .invalid {
106
119
  let reason = task.closeReason.map { String(decoding: $0, as: UTF8.self) } ?? ""
@@ -112,7 +125,7 @@ final class WebSocketBridge {
112
125
  }
113
126
 
114
127
  private func emit(_ id: Int, _ fields: JSONObject) {
115
- send(id, .object(fields))
128
+ send(id, .object(fields), [])
116
129
  }
117
130
  }
118
131
  #endif
@@ -62,7 +62,8 @@ final class WorkerHost: @unchecked Sendable {
62
62
  // MARK: On the worker's thread
63
63
 
64
64
  private func setUp() {
65
- let context = JSContext(virtualMachine: JSVirtualMachine())!
65
+ // Its own virtual machine, made one at a time with the app's (JavaScriptCoreHost.swift).
66
+ let context = JavaScriptCoreHost.withVirtualMachineLock { JSContext(virtualMachine: JSVirtualMachine())! }
66
67
  context.name = "SwiftBrowser worker \(name)"
67
68
  context.exceptionHandler = { [weak self] _, exception in
68
69
  guard let self else { return }
@@ -85,7 +86,7 @@ final class WorkerHost: @unchecked Sendable {
85
86
  for timer in timers.values { timer.invalidate() }
86
87
  timers.removeAll()
87
88
  context?.exceptionHandler = nil
88
- context = nil
89
+ JavaScriptCoreHost.withVirtualMachineLock { context = nil }
89
90
  }
90
91
 
91
92
  private func install(_ native: JSValue, _ name: String, _ body: @escaping ([JSONValue]) -> JSONValue?) {
@@ -35,7 +35,7 @@ public struct NativeFunctionError: Error, CustomStringConvertible {
35
35
  }
36
36
 
37
37
  /// Swift the app's JavaScript can call: the function half of the escape hatch
38
- /// (docs/native.md; `NativeRegistry` is the view half). The Swift app
38
+ /// (docs/native.md; `ViewRegistry` is the view half). The Swift app
39
39
  /// registers functions once, before the app loads, and `@swiftbrowser/native`
40
40
  /// calls them: `callNative(name, params)` resolves with what an async function
41
41
  /// returns, through the host request `app:<name>` (HostRequestBridge.swift);
@@ -0,0 +1,57 @@
1
+ #if canImport(SwiftUI)
2
+ import Foundation
3
+ import SwiftBrowserProtocol
4
+ import SwiftBrowserTree
5
+ import SwiftUI
6
+
7
+ /// A view a package or the app draws itself, by name: the `registered`
8
+ /// element kind (docs/native.md), which `RegisteredView` in
9
+ /// @swiftbrowser/react emits. A page registers its own element under the
10
+ /// same name (@swiftbrowser/web's `ViewRegistry`), so one component is a
11
+ /// SwiftUI view here and the package's DOM there, drawing what never crosses
12
+ /// the bridge (an audio node's frames, a map's tiles).
13
+ ///
14
+ /// ```swift
15
+ /// ViewRegistry.register("MapView") { context in
16
+ /// MapView(region: context.props["region"]) { context.send(["tapped": true]) }
17
+ /// }
18
+ /// ```
19
+ ///
20
+ /// ```tsx
21
+ /// <RegisteredView name="MapView" props={{ region }} onEvent={(value) => …} />
22
+ /// ```
23
+ @MainActor
24
+ public enum ViewRegistry {
25
+ /// What a registered view gets: the element's `props` as the app wrote
26
+ /// them, its children (SwiftUI views the app put inside it) and a way to
27
+ /// send a value back, which reaches the element's `onEvent` in JavaScript.
28
+ public struct Context {
29
+ public let id: Int
30
+ public let props: JSONValue
31
+ public let children: [ElementNode]
32
+ public let send: (JSONValue) -> Void
33
+
34
+ /// The children as a view (built on the main actor, as views are).
35
+ @MainActor public var content: some View {
36
+ ForEach(children) { ElementView(node: $0) }
37
+ }
38
+ }
39
+
40
+ public typealias Builder = (Context) -> AnyView
41
+ private static var builders: [String: Builder] = [:]
42
+
43
+ public static func register<V: View>(_ name: String, @ViewBuilder _ builder: @escaping (Context) -> V) {
44
+ builders[name] = { AnyView(builder($0)) }
45
+ }
46
+
47
+ public static func unregister(_ name: String) {
48
+ builders[name] = nil
49
+ }
50
+
51
+ public static func isRegistered(_ name: String) -> Bool { builders[name] != nil }
52
+
53
+ static func view(name: String, context: Context) -> AnyView? {
54
+ builders[name]?(context)
55
+ }
56
+ }
57
+ #endif
@@ -61,6 +61,7 @@ struct ElementView: View {
61
61
  // Lists
62
62
  case "list": return AnyView(ListElement(node: node))
63
63
  case "section": return AnyView(SectionElement(node: node))
64
+ case "disclosuregroup": return AnyView(DisclosureGroupElement(node: node))
64
65
  case "listrow": return AnyView(ListRowElement(node: node))
65
66
  case "swipeactions": return AnyView(SwipeActionsElement(node: node))
66
67
  // Navigation and presentation
@@ -88,8 +89,12 @@ struct ElementView: View {
88
89
  // Shapes and colors
89
90
  case "color": return AnyView(ColorElement(node: node))
90
91
  case "shape": return ShapeElement(node: node).reportingSize(of: node)
92
+ case "canvas": return AnyView(CanvasElement(node: node))
93
+ case "layout": return AnyView(LayoutElement(node: node))
94
+ case "draggable": return AnyView(DraggableElement(node: node))
95
+ case "dropdestination": return AnyView(DropDestinationElement(node: node))
91
96
  // The escape hatch
92
- case "native": return AnyView(NativeElement(node: node))
97
+ case "registered": return AnyView(RegisteredElement(node: node))
93
98
  default: return AnyView(UnsupportedElement(node: node))
94
99
  }
95
100
  }
@@ -130,18 +135,18 @@ struct UnsupportedElement: View {
130
135
  }
131
136
  }
132
137
 
133
- /// The `native` kind: a view the Swift app registered under `name`.
134
- struct NativeElement: View {
138
+ /// The `registered` kind: a view registered under `name` (`ViewRegistry`).
139
+ struct RegisteredElement: View {
135
140
  let node: ElementNode
136
141
  @Environment(\.nativeApp) private var app
137
142
 
138
143
  var body: some View {
139
- let props = NativeProps(props: node.props)
144
+ let props = RegisteredProps(props: node.props)
140
145
  let app = app
141
- let context = NativeViewContext(id: node.id, props: props.props, children: node.children) { value in
142
- app?.dispatch(.native(node.id, name: props.name, value: value))
146
+ let context = ViewRegistry.Context(id: node.id, props: props.props, children: node.children) { value in
147
+ app?.dispatch(.registered(node.id, name: props.name, value: value))
143
148
  }
144
- if let view = NativeRegistry.view(name: props.name, context: context) {
149
+ if let view = ViewRegistry.view(name: props.name, context: context) {
145
150
  view
146
151
  } else {
147
152
  UnsupportedElement(node: node)
@@ -527,7 +527,24 @@ struct SliderElement: View {
527
527
  let range = props.min...max(props.min, props.max)
528
528
  // The drag's start and end, with the value then (`onEditingChanged`).
529
529
  let editing = { (editing: Bool) -> Void in app?.dispatch(.slide(node.id, SliderProps(props: node.props).value, editing: editing)) }
530
- if node.children.isEmpty {
530
+ // The children by what `labels` says each is; the value labels sit at
531
+ // the track's ends (the label itself iOS keeps for accessibility).
532
+ let part = { (name: String) -> ElementNode? in
533
+ guard let index = props.labels.firstIndex(of: name), node.children.indices.contains(index) else { return nil }
534
+ return node.children[index]
535
+ }
536
+ let low = part("min"), high = part("max")
537
+ if low != nil || high != nil {
538
+ let label = part("label")
539
+ let labelView = { label.map { AnyView(ElementView(node: $0)) } ?? AnyView(EmptyView()) }
540
+ let lowView = { low.map { AnyView(ElementView(node: $0)) } ?? AnyView(EmptyView()) }
541
+ let highView = { high.map { AnyView(ElementView(node: $0)) } ?? AnyView(EmptyView()) }
542
+ if let step = props.step, step > 0 {
543
+ Slider(value: value, in: range, step: step, label: labelView, minimumValueLabel: lowView, maximumValueLabel: highView, onEditingChanged: editing)
544
+ } else {
545
+ Slider(value: value, in: range, label: labelView, minimumValueLabel: lowView, maximumValueLabel: highView, onEditingChanged: editing)
546
+ }
547
+ } else if node.children.isEmpty {
531
548
  if let step = props.step, step > 0 {
532
549
  Slider(value: value, in: range, step: step, onEditingChanged: editing)
533
550
  } else {