1- //
2- // PluginInstaller.swift
3- // TablePro
4- //
5-
6- import CryptoKit
71import Foundation
82import os
93import TableProUtilities
@@ -22,14 +16,14 @@ actor PluginInstaller {
2216 _ registryPlugin : RegistryPlugin ,
2317 binary : RegistryBinary ,
2418 into userPluginsDir : URL ,
25- progressHandler : @escaping @Sendable ( StagedInstallState ) async -> Void
19+ progress : @escaping @Sendable ( Double ) async -> Void
2620 ) async throws -> URL {
2721 try await runCoalesced ( pluginId : registryPlugin . id ) {
2822 try await self . performDownloadAndCommit (
2923 registryPlugin ,
3024 binary : binary ,
3125 into : userPluginsDir ,
32- progressHandler : progressHandler
26+ progress : progress
3327 )
3428 }
3529 }
@@ -39,19 +33,18 @@ actor PluginInstaller {
3933 binary : RegistryBinary ,
4034 into userPluginsDir : URL ,
4135 hasLiveConnections : Bool ,
42- progressHandler : @escaping @Sendable ( StagedInstallState ) async -> Void
36+ progress : @escaping @Sendable ( Double ) async -> Void
4337 ) async throws -> PluginUpdateResult {
4438 if hasLiveConnections {
4539 let stagedURL = try await runCoalesced ( pluginId : registryPlugin . id ) {
4640 try await self . performDownloadAndStage (
4741 registryPlugin ,
4842 binary : binary ,
4943 into : userPluginsDir ,
50- progressHandler : progressHandler
44+ progress : progress
5145 )
5246 }
5347 stagedUpdates [ registryPlugin . id ] = stagedURL
54- await progressHandler ( . staged ( at : stagedURL ) )
5548 return . staged ( at : stagedURL )
5649 }
5750
@@ -60,7 +53,7 @@ actor PluginInstaller {
6053 registryPlugin ,
6154 binary : binary ,
6255 into : userPluginsDir ,
63- progressHandler : progressHandler
56+ progress : progress
6457 )
6558 }
6659 return . installed ( pluginURL : finalURL )
@@ -122,23 +115,21 @@ actor PluginInstaller {
122115 _ registryPlugin : RegistryPlugin ,
123116 binary : RegistryBinary ,
124117 into userPluginsDir : URL ,
125- progressHandler : @escaping @Sendable ( StagedInstallState ) async -> Void
118+ progress : @escaping @Sendable ( Double ) async -> Void
126119 ) async throws -> URL {
127120 let extracted = try await downloadExtractVerify (
128121 registryPlugin ,
129122 binary : binary ,
130123 into : userPluginsDir ,
131- progressHandler : progressHandler
124+ progress : progress
132125 )
133126
134127 defer {
135128 try? FileManager . default . removeItem ( at : extracted . workingDir )
136129 }
137130
138131 let destURL = userPluginsDir . appendingPathComponent ( extracted . bundleURL . lastPathComponent , isDirectory : true )
139- let finalURL = try Self . atomicReplace ( stagedBundleURL : extracted . bundleURL , destURL : destURL )
140- await progressHandler ( . installed ( pluginURL : finalURL ) )
141- return finalURL
132+ return try Self . atomicReplace ( stagedBundleURL : extracted . bundleURL , destURL : destURL )
142133 }
143134
144135 // MARK: - Download + stage (live connections)
@@ -147,13 +138,13 @@ actor PluginInstaller {
147138 _ registryPlugin : RegistryPlugin ,
148139 binary : RegistryBinary ,
149140 into userPluginsDir : URL ,
150- progressHandler : @escaping @Sendable ( StagedInstallState ) async -> Void
141+ progress : @escaping @Sendable ( Double ) async -> Void
151142 ) async throws -> URL {
152143 let extracted = try await downloadExtractVerify (
153144 registryPlugin ,
154145 binary : binary ,
155146 into : userPluginsDir ,
156- progressHandler : progressHandler
147+ progress : progress
157148 )
158149 return extracted . bundleURL
159150 }
@@ -169,20 +160,14 @@ actor PluginInstaller {
169160 _ registryPlugin : RegistryPlugin ,
170161 binary : RegistryBinary ,
171162 into userPluginsDir : URL ,
172- progressHandler : @escaping @Sendable ( StagedInstallState ) async -> Void
163+ progress : @escaping @Sendable ( Double ) async -> Void
173164 ) async throws -> ExtractedBundle {
174165 let stagingRoot = Self . stagingRoot ( for : userPluginsDir )
175166 try FileManager . default . createDirectory ( at : stagingRoot , withIntermediateDirectories : true )
176167 let workingDir = stagingRoot . appendingPathComponent ( UUID ( ) . uuidString , isDirectory : true )
177168 try FileManager . default . createDirectory ( at : workingDir , withIntermediateDirectories : true )
178169
179- let context = await MainActor . run {
180- (
181- kit : PluginManager . currentPluginKitVersion ,
182- minimumKit : PluginManager . minimumCompatiblePluginKitVersion ,
183- session : RegistryClient . shared . session
184- )
185- }
170+ let session = await MainActor . run { RegistryClient . shared . session }
186171
187172 // The registry manifest is fetched over the network, so the URL it names is untrusted
188173 // input. The code-signature check is the real gate, but nothing should be fetched over
@@ -192,30 +177,16 @@ actor PluginInstaller {
192177 throw PluginError . downloadFailed ( " Invalid download URL " )
193178 }
194179
195- /// Zero here is "not started", not "nought per cent done", and nothing else arrives until
196- /// the whole file is down: measured on a 25.7 MB download with a known `Content-Length`,
197- /// `download(from:delegate:)` delivered no `URLSessionDownloadDelegate` byte callbacks at
198- /// all, because the async form routes only `URLSessionTaskDelegate` messages to a per-task
199- /// delegate. Anything drawing this has to stay indeterminate until a fraction moves.
200- await progressHandler ( . downloading ( fraction : 0 ) )
201-
202- let ( tempDownloadURL , response ) = try await context . session . download ( from : downloadURL )
180+ let ( tempDownloadURL , response ) = try await Self . download ( downloadURL , with : session , progress : progress )
203181 guard let http = response as? HTTPURLResponse , ( 200 ... 299 ) . contains ( http . statusCode ) else {
204182 let code = ( response as? HTTPURLResponse ) ? . statusCode ?? 0
205183 throw PluginError . downloadFailed ( " HTTP \( code ) " )
206184 }
207185
208- await progressHandler ( . downloading ( fraction : 0.5 ) )
209-
210- let payload = try Data ( contentsOf : tempDownloadURL )
211- let digest = SHA256 . hash ( data : payload )
212- let hex = digest . hexEncoded
213- guard hex == binary . sha256 . lowercased ( ) else {
186+ guard DownloadedBinary . sha256Hex ( ofFileAt : tempDownloadURL . path ) == binary . sha256 . lowercased ( ) else {
214187 throw PluginError . checksumMismatch
215188 }
216189
217- await progressHandler ( . downloading ( fraction : 1.0 ) )
218-
219190 let zipURL = workingDir . appendingPathComponent ( " \( registryPlugin . id ) .zip " )
220191 try FileManager . default . moveItem ( at : tempDownloadURL , to : zipURL )
221192
@@ -227,19 +198,37 @@ actor PluginInstaller {
227198 }
228199
229200 try PluginCodeSignatureVerifier . verify ( bundle : stagedBundle )
230-
231- try Self . validateStagedABI (
232- bundleURL : bundleURL ,
233- currentKit : context . kit ,
234- minimumKit : context . minimumKit
235- )
201+ try PluginManager . validateBundleVersions ( stagedBundle )
236202 Self . stripQuarantine ( at : bundleURL )
237203
238204 return ExtractedBundle ( workingDir : workingDir , bundleURL : bundleURL )
239205 }
240206
241207 // MARK: - Helpers (nonisolated)
242208
209+ nonisolated static func download (
210+ _ url : URL ,
211+ with session : URLSession ,
212+ progress : @escaping @Sendable ( Double ) async -> Void
213+ ) async throws -> ( URL , URLResponse ) {
214+ let observer = DownloadProgressObserver ( )
215+ let forwarding = Task {
216+ for await fraction in observer . fractions {
217+ await progress ( fraction )
218+ }
219+ }
220+ do {
221+ let result = try await session . download ( from : url , delegate : observer )
222+ observer . finish ( isComplete : true )
223+ await forwarding . value
224+ return result
225+ } catch {
226+ observer . finish ( isComplete : false )
227+ forwarding . cancel ( )
228+ throw error
229+ }
230+ }
231+
243232 nonisolated static func stagingRoot ( for userPluginsDir : URL ) -> URL {
244233 userPluginsDir . deletingLastPathComponent ( )
245234 . appendingPathComponent ( " PluginStaging " , isDirectory : true )
@@ -276,27 +265,6 @@ actor PluginInstaller {
276265 DownloadedBinary . stripQuarantine ( at : url )
277266 }
278267
279- nonisolated static func validateStagedABI (
280- bundleURL : URL ,
281- currentKit : Int ,
282- minimumKit : Int
283- ) throws {
284- guard let bundle = Bundle ( url : bundleURL ) ,
285- let info = bundle . infoDictionary
286- else {
287- throw PluginError . invalidBundle ( " Cannot read Info.plist " )
288- }
289- guard let version = info [ " TableProPluginKitVersion " ] as? Int else {
290- throw PluginError . pluginOutdated ( pluginVersion : 0 , requiredVersion : currentKit )
291- }
292- if version > currentKit {
293- throw PluginError . incompatibleVersion ( required : version , current : currentKit )
294- }
295- if version < minimumKit {
296- throw PluginError . pluginOutdated ( pluginVersion : version , requiredVersion : currentKit )
297- }
298- }
299-
300268 nonisolated static func atomicReplace ( stagedBundleURL : URL , destURL : URL ) throws -> URL {
301269 var resultURL : NSURL ?
302270 let backupName = " \( destURL . lastPathComponent ) .bak "
@@ -311,8 +279,6 @@ actor PluginInstaller {
311279 options : [ ] ,
312280 resultingItemURL : & resultURL
313281 )
314- let backupURL = destDir . appendingPathComponent ( backupName )
315- try? FileManager . default . removeItem ( at : backupURL )
316282 } else {
317283 try FileManager . default . moveItem ( at : stagedBundleURL , to : destURL )
318284 }
@@ -321,11 +287,37 @@ actor PluginInstaller {
321287 }
322288}
323289
324- enum StagedInstallState : Sendable {
325- case downloading ( fraction : Double )
326- case staged ( at : URL )
327- case installed ( pluginURL : URL )
328- case failed ( any Error )
290+ // The async download sends a task delegate no byte callbacks, but the task's own progress moves.
291+ private final class DownloadProgressObserver : NSObject , URLSessionTaskDelegate , Sendable {
292+ let fractions : AsyncStream < Double >
293+ private let continuation : AsyncStream < Double > . Continuation
294+ private let observation = OSAllocatedUnfairLock < NSKeyValueObservation ? > ( uncheckedState : nil )
295+
296+ override init ( ) {
297+ ( fractions , continuation ) = AsyncStream . makeStream ( of : Double . self )
298+ super . init ( )
299+ }
300+
301+ func urlSession ( _ session : URLSession , didCreateTask task : URLSessionTask ) {
302+ let continuation = continuation
303+ let observation = task . progress . observe ( \. fractionCompleted ) { progress , _ in
304+ // Without a Content-Length the fraction creeps on a guess; the UI stays indeterminate.
305+ guard task . countOfBytesExpectedToReceive > 0 else { return }
306+ continuation . yield ( progress . fractionCompleted )
307+ }
308+ self . observation . withLockUnchecked { $0 = observation }
309+ }
310+
311+ func finish ( isComplete : Bool ) {
312+ observation . withLockUnchecked { observation in
313+ observation ? . invalidate ( )
314+ observation = nil
315+ }
316+ if isComplete {
317+ continuation . yield ( 1 )
318+ }
319+ continuation . finish ( )
320+ }
329321}
330322
331323enum PluginUpdateResult : Sendable {
0 commit comments