Files
2026-09-14 08:51:53 -05:00

253 lines
12 KiB
Swift

import SwiftUI
import XcodesKit
import XcodesLoginKit
import Path
import Version
struct MainWindow: View {
@EnvironmentObject var appState: AppState
@State private var selectedXcodeID: Xcode.ID?
@State private var searchText: String = ""
@AppStorage("lastUpdated") private var lastUpdated: Double?
// These two properties should be per-scene state managed by @SceneStorage property wrappers.
// There's currently a bug with @SceneStorage on macOS, though, where quitting the app will discard the values, which removes a lot of its utility.
// In the meantime, we're using @AppStorage so that persistence and state restoration works, even though it's not per-scene.
// FB8979533 SceneStorage doesn't restore value after app is quit by user
@AppStorage("isShowingInfoPane") private var isShowingInfoPane = false
@AppStorage("xcodeListCategory") private var category: XcodeListCategory = .all
@AppStorage("xcodeListArchitecture") private var architecture: XcodeListArchitecture = .defaultForCurrentMachine
@AppStorage("isInstalledOnly") private var isInstalledOnly = false
var body: some View {
NavigationSplitViewWrapper {
XcodeListView(selectedXcodeID: $selectedXcodeID, searchText: searchText, category: category, isInstalledOnly: isInstalledOnly, architecture: architecture)
.layoutPriority(1)
.alert(item: $appState.xcodeBeingConfirmedForUninstallation) { xcode in
Alert(title: Text(String(format: localizeString("Alert.Uninstall.Title"), xcode.description)),
message: Text("Alert.Uninstall.Message"),
primaryButton: .destructive(Text("Uninstall"), action: { self.appState.uninstall(xcode: xcode) }),
secondaryButton: .cancel(Text("Cancel")))
}
.searchable(text: $searchText, placement: .sidebar)
.mainToolbar(
category: $category,
isInstalledOnly: $isInstalledOnly,
isShowingInfoPane: $isShowingInfoPane,
architecture: $architecture
)
} detail: {
Group {
if let xcode = xcode {
InfoPane(xcode: xcode)
} else {
UnselectedView()
}
}
.toolbar {
ToolbarItemGroup {
Button(action: { appState.presentedSheet = .signIn }, label: {
Label {
if let authenticatedUserName {
Text(verbatim: authenticatedUserName)
} else {
Text("Login")
}
} icon: {
Image(systemName: "person.circle")
}
.labelStyle(.titleAndIcon)
})
.help("LoginDescription")
if #available(macOS 14, *) {
SettingsLink(label: {
Label("Preferences", systemImage: "gearshape")
})
.help("PreferencesDescription")
} else {
Button(action: {
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
}, label: {
Label("Preferences", systemImage: "gearshape")
})
.help("PreferencesDescription")
}
}
}
}
.bottomStatusBar()
.padding([.top], 0)
.navigationSubtitle(subtitleText)
.frame(minWidth: 600, maxWidth: .infinity, minHeight: 300, maxHeight: .infinity)
.emittingError($appState.error, recoveryHandler: { _ in })
.sheet(item: $appState.presentedSheet) { sheet in
switch sheet {
case .signIn:
signInView()
.environmentObject(appState)
case .twoFactor(let secondFactorData):
secondFactorView(secondFactorData)
.environmentObject(appState)
case .securityKeyTouchToConfirm:
SignInSecurityKeyTouchView(isPresented: $appState.presentedSheet.isNotNil)
.environmentObject(appState)
}
}
.alert(item: $appState.presentedAlert, content: { presentedAlert in
alert(for: presentedAlert)
})
// 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 xcode: Xcode? {
appState.allXcodes.first(where: { $0.id == selectedXcodeID })
}
private var authenticatedUserName: String? {
guard case let .authenticated(appleSession) = appState.authenticationState else {
return nil
}
return appleSession.user.fullName ?? appState.savedUsername
}
private var subtitleText: Text {
if let lastUpdated = lastUpdated.map(Date.init(timeIntervalSince1970:)) {
return Text("\(localizeString("UpdatedAt")) \(lastUpdated, style: .date) \(lastUpdated, style: .time)")
} else {
return Text("")
}
}
@ViewBuilder
private func secondFactorView(_ secondFactorData: XcodesSheet.SecondFactorData) -> some View {
switch secondFactorData.option {
case .codeSent:
SignIn2FAView(isPresented: $appState.presentedSheet.isNotNil, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData)
case .smsSent(let trustedPhoneNumber):
SignInSMSView(isPresented: $appState.presentedSheet.isNotNil, trustedPhoneNumber: trustedPhoneNumber, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData)
case .smsPendingChoice:
SignInPhoneListView(isPresented: $appState.presentedSheet.isNotNil, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData)
case .securityKey:
SignInSecurityKeyPinView(isPresented: $appState.presentedSheet.isNotNil, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData)
}
}
@ViewBuilder
private func signInView() -> some View {
if case .authenticated = appState.authenticationState {
VStack {
SignedInView()
.padding(32)
HStack {
Spacer()
Button("Close") { appState.presentedSheet = nil }
.keyboardShortcut(.cancelAction)
}
}
.padding()
} else if case let .waitingForFederatedAuthentication(federationResponse) = appState.authenticationState {
SignInFederatedView(federationResponse: federationResponse)
.environmentObject(appState)
.frame(width: 400)
} else {
SignInCredentialsView()
.frame(width: 400)
}
}
private func alert(for alertType: XcodesAlert) -> Alert {
switch alertType {
case let .cancelInstall(xcode):
return Alert(
title: Text(String(format: localizeString("Alert.CancelInstall.Title"), xcode.description)),
message: Text("Alert.CancelInstall.Message"),
primaryButton: .destructive(
Text("Alert.CancelInstall.PrimaryButton"),
action: {
self.appState.cancelInstall(id: xcode.id)
}
),
secondaryButton: .cancel(Text("Cancel"))
)
case .privilegedHelper:
return Alert(
title: Text("Alert.PrivilegedHelper.Title"),
message: Text("Alert.PrivilegedHelper.Message"),
primaryButton: .default(Text("Install"), action: {
appState.respondToPreparedHelperAction(userConsented: true)
}),
secondaryButton: .cancel {
appState.respondToPreparedHelperAction(userConsented: false)
}
)
case let .generic(title, message):
return Alert(
title: Text(title),
message: Text(message),
dismissButton: .default(
Text("OK"),
action: { appState.presentedAlert = nil }
)
)
case .unauthenticated:
return Alert(
title: Text("Alert.Install.Error.Title"),
message: Text("Alert.Install.AuthError.Message"),
primaryButton: .default(
Text("Login"),
action: {
appState.presentedSheet = .signIn
}
),
secondaryButton: .cancel(
Text("Cancel")
)
)
case let .checkMinSupportedVersion(xcode, deviceVersion):
return Alert(
title: Text("Alert.MinSupported.Title"),
message: Text(String(format: localizeString("Alert.MinSupported.Message"), xcode.xcodeID.version.descriptionWithoutBuildMetadata, xcode.requiredMacOSVersion ?? "", deviceVersion)),
primaryButton: .default(
Text("Install"),
action: {
self.appState.install(id: xcode.xcodeID)
}
),
secondaryButton: .cancel(Text("Cancel"))
)
case let .cancelRuntimeInstall(runtime):
return Alert(
title: Text(String(format: localizeString("Alert.CancelInstall.Runtimes.Title"), runtime.name)),
message: Text("Alert.CancelInstall.Message"),
primaryButton: .destructive(
Text("Alert.CancelInstall.PrimaryButton"),
action: {
self.appState.cancelRuntimeInstall(runtime: runtime)
}
),
secondaryButton: .cancel(Text("Cancel"))
)
}
}
}
struct MainWindow_Previews: PreviewProvider {
@MainActor
static var previews: some View {
MainWindow().environmentObject({ () -> AppState in
let a = AppState()
a.allXcodes = [
Xcode(version: Version("12.0.0+1234A")!, identicalBuilds: [XcodeID(version: Version("12.0.0+1234A")!), XcodeID(version: Version("12.0.0-RC+1234A")!)], installState: .installed(Path("/Applications/Xcode-12.3.0.app")!), selected: false, icon: nil),
Xcode(version: Version("12.3.0")!, installState: .installed(Path("/Applications/Xcode-12.3.0.app")!), selected: true, icon: nil),
Xcode(version: Version("12.2.0")!, installState: .notInstalled, selected: false, icon: nil),
Xcode(version: Version("12.1.0")!, installState: .installing(.downloading(progress: configure(Progress(totalUnitCount: 100)) { $0.completedUnitCount = 40 })), selected: false, icon: nil),
Xcode(version: Version("12.0.0")!, installState: .installed(Path("/Applications/Xcode-12.3.0.app")!), selected: false, icon: nil),
]
return a
}())
}
}