diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 6ac509bb..3cc1085e 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -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) { diff --git a/XcodesTests/AppStateTests.swift b/XcodesTests/AppStateTests.swift index 2945b06c..42926819 100644 --- a/XcodesTests/AppStateTests.swift +++ b/XcodesTests/AppStateTests.swift @@ -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?