@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.
- package/README.md +53 -11
- package/bin/swiftbrowser-native.mjs +47 -0
- package/dist/blob.d.ts +85 -11
- package/dist/blob.d.ts.map +1 -1
- package/dist/blob.js +184 -34
- package/dist/blob.js.map +1 -1
- package/dist/bridge.d.ts +66 -8
- 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 +27 -13
- package/dist/fetch.d.ts.map +1 -1
- package/dist/fetch.js +117 -45
- package/dist/fetch.js.map +1 -1
- package/dist/hostRequests.d.ts +24 -6
- package/dist/hostRequests.d.ts.map +1 -1
- package/dist/hostRequests.js +108 -15
- package/dist/hostRequests.js.map +1 -1
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -2
- package/dist/index.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 +37 -11
- package/dist/webSocket.js.map +1 -1
- package/package.json +7 -3
- package/src/blob.ts +215 -34
- package/src/bridge.ts +56 -8
- package/src/encoding.ts +22 -0
- package/src/fetch.ts +111 -48
- package/src/hostRequests.ts +129 -22
- package/src/index.ts +1 -2
- package/src/ios.ts +253 -0
- package/src/runtime.ts +23 -8
- package/src/subtle.ts +10 -6
- package/src/webSocket.ts +37 -13
- package/swift/Packages/SwiftBrowserDSP/Package.swift +31 -0
- package/swift/Packages/SwiftBrowserDSP/README.md +42 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ProgramNode.swift +306 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ScopeView.swift +79 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Machine.swift +1561 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Program.swift +483 -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/Packages/SwiftBrowserShader/Package.swift +31 -0
- package/swift/Packages/SwiftBrowserShader/README.md +45 -0
- package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShader/ShaderRenderer.swift +103 -0
- package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShader/ShaderView.swift +168 -0
- package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShaderCore/ShaderProps.swift +120 -0
- package/swift/Sources/SwiftBrowserNative/Host/AudioEngineBridge.swift +921 -0
- package/swift/Sources/SwiftBrowserNative/Host/AudioEngineNodes.swift +70 -0
- package/swift/Sources/SwiftBrowserNative/Host/BodyStream.swift +166 -0
- package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +144 -10
- package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +151 -27
- package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +139 -40
- 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/Runtime/NativeFunctions.swift +1 -1
- package/swift/Sources/SwiftBrowserNative/Runtime/ViewRegistry.swift +57 -0
- package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +12 -7
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +18 -1
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Drawing.swift +378 -0
- 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 +87 -21
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/PinchTracker.swift +200 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/PopTap.swift +103 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +3 -2
- package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +146 -0
- package/swift/Sources/SwiftBrowserProtocol/Drawing.swift +306 -0
- package/swift/Sources/SwiftBrowserProtocol/Props.swift +31 -2
- package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +24 -3
- package/swift/Sources/SwiftBrowserProtocol/Style.swift +87 -0
- package/swift/Sources/SwiftBrowserTree/OpTree.swift +25 -0
- package/wasm/SwiftBrowserNative.wasm +2 -2
- package/dist/native.d.ts +0 -19
- package/dist/native.d.ts.map +0 -1
- package/dist/native.js +0 -17
- package/dist/native.js.map +0 -1
- package/src/native.ts +0 -35
- 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
|
|
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
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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.
|