@swiftbrowser/native 0.19.0 → 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 +34 -11
- 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 +33 -2
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js.map +1 -1
- package/dist/fetch.d.ts +19 -11
- package/dist/fetch.d.ts.map +1 -1
- package/dist/fetch.js +105 -41
- package/dist/fetch.js.map +1 -1
- package/dist/hostRequests.d.ts +3 -1
- package/dist/hostRequests.d.ts.map +1 -1
- package/dist/hostRequests.js +47 -6
- 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/runtime.js +1 -1
- package/dist/runtime.js.map +1 -1
- package/dist/webSocket.d.ts.map +1 -1
- package/dist/webSocket.js +13 -1
- package/dist/webSocket.js.map +1 -1
- package/package.json +3 -3
- package/src/blob.ts +215 -34
- package/src/bridge.ts +27 -2
- package/src/fetch.ts +99 -41
- package/src/hostRequests.ts +62 -8
- package/src/index.ts +1 -2
- package/src/runtime.ts +1 -1
- package/src/webSocket.ts +10 -1
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ProgramNode.swift +122 -19
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ScopeView.swift +79 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Machine.swift +1247 -209
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Program.swift +314 -19
- 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/BodyStream.swift +166 -0
- package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +141 -7
- package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +47 -1
- package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +98 -29
- 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 +11 -7
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Drawing.swift +378 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +15 -10
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/PopTap.swift +31 -28
- package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +142 -0
- package/swift/Sources/SwiftBrowserProtocol/Drawing.swift +306 -0
- package/swift/Sources/SwiftBrowserProtocol/Props.swift +5 -2
- package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +24 -3
- package/swift/Sources/SwiftBrowserProtocol/Style.swift +87 -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
|
@@ -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
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
|
|
289
|
-
picker
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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 =
|
|
371
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
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; `
|
|
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 "
|
|
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 `
|
|
135
|
-
struct
|
|
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 =
|
|
144
|
+
let props = RegisteredProps(props: node.props)
|
|
141
145
|
let app = app
|
|
142
|
-
let context =
|
|
143
|
-
app?.dispatch(.
|
|
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 =
|
|
149
|
+
if let view = ViewRegistry.view(name: props.name, context: context) {
|
|
146
150
|
view
|
|
147
151
|
} else {
|
|
148
152
|
UnsupportedElement(node: node)
|