@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,378 @@
1
+ #if canImport(SwiftUI)
2
+ import Foundation
3
+ import SwiftBrowserProtocol
4
+ import SwiftBrowserTree
5
+ import SwiftUI
6
+ #if canImport(UniformTypeIdentifiers)
7
+ import UniformTypeIdentifiers
8
+ #endif
9
+
10
+ // The kinds that draw or lay out on the app's say: a `canvas` replaying the
11
+ // commands the app's renderer recorded, a custom `layout` answered by the
12
+ // app's JavaScript, and the drag and drop pair (docs/native.md, "Views").
13
+
14
+ // MARK: - canvas
15
+
16
+ /// `canvas`: SwiftUI's `Canvas`, replaying each command with the context's state as the app recorded
17
+ /// it: its clips (in the canvas's coordinates), then its transform, opacity, blend mode and filters.
18
+ /// It fills its proposal and clips the drawing to its box, as the page's does.
19
+ struct CanvasElement: View {
20
+ let node: ElementNode
21
+
22
+ var body: some View {
23
+ let props = CanvasProps(props: node.props)
24
+ Canvas { context, _ in
25
+ for command in props.commands { draw(command, into: context) }
26
+ }
27
+ .clipped()
28
+ }
29
+
30
+ private func draw(_ command: CanvasCommand, into canvas: GraphicsContext) {
31
+ var context = canvas
32
+ for clip in command.clips {
33
+ if let path = Path(svg: clip) { context.clip(to: path) }
34
+ }
35
+ if let m = command.transform { context.transform = CGAffineTransform(a: m[0], b: m[1], c: m[2], d: m[3], tx: m[4], ty: m[5]) }
36
+ context.opacity = command.opacity
37
+ if let mode = command.blendMode { setBlendMode(mode, of: &context) }
38
+ for filter in command.filters { context.addFilter(Self.filter(filter)) }
39
+ switch command.operation {
40
+ case .fill(let data, let shading, let evenOdd):
41
+ guard let path = Path(svg: data) else { return }
42
+ context.fill(path, with: Self.shading(shading), style: FillStyle(eoFill: evenOdd))
43
+ case .stroke(let data, let shading, let spec):
44
+ guard let path = Path(svg: data) else { return }
45
+ context.stroke(path, with: Self.shading(shading), style: StrokeStyle(
46
+ lineWidth: spec.lineWidth,
47
+ lineCap: spec.lineCap == "round" ? .round : spec.lineCap == "square" ? .square : .butt,
48
+ lineJoin: spec.lineJoin == "round" ? .round : spec.lineJoin == "bevel" ? .bevel : .miter,
49
+ miterLimit: spec.miterLimit ?? 10,
50
+ dash: spec.dash.map { CGFloat($0) },
51
+ dashPhase: spec.dashPhase
52
+ ))
53
+ case .text(let text, let x, let y, let anchor, let shading, let font):
54
+ var resolved = context.resolve(font.map { Text(verbatim: text).font(Font(spec: $0)) } ?? Text(verbatim: text))
55
+ resolved.shading = Self.shading(shading)
56
+ context.draw(resolved, at: CGPoint(x: x, y: y), anchor: UnitPoint(spec: anchor))
57
+ case .image(let image, let x, let y, let anchor, let width, let height, let shading):
58
+ // A symbol or a catalog image; a remote one has no bytes to draw synchronously here.
59
+ let source: Image
60
+ switch image {
61
+ case .symbol(let name, _): source = Image(systemName: name)
62
+ case .asset(let name, _): source = Image(name)
63
+ default: return
64
+ }
65
+ var resolved = context.resolve(source)
66
+ if let shading { resolved.shading = Self.shading(shading) }
67
+ if let width, let height {
68
+ let origin = CGPoint(x: x - anchor.x * width, y: y - anchor.y * height)
69
+ context.draw(resolved, in: CGRect(origin: origin, size: CGSize(width: width, height: height)))
70
+ } else {
71
+ context.draw(resolved, at: CGPoint(x: x, y: y), anchor: UnitPoint(spec: anchor))
72
+ }
73
+ }
74
+ }
75
+
76
+ static func shading(_ spec: CanvasShadingSpec) -> GraphicsContext.Shading {
77
+ let gradient = { (stops: [GradientStopSpec]) in Gradient(stops: stops.map { Gradient.Stop(color: Color(spec: $0.color), location: $0.location) }) }
78
+ switch spec {
79
+ case .style(let style):
80
+ return .style(AnyShapeStyle(spec: style))
81
+ case .linearGradient(let stops, let start, let end):
82
+ return .linearGradient(gradient(stops), startPoint: CGPoint(x: start.x, y: start.y), endPoint: CGPoint(x: end.x, y: end.y))
83
+ case .radialGradient(let stops, let center, let startRadius, let endRadius):
84
+ return .radialGradient(gradient(stops), center: CGPoint(x: center.x, y: center.y), startRadius: startRadius, endRadius: endRadius)
85
+ }
86
+ }
87
+
88
+ static func filter(_ spec: CanvasFilterSpec) -> GraphicsContext.Filter {
89
+ switch spec {
90
+ case .blur(let radius): .blur(radius: radius)
91
+ case .shadow(let color, let radius, let x, let y): .shadow(color: color.map(Color.init(spec:)) ?? Color.black.opacity(0.33), radius: radius, x: x, y: y)
92
+ case .grayscale(let amount): .grayscale(amount)
93
+ case .saturation(let amount): .saturation(amount)
94
+ case .brightness(let amount): .brightness(amount)
95
+ case .contrast(let amount): .contrast(amount)
96
+ case .hueRotation(let degrees): .hueRotation(.degrees(degrees))
97
+ case .invert(let amount): .colorInvert(amount)
98
+ }
99
+ }
100
+
101
+ /// The command's CSS blend mode. Assigned member by member: the context's blend mode is its own type
102
+ /// natively and the view's `BlendMode` in the shim, and both name these.
103
+ private func setBlendMode(_ name: String, of context: inout GraphicsContext) {
104
+ switch name {
105
+ case "multiply": context.blendMode = .multiply
106
+ case "screen": context.blendMode = .screen
107
+ case "overlay": context.blendMode = .overlay
108
+ case "darken": context.blendMode = .darken
109
+ case "lighten": context.blendMode = .lighten
110
+ case "color-dodge", "colorDodge": context.blendMode = .colorDodge
111
+ case "color-burn", "colorBurn": context.blendMode = .colorBurn
112
+ case "soft-light", "softLight": context.blendMode = .softLight
113
+ case "hard-light", "hardLight": context.blendMode = .hardLight
114
+ case "difference": context.blendMode = .difference
115
+ case "exclusion": context.blendMode = .exclusion
116
+ case "hue": context.blendMode = .hue
117
+ case "saturation": context.blendMode = .saturation
118
+ case "color": context.blendMode = .color
119
+ case "luminosity": context.blendMode = .luminosity
120
+ case "plus-lighter", "plusLighter": context.blendMode = .plusLighter
121
+ case "plus-darker", "plusDarker": context.blendMode = .plusDarker
122
+ default: context.blendMode = .normal
123
+ }
124
+ }
125
+ }
126
+
127
+ // MARK: - layout
128
+
129
+ /// `layout`: a SwiftUI `Layout` the app's JavaScript answers. After a pass it reports the proposal it
130
+ /// was placed with and each child's min, ideal and max sizes (`layout`, deferred past the pass, as
131
+ /// `geometry` is, and only when they changed); the app answers with the element's `size` and `frames`.
132
+ /// Until the first answer it fills its proposal; for another proposal it reads its flexibility from the
133
+ /// answer's `sizes`, and places the children at the last frames.
134
+ struct LayoutElement: View {
135
+ let node: ElementNode
136
+ @Environment(\.nativeApp) private var app
137
+ @State private var reporter = LayoutReporter()
138
+
139
+ var body: some View {
140
+ let reporter = reporter
141
+ let id = node.id
142
+ let app = app
143
+ let layout = AppLayout(props: LayoutProps(props: node.props)) { record in
144
+ reporter.report(RendererEvent.layout(id, proposal: record.proposal, children: record.children), app: app)
145
+ }
146
+ layout { ChildrenView(node: node) }
147
+ }
148
+ }
149
+
150
+ /// The last record a `layout` element sent: a pass that measured the same sends nothing.
151
+ final class LayoutReporter: @unchecked Sendable {
152
+ private var last: RendererEvent?
153
+
154
+ func report(_ event: RendererEvent, app: NativeApp?) {
155
+ guard event != last else { return }
156
+ last = event
157
+ Task { @MainActor in app?.dispatch(event) }
158
+ }
159
+ }
160
+
161
+ struct AppLayout: Layout {
162
+ struct Record {
163
+ var proposal: LayoutProposalSpec
164
+ var children: [LayoutMeasurementSpec]
165
+ }
166
+
167
+ let props: LayoutProps
168
+ let report: (Record) -> Void
169
+
170
+ private static func spec(_ proposal: ProposedViewSize) -> LayoutProposalSpec {
171
+ LayoutProposalSpec(width: proposal.width.map { LayoutDimensionSpec(Double($0)) }, height: proposal.height.map { LayoutDimensionSpec(Double($0)) })
172
+ }
173
+
174
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
175
+ if let size = props.size, props.proposal == Self.spec(proposal) { return CGSize(width: size.width, height: size.height) }
176
+ if let min = props.min, let ideal = props.ideal, let max = props.max {
177
+ func axis(_ proposed: CGFloat?, _ min: LayoutDimensionSpec, _ ideal: LayoutDimensionSpec, _ max: LayoutDimensionSpec) -> CGFloat {
178
+ guard let proposed else { return ideal.value.isFinite ? ideal.value : 0 }
179
+ let clamped = Swift.min(Swift.max(Double(proposed), min.value), max.value)
180
+ return clamped.isFinite ? clamped : (ideal.value.isFinite ? ideal.value : 0)
181
+ }
182
+ return CGSize(width: axis(proposal.width, min.width, ideal.width, max.width), height: axis(proposal.height, min.height, ideal.height, max.height))
183
+ }
184
+ return proposal.replacingUnspecifiedDimensions()
185
+ }
186
+
187
+ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
188
+ let measure = { (subview: LayoutSubview, proposal: ProposedViewSize) -> LayoutSizeSpec in
189
+ let size = subview.sizeThatFits(proposal)
190
+ return LayoutSizeSpec(width: Double(size.width), height: Double(size.height))
191
+ }
192
+ report(Record(
193
+ proposal: Self.spec(proposal),
194
+ children: subviews.map { LayoutMeasurementSpec(min: measure($0, .zero), ideal: measure($0, .unspecified), max: measure($0, .infinity)) }
195
+ ))
196
+ if let frames = props.frames, frames.count == subviews.count {
197
+ for (subview, frame) in zip(subviews, frames) {
198
+ subview.place(at: CGPoint(x: bounds.minX + frame.x, y: bounds.minY + frame.y), anchor: .topLeading, proposal: ProposedViewSize(width: frame.width, height: frame.height))
199
+ }
200
+ } else {
201
+ for subview in subviews { subview.place(at: bounds.origin, anchor: .topLeading, proposal: ProposedViewSize(bounds.size)) }
202
+ }
203
+ }
204
+ }
205
+
206
+ // MARK: - drag and drop
207
+
208
+ /// `draggable`: SwiftUI's `.draggable`, carrying the element's id as text: what is dragged stays in
209
+ /// the app, which makes it at `dragstart` (sent as the drag begins) and hands it to the destination the
210
+ /// drop names. A drop on no destination sends no `dragend`; the app's next lift replaces what it kept.
211
+ struct DraggableElement: View {
212
+ let node: ElementNode
213
+ @Environment(\.nativeApp) private var app
214
+
215
+ var body: some View {
216
+ let id = node.id
217
+ let app = app
218
+ content.draggable(Self.lift(id, app))
219
+ }
220
+
221
+ @ViewBuilder private var content: some View {
222
+ if node.children.count == 1 { ElementView(node: node.children[0]) } else { ChildrenView(node: node) }
223
+ }
224
+
225
+ /// The payload, made as the drag begins: the app hears `dragstart` first.
226
+ private static func lift(_ id: Int, _ app: NativeApp?) -> String {
227
+ Task { @MainActor in app?.dispatch(.dragStart(id)) }
228
+ return DragPayload.text(for: id)
229
+ }
230
+ }
231
+
232
+ #if !canImport(FoundationBridge)
233
+ /// What a drop from another app carries: text (or a link) as the page offers it, or a file or folder, copied
234
+ /// into a tmp folder of the app's while the drop runs (a received file is the system's only until then).
235
+ struct DropPayload: Transferable {
236
+ enum Kind {
237
+ case text(String)
238
+ case file(URL, folder: Bool)
239
+ }
240
+ let kind: Kind
241
+
242
+ static var transferRepresentation: some TransferRepresentation {
243
+ FileRepresentation(importedContentType: .folder) { received in
244
+ DropPayload(kind: .file(try Self.keep(received.file), folder: true))
245
+ }
246
+ FileRepresentation(importedContentType: .item) { received in
247
+ DropPayload(kind: .file(try Self.keep(received.file), folder: false))
248
+ }
249
+ ProxyRepresentation { (url: URL) in DropPayload(kind: .text(url.absoluteString)) }
250
+ ProxyRepresentation { (text: String) in DropPayload(kind: .text(text)) }
251
+ }
252
+
253
+ /// The received item copied into a folder of its own in tmp, registered as picked (PickedFiles) until the app releases it.
254
+ private static func keep(_ received: URL) throws -> URL {
255
+ let folder = FileManager.default.temporaryDirectory.appendingPathComponent("swiftbrowser-drops-\(UUID().uuidString)", isDirectory: true)
256
+ try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
257
+ let kept = folder.appendingPathComponent(received.lastPathComponent)
258
+ try FileManager.default.copyItem(at: received, to: kept)
259
+ PickedFiles.shared.register([kept], temporary: folder)
260
+ return kept
261
+ }
262
+ }
263
+
264
+ /// `dropdestination`: SwiftUI's `.dropDestination`. A drag over it sends `drop` `entered` and `exited`; a
265
+ /// drop sends `performed` with the `draggable` it came from (and that one's `dragend`), or, for what another
266
+ /// app dragged in and `accepts` takes, the text as `items`, or each file (a dropped folder's at any depth, named
267
+ /// by their paths in it) as an item still on disk: `{ type, name, path?, url, size }`, which the app reads on
268
+ /// demand through the files bridge (a `LazyFile` in @swiftbrowser/native).
269
+ struct DropDestinationElement: View {
270
+ let node: ElementNode
271
+ @Environment(\.nativeApp) private var app
272
+
273
+ var body: some View {
274
+ let id = node.id
275
+ let app = app
276
+ let props = DropDestinationProps(props: node.props)
277
+ content.dropDestination(for: DropPayload.self) { payloads, location in
278
+ var events: [RendererEvent] = []
279
+ var outside: [JSONValue] = []
280
+ for payload in payloads {
281
+ switch payload.kind {
282
+ case .text(let text):
283
+ if let source = DragPayload.source(of: text) {
284
+ events.append(.drop(id, phase: "performed", source: source, x: location.x, y: location.y))
285
+ events.append(.dragEnd(source))
286
+ } else if props.acceptsText {
287
+ let link = URL(string: text).map { $0.scheme != nil && !text.contains(where: \.isWhitespace) } ?? false
288
+ outside.append(.object(["type": .string(link ? "text/uri-list" : "text/plain"), "text": .string(text)]))
289
+ }
290
+ case .file(let url, let folder):
291
+ guard props.acceptsFiles else { continue }
292
+ outside.append(contentsOf: Self.fileItems(url, folder: folder, props: props))
293
+ }
294
+ }
295
+ if !outside.isEmpty { events.append(.drop(id, phase: "performed", items: outside, x: location.x, y: location.y)) }
296
+ guard !events.isEmpty else { return false }
297
+ let sent = events
298
+ Task { @MainActor in for event in sent { app?.dispatch(event) } }
299
+ return true
300
+ } isTargeted: { targeted in
301
+ Task { @MainActor in app?.dispatch(.drop(id, phase: targeted ? "entered" : "exited")) }
302
+ }
303
+ }
304
+
305
+ /// A dropped file as one item, or a dropped folder's files (at any depth, hidden ones left out) as items named by
306
+ /// their paths from the folder, the folder's name first, as the page names a dropped folder's files.
307
+ private static func fileItems(_ url: URL, folder: Bool, props: DropDestinationProps) -> [JSONValue] {
308
+ let mime = { (file: URL) in UTType(filenameExtension: file.pathExtension)?.preferredMIMEType ?? "application/octet-stream" }
309
+ let item = { (file: URL, path: String?) -> JSONValue in
310
+ let size = (try? file.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0
311
+ var value: JSONObject = [
312
+ "type": .string(mime(file)),
313
+ "name": .string(file.lastPathComponent),
314
+ "url": .string(file.standardizedFileURL.absoluteString),
315
+ "size": .number(Double(size)),
316
+ ]
317
+ if let path { value["path"] = .string(path) }
318
+ return .object(value)
319
+ }
320
+ guard folder else { return props.accepts(mimeType: mime(url)) ? [item(url, nil)] : [] }
321
+ let root = url.standardizedFileURL.pathComponents
322
+ guard let walker = FileManager.default.enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) else { return [] }
323
+ var items: [JSONValue] = []
324
+ var urls: [URL] = []
325
+ for case let file as URL in walker {
326
+ guard (try? file.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true, props.accepts(mimeType: mime(file)) else { continue }
327
+ let components = file.standardizedFileURL.pathComponents
328
+ guard components.count > root.count else { continue }
329
+ items.append(item(file, ([url.lastPathComponent] + components.dropFirst(root.count)).joined(separator: "/")))
330
+ urls.append(file)
331
+ }
332
+ PickedFiles.shared.register(urls)
333
+ return items
334
+ }
335
+
336
+ @ViewBuilder private var content: some View {
337
+ if node.children.count == 1 { ElementView(node: node.children[0]) } else { ChildrenView(node: node) }
338
+ }
339
+ }
340
+ #else
341
+ /// `dropdestination`: SwiftUI's `.dropDestination(for: String.self)`, against the shim (the round trip): in-app
342
+ /// drags, and text from outside that `accepts` takes; files need the system's `Transferable` (above).
343
+ struct DropDestinationElement: View {
344
+ let node: ElementNode
345
+ @Environment(\.nativeApp) private var app
346
+
347
+ var body: some View {
348
+ let id = node.id
349
+ let app = app
350
+ let accepts = DropDestinationProps(props: node.props).acceptsText
351
+ content.dropDestination(for: String.self) { texts, location in
352
+ var events: [RendererEvent] = []
353
+ var outside: [JSONValue] = []
354
+ for text in texts {
355
+ if let source = DragPayload.source(of: text) {
356
+ events.append(.drop(id, phase: "performed", source: source, x: location.x, y: location.y))
357
+ events.append(.dragEnd(source))
358
+ } else if accepts {
359
+ let link = URL(string: text).map { $0.scheme != nil && !text.contains(where: \.isWhitespace) } ?? false
360
+ outside.append(.object(["type": .string(link ? "text/uri-list" : "text/plain"), "text": .string(text)]))
361
+ }
362
+ }
363
+ if !outside.isEmpty { events.append(.drop(id, phase: "performed", items: outside, x: location.x, y: location.y)) }
364
+ guard !events.isEmpty else { return false }
365
+ let sent = events
366
+ Task { @MainActor in for event in sent { app?.dispatch(event) } }
367
+ return true
368
+ } isTargeted: { targeted in
369
+ Task { @MainActor in app?.dispatch(.drop(id, phase: targeted ? "entered" : "exited")) }
370
+ }
371
+ }
372
+
373
+ @ViewBuilder private var content: some View {
374
+ if node.children.count == 1 { ElementView(node: node.children[0]) } else { ChildrenView(node: node) }
375
+ }
376
+ }
377
+ #endif
378
+ #endif
@@ -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.