@@ -46,16 +46,33 @@ extension AppState {
4646 }
4747
4848 func downloadRuntime( runtime: DownloadableRuntime ) {
49- Task {
49+ runtimePublishers [ runtime . identifier ] = Task {
5050 do {
51- try await downloadRunTimeFull ( runtime: runtime)
51+ let downloadedURL = try await downloadRunTimeFull ( runtime: runtime)
52+ if !Task. isCancelled {
53+ Logger . appState. debug ( " Installing rungtime: \( runtime. name) " )
54+ DispatchQueue . main. async {
55+ self . setInstallationStep ( of: runtime, to: . installing)
56+ }
57+ switch runtime. contentType {
58+ case . package :
59+ // not supported yet (do we need to for old packages?)
60+ throw " Installing via package not support - please install manually from \( downloadedURL. description) "
61+ case . diskImage:
62+ try await self . installFromImage ( dmgURL: downloadedURL)
63+ DispatchQueue . main. async {
64+ self . setInstallationStep ( of: runtime, to: . trashingArchive)
65+ }
66+ try Current . files. removeItem ( at: downloadedURL)
67+ }
5268
53- DispatchQueue . main. async {
54- guard let index = self . downloadableRuntimes. firstIndex ( where: { $0. identifier == runtime. identifier } ) else { return }
55- self . downloadableRuntimes [ index] . installState = . installed
69+ DispatchQueue . main. async {
70+ guard let index = self . downloadableRuntimes. firstIndex ( where: { $0. identifier == runtime. identifier } ) else { return }
71+ self . downloadableRuntimes [ index] . installState = . installed
72+ }
73+ updateInstalledRuntimes ( )
5674 }
57-
58- updateInstalledRuntimes ( )
75+
5976 }
6077 catch {
6178 Logger . appState. error ( " Error downloading runtime: \( error. localizedDescription) " )
@@ -67,38 +84,44 @@ extension AppState {
6784 }
6885 }
6986
70- func downloadRunTimeFull( runtime: DownloadableRuntime ) async throws {
87+ func downloadRunTimeFull( runtime: DownloadableRuntime ) async throws -> URL {
7188 // sets a proper cookie for runtimes
7289 try await validateADCSession ( path: runtime. downloadPath)
7390
7491 let downloader = Downloader ( rawValue: UserDefaults . standard. string ( forKey: " downloader " ) ?? " aria2 " ) ?? . aria2
75- Logger . appState. info ( " Downloading \( runtime. visibleIdentifier) with \( downloader) " )
7692
77-
78- let url = try await self . downloadRuntime ( for: runtime, downloader: downloader, progressChanged: { [ unowned self] progress in
79- DispatchQueue . main. async {
80- self . setInstallationStep ( of: runtime, to: . downloading( progress: progress) )
81- }
82- } ) . async ( )
83-
84- Logger . appState. debug ( " Done downloading: \( url) " )
85- DispatchQueue . main. async {
86- self . setInstallationStep ( of: runtime, to: . installing)
93+ let url = URL ( string: runtime. source) !
94+ let expectedRuntimePath = Path . xcodesApplicationSupport/ " \( url. lastPathComponent) "
95+ // aria2 downloads directly to the destination (instead of into /tmp first) so we need to make sure that the download isn't incomplete
96+ let aria2DownloadMetadataPath = expectedRuntimePath. parent/ ( expectedRuntimePath. basename ( ) + " .aria2 " )
97+ var aria2DownloadIsIncomplete = false
98+ if case . aria2 = downloader, aria2DownloadMetadataPath. exists {
99+ aria2DownloadIsIncomplete = true
87100 }
88- switch runtime. contentType {
89- case . package :
90- // not supported yet (do we need to for old packages?)
91- throw " Installing via package not support - please install manually from \( url. description) "
92- case . diskImage:
93- try await self . installFromImage ( dmgURL: url)
94- DispatchQueue . main. async {
95- self . setInstallationStep ( of: runtime, to: . trashingArchive)
96- }
97- try Current . files. removeItem ( at: url)
101+ if Current . files. fileExistsAtPath ( expectedRuntimePath. string) , aria2DownloadIsIncomplete == false {
102+ Logger . appState. info ( " Found existing runtime that will be used for installation at \( expectedRuntimePath) . " )
103+ return expectedRuntimePath. url
104+ }
105+
106+ Logger . appState. info ( " Downloading \( runtime. visibleIdentifier) with \( downloader) " )
107+ switch downloader {
108+ case . aria2:
109+ let aria2Path = Path ( url: Bundle . main. url ( forAuxiliaryExecutable: " aria2c " ) !) !
110+ for try await progress in downloadRuntimeWithAria2 ( runtime, to: expectedRuntimePath, aria2Path: aria2Path) {
111+ DispatchQueue . main. async {
112+ Logger . appState. debug ( " Downloading: \( progress. fractionCompleted) " )
113+ self . setInstallationStep ( of: runtime, to: . downloading( progress: progress) )
114+ }
115+ }
116+ Logger . appState. debug ( " Done downloading " )
117+
118+ case . urlSession:
119+ throw " Downloading runtimes with URLSession is not supported. Please use aria2 "
98120 }
121+ return expectedRuntimePath. url
99122 }
123+
100124
101- @MainActor
102125 func downloadRuntime( for runtime: DownloadableRuntime , downloader: Downloader , progressChanged: @escaping ( Progress ) -> Void ) -> AnyPublisher < URL , Error > {
103126 // Check to see if the dmg is in the expected path in case it was downloaded but failed to install
104127
@@ -156,9 +179,36 @@ extension AppState {
156179 . eraseToAnyPublisher ( )
157180 }
158181
182+ public func downloadRuntimeWithAria2( _ runtime: DownloadableRuntime , to destination: Path , aria2Path: Path ) -> AsyncThrowingStream < Progress , Error > {
183+ let cookies = AppleAPI . Current. network. session. configuration. httpCookieStorage? . cookies ( for: runtime. url) ?? [ ]
184+
185+ return Current . shell. downloadWithAria2Async ( aria2Path, runtime. url, destination, cookies)
186+ }
187+
188+
159189 public func installFromImage( dmgURL: URL ) async throws {
160190 try await self . runtimeService. installRuntimeImage ( dmgURL: dmgURL)
161191 }
192+
193+ func cancelRuntimeInstall( runtime: DownloadableRuntime ) {
194+ // Cancel the publisher
195+
196+ runtimePublishers [ runtime. identifier] ? . cancel ( )
197+ runtimePublishers [ runtime. identifier] = nil
198+
199+ // If the download is cancelled by the user, clean up the download files that aria2 creates.
200+ let url = URL ( string: runtime. source) !
201+ let expectedRuntimePath = Path . xcodesApplicationSupport/ " \( url. lastPathComponent) "
202+ let aria2DownloadMetadataPath = expectedRuntimePath. parent/ ( expectedRuntimePath. basename ( ) + " .aria2 " )
203+
204+ try ? Current . files. removeItem ( at: expectedRuntimePath. url)
205+ try ? Current . files. removeItem ( at: aria2DownloadMetadataPath. url)
206+
207+ guard let index = self . downloadableRuntimes. firstIndex ( where: { $0. identifier == runtime. identifier } ) else { return }
208+ self . downloadableRuntimes [ index] . installState = . notInstalled
209+
210+ updateInstalledRuntimes ( )
211+ }
162212}
163213
164214extension AnyPublisher {
@@ -181,3 +231,42 @@ extension AnyPublisher {
181231 }
182232 }
183233}
234+ extension AnyPublisher where Failure: Error {
235+ struct Subscriber {
236+ fileprivate let send : ( Output ) -> Void
237+ fileprivate let complete : ( Subscribers . Completion < Failure > ) -> Void
238+
239+ func send( _ value: Output ) { self . send ( value) }
240+ func send( completion: Subscribers . Completion < Failure > ) { self . complete ( completion) }
241+ }
242+
243+ init ( _ closure: ( Subscriber ) -> AnyCancellable ) {
244+ let subject = PassthroughSubject < Output , Failure > ( )
245+
246+ let subscriber = Subscriber (
247+ send: subject. send,
248+ complete: subject. send ( completion: )
249+ )
250+ let cancel = closure ( subscriber)
251+
252+ self = subject
253+ . handleEvents ( receiveCancel: cancel. cancel)
254+ . eraseToAnyPublisher ( )
255+ }
256+ }
257+
258+ extension AnyPublisher where Failure == Error {
259+ init ( taskPriority: TaskPriority ? = nil , asyncFunc: @escaping ( ) async throws -> Output ) {
260+ self . init { subscriber in
261+ let task = Task ( priority: taskPriority) {
262+ do {
263+ subscriber. send ( try await asyncFunc ( ) )
264+ subscriber. send ( completion: . finished)
265+ } catch {
266+ subscriber. send ( completion: . failure( error) )
267+ }
268+ }
269+ return AnyCancellable { task. cancel ( ) }
270+ }
271+ }
272+ }
0 commit comments