From fc0aed03edea9508719af3976475a352e9992e7a Mon Sep 17 00:00:00 2001 From: Savanne Kham Date: Wed, 16 Sep 2026 11:51:36 +0200 Subject: [PATCH] feat(open): launch Xcode directly, reusing a running instance NSWorkspace.openApplication routes through LaunchServices, which refuses to launch an Xcode built before the current macOS (e.g. Xcode 26 on macOS 27), showing an 'update to the latest version' dialog and making the Open button unusable for those versions. Launch the Xcode executable directly to bypass that compatibility gate. Before spawning, reuse an already-running instance of the same Xcode (matched by bundle URL) and activate it, so repeated clicks do not spawn duplicate processes. Rosetta launches are preserved via arch -x86_64. --- Xcodes/Backend/AppState.swift | 74 +++++++++++++++++++++++++++++---- XcodesTests/AppStateTests.swift | 8 ++++ 2 files changed, 74 insertions(+), 8 deletions(-) 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?