@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.
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,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?) {
@@ -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
@@ -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 {
@@ -156,6 +156,7 @@ struct ListRowsView: View {
156
156
  /// children are view headers.
157
157
  struct SectionElement: View {
158
158
  let node: ElementNode
159
+ @Environment(\.nativeApp) private var app
159
160
 
160
161
  var body: some View {
161
162
  let props = SectionProps(props: node.props)
@@ -164,7 +165,24 @@ struct SectionElement: View {
164
165
  let rows = node.children.filter { $0.kind != "sectionheader" && $0.kind != "sectionfooter" }
165
166
  let title = props.header.flatMap { $0.isEmpty ? nil : $0 }
166
167
  let note = props.footer.flatMap { $0.isEmpty ? nil : $0 }
167
- if header != nil || footer != nil {
168
+ if props.collapsible, #available(iOS 17, macOS 14, *) {
169
+ // `Section(isExpanded:)`: SwiftUI collapses it in a sidebar-style
170
+ // list and shows it whole elsewhere; the chevron's tap goes to the
171
+ // app, which flips the binding and sends `expanded` back.
172
+ let expanded = Binding<Bool>(
173
+ get: { SectionProps(props: node.props).expanded },
174
+ set: { _ in app?.dispatch(.tap(node.id)) }
175
+ )
176
+ Section(isExpanded: expanded) {
177
+ ListRowsView(children: rows)
178
+ } header: {
179
+ if let header {
180
+ ChildrenView(node: header)
181
+ } else if let title {
182
+ Text(verbatim: title)
183
+ }
184
+ }
185
+ } else if header != nil || footer != nil {
168
186
  // View headers and footers (`sectionheader` / `sectionfooter` children).
169
187
  Section {
170
188
  ListRowsView(children: rows)
@@ -194,6 +212,28 @@ struct SectionElement: View {
194
212
  }
195
213
  }
196
214
 
215
+ /// `disclosuregroup`: SwiftUI's own, its label the first child, its content
216
+ /// the rest; the chevron's tap goes to the app, which flips the binding and
217
+ /// sends `expanded` back.
218
+ struct DisclosureGroupElement: View {
219
+ let node: ElementNode
220
+ @Environment(\.nativeApp) private var app
221
+
222
+ var body: some View {
223
+ let expanded = Binding<Bool>(
224
+ get: { DisclosureGroupProps(props: node.props).expanded },
225
+ set: { _ in app?.dispatch(.tap(node.id)) }
226
+ )
227
+ DisclosureGroup(isExpanded: expanded) {
228
+ ForEach(Array(node.children.dropFirst())) { ElementView(node: $0) }
229
+ } label: {
230
+ if let label = node.children.first {
231
+ ElementView(node: label)
232
+ }
233
+ }
234
+ }
235
+ }
236
+
197
237
  /// `listrow`: the row's content with its edit-mode hints; a selectable
198
238
  /// row is the list's selection's tag (`ListElement`), or under the shim
199
239
  /// sends `tap` itself.
@@ -81,7 +81,9 @@ struct DatePickerElement: View {
81
81
  /// long press sends `longpress`, and two fingers spreading (`magnify`) or
82
82
  /// turning (`rotate`) send `pinch` events. The pinches go alongside the
83
83
  /// view's other gestures (`simultaneousGesture`), so a canvas's one-finger
84
- /// pan inside a two-finger zoom does not keep the zoom from starting. The
84
+ /// pan inside a two-finger zoom does not keep the zoom from starting; on
85
+ /// iOS 18 they are read from the fingers (PinchTracker.swift), so they begin
86
+ /// as soon as the fingers move. The
85
87
  /// drag and the long press attach as `mode` says (`.gesture`,
86
88
  /// `.highPriorityGesture` or `.simultaneousGesture`), and the drag's points
87
89
  /// are in `coordinateSpace` (`.local` or `.global`).
@@ -117,6 +119,75 @@ struct GestureElement: View {
117
119
  to: dragged, mode: props.mode
118
120
  )
119
121
  : dragged
122
+ #if os(iOS)
123
+ if props.magnify || props.rotate {
124
+ if #available(iOS 18.0, *) {
125
+ fingersPinch(pressed, props: props)
126
+ } else {
127
+ systemPinch(pressed, props: props)
128
+ }
129
+ } else {
130
+ pressed
131
+ }
132
+ #else
133
+ systemPinch(pressed, props: props)
134
+ #endif
135
+ }
136
+
137
+ /// The pinch from the fingers themselves (iOS 18): `SpatialEventGesture`
138
+ /// reports every finger, and PinchTracker.swift takes the scale, the
139
+ /// rotation and the midpoint from the first two, beginning as soon as
140
+ /// the thresholds pass (on the first move with none), as the web
141
+ /// renderer and Photos do. SwiftUI's `MagnifyGesture` begins only once
142
+ /// the system's pinch recognizer has made up its mind, a good way into
143
+ /// the spread.
144
+ #if os(iOS)
145
+ @available(iOS 18.0, *)
146
+ private func fingersPinch(_ view: AnyView, props: GestureProps) -> some View {
147
+ let fingers = fingers
148
+ let tracker: PinchTracker = {
149
+ if let current = fingers.tracker, current.magnify == props.magnify, current.rotate == props.rotate,
150
+ current.minimumScaleDelta == max(0, props.minimumScaleDelta), current.minimumAngleDelta == max(0, props.minimumAngleDelta) {
151
+ return current
152
+ }
153
+ let made = PinchTracker(magnify: props.magnify, rotate: props.rotate, minimumScaleDelta: props.minimumScaleDelta, minimumAngleDelta: props.minimumAngleDelta)
154
+ fingers.tracker = made
155
+ return made
156
+ }()
157
+ return view
158
+ .onGeometryChange(for: CGSize.self) { proxy in proxy.size } action: { size in fingers.size = size }
159
+ .simultaneousGesture(
160
+ SpatialEventGesture()
161
+ .onChanged { events in
162
+ let reported = events.map { PinchTracker.Finger(id: AnyHashable($0.id), location: $0.location, active: $0.phase == .active) }
163
+ let cancelled = events.contains { $0.phase == .cancelled }
164
+ let time = events.map(\.timestamp).max() ?? Date().timeIntervalSinceReferenceDate
165
+ send(tracker.update(fingers: reported, cancelled: cancelled, at: time), size: fingers.size)
166
+ }
167
+ .onEnded { events in
168
+ send(tracker.end(cancelled: events.contains { $0.phase == .cancelled }), size: fingers.size)
169
+ }
170
+ )
171
+ }
172
+
173
+ private func send(_ report: PinchTracker.Report?, size: CGSize) {
174
+ guard let report else { return }
175
+ let anchor = (
176
+ x: size.width > 0 ? Double(report.startLocation.x / size.width) : 0.5,
177
+ y: size.height > 0 ? Double(report.startLocation.y / size.height) : 0.5
178
+ )
179
+ app?.dispatch(.pinch(
180
+ node.id, phase: report.phase, scale: report.scale, rotation: report.rotation, scaleVelocity: report.scaleVelocity, rotationVelocity: report.rotationVelocity,
181
+ startLocation: (Double(report.startLocation.x), Double(report.startLocation.y)), startAnchor: anchor,
182
+ location: (Double(report.location.x), Double(report.location.y))
183
+ ))
184
+ }
185
+ #endif
186
+
187
+ /// SwiftUI's own `MagnifyGesture` and `RotateGesture` (before iOS 18, and
188
+ /// on the Mac, where a trackpad pinch is no finger), with the fingers
189
+ /// tracked for the pinch's location where the system reports them.
190
+ private func systemPinch(_ pressed: AnyView, props: GestureProps) -> AnyView {
120
191
  let tracked = props.magnify || props.rotate ? trackingFingers(pressed) : pressed
121
192
  let magnified: AnyView = props.magnify
122
193
  ? AnyView(tracked.simultaneousGesture(
@@ -132,22 +203,19 @@ struct GestureElement: View {
132
203
  }
133
204
  ))
134
205
  : tracked
135
- if props.rotate {
136
- magnified.simultaneousGesture(
137
- RotateGesture(minimumAngleDelta: .degrees(props.minimumAngleDelta))
138
- .onChanged { value in
139
- let phase = rotating ? "changed" : "began"
140
- rotating = true
141
- sendPinch(phase: phase, scale: 1, rotation: value.rotation.degrees, scaleVelocity: 0, rotationVelocity: value.velocity.degrees, startLocation: value.startLocation, startAnchor: value.startAnchor)
142
- }
143
- .onEnded { value in
144
- rotating = false
145
- sendPinch(phase: "ended", scale: 1, rotation: value.rotation.degrees, scaleVelocity: 0, rotationVelocity: value.velocity.degrees, startLocation: value.startLocation, startAnchor: value.startAnchor)
146
- }
147
- )
148
- } else {
149
- magnified
150
- }
206
+ guard props.rotate else { return magnified }
207
+ return AnyView(magnified.simultaneousGesture(
208
+ RotateGesture(minimumAngleDelta: .degrees(props.minimumAngleDelta))
209
+ .onChanged { value in
210
+ let phase = rotating ? "changed" : "began"
211
+ rotating = true
212
+ sendPinch(phase: phase, scale: 1, rotation: value.rotation.degrees, scaleVelocity: 0, rotationVelocity: value.velocity.degrees, startLocation: value.startLocation, startAnchor: value.startAnchor)
213
+ }
214
+ .onEnded { value in
215
+ rotating = false
216
+ sendPinch(phase: "ended", scale: 1, rotation: value.rotation.degrees, scaleVelocity: 0, rotationVelocity: value.velocity.degrees, startLocation: value.startLocation, startAnchor: value.startAnchor)
217
+ }
218
+ ))
151
219
  }
152
220
 
153
221
  /// The gesture on the view as `mode` composes it with the view's others and its subviews'.
@@ -224,6 +292,9 @@ final class Fingers {
224
292
  }
225
293
  var midpoint: CGPoint?
226
294
  var pinch: Pinch?
295
+ /// iOS 18: the pinch taken from the fingers, and the element's size for its anchor.
296
+ var tracker: PinchTracker?
297
+ var size: CGSize = .zero
227
298
  }
228
299
 
229
300
  /// `viewthatfits`: the children are the candidates, in order.