Skip to content

Commit 3bcbb67

Browse files
Show progress bar while downloading Swift SDK bundles (#7315)
### Motivation: The download operation takes a while, and it makes users feel like SwiftPM is stuck. ### Modifications: This patch adds a progress indicator to the download operation to make it clear that SwiftPM is still working and how much work is left. https://github.com/apple/swift-package-manager/assets/11702759/c535ede4-3d11-4992-9314-55a4e1e864d6
1 parent b08692f commit 3bcbb67

File tree

5 files changed

+170
-3
lines changed

5 files changed

+170
-3
lines changed

Sources/Basics/CMakeLists.txt

+1
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ add_library(Basics
5151
Netrc.swift
5252
Observability.swift
5353
OSSignpost.swift
54+
ProgressAnimation.swift
5455
SQLite.swift
5556
Sandbox.swift
5657
SendableTimeInterval.swift
+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift open source project
4+
//
5+
// Copyright (c) 2022 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See http://swift.org/LICENSE.txt for license information
9+
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import _Concurrency
14+
import protocol TSCUtility.ProgressAnimationProtocol
15+
16+
/// A progress animation wrapper that throttles updates to a given interval.
17+
@_spi(SwiftPMInternal)
18+
public class ThrottledProgressAnimation: ProgressAnimationProtocol {
19+
private let animation: ProgressAnimationProtocol
20+
private let shouldUpdate: () -> Bool
21+
private var pendingUpdate: (Int, Int, String)?
22+
23+
public convenience init(_ animation: ProgressAnimationProtocol, interval: ContinuousClock.Duration) {
24+
self.init(animation, clock: ContinuousClock(), interval: interval)
25+
}
26+
27+
public convenience init<C: Clock>(_ animation: ProgressAnimationProtocol, clock: C, interval: C.Duration) {
28+
self.init(animation, now: { clock.now }, interval: interval, clock: C.self)
29+
}
30+
31+
init<C: Clock>(
32+
_ animation: ProgressAnimationProtocol,
33+
now: @escaping () -> C.Instant, interval: C.Duration, clock: C.Type = C.self
34+
) {
35+
self.animation = animation
36+
var lastUpdate: C.Instant?
37+
self.shouldUpdate = {
38+
let now = now()
39+
if let lastUpdate = lastUpdate, now < lastUpdate.advanced(by: interval) {
40+
return false
41+
}
42+
// If we're over the interval or it's the first update, should update.
43+
lastUpdate = now
44+
return true
45+
}
46+
}
47+
48+
public func update(step: Int, total: Int, text: String) {
49+
guard shouldUpdate() else {
50+
pendingUpdate = (step, total, text)
51+
return
52+
}
53+
pendingUpdate = nil
54+
animation.update(step: step, total: total, text: text)
55+
}
56+
57+
public func complete(success: Bool) {
58+
if let (step, total, text) = pendingUpdate {
59+
animation.update(step: step, total: total, text: text)
60+
}
61+
animation.complete(success: success)
62+
}
63+
64+
public func clear() {
65+
animation.clear()
66+
}
67+
}

Sources/PackageModel/SwiftSDKs/SwiftSDKBundleStore.swift

+20-2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import Basics
1515
import struct Foundation.URL
1616
import protocol TSCBasic.FileSystem
1717
import struct TSCBasic.RegEx
18+
import protocol TSCUtility.ProgressAnimationProtocol
1819

1920
public final class SwiftSDKBundleStore {
2021
public enum Output: Equatable, CustomStringConvertible {
@@ -64,16 +65,21 @@ public final class SwiftSDKBundleStore {
6465
/// Closure invoked for output produced by this store during its operation.
6566
private let outputHandler: (Output) -> Void
6667

68+
/// Progress animation used for downloading SDK bundles.
69+
private let downloadProgressAnimation: ProgressAnimationProtocol?
70+
6771
public init(
6872
swiftSDKsDirectory: AbsolutePath,
6973
fileSystem: any FileSystem,
7074
observabilityScope: ObservabilityScope,
71-
outputHandler: @escaping (Output) -> Void
75+
outputHandler: @escaping (Output) -> Void,
76+
downloadProgressAnimation: ProgressAnimationProtocol? = nil
7277
) {
7378
self.swiftSDKsDirectory = swiftSDKsDirectory
7479
self.fileSystem = fileSystem
7580
self.observabilityScope = observabilityScope
7681
self.outputHandler = outputHandler
82+
self.downloadProgressAnimation = downloadProgressAnimation
7783
}
7884

7985
/// An array of valid Swift SDK bundles stored in ``SwiftSDKBundleStore//swiftSDKsDirectory``.
@@ -171,8 +177,20 @@ public final class SwiftSDKBundleStore {
171177
_ = try await httpClient.execute(
172178
request,
173179
observabilityScope: self.observabilityScope,
174-
progress: nil
180+
progress: { step, total in
181+
guard let progressAnimation = self.downloadProgressAnimation else {
182+
return
183+
}
184+
let step = step > Int.max ? Int.max : Int(step)
185+
let total = total.map { $0 > Int.max ? Int.max : Int($0) } ?? step
186+
progressAnimation.update(
187+
step: step,
188+
total: total,
189+
text: "Downloading \(bundleURL.lastPathComponent)"
190+
)
191+
}
175192
)
193+
self.downloadProgressAnimation?.complete(success: true)
176194

177195
bundlePath = downloadedBundlePath
178196

Sources/SwiftSDKTool/InstallSwiftSDK.swift

+6-1
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@
1111
//===----------------------------------------------------------------------===//
1212

1313
import ArgumentParser
14+
@_spi(SwiftPMInternal)
1415
import Basics
1516
import CoreCommands
1617
import Foundation
1718
import PackageModel
1819

1920
import var TSCBasic.stdoutStream
21+
import class TSCUtility.PercentProgressAnimation
2022

2123
public struct InstallSwiftSDK: SwiftSDKSubcommand {
2224
public static let configuration = CommandConfiguration(
@@ -47,7 +49,10 @@ public struct InstallSwiftSDK: SwiftSDKSubcommand {
4749
swiftSDKsDirectory: swiftSDKsDirectory,
4850
fileSystem: self.fileSystem,
4951
observabilityScope: observabilityScope,
50-
outputHandler: { print($0.description) }
52+
outputHandler: { print($0.description) },
53+
downloadProgressAnimation: ThrottledProgressAnimation(
54+
PercentProgressAnimation(stream: stdoutStream, header: "Downloading"), interval: .milliseconds(300)
55+
)
5156
)
5257
try await store.install(
5358
bundlePathOrURL: bundlePathOrURL,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift open source project
4+
//
5+
// Copyright (c) 2014-2022 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See http://swift.org/LICENSE.txt for license information
9+
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import _Concurrency
14+
import XCTest
15+
import protocol TSCUtility.ProgressAnimationProtocol
16+
17+
@_spi(SwiftPMInternal)
18+
@testable
19+
import Basics
20+
21+
final class ProgressAnimationTests: XCTestCase {
22+
class TrackingProgressAnimation: ProgressAnimationProtocol {
23+
var steps: [Int] = []
24+
25+
func update(step: Int, total: Int, text: String) {
26+
steps.append(step)
27+
}
28+
29+
func complete(success: Bool) {}
30+
func clear() {}
31+
}
32+
33+
func testThrottledPercentProgressAnimation() {
34+
do {
35+
let tracking = TrackingProgressAnimation()
36+
var now = ContinuousClock().now
37+
let animation = ThrottledProgressAnimation(
38+
tracking, now: { now }, interval: .milliseconds(100),
39+
clock: ContinuousClock.self
40+
)
41+
42+
// Update the animation 10 times with a 50ms interval.
43+
let total = 10
44+
for i in 0...total {
45+
animation.update(step: i, total: total, text: "")
46+
now += .milliseconds(50)
47+
}
48+
animation.complete(success: true)
49+
XCTAssertEqual(tracking.steps, [0, 2, 4, 6, 8, 10])
50+
}
51+
52+
do {
53+
// Check that the last animation update is sent even if
54+
// the interval has not passed.
55+
let tracking = TrackingProgressAnimation()
56+
var now = ContinuousClock().now
57+
let animation = ThrottledProgressAnimation(
58+
tracking, now: { now }, interval: .milliseconds(100),
59+
clock: ContinuousClock.self
60+
)
61+
62+
// Update the animation 10 times with a 50ms interval.
63+
let total = 10
64+
for i in 0...total-1 {
65+
animation.update(step: i, total: total, text: "")
66+
now += .milliseconds(50)
67+
}
68+
// The next update is at 1000ms, but we are at 950ms,
69+
// so "step 9" is not sent yet.
70+
XCTAssertEqual(tracking.steps, [0, 2, 4, 6, 8])
71+
// After explicit "completion", the last step is flushed out.
72+
animation.complete(success: true)
73+
XCTAssertEqual(tracking.steps, [0, 2, 4, 6, 8, 9])
74+
}
75+
}
76+
}

0 commit comments

Comments
 (0)