diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index ed443f8..529ad3b 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ CA378F992466567600A58CE0 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA378F982466567600A58CE0 /* AppState.swift */; }; CA39711924495F0E00AFFB77 /* AppStoreButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */; }; CA44901F2463AD34003D8213 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA44901E2463AD34003D8213 /* Tag.swift */; }; + CA452BB0259FD9770072DFA4 /* ProgressIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA452BAF259FD9770072DFA4 /* ProgressIndicator.swift */; }; CA5D781E257365D6008EDE9D /* PinCodeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA5D781D257365D6008EDE9D /* PinCodeTextView.swift */; }; CA61A6E0259835580008926E /* Xcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA61A6DF259835580008926E /* Xcode.swift */; }; CA735109257BF96D00EA9CF8 /* AttributedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA735108257BF96D00EA9CF8 /* AttributedText.swift */; }; @@ -61,6 +62,9 @@ CABFAA2D2592FBFC00380FEE /* Configure.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFAA2B2592FBFC00380FEE /* Configure.swift */; }; CABFAA432593104F00380FEE /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFAA422593104F00380FEE /* AboutView.swift */; }; CABFAA492593162500380FEE /* Bundle+InfoPlistValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFAA482593162500380FEE /* Bundle+InfoPlistValues.swift */; }; + CAC281C8259F97E100B8AB0B /* InstallationStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC281C7259F97E100B8AB0B /* InstallationStepView.swift */; }; + CAC281CD259F97FA00B8AB0B /* ObservingProgressIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC281CC259F97FA00B8AB0B /* ObservingProgressIndicator.swift */; }; + CAC281DA259F985100B8AB0B /* InstallationStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC281D9259F985100B8AB0B /* InstallationStep.swift */; }; CAD2E7A22449574E00113D76 /* XcodesApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAD2E7A12449574E00113D76 /* XcodesApp.swift */; }; CAD2E7A42449574E00113D76 /* XcodeListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAD2E7A32449574E00113D76 /* XcodeListView.swift */; }; CAD2E7A62449575000113D76 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CAD2E7A52449575000113D76 /* Assets.xcassets */; }; @@ -123,6 +127,7 @@ CA378F982466567600A58CE0 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreButtonStyle.swift; sourceTree = ""; }; CA44901E2463AD34003D8213 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = ""; }; + CA452BAF259FD9770072DFA4 /* ProgressIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressIndicator.swift; sourceTree = ""; }; CA538A0C255A4F1A00E64DD7 /* AppleAPI */ = {isa = PBXFileReference; lastKnownFileType = folder; name = AppleAPI; path = Xcodes/AppleAPI; sourceTree = ""; }; CA5D781D257365D6008EDE9D /* PinCodeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinCodeTextView.swift; sourceTree = ""; }; CA61A6DF259835580008926E /* Xcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Xcode.swift; sourceTree = ""; }; @@ -179,6 +184,9 @@ CABFAA2B2592FBFC00380FEE /* Configure.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Configure.swift; path = Xcodes/Backend/Configure.swift; sourceTree = SOURCE_ROOT; }; CABFAA422593104F00380FEE /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; CABFAA482593162500380FEE /* Bundle+InfoPlistValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+InfoPlistValues.swift"; sourceTree = ""; }; + CAC281C7259F97E100B8AB0B /* InstallationStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallationStepView.swift; sourceTree = ""; }; + CAC281CC259F97FA00B8AB0B /* ObservingProgressIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservingProgressIndicator.swift; sourceTree = ""; }; + CAC281D9259F985100B8AB0B /* InstallationStep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallationStep.swift; sourceTree = ""; }; CAD2E79E2449574E00113D76 /* Xcodes.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Xcodes.app; sourceTree = BUILT_PRODUCTS_DIR; }; CAD2E7A12449574E00113D76 /* XcodesApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodesApp.swift; sourceTree = ""; }; CAD2E7A32449574E00113D76 /* XcodeListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeListView.swift; sourceTree = ""; }; @@ -236,7 +244,9 @@ 63EAA4E9259944340046AB8F /* Common */ = { isa = PBXGroup; children = ( + CAC281CC259F97FA00B8AB0B /* ObservingProgressIndicator.swift */, 63EAA4EA259944450046AB8F /* ProgressButton.swift */, + CA452BAF259FD9770072DFA4 /* ProgressIndicator.swift */, ); path = Common; sourceTree = ""; @@ -307,6 +317,7 @@ CAE42486259A68A300B8B246 /* XcodeListCategory.swift */, CAD2E7A32449574E00113D76 /* XcodeListView.swift */, CAFFFED7259CDA5000903F81 /* XcodeListViewRow.swift */, + CAC281C7259F97E100B8AB0B /* InstallationStepView.swift */, ); path = XcodeList; sourceTree = ""; @@ -329,6 +340,7 @@ CABFA9AC2592EEE900380FEE /* Foundation.swift */, CA9FF8F425959CE000E47BAF /* HelperInstaller.swift */, CA9FF9352595B44700E47BAF /* HelperClient.swift */, + CAC281D9259F985100B8AB0B /* InstallationStep.swift */, CA9FF8862595607900E47BAF /* InstalledXcode.swift */, CAE4248B259A68B800B8B246 /* Optional+IsNotNil.swift */, CABFA9AE2592EEE900380FEE /* Path+.swift */, @@ -624,6 +636,7 @@ CABFA9BB2592EEEA00380FEE /* DateFormatter+.swift in Sources */, CABFA9BD2592EEEA00380FEE /* Environment.swift in Sources */, CABFA9C32592EEEA00380FEE /* Downloads.swift in Sources */, + CAC281DA259F985100B8AB0B /* InstallationStep.swift in Sources */, CA378F992466567600A58CE0 /* AppState.swift in Sources */, CAD2E7A42449574E00113D76 /* XcodeListView.swift in Sources */, CAA1CB45255A5B60003FD669 /* SignIn2FAView.swift in Sources */, @@ -632,6 +645,8 @@ CA9FF8872595607900E47BAF /* InstalledXcode.swift in Sources */, CA61A6E0259835580008926E /* Xcode.swift in Sources */, CAE4247F259A666100B8B246 /* MainWindow.swift in Sources */, + CA452BB0259FD9770072DFA4 /* ProgressIndicator.swift in Sources */, + CAC281C8259F97E100B8AB0B /* InstallationStepView.swift in Sources */, CA9FF84E2595079F00E47BAF /* ScrollingTextView.swift in Sources */, CABFA9C12592EEEA00380FEE /* Version+.swift in Sources */, CA9FF8522595080100E47BAF /* AcknowledgementsView.swift in Sources */, @@ -644,6 +659,7 @@ CA9FF8F525959CE000E47BAF /* HelperInstaller.swift in Sources */, CA73510D257BFCEF00EA9CF8 /* NSAttributedString+.swift in Sources */, CAFBDB952598FE96003DCC5A /* FocusedValues.swift in Sources */, + CAC281CD259F97FA00B8AB0B /* ObservingProgressIndicator.swift in Sources */, CAFBDC68259A308B003DCC5A /* InfoPane.swift in Sources */, CAA1CB4D255A5CFD003FD669 /* SignInPhoneListView.swift in Sources */, CAFBDC6C259A3098003DCC5A /* View+Conditional.swift in Sources */, diff --git a/Xcodes/Backend/InstallationStep.swift b/Xcodes/Backend/InstallationStep.swift new file mode 100644 index 0000000..f434229 --- /dev/null +++ b/Xcodes/Backend/InstallationStep.swift @@ -0,0 +1,45 @@ +import Foundation + +/// A numbered step +enum InstallationStep: Equatable, CustomStringConvertible { + case downloading(progress: Progress) + case unarchiving + case moving(destination: String) + case trashingArchive + case checkingSecurity + case finishing + + var description: String { + "(\(stepNumber)/\(stepCount)) \(message)" + } + + var message: String { + switch self { + case .downloading: + return "Downloading" + case .unarchiving: + return "Unarchiving (This can take a while)" + case .moving(let destination): + return "Moving to \(destination)" + case .trashingArchive: + return "Moving archive to the Trash" + case .checkingSecurity: + return "Security verification" + case .finishing: + return "Finishing" + } + } + + var stepNumber: Int { + switch self { + case .downloading: return 1 + case .unarchiving: return 2 + case .moving: return 3 + case .trashingArchive: return 4 + case .checkingSecurity: return 5 + case .finishing: return 6 + } + } + + var stepCount: Int { 6 } +} diff --git a/Xcodes/Backend/Xcode.swift b/Xcodes/Backend/Xcode.swift index cfb8d7d..aa422a0 100644 --- a/Xcodes/Backend/Xcode.swift +++ b/Xcodes/Backend/Xcode.swift @@ -47,6 +47,6 @@ struct Xcode: Identifiable, CustomStringConvertible { enum XcodeInstallState: Equatable { case notInstalled - case installing(Progress) + case installing(InstallationStep) case installed } diff --git a/Xcodes/Frontend/Common/ObservingProgressIndicator.swift b/Xcodes/Frontend/Common/ObservingProgressIndicator.swift new file mode 100644 index 0000000..61e93a1 --- /dev/null +++ b/Xcodes/Frontend/Common/ObservingProgressIndicator.swift @@ -0,0 +1,62 @@ +import Combine +import SwiftUI + +/// A ProgressIndicator that reflects the state of a Progress object. +/// This functionality is already built in to ProgressView, +/// but this implementation ensures that changes are received on the main thread. +@available(iOS 14.0, macOS 11.0, *) +public struct ObservingProgressIndicator: View { + let controlSize: NSControl.ControlSize + let style: NSProgressIndicator.Style + @StateObject private var progress: ProgressWrapper + + public init( + _ progress: Progress, + controlSize: NSControl.ControlSize, + style: NSProgressIndicator.Style + ) { + _progress = StateObject(wrappedValue: ProgressWrapper(progress: progress)) + self.controlSize = controlSize + self.style = style + } + + class ProgressWrapper: ObservableObject { + var progress: Progress + var cancellable: AnyCancellable! + + init(progress: Progress) { + self.progress = progress + cancellable = progress + .publisher(for: \.fractionCompleted) + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.objectWillChange.send() } + } + } + + public var body: some View { + ProgressIndicator( + minValue: 0.0, + maxValue: 1.0, + doubleValue: progress.progress.fractionCompleted, + controlSize: controlSize, + isIndeterminate: progress.progress.isIndeterminate, + style: style + ) + } +} + +@available(iOS 14.0, macOS 11.0, *) +struct ObservingProgressBar_Previews: PreviewProvider { + static var previews: some View { + Group { + ObservingProgressIndicator( + configure(Progress(totalUnitCount: 100)) { + $0.completedUnitCount = 40 + }, + controlSize: .small, + style: .spinning + ) + } + .previewLayout(.sizeThatFits) + } +} diff --git a/Xcodes/Frontend/Common/ProgressIndicator.swift b/Xcodes/Frontend/Common/ProgressIndicator.swift new file mode 100644 index 0000000..4c11f0a --- /dev/null +++ b/Xcodes/Frontend/Common/ProgressIndicator.swift @@ -0,0 +1,40 @@ +import SwiftUI +import AppKit + +/// You probably want ProgressView unless you need more of NSProgressIndicator's API, which this exposes. +struct ProgressIndicator: NSViewRepresentable { + typealias NSViewType = NSProgressIndicator + + let minValue: Double + let maxValue: Double + let doubleValue: Double + let controlSize: NSControl.ControlSize + let isIndeterminate: Bool + let style: NSProgressIndicator.Style + + func makeNSView(context: Context) -> NSViewType { + NSProgressIndicator() + } + + func updateNSView(_ nsView: NSViewType, context: Context) { + nsView.minValue = minValue + nsView.maxValue = maxValue + nsView.doubleValue = doubleValue + nsView.controlSize = controlSize + nsView.isIndeterminate = isIndeterminate + nsView.style = style + } +} + +struct ProgressIndicator_Previews: PreviewProvider { + static var previews: some View { + ProgressIndicator( + minValue: 0, + maxValue: 1, + doubleValue: 0.4, + controlSize: .small, + isIndeterminate: false, + style: .spinning + ) + } +} diff --git a/Xcodes/Frontend/XcodeList/InstallationStepView.swift b/Xcodes/Frontend/XcodeList/InstallationStepView.swift new file mode 100644 index 0000000..0c7ab3c --- /dev/null +++ b/Xcodes/Frontend/XcodeList/InstallationStepView.swift @@ -0,0 +1,104 @@ +import SwiftUI + +struct InstallationStepView: View { + let installationStep: InstallationStep + let highlighted: Bool + let cancel: () -> Void + + var body: some View { + HStack { + switch installationStep { + case let .downloading(progress): + // FB8955769 ProgressView.init(_: Progress) doesn't ensure that changes from the Progress object are applied to the UI on the main thread + // This Progress is vended by URLSession so I don't think we can control that. + // Use our own version of ProgressView that does this instead. + ObservingProgressIndicator( + progress, + controlSize: .small, + style: .spinning + ) + case .unarchiving, .moving, .trashingArchive, .checkingSecurity, .finishing: + ProgressView() + .scaleEffect(0.5) + } + + Text("Step \(installationStep.stepNumber) of \(installationStep.stepCount): \(installationStep.message)") + .font(.footnote) + + Button(action: { }) { + Label("Cancel", systemImage: "xmark.circle.fill") + .labelStyle(IconOnlyLabelStyle()) + } + .buttonStyle(PlainButtonStyle()) + .foregroundColor(highlighted ? .white : .secondary) + .help("Cancel installation") + } + .frame(minWidth: 80) + } +} + +struct InstallView_Previews: PreviewProvider { + static var previews: some View { + Group { + ForEach(ColorScheme.allCases, id: \.self) { colorScheme in + Group { + InstallationStepView( + installationStep: .downloading( + progress: configure(Progress(totalUnitCount: 100)) { $0.completedUnitCount = 40 } + ), + highlighted: false, + cancel: {} + ) + + InstallationStepView( + installationStep: .unarchiving, + highlighted: false, + cancel: {} + ) + + InstallationStepView( + installationStep: .moving(destination: "/Applications"), + highlighted: false, + cancel: {} + ) + + InstallationStepView( + installationStep: .trashingArchive, + highlighted: false, + cancel: {} + ) + + InstallationStepView( + installationStep: .checkingSecurity, + highlighted: false, + cancel: {} + ) + + InstallationStepView( + installationStep: .finishing, + highlighted: false, + cancel: {} + ) + } + .padding() + .background(Color(.windowBackgroundColor)) + .environment(\.colorScheme, colorScheme) + } + + ForEach(ColorScheme.allCases, id: \.self) { colorScheme in + Group { + InstallationStepView( + installationStep: .downloading( + progress: configure(Progress(totalUnitCount: 100)) { $0.completedUnitCount = 40 } + ), + highlighted: true, + cancel: {} + ) + } + .padding() + .background(Color(.selectedContentBackgroundColor)) + .environment(\.colorScheme, colorScheme) + } + } + } +} diff --git a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift index ad8e2ec..8a21574 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift @@ -72,14 +72,17 @@ struct XcodeListViewRow: View { @ViewBuilder private func installControl(for xcode: Xcode) -> some View { - if xcode.installed { + switch xcode.installState { + case .installed: Button("OPEN") { appState.open(id: xcode.id) } .buttonStyle(AppStoreButtonStyle(primary: true, highlighted: selected)) .help("Open this version") - } else { - Button("INSTALL") { print("Installing...") } + case .notInstalled: + Button("INSTALL") { appState.install(id: xcode.id) } .buttonStyle(AppStoreButtonStyle(primary: false, highlighted: selected)) .help("Install this version") + case let .installing(installationStep): + InstallationStepView(installationStep: installationStep, highlighted: selected, cancel: {}) } } } @@ -98,7 +101,7 @@ struct XcodeListViewRow_Previews: PreviewProvider { ) XcodeListViewRow( - xcode: Xcode(version: Version("12.1.0")!, installState: .notInstalled, selected: false, path: nil, icon: nil), + xcode: Xcode(version: Version("12.1.0")!, installState: .installing(.downloading(progress: configure(Progress(totalUnitCount: 100)) { $0.completedUnitCount = 40 })), selected: false, path: nil, icon: nil), selected: false )