mirror of
https://github.com/XcodesOrg/XcodesApp.git
synced 2026-02-06 20:28:39 +01:00
236 lines
12 KiB
Swift
236 lines
12 KiB
Swift
import ErrorHandling
|
|
import SwiftUI
|
|
import XcodesKit
|
|
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("isInstalledOnly") private var isInstalledOnly = false
|
|
|
|
var body: some View {
|
|
NavigationSplitViewWrapper {
|
|
XcodeListView(selectedXcodeID: $selectedXcodeID, searchText: searchText, category: category, isInstalledOnly: isInstalledOnly)
|
|
.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
|
|
)
|
|
} detail: {
|
|
Group {
|
|
if let xcode = xcode {
|
|
InfoPane(xcode: xcode)
|
|
} else {
|
|
UnselectedView()
|
|
}
|
|
}
|
|
.toolbar {
|
|
ToolbarItemGroup {
|
|
Button(action: { appState.presentedSheet = .signIn }, label: {
|
|
Label("Login", systemImage: "person.circle")
|
|
})
|
|
.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 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 appState.authenticationState == .authenticated {
|
|
VStack {
|
|
SignedInView()
|
|
.padding(32)
|
|
HStack {
|
|
Spacer()
|
|
Button("Close") { appState.presentedSheet = nil }
|
|
.keyboardShortcut(.cancelAction)
|
|
}
|
|
}
|
|
.padding()
|
|
} 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: {
|
|
// The isPreparingUserForActionRequiringHelper closure is set to nil by the alert's binding when its dismissed.
|
|
// We need to capture it to be invoked after that happens.
|
|
let helperAction = appState.isPreparingUserForActionRequiringHelper
|
|
DispatchQueue.main.async {
|
|
// This really shouldn't be nil, but sometimes this alert is being shown twice and I don't know why.
|
|
// There are some DispatchQueue.main.async's scattered around which make this better but in some situations it's still happening.
|
|
// When that happens, the second time the user clicks an alert button isPreparingUserForActionRequiringHelper will be nil.
|
|
// To at least not crash, we're using ?
|
|
helperAction?(true)
|
|
appState.presentedAlert = nil
|
|
}
|
|
}),
|
|
secondaryButton: .cancel {
|
|
// The isPreparingUserForActionRequiringHelper closure is set to nil by the alert's binding when its dismissed.
|
|
// We need to capture it to be invoked after that happens.
|
|
let helperAction = appState.isPreparingUserForActionRequiringHelper
|
|
DispatchQueue.main.async {
|
|
// This really shouldn't be nil, but sometimes this alert is being shown twice and I don't know why.
|
|
// There are some DispatchQueue.main.async's scattered around which make this better but in some situations it's still happening.
|
|
// When that happens, the second time the user clicks an alert button isPreparingUserForActionRequiringHelper will be nil.
|
|
// To at least not crash, we're using ?
|
|
helperAction?(false)
|
|
appState.presentedAlert = nil
|
|
}
|
|
}
|
|
)
|
|
case let .generic(title, message):
|
|
return Alert(
|
|
title: Text(title),
|
|
message: Text(message),
|
|
dismissButton: .default(
|
|
Text("OK"),
|
|
action: { appState.presentedAlert = nil }
|
|
)
|
|
)
|
|
case let .checkMinSupportedVersion(xcode, deviceVersion):
|
|
return Alert(
|
|
title: Text("Alert.MinSupported.Title"),
|
|
message: Text(String(format: localizeString("Alert.MinSupported.Message"), xcode.version.descriptionWithoutBuildMetadata, xcode.requiredMacOSVersion ?? "", deviceVersion)),
|
|
primaryButton: .default(
|
|
Text("Install"),
|
|
action: {
|
|
self.appState.install(id: xcode.version)
|
|
}
|
|
),
|
|
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 {
|
|
static var previews: some View {
|
|
MainWindow().environmentObject({ () -> AppState in
|
|
let a = AppState()
|
|
a.allXcodes = [
|
|
Xcode(version: Version("12.0.0+1234A")!, identicalBuilds: [Version("12.0.0+1234A")!, 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
|
|
}())
|
|
}
|
|
}
|