import AppKit import AsyncPassthroughSubject import AXExtension import Combine import CoreGraphics import Foundation import Logger import Perception public class XcodeWindowInspector { public let app: NSRunningApplication public let windowID: CGWindowID public var uiElement: AXUIElement { let windowID = self.windowID if _uiElement.parent == nil { let app = AXUIElementCreateApplication(app.processIdentifier) app.setMessagingTimeout(2) if let newWindowElement = app.windows.first(where: { $0.windowID == windowID }) { self._uiElement = newWindowElement newWindowElement.setMessagingTimeout(2) } } return _uiElement } var _uiElement: AXUIElement init( app: NSRunningApplication, uiElement: AXUIElement ) { self.app = app _uiElement = uiElement uiElement.setMessagingTimeout(2) windowID = uiElement.windowID ?? 0 } public var isInvalid: Bool { uiElement.parent == nil } } @XcodeInspectorActor @Perceptible public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector, Sendable { @MainActor public private(set) var documentURL: URL = .init(fileURLWithPath: "/") @MainActor public private(set) var workspaceURL: URL = .init(fileURLWithPath: "/") @MainActor public private(set) var projectRootURL: URL = .init(fileURLWithPath: "/") @PerceptionIgnored private var focusedElementChangedTask: Task? public func refresh() { Task { @MainActor in updateURLs() } } @MainActor public init( app: NSRunningApplication, uiElement: AXUIElement, axNotifications: AsyncPassthroughSubject ) { super.init(app: app, uiElement: uiElement) focusedElementChangedTask = Task { @MainActor [weak self, axNotifications] in self?.updateURLs() await withThrowingTaskGroup(of: Void.self) { [weak self] group in group.addTask { [weak self] in // prevent that documentURL may not be available yet try await Task.sleep(nanoseconds: 500_000_000) if await self?.documentURL == .init(fileURLWithPath: "/") { await self?.updateURLs() } } group.addTask { [weak self] in for await notification in await axNotifications.notifications() { guard notification.kind == .focusedUIElementChanged || notification.kind == .titleChanged else { continue } guard let self else { return } try Task.checkCancellation() await Task.yield() await self.updateURLs() } } } } } @MainActor func updateURLs() { let documentURL = Self.extractDocumentURL(windowElement: uiElement) if let documentURL { self.documentURL = documentURL } let workspaceURL = Self.extractWorkspaceURL(windowElement: uiElement) if let workspaceURL { self.workspaceURL = workspaceURL } let projectURL = Self.extractProjectURL( workspaceURL: workspaceURL, documentURL: documentURL ) if let projectURL { projectRootURL = projectURL } } nonisolated static func extractDocumentURL( windowElement: AXUIElement ) -> URL? { // fetch file path of the frontmost window of Xcode through Accessibility API. let path = windowElement.document if let path = path?.removingPercentEncoding { let url = URL( fileURLWithPath: path .replacingOccurrences(of: "file://", with: "") ) return adjustFileURL(url) } return nil } nonisolated static func extractWorkspaceURL( windowElement: AXUIElement ) -> URL? { for child in windowElement.children { if child.description.starts(with: "/"), child.description.count > 1 { let path = child.description let trimmedNewLine = path.trimmingCharacters(in: .newlines) let url = URL(fileURLWithPath: trimmedNewLine) return url } } return nil } public nonisolated static func extractProjectURL( workspaceURL: URL?, documentURL: URL? ) -> URL? { guard var currentURL = workspaceURL ?? documentURL else { return nil } var firstDirectoryURL: URL? var lastGitDirectoryURL: URL? while currentURL.pathComponents.count > 1 { defer { currentURL.deleteLastPathComponent() } guard FileManager.default.fileIsDirectory(atPath: currentURL.path) else { continue } guard currentURL.pathExtension != "xcodeproj" else { continue } guard currentURL.pathExtension != "xcworkspace" else { continue } guard currentURL.pathExtension != "playground" else { continue } if firstDirectoryURL == nil { firstDirectoryURL = currentURL } let gitURL = currentURL.appendingPathComponent(".git") if FileManager.default.fileIsDirectory(atPath: gitURL.path) { lastGitDirectoryURL = currentURL } else if let text = try? String(contentsOf: gitURL) { if !text.hasPrefix("gitdir: ../"), // it's not a sub module text.range(of: "/.git/worktrees/") != nil // it's a git worktree { lastGitDirectoryURL = currentURL } } } return lastGitDirectoryURL ?? firstDirectoryURL ?? workspaceURL } nonisolated static func adjustFileURL(_ url: URL) -> URL { if url.pathExtension == "playground", FileManager.default.fileIsDirectory(atPath: url.path) { return url.appendingPathComponent("Contents.swift") } return url } }