diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index 5852ae2..b5e9823 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -69,9 +69,14 @@ CAD2E7A62449575000113D76 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CAD2E7A52449575000113D76 /* Assets.xcassets */; }; CAD2E7A92449575000113D76 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CAD2E7A82449575000113D76 /* Preview Assets.xcassets */; }; CAD2E7B82449575100113D76 /* XcodesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAD2E7B72449575100113D76 /* XcodesTests.swift */; }; + CAE4247F259A666100B8B246 /* MainWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAE4247E259A666100B8B246 /* MainWindow.swift */; }; + CAE42487259A68A300B8B246 /* XcodeListCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAE42486259A68A300B8B246 /* XcodeListCategory.swift */; }; + CAE4248C259A68B800B8B246 /* Optional+IsNotNil.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAE4248B259A68B800B8B246 /* Optional+IsNotNil.swift */; }; CAFBDB912598FE80003DCC5A /* SelectedXcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFBDB902598FE80003DCC5A /* SelectedXcode.swift */; }; CAFBDB952598FE96003DCC5A /* FocusedValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFBDB942598FE96003DCC5A /* FocusedValues.swift */; }; CAFBDC4E2599B33D003DCC5A /* MainToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFBDC4D2599B33D003DCC5A /* MainToolbar.swift */; }; + CAFBDC68259A308B003DCC5A /* InspectorPane.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFBDC67259A308B003DCC5A /* InspectorPane.swift */; }; + CAFBDC6C259A3098003DCC5A /* View+Conditional.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFBDC6B259A3098003DCC5A /* View+Conditional.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -187,10 +192,15 @@ CAD2E7B32449575100113D76 /* XcodesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = XcodesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; CAD2E7B72449575100113D76 /* XcodesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodesTests.swift; sourceTree = ""; }; CAD2E7B92449575100113D76 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + CAE4247E259A666100B8B246 /* MainWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindow.swift; sourceTree = ""; }; + CAE42486259A68A300B8B246 /* XcodeListCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeListCategory.swift; sourceTree = ""; }; + CAE4248B259A68B800B8B246 /* Optional+IsNotNil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+IsNotNil.swift"; sourceTree = ""; }; CAFBDB902598FE80003DCC5A /* SelectedXcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedXcode.swift; sourceTree = ""; }; CAFBDB942598FE96003DCC5A /* FocusedValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusedValues.swift; sourceTree = ""; }; CAFBDBA525990C76003DCC5A /* SimpleXPCApp.LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = SimpleXPCApp.LICENSE; sourceTree = ""; }; CAFBDC4D2599B33D003DCC5A /* MainToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainToolbar.swift; sourceTree = ""; }; + CAFBDC67259A308B003DCC5A /* InspectorPane.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorPane.swift; sourceTree = ""; }; + CAFBDC6B259A3098003DCC5A /* View+Conditional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Conditional.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -295,8 +305,10 @@ isa = PBXGroup; children = ( CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */, + CAFBDC67259A308B003DCC5A /* InspectorPane.swift */, CAFBDC4D2599B33D003DCC5A /* MainToolbar.swift */, CA44901E2463AD34003D8213 /* Tag.swift */, + CAE42486259A68A300B8B246 /* XcodeListCategory.swift */, CAD2E7A32449574E00113D76 /* XcodeListView.swift */, ); path = XcodeList; @@ -321,6 +333,7 @@ CA9FF8F425959CE000E47BAF /* HelperInstaller.swift */, CA9FF9352595B44700E47BAF /* HelperClient.swift */, CA9FF8862595607900E47BAF /* InstalledXcode.swift */, + CAE4248B259A68B800B8B246 /* Optional+IsNotNil.swift */, CABFA9AE2592EEE900380FEE /* Path+.swift */, CABFA9B42592EEEA00380FEE /* Process.swift */, CABFA9B02592EEEA00380FEE /* Promise+.swift */, @@ -343,7 +356,9 @@ CA9FF8552595082000E47BAF /* About */, CAA1CB50255A5D16003FD669 /* SignIn */, CABFAA142592F73000380FEE /* XcodeList */, + CAE4247E259A666100B8B246 /* MainWindow.swift */, CABFAA2A2592FBFC00380FEE /* SettingsView.swift */, + CAFBDC6B259A3098003DCC5A /* View+Conditional.swift */, CA9FF8652595130600E47BAF /* View+IsHidden.swift */, ); path = Frontend; @@ -576,6 +591,7 @@ outputFileListPaths = ( ); outputPaths = ( + "$(SRCROOT)/Xcodes/Resources/Licenses.rtf", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; @@ -606,6 +622,7 @@ CA11E7BA2598476C00D2EE1C /* XcodeCommands.swift in Sources */, CABFAA492593162500380FEE /* Bundle+InfoPlistValues.swift in Sources */, CA9FF8662595130600E47BAF /* View+IsHidden.swift in Sources */, + CAE4248C259A68B800B8B246 /* Optional+IsNotNil.swift in Sources */, CA9FF9362595B44700E47BAF /* HelperClient.swift in Sources */, CABFA9CA2592EEEA00380FEE /* AppState+Update.swift in Sources */, CA44901F2463AD34003D8213 /* Tag.swift in Sources */, @@ -620,6 +637,7 @@ CABFA9CD2592EEEA00380FEE /* Foundation.swift in Sources */, CA9FF8872595607900E47BAF /* InstalledXcode.swift in Sources */, CA61A6E0259835580008926E /* Xcode.swift in Sources */, + CAE4247F259A666100B8B246 /* MainWindow.swift in Sources */, CA9FF84E2595079F00E47BAF /* ScrollingTextView.swift in Sources */, CABFA9C12592EEEA00380FEE /* Version+.swift in Sources */, CA9FF8522595080100E47BAF /* AcknowledgementsView.swift in Sources */, @@ -633,9 +651,12 @@ CA73510D257BFCEF00EA9CF8 /* NSAttributedString+.swift in Sources */, CAFBDB952598FE96003DCC5A /* FocusedValues.swift in Sources */, CABFA9C22592EEEA00380FEE /* Promise+.swift in Sources */, + CAFBDC68259A308B003DCC5A /* InspectorPane.swift in Sources */, CAA1CB4D255A5CFD003FD669 /* SignInPhoneListView.swift in Sources */, + CAFBDC6C259A3098003DCC5A /* View+Conditional.swift in Sources */, CABFA9CF2592EEEA00380FEE /* Process.swift in Sources */, CABFA9C72592EEEA00380FEE /* Entry+.swift in Sources */, + CAE42487259A68A300B8B246 /* XcodeListCategory.swift in Sources */, CABFAA2C2592FBFC00380FEE /* SettingsView.swift in Sources */, CA9FF87B2595293E00E47BAF /* DataSource.swift in Sources */, CABFA9C92592EEEA00380FEE /* URLRequest+Apple.swift in Sources */, diff --git a/Xcodes/Backend/AppState+Update.swift b/Xcodes/Backend/AppState+Update.swift index 856d49f..eb5d977 100644 --- a/Xcodes/Backend/AppState+Update.swift +++ b/Xcodes/Backend/AppState+Update.swift @@ -170,7 +170,11 @@ extension AppState { version: version, url: downloadURL, filename: String(downloadURL.path.suffix(fromLast: "/")), - releaseDate: releaseDate + releaseDate: releaseDate, + requiredMacOSVersion: xcReleasesXcode.requires, + releaseNotesURL: xcReleasesXcode.links?.notes?.url, + sdks: xcReleasesXcode.sdks, + compilers: xcReleasesXcode.compilers ) } return xcodes diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 1fd3c5c..4410fe3 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -237,7 +237,7 @@ class AppState: ObservableObject { ) } - func launch(id: Xcode.ID) { + func open(id: Xcode.ID) { guard let installedXcode = Current.files.installedXcodes(Path.root/"Applications").first(where: { $0.version == id }) else { return } NSWorkspace.shared.openApplication(at: installedXcode.path.url, configuration: .init()) } @@ -274,12 +274,17 @@ 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 } return Xcode( version: xcodeVersion, installState: installedXcodes.contains(where: { xcodeVersion.isEquivalentForDeterminingIfInstalled(toInstalled: $0.version) }) ? .installed : .notInstalled, selected: false, path: installedXcode?.path.string, - icon: (installedXcode?.path.string).map(NSWorkspace.shared.icon(forFile:)) + icon: (installedXcode?.path.string).map(NSWorkspace.shared.icon(forFile:)), + requiredMacOSVersion: availableXcode?.requiredMacOSVersion, + releaseNotesURL: availableXcode?.releaseNotesURL, + sdks: availableXcode?.sdks, + compilers: availableXcode?.compilers ) } } diff --git a/Xcodes/Backend/AvailableXcode.swift b/Xcodes/Backend/AvailableXcode.swift index f1c0c0a..8ef4e18 100644 --- a/Xcodes/Backend/AvailableXcode.swift +++ b/Xcodes/Backend/AvailableXcode.swift @@ -1,5 +1,7 @@ import Foundation import Version +import struct XCModel.SDKs +import struct XCModel.Compilers /// A version of Xcode that's available for installation public struct AvailableXcode: Codable { @@ -7,11 +9,28 @@ public struct AvailableXcode: Codable { public let url: URL public let filename: String public let releaseDate: Date? + public let requiredMacOSVersion: String? + public let releaseNotesURL: URL? + public let sdks: SDKs? + public let compilers: Compilers? - public init(version: Version, url: URL, filename: String, releaseDate: Date?) { + public init( + version: Version, + url: URL, + filename: String, + releaseDate: Date?, + requiredMacOSVersion: String? = nil, + releaseNotesURL: URL? = nil, + sdks: SDKs? = nil, + compilers: Compilers? = nil + ) { self.version = version self.url = url self.filename = filename self.releaseDate = releaseDate + self.requiredMacOSVersion = requiredMacOSVersion + self.releaseNotesURL = releaseNotesURL + self.sdks = sdks + self.compilers = compilers } } diff --git a/Xcodes/Backend/Optional+IsNotNil.swift b/Xcodes/Backend/Optional+IsNotNil.swift new file mode 100644 index 0000000..9fdcd34 --- /dev/null +++ b/Xcodes/Backend/Optional+IsNotNil.swift @@ -0,0 +1,9 @@ +import Foundation + +extension Optional { + /// Note that this is lossy when setting, so you can really only set it to nil, but this is sufficient for mapping `Binding` to `Binding` for Alerts, Popovers, etc. + var isNotNil: Bool { + get { self != nil } + set { self = newValue ? self : nil } + } +} diff --git a/Xcodes/Backend/Xcode.swift b/Xcodes/Backend/Xcode.swift index c322288..cfb8d7d 100644 --- a/Xcodes/Backend/Xcode.swift +++ b/Xcodes/Backend/Xcode.swift @@ -1,6 +1,8 @@ import AppKit import Foundation import Version +import struct XCModel.SDKs +import struct XCModel.Compilers struct Xcode: Identifiable, CustomStringConvertible { let version: Version @@ -8,6 +10,32 @@ struct Xcode: Identifiable, CustomStringConvertible { let selected: Bool let path: String? let icon: NSImage? + let requiredMacOSVersion: String? + let releaseNotesURL: URL? + let sdks: SDKs? + let compilers: Compilers? + + init( + version: Version, + installState: XcodeInstallState, + selected: Bool, + path: String?, + icon: NSImage?, + requiredMacOSVersion: String? = nil, + releaseNotesURL: URL? = nil, + sdks: SDKs? = nil, + compilers: Compilers? = nil + ) { + self.version = version + self.installState = installState + self.selected = selected + self.path = path + self.icon = icon + self.requiredMacOSVersion = requiredMacOSVersion + self.releaseNotesURL = releaseNotesURL + self.sdks = sdks + self.compilers = compilers + } var id: Version { version } var installed: Bool { installState == .installed } diff --git a/Xcodes/Backend/XcodeCommands.swift b/Xcodes/Backend/XcodeCommands.swift index c8fd256..df00299 100644 --- a/Xcodes/Backend/XcodeCommands.swift +++ b/Xcodes/Backend/XcodeCommands.swift @@ -14,7 +14,7 @@ struct XcodeCommands: Commands { Divider() SelectCommand() - LaunchCommand() + OpenCommand() RevealCommand() CopyPathCommand() } @@ -66,19 +66,19 @@ struct SelectButton: View { } } -struct LaunchButton: View { +struct OpenButton: View { @EnvironmentObject var appState: AppState let xcode: Xcode? var body: some View { - Button(action: launch) { - Text("Launch") + Button(action: open) { + Text("Open") } } - private func launch() { + private func open() { guard let xcode = xcode else { return } - appState.launch(id: xcode.id) + appState.open(id: xcode.id) } } @@ -138,12 +138,12 @@ struct SelectCommand: View { } } -struct LaunchCommand: View { +struct OpenCommand: View { @EnvironmentObject var appState: AppState @FocusedValue(\.selectedXcode) private var selectedXcode: SelectedXcode? var body: some View { - LaunchButton(xcode: selectedXcode.unwrapped) + OpenButton(xcode: selectedXcode.unwrapped) .keyboardShortcut(KeyboardShortcut(.downArrow, modifiers: .command)) .disabled(selectedXcode.unwrapped?.installed != true) } diff --git a/Xcodes/Frontend/MainWindow.swift b/Xcodes/Frontend/MainWindow.swift new file mode 100644 index 0000000..28eb395 --- /dev/null +++ b/Xcodes/Frontend/MainWindow.swift @@ -0,0 +1,74 @@ +import SwiftUI + +struct MainWindow: View { + @EnvironmentObject var appState: AppState + @State private var selection: Xcode.ID? + @State private var searchText: String = "" + @AppStorage("lastUpdated") private var lastUpdated: Double? + @SceneStorage("isShowingInfoPane") private var isShowingInfoPane = false + @SceneStorage("xcodeListCategory") private var category: XcodeListCategory = .all + + var body: some View { + HSplitView { + XcodeListView(searchText: searchText, category: category) + .frame(minWidth: 300) + .layoutPriority(1) + + InspectorPane() + .frame(minWidth: 300, maxWidth: .infinity) + .frame(width: isShowingInfoPane ? nil : 0) + .isHidden(!isShowingInfoPane) + } + .mainToolbar( + category: $category, + isShowingInfoPane: $isShowingInfoPane, + searchText: $searchText + ) + .navigationSubtitle(subtitleText) + .frame(minWidth: 600, maxWidth: .infinity, minHeight: 300, maxHeight: .infinity) + .alert(item: $appState.error) { error in + Alert(title: Text(error.title), + message: Text(verbatim: error.message), + dismissButton: .default(Text("OK"))) + } + /* + Removing this for now, because it's overriding the error alert that's being worked on above. + .alert(item: $appState.xcodeBeingConfirmedForUninstallation) { xcode in + Alert(title: Text("Uninstall Xcode \(xcode.description)?"), + message: Text("It will be moved to the Trash, but won't be emptied."), + primaryButton: .destructive(Text("Uninstall"), action: { self.appState.uninstall(id: xcode.id) }), + secondaryButton: .cancel(Text("Cancel"))) + } + **/ + .sheet(isPresented: $appState.secondFactorData.isNotNil) { + secondFactorView(appState.secondFactorData!) + .environmentObject(appState) + } + } + + private var subtitleText: Text { + if let lastUpdated = lastUpdated.map(Date.init(timeIntervalSince1970:)) { + return Text("Updated at \(lastUpdated, style: .date) \(lastUpdated, style: .time)") + } else { + return Text("") + } + } + + @ViewBuilder + private func secondFactorView(_ secondFactorData: AppState.SecondFactorData) -> some View { + switch secondFactorData.option { + case .codeSent: + SignIn2FAView(isPresented: $appState.secondFactorData.isNotNil, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData) + case .smsSent(let trustedPhoneNumber): + SignInSMSView(isPresented: $appState.secondFactorData.isNotNil, trustedPhoneNumber: trustedPhoneNumber, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData) + case .smsPendingChoice: + SignInPhoneListView(isPresented: $appState.secondFactorData.isNotNil, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData) + } + } +} + +struct MainWindow_Previews: PreviewProvider { + static var previews: some View { + MainWindow() + } +} diff --git a/Xcodes/Frontend/View+Conditional.swift b/Xcodes/Frontend/View+Conditional.swift new file mode 100644 index 0000000..543d993 --- /dev/null +++ b/Xcodes/Frontend/View+Conditional.swift @@ -0,0 +1,12 @@ +import SwiftUI + +extension View { + @ViewBuilder + func `if`(_ predicate: Bool, then: (Self) -> Other) -> some View { + if predicate { + then(self) + } else { + self + } + } +} diff --git a/Xcodes/Frontend/XcodeList/InspectorPane.swift b/Xcodes/Frontend/XcodeList/InspectorPane.swift new file mode 100644 index 0000000..144d734 --- /dev/null +++ b/Xcodes/Frontend/XcodeList/InspectorPane.swift @@ -0,0 +1,265 @@ +import AppKit +import SwiftUI +import Version +import struct XCModel.SDKs +import struct XCModel.Compilers + +struct InspectorPane: View { + @EnvironmentObject var appState: AppState + @SwiftUI.Environment(\.openURL) var openURL: OpenURLAction + + var body: some View { + Group { + if let xcode = appState.allXcodes.first(where: { $0.id == appState.selectedXcodeID }) { + VStack(spacing: 16) { + icon(for: xcode) + + VStack(alignment: .leading) { + Text("Xcode \(xcode.description)") + .font(.title) + .frame(maxWidth: .infinity, alignment: .leading) + + if let path = xcode.path { + HStack { + Text(path) + Button(action: { appState.reveal(id: xcode.id) }) { + Image(systemName: "arrow.right.circle.fill") + } + .buttonStyle(PlainButtonStyle()) + } + + HStack { + if xcode.selected { + Button("Selected", action: {}) + .disabled(true) + } else { + SelectButton(xcode: xcode) + } + + OpenButton(xcode: xcode) + } + } else { + InstallButton(xcode: xcode) + } + } + + Divider() + + releaseNotes(for: xcode) + compatibility(for: xcode) + sdks(for: xcode) + compilers(for: xcode) + + Spacer() + } + } else { + empty + } + } + .padding() + .frame(minWidth: 200, maxWidth: .infinity) + } + + @ViewBuilder + private func icon(for xcode: Xcode) -> some View { + if let path = xcode.path { + Image(nsImage: NSWorkspace.shared.icon(forFile: path)) + } else { + Image(systemName: "app.fill") + .resizable() + .frame(width: 32, height: 32) + .foregroundColor(.secondary) + } + } + + @ViewBuilder + private func releaseNotes(for xcode: Xcode) -> some View { + if let releaseNotesURL = xcode.releaseNotesURL { + Button(action: { openURL(releaseNotesURL) }) { + Label("Release Notes", systemImage: "link") + } + .buttonStyle(LinkButtonStyle()) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + EmptyView() + } + } + + @ViewBuilder + private func compatibility(for xcode: Xcode) -> some View { + if let requiredMacOSVersion = xcode.requiredMacOSVersion { + VStack(alignment: .leading) { + Text("Compatibility") + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + Text("Requires macOS \(requiredMacOSVersion) or later") + .font(.subheadline) + .frame(maxWidth: .infinity, alignment: .leading) + } + } else { + EmptyView() + } + } + + @ViewBuilder + private func sdks(for xcode: Xcode) -> some View { + if let sdks = xcode.sdks { + VStack(alignment: .leading) { + Text("SDKs") + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + + ForEach([ + ("macOS", \SDKs.macOS), + ("iOS", \.iOS), + ("watchOS", \.watchOS), + ("tvOS", \.tvOS), + ], id: \.0) { row in + if let sdk = sdks[keyPath: row.1] { + Text("\(row.0): \(sdk.compactMap { $0.number }.joined(separator: ", "))") + .font(.subheadline) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } else { + EmptyView() + } + } + + @ViewBuilder + private func compilers(for xcode: Xcode) -> some View { + if let compilers = xcode.compilers { + VStack(alignment: .leading) { + Text("Compilers") + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + + ForEach([ + ("Swift", \Compilers.swift), + ("Clang", \.clang), + ("LLVM", \.llvm), + ("LLVM GCC", \.llvm_gcc), + ("GCC", \.gcc), + ], id: \.0) { row in + if let sdk = compilers[keyPath: row.1] { + Text("\(row.0): \(sdk.compactMap { $0.number }.joined(separator: ", "))") + .font(.subheadline) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } else { + EmptyView() + } + } + + @ViewBuilder + private var empty: some View { + VStack { + Spacer() + Text("No Xcode Selected") + .font(.title) + .foregroundColor(.secondary) + Spacer() + } + } +} + +struct InspectorPane_Previews: PreviewProvider { + static var previews: some View { + Group { + InspectorPane() + .environmentObject(configure(AppState()) { + $0.allXcodes = [ + .init( + version: Version(major: 12, minor: 3, patch: 0), + installState: .installed, + selected: true, + path: "/Applications/Xcode-12.3.0.app", + icon: NSWorkspace.shared.icon(forFile: "/Applications/Xcode-12.3.0.app"), + requiredMacOSVersion: "10.15.4", + releaseNotesURL: URL(string: "https://developer.apple.com/documentation/xcode-release-notes/xcode-12_3-release-notes/")!, + sdks: SDKs( + macOS: .init(number: "11.1"), + iOS: .init(number: "14.3"), + watchOS: .init(number: "7.3"), + tvOS: .init(number: "14.3") + ), + compilers: Compilers( + gcc: .init(number: "4"), + llvm_gcc: .init(number: "213"), + llvm: .init(number: "2.3"), + clang: .init(number: "7.3"), + swift: .init(number: "5.3.2") + )) + ] + $0.selectedXcodeID = Version(major: 12, minor: 3, patch: 0) + }) + .previewDisplayName("Populated, Installed, Selected") + + InspectorPane() + .environmentObject(configure(AppState()) { + $0.allXcodes = [ + .init( + version: Version(major: 12, minor: 3, patch: 0), + installState: .installed, + selected: false, + path: "/Applications/Xcode-12.3.0.app", + icon: NSWorkspace.shared.icon(forFile: "/Applications/Xcode-12.3.0.app"), + sdks: SDKs( + macOS: .init(number: "11.1"), + iOS: .init(number: "14.3"), + watchOS: .init(number: "7.3"), + tvOS: .init(number: "14.3") + ), + compilers: Compilers( + gcc: .init(number: "4"), + llvm_gcc: .init(number: "213"), + llvm: .init(number: "2.3"), + clang: .init(number: "7.3"), + swift: .init(number: "5.3.2") + )) + ] + $0.selectedXcodeID = Version(major: 12, minor: 3, patch: 0) + }) + .previewDisplayName("Populated, Installed, Unselected") + + InspectorPane() + .environmentObject(configure(AppState()) { + $0.allXcodes = [ + .init( + version: Version(major: 12, minor: 3, patch: 0), + installState: .notInstalled, + selected: false, + path: nil, + icon: nil, + sdks: SDKs( + macOS: .init(number: "11.1"), + iOS: .init(number: "14.3"), + watchOS: .init(number: "7.3"), + tvOS: .init(number: "14.3") + ), + compilers: Compilers( + gcc: .init(number: "4"), + llvm_gcc: .init(number: "213"), + llvm: .init(number: "2.3"), + clang: .init(number: "7.3"), + swift: .init(number: "5.3.2") + )) + ] + $0.selectedXcodeID = Version(major: 12, minor: 3, patch: 0) + }) + .previewDisplayName("Populated, Uninstalled") + + InspectorPane() + .environmentObject(configure(AppState()) { + $0.allXcodes = [ + ] + $0.selectedXcodeID = nil + }) + .previewDisplayName("Empty") + } + .frame(maxWidth: 300) + } +} diff --git a/Xcodes/Frontend/XcodeList/MainToolbar.swift b/Xcodes/Frontend/XcodeList/MainToolbar.swift index 1c60822..b34c88e 100644 --- a/Xcodes/Frontend/XcodeList/MainToolbar.swift +++ b/Xcodes/Frontend/XcodeList/MainToolbar.swift @@ -2,7 +2,8 @@ import SwiftUI struct MainToolbarModifier: ViewModifier { @EnvironmentObject var appState: AppState - @Binding var category: XcodeListView.Category + @Binding var category: XcodeListCategory + @Binding var isShowingInfoPane: Bool @Binding var searchText: String func body(content: Content) -> some View { @@ -35,6 +36,16 @@ struct MainToolbarModifier: ViewModifier { } } + Button(action: { isShowingInfoPane.toggle() }) { + if isShowingInfoPane { + Label("Inspector", systemImage: "info.circle.fill") + .foregroundColor(.accentColor) + } else { + Label("Inspector", systemImage: "info.circle") + } + } + .keyboardShortcut(KeyboardShortcut("i", modifiers: [.command, .option])) + TextField("Search...", text: $searchText) .textFieldStyle(RoundedBorderTextFieldStyle()) .frame(width: 200) @@ -44,9 +55,16 @@ struct MainToolbarModifier: ViewModifier { extension View { func mainToolbar( - category: Binding, + category: Binding, + isShowingInfoPane: Binding, searchText: Binding ) -> some View { - self.modifier(MainToolbarModifier(category: category, searchText: searchText)) + self.modifier( + MainToolbarModifier( + category: category, + isShowingInfoPane: isShowingInfoPane, + searchText: searchText + ) + ) } } diff --git a/Xcodes/Frontend/XcodeList/XcodeListCategory.swift b/Xcodes/Frontend/XcodeList/XcodeListCategory.swift new file mode 100644 index 0000000..520414e --- /dev/null +++ b/Xcodes/Frontend/XcodeList/XcodeListCategory.swift @@ -0,0 +1,15 @@ +import Foundation + +enum XcodeListCategory: String, CaseIterable, Identifiable, CustomStringConvertible { + case all + case installed + + var id: Self { self } + + var description: String { + switch self { + case .all: return "All" + case .installed: return "Installed" + } + } +} diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index d58b9fb..a2fa65a 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -4,10 +4,13 @@ import PromiseKit struct XcodeListView: View { @EnvironmentObject var appState: AppState - @State private var selection: Xcode.ID? - @State private var searchText: String = "" - @AppStorage("lastUpdated") private var lastUpdated: Double? - @AppStorage("xcodeListCategory") private var category: Category = .all + private let searchText: String + private let category: XcodeListCategory + + init(searchText: String, category: XcodeListCategory) { + self.searchText = searchText + self.category = category + } var visibleXcodes: [Xcode] { var xcodes: [Xcode] @@ -25,20 +28,6 @@ struct XcodeListView: View { return xcodes } - enum Category: String, CaseIterable, Identifiable, CustomStringConvertible { - case all - case installed - - var id: Self { self } - - var description: String { - switch self { - case .all: return "All" - case .installed: return "Installed" - } - } - } - var body: some View { List(visibleXcodes, selection: $appState.selectedXcodeID) { xcode in HStack { @@ -69,58 +58,17 @@ struct XcodeListView: View { } .contextMenu { InstallButton(xcode: xcode) - + Divider() - + if xcode.installed { SelectButton(xcode: xcode) - LaunchButton(xcode: xcode) + OpenButton(xcode: xcode) RevealButton(xcode: xcode) CopyPathButton(xcode: xcode) } } } - .mainToolbar(category: $category, searchText: $searchText) - .navigationSubtitle(subtitleText) - .frame(minWidth: 200, maxWidth: .infinity, minHeight: 300, maxHeight: .infinity) - .alert(item: $appState.error) { error in - Alert(title: Text(error.title), - message: Text(verbatim: error.message), - dismissButton: .default(Text("OK"))) - } - /* - Removing this for now, because it's overriding the error alert that's being worked on above. - .alert(item: $appState.xcodeBeingConfirmedForUninstallation) { xcode in - Alert(title: Text("Uninstall Xcode \(xcode.description)?"), - message: Text("It will be moved to the Trash, but won't be emptied."), - primaryButton: .destructive(Text("Uninstall"), action: { self.appState.uninstall(id: xcode.id) }), - secondaryButton: .cancel(Text("Cancel"))) - } - **/ - .sheet(isPresented: $appState.secondFactorData.isNotNil) { - secondFactorView(appState.secondFactorData!) - .environmentObject(appState) - } - } - - @ViewBuilder - func secondFactorView(_ secondFactorData: AppState.SecondFactorData) -> some View { - switch secondFactorData.option { - case .codeSent: - SignIn2FAView(isPresented: $appState.secondFactorData.isNotNil, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData) - case .smsSent(let trustedPhoneNumber): - SignInSMSView(isPresented: $appState.secondFactorData.isNotNil, trustedPhoneNumber: trustedPhoneNumber, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData) - case .smsPendingChoice: - SignInPhoneListView(isPresented: $appState.secondFactorData.isNotNil, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData) - } - } - - private var subtitleText: Text { - if let lastUpdated = lastUpdated.map(Date.init(timeIntervalSince1970:)) { - return Text("Updated at \(lastUpdated, style: .date) \(lastUpdated, style: .time)") - } else { - return Text("") - } } @ViewBuilder @@ -138,7 +86,7 @@ struct XcodeListView: View { struct XcodeListView_Previews: PreviewProvider { static var previews: some View { Group { - XcodeListView() + XcodeListView(searchText: "", category: .all) .environmentObject({ () -> AppState in let a = AppState() a.allXcodes = [ @@ -153,11 +101,3 @@ struct XcodeListView_Previews: PreviewProvider { .previewLayout(.sizeThatFits) } } - -extension Optional { - /// Note that this is lossy when setting, so you can really only set it to nil, but this is sufficient for mapping `Binding` to `Binding` for Alerts, Popovers, etc. - var isNotNil: Bool { - get { self != nil } - set { self = newValue ? self : nil } - } -} diff --git a/Xcodes/XcodesApp.swift b/Xcodes/XcodesApp.swift index 14036a1..fae3bb0 100644 --- a/Xcodes/XcodesApp.swift +++ b/Xcodes/XcodesApp.swift @@ -9,7 +9,7 @@ struct XcodesApp: App { var body: some Scene { WindowGroup("Xcodes") { - XcodeListView() + MainWindow() .environmentObject(appState) // This is intentionally used on a View, and not on a WindowGroup, // so that it's triggered when an individual window's phase changes instead of all window phases.