@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.
Files changed (64) 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 +122 -19
  35. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ScopeView.swift +79 -0
  36. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Machine.swift +1247 -209
  37. package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Program.swift +314 -19
  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/Drawing.swift +378 -0
  51. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +15 -10
  52. package/swift/Sources/SwiftBrowserNative/Views/Kinds/PopTap.swift +31 -28
  53. package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +142 -0
  54. package/swift/Sources/SwiftBrowserProtocol/Drawing.swift +306 -0
  55. package/swift/Sources/SwiftBrowserProtocol/Props.swift +5 -2
  56. package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +24 -3
  57. package/swift/Sources/SwiftBrowserProtocol/Style.swift +87 -0
  58. package/wasm/SwiftBrowserNative.wasm +2 -2
  59. package/dist/native.d.ts +0 -19
  60. package/dist/native.d.ts.map +0 -1
  61. package/dist/native.js +0 -17
  62. package/dist/native.js.map +0 -1
  63. package/src/native.ts +0 -35
  64. package/swift/Sources/SwiftBrowserNative/Runtime/NativeRegistry.swift +0 -53
@@ -0,0 +1,168 @@
1
+ #if canImport(MetalKit) && canImport(SwiftUI)
2
+ import MetalKit
3
+ import SwiftBrowserNative
4
+ import SwiftBrowserProtocol
5
+ import SwiftUI
6
+
7
+ /// The native side of @swiftbrowser/shader: installs `ShaderView`'s view, an
8
+ /// `MTKView` that draws the shader's Metal over its box.
9
+ ///
10
+ /// ```swift
11
+ /// import SwiftBrowserShader
12
+ ///
13
+ /// init() {
14
+ /// SwiftBrowserShader.install()
15
+ /// }
16
+ /// ```
17
+ @MainActor
18
+ public enum SwiftBrowserShader {
19
+ /// The name `ShaderView` is registered under, on both platforms (view.ts `SHADER_VIEW`).
20
+ public static let viewName = "SwiftBrowserShader.view"
21
+
22
+ /// Registers `ShaderView`'s SwiftUI view; call at launch, before the bundle loads.
23
+ public static func install() {
24
+ ViewRegistry.register(viewName) { context in
25
+ ShaderSurface(props: ShaderProps(props: context.props), send: context.send)
26
+ }
27
+ }
28
+ }
29
+
30
+ /// `ShaderView` (@swiftbrowser/shader): the shader drawn when its props or its size change, or every display frame
31
+ /// when `animated`. A shader its device does not compile draws nothing and says why (`onError`), once.
32
+ struct ShaderSurface: View {
33
+ let props: ShaderProps
34
+ let send: (JSONValue) -> Void
35
+
36
+ var body: some View {
37
+ if let device = SharedDevice.device {
38
+ MetalShaderView(device: device, props: props, send: send)
39
+ } else {
40
+ Color.clear
41
+ }
42
+ }
43
+ }
44
+
45
+ @MainActor
46
+ private enum SharedDevice {
47
+ static let device: MTLDevice? = MTLCreateSystemDefaultDevice()
48
+ }
49
+
50
+ /// Draws for an `MTKView`: on demand (`setNeedsDisplay`) unless animated.
51
+ @MainActor
52
+ final class ShaderDrawing: NSObject, MTKViewDelegate {
53
+ let renderer: ShaderRenderer
54
+ let queue: MTLCommandQueue?
55
+ var props: ShaderProps
56
+ var send: (JSONValue) -> Void
57
+ private var reported = ""
58
+ private var started = Date()
59
+
60
+ init(device: MTLDevice, props: ShaderProps, send: @escaping (JSONValue) -> Void) {
61
+ renderer = ShaderRenderer(device: device)
62
+ queue = device.makeCommandQueue()
63
+ self.props = props
64
+ self.send = send
65
+ }
66
+
67
+ func configure(_ view: MTKView) {
68
+ view.device = renderer.device
69
+ view.colorPixelFormat = renderer.pixelFormat
70
+ view.clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 0)
71
+ view.framebufferOnly = true
72
+ view.delegate = self
73
+ #if os(macOS)
74
+ view.layer?.isOpaque = false
75
+ #else
76
+ view.isOpaque = false
77
+ view.backgroundColor = .clear
78
+ #endif
79
+ update(view)
80
+ }
81
+
82
+ /// New props: drawn at once, or from now on every frame when animated.
83
+ func update(_ view: MTKView) {
84
+ if !props.animated { started = Date() }
85
+ view.isPaused = !props.animated
86
+ view.enableSetNeedsDisplay = !props.animated
87
+ view.preferredFramesPerSecond = 60
88
+ redraw(view)
89
+ }
90
+
91
+ private func redraw(_ view: MTKView) {
92
+ #if os(macOS)
93
+ view.needsDisplay = true
94
+ #else
95
+ view.setNeedsDisplay()
96
+ #endif
97
+ }
98
+
99
+ func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
100
+ redraw(view)
101
+ }
102
+
103
+ func draw(in view: MTKView) {
104
+ do {
105
+ try renderer.prepare(props)
106
+ } catch {
107
+ let message = String(describing: error)
108
+ if message != reported {
109
+ reported = message
110
+ send(.object(["error": .string(message)]))
111
+ }
112
+ }
113
+ let bounds = view.bounds.size
114
+ guard bounds.width > 0, bounds.height > 0, let pass = view.currentRenderPassDescriptor, let drawable = view.currentDrawable,
115
+ let buffer = queue?.makeCommandBuffer(), let encoder = buffer.makeRenderCommandEncoder(descriptor: pass) else { return }
116
+ // The scale the drawable has, so that a point's rows and its pixels line up.
117
+ let scale = view.drawableSize.height / bounds.height
118
+ renderer.draw(props, into: encoder, width: bounds.width, height: bounds.height, scale: scale, time: Date().timeIntervalSince(started))
119
+ encoder.endEncoding()
120
+ buffer.present(drawable)
121
+ buffer.commit()
122
+ }
123
+ }
124
+
125
+ #if os(macOS)
126
+ private struct MetalShaderView: NSViewRepresentable {
127
+ let device: MTLDevice
128
+ let props: ShaderProps
129
+ let send: (JSONValue) -> Void
130
+
131
+ func makeCoordinator() -> ShaderDrawing { ShaderDrawing(device: device, props: props, send: send) }
132
+
133
+ func makeNSView(context: Context) -> MTKView {
134
+ let view = MTKView(frame: .zero, device: device)
135
+ context.coordinator.configure(view)
136
+ return view
137
+ }
138
+
139
+ func updateNSView(_ view: MTKView, context: Context) {
140
+ context.coordinator.send = send
141
+ guard context.coordinator.props != props else { return }
142
+ context.coordinator.props = props
143
+ context.coordinator.update(view)
144
+ }
145
+ }
146
+ #else
147
+ private struct MetalShaderView: UIViewRepresentable {
148
+ let device: MTLDevice
149
+ let props: ShaderProps
150
+ let send: (JSONValue) -> Void
151
+
152
+ func makeCoordinator() -> ShaderDrawing { ShaderDrawing(device: device, props: props, send: send) }
153
+
154
+ func makeUIView(context: Context) -> MTKView {
155
+ let view = MTKView(frame: .zero, device: device)
156
+ context.coordinator.configure(view)
157
+ return view
158
+ }
159
+
160
+ func updateUIView(_ view: MTKView, context: Context) {
161
+ context.coordinator.send = send
162
+ guard context.coordinator.props != props else { return }
163
+ context.coordinator.props = props
164
+ context.coordinator.update(view)
165
+ }
166
+ }
167
+ #endif
168
+ #endif
@@ -0,0 +1,120 @@
1
+ import SwiftBrowserProtocol
2
+
3
+ /// A compiled shader as `ShaderView` sends it natively (@swiftbrowser/shader's
4
+ /// `CompiledShader` with only its Metal): where each uniform's floats are,
5
+ /// how many floats a draw reads, its textures' names in slot order, and the
6
+ /// Metal library (`sb_vertex`, `sb_fragment`).
7
+ public struct ShaderLayout: Equatable, Sendable {
8
+ public struct Uniform: Equatable, Sendable {
9
+ public let name: String
10
+ public let offset: Int
11
+ public let size: Int
12
+ public let value: [Float]
13
+ }
14
+
15
+ public let uniforms: [Uniform]
16
+ public let floats: Int
17
+ public let textures: [String]
18
+ public let msl: String
19
+
20
+ /// The floats every shader starts with (shader.ts `BUILTIN_UNIFORMS`): width, height, scale, time.
21
+ public static let builtins = 4
22
+
23
+ public init?(json: JSONValue) {
24
+ guard let msl = json["msl"].string, let floats = json["floats"].int, floats >= Self.builtins, floats <= 1024 else { return nil }
25
+ var uniforms: [Uniform] = []
26
+ for entry in json["uniforms"].array ?? [] {
27
+ guard let name = entry["name"].string, let offset = entry["offset"].int, let size = entry["size"].int,
28
+ (1...4).contains(size), offset >= Self.builtins, offset + size <= floats else { return nil }
29
+ let value = (entry["value"].array ?? []).map { Float($0.double ?? 0) }
30
+ uniforms.append(Uniform(name: name, offset: offset, size: size, value: value))
31
+ }
32
+ self.uniforms = uniforms
33
+ self.floats = floats
34
+ self.textures = (json["textures"].array ?? []).compactMap(\.string)
35
+ self.msl = msl
36
+ }
37
+ }
38
+
39
+ /// A float texture: `width` × `height` values, row by row (row 0 at the top).
40
+ public struct ShaderTextureData: Equatable, Sendable {
41
+ public let width: Int
42
+ public let height: Int
43
+ public let data: [Float]
44
+
45
+ public init?(json: JSONValue) {
46
+ guard let width = json["width"].int, let height = json["height"].int, width > 0, height > 0,
47
+ let values = json["data"].array, values.count >= width * height else { return nil }
48
+ self.width = width
49
+ self.height = height
50
+ self.data = values.prefix(width * height).map { Float($0.double ?? 0) }
51
+ }
52
+ }
53
+
54
+ /// `ShaderView`'s props (packages/shader/src/view.ts): the shader, its uniforms by name, its textures by slot, and
55
+ /// whether it draws every frame.
56
+ public struct ShaderProps: Equatable, Sendable {
57
+ public let shader: ShaderLayout?
58
+ public let uniforms: JSONObject
59
+ public let textures: [ShaderTextureData?]
60
+ public let animated: Bool
61
+
62
+ public init(props: JSONValue) {
63
+ shader = ShaderLayout(json: props["shader"])
64
+ uniforms = props["uniforms"].object ?? [:]
65
+ textures = (props["textures"].array ?? []).map(ShaderTextureData.init(json:))
66
+ animated = props["animated"].bool ?? false
67
+ }
68
+
69
+ /**
70
+ * The floats a draw reads (shader.ts `packUniforms`): the view's size in points, its pixels a point and the
71
+ * seconds it has run, then each uniform as given (a number, a vector, or a `#rrggbb` color), its default when
72
+ * absent or of another size, a color of three components taking alpha 1 where four are wanted.
73
+ */
74
+ public func packedUniforms(width: Double, height: Double, scale: Double, time: Double) -> [Float] {
75
+ guard let shader else { return [] }
76
+ var floats = [Float](repeating: 0, count: shader.floats)
77
+ floats[0] = Float(width)
78
+ floats[1] = Float(height)
79
+ floats[2] = Float(scale)
80
+ floats[3] = Float(time)
81
+ for uniform in shader.uniforms {
82
+ var given = uniforms[uniform.name].flatMap(Self.floats(of:))
83
+ if let three = given, three.count == 3, uniform.size == 4 { given = three + [1] }
84
+ let value = given.flatMap { $0.count == uniform.size ? $0 : nil } ?? uniform.value
85
+ for i in 0..<uniform.size { floats[uniform.offset + i] = i < value.count ? value[i] : 0 }
86
+ }
87
+ return floats
88
+ }
89
+
90
+ /// A uniform's value as floats: a number, an array of numbers, or a color.
91
+ static func floats(of value: JSONValue) -> [Float]? {
92
+ switch value {
93
+ case .number(let number): return number.isFinite ? [Float(number)] : nil
94
+ case .string(let text): return parseColor(text)
95
+ case .array(let items):
96
+ let numbers = items.compactMap(\.double)
97
+ return numbers.count == items.count ? numbers.map { Float($0) } : nil
98
+ default: return nil
99
+ }
100
+ }
101
+ }
102
+
103
+ /// A `#rrggbb` or `#rrggbbaa` color as 0 … 1 components (shader.ts `parseColor`).
104
+ public func parseColor(_ text: String) -> [Float]? {
105
+ var digits = Substring(text)
106
+ while let first = digits.first, first.isWhitespace { digits = digits.dropFirst() }
107
+ while let last = digits.last, last.isWhitespace { digits = digits.dropLast() }
108
+ guard digits.first == "#" else { return nil }
109
+ digits = digits.dropFirst()
110
+ guard digits.count == 6 || digits.count == 8 else { return nil }
111
+ var components: [Float] = []
112
+ var index = digits.startIndex
113
+ while index < digits.endIndex {
114
+ let next = digits.index(index, offsetBy: 2)
115
+ guard let byte = UInt8(digits[index..<next], radix: 16) else { return nil }
116
+ components.append(Float(byte) / 255)
117
+ index = next
118
+ }
119
+ return components
120
+ }
@@ -0,0 +1,166 @@
1
+ #if canImport(SwiftUI)
2
+ import Foundation
3
+
4
+ /// A request body streamed from its parts: bytes the page sent beside the
5
+ /// request, and ranges of files on disk (a picked file, a dropped one, one in
6
+ /// the app's directories), read a piece at a time through a `FileHandle` as
7
+ /// `URLSession` asks for them, so a body of gigabytes costs no memory. The
8
+ /// files are those the app may read (`FilesBridge.readable`); a folder opened
9
+ /// in place keeps its security scope for the read.
10
+ enum BodyPart: Sendable {
11
+ case bytes(Data)
12
+ case file(URL, start: Int, end: Int)
13
+
14
+ var length: Int {
15
+ switch self {
16
+ case .bytes(let data): return data.count
17
+ case .file(_, let start, let end): return max(0, end - start)
18
+ }
19
+ }
20
+
21
+ /// `{ "$bytes": n }` (the nth view beside the request) or `{ url, start, end }` of a file the app may read.
22
+ init(part: JSONValue, bytes: [HostBytes]) throws {
23
+ if let index = part["$bytes"].int {
24
+ guard bytes.indices.contains(index) else { throw HostRequestFailure("unsupported: body part \(index) was not sent") }
25
+ self = .bytes(bytes[index].data)
26
+ return
27
+ }
28
+ guard let urlString = part["url"].string, let start = part["start"].int, let end = part["end"].int else {
29
+ throw HostRequestFailure("unsupported: a body part is neither bytes nor a file range")
30
+ }
31
+ #if !canImport(FoundationBridge)
32
+ guard let url = FilesBridge.readableURL(urlString) else { throw HostRequestFailure("unsupported: \(urlString) is not a file the app may read") }
33
+ self = .file(url, start: max(0, start), end: max(0, end))
34
+ #else
35
+ throw HostRequestFailure("unsupported: a body streamed from a file needs the native files bridge")
36
+ #endif
37
+ }
38
+ }
39
+
40
+ #if !canImport(FoundationBridge)
41
+ /// An `InputStream` over the parts in order, for `URLRequest.httpBodyStream`.
42
+ final class PartsInputStream: InputStream, @unchecked Sendable {
43
+ private let parts: [BodyPart]
44
+ private var index = 0
45
+ private var offset = 0
46
+ private var handle: FileHandle?
47
+ private var stopAccess: (() -> Void)?
48
+ private var status: Stream.Status = .notOpen
49
+ private var error: Error?
50
+ private weak var delegate_: StreamDelegate?
51
+
52
+ init(parts: [BodyPart]) {
53
+ self.parts = parts
54
+ super.init(data: Data())
55
+ }
56
+
57
+ override var streamStatus: Stream.Status { status }
58
+ override var streamError: Error? { error }
59
+ override var delegate: StreamDelegate? {
60
+ get { delegate_ }
61
+ set { delegate_ = newValue }
62
+ }
63
+ override var hasBytesAvailable: Bool { status == .open && index < parts.count }
64
+
65
+ override func open() {
66
+ status = .open
67
+ }
68
+
69
+ override func close() {
70
+ try? handle?.close()
71
+ handle = nil
72
+ stopAccess?()
73
+ stopAccess = nil
74
+ status = .closed
75
+ }
76
+
77
+ override func property(forKey key: Stream.PropertyKey) -> Any? { nil }
78
+ override func setProperty(_ property: Any?, forKey key: Stream.PropertyKey) -> Bool { false }
79
+ override func schedule(in aRunLoop: RunLoop, forMode mode: RunLoop.Mode) {}
80
+ override func remove(from aRunLoop: RunLoop, forMode mode: RunLoop.Mode) {}
81
+
82
+ /// Fills `buffer` from the current part, moving to the next as each ends; 0 at the end, -1 on a read error.
83
+ override func read(_ buffer: UnsafeMutablePointer<UInt8>, maxLength: Int) -> Int {
84
+ guard status == .open else { return -1 }
85
+ while index < parts.count {
86
+ let part = parts[index]
87
+ let remaining = part.length - offset
88
+ if remaining <= 0 {
89
+ advance()
90
+ continue
91
+ }
92
+ let count = min(maxLength, remaining)
93
+ switch part {
94
+ case .bytes(let data):
95
+ _ = data.copyBytes(to: buffer, from: offset..<(offset + count))
96
+ offset += count
97
+ return count
98
+ case .file(let url, let start, _):
99
+ do {
100
+ let handle = try openHandle(url, at: start + offset)
101
+ guard let data = try handle.read(upToCount: count), !data.isEmpty else {
102
+ throw HostRequestFailure("unavailable: \(url.lastPathComponent) ended before its range")
103
+ }
104
+ _ = data.copyBytes(to: buffer, count: data.count)
105
+ offset += data.count
106
+ return data.count
107
+ } catch {
108
+ self.error = error
109
+ status = .error
110
+ return -1
111
+ }
112
+ }
113
+ }
114
+ status = .atEnd
115
+ return 0
116
+ }
117
+
118
+ private func openHandle(_ url: URL, at position: Int) throws -> FileHandle {
119
+ if let handle { return handle }
120
+ #if !canImport(FoundationBridge)
121
+ stopAccess = PickedFiles.shared.beginAccessing(url)
122
+ #endif
123
+ let handle = try FileHandle(forReadingFrom: url)
124
+ try handle.seek(toOffset: UInt64(position))
125
+ self.handle = handle
126
+ return handle
127
+ }
128
+
129
+ private func advance() {
130
+ try? handle?.close()
131
+ handle = nil
132
+ stopAccess?()
133
+ stopAccess = nil
134
+ index += 1
135
+ offset = 0
136
+ }
137
+ }
138
+
139
+ /// The session delegate that makes a fresh stream of a task's parts when
140
+ /// `URLSession` must send the body again (a redirect, an authentication
141
+ /// challenge): a stream cannot be rewound, its parts can be read again.
142
+ final class BodyStreams: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
143
+ private let lock = NSLock()
144
+ private var parts: [Int: [BodyPart]] = [:]
145
+
146
+ func register(_ task: URLSessionTask, parts: [BodyPart]) {
147
+ lock.lock()
148
+ self.parts[task.taskIdentifier] = parts
149
+ lock.unlock()
150
+ }
151
+
152
+ func urlSession(_ session: URLSession, task: URLSessionTask, needNewBodyStream completionHandler: @escaping (InputStream?) -> Void) {
153
+ lock.lock()
154
+ let parts = parts[task.taskIdentifier]
155
+ lock.unlock()
156
+ completionHandler(parts.map { PartsInputStream(parts: $0) })
157
+ }
158
+
159
+ func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
160
+ lock.lock()
161
+ parts[task.taskIdentifier] = nil
162
+ lock.unlock()
163
+ }
164
+ }
165
+ #endif
166
+ #endif
@@ -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