From 047288384dcb37aa951d65b6ecf74c1ab71f0b12 Mon Sep 17 00:00:00 2001 From: Brandon Evans Date: Mon, 28 Dec 2020 10:35:06 -0700 Subject: [PATCH 1/3] Reflect currently-selected Xcode in list --- Xcodes/Backend/AppState+Update.swift | 25 ++++++++++- Xcodes/Backend/AppState.swift | 20 ++++++--- Xcodes/Backend/Environment.swift | 7 +-- Xcodes/Backend/Process.swift | 44 +++++++++++++++++++ Xcodes/Backend/XcodeCommands.swift | 1 + Xcodes/Frontend/XcodeList/XcodeListView.swift | 9 ++-- 6 files changed, 89 insertions(+), 17 deletions(-) diff --git a/Xcodes/Backend/AppState+Update.swift b/Xcodes/Backend/AppState+Update.swift index eb5d977..907b7e2 100644 --- a/Xcodes/Backend/AppState+Update.swift +++ b/Xcodes/Backend/AppState+Update.swift @@ -15,13 +15,25 @@ extension AppState { let lastUpdated = Current.defaults.date(forKey: "lastUpdated"), // This is bad date math but for this use case it doesn't need to be exact lastUpdated < Current.date().addingTimeInterval(-60 * 60 * 24) - else { return } + else { + updatePublisher = updateSelectedXcodePath() + .sink( + receiveCompletion: { _ in + self.updatePublisher = nil + }, + receiveValue: { _ in } + ) + return + } update() as Void } func update() { guard !isUpdating else { return } - updatePublisher = updateAvailableXcodes(from: self.dataSource) + updatePublisher = updateSelectedXcodePath() + .flatMap { _ in + self.updateAvailableXcodes(from: self.dataSource) + } .sink( receiveCompletion: { [unowned self] completion in switch completion { @@ -36,6 +48,15 @@ extension AppState { receiveValue: { _ in } ) } + + func updateSelectedXcodePath() -> AnyPublisher { + Current.shell.xcodeSelectPrintPath() + .handleEvents(receiveOutput: { output in self.selectedXcodePath = output.out }) + // Ignore xcode-select failures + .map { _ in Void() } + .catch { _ in Just(()) } + .eraseToAnyPublisher() + } private func updateAvailableXcodes(from dataSource: DataSource) -> AnyPublisher<[AvailableXcode], Error> { switch dataSource { diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 4410fe3..cef6b90 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -16,10 +16,15 @@ class AppState: ObservableObject { @Published var authenticationState: AuthenticationState = .unauthenticated @Published var availableXcodes: [AvailableXcode] = [] { willSet { - updateAllXcodes(newValue) + updateAllXcodes(availableXcodes: newValue, selectedXcodePath: selectedXcodePath) } } var allXcodes: [Xcode] = [] + @Published var selectedXcodePath: String? { + willSet { + updateAllXcodes(availableXcodes: availableXcodes, selectedXcodePath: newValue) + } + } @Published var updatePublisher: AnyCancellable? var isUpdating: Bool { updatePublisher != nil } @Published var error: AlertContent? @@ -226,6 +231,9 @@ class AppState: ObservableObject { else { return } selectPublisher = HelperClient().switchXcodePath(installedXcode.path.string) + .flatMap { [unowned self] _ in + self.updateSelectedXcodePath() + } .sink( receiveCompletion: { [unowned self] completion in if case let .failure(error) = completion { @@ -251,9 +259,9 @@ class AppState: ObservableObject { // MARK: - Private - private func updateAllXcodes(_ xcodes: [AvailableXcode]) { + private func updateAllXcodes(availableXcodes: [AvailableXcode], selectedXcodePath: String?) { let installedXcodes = Current.files.installedXcodes(Path.root/"Applications") - var allXcodeVersions = xcodes.map { $0.version } + var allXcodeVersions = availableXcodes.map { $0.version } for installedXcode in installedXcodes { // If an installed version isn't listed online, add the installed version if !allXcodeVersions.contains(where: { version in @@ -274,11 +282,11 @@ class AppState: ObservableObject { .sorted(by: >) .map { xcodeVersion in let installedXcode = installedXcodes.first(where: { xcodeVersion.isEquivalentForDeterminingIfInstalled(toInstalled: $0.version) }) - let availableXcode = xcodes.first { $0.version == xcodeVersion } + let availableXcode = availableXcodes.first { $0.version == xcodeVersion } return Xcode( version: xcodeVersion, - installState: installedXcodes.contains(where: { xcodeVersion.isEquivalentForDeterminingIfInstalled(toInstalled: $0.version) }) ? .installed : .notInstalled, - selected: false, + installState: installedXcode != nil ? .installed : .notInstalled, + selected: installedXcode != nil && selectedXcodePath?.hasPrefix(installedXcode!.path.string) == true, path: installedXcode?.path.string, icon: (installedXcode?.path.string).map(NSWorkspace.shared.icon(forFile:)), requiredMacOSVersion: availableXcode?.requiredMacOSVersion, diff --git a/Xcodes/Backend/Environment.swift b/Xcodes/Backend/Environment.swift index fa3be9d..99f84be 100644 --- a/Xcodes/Backend/Environment.swift +++ b/Xcodes/Backend/Environment.swift @@ -1,3 +1,4 @@ +import Combine import Foundation import PromiseKit import PMKFoundation @@ -50,11 +51,7 @@ public struct Shell { authenticateSudoerIfNecessary(passwordInput) } - public var xcodeSelectPrintPath: () -> Promise = { Process.run(Path.root.usr.bin.join("xcode-select"), "-p") } - public var xcodeSelectSwitch: (String?, String) -> Promise = { Process.sudo(password: $0, Path.root.usr.bin.join("xcode-select"), "-s", $1) } - public func xcodeSelectSwitch(password: String?, path: String) -> Promise { - xcodeSelectSwitch(password, path) - } + public var xcodeSelectPrintPath: () -> AnyPublisher = { Process.run(Path.root.usr.bin.join("xcode-select"), "-p") } } public struct Files { diff --git a/Xcodes/Backend/Process.swift b/Xcodes/Backend/Process.swift index dd79217..779f151 100644 --- a/Xcodes/Backend/Process.swift +++ b/Xcodes/Backend/Process.swift @@ -1,3 +1,4 @@ +import Combine import Foundation import PromiseKit import PMKFoundation @@ -38,4 +39,47 @@ extension Process { return (process.terminationStatus, output, error) } } + + @discardableResult + static func run(_ executable: Path, workingDirectory: URL? = nil, input: String? = nil, _ arguments: String...) -> AnyPublisher { + return run(executable.url, workingDirectory: workingDirectory, input: input, arguments) + } + + @discardableResult + static func run(_ executable: URL, workingDirectory: URL? = nil, input: String? = nil, _ arguments: [String]) -> AnyPublisher { + Deferred { + Future { promise in + let process = Process() + process.currentDirectoryURL = workingDirectory ?? executable.deletingLastPathComponent() + process.executableURL = executable + process.arguments = arguments + + let (stdout, stderr) = (Pipe(), Pipe()) + process.standardOutput = stdout + process.standardError = stderr + + if let input = input { + let inputPipe = Pipe() + process.standardInput = inputPipe.fileHandleForReading + inputPipe.fileHandleForWriting.write(Data(input.utf8)) + inputPipe.fileHandleForWriting.closeFile() + } + + do { + try process.run() + process.waitUntilExit() + + let output = String(data: stdout.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let error = String(data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + + promise(.success((process.terminationStatus, output, error))) + } catch { + promise(.failure(error)) + } + } + } + .subscribe(on: DispatchQueue.global()) + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } } diff --git a/Xcodes/Backend/XcodeCommands.swift b/Xcodes/Backend/XcodeCommands.swift index df00299..4ec374a 100644 --- a/Xcodes/Backend/XcodeCommands.swift +++ b/Xcodes/Backend/XcodeCommands.swift @@ -58,6 +58,7 @@ struct SelectButton: View { Button(action: select) { Text("Select") } + .disabled(xcode?.selected != false) } private func select() { diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index a2fa65a..a2c5d6c 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -42,13 +42,14 @@ struct XcodeListView: View { .foregroundColor(appState.selectedXcodeID == xcode.id ? Color(NSColor.selectedMenuItemTextColor) : Color(NSColor.secondaryLabelColor)) } + + Spacer() + if xcode.selected { Tag(text: "SELECTED") .foregroundColor(.green) } - Spacer() - Button(xcode.installed ? "INSTALLED" : "INSTALL") { print("Installing...") } @@ -90,10 +91,10 @@ struct XcodeListView_Previews: PreviewProvider { .environmentObject({ () -> AppState in let a = AppState() a.allXcodes = [ - Xcode(version: Version("12.3.0")!, installState: .installed, selected: true, path: nil, icon: nil), + Xcode(version: Version("12.3.0")!, installState: .installed, selected: true, path: "/Applications/Xcode-12.3.0.app", icon: nil), Xcode(version: Version("12.2.0")!, installState: .notInstalled, selected: false, path: nil, icon: nil), Xcode(version: Version("12.1.0")!, installState: .notInstalled, selected: false, path: nil, icon: nil), - Xcode(version: Version("12.0.0")!, installState: .installed, selected: false, path: nil, icon: nil), + Xcode(version: Version("12.0.0")!, installState: .installed, selected: false, path: "/Applications/Xcode-12.3.0.app", icon: nil), ] return a }()) From b32687484d5bfb3c4a6985c612fda6d6342b26ab Mon Sep 17 00:00:00 2001 From: Brandon Evans Date: Tue, 29 Dec 2020 13:47:04 -0700 Subject: [PATCH 2/3] Move Xcode selection state into window --- Xcodes/Backend/AppState.swift | 4 ---- Xcodes/Frontend/MainWindow.swift | 9 ++++++--- Xcodes/Frontend/XcodeList/InspectorPane.swift | 15 ++++++--------- Xcodes/Frontend/XcodeList/XcodeListView.swift | 8 +++++--- Xcodes/XcodesApp.swift | 3 --- 5 files changed, 17 insertions(+), 22 deletions(-) diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index cef6b90..42b91b8 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -32,10 +32,6 @@ class AppState: ObservableObject { @Published var presentingSignInAlert = false @Published var isProcessingAuthRequest = false @Published var secondFactorData: SecondFactorData? - // Selected in the Xcode list, not in the xcode-select sense - // This probably belongs as private @State in XcodeListView, - // but we need it here instead so that it can be a focusedValue at the top level in XcodesApp instead of in a list row. The latter seems more like how the focusedValue API is supposed to work, but currently doesn't. - @Published var selectedXcodeID: Xcode.ID? @Published var xcodeBeingConfirmedForUninstallation: Xcode? @Published var helperInstallState: HelperInstallState = .notInstalled diff --git a/Xcodes/Frontend/MainWindow.swift b/Xcodes/Frontend/MainWindow.swift index 28eb395..2de7592 100644 --- a/Xcodes/Frontend/MainWindow.swift +++ b/Xcodes/Frontend/MainWindow.swift @@ -2,7 +2,7 @@ import SwiftUI struct MainWindow: View { @EnvironmentObject var appState: AppState - @State private var selection: Xcode.ID? + @State private var selectedXcodeID: Xcode.ID? @State private var searchText: String = "" @AppStorage("lastUpdated") private var lastUpdated: Double? @SceneStorage("isShowingInfoPane") private var isShowingInfoPane = false @@ -10,11 +10,11 @@ struct MainWindow: View { var body: some View { HSplitView { - XcodeListView(searchText: searchText, category: category) + XcodeListView(selectedXcodeID: $selectedXcodeID, searchText: searchText, category: category) .frame(minWidth: 300) .layoutPriority(1) - InspectorPane() + InspectorPane(selectedXcodeID: selectedXcodeID) .frame(minWidth: 300, maxWidth: .infinity) .frame(width: isShowingInfoPane ? nil : 0) .isHidden(!isShowingInfoPane) @@ -44,6 +44,9 @@ struct MainWindow: View { secondFactorView(appState.secondFactorData!) .environmentObject(appState) } + // I'm expecting to be able to use this modifier on a List row, but using it at the top level here is the only way that has made XcodeCommands work so far. + // FB8954571 focusedValue(_:_:) on List row doesn't propagate value to @FocusedValue + .focusedValue(\.selectedXcode, SelectedXcode(appState.allXcodes.first { $0.id == selectedXcodeID })) } private var subtitleText: Text { diff --git a/Xcodes/Frontend/XcodeList/InspectorPane.swift b/Xcodes/Frontend/XcodeList/InspectorPane.swift index 144d734..078675b 100644 --- a/Xcodes/Frontend/XcodeList/InspectorPane.swift +++ b/Xcodes/Frontend/XcodeList/InspectorPane.swift @@ -6,11 +6,12 @@ import struct XCModel.Compilers struct InspectorPane: View { @EnvironmentObject var appState: AppState + let selectedXcodeID: Xcode.ID? @SwiftUI.Environment(\.openURL) var openURL: OpenURLAction var body: some View { Group { - if let xcode = appState.allXcodes.first(where: { $0.id == appState.selectedXcodeID }) { + if let xcode = appState.allXcodes.first(where: { $0.id == selectedXcodeID }) { VStack(spacing: 16) { icon(for: xcode) @@ -169,7 +170,7 @@ struct InspectorPane: View { struct InspectorPane_Previews: PreviewProvider { static var previews: some View { Group { - InspectorPane() + InspectorPane(selectedXcodeID: Version(major: 12, minor: 3, patch: 0)) .environmentObject(configure(AppState()) { $0.allXcodes = [ .init( @@ -194,11 +195,10 @@ struct InspectorPane_Previews: PreviewProvider { swift: .init(number: "5.3.2") )) ] - $0.selectedXcodeID = Version(major: 12, minor: 3, patch: 0) }) .previewDisplayName("Populated, Installed, Selected") - InspectorPane() + InspectorPane(selectedXcodeID: Version(major: 12, minor: 3, patch: 0)) .environmentObject(configure(AppState()) { $0.allXcodes = [ .init( @@ -221,11 +221,10 @@ struct InspectorPane_Previews: PreviewProvider { swift: .init(number: "5.3.2") )) ] - $0.selectedXcodeID = Version(major: 12, minor: 3, patch: 0) }) .previewDisplayName("Populated, Installed, Unselected") - InspectorPane() + InspectorPane(selectedXcodeID: Version(major: 12, minor: 3, patch: 0)) .environmentObject(configure(AppState()) { $0.allXcodes = [ .init( @@ -248,15 +247,13 @@ struct InspectorPane_Previews: PreviewProvider { swift: .init(number: "5.3.2") )) ] - $0.selectedXcodeID = Version(major: 12, minor: 3, patch: 0) }) .previewDisplayName("Populated, Uninstalled") - InspectorPane() + InspectorPane(selectedXcodeID: nil) .environmentObject(configure(AppState()) { $0.allXcodes = [ ] - $0.selectedXcodeID = nil }) .previewDisplayName("Empty") } diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index a2c5d6c..edafc3c 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -4,10 +4,12 @@ import PromiseKit struct XcodeListView: View { @EnvironmentObject var appState: AppState + @Binding var selectedXcodeID: Xcode.ID? private let searchText: String private let category: XcodeListCategory - init(searchText: String, category: XcodeListCategory) { + init(selectedXcodeID: Binding, searchText: String, category: XcodeListCategory) { + self._selectedXcodeID = selectedXcodeID self.searchText = searchText self.category = category } @@ -29,7 +31,7 @@ struct XcodeListView: View { } var body: some View { - List(visibleXcodes, selection: $appState.selectedXcodeID) { xcode in + List(visibleXcodes, selection: $selectedXcodeID) { xcode in HStack { appIconView(for: xcode) @@ -87,7 +89,7 @@ struct XcodeListView: View { struct XcodeListView_Previews: PreviewProvider { static var previews: some View { Group { - XcodeListView(searchText: "", category: .all) + XcodeListView(selectedXcodeID: .constant(nil), searchText: "", category: .all) .environmentObject({ () -> AppState in let a = AppState() a.allXcodes = [ diff --git a/Xcodes/XcodesApp.swift b/Xcodes/XcodesApp.swift index fae3bb0..bb4398f 100644 --- a/Xcodes/XcodesApp.swift +++ b/Xcodes/XcodesApp.swift @@ -20,9 +20,6 @@ struct XcodesApp: App { appState.updateIfNeeded() } } - // I'm expecting to be able to use this modifier on a List row, but using it at the top level here is the only way that has made XcodeCommands work so far. - // FB8954571 focusedValue(_:_:) on List row doesn't propagate value to @FocusedValue - .focusedValue(\.selectedXcode, SelectedXcode(appState.allXcodes.first { $0.id == appState.selectedXcodeID })) } .commands { CommandGroup(replacing: .appInfo) { From 94ef499e9c3f1956c4c4b6636282190ac21f1da9 Mon Sep 17 00:00:00 2001 From: Chad Sykes Date: Tue, 29 Dec 2020 14:52:34 -0700 Subject: [PATCH 3/3] Non-functional fix so it compiles locally --- Xcodes/Frontend/XcodeList/XcodeListView.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index edafc3c..3503114 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -41,7 +41,7 @@ struct XcodeListView: View { Text(verbatim: xcode.path ?? "") .font(.caption) - .foregroundColor(appState.selectedXcodeID == xcode.id ? Color(NSColor.selectedMenuItemTextColor) : Color(NSColor.secondaryLabelColor)) + .foregroundColor(selectedXcodeID == xcode.id ? Color(NSColor.selectedMenuItemTextColor) : Color(NSColor.secondaryLabelColor)) } @@ -56,8 +56,8 @@ struct XcodeListView: View { print("Installing...") } .buttonStyle(AppStoreButtonStyle(installed: xcode.installed, - highlighted: appState.selectedXcodeID == xcode.id)) - .disabled(xcode.installed) + highlighted: selectedXcodeID == xcode.id)) + .disabled(xcode.installed) } .contextMenu { InstallButton(xcode: xcode)