Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 66 additions & 8 deletions Xcodes/Backend/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -811,18 +811,76 @@ class AppState: ObservableObject {
}

func open(xcode: Xcode, openInRosetta: Bool? = false) {
switch xcode.installState {
case let .installed(path):
let config = NSWorkspace.OpenConfiguration.init()
if (openInRosetta ?? false) {
config.architecture = CPU_TYPE_X86_64
}
guard case let .installed(path) = xcode.installState else {
Logger.appState.error("\(xcode.id.version) is not installed")
return
}

let bundleURL = path.url.standardizedFileURL

// Already running: bring it forward instead of spawning a second one.
if let running = runningApplication(atBundleURL: bundleURL) {
running.activate(from: .current, options: [.activateAllWindows])
return
}

// LaunchServices only blocks an Xcode older than the running macOS. Launch those
// directly to bypass the gate; newer or same-generation Xcodes open normally.
guard Self.requiresDirectLaunch(xcodeVersion: xcode.version, osMajorVersion: ProcessInfo.processInfo.operatingSystemVersion.majorVersion) else {
let config = NSWorkspace.OpenConfiguration()
if openInRosetta == true { config.architecture = CPU_TYPE_X86_64 }
config.allowsRunningApplicationSubstitution = false
NSWorkspace.shared.openApplication(at: path.url, configuration: config)
default:
Logger.appState.error("\(xcode.id.version) is not installed")
return
}

do {
try launchProcess(forAppAt: path.url, inRosetta: openInRosetta == true).run()
activateApplication(atBundleURL: bundleURL)
} catch {
Logger.appState.error("Failed to open \(xcode.id.version): \(error.localizedDescription)")
self.error = error
presentedAlert = .generic(title: localizeString("Open"), message: error.legibleLocalizedDescription)
}
}

/// Whether an Xcode must be launched directly to bypass the LaunchServices
/// compatibility gate, i.e. it predates the running macOS.
static func requiresDirectLaunch(xcodeVersion: Version, osMajorVersion: Int) -> Bool {
xcodeVersion.major < osMajorVersion
}

private func runningApplication(atBundleURL bundleURL: URL) -> NSRunningApplication? {
NSWorkspace.shared.runningApplications.first {
!$0.isTerminated && $0.bundleURL?.standardizedFileURL == bundleURL
}
}

private func launchProcess(forAppAt url: URL, inRosetta: Bool) -> Process {
let binary = Bundle(url: url)?.executableURL ?? url.appendingPathComponent("Contents/MacOS/Xcode")
let process = Process()
if inRosetta {
process.executableURL = URL(fileURLWithPath: "/usr/bin/arch")
process.arguments = ["-x86_64", binary.path]
} else {
process.executableURL = binary
}
process.standardInput = FileHandle.nullDevice
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
return process
}

// A freshly exec'd app isn't activatable right away; retry until it's frontmost.
private func activateApplication(atBundleURL bundleURL: URL) {
Task { @MainActor in
for _ in 0..<40 {
let app = runningApplication(atBundleURL: bundleURL)
if app?.isActive == true { return }
app?.activate(from: .current, options: [.activateAllWindows])
try? await Task.sleep(for: .milliseconds(200))
}
}
}

func copyPath(xcode: Xcode) {
Expand Down
8 changes: 8 additions & 0 deletions XcodesTests/AppStateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1021,6 +1021,14 @@ class AppStateTests: XCTestCase {
)
}

func test_RequiresDirectLaunch_OnlyForXcodeOlderThanRunningMacOS() {
// Older than the OS: blocked by LaunchServices, must launch directly.
XCTAssertTrue(AppState.requiresDirectLaunch(xcodeVersion: Version(major: 26, minor: 6, patch: 0), osMajorVersion: 27))
// Same generation or newer: opens normally via NSWorkspace.
XCTAssertFalse(AppState.requiresDirectLaunch(xcodeVersion: Version(major: 27, minor: 0, patch: 0), osMajorVersion: 27))
XCTAssertFalse(AppState.requiresDirectLaunch(xcodeVersion: Version(major: 27, minor: 0, patch: 0), osMajorVersion: 26))
}

private func recordAllXcodeInstallStates(during operation: () async throws -> Void) async throws -> [[XcodeInstallState]] {
var states: [[XcodeInstallState]] = []
var cancellable: AnyCancellable?
Expand Down