@dvai-bridge/ios 4.0.0 → 4.0.2

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 (41) hide show
  1. package/Package.swift +104 -104
  2. package/ios/Sources/DVAIBridge/BackendKind.swift +23 -23
  3. package/ios/Sources/DVAIBridge/BoundServer.swift +46 -46
  4. package/ios/Sources/DVAIBridge/DVAIBridge.swift +658 -658
  5. package/ios/Sources/DVAIBridge/DVAIBridgeConfig.swift +86 -86
  6. package/ios/Sources/DVAIBridge/DVAIBridgeError.swift +33 -33
  7. package/ios/Sources/DVAIBridge/Internal/BackendSelector.swift +59 -59
  8. package/ios/Sources/DVAIBridge/Internal/ProgressBroadcaster.swift +84 -84
  9. package/ios/Sources/DVAIBridge/License/Audience.swift +133 -133
  10. package/ios/Sources/DVAIBridge/License/Discovery.swift +164 -164
  11. package/ios/Sources/DVAIBridge/License/LicenseValidator.swift +392 -392
  12. package/ios/Sources/DVAIBridge/License/PublicKeys.swift +114 -114
  13. package/ios/Sources/DVAIBridge/License/Types.swift +195 -195
  14. package/ios/Sources/DVAIBridge/Offload/OffloadConfig.swift +118 -118
  15. package/ios/Sources/DVAIBridge/ProgressEvent.swift +34 -34
  16. package/ios/Sources/DVAICoreMLCore/CoreMLBackendError.swift +19 -19
  17. package/ios/Sources/DVAICoreMLCore/CoreMLHandlers.swift +123 -123
  18. package/ios/Sources/DVAICoreMLCore/CoreMLPluginState.swift +130 -130
  19. package/ios/Sources/DVAICoreMLCore/Internal/CoreMLEngine.swift +137 -137
  20. package/ios/Sources/DVAICoreMLCore/Internal/CoreMLGenerator.swift +108 -108
  21. package/ios/Sources/DVAICoreMLCore/Internal/CoreMLSampler.swift +96 -96
  22. package/ios/Sources/DVAICoreMLCore/Internal/CoreMLTokenizer.swift +69 -69
  23. package/ios/Tests/DVAIBridgeTests/BackendSelectorTests.swift +53 -53
  24. package/ios/Tests/DVAIBridgeTests/CoreMLEngineTests.swift +18 -18
  25. package/ios/Tests/DVAIBridgeTests/CoreMLGeneratorShapeTests.swift +11 -11
  26. package/ios/Tests/DVAIBridgeTests/CoreMLHandlersTests.swift +32 -32
  27. package/ios/Tests/DVAIBridgeTests/CoreMLPluginStateTests.swift +41 -41
  28. package/ios/Tests/DVAIBridgeTests/CoreMLSamplerTests.swift +40 -40
  29. package/ios/Tests/DVAIBridgeTests/CoreMLTokenizerTests.swift +19 -19
  30. package/ios/Tests/DVAIBridgeTests/DVAIBridgeAPIShapeTests.swift +37 -37
  31. package/ios/Tests/DVAIBridgeTests/DVAIBridgeConfigTests.swift +52 -52
  32. package/ios/Tests/DVAIBridgeTests/DVAIBridgeErrorTests.swift +33 -33
  33. package/ios/Tests/DVAIBridgeTests/LicenseValidatorTests.swift +658 -658
  34. package/ios/Tests/DVAIBridgeTests/ProgressBroadcasterTests.swift +69 -69
  35. package/ios/Tests/DVAIBridgeTests/ProgressEventTests.swift +25 -25
  36. package/ios/Tests/DVAIBridgeTests/ReactiveStateTests.swift +45 -45
  37. package/ios/Tests/DVAIBridgeTests/RealModelIntegrationTest.swift +385 -359
  38. package/package.json +3 -4
  39. package/DVAIBridge.podspec +0 -120
  40. package/LICENSE +0 -51
  41. package/README.md +0 -199
@@ -1,86 +1,86 @@
1
- import Foundation
2
-
3
- public struct DVAIBridgeConfig: Sendable {
4
- public enum CORSOrigin: Sendable {
5
- case wildcard
6
- case exact(String)
7
- case allowlist([String])
8
- }
9
-
10
- public var backend: BackendKind
11
- public var modelPath: String?
12
- public var mmprojPath: String?
13
- public var tokenizerPath: String?
14
- public var gpuLayers: Int
15
- public var contextSize: Int
16
- public var threads: Int
17
- public var embeddingMode: Bool
18
- public var httpBasePort: Int
19
- public var httpMaxPortAttempts: Int
20
- public var corsOrigin: CORSOrigin
21
- public var autoUnloadOnLowMemory: Bool
22
- public var logLevel: String // "silent" | "info" | "debug" — matches the Capacitor surface
23
-
24
- public init(
25
- backend: BackendKind = .auto,
26
- modelPath: String? = nil,
27
- mmprojPath: String? = nil,
28
- tokenizerPath: String? = nil,
29
- gpuLayers: Int = 99,
30
- contextSize: Int = 2048,
31
- threads: Int = 4,
32
- embeddingMode: Bool = false,
33
- httpBasePort: Int = 38883,
34
- httpMaxPortAttempts: Int = 16,
35
- corsOrigin: CORSOrigin = .wildcard,
36
- autoUnloadOnLowMemory: Bool = false,
37
- logLevel: String = "info"
38
- ) {
39
- self.backend = backend
40
- self.modelPath = modelPath
41
- self.mmprojPath = mmprojPath
42
- self.tokenizerPath = tokenizerPath
43
- self.gpuLayers = gpuLayers
44
- self.contextSize = contextSize
45
- self.threads = threads
46
- self.embeddingMode = embeddingMode
47
- self.httpBasePort = httpBasePort
48
- self.httpMaxPortAttempts = httpMaxPortAttempts
49
- self.corsOrigin = corsOrigin
50
- self.autoUnloadOnLowMemory = autoUnloadOnLowMemory
51
- self.logLevel = logLevel
52
- }
53
-
54
- /// v3.2 — copy with `httpBasePort` overridden. Used by the
55
- /// OffloadProxy lifecycle to push the backend off the user-facing
56
- /// port (proxy claims `httpBasePort`, backend gets `httpBasePort + 100`).
57
- public func with(httpBasePort newPort: Int) -> DVAIBridgeConfig {
58
- var copy = self
59
- copy.httpBasePort = newPort
60
- return copy
61
- }
62
-
63
- /// Translate this config into the `[String: Any]` shape the underlying
64
- /// core PluginStates expect (matches the Capacitor JSObject shape).
65
- internal func toCoreOpts() -> [String: Any] {
66
- var opts: [String: Any] = [
67
- "gpuLayers": gpuLayers,
68
- "contextSize": contextSize,
69
- "threads": threads,
70
- "embeddingMode": embeddingMode,
71
- "httpBasePort": httpBasePort,
72
- "httpMaxPortAttempts": httpMaxPortAttempts,
73
- "autoUnloadOnLowMemory": autoUnloadOnLowMemory,
74
- "logLevel": logLevel,
75
- ]
76
- if let modelPath { opts["modelPath"] = modelPath }
77
- if let mmprojPath { opts["mmprojPath"] = mmprojPath }
78
- if let tokenizerPath { opts["tokenizerPath"] = tokenizerPath }
79
- switch corsOrigin {
80
- case .wildcard: opts["corsOrigin"] = "*"
81
- case .exact(let s): opts["corsOrigin"] = s
82
- case .allowlist(let xs): opts["corsOrigin"] = xs
83
- }
84
- return opts
85
- }
86
- }
1
+ import Foundation
2
+
3
+ public struct DVAIBridgeConfig: Sendable {
4
+ public enum CORSOrigin: Sendable {
5
+ case wildcard
6
+ case exact(String)
7
+ case allowlist([String])
8
+ }
9
+
10
+ public var backend: BackendKind
11
+ public var modelPath: String?
12
+ public var mmprojPath: String?
13
+ public var tokenizerPath: String?
14
+ public var gpuLayers: Int
15
+ public var contextSize: Int
16
+ public var threads: Int
17
+ public var embeddingMode: Bool
18
+ public var httpBasePort: Int
19
+ public var httpMaxPortAttempts: Int
20
+ public var corsOrigin: CORSOrigin
21
+ public var autoUnloadOnLowMemory: Bool
22
+ public var logLevel: String // "silent" | "info" | "debug" — matches the Capacitor surface
23
+
24
+ public init(
25
+ backend: BackendKind = .auto,
26
+ modelPath: String? = nil,
27
+ mmprojPath: String? = nil,
28
+ tokenizerPath: String? = nil,
29
+ gpuLayers: Int = 99,
30
+ contextSize: Int = 2048,
31
+ threads: Int = 4,
32
+ embeddingMode: Bool = false,
33
+ httpBasePort: Int = 38883,
34
+ httpMaxPortAttempts: Int = 16,
35
+ corsOrigin: CORSOrigin = .wildcard,
36
+ autoUnloadOnLowMemory: Bool = false,
37
+ logLevel: String = "info"
38
+ ) {
39
+ self.backend = backend
40
+ self.modelPath = modelPath
41
+ self.mmprojPath = mmprojPath
42
+ self.tokenizerPath = tokenizerPath
43
+ self.gpuLayers = gpuLayers
44
+ self.contextSize = contextSize
45
+ self.threads = threads
46
+ self.embeddingMode = embeddingMode
47
+ self.httpBasePort = httpBasePort
48
+ self.httpMaxPortAttempts = httpMaxPortAttempts
49
+ self.corsOrigin = corsOrigin
50
+ self.autoUnloadOnLowMemory = autoUnloadOnLowMemory
51
+ self.logLevel = logLevel
52
+ }
53
+
54
+ /// v3.2 — copy with `httpBasePort` overridden. Used by the
55
+ /// OffloadProxy lifecycle to push the backend off the user-facing
56
+ /// port (proxy claims `httpBasePort`, backend gets `httpBasePort + 100`).
57
+ public func with(httpBasePort newPort: Int) -> DVAIBridgeConfig {
58
+ var copy = self
59
+ copy.httpBasePort = newPort
60
+ return copy
61
+ }
62
+
63
+ /// Translate this config into the `[String: Any]` shape the underlying
64
+ /// core PluginStates expect (matches the Capacitor JSObject shape).
65
+ internal func toCoreOpts() -> [String: Any] {
66
+ var opts: [String: Any] = [
67
+ "gpuLayers": gpuLayers,
68
+ "contextSize": contextSize,
69
+ "threads": threads,
70
+ "embeddingMode": embeddingMode,
71
+ "httpBasePort": httpBasePort,
72
+ "httpMaxPortAttempts": httpMaxPortAttempts,
73
+ "autoUnloadOnLowMemory": autoUnloadOnLowMemory,
74
+ "logLevel": logLevel,
75
+ ]
76
+ if let modelPath { opts["modelPath"] = modelPath }
77
+ if let mmprojPath { opts["mmprojPath"] = mmprojPath }
78
+ if let tokenizerPath { opts["tokenizerPath"] = tokenizerPath }
79
+ switch corsOrigin {
80
+ case .wildcard: opts["corsOrigin"] = "*"
81
+ case .exact(let s): opts["corsOrigin"] = s
82
+ case .allowlist(let xs): opts["corsOrigin"] = xs
83
+ }
84
+ return opts
85
+ }
86
+ }
@@ -1,33 +1,33 @@
1
- import Foundation
2
-
3
- public enum DVAIBridgeError: Error, LocalizedError, Sendable {
4
- case notStarted
5
- case alreadyStarted(currentBackend: BackendKind, baseUrl: String)
6
- case configurationInvalid(reason: String)
7
- case backendUnavailable(BackendKind, reason: String)
8
- case modelLoadFailed(reason: String)
9
- case downloadFailed(reason: String)
10
- case checksumMismatch
11
- case backendError(underlying: String)
12
-
13
- public var errorDescription: String? {
14
- switch self {
15
- case .notStarted:
16
- return "DVAIBridge has not been started. Call DVAIBridge.shared.start(...) first."
17
- case .alreadyStarted(let backend, let baseUrl):
18
- return "DVAIBridge is already running with backend \(backend) at \(baseUrl). Call stop() before starting a new session."
19
- case .configurationInvalid(let reason):
20
- return "Configuration invalid: \(reason)"
21
- case .backendUnavailable(let backend, let reason):
22
- return "Backend \(backend) is unavailable on this device: \(reason)"
23
- case .modelLoadFailed(let reason):
24
- return "Model load failed: \(reason)"
25
- case .downloadFailed(let reason):
26
- return "Download failed: \(reason)"
27
- case .checksumMismatch:
28
- return "Downloaded file's SHA-256 didn't match the expected value. The file has been deleted from the cache."
29
- case .backendError(let msg):
30
- return "Backend error: \(msg)"
31
- }
32
- }
33
- }
1
+ import Foundation
2
+
3
+ public enum DVAIBridgeError: Error, LocalizedError, Sendable {
4
+ case notStarted
5
+ case alreadyStarted(currentBackend: BackendKind, baseUrl: String)
6
+ case configurationInvalid(reason: String)
7
+ case backendUnavailable(BackendKind, reason: String)
8
+ case modelLoadFailed(reason: String)
9
+ case downloadFailed(reason: String)
10
+ case checksumMismatch
11
+ case backendError(underlying: String)
12
+
13
+ public var errorDescription: String? {
14
+ switch self {
15
+ case .notStarted:
16
+ return "DVAIBridge has not been started. Call DVAIBridge.shared.start(...) first."
17
+ case .alreadyStarted(let backend, let baseUrl):
18
+ return "DVAIBridge is already running with backend \(backend) at \(baseUrl). Call stop() before starting a new session."
19
+ case .configurationInvalid(let reason):
20
+ return "Configuration invalid: \(reason)"
21
+ case .backendUnavailable(let backend, let reason):
22
+ return "Backend \(backend) is unavailable on this device: \(reason)"
23
+ case .modelLoadFailed(let reason):
24
+ return "Model load failed: \(reason)"
25
+ case .downloadFailed(let reason):
26
+ return "Download failed: \(reason)"
27
+ case .checksumMismatch:
28
+ return "Downloaded file's SHA-256 didn't match the expected value. The file has been deleted from the cache."
29
+ case .backendError(let msg):
30
+ return "Backend error: \(msg)"
31
+ }
32
+ }
33
+ }
@@ -1,59 +1,59 @@
1
- import Foundation
2
-
3
- internal enum BackendSelector {
4
- /// Resolve `.auto` to a concrete backend; pass-through for explicit choices.
5
- /// - Throws `DVAIBridgeError.configurationInvalid` if `.auto` can't decide.
6
- static func resolve(_ kind: BackendKind, config: DVAIBridgeConfig) throws -> BackendKind {
7
- if kind != .auto { return kind }
8
-
9
- // 1. modelPath ending in .gguf → .llama
10
- if let path = config.modelPath, path.hasSuffix(".gguf") {
11
- return .llama
12
- }
13
-
14
- // 2. modelPath ending in .mlmodelc / .mlpackage → .coreml
15
- if let path = config.modelPath,
16
- path.hasSuffix(".mlmodelc") || path.hasSuffix(".mlpackage") {
17
- return .coreml
18
- }
19
-
20
- // 3. modelPath ending in .task / .litertlm → no iOS backend supports
21
- // those; fall through to error
22
- if let path = config.modelPath,
23
- path.hasSuffix(".task") || path.hasSuffix(".litertlm") {
24
- throw DVAIBridgeError.configurationInvalid(reason:
25
- "Model file '\(path)' is a MediaPipe / LiteRT-LM format. " +
26
- "Use it via the Android SDK; iOS supports llama.cpp (.gguf), " +
27
- "Apple Foundation Models (no file), and CoreML (.mlmodelc / .mlpackage).")
28
- }
29
-
30
- // 4. No modelPath + iOS 26+ → .foundation
31
- if config.modelPath == nil {
32
- if #available(iOS 26.0, macOS 26.0, *) {
33
- return .foundation
34
- }
35
- throw DVAIBridgeError.configurationInvalid(reason:
36
- "auto backend requires either modelPath (for .llama / .coreml) " +
37
- "or iOS 26+ (for .foundation). Set DVAIBridgeConfig.backend explicitly.")
38
- }
39
-
40
- // 5. modelPath looks like a HuggingFace id ("<owner>/<repo>" with no
41
- // file extension) → likely MLX. Don't auto-resolve here because
42
- // not every HF id is MLX (could be GGUF in a HF repo etc.) and
43
- // .mlx requires Apple Silicon at runtime. Provide a clear hint.
44
- if let path = config.modelPath,
45
- path.contains("/"),
46
- !path.contains(".") {
47
- throw DVAIBridgeError.configurationInvalid(reason:
48
- "modelPath '\(path)' looks like a HuggingFace identifier. " +
49
- "If this is an MLX-converted checkpoint (e.g. 'mlx-community/...'), " +
50
- "set DVAIBridgeConfig.backend = .mlx explicitly — `.auto` won't " +
51
- "infer MLX because not every HF id is an MLX checkpoint.")
52
- }
53
-
54
- // 6. Unknown extension
55
- throw DVAIBridgeError.configurationInvalid(reason:
56
- "auto backend can't infer from modelPath '\(config.modelPath ?? "<nil>")'. " +
57
- "Set DVAIBridgeConfig.backend = .llama / .foundation / .coreml / .mlx explicitly.")
58
- }
59
- }
1
+ import Foundation
2
+
3
+ internal enum BackendSelector {
4
+ /// Resolve `.auto` to a concrete backend; pass-through for explicit choices.
5
+ /// - Throws `DVAIBridgeError.configurationInvalid` if `.auto` can't decide.
6
+ static func resolve(_ kind: BackendKind, config: DVAIBridgeConfig) throws -> BackendKind {
7
+ if kind != .auto { return kind }
8
+
9
+ // 1. modelPath ending in .gguf → .llama
10
+ if let path = config.modelPath, path.hasSuffix(".gguf") {
11
+ return .llama
12
+ }
13
+
14
+ // 2. modelPath ending in .mlmodelc / .mlpackage → .coreml
15
+ if let path = config.modelPath,
16
+ path.hasSuffix(".mlmodelc") || path.hasSuffix(".mlpackage") {
17
+ return .coreml
18
+ }
19
+
20
+ // 3. modelPath ending in .task / .litertlm → no iOS backend supports
21
+ // those; fall through to error
22
+ if let path = config.modelPath,
23
+ path.hasSuffix(".task") || path.hasSuffix(".litertlm") {
24
+ throw DVAIBridgeError.configurationInvalid(reason:
25
+ "Model file '\(path)' is a MediaPipe / LiteRT-LM format. " +
26
+ "Use it via the Android SDK; iOS supports llama.cpp (.gguf), " +
27
+ "Apple Foundation Models (no file), and CoreML (.mlmodelc / .mlpackage).")
28
+ }
29
+
30
+ // 4. No modelPath + iOS 26+ → .foundation
31
+ if config.modelPath == nil {
32
+ if #available(iOS 26.0, macOS 26.0, *) {
33
+ return .foundation
34
+ }
35
+ throw DVAIBridgeError.configurationInvalid(reason:
36
+ "auto backend requires either modelPath (for .llama / .coreml) " +
37
+ "or iOS 26+ (for .foundation). Set DVAIBridgeConfig.backend explicitly.")
38
+ }
39
+
40
+ // 5. modelPath looks like a HuggingFace id ("<owner>/<repo>" with no
41
+ // file extension) → likely MLX. Don't auto-resolve here because
42
+ // not every HF id is MLX (could be GGUF in a HF repo etc.) and
43
+ // .mlx requires Apple Silicon at runtime. Provide a clear hint.
44
+ if let path = config.modelPath,
45
+ path.contains("/"),
46
+ !path.contains(".") {
47
+ throw DVAIBridgeError.configurationInvalid(reason:
48
+ "modelPath '\(path)' looks like a HuggingFace identifier. " +
49
+ "If this is an MLX-converted checkpoint (e.g. 'mlx-community/...'), " +
50
+ "set DVAIBridgeConfig.backend = .mlx explicitly — `.auto` won't " +
51
+ "infer MLX because not every HF id is an MLX checkpoint.")
52
+ }
53
+
54
+ // 6. Unknown extension
55
+ throw DVAIBridgeError.configurationInvalid(reason:
56
+ "auto backend can't infer from modelPath '\(config.modelPath ?? "<nil>")'. " +
57
+ "Set DVAIBridgeConfig.backend = .llama / .foundation / .coreml / .mlx explicitly.")
58
+ }
59
+ }
@@ -1,84 +1,84 @@
1
- import Foundation
2
- import Combine
3
-
4
- /// Internal event broadcaster. Backs three public observation surfaces:
5
- /// `progressPublisher` (Combine), `progressStream` (AsyncStream), and
6
- /// `addProgressListener(_:)` (callback). All three observe the same source.
7
- internal final class ProgressBroadcaster: @unchecked Sendable {
8
- // Combine
9
- private let subject = PassthroughSubject<ProgressEvent, Never>()
10
- var publisher: AnyPublisher<ProgressEvent, Never> { subject.eraseToAnyPublisher() }
11
-
12
- // AsyncStream — one continuation per consumer
13
- private let lock = NSLock()
14
- private var continuations: [UUID: AsyncStream<ProgressEvent>.Continuation] = [:]
15
-
16
- // Callback — one entry per addProgressListener call
17
- private var callbacks: [UUID: @Sendable (ProgressEvent) -> Void] = [:]
18
-
19
- func emit(_ event: ProgressEvent) {
20
- subject.send(event)
21
-
22
- lock.lock()
23
- let conts = continuations.values
24
- let cbs = Array(callbacks.values)
25
- lock.unlock()
26
-
27
- for cont in conts { cont.yield(event) }
28
- for cb in cbs { cb(event) }
29
- }
30
-
31
- func makeStream() -> AsyncStream<ProgressEvent> {
32
- let id = UUID()
33
- return AsyncStream { continuation in
34
- lock.lock()
35
- continuations[id] = continuation
36
- lock.unlock()
37
-
38
- continuation.onTermination = { [weak self] _ in
39
- self?.lock.lock()
40
- self?.continuations.removeValue(forKey: id)
41
- self?.lock.unlock()
42
- }
43
- }
44
- }
45
-
46
- @discardableResult
47
- func addCallback(_ cb: @escaping @Sendable (ProgressEvent) -> Void) -> CancellationToken {
48
- let id = UUID()
49
- lock.lock()
50
- callbacks[id] = cb
51
- lock.unlock()
52
-
53
- return CancellationToken { [weak self] in
54
- self?.lock.lock()
55
- self?.callbacks.removeValue(forKey: id)
56
- self?.lock.unlock()
57
- }
58
- }
59
- }
60
-
61
- /// Caller-held token returned by `addProgressListener(_:)`. Drop or call
62
- /// `.cancel()` to stop receiving events.
63
- public final class CancellationToken: @unchecked Sendable {
64
- private let cancelClosure: @Sendable () -> Void
65
- private var cancelled = false
66
- private let lock = NSLock()
67
-
68
- internal init(cancel: @escaping @Sendable () -> Void) {
69
- self.cancelClosure = cancel
70
- }
71
-
72
- public func cancel() {
73
- lock.lock()
74
- defer { lock.unlock() }
75
- if !cancelled {
76
- cancelled = true
77
- cancelClosure()
78
- }
79
- }
80
-
81
- deinit {
82
- cancel()
83
- }
84
- }
1
+ import Foundation
2
+ import Combine
3
+
4
+ /// Internal event broadcaster. Backs three public observation surfaces:
5
+ /// `progressPublisher` (Combine), `progressStream` (AsyncStream), and
6
+ /// `addProgressListener(_:)` (callback). All three observe the same source.
7
+ internal final class ProgressBroadcaster: @unchecked Sendable {
8
+ // Combine
9
+ private let subject = PassthroughSubject<ProgressEvent, Never>()
10
+ var publisher: AnyPublisher<ProgressEvent, Never> { subject.eraseToAnyPublisher() }
11
+
12
+ // AsyncStream — one continuation per consumer
13
+ private let lock = NSLock()
14
+ private var continuations: [UUID: AsyncStream<ProgressEvent>.Continuation] = [:]
15
+
16
+ // Callback — one entry per addProgressListener call
17
+ private var callbacks: [UUID: @Sendable (ProgressEvent) -> Void] = [:]
18
+
19
+ func emit(_ event: ProgressEvent) {
20
+ subject.send(event)
21
+
22
+ lock.lock()
23
+ let conts = continuations.values
24
+ let cbs = Array(callbacks.values)
25
+ lock.unlock()
26
+
27
+ for cont in conts { cont.yield(event) }
28
+ for cb in cbs { cb(event) }
29
+ }
30
+
31
+ func makeStream() -> AsyncStream<ProgressEvent> {
32
+ let id = UUID()
33
+ return AsyncStream { continuation in
34
+ lock.lock()
35
+ continuations[id] = continuation
36
+ lock.unlock()
37
+
38
+ continuation.onTermination = { [weak self] _ in
39
+ self?.lock.lock()
40
+ self?.continuations.removeValue(forKey: id)
41
+ self?.lock.unlock()
42
+ }
43
+ }
44
+ }
45
+
46
+ @discardableResult
47
+ func addCallback(_ cb: @escaping @Sendable (ProgressEvent) -> Void) -> CancellationToken {
48
+ let id = UUID()
49
+ lock.lock()
50
+ callbacks[id] = cb
51
+ lock.unlock()
52
+
53
+ return CancellationToken { [weak self] in
54
+ self?.lock.lock()
55
+ self?.callbacks.removeValue(forKey: id)
56
+ self?.lock.unlock()
57
+ }
58
+ }
59
+ }
60
+
61
+ /// Caller-held token returned by `addProgressListener(_:)`. Drop or call
62
+ /// `.cancel()` to stop receiving events.
63
+ public final class CancellationToken: @unchecked Sendable {
64
+ private let cancelClosure: @Sendable () -> Void
65
+ private var cancelled = false
66
+ private let lock = NSLock()
67
+
68
+ internal init(cancel: @escaping @Sendable () -> Void) {
69
+ self.cancelClosure = cancel
70
+ }
71
+
72
+ public func cancel() {
73
+ lock.lock()
74
+ defer { lock.unlock() }
75
+ if !cancelled {
76
+ cancelled = true
77
+ cancelClosure()
78
+ }
79
+ }
80
+
81
+ deinit {
82
+ cancel()
83
+ }
84
+ }