diff --git a/DECISIONS.md b/DECISIONS.md index 5671e1a..bc9bd91 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -49,3 +49,18 @@ Uninstallation is not provided yet. I had this partially implemented (one attemp - [erikberglund/SwiftPrivilegedHelper](https://github.com/erikberglund/SwiftPrivilegedHelper) - [aronskaya/smjobbless](https://github.com/aronskaya/smjobbless) - [securing/SimpleXPCApp](https://github.com/securing/SimpleXPCApp) + +## Selecting the active version of Xcode + +This isn't a technical decision, but we spent enough time talking about this that it's probably worth sharing. When a user has more than one version of Xcode installed, a specific version of the developer tools can be selected with the `xcode-select` tool. The selected version of tools like xcodebuild or xcrun will be used unless the DEVELOPER_DIR environment variable has been set to a different path. You can read more about this in the `xcode-select` man pages. Notably, the man pages and [some notarization documentation](https://developer.apple.com/documentation/xcode/notarizing_macos_software_before_distribution) use the term "active" to indicate the Xcode version that's been selected. [This](https://developer.apple.com/library/archive/technotes/tn2339/_index.html#//apple_ref/doc/uid/DTS40014588-CH1-HOW_DO_I_SELECT_THE_DEFAULT_VERSION_OF_XCODE_TO_USE_FOR_MY_COMMAND_LINE_TOOLS_) older tech note uses the term "default". And of course, the `xcode-select` tool has the term "select" in its name. xcodes used the terms "select" and "selected" for this functionality, intending to match the xcode-select tool. + +Here are the descriptions of these terms from [Apple's Style Guide](https://books.apple.com/ca/book/apple-style-guide/id1161855204): + +> active: Use to refer to the app or window currently being used. Preferred to in front. +> default: OK to use to describe the state of settings before the user changes them. See also preset +> preset: Use to refer to a group of customized settings an app provides or the user saves for reuse. +> select: Use select, not choose, to refer to the action users perform when they select among multiple objects + +Xcodes.app has this same functionality as xcodes, which still uses `xcode-select` under the hood, but because the main UI is a list of selectable rows, there _may_ be some ambiguity about the meaning of "selected". "Default" has a less clear connection to `xcode-select`'s name, but does accurately describe the behaviour that results. In Xcode 11 Launch Services also uses the selected Xcode version when opening a (GUI) developer tool bundled with Xcode, like Instruments. We could also try to follow Apple's lead by using the term "active" from the `xcode-select` man pages and notarization documentation. According to the style guide "active" already has a clear meaning in a GUI context. + +Ultimately, we've decided to align with Apple's usage of "active" and "make active" in this specific context, despite possible confusion with the definition in the style guide. diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index b5e9823..f0a8f89 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -75,8 +75,9 @@ 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 */; }; + CAFBDC68259A308B003DCC5A /* InfoPane.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFBDC67259A308B003DCC5A /* InfoPane.swift */; }; CAFBDC6C259A3098003DCC5A /* View+Conditional.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFBDC6B259A3098003DCC5A /* View+Conditional.swift */; }; + CAFFFED8259CDA5000903F81 /* XcodeListViewRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFFFED7259CDA5000903F81 /* XcodeListViewRow.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -199,8 +200,9 @@ 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 = ""; }; + CAFBDC67259A308B003DCC5A /* InfoPane.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoPane.swift; sourceTree = ""; }; CAFBDC6B259A3098003DCC5A /* View+Conditional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Conditional.swift"; sourceTree = ""; }; + CAFFFED7259CDA5000903F81 /* XcodeListViewRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeListViewRow.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -305,11 +307,12 @@ isa = PBXGroup; children = ( CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */, - CAFBDC67259A308B003DCC5A /* InspectorPane.swift */, + CAFBDC67259A308B003DCC5A /* InfoPane.swift */, CAFBDC4D2599B33D003DCC5A /* MainToolbar.swift */, CA44901E2463AD34003D8213 /* Tag.swift */, CAE42486259A68A300B8B246 /* XcodeListCategory.swift */, CAD2E7A32449574E00113D76 /* XcodeListView.swift */, + CAFFFED7259CDA5000903F81 /* XcodeListViewRow.swift */, ); path = XcodeList; sourceTree = ""; @@ -651,10 +654,11 @@ CA73510D257BFCEF00EA9CF8 /* NSAttributedString+.swift in Sources */, CAFBDB952598FE96003DCC5A /* FocusedValues.swift in Sources */, CABFA9C22592EEEA00380FEE /* Promise+.swift in Sources */, - CAFBDC68259A308B003DCC5A /* InspectorPane.swift in Sources */, + CAFBDC68259A308B003DCC5A /* InfoPane.swift in Sources */, CAA1CB4D255A5CFD003FD669 /* SignInPhoneListView.swift in Sources */, CAFBDC6C259A3098003DCC5A /* View+Conditional.swift in Sources */, CABFA9CF2592EEEA00380FEE /* Process.swift in Sources */, + CAFFFED8259CDA5000903F81 /* XcodeListViewRow.swift in Sources */, CABFA9C72592EEEA00380FEE /* Entry+.swift in Sources */, CAE42487259A68A300B8B246 /* XcodeListCategory.swift in Sources */, CABFAA2C2592FBFC00380FEE /* SettingsView.swift in Sources */, diff --git a/Xcodes/Backend/XcodeCommands.swift b/Xcodes/Backend/XcodeCommands.swift index e12cedb..9a4b1c3 100644 --- a/Xcodes/Backend/XcodeCommands.swift +++ b/Xcodes/Backend/XcodeCommands.swift @@ -58,7 +58,11 @@ struct SelectButton: View { var body: some View { Button(action: select) { - Text("Select") + if xcode?.selected == true { + Text("Active") + } else { + Text("Make active") + } } .disabled(xcode?.selected != false) .help("Select") diff --git a/Xcodes/Frontend/MainWindow.swift b/Xcodes/Frontend/MainWindow.swift index 2de7592..fe78e1b 100644 --- a/Xcodes/Frontend/MainWindow.swift +++ b/Xcodes/Frontend/MainWindow.swift @@ -14,7 +14,7 @@ struct MainWindow: View { .frame(minWidth: 300) .layoutPriority(1) - InspectorPane(selectedXcodeID: selectedXcodeID) + InfoPane(selectedXcodeID: selectedXcodeID) .frame(minWidth: 300, maxWidth: .infinity) .frame(width: isShowingInfoPane ? nil : 0) .isHidden(!isShowingInfoPane) diff --git a/Xcodes/Frontend/XcodeList/AppStoreButtonStyle.swift b/Xcodes/Frontend/XcodeList/AppStoreButtonStyle.swift index c22f16d..3b7b7df 100644 --- a/Xcodes/Frontend/XcodeList/AppStoreButtonStyle.swift +++ b/Xcodes/Frontend/XcodeList/AppStoreButtonStyle.swift @@ -1,75 +1,137 @@ import SwiftUI struct AppStoreButtonStyle: ButtonStyle { - var installed: Bool + var primary: Bool var highlighted: Bool private struct AppStoreButton: View { + @SwiftUI.Environment(\.isEnabled) var isEnabled var configuration: ButtonStyle.Configuration - var installed: Bool + var primary: Bool var highlighted: Bool - // This seems to magically help the highlight colors update on time - @SwiftUI.Environment(\.isFocused) var isFocused var textColor: Color { - if installed { - if highlighted { - return Color.white + if isEnabled { + if primary { + if highlighted { + return Color.accentColor + } + else { + return Color.white + } } else { - return Color.secondary - } - } - else { - if highlighted { return Color.accentColor } + } else { + if primary { + if highlighted { + return Color(.disabledControlTextColor) + } + else { + return Color.white + } + } else { - return Color.white + if highlighted { + return Color.white + } + else { + return Color(.disabledControlTextColor) + } } } } func background(isPressed: Bool) -> some View { Group { - if installed { - EmptyView() + if isEnabled { + if primary { + Capsule() + .fill( + highlighted ? + Color.white : + Color.accentColor + ) + .brightness(isPressed ? -0.25 : 0) + } else { + Capsule() + .fill( + Color(NSColor(red: 0.95, green: 0.95, blue: 0.97, alpha: 1)) + ) + .brightness(isPressed ? -0.25 : 0) + } } else { - Capsule() - .fill( - highlighted ? - Color.white : - Color.accentColor - ) - .brightness(isPressed ? -0.25 : 0) + if primary { + Capsule() + .fill( + highlighted ? + Color.white : + Color(.disabledControlTextColor) + ) + .brightness(isPressed ? -0.25 : 0) + } else { + EmptyView() + } } } } var body: some View { configuration.label - .font(Font.caption.weight(.medium)) + .font(Font.caption.weight(.bold)) .foregroundColor(textColor) - .padding(EdgeInsets(top: 2, leading: 8, bottom: 2, trailing: 8)) - .frame(minWidth: 80) + .padding(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) + .frame(minWidth: 65) .background(background(isPressed: configuration.isPressed)) .padding(1) } } func makeBody(configuration: ButtonStyle.Configuration) -> some View { - AppStoreButton(configuration: configuration, installed: installed, highlighted: highlighted) + AppStoreButton(configuration: configuration, primary: primary, highlighted: highlighted) } } struct AppStoreButtonStyle_Previews: PreviewProvider { static var previews: some View { Group { - Button("INSTALLED", action: {}) - .buttonStyle(AppStoreButtonStyle(installed: true, highlighted: false)) - .padding() - Button("INSTALL", action: {}) - .buttonStyle(AppStoreButtonStyle(installed: false, highlighted: false)) - .padding() + ForEach([ColorScheme.light, .dark], id: \.self) { colorScheme in + Group { + Button("OPEN", action: {}) + .buttonStyle(AppStoreButtonStyle(primary: true, highlighted: false)) + .padding() + .background(Color(.textBackgroundColor)) + .previewDisplayName("Primary") + Button("OPEN", action: {}) + .buttonStyle(AppStoreButtonStyle(primary: true, highlighted: true)) + .padding() + .background(Color(.controlAccentColor)) + .previewDisplayName("Primary, Highlighted") + Button("OPEN", action: {}) + .buttonStyle(AppStoreButtonStyle(primary: true, highlighted: false)) + .padding() + .disabled(true) + .background(Color(.textBackgroundColor)) + .previewDisplayName("Primary, Disabled") + Button("INSTALL", action: {}) + .buttonStyle(AppStoreButtonStyle(primary: false, highlighted: false)) + .padding() + .background(Color(.textBackgroundColor)) + .previewDisplayName("Secondary") + Button("INSTALL", action: {}) + .buttonStyle(AppStoreButtonStyle(primary: false, highlighted: true)) + .padding() + .background(Color(.controlAccentColor)) + .previewDisplayName("Secondary, Highlighted") + Button("INSTALL", action: {}) + .buttonStyle(AppStoreButtonStyle(primary: false, highlighted: false)) + .padding() + .disabled(true) + .background(Color(.textBackgroundColor)) + .previewDisplayName("Secondary, Disabled") + } + .environment(\.colorScheme, colorScheme) + } } } } diff --git a/Xcodes/Frontend/XcodeList/InspectorPane.swift b/Xcodes/Frontend/XcodeList/InfoPane.swift similarity index 92% rename from Xcodes/Frontend/XcodeList/InspectorPane.swift rename to Xcodes/Frontend/XcodeList/InfoPane.swift index bc3de77..9395fab 100644 --- a/Xcodes/Frontend/XcodeList/InspectorPane.swift +++ b/Xcodes/Frontend/XcodeList/InfoPane.swift @@ -4,7 +4,7 @@ import Version import struct XCModel.SDKs import struct XCModel.Compilers -struct InspectorPane: View { +struct InfoPane: View { @EnvironmentObject var appState: AppState let selectedXcodeID: Xcode.ID? @SwiftUI.Environment(\.openURL) var openURL: OpenURLAction @@ -31,15 +31,12 @@ struct InspectorPane: View { } HStack { - if xcode.selected { - Button("Selected", action: {}) - .disabled(true) - .help("Selected") - } else { - SelectButton(xcode: xcode) - } + SelectButton(xcode: xcode) + .disabled(xcode.selected) + .help("Selected") OpenButton(xcode: xcode) + .help("Open") } } else { InstallButton(xcode: xcode) @@ -170,10 +167,10 @@ struct InspectorPane: View { } } -struct InspectorPane_Previews: PreviewProvider { +struct InfoPane_Previews: PreviewProvider { static var previews: some View { Group { - InspectorPane(selectedXcodeID: Version(major: 12, minor: 3, patch: 0)) + InfoPane(selectedXcodeID: Version(major: 12, minor: 3, patch: 0)) .environmentObject(configure(AppState()) { $0.allXcodes = [ .init( @@ -201,7 +198,7 @@ struct InspectorPane_Previews: PreviewProvider { }) .previewDisplayName("Populated, Installed, Selected") - InspectorPane(selectedXcodeID: Version(major: 12, minor: 3, patch: 0)) + InfoPane(selectedXcodeID: Version(major: 12, minor: 3, patch: 0)) .environmentObject(configure(AppState()) { $0.allXcodes = [ .init( @@ -227,7 +224,7 @@ struct InspectorPane_Previews: PreviewProvider { }) .previewDisplayName("Populated, Installed, Unselected") - InspectorPane(selectedXcodeID: Version(major: 12, minor: 3, patch: 0)) + InfoPane(selectedXcodeID: Version(major: 12, minor: 3, patch: 0)) .environmentObject(configure(AppState()) { $0.allXcodes = [ .init( @@ -253,7 +250,7 @@ struct InspectorPane_Previews: PreviewProvider { }) .previewDisplayName("Populated, Uninstalled") - InspectorPane(selectedXcodeID: nil) + InfoPane(selectedXcodeID: nil) .environmentObject(configure(AppState()) { $0.allXcodes = [ ] diff --git a/Xcodes/Frontend/XcodeList/MainToolbar.swift b/Xcodes/Frontend/XcodeList/MainToolbar.swift index 8c804b8..392ae32 100644 --- a/Xcodes/Frontend/XcodeList/MainToolbar.swift +++ b/Xcodes/Frontend/XcodeList/MainToolbar.swift @@ -40,10 +40,10 @@ struct MainToolbarModifier: ViewModifier { Button(action: { isShowingInfoPane.toggle() }) { if isShowingInfoPane { - Label("Inspector", systemImage: "info.circle.fill") + Label("Info", systemImage: "info.circle.fill") .foregroundColor(.accentColor) } else { - Label("Inspector", systemImage: "info.circle") + Label("Info", systemImage: "info.circle") } } .keyboardShortcut(KeyboardShortcut("i", modifiers: [.command, .option])) diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index 3503114..c3c564f 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -32,56 +32,7 @@ struct XcodeListView: View { var body: some View { List(visibleXcodes, selection: $selectedXcodeID) { xcode in - HStack { - appIconView(for: xcode) - - VStack(alignment: .leading) { - Text(xcode.description) - .font(.body) - - Text(verbatim: xcode.path ?? "") - .font(.caption) - .foregroundColor(selectedXcodeID == xcode.id ? Color(NSColor.selectedMenuItemTextColor) : Color(NSColor.secondaryLabelColor)) - } - - - Spacer() - - if xcode.selected { - Tag(text: "SELECTED") - .foregroundColor(.green) - } - - Button(xcode.installed ? "INSTALLED" : "INSTALL") { - print("Installing...") - } - .buttonStyle(AppStoreButtonStyle(installed: xcode.installed, - highlighted: selectedXcodeID == xcode.id)) - .disabled(xcode.installed) - } - .contextMenu { - InstallButton(xcode: xcode) - - Divider() - - if xcode.installed { - SelectButton(xcode: xcode) - OpenButton(xcode: xcode) - RevealButton(xcode: xcode) - CopyPathButton(xcode: xcode) - } - } - } - } - - @ViewBuilder - func appIconView(for xcode: Xcode) -> some View { - if let icon = xcode.icon { - Image(nsImage: icon) - } else { - Color.clear - .frame(width: 32, height: 32) - .foregroundColor(.secondary) + XcodeListViewRow(xcode: xcode, selected: selectedXcodeID == xcode.id) } } } diff --git a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift new file mode 100644 index 0000000..1b1b16c --- /dev/null +++ b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift @@ -0,0 +1,112 @@ +import SwiftUI +import Version + +struct XcodeListViewRow: View { + @EnvironmentObject var appState: AppState + let xcode: Xcode + let selected: Bool + + var body: some View { + HStack { + appIconView(for: xcode) + + VStack(alignment: .leading) { + Text(xcode.description) + .font(.body) + + Text(verbatim: xcode.path ?? "") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + selectControl(for: xcode) + .padding(.trailing, 16) + installControl(for: xcode) + } + .contextMenu { + InstallButton(xcode: xcode) + + Divider() + + if xcode.installed { + SelectButton(xcode: xcode) + OpenButton(xcode: xcode) + RevealButton(xcode: xcode) + CopyPathButton(xcode: xcode) + } + } + } + + @ViewBuilder + func appIconView(for xcode: Xcode) -> some View { + if let icon = xcode.icon { + Image(nsImage: icon) + } else { + Color.clear + .frame(width: 32, height: 32) + .foregroundColor(.secondary) + } + } + + @ViewBuilder + private func selectControl(for xcode: Xcode) -> some View { + if xcode.installed { + if xcode.selected { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + .help("This is the active version") + } else { + Button(action: { appState.select(id: xcode.id) }) { + Image(systemName: "checkmark.circle") + .foregroundColor(.secondary) + } + .buttonStyle(PlainButtonStyle()) + .help("Make this the active version") + } + } else { + EmptyView() + } + } + + @ViewBuilder + private func installControl(for xcode: Xcode) -> some View { + if xcode.installed { + Button("OPEN") { appState.open(id: xcode.id) } + .buttonStyle(AppStoreButtonStyle(primary: true, highlighted: selected)) + .help("Open this version") + } else { + Button("INSTALL") { print("Installing...") } + .buttonStyle(AppStoreButtonStyle(primary: false, highlighted: selected)) + .help("Install this version") + } + } +} + +struct XcodeListViewRow_Previews: PreviewProvider { + static var previews: some View { + Group { + XcodeListViewRow( + xcode: Xcode(version: Version("12.3.0")!, installState: .installed, selected: true, path: "/Applications/Xcode-12.3.0.app", icon: nil), + selected: false + ) + + XcodeListViewRow( + xcode: Xcode(version: Version("12.2.0")!, installState: .notInstalled, selected: false, path: nil, icon: nil), + selected: false + ) + + XcodeListViewRow( + xcode: Xcode(version: Version("12.1.0")!, installState: .notInstalled, selected: false, path: nil, icon: nil), + selected: false + ) + + XcodeListViewRow( + xcode: Xcode(version: Version("12.0.0")!, installState: .installed, selected: false, path: "/Applications/Xcode-12.3.0.app", icon: nil), + selected: false + ) + } + .environmentObject(AppState()) + } +}