@swiftbrowser/native 0.18.3 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -11
- package/bin/swiftbrowser-native.mjs +47 -0
- package/dist/blob.d.ts +85 -11
- package/dist/blob.d.ts.map +1 -1
- package/dist/blob.js +184 -34
- package/dist/blob.js.map +1 -1
- package/dist/bridge.d.ts +66 -8
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js.map +1 -1
- package/dist/encoding.d.ts +13 -0
- package/dist/encoding.d.ts.map +1 -1
- package/dist/encoding.js +24 -0
- package/dist/encoding.js.map +1 -1
- package/dist/fetch.d.ts +27 -13
- package/dist/fetch.d.ts.map +1 -1
- package/dist/fetch.js +117 -45
- package/dist/fetch.js.map +1 -1
- package/dist/hostRequests.d.ts +24 -6
- package/dist/hostRequests.d.ts.map +1 -1
- package/dist/hostRequests.js +108 -15
- package/dist/hostRequests.js.map +1 -1
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -2
- package/dist/index.js.map +1 -1
- package/dist/ios.d.ts +79 -0
- package/dist/ios.d.ts.map +1 -0
- package/dist/ios.js +181 -0
- package/dist/ios.js.map +1 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +16 -7
- package/dist/runtime.js.map +1 -1
- package/dist/subtle.d.ts +1 -1
- package/dist/subtle.d.ts.map +1 -1
- package/dist/subtle.js +9 -5
- package/dist/subtle.js.map +1 -1
- package/dist/webSocket.d.ts +12 -5
- package/dist/webSocket.d.ts.map +1 -1
- package/dist/webSocket.js +37 -11
- package/dist/webSocket.js.map +1 -1
- package/package.json +7 -3
- package/src/blob.ts +215 -34
- package/src/bridge.ts +56 -8
- package/src/encoding.ts +22 -0
- package/src/fetch.ts +111 -48
- package/src/hostRequests.ts +129 -22
- package/src/index.ts +1 -2
- package/src/ios.ts +253 -0
- package/src/runtime.ts +23 -8
- package/src/subtle.ts +10 -6
- package/src/webSocket.ts +37 -13
- package/swift/Packages/SwiftBrowserDSP/Package.swift +31 -0
- package/swift/Packages/SwiftBrowserDSP/README.md +42 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ProgramNode.swift +306 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ScopeView.swift +79 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Machine.swift +1561 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Program.swift +483 -0
- package/swift/Packages/SwiftBrowserLocalModel/Package.swift +35 -0
- package/swift/Packages/SwiftBrowserLocalModel/README.md +66 -0
- package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LlamaEngine.swift +223 -0
- package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LocalLanguageModel.swift +409 -0
- package/swift/Packages/SwiftBrowserShader/Package.swift +31 -0
- package/swift/Packages/SwiftBrowserShader/README.md +45 -0
- package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShader/ShaderRenderer.swift +103 -0
- package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShader/ShaderView.swift +168 -0
- package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShaderCore/ShaderProps.swift +120 -0
- package/swift/Sources/SwiftBrowserNative/Host/AudioEngineBridge.swift +921 -0
- package/swift/Sources/SwiftBrowserNative/Host/AudioEngineNodes.swift +70 -0
- package/swift/Sources/SwiftBrowserNative/Host/BodyStream.swift +166 -0
- package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +144 -10
- package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +151 -27
- package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +139 -40
- package/swift/Sources/SwiftBrowserNative/Host/JSHost.swift +116 -0
- package/swift/Sources/SwiftBrowserNative/Host/JavaScriptCoreHost.swift +141 -2
- package/swift/Sources/SwiftBrowserNative/Host/LanguageModelBridge.swift +511 -0
- package/swift/Sources/SwiftBrowserNative/Host/LanguageModelProvider.swift +195 -0
- package/swift/Sources/SwiftBrowserNative/Host/WebSocketBridge.swift +24 -11
- package/swift/Sources/SwiftBrowserNative/Host/WorkerHost.swift +3 -2
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeFunctions.swift +1 -1
- package/swift/Sources/SwiftBrowserNative/Runtime/ViewRegistry.swift +57 -0
- package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +12 -7
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +18 -1
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Drawing.swift +378 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Lists.swift +41 -1
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/More.swift +88 -17
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +87 -21
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/PinchTracker.swift +200 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/PopTap.swift +103 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +3 -2
- package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +146 -0
- package/swift/Sources/SwiftBrowserProtocol/Drawing.swift +306 -0
- package/swift/Sources/SwiftBrowserProtocol/Props.swift +31 -2
- package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +24 -3
- package/swift/Sources/SwiftBrowserProtocol/Style.swift +87 -0
- package/swift/Sources/SwiftBrowserTree/OpTree.swift +25 -0
- package/wasm/SwiftBrowserNative.wasm +2 -2
- package/dist/native.d.ts +0 -19
- package/dist/native.d.ts.map +0 -1
- package/dist/native.js +0 -17
- package/dist/native.js.map +0 -1
- package/src/native.ts +0 -35
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeRegistry.swift +0 -53
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#if canImport(SwiftUI) && !canImport(FoundationBridge)
|
|
2
|
+
import AVFoundation
|
|
3
|
+
import SwiftBrowserProtocol
|
|
4
|
+
|
|
5
|
+
/// A node of a kind a package adds to the app's `AVAudioEngine`
|
|
6
|
+
/// (AudioEngineBridge.swift): SwiftBrowserDSP's `program`, a synthesizer
|
|
7
|
+
/// rendered on the audio thread. The bridge attaches `node`, gives it the
|
|
8
|
+
/// values the app attaches it with and sets, and hands it the notes a
|
|
9
|
+
/// sampler would take; volume and pan go to the node's `AVAudioMixing`
|
|
10
|
+
/// first, as for the engine's own nodes. An app that adds no such package
|
|
11
|
+
/// pays nothing for this hook.
|
|
12
|
+
@MainActor
|
|
13
|
+
public protocol AudioEngineCustomNode: AnyObject {
|
|
14
|
+
/// What the engine attaches and connects: a source takes no input.
|
|
15
|
+
var node: AVAudioNode { get }
|
|
16
|
+
|
|
17
|
+
/// A property, from `attach`'s values or a `set`: false for one it does
|
|
18
|
+
/// not have. Throws a `HostRequestFailure`-shaped error (`kind: detail`)
|
|
19
|
+
/// for a value it cannot take.
|
|
20
|
+
func set(_ key: String, _ value: JSONValue) throws -> Bool
|
|
21
|
+
|
|
22
|
+
/// `startNote`, `stopNote`, `controller` or `pitchBend`, with the
|
|
23
|
+
/// command's params (`note`, `velocity`, `controller`, `value`,
|
|
24
|
+
/// `channel`): false for a node that takes no notes.
|
|
25
|
+
func note(_ action: String, _ params: JSONObject) throws -> Bool
|
|
26
|
+
|
|
27
|
+
/// The engine stopped or reset, or the node was detached: stop sounding.
|
|
28
|
+
func halt()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public extension AudioEngineCustomNode {
|
|
32
|
+
func note(_ action: String, _ params: JSONObject) throws -> Bool { false }
|
|
33
|
+
func halt() {}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/// What a package's node is made with.
|
|
37
|
+
public struct AudioEngineNodeContext {
|
|
38
|
+
/// The format to render in: stereo at the engine's output rate (or the
|
|
39
|
+
/// offline rendering's format), the one the engine's players send in.
|
|
40
|
+
public let format: AVAudioFormat
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/// The node kinds packages add, by the name the app attaches them with
|
|
44
|
+
/// (@swiftbrowser/web's `registerAudioNodeKind` is the page's side). Register
|
|
45
|
+
/// at launch, before the bundle loads.
|
|
46
|
+
@MainActor
|
|
47
|
+
public enum AudioEngineNodes {
|
|
48
|
+
public typealias Make = @MainActor (AudioEngineNodeContext) throws -> any AudioEngineCustomNode
|
|
49
|
+
|
|
50
|
+
private static var kinds: [String: Make] = [:]
|
|
51
|
+
|
|
52
|
+
/// The engine's own kinds, which a package cannot replace.
|
|
53
|
+
private static let own: Set<String> = ["player", "sampler", "mixer", "reverb", "delay", "distortion", "eq", "timePitch"]
|
|
54
|
+
|
|
55
|
+
/// Adds `kind`, replacing one of the same name a package added before.
|
|
56
|
+
public static func register(_ kind: String, _ make: @escaping Make) {
|
|
57
|
+
precondition(!own.contains(kind), "\(kind) is one of the engine's own node kinds")
|
|
58
|
+
kinds[kind] = make
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// Whether a package added `kind`.
|
|
62
|
+
public static func isRegistered(_ kind: String) -> Bool {
|
|
63
|
+
kinds[kind] != nil
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
static func make(_ kind: String, _ context: AudioEngineNodeContext) throws -> (any AudioEngineCustomNode)? {
|
|
67
|
+
try kinds[kind].map { try $0(context) }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
#endif
|
|
@@ -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
|
-
|
|
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,19 +159,51 @@ 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) }
|
|
139
|
-
return .object(["data": .
|
|
169
|
+
let data = try PickedFiles.shared.accessing(url) { try coordinated(url, writing: nil) { try Data(contentsOf: $0) } }
|
|
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
|
-
guard let data =
|
|
143
|
-
throw HostRequestFailure("unsupported: files: data is
|
|
205
|
+
guard let data = RequestBytes.data(params["data"]) else {
|
|
206
|
+
throw HostRequestFailure("unsupported: files: data is neither bytes nor base64")
|
|
144
207
|
}
|
|
145
208
|
if isDirectory(url) == true { throw HostRequestFailure("isDirectory: \(url.absoluteString) is a directory") }
|
|
146
209
|
try requireDirectory(url.deletingLastPathComponent(), for: url)
|
|
@@ -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
|