@swiftbrowser/native 0.19.0 → 0.23.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 (66) hide show
  1. package/README.md +34 -11
  2. package/dist/blob.d.ts +85 -11
  3. package/dist/blob.d.ts.map +1 -1
  4. package/dist/blob.js +184 -34
  5. package/dist/blob.js.map +1 -1
  6. package/dist/bridge.d.ts +33 -2
  7. package/dist/bridge.d.ts.map +1 -1
  8. package/dist/bridge.js.map +1 -1
  9. package/dist/fetch.d.ts +19 -11
  10. package/dist/fetch.d.ts.map +1 -1
  11. package/dist/fetch.js +105 -41
  12. package/dist/fetch.js.map +1 -1
  13. package/dist/hostRequests.d.ts +3 -1
  14. package/dist/hostRequests.d.ts.map +1 -1
  15. package/dist/hostRequests.js +47 -6
  16. package/dist/hostRequests.js.map +1 -1
  17. package/dist/index.d.ts +1 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/runtime.js +1 -1
  22. package/dist/runtime.js.map +1 -1
  23. package/dist/webSocket.d.ts.map +1 -1
  24. package/dist/webSocket.js +13 -1
  25. package/dist/webSocket.js.map +1 -1
  26. package/package.json +3 -3
  27. package/src/blob.ts +215 -34
  28. package/src/bridge.ts +27 -2
  29. package/src/fetch.ts +99 -41
  30. package/src/hostRequests.ts +62 -8
  31. package/src/index.ts +1 -2
  32. package/src/runtime.ts +1 -1
  33. package/src/webSocket.ts +10 -1
  34. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ProgramNode.swift +185 -24
  35. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ScopeView.swift +79 -0
  36. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Machine.swift +2075 -213
  37. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Program.swift +407 -16
  38. package/swift/Packages/SwiftBrowserShader/Package.swift +31 -0
  39. package/swift/Packages/SwiftBrowserShader/README.md +45 -0
  40. package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShader/ShaderRenderer.swift +103 -0
  41. package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShader/ShaderView.swift +168 -0
  42. package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShaderCore/ShaderProps.swift +120 -0
  43. package/swift/Sources/SwiftBrowserNative/Host/BodyStream.swift +166 -0
  44. package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +141 -7
  45. package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +47 -1
  46. package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +98 -29
  47. package/swift/Sources/SwiftBrowserNative/Runtime/NativeFunctions.swift +1 -1
  48. package/swift/Sources/SwiftBrowserNative/Runtime/ViewRegistry.swift +57 -0
  49. package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +11 -7
  50. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +28 -6
  51. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Drawing.swift +378 -0
  52. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +15 -10
  53. package/swift/Sources/SwiftBrowserNative/Views/Kinds/PopTap.swift +31 -28
  54. package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +142 -0
  55. package/swift/Sources/SwiftBrowserProtocol/Drawing.swift +306 -0
  56. package/swift/Sources/SwiftBrowserProtocol/Props.swift +5 -2
  57. package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +24 -3
  58. package/swift/Sources/SwiftBrowserProtocol/Style.swift +87 -0
  59. package/swift/Sources/SwiftBrowserProtocol/TextChange.swift +17 -0
  60. package/wasm/SwiftBrowserNative.wasm +2 -2
  61. package/dist/native.d.ts +0 -19
  62. package/dist/native.d.ts.map +0 -1
  63. package/dist/native.js +0 -17
  64. package/dist/native.js.map +0 -1
  65. package/src/native.ts +0 -35
  66. package/swift/Sources/SwiftBrowserNative/Runtime/NativeRegistry.swift +0 -53
@@ -49,6 +49,14 @@ struct FilesBridge: Sendable {
49
49
  return roots
50
50
  }
51
51
 
52
+ /// Whether `url` is in one of the app's directories, or a file the viewer picked or dropped (PickedFiles),
53
+ /// which the app may read and copy in (`readRange`, `importItem`, `attributes`) but not write.
54
+ private func readable(_ url: URL) -> Bool {
55
+ let path = url.path
56
+ if roots.contains(where: { path == $0.path || path.hasPrefix($0.path + "/") }) { return true }
57
+ return PickedFiles.shared.contains(url)
58
+ }
59
+
52
60
  /// Runs `body` on `url` under file coordination when the URL is in the iCloud container, else as it is.
53
61
  private func coordinated<T>(_ url: URL, writing: NSFileCoordinator.WritingOptions?, _ body: (URL) throws -> T) throws -> T {
54
62
  guard ICloudContainer.shared.contains(url) else { return try body(url) }
@@ -84,20 +92,43 @@ struct FilesBridge: Sendable {
84
92
  throw HostRequestFailure("unavailable: \(url.absoluteString) did not download from iCloud Drive")
85
93
  }
86
94
 
87
- /// The file URL `params[key]` names, under today's container, inside the app's directories.
88
- private func url(_ params: JSONObject, _ key: String) throws -> URL {
95
+ /// The file URL `params[key]` names, under today's container, inside the app's directories (or, `reading`, a picked file's).
96
+ private func url(_ params: JSONObject, _ key: String, reading: Bool = false) throws -> URL {
89
97
  guard let string = params[key]?.string, let parsed = URL(string: string) else {
90
98
  throw HostRequestFailure("unsupported: files: \(key) is not a URL")
91
99
  }
92
100
  guard parsed.isFileURL else { throw HostRequestFailure("unsupported: \(string) is not a file URL") }
93
101
  let url = AppFiles.current(parsed).standardizedFileURL
94
102
  let path = url.path
95
- guard roots.contains(where: { path == $0.path || path.hasPrefix($0.path + "/") }) else {
103
+ let inApp = roots.contains(where: { path == $0.path || path.hasPrefix($0.path + "/") })
104
+ guard inApp || (reading && readable(url)) else {
96
105
  throw HostRequestFailure("unsupported: \(string) is not in the app's directories")
97
106
  }
98
107
  return url
99
108
  }
100
109
 
110
+ /// The file URL `string` names when the app may read it (in its directories, or picked or dropped); nil otherwise. For a body streamed from disk (BodyStream.swift).
111
+ static func readableURL(_ string: String) -> URL? {
112
+ let bridge = FilesBridge()
113
+ guard let parsed = URL(string: string), parsed.isFileURL else { return nil }
114
+ let url = AppFiles.current(parsed).standardizedFileURL
115
+ return bridge.readable(url) ? url : nil
116
+ }
117
+
118
+ /// The file's bytes from `start` up to `end` (clamped), through a `FileHandle`, so a large file is read a piece at a time.
119
+ private func readRange(_ url: URL, start: Int, end: Int?) throws -> Data {
120
+ try coordinated(url, writing: nil) { target in
121
+ let handle = try FileHandle(forReadingFrom: target)
122
+ defer { try? handle.close() }
123
+ let size = try handle.seekToEnd()
124
+ let from = min(UInt64(max(0, start)), size)
125
+ let to = min(end.map { UInt64(max(0, $0)) } ?? size, size)
126
+ guard from < to else { return Data() }
127
+ try handle.seek(toOffset: from)
128
+ return try handle.read(upToCount: Int(to - from)) ?? Data()
129
+ }
130
+ }
131
+
101
132
  private func isDirectory(_ url: URL) -> Bool? {
102
133
  var directory: ObjCBool = false
103
134
  if manager.fileExists(atPath: url.path, isDirectory: &directory) { return directory.boolValue }
@@ -128,15 +159,47 @@ struct FilesBridge: Sendable {
128
159
  try await ensureDownloaded(url)
129
160
  return .object(["url": .string(url.absoluteString)])
130
161
  case "read":
131
- let url = try url(params, "url")
162
+ let url = try url(params, "url", reading: true)
132
163
  switch isDirectory(url) {
133
164
  case nil: throw HostRequestFailure("notFound: no file at \(url.absoluteString)")
134
165
  case true?: throw HostRequestFailure("isDirectory: \(url.absoluteString) is a directory")
135
166
  case false?: break
136
167
  }
137
168
  try await ensureDownloaded(url)
138
- let data = try coordinated(url, writing: nil) { try Data(contentsOf: $0) }
169
+ let data = try PickedFiles.shared.accessing(url) { try coordinated(url, writing: nil) { try Data(contentsOf: $0) } }
139
170
  return .object(["data": RequestBytes.value(data)])
171
+ case "readRange":
172
+ // `fileManager.contents(url, { start, end })`: a piece of the file, `FileHandle`'s seek and read.
173
+ let url = try url(params, "url", reading: true)
174
+ switch isDirectory(url) {
175
+ case nil: throw HostRequestFailure("notFound: no file at \(url.absoluteString)")
176
+ case true?: throw HostRequestFailure("isDirectory: \(url.absoluteString) is a directory")
177
+ case false?: break
178
+ }
179
+ try await ensureDownloaded(url)
180
+ let start = params["start"]?.int ?? Int(params["start"]?.double ?? 0)
181
+ let end = params["end"]?.int ?? params["end"]?.double.map { Int($0) }
182
+ let data = try PickedFiles.shared.accessing(url) { try readRange(url, start: start, end: end) }
183
+ return .object(["data": RequestBytes.value(data)])
184
+ case "importItem":
185
+ // `fileManager.write(url, pickedFile)`: the picked file copied on disk into the app's directories, no bytes across the bridge.
186
+ let from = try url(params, "from", reading: true)
187
+ let to = try url(params, "to")
188
+ guard isDirectory(from) == false else { throw HostRequestFailure("notFound: no file at \(from.absoluteString)") }
189
+ if isDirectory(to) == true { throw HostRequestFailure("isDirectory: \(to.absoluteString) is a directory") }
190
+ try requireDirectory(to.deletingLastPathComponent(), for: to)
191
+ try await ensureDownloaded(from)
192
+ try PickedFiles.shared.accessing(from) {
193
+ try coordinated(to, writing: .forReplacing) { target in
194
+ if manager.fileExists(atPath: target.path) { try manager.removeItem(at: target) }
195
+ try manager.copyItem(at: from, to: target)
196
+ }
197
+ }
198
+ return .object([:])
199
+ case "release":
200
+ // The app is done with what it picked: the folder's security scope ends and the copies in tmp go.
201
+ PickedFiles.shared.release(params["urls"]?.array?.compactMap { $0.string.flatMap(URL.init(string:)) })
202
+ return .object([:])
140
203
  case "write":
141
204
  let url = try url(params, "url")
142
205
  guard let data = RequestBytes.data(params["data"]) else {
@@ -169,9 +232,9 @@ struct FilesBridge: Sendable {
169
232
  }).sorted { $0.localizedStandardCompare($1) == .orderedAscending }
170
233
  return .object(["urls": .array(names.map { .string(url.appendingPathComponent($0).absoluteString) })])
171
234
  case "attributes":
172
- let url = try url(params, "url")
235
+ let url = try url(params, "url", reading: true)
173
236
  guard let directory = isDirectory(url) else { return .null }
174
- let attributes = try manager.attributesOfItem(atPath: url.path)
237
+ let attributes = try PickedFiles.shared.accessing(url) { try manager.attributesOfItem(atPath: url.path) }
175
238
  let size = directory ? 0 : ((attributes[.size] as? NSNumber)?.doubleValue ?? 0)
176
239
  let modified = (attributes[.modificationDate] as? Date).map { JSONValue.number(($0.timeIntervalSince1970 * 1000).rounded()) } ?? .null
177
240
  let created = (attributes[.creationDate] as? Date).map { JSONValue.number(($0.timeIntervalSince1970 * 1000).rounded()) } ?? .null
@@ -203,6 +266,77 @@ struct FilesBridge: Sendable {
203
266
 
204
267
  #endif
205
268
 
269
+ #if !canImport(FoundationBridge)
270
+ /// The files and folders the viewer picked (`pickFiles`) or dropped: the page
271
+ /// gets their URLs and sizes, not their bytes, and reads them through the
272
+ /// files bridge (`readRange`, `importItem`) while they are registered here.
273
+ /// A folder opened in place keeps its security scope until the app releases
274
+ /// it (`files` `release`) or picks again; copies the pickers made in tmp are
275
+ /// deleted then too.
276
+ final class PickedFiles: @unchecked Sendable {
277
+ static let shared = PickedFiles()
278
+
279
+ private let lock = NSLock()
280
+ /// Files the app may read, by standardized path; the scoped folders they came from, if any.
281
+ private var files: [String: URL] = [:]
282
+ private var scopedFolders: [URL] = []
283
+ /// Folders in tmp the pickers copied into, removed on release.
284
+ private var temporaryFolders: [URL] = []
285
+
286
+ /// Registers files the app may read: `scoped` is a folder whose security scope is open (stopped on release).
287
+ func register(_ urls: [URL], scoped folder: URL? = nil, temporary: URL? = nil) {
288
+ lock.lock()
289
+ defer { lock.unlock() }
290
+ for url in urls { files[url.standardizedFileURL.path] = url }
291
+ if let folder { scopedFolders.append(folder) }
292
+ if let temporary { temporaryFolders.append(temporary) }
293
+ }
294
+
295
+ func contains(_ url: URL) -> Bool {
296
+ lock.lock()
297
+ defer { lock.unlock() }
298
+ let path = url.standardizedFileURL.path
299
+ if files[path] != nil { return true }
300
+ return scopedFolders.contains { path.hasPrefix($0.standardizedFileURL.path + "/") }
301
+ }
302
+
303
+ /// Runs `body` with the folder's security scope open where `url` is in a scoped folder.
304
+ func accessing<T>(_ url: URL, _ body: () throws -> T) rethrows -> T {
305
+ let stop = beginAccessing(url)
306
+ defer { stop?() }
307
+ return try body()
308
+ }
309
+
310
+ /// Opens the security scope of the folder `url` is in, if any, until the closure answered is called (a stream reading over time).
311
+ func beginAccessing(_ url: URL) -> (() -> Void)? {
312
+ lock.lock()
313
+ let path = url.standardizedFileURL.path
314
+ let folder = scopedFolders.first { path.hasPrefix($0.standardizedFileURL.path + "/") }
315
+ lock.unlock()
316
+ guard let folder, folder.startAccessingSecurityScopedResource() else { return nil }
317
+ return { folder.stopAccessingSecurityScopedResource() }
318
+ }
319
+
320
+ /// Forgets the given files (all of them when nil), ending scopes and deleting the pickers' copies no file still names.
321
+ func release(_ urls: [URL]?) {
322
+ lock.lock()
323
+ if let urls {
324
+ for url in urls { files[url.standardizedFileURL.path] = nil }
325
+ } else {
326
+ files.removeAll()
327
+ }
328
+ let stillNamed = Set(files.values.map { $0.standardizedFileURL.path })
329
+ let doneFolders = scopedFolders.filter { folder in !stillNamed.contains { $0.hasPrefix(folder.standardizedFileURL.path + "/") } }
330
+ scopedFolders.removeAll { doneFolders.contains($0) }
331
+ let doneTemporary = temporaryFolders.filter { folder in !stillNamed.contains { $0.hasPrefix(folder.standardizedFileURL.path + "/") } }
332
+ temporaryFolders.removeAll { doneTemporary.contains($0) }
333
+ lock.unlock()
334
+ for folder in doneFolders { folder.stopAccessingSecurityScopedResource() }
335
+ for folder in doneTemporary { try? FileManager.default.removeItem(at: folder) }
336
+ }
337
+ }
338
+ #endif
339
+
206
340
  /// Files the app keeps (a recording's, `fileManager`'s) are named by their
207
341
  /// URL, and iOS moves an app's container when it is installed again: a file
208
342
  /// URL into an old `Documents` that is not there anymore is the same path
@@ -29,6 +29,8 @@ import IOKit.pwr_mgt
29
29
  /// | `setTimer(id, delayMs, repeats)`, `clearTimer(id)` | timers on the main run loop; fire `__sbTimerFired(id)` |
30
30
  /// | `hostRequestBytes(id, kind, json, ...views)` | `hostRequest` with typed arrays beside the JSON, which names each as `{ "$bytes": n }` (docs/notes/bytes.md); only where the engine carries bytes (JavaScriptCore), and an answer's bytes follow `__sbHostResponse`'s JSON the same way |
31
31
  /// | `fetch(id, requestJSON, ...views)` | `URLSession`; answers `__sbFetchResult(id, resultJSON, ...bytes)`: a body as a typed array beside the JSON where the engine carries bytes and the request says `bytes: true`, else base64 |
32
+ /// | `fetchCancel(id)` | cancels the request's task (an `AbortController`'s abort); it answers nothing |
33
+ /// | `fetchStreams()` | true: `fetch` takes `bodyParts`, typed arrays beside the request and ranges of files on disk, streamed in order (BodyStream.swift) |
32
34
  /// | `probeImage(id, url)` | the image's pixel size through the image loader (ImageLoader.swift), which keeps the bytes for the `image` that follows; answers `__sbImageProbed(id, {width, height} or {error})`. The runtime's `Image` (a page's `new Image()`). Apple platforms only |
33
35
  /// | `storageGet/Set/Remove/Keys/Clear` | `localStorage` over `UserDefaults` |
34
36
  /// | `randomBytes(count)` | base64 of random bytes (`crypto.getRandomValues`) |
@@ -65,7 +67,15 @@ final class HostBridge {
65
67
  private var workers: [Int: any WorkerEngine] = [:]
66
68
  #endif
67
69
  private let logger = Logger(subsystem: "swiftbrowser", category: "js")
70
+ #if canImport(FoundationBridge)
68
71
  private let session = URLSession(configuration: .default)
72
+ #else
73
+ /// Streamed bodies are rebuilt from their parts when a redirect or a challenge asks for the body again (`needNewBodyStream`).
74
+ private let bodyStreams = BodyStreams()
75
+ private lazy var session = URLSession(configuration: .default, delegate: bodyStreams, delegateQueue: nil)
76
+ #endif
77
+ /// The requests under way, by id, for `fetchCancel`.
78
+ private var fetchTasks: [Int: URLSessionDataTask] = [:]
69
79
  #if canImport(MediaPlayer)
70
80
  private var nowPlaying: NowPlayingBridge?
71
81
  #endif
@@ -204,6 +214,18 @@ final class HostBridge {
204
214
  return nil
205
215
  }
206
216
 
217
+ // An `AbortController`'s abort: the task is cancelled and answers nothing.
218
+ host.install("fetchCancel") { [weak self] arguments in
219
+ guard let self, let id = arguments[safe: 0]?.int else { return nil }
220
+ self.fetchTasks.removeValue(forKey: id)?.cancel()
221
+ return nil
222
+ }
223
+
224
+ #if !canImport(FoundationBridge)
225
+ // A body as parts (`bodyParts`): typed arrays beside the request and ranges of files on disk, streamed in order.
226
+ host.install("fetchStreams") { _ in .bool(true) }
227
+ #endif
228
+
207
229
  #if !canImport(FoundationBridge)
208
230
  host.install("probeImage") { [weak self] arguments in
209
231
  guard let self, let id = arguments[safe: 0]?.int, let string = arguments[safe: 1]?.string else { return nil }
@@ -680,7 +702,9 @@ final class HostBridge {
680
702
  // MARK: fetch
681
703
 
682
704
  /// `{ url, method, headers: {name: value}, body, bytes }` in, the body
683
- /// base64 or `{ "$bytes": 0 }` naming a view in `bytes`;
705
+ /// base64 or `{ "$bytes": 0 }` naming a view in `bytes`, or `bodyParts`
706
+ /// (`fetchStreams`): views and ranges of files on disk, streamed in
707
+ /// order without reading the files into memory;
684
708
  /// `{ status, statusText, headers, url, body }` or `{ error }` out, the
685
709
  /// body base64, or a `Uint8Array` after the JSON where the request said
686
710
  /// `bytes: true` (docs/notes/bytes.md).
@@ -697,8 +721,25 @@ final class HostBridge {
697
721
  if let data = request["body"].bytes(from: bytes) {
698
722
  urlRequest.httpBody = data
699
723
  }
724
+ #if !canImport(FoundationBridge)
725
+ var streamedParts: [BodyPart]?
726
+ if let parts = request["bodyParts"].array {
727
+ do {
728
+ streamedParts = try parts.map { try BodyPart(part: $0, bytes: bytes) }
729
+ } catch {
730
+ callIn("__sbFetchResult", [.number(Double(id)), .object(["error": .string(error.localizedDescription)])])
731
+ return
732
+ }
733
+ urlRequest.httpBodyStream = PartsInputStream(parts: streamedParts!)
734
+ if urlRequest.value(forHTTPHeaderField: "Content-Length") == nil {
735
+ urlRequest.setValue(String(streamedParts!.reduce(0) { $0 + $1.length }), forHTTPHeaderField: "Content-Length")
736
+ }
737
+ }
738
+ #endif
700
739
  let answersBytes = host.carriesBytes && request["bytes"].bool == true
701
740
  let task = session.dataTask(with: urlRequest) { [weak self] data, response, error in
741
+ // Cancelled (`fetchCancel`): the app rejected its promise already and hears nothing more.
742
+ if let error, (error as NSError).code == NSURLErrorCancelled, (error as NSError).domain == NSURLErrorDomain { return }
702
743
  let result: JSONValue
703
744
  if let error {
704
745
  result = .object(["error": .string(error.localizedDescription)])
@@ -719,10 +760,15 @@ final class HostBridge {
719
760
  let body = error == nil && answersBytes ? [HostValue.bytes(HostBytes(data ?? Data(), kind: .uint8))] : []
720
761
  DispatchQueue.main.async { [weak self] in
721
762
  MainActor.assumeIsolated {
763
+ self?.fetchTasks.removeValue(forKey: id)
722
764
  self?.callIn("__sbFetchResult", values: [.json(.number(Double(id))), .json(result)] + body)
723
765
  }
724
766
  }
725
767
  }
768
+ fetchTasks[id] = task
769
+ #if !canImport(FoundationBridge)
770
+ if let streamedParts { bodyStreams.register(task, parts: streamedParts) }
771
+ #endif
726
772
  task.resume()
727
773
  }
728
774
  }
@@ -28,7 +28,8 @@ import UniformTypeIdentifiers
28
28
  /// are the web renderer's (docs/ops-protocol.md): `share` (`title`, `text`,
29
29
  /// `url`, `files`: a share sheet; `{ outcome }`), `pickFiles` (`accept`,
30
30
  /// `multiple`: the photo picker for pictures and videos, else the document
31
- /// picker; `{ files: [{ name, type, data }] }`), `clipboard` (`write` with
31
+ /// picker, or with `public.folder` a folder's files, named by their paths in
32
+ /// it; `{ files: [{ name, type, data }] }`), `clipboard` (`write` with
32
33
  /// `text` or `files`, `readText`; `{ text }`), `pickColor` (`color`: the
33
34
  /// color picker; `{ color }`), `webAuth` (`url`, `callbackScheme`,
34
35
  /// `ephemeral`: `ASWebAuthenticationSession`; `{ url }`) and, on iOS,
@@ -128,6 +129,13 @@ final class HostRequestBridge {
128
129
  let value = try await files.handle(params)
129
130
  // The container resolved: from now on its changes reach the app.
130
131
  if params["action"]?.string == "iCloud", value != .null, iCloud == nil { iCloud = ICloudWatcher(send: filesChanged) }
132
+ // What the app itself wrote, made, moved or removed: `fileManager.watch` hears it, as the page's host tells its watchers.
133
+ switch params["action"]?.string {
134
+ case "write", "remove", "createDirectory": if let url = params["url"]?.string { filesChanged([url]) }
135
+ case "move", "copy": filesChanged([params["from"]?.string, params["to"]?.string].compactMap { $0 })
136
+ case "importItem": if let url = params["to"]?.string { filesChanged([url]) }
137
+ default: break
138
+ }
131
139
  return value
132
140
  #endif
133
141
  #if canImport(AuthenticationServices) && !canImport(FoundationBridge)
@@ -202,9 +210,14 @@ final class HostRequestBridge {
202
210
  let folder = FileManager.default.temporaryDirectory.appendingPathComponent("swiftbrowser-share-\(UUID().uuidString)", isDirectory: true)
203
211
  try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
204
212
  return try files.compactMap { file -> URL? in
205
- guard let data = RequestBytes.data(file["data"]) else { return nil }
206
213
  let name = file["name"].string.flatMap { $0.isEmpty ? nil : $0 } ?? "file"
207
214
  let url = folder.appendingPathComponent(name.components(separatedBy: "/").last ?? name)
215
+ // A picked file still on disk (its `url`) is copied; bytes are written.
216
+ if let source = file["url"].string.flatMap(URL.init(string:)), source.isFileURL, PickedFiles.shared.contains(source) {
217
+ try PickedFiles.shared.accessing(source) { try FileManager.default.copyItem(at: source, to: url) }
218
+ return url
219
+ }
220
+ guard let data = RequestBytes.data(file["data"]) else { return nil }
208
221
  try data.write(to: url)
209
222
  return url
210
223
  }
@@ -214,8 +227,43 @@ final class HostRequestBridge {
214
227
  UTType(filenameExtension: url.pathExtension)?.preferredMIMEType ?? "application/octet-stream"
215
228
  }
216
229
 
217
- private static func fileValue(name: String, type: String, data: Data) -> JSONValue {
218
- .object(["name": .string(name), "type": .string(type), "data": RequestBytes.value(data)])
230
+ /// A file still on disk, as the page's `LazyFile` reads it: its URL and size, no bytes (docs/notes/bytes.md).
231
+ static func fileReference(name: String, url: URL) -> JSONValue {
232
+ let values = try? url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey])
233
+ var value: JSONObject = [
234
+ "name": .string(name),
235
+ "type": .string(mimeType(of: url)),
236
+ "url": .string(url.standardizedFileURL.absoluteString),
237
+ "size": .number(Double(values?.fileSize ?? 0)),
238
+ ]
239
+ if let modified = values?.contentModificationDate { value["lastModified"] = .number((modified.timeIntervalSince1970 * 1000).rounded()) }
240
+ return .object(value)
241
+ }
242
+
243
+ /// A chosen folder's files, at any depth, that `types` allow (any when none), each named by its path from the
244
+ /// folder, the folder's own name first (`Bank/Leads/Saw.fxp`), as the page's folder chooser names them. The
245
+ /// files stay on disk: the folder's security scope is kept open (PickedFiles) and the page reads what it needs.
246
+ static func folderFiles(_ folder: URL, types: [UTType]) -> [JSONValue] {
247
+ let scoped = folder.startAccessingSecurityScopedResource()
248
+ defer { if scoped { folder.stopAccessingSecurityScopedResource() } }
249
+ let root = folder.resolvingSymlinksInPath().standardizedFileURL.pathComponents
250
+ guard let walker = FileManager.default.enumerator(at: folder, includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) else { return [] }
251
+ var files: [JSONValue] = []
252
+ var urls: [URL] = []
253
+ for case let url as URL in walker {
254
+ guard (try? url.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true else { continue }
255
+ if !types.isEmpty {
256
+ guard let type = UTType(filenameExtension: url.pathExtension), types.contains(where: { type.conforms(to: $0) }) else { continue }
257
+ }
258
+ let components = url.resolvingSymlinksInPath().standardizedFileURL.pathComponents
259
+ guard components.count > root.count, Array(components.prefix(root.count)) == root else { continue }
260
+ let path = ([folder.lastPathComponent] + components.dropFirst(root.count)).joined(separator: "/")
261
+ files.append(fileReference(name: path, url: url))
262
+ urls.append(url)
263
+ }
264
+ // Scoped again for as long as the app reads the files: `accessing` opens it per read, `release` ends it.
265
+ PickedFiles.shared.register(urls, scoped: scoped ? folder : nil)
266
+ return files
219
267
  }
220
268
 
221
269
  #if canImport(UIKit)
@@ -285,16 +333,18 @@ final class HostRequestBridge {
285
333
  continuation.resume(returning: urls)
286
334
  }
287
335
  keep(delegate)
288
- let picker = UIDocumentPickerViewController(forOpeningContentTypes: accept.types.isEmpty ? [.item] : accept.types, asCopy: true)
289
- picker.allowsMultipleSelection = multiple
336
+ // A folder is opened in place (a folder cannot be copied in), its files read while its scope is open.
337
+ let picker = accept.folder
338
+ ? UIDocumentPickerViewController(forOpeningContentTypes: [.folder], asCopy: false)
339
+ : UIDocumentPickerViewController(forOpeningContentTypes: accept.types.isEmpty ? [.item] : accept.types, asCopy: true)
340
+ picker.allowsMultipleSelection = multiple && !accept.folder
290
341
  picker.delegate = delegate
291
342
  presenter.present(picker, animated: true)
292
343
  }
293
- let files = urls.compactMap { url -> JSONValue? in
294
- guard let data = try? Data(contentsOf: url) else { return nil }
295
- return Self.fileValue(name: url.lastPathComponent, type: Self.mimeType(of: url), data: data)
296
- }
297
- return .object(["files": .array(files)])
344
+ if accept.folder { return .object(["files": .array(urls.first.map { Self.folderFiles($0, types: accept.types) } ?? [])]) }
345
+ // Copied into the app's tmp by the picker (`asCopy: true`): the page gets their URLs and reads them a piece at a time.
346
+ PickedFiles.shared.register(urls)
347
+ return .object(["files": .array(urls.map { Self.fileReference(name: $0.lastPathComponent, url: $0) })])
298
348
  }
299
349
 
300
350
  /// `fileExporter`: the files are written to the temporary directory and the
@@ -366,16 +416,15 @@ final class HostRequestBridge {
366
416
  private func pickFiles(_ params: JSONObject) async throws -> JSONValue {
367
417
  let accept = Self.parseAccept(params["accept"]?.string ?? "")
368
418
  let panel = NSOpenPanel()
369
- panel.allowsMultipleSelection = params["multiple"]?.bool ?? false
370
- panel.canChooseDirectories = false
371
- if !accept.types.isEmpty { panel.allowedContentTypes = accept.types }
419
+ panel.allowsMultipleSelection = !accept.folder && (params["multiple"]?.bool ?? false)
420
+ panel.canChooseDirectories = accept.folder
421
+ panel.canChooseFiles = !accept.folder
422
+ if !accept.folder, !accept.types.isEmpty { panel.allowedContentTypes = accept.types }
372
423
  let response = await panel.begin()
373
424
  guard response == .OK else { return .object(["files": .array([])]) }
374
- let files = panel.urls.compactMap { url -> JSONValue? in
375
- guard let data = try? Data(contentsOf: url) else { return nil }
376
- return Self.fileValue(name: url.lastPathComponent, type: Self.mimeType(of: url), data: data)
377
- }
378
- return .object(["files": .array(files)])
425
+ if accept.folder { return .object(["files": .array(panel.urls.first.map { Self.folderFiles($0, types: accept.types) } ?? [])]) }
426
+ PickedFiles.shared.register(panel.urls)
427
+ return .object(["files": .array(panel.urls.map { Self.fileReference(name: $0.lastPathComponent, url: $0) })])
379
428
  }
380
429
 
381
430
  /// `fileExporter`: a save panel per file; answers `{ outcome: "saved", urls }`, or `cancelled` at the first panel dismissed.
@@ -421,12 +470,15 @@ final class HostRequestBridge {
421
470
  }
422
471
  #endif
423
472
 
424
- /// What a chooser's `accept` asks for: the content types, and whether they are all pictures or videos (the photo library's).
425
- static func parseAccept(_ accept: String) -> (types: [UTType], photos: Bool, images: Bool, videos: Bool) {
426
- let entries = accept.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
473
+ /// What a chooser's `accept` asks for: the content types, and whether they are all pictures or videos (the
474
+ /// photo library's), or a folder (`public.folder`, `UTType.folder`), its files then those the other types allow.
475
+ static func parseAccept(_ accept: String) -> (types: [UTType], photos: Bool, images: Bool, videos: Bool, folder: Bool) {
476
+ var entries = accept.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
477
+ let folder = entries.contains(UTType.folder.identifier)
478
+ entries.removeAll { $0 == UTType.folder.identifier }
427
479
  let images = entries.contains { $0.hasPrefix("image/") }
428
480
  let videos = entries.contains { $0.hasPrefix("video/") }
429
- let photos = !entries.isEmpty && entries.allSatisfy { $0.hasPrefix("image/") || $0.hasPrefix("video/") }
481
+ let photos = !folder && !entries.isEmpty && entries.allSatisfy { $0.hasPrefix("image/") || $0.hasPrefix("video/") }
430
482
  let types = entries.compactMap { entry -> UTType? in
431
483
  if entry.hasPrefix(".") { return UTType(filenameExtension: String(entry.dropFirst())) }
432
484
  switch entry {
@@ -436,7 +488,7 @@ final class HostRequestBridge {
436
488
  default: return UTType(mimeType: entry)
437
489
  }
438
490
  }
439
- return (types, photos, images, videos)
491
+ return (types, photos, images, videos, folder)
440
492
  }
441
493
 
442
494
  /// A picked photo or video as a file: its first picture (or movie) representation, named after the item.
@@ -446,14 +498,31 @@ final class HostRequestBridge {
446
498
  ?? identifiers.first(where: { UTType($0)?.conforms(to: .movie) == true })
447
499
  ?? identifiers.first else { return nil }
448
500
  let type = UTType(identifier)
449
- let data: Data? = await withCheckedContinuation { continuation in
450
- _ = provider.loadDataRepresentation(forTypeIdentifier: identifier) { data, _ in continuation.resume(returning: data) }
451
- }
452
- guard let data else { return nil }
453
501
  let ext = type?.preferredFilenameExtension ?? "bin"
454
502
  let suggested = provider.suggestedName ?? "photo"
455
503
  let name = suggested.contains(".") ? suggested : "\(suggested).\(ext)"
456
- return fileValue(name: name, type: type?.preferredMIMEType ?? "application/octet-stream", data: data)
504
+ // The file the library hands over lives only while the completion runs: copied into a tmp folder of
505
+ // its own, registered (PickedFiles) and answered as a reference, so the page reads it a piece at a time.
506
+ let folder = FileManager.default.temporaryDirectory.appendingPathComponent("swiftbrowser-picks-\(UUID().uuidString)", isDirectory: true)
507
+ let copied: URL? = await withCheckedContinuation { continuation in
508
+ _ = provider.loadFileRepresentation(forTypeIdentifier: identifier) { url, _ in
509
+ guard let url else {
510
+ continuation.resume(returning: nil)
511
+ return
512
+ }
513
+ do {
514
+ try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
515
+ let target = folder.appendingPathComponent(name)
516
+ try FileManager.default.copyItem(at: url, to: target)
517
+ continuation.resume(returning: target)
518
+ } catch {
519
+ continuation.resume(returning: nil)
520
+ }
521
+ }
522
+ }
523
+ guard let copied else { return nil }
524
+ PickedFiles.shared.register([copied], temporary: folder)
525
+ return fileReference(name: name, url: copied)
457
526
  }
458
527
  #endif
459
528
  }
@@ -35,7 +35,7 @@ public struct NativeFunctionError: Error, CustomStringConvertible {
35
35
  }
36
36
 
37
37
  /// Swift the app's JavaScript can call: the function half of the escape hatch
38
- /// (docs/native.md; `NativeRegistry` is the view half). The Swift app
38
+ /// (docs/native.md; `ViewRegistry` is the view half). The Swift app
39
39
  /// registers functions once, before the app loads, and `@swiftbrowser/native`
40
40
  /// calls them: `callNative(name, params)` resolves with what an async function
41
41
  /// returns, through the host request `app:<name>` (HostRequestBridge.swift);
@@ -0,0 +1,57 @@
1
+ #if canImport(SwiftUI)
2
+ import Foundation
3
+ import SwiftBrowserProtocol
4
+ import SwiftBrowserTree
5
+ import SwiftUI
6
+
7
+ /// A view a package or the app draws itself, by name: the `registered`
8
+ /// element kind (docs/native.md), which `RegisteredView` in
9
+ /// @swiftbrowser/react emits. A page registers its own element under the
10
+ /// same name (@swiftbrowser/web's `ViewRegistry`), so one component is a
11
+ /// SwiftUI view here and the package's DOM there, drawing what never crosses
12
+ /// the bridge (an audio node's frames, a map's tiles).
13
+ ///
14
+ /// ```swift
15
+ /// ViewRegistry.register("MapView") { context in
16
+ /// MapView(region: context.props["region"]) { context.send(["tapped": true]) }
17
+ /// }
18
+ /// ```
19
+ ///
20
+ /// ```tsx
21
+ /// <RegisteredView name="MapView" props={{ region }} onEvent={(value) => …} />
22
+ /// ```
23
+ @MainActor
24
+ public enum ViewRegistry {
25
+ /// What a registered view gets: the element's `props` as the app wrote
26
+ /// them, its children (SwiftUI views the app put inside it) and a way to
27
+ /// send a value back, which reaches the element's `onEvent` in JavaScript.
28
+ public struct Context {
29
+ public let id: Int
30
+ public let props: JSONValue
31
+ public let children: [ElementNode]
32
+ public let send: (JSONValue) -> Void
33
+
34
+ /// The children as a view (built on the main actor, as views are).
35
+ @MainActor public var content: some View {
36
+ ForEach(children) { ElementView(node: $0) }
37
+ }
38
+ }
39
+
40
+ public typealias Builder = (Context) -> AnyView
41
+ private static var builders: [String: Builder] = [:]
42
+
43
+ public static func register<V: View>(_ name: String, @ViewBuilder _ builder: @escaping (Context) -> V) {
44
+ builders[name] = { AnyView(builder($0)) }
45
+ }
46
+
47
+ public static func unregister(_ name: String) {
48
+ builders[name] = nil
49
+ }
50
+
51
+ public static func isRegistered(_ name: String) -> Bool { builders[name] != nil }
52
+
53
+ static func view(name: String, context: Context) -> AnyView? {
54
+ builders[name]?(context)
55
+ }
56
+ }
57
+ #endif
@@ -89,8 +89,12 @@ struct ElementView: View {
89
89
  // Shapes and colors
90
90
  case "color": return AnyView(ColorElement(node: node))
91
91
  case "shape": return ShapeElement(node: node).reportingSize(of: node)
92
+ case "canvas": return AnyView(CanvasElement(node: node))
93
+ case "layout": return AnyView(LayoutElement(node: node))
94
+ case "draggable": return AnyView(DraggableElement(node: node))
95
+ case "dropdestination": return AnyView(DropDestinationElement(node: node))
92
96
  // The escape hatch
93
- case "native": return AnyView(NativeElement(node: node))
97
+ case "registered": return AnyView(RegisteredElement(node: node))
94
98
  default: return AnyView(UnsupportedElement(node: node))
95
99
  }
96
100
  }
@@ -131,18 +135,18 @@ struct UnsupportedElement: View {
131
135
  }
132
136
  }
133
137
 
134
- /// The `native` kind: a view the Swift app registered under `name`.
135
- struct NativeElement: View {
138
+ /// The `registered` kind: a view registered under `name` (`ViewRegistry`).
139
+ struct RegisteredElement: View {
136
140
  let node: ElementNode
137
141
  @Environment(\.nativeApp) private var app
138
142
 
139
143
  var body: some View {
140
- let props = NativeProps(props: node.props)
144
+ let props = RegisteredProps(props: node.props)
141
145
  let app = app
142
- let context = NativeViewContext(id: node.id, props: props.props, children: node.children) { value in
143
- app?.dispatch(.native(node.id, name: props.name, value: value))
146
+ let context = ViewRegistry.Context(id: node.id, props: props.props, children: node.children) { value in
147
+ app?.dispatch(.registered(node.id, name: props.name, value: value))
144
148
  }
145
- if let view = NativeRegistry.view(name: props.name, context: context) {
149
+ if let view = ViewRegistry.view(name: props.name, context: context) {
146
150
  view
147
151
  } else {
148
152
  UnsupportedElement(node: node)