diff --git a/ChatPlugins/Sources/TerminalChatPlugin/TerminalChatPlugin.swift b/ChatPlugins/Sources/TerminalChatPlugin/TerminalChatPlugin.swift index bb3d5028..aca1c01c 100644 --- a/ChatPlugins/Sources/TerminalChatPlugin/TerminalChatPlugin.swift +++ b/ChatPlugins/Sources/TerminalChatPlugin/TerminalChatPlugin.swift @@ -59,8 +59,8 @@ public final class TerminalChatPlugin: ChatPlugin { } do { - let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL - let projectURL = await XcodeInspector.shared.safe.realtimeActiveProjectURL + let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL + let projectURL = XcodeInspector.shared.realtimeActiveProjectURL var environment = [String: String]() if let fileURL { diff --git a/Core/Package.swift b/Core/Package.swift index f94f431a..fa46fdd0 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -335,6 +335,14 @@ extension [Target.Dependency] { } return self } + + func proCore(_ targetNames: [String]) -> [Target.Dependency] { + if isProIncluded { + return self + targetNames + .map { Target.Dependency.product(name: $0, package: "ProCore") } + } + return self + } } extension [Package.Dependency] { diff --git a/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift index f99d0890..ce74605f 100644 --- a/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift +++ b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift @@ -277,30 +277,47 @@ extension TabToAcceptSuggestion { } } -import Combine - private class ThreadSafeAccessToXcodeInspector { static let shared = ThreadSafeAccessToXcodeInspector() private(set) var activeDocumentURL: URL? private(set) var activeXcode: AppInstanceInspector? private(set) var focusedEditor: SourceEditor? - private var cancellable: Set = [] init() { - let inspector = XcodeInspector.shared + Task { [weak self] in + for await _ in NotificationCenter.default + .notifications(named: .activeDocumentURLDidChange) + { + guard let self else { return } + self.activeDocumentURL = await XcodeInspector.shared.activeDocumentURL + } + } - inspector.$activeDocumentURL.receive(on: DispatchQueue.main).sink { [weak self] newValue in - self?.activeDocumentURL = newValue - }.store(in: &cancellable) + Task { [weak self] in + for await _ in NotificationCenter.default + .notifications(named: .activeXcodeDidChange) + { + guard let self else { return } + self.activeXcode = await XcodeInspector.shared.activeXcode + } + } - inspector.$activeXcode.receive(on: DispatchQueue.main).sink { [weak self] newValue in - self?.activeXcode = newValue - }.store(in: &cancellable) + Task { [weak self] in + for await _ in NotificationCenter.default + .notifications(named: .focusedEditorDidChange) + { + guard let self else { return } + self.focusedEditor = await XcodeInspector.shared.focusedEditor + } + } - inspector.$focusedEditor.receive(on: DispatchQueue.main).sink { [weak self] newValue in - self?.focusedEditor = newValue - }.store(in: &cancellable) + // Initialize current values + Task { + activeDocumentURL = await XcodeInspector.shared.activeDocumentURL + activeXcode = await XcodeInspector.shared.activeApplication + focusedEditor = await XcodeInspector.shared.focusedEditor + } } } diff --git a/Core/Sources/LegacyChatPlugin/TerminalChatPlugin.swift b/Core/Sources/LegacyChatPlugin/TerminalChatPlugin.swift index 19a5d284..3ac8bd74 100644 --- a/Core/Sources/LegacyChatPlugin/TerminalChatPlugin.swift +++ b/Core/Sources/LegacyChatPlugin/TerminalChatPlugin.swift @@ -34,8 +34,8 @@ public actor TerminalChatPlugin: LegacyChatPlugin { } do { - let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL - let projectURL = await XcodeInspector.shared.safe.realtimeActiveProjectURL + let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL + let projectURL = XcodeInspector.shared.realtimeActiveProjectURL var environment = [String: String]() if let fileURL { diff --git a/Core/Sources/Service/GlobalShortcutManager.swift b/Core/Sources/Service/GlobalShortcutManager.swift index cc799fc0..a3bb32b4 100644 --- a/Core/Sources/Service/GlobalShortcutManager.swift +++ b/Core/Sources/Service/GlobalShortcutManager.swift @@ -11,7 +11,7 @@ extension KeyboardShortcuts.Name { @MainActor final class GlobalShortcutManager { let guiController: GraphicalUserInterfaceController - private var cancellable = Set() + private var activeAppChangeTask: Task? nonisolated init(guiController: GraphicalUserInterfaceController) { self.guiController = guiController @@ -34,22 +34,30 @@ final class GlobalShortcutManager { } } - XcodeInspector.shared.$activeApplication.sink { app in - if !UserDefaults.shared.value(for: \.showHideWidgetShortcutGlobally) { - let shouldBeEnabled = if let app, app.isXcode || app.isExtensionService { - true + activeAppChangeTask?.cancel() + activeAppChangeTask = Task.detached { [weak self] in + let notifications = NotificationCenter.default + .notifications(named: .activeApplicationDidChange) + for await _ in notifications { + guard let self else { return } + try Task.checkCancellation() + if !UserDefaults.shared.value(for: \.showHideWidgetShortcutGlobally) { + let app = await XcodeInspector.shared.activeApplication + let shouldBeEnabled = if let app, app.isXcode || app.isExtensionService { + true + } else { + false + } + if shouldBeEnabled { + await self.setupShortcutIfNeeded() + } else { + await self.removeShortcutIfNeeded() + } } else { - false + await self.setupShortcutIfNeeded() } - if shouldBeEnabled { - self.setupShortcutIfNeeded() - } else { - self.removeShortcutIfNeeded() - } - } else { - self.setupShortcutIfNeeded() } - }.store(in: &cancellable) + } } func setupShortcutIfNeeded() { diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift index 2d00c960..09661c0d 100644 --- a/Core/Sources/Service/RealtimeSuggestionController.swift +++ b/Core/Sources/Service/RealtimeSuggestionController.swift @@ -2,7 +2,6 @@ import ActiveApplicationMonitor import AppKit import AsyncAlgorithms import AXExtension -import Combine import Foundation import Logger import Preferences @@ -11,7 +10,7 @@ import Workspace import XcodeInspector public actor RealtimeSuggestionController { - private var cancellable: Set = [] + private var xcodeChangeObservationTask: Task? private var inflightPrefetchTask: Task? private var editorObservationTask: Task? private var sourceEditor: SourceEditor? @@ -19,7 +18,6 @@ public actor RealtimeSuggestionController { init() {} deinit { - cancellable.forEach { $0.cancel() } inflightPrefetchTask?.cancel() editorObservationTask?.cancel() } @@ -30,16 +28,18 @@ public actor RealtimeSuggestionController { } private func observeXcodeChange() { - cancellable.forEach { $0.cancel() } + xcodeChangeObservationTask?.cancel() - XcodeInspector.shared.$focusedEditor - .sink { [weak self] editor in + xcodeChangeObservationTask = Task { [weak self] in + for await _ in NotificationCenter.default + .notifications(named: .focusedEditorDidChange) + { guard let self else { return } - Task { - guard let editor else { return } - await self.handleFocusElementChange(editor) - } - }.store(in: &cancellable) + try Task.checkCancellation() + guard let editor = await XcodeInspector.shared.focusedEditor else { continue } + await self.handleFocusElementChange(editor) + } + } } private func handleFocusElementChange(_ sourceEditor: SourceEditor) { @@ -51,7 +51,7 @@ public actor RealtimeSuggestionController { editorObservationTask = nil editorObservationTask = Task { [weak self] in - if let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL { + if let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL { await PseudoCommandHandler().invalidateRealtimeSuggestionsIfNeeded( fileURL: fileURL, sourceEditor: sourceEditor @@ -86,7 +86,7 @@ public actor RealtimeSuggestionController { } group.addTask { let handler = { - guard let fileURL = await XcodeInspector.shared.safe.activeDocumentURL + guard let fileURL = await XcodeInspector.shared.activeDocumentURL else { return } await PseudoCommandHandler().invalidateRealtimeSuggestionsIfNeeded( fileURL: fileURL, @@ -113,7 +113,7 @@ public actor RealtimeSuggestionController { Task { @WorkspaceActor in // Get cache ready for real-time suggestions. guard UserDefaults.shared.value(for: \.preCacheOnFileOpen) else { return } - guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return } let (_, filespace) = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) @@ -123,7 +123,7 @@ public actor RealtimeSuggestionController { // avoid the command get called twice filespace.codeMetadata.uti = "" do { - try await XcodeInspector.shared.safe.latestActiveXcode? + try await XcodeInspector.shared.latestActiveXcode? .triggerCopilotCommand(name: "Prepare for Real-time Suggestions") } catch { if filespace.codeMetadata.uti?.isEmpty ?? true { @@ -147,7 +147,7 @@ public actor RealtimeSuggestionController { else { return } if UserDefaults.shared.value(for: \.disableSuggestionFeatureGlobally), - let fileURL = await XcodeInspector.shared.safe.activeDocumentURL, + let fileURL = await XcodeInspector.shared.activeDocumentURL, let (workspace, _) = try? await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) { @@ -184,7 +184,7 @@ public actor RealtimeSuggestionController { } func notifyEditingFileChange(editor: AXUIElement) async { - guard let fileURL = await XcodeInspector.shared.safe.activeDocumentURL, + guard let fileURL = await XcodeInspector.shared.activeDocumentURL, let (workspace, _) = try? await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) else { return } diff --git a/Core/Sources/Service/ScheduledCleaner.swift b/Core/Sources/Service/ScheduledCleaner.swift index 0475baf9..b011bd78 100644 --- a/Core/Sources/Service/ScheduledCleaner.swift +++ b/Core/Sources/Service/ScheduledCleaner.swift @@ -34,7 +34,7 @@ public final class ScheduledCleaner { func cleanUp() async { guard let service else { return } - let workspaceInfos = XcodeInspector.shared.xcodes.reduce( + let workspaceInfos = await XcodeInspector.shared.xcodes.reduce( into: [ XcodeAppInstanceInspector.WorkspaceIdentifier: XcodeAppInstanceInspector.WorkspaceInfo diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift index 876b2110..1f5da7fc 100644 --- a/Core/Sources/Service/Service.swift +++ b/Core/Sources/Service/Service.swift @@ -98,18 +98,20 @@ public final class Service { globalShortcutManager.start() keyBindingManager.start() - Task { - await XcodeInspector.shared.safe.$activeDocumentURL - .removeDuplicates() - .filter { $0 != .init(fileURLWithPath: "/") } - .compactMap { $0 } - .sink { fileURL in - Task { - @Dependency(\.workspacePool) var workspacePool - return try await workspacePool - .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) - } - }.store(in: &cancellable) + Task.detached { [weak self] in + let notifications = NotificationCenter.default + .notifications(named: .activeDocumentURLDidChange) + var previousURL: URL? + for await _ in notifications { + guard self != nil else { return } + let url = await XcodeInspector.shared.activeDocumentURL + if let url, url != previousURL, url != .init(fileURLWithPath: "/") { + previousURL = url + @Dependency(\.workspacePool) var workspacePool + _ = try await workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: url) + } + } } } @@ -158,7 +160,7 @@ public extension Service { } } } - + try ExtensionServiceRequests.GetSuggestionLineAcceptedCode.handle( endpoint: endpoint, requestBody: requestBody, diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index f79d7167..d7f83696 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -187,7 +187,7 @@ struct PseudoCommandHandler: CommandHandler { } }() else { do { - try await XcodeInspector.shared.safe.latestActiveXcode? + try await XcodeInspector.shared.latestActiveXcode? .triggerCopilotCommand(name: command.name) } catch { let presenter = PresentInWindowSuggestionPresenter() @@ -211,11 +211,11 @@ struct PseudoCommandHandler: CommandHandler { throw CancellationError() } do { - try await XcodeInspector.shared.safe.latestActiveXcode? + try await XcodeInspector.shared.latestActiveXcode? .triggerCopilotCommand(name: "Accept Modification") } catch { do { - try await XcodeInspector.shared.safe.latestActiveXcode? + try await XcodeInspector.shared.latestActiveXcode? .triggerCopilotCommand(name: "Accept Prompt to Code") } catch { let last = Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI @@ -288,7 +288,7 @@ struct PseudoCommandHandler: CommandHandler { throw CancellationError() } do { - try await XcodeInspector.shared.safe.latestActiveXcode? + try await XcodeInspector.shared.latestActiveXcode? .triggerCopilotCommand(name: "Accept Suggestion Line") } catch { let last = Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI @@ -350,7 +350,7 @@ struct PseudoCommandHandler: CommandHandler { throw CancellationError() } do { - try await XcodeInspector.shared.safe.latestActiveXcode? + try await XcodeInspector.shared.latestActiveXcode? .triggerCopilotCommand(name: "Accept Suggestion") } catch { let last = Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI @@ -407,7 +407,7 @@ struct PseudoCommandHandler: CommandHandler { } func dismissSuggestion() async { - guard let documentURL = await XcodeInspector.shared.safe.activeDocumentURL else { return } + guard let documentURL = await XcodeInspector.shared.activeDocumentURL else { return } PresentInWindowSuggestionPresenter().discardSuggestion(fileURL: documentURL) guard let (_, filespace) = try? await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: documentURL) else { return } @@ -670,7 +670,7 @@ extension PseudoCommandHandler { } func getFileURL() async -> URL? { - await XcodeInspector.shared.safe.realtimeActiveDocumentURL + XcodeInspector.shared.realtimeActiveDocumentURL } @WorkspaceActor @@ -688,7 +688,7 @@ extension PseudoCommandHandler { guard let filespace = await getFilespace(), let sourceEditor = await { if let sourceEditor { sourceEditor } - else { await XcodeInspector.shared.safe.focusedEditor } + else { await XcodeInspector.shared.focusedEditor } }() else { return nil } if Task.isCancelled { return nil } @@ -713,7 +713,7 @@ extension PseudoCommandHandler { } func handleAcceptSuggestionLineCommand(editor: EditorContent) async throws -> CodeSuggestion? { - guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + guard let _ = XcodeInspector.shared.realtimeActiveDocumentURL else { return nil } return try await acceptSuggestionLineInGroup( @@ -726,7 +726,7 @@ extension PseudoCommandHandler { atIndex index: Int?, editor: EditorContent ) async throws -> CodeSuggestion? { - guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return nil } let (workspace, _) = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) diff --git a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift index 4df98546..53b0c833 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift @@ -42,7 +42,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { defer { presenter.markAsProcessing(false) } - guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return } let (workspace, filespace) = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) @@ -76,7 +76,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { @WorkspaceActor private func _presentNextSuggestion(editor: EditorContent) async throws { - guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return } let (workspace, filespace) = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) @@ -102,7 +102,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { @WorkspaceActor private func _presentPreviousSuggestion(editor: EditorContent) async throws { - guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return } let (workspace, filespace) = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) @@ -128,7 +128,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { @WorkspaceActor private func _rejectSuggestion(editor: EditorContent) async throws { - guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return } let (workspace, _) = try await Service.shared.workspacePool @@ -139,7 +139,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { @WorkspaceActor func acceptSuggestion(editor: EditorContent) async throws -> UpdatedContent? { - guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return nil } let (workspace, _) = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) @@ -199,7 +199,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { } func acceptPromptToCode(editor: EditorContent) async throws -> UpdatedContent? { - guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return nil } let injector = SuggestionInjector() @@ -286,7 +286,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { @WorkspaceActor func prepareCache(editor: EditorContent) async throws -> UpdatedContent? { - guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return nil } let (_, filespace) = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) @@ -381,7 +381,7 @@ extension WindowBaseCommandHandler { generateDescription: Bool?, name: String? ) async throws { - guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return } let (workspace, _) = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/Widget.swift b/Core/Sources/SuggestionWidget/FeatureReducers/Widget.swift index b8c20fa7..493628fc 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/Widget.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/Widget.swift @@ -232,12 +232,18 @@ public struct Widget { case .observeActiveApplicationChange: return .run { send in let stream = AsyncStream { continuation in - let cancellable = xcodeInspector.$activeApplication.sink { newValue in - guard let newValue else { return } - continuation.yield(newValue) + let task = Task { + let notifications = NotificationCenter.default + .notifications(named: .activeApplicationDidChange) + for await _ in notifications { + try Task.checkCancellation() + if let app = await XcodeInspector.shared.activeApplication { + continuation.yield(app) + } + } } continuation.onTermination = { _ in - cancellable.cancel() + task.cancel() } } @@ -305,8 +311,7 @@ public struct Widget { case .updateFocusingDocumentURL: return .run { send in await send(.setFocusingDocumentURL( - to: await xcodeInspector.safe - .realtimeActiveDocumentURL + to: xcodeInspector.realtimeActiveDocumentURL )) } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetPanel.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetPanel.swift index 96414ef2..7d911f75 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetPanel.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetPanel.swift @@ -55,7 +55,7 @@ public struct WidgetPanel { switch action { case .presentSuggestion: return .run { send in - guard let fileURL = await xcodeInspector.safe.activeDocumentURL, + guard let fileURL = await xcodeInspector.activeDocumentURL, let provider = await fetchSuggestionProvider(fileURL: fileURL) else { return } await send(.presentSuggestionProvider(provider, displayContent: true)) @@ -101,7 +101,7 @@ public struct WidgetPanel { case .switchToAnotherEditorAndUpdateContent: return .run { send in - guard let fileURL = await xcodeInspector.safe.realtimeActiveDocumentURL + guard let fileURL = xcodeInspector.realtimeActiveDocumentURL else { return } await send(.sharedPanel( diff --git a/Core/Sources/SuggestionWidget/TextCursorTracker.swift b/Core/Sources/SuggestionWidget/TextCursorTracker.swift index f73511a7..6de2dc29 100644 --- a/Core/Sources/SuggestionWidget/TextCursorTracker.swift +++ b/Core/Sources/SuggestionWidget/TextCursorTracker.swift @@ -1,9 +1,8 @@ -import Combine import Foundation import Perception import SuggestionBasic -import XcodeInspector import SwiftUI +import XcodeInspector /// A passive tracker that observe the changes of the source editor content. @Perceptible @@ -29,8 +28,7 @@ final class TextCursorTracker { lineAnnotations: [] ) - @PerceptionIgnored var editorObservationTask: Set = [] - @PerceptionIgnored var eventObservationTask: Task? + @PerceptionIgnored var eventObservationTask: Task? init() { observeAppChange() @@ -39,37 +37,38 @@ final class TextCursorTracker { deinit { eventObservationTask?.cancel() } - + var isPreview: Bool { ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" } private func observeAppChange() { if isPreview { return } - editorObservationTask = [] - Task { - await XcodeInspector.shared.safe.$focusedEditor.sink { [weak self] editor in - guard let editor, let self else { return } - Task { @MainActor in - self.observeAXNotifications(editor) - } - }.store(in: &editorObservationTask) + Task { [weak self] in + let notifications = NotificationCenter.default + .notifications(named: .focusedEditorDidChange) + for await _ in notifications { + guard let self else { return } + guard let editor = await XcodeInspector.shared.focusedEditor else { continue } + await self.observeAXNotifications(editor) + } } } - private func observeAXNotifications(_ editor: SourceEditor) { + private func observeAXNotifications(_ editor: SourceEditor) async { if isPreview { return } eventObservationTask?.cancel() let content = editor.getLatestEvaluatedContent() - Task { @MainActor in + await MainActor.run { self.content = content } eventObservationTask = Task { [weak self] in for await event in await editor.axNotifications.notifications() { + try Task.checkCancellation() guard let self else { return } guard event.kind == .evaluatedContentChanged else { continue } let content = editor.getLatestEvaluatedContent() - Task { @MainActor in + await MainActor.run { self.content = content } } @@ -87,3 +86,4 @@ extension EnvironmentValues { set { self[TextCursorTrackerEnvironmentKey.self] = newValue } } } + diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index 64386a82..b9999144 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -1,11 +1,11 @@ import AppKit import AsyncAlgorithms import ChatTab -import Combine import ComposableArchitecture import Dependencies import Foundation import SharedUIComponents +import SwiftNavigation import SwiftUI import XcodeInspector @@ -23,7 +23,6 @@ actor WidgetWindowsController: NSObject { var currentApplicationProcessIdentifier: pid_t? - var cancellable: Set = [] var observeToAppTask: Task? var observeToFocusedEditorTask: Task? @@ -56,23 +55,30 @@ actor WidgetWindowsController: NSObject { } func start() { - cancellable.removeAll() - - xcodeInspector.$activeApplication.sink { [weak self] app in - guard let app else { return } - Task { [weak self] in await self?.activate(app) } - }.store(in: &cancellable) + Task { [xcodeInspector] in + await observe { [weak self] in + if let app = xcodeInspector.activeApplication { + Task { + await self?.activate(app) + } + } + } - xcodeInspector.$focusedEditor.sink { [weak self] editor in - guard let editor else { return } - Task { [weak self] in await self?.observe(toEditor: editor) } - }.store(in: &cancellable) + await observe { [weak self] in + if let editor = xcodeInspector.focusedEditor { + Task { + await self?.observe(toEditor: editor) + } + } + } - xcodeInspector.$completionPanel.sink { [weak self] newValue in - Task { [weak self] in - await self?.handleCompletionPanelChange(isDisplaying: newValue != nil) + await observe { [weak self] in + let isDisplaying = xcodeInspector.completionPanel != nil + Task { + await self?.handleCompletionPanelChange(isDisplaying: isDisplaying) + } } - }.store(in: &cancellable) + } userDefaultsObservers.presentationModeChangeObserver.onChange = { [weak self] in Task { [weak self] in @@ -135,7 +141,7 @@ private extension WidgetWindowsController { /// Hide the widgets before switching to another window/editor /// so the transition looks better. func hideWidgetForTransitions() async { - let newDocumentURL = await xcodeInspector.safe.realtimeActiveDocumentURL + let newDocumentURL = xcodeInspector.realtimeActiveDocumentURL let documentURL = await MainActor .run { store.withState { $0.focusingDocumentURL } } if documentURL != newDocumentURL { @@ -209,7 +215,7 @@ private extension WidgetWindowsController { selectionRangeChange.debounce(for: Duration.milliseconds(500)), scroll ) { - guard await xcodeInspector.safe.latestActiveXcode != nil else { return } + guard await xcodeInspector.latestActiveXcode != nil else { return } try Task.checkCancellation() // for better looking @@ -222,7 +228,7 @@ private extension WidgetWindowsController { } } else { for await notification in merge(selectionRangeChange, scroll) { - guard await xcodeInspector.safe.latestActiveXcode != nil else { return } + guard await xcodeInspector.latestActiveXcode != nil else { return } try Task.checkCancellation() // for better looking @@ -268,9 +274,9 @@ extension WidgetWindowsController { windows.suggestionPanelWindow.alphaValue = 0 } - func generateWidgetLocation() -> WidgetLocation? { - if let application = xcodeInspector.latestActiveXcode?.appElement { - if let focusElement = xcodeInspector.focusedEditor?.element, + func generateWidgetLocation() async -> WidgetLocation? { + if let application = await xcodeInspector.latestActiveXcode?.appElement { + if let focusElement = await xcodeInspector.focusedEditor?.element, let parent = focusElement.parent, let frame = parent.rect, let screen = NSScreen.screens.first(where: { $0.frame.origin == .zero }), @@ -297,7 +303,7 @@ extension WidgetWindowsController { editorFrame: frame, mainScreen: screen, activeScreen: windowContainingScreen, editor: focusElement, - completionPanel: xcodeInspector.completionPanel + completionPanel: await xcodeInspector.completionPanel ) default: break @@ -318,7 +324,7 @@ extension WidgetWindowsController { editorFrame: frame, mainScreen: screen, activeScreen: windowContainingScreen, editor: focusElement, - completionPanel: xcodeInspector.completionPanel + completionPanel: await xcodeInspector.completionPanel ) default: break @@ -392,9 +398,9 @@ extension WidgetWindowsController { } try Task.checkCancellation() let xcodeInspector = self.xcodeInspector - let activeApp = await xcodeInspector.safe.activeApplication - let latestActiveXcode = await xcodeInspector.safe.latestActiveXcode - let previousActiveApplication = xcodeInspector.previousActiveApplication + let activeApp = await xcodeInspector.activeApplication + let latestActiveXcode = await xcodeInspector.latestActiveXcode + let previousActiveApplication = await xcodeInspector.previousActiveApplication await MainActor.run { if let activeApp, activeApp.isXcode { let application = activeApp.appElement @@ -526,7 +532,7 @@ extension WidgetWindowsController { func adjustModificationPanelLevel() async { let window = windows.sharedPanelWindow - let latestApp = await xcodeInspector.safe.activeApplication + let latestApp = await xcodeInspector.activeApplication let latestAppIsXcodeOrExtension = if let latestApp { latestApp.isXcode || latestApp.isExtensionService } else { @@ -556,7 +562,7 @@ extension WidgetWindowsController { let floatOnTopWhenOverlapsXcode = UserDefaults.shared .value(for: \.keepFloatOnTopIfChatPanelAndXcodeOverlaps) - let latestApp = await xcodeInspector.safe.activeApplication + let latestApp = await xcodeInspector.activeApplication let latestAppIsXcodeOrExtension = if let latestApp { latestApp.isXcode || latestApp.isExtensionService } else { @@ -564,7 +570,7 @@ extension WidgetWindowsController { } async let overlap: Bool = { @MainActor in - guard let xcode = await xcodeInspector.safe.latestActiveXcode else { return false } + guard let xcode = await xcodeInspector.latestActiveXcode else { return false } let windowElements = xcode.appElement.windows let overlap = windowElements.contains { if let position = $0.position, let size = $0.size { @@ -611,7 +617,7 @@ extension WidgetWindowsController { @MainActor func handleSpaceChange() async { - let activeXcode = await XcodeInspector.shared.safe.activeXcode + let activeXcode = XcodeInspector.shared.activeXcode let xcode = activeXcode?.appElement diff --git a/Core/Sources/XcodeThemeController/XcodeThemeController.swift b/Core/Sources/XcodeThemeController/XcodeThemeController.swift index 5cff0ddd..01118547 100644 --- a/Core/Sources/XcodeThemeController/XcodeThemeController.swift +++ b/Core/Sources/XcodeThemeController/XcodeThemeController.swift @@ -157,8 +157,9 @@ extension XcodeThemeController { } let xcodeURL: URL? = { - // Use the latest running Xcode - if let running = XcodeInspector.shared.latestActiveXcode?.bundleURL { + if let running = NSWorkspace.shared + .urlForApplication(withBundleIdentifier: "com.apple.dt.Xcode") + { return running } // Use the main Xcode.app diff --git a/ExtensionService/AppDelegate+Menu.swift b/ExtensionService/AppDelegate+Menu.swift index cc665bb9..5bec033c 100644 --- a/ExtensionService/AppDelegate+Menu.swift +++ b/ExtensionService/AppDelegate+Menu.swift @@ -243,7 +243,9 @@ private extension AppDelegate { } @objc func reactivateObservationsToXcode() { - XcodeInspector.shared.reactivateObservationsToXcode() + Task { + await XcodeInspector.shared.reactivateObservationsToXcode() + } } @objc func openExtensionManager() { diff --git a/Tool/Package.swift b/Tool/Package.swift index 30301347..69e17995 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -243,7 +243,10 @@ let package = Package( ] ), - .target(name: "AXExtension"), + .target( + name: "AXExtension", + dependencies: ["Logger"] + ), .target( name: "AXNotificationStream", diff --git a/Tool/Sources/AXExtension/AXUIElement.swift b/Tool/Sources/AXExtension/AXUIElement.swift index ba09b05d..772a75af 100644 --- a/Tool/Sources/AXExtension/AXUIElement.swift +++ b/Tool/Sources/AXExtension/AXUIElement.swift @@ -1,9 +1,6 @@ import AppKit import Foundation import Logger -#if DEBUG -import IssueReporting -#endif // MARK: - State @@ -61,7 +58,7 @@ public extension AXUIElement { } var isSourceEditor: Bool { - description == "Source Editor" + description == "Source Editor" && roleDescription != "unknown" } var selectedTextRange: ClosedRange? { @@ -188,7 +185,7 @@ public extension AXUIElement { ) -> AXUIElement? { #if DEBUG if depth >= 50 { - reportIssue("AXUIElement.child: Exceeding recommended depth.") + fatalError("AXUIElement.child: Exceeding recommended depth.") } #endif @@ -225,7 +222,7 @@ public extension AXUIElement { func children(depth: Int = 0, where match: (AXUIElement) -> Bool) -> [AXUIElement] { #if DEBUG if depth >= 50 { - reportIssue("AXUIElement.children: Exceeding recommended depth.") + fatalError("AXUIElement.children: Exceeding recommended depth.") } #endif @@ -248,7 +245,7 @@ public extension AXUIElement { func firstChild(depth: Int = 0, where match: (AXUIElement) -> Bool) -> AXUIElement? { #if DEBUG if depth >= 50 { - reportIssue("AXUIElement.firstChild: Exceeding recommended depth.") + fatalError("AXUIElement.firstChild: Exceeding recommended depth.") } #endif for child in children { @@ -352,5 +349,7 @@ public extension AXUIElement { } } +extension AXError: @retroactive _BridgedNSError {} +extension AXError: @retroactive _ObjectiveCBridgeableError {} extension AXError: @retroactive Error {} diff --git a/Tool/Sources/AppActivator/AppActivator.swift b/Tool/Sources/AppActivator/AppActivator.swift index 8d31b32c..2011360a 100644 --- a/Tool/Sources/AppActivator/AppActivator.swift +++ b/Tool/Sources/AppActivator/AppActivator.swift @@ -34,7 +34,7 @@ public extension NSWorkspace { static func activatePreviousActiveApp(delay: TimeInterval = 0.2) { Task { @MainActor in - guard let app = await XcodeInspector.shared.safe.previousActiveApplication + guard let app = XcodeInspector.shared.previousActiveApplication else { return } try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) activateApp(app) @@ -43,7 +43,7 @@ public extension NSWorkspace { static func activatePreviousActiveXcode(delay: TimeInterval = 0.2) { Task { @MainActor in - guard let app = await XcodeInspector.shared.safe.latestActiveXcode else { return } + guard let app = XcodeInspector.shared.latestActiveXcode else { return } try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) activateApp(app) } diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift index 3d78fab2..86832df5 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift @@ -7,14 +7,19 @@ public final class BuiltinExtensionManager { public static let shared: BuiltinExtensionManager = .init() public private(set) var extensions: [any BuiltinExtension] = [] - private var cancellable: Set = [] - init() { - XcodeInspector.shared.$activeApplication.sink { [weak self] app in - if let app, app.isXcode || app.isExtensionService { - self?.checkAppConfiguration() + Task { [weak self] in + let notifications = NotificationCenter.default + .notifications(named: .activeApplicationDidChange) + for await _ in notifications { + guard let self else { return } + if let app = await XcodeInspector.shared.activeApplication, + app.isXcode || app.isExtensionService + { + self.checkAppConfiguration() + } } - }.store(in: &cancellable) + } } public func setupExtensions(_ extensions: [any BuiltinExtension]) { diff --git a/Tool/Sources/ChatBasic/ChatGPTFunction.swift b/Tool/Sources/ChatBasic/ChatGPTFunction.swift index 4b19471d..77913a2a 100644 --- a/Tool/Sources/ChatBasic/ChatGPTFunction.swift +++ b/Tool/Sources/ChatBasic/ChatGPTFunction.swift @@ -89,7 +89,7 @@ public extension ChatGPTArgumentsCollectingFunction { } } -public struct ChatGPTFunctionSchema: Codable, Equatable { +public struct ChatGPTFunctionSchema: Codable, Equatable, Sendable { public var name: String public var description: String public var parameters: JSONSchemaValue diff --git a/Tool/Sources/ChatBasic/ChatMessage.swift b/Tool/Sources/ChatBasic/ChatMessage.swift index 23e57000..033e3578 100644 --- a/Tool/Sources/ChatBasic/ChatMessage.swift +++ b/Tool/Sources/ChatBasic/ChatMessage.swift @@ -10,6 +10,8 @@ public struct ChatMessage: Equatable, Codable { case system case user case assistant + // There is no `tool` role + // because tool calls and results are stored in the assistant messages. } /// A function call that can be made by the bot. diff --git a/Tool/Sources/CodeiumService/ChatTab/CodeiumChatBrowser.swift b/Tool/Sources/CodeiumService/ChatTab/CodeiumChatBrowser.swift index 46ec9e51..5d7fab76 100644 --- a/Tool/Sources/CodeiumService/ChatTab/CodeiumChatBrowser.swift +++ b/Tool/Sources/CodeiumService/ChatTab/CodeiumChatBrowser.swift @@ -61,7 +61,7 @@ struct CodeiumChatBrowser { case .loadCurrentWorkspace: return .run { send in - guard let workspaceURL = await XcodeInspector.shared.safe.activeWorkspaceURL + guard let workspaceURL = await XcodeInspector.shared.activeWorkspaceURL else { await send(.presentError("Can't find workspace.")) return diff --git a/Tool/Sources/CodeiumService/LanguageServer/CodeiumLanguageServer.swift b/Tool/Sources/CodeiumService/LanguageServer/CodeiumLanguageServer.swift index c3f83118..051994b9 100644 --- a/Tool/Sources/CodeiumService/LanguageServer/CodeiumLanguageServer.swift +++ b/Tool/Sources/CodeiumService/LanguageServer/CodeiumLanguageServer.swift @@ -387,7 +387,7 @@ class WorkspaceParser: NSObject, XMLParserDelegate { } public func getProjectPaths() async -> [String] { - guard let workspaceURL = await XcodeInspector.shared.safe.realtimeActiveWorkspaceURL else { + guard let workspaceURL = XcodeInspector.shared.realtimeActiveWorkspaceURL else { return [] } diff --git a/Tool/Sources/CodeiumService/Services/CodeiumService.swift b/Tool/Sources/CodeiumService/Services/CodeiumService.swift index 33cb8c0d..046d2df2 100644 --- a/Tool/Sources/CodeiumService/Services/CodeiumService.swift +++ b/Tool/Sources/CodeiumService/Services/CodeiumService.swift @@ -208,7 +208,7 @@ extension CodeiumService { } throw E() } - var ideVersion = await XcodeInspector.shared.safe.latestActiveXcode?.version + var ideVersion = await XcodeInspector.shared.latestActiveXcode?.version ?? fallbackXcodeVersion let versionNumberSegmentCount = ideVersion.split(separator: ".").count if versionNumberSegmentCount == 2 { diff --git a/Tool/Sources/CustomCommandTemplateProcessor/CustomCommandTemplateProcessor.swift b/Tool/Sources/CustomCommandTemplateProcessor/CustomCommandTemplateProcessor.swift index 2a54d320..9f648612 100644 --- a/Tool/Sources/CustomCommandTemplateProcessor/CustomCommandTemplateProcessor.swift +++ b/Tool/Sources/CustomCommandTemplateProcessor/CustomCommandTemplateProcessor.swift @@ -39,8 +39,8 @@ public struct CustomCommandTemplateProcessor { } func getEditorInformation() async -> EditorInformation { - let editorContent = await XcodeInspector.shared.safe.focusedEditor?.getContent() - let documentURL = await XcodeInspector.shared.safe.activeDocumentURL + let editorContent = await XcodeInspector.shared.focusedEditor?.getContent() + let documentURL = await XcodeInspector.shared.activeDocumentURL let language = documentURL.map(languageIdentifierFromFileURL) ?? .plaintext return .init( diff --git a/Tool/Sources/Logger/Logger.swift b/Tool/Sources/Logger/Logger.swift index 390e54d5..2d791376 100644 --- a/Tool/Sources/Logger/Logger.swift +++ b/Tool/Sources/Logger/Logger.swift @@ -23,6 +23,7 @@ public final class Logger { public static let license = Logger(category: "License") public static let `extension` = Logger(category: "Extension") public static let communicationBridge = Logger(category: "CommunicationBridge") + public static let chatProxy = Logger(category: "ChatProxy") public static let debug = Logger(category: "Debug") #if DEBUG /// Use a temp logger to log something temporary. I won't be available in release builds. diff --git a/Tool/Sources/OpenAIService/APIs/BuiltinExtensionChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/BuiltinExtensionChatCompletionsService.swift index c08435ae..888c301f 100644 --- a/Tool/Sources/OpenAIService/APIs/BuiltinExtensionChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/BuiltinExtensionChatCompletionsService.swift @@ -62,8 +62,8 @@ extension BuiltinExtensionChatCompletionsService: ChatCompletionsStreamAPI { ) async throws -> AsyncThrowingStream { let service = try getChatService() let (message, history) = extractMessageAndHistory(from: requestBody) - guard let workspaceURL = await XcodeInspector.shared.safe.realtimeActiveWorkspaceURL, - let projectURL = await XcodeInspector.shared.safe.realtimeActiveProjectURL + guard let workspaceURL = XcodeInspector.shared.realtimeActiveWorkspaceURL, + let projectURL = XcodeInspector.shared.realtimeActiveProjectURL else { throw CancellationError() } let stream = await service.sendMessage( message, diff --git a/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift index 12434f1e..548ca8a1 100644 --- a/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift @@ -127,13 +127,13 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet var stop_sequence: String? } - struct RequestBody: Encodable, Equatable { - struct CacheControl: Encodable, Equatable { - enum CacheControlType: String, Codable, Equatable { + public struct RequestBody: Encodable, Equatable { + public struct CacheControl: Codable, Equatable, Sendable { + public enum CacheControlType: String, Codable, Equatable, Sendable { case ephemeral } - var type: CacheControlType = .ephemeral + public var type: CacheControlType = .ephemeral } struct MessageContent: Encodable, Equatable { diff --git a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift index 0418ca8e..c1ad2027 100644 --- a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift @@ -7,7 +7,7 @@ import Logger import Preferences /// https://platform.openai.com/docs/api-reference/chat/create -actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI { +public actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI { struct CompletionAPIError: Error, Decodable, LocalizedError { struct ErrorDetail: Decodable { var message: String @@ -68,7 +68,7 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI } } - enum MessageRole: String, Codable { + public enum MessageRole: String, Codable, Sendable { case system case user case assistant @@ -86,42 +86,84 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI } } - struct StreamDataChunk: Codable { - var id: String? - var object: String? - var model: String? - var choices: [Choice]? - var usage: ResponseBody.Usage? - - struct Choice: Codable { - var delta: Delta? - var index: Int? - var finish_reason: String? + public struct StreamDataChunk: Codable, Sendable { + public var id: String? + public var provider: String? + public var object: String? + public var model: String? + public var choices: [Choice]? + public var usage: ResponseBody.Usage? + public var created: Int? + + public struct Choice: Codable, Sendable { + public var delta: Delta? + public var index: Int? + public var finish_reason: String? + + public struct Delta: Codable, Sendable { + public var role: MessageRole? + public var content: String? + public var reasoning_content: String? + public var reasoning: String? + public var function_call: RequestBody.MessageFunctionCall? + public var tool_calls: [RequestBody.MessageToolCall]? + + public init( + role: MessageRole? = nil, + content: String? = nil, + reasoning_content: String? = nil, + reasoning: String? = nil, + function_call: RequestBody.MessageFunctionCall? = nil, + tool_calls: [RequestBody.MessageToolCall]? = nil + ) { + self.role = role + self.content = content + self.reasoning_content = reasoning_content + self.reasoning = reasoning + self.function_call = function_call + self.tool_calls = tool_calls + } + } - struct Delta: Codable { - var role: MessageRole? - var content: String? - var reasoning_content: String? - var reasoning: String? - var function_call: RequestBody.MessageFunctionCall? - var tool_calls: [RequestBody.MessageToolCall]? + public init(delta: Delta? = nil, index: Int? = nil, finish_reason: String? = nil) { + self.delta = delta + self.index = index + self.finish_reason = finish_reason } } + + public init( + id: String? = nil, + provider: String? = nil, + object: String? = nil, + model: String? = nil, + choices: [Choice]? = nil, + usage: ResponseBody.Usage? = nil, + created: Int? = nil + ) { + self.id = id + self.provider = provider + self.object = object + self.model = model + self.choices = choices + self.usage = usage + self.created = created + } } - struct ResponseBody: Codable, Equatable { - struct Message: Codable, Equatable { + public struct ResponseBody: Codable, Equatable { + public struct Message: Codable, Equatable, Sendable { /// The role of the message. - var role: MessageRole + public var role: MessageRole /// The content of the message. - var content: String? - var reasoning_content: String? - var reasoning: String? + public var content: String? + public var reasoning_content: String? + public var reasoning: String? /// When we want to reply to a function call with the result, we have to provide the /// name of the function call, and include the result in `content`. /// /// - important: It's required when the role is `function`. - var name: String? + public var name: String? /// When the bot wants to call a function, it will reply with a function call in format: /// ```json /// { @@ -129,79 +171,171 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI /// "arguments": "{ \"location\": \"earth\" }" /// } /// ``` - var function_call: RequestBody.MessageFunctionCall? + public var function_call: RequestBody.MessageFunctionCall? /// Tool calls in an assistant message. - var tool_calls: [RequestBody.MessageToolCall]? + public var tool_calls: [RequestBody.MessageToolCall]? + + public init( + role: MessageRole, + content: String? = nil, + reasoning_content: String? = nil, + reasoning: String? = nil, + name: String? = nil, + function_call: RequestBody.MessageFunctionCall? = nil, + tool_calls: [RequestBody.MessageToolCall]? = nil + ) { + self.role = role + self.content = content + self.reasoning_content = reasoning_content + self.reasoning = reasoning + self.name = name + self.function_call = function_call + self.tool_calls = tool_calls + } } - struct Choice: Codable, Equatable { - var message: Message - var index: Int? - var finish_reason: String? + public struct Choice: Codable, Equatable, Sendable { + public var message: Message + public var index: Int? + public var finish_reason: String? + + public init(message: Message, index: Int? = nil, finish_reason: String? = nil) { + self.message = message + self.index = index + self.finish_reason = finish_reason + } } - struct Usage: Codable, Equatable { - var prompt_tokens: Int? - var completion_tokens: Int? - var total_tokens: Int? - var prompt_tokens_details: PromptTokensDetails? - var completion_tokens_details: CompletionTokensDetails? + public struct Usage: Codable, Equatable, Sendable { + public var prompt_tokens: Int? + public var completion_tokens: Int? + public var total_tokens: Int? + public var prompt_tokens_details: PromptTokensDetails? + public var completion_tokens_details: CompletionTokensDetails? - struct PromptTokensDetails: Codable, Equatable { - var cached_tokens: Int? - var audio_tokens: Int? + public struct PromptTokensDetails: Codable, Equatable, Sendable { + public var cached_tokens: Int? + public var audio_tokens: Int? + + public init(cached_tokens: Int? = nil, audio_tokens: Int? = nil) { + self.cached_tokens = cached_tokens + self.audio_tokens = audio_tokens + } } - struct CompletionTokensDetails: Codable, Equatable { - var reasoning_tokens: Int? - var audio_tokens: Int? + public struct CompletionTokensDetails: Codable, Equatable, Sendable { + public var reasoning_tokens: Int? + public var audio_tokens: Int? + + public init(reasoning_tokens: Int? = nil, audio_tokens: Int? = nil) { + self.reasoning_tokens = reasoning_tokens + self.audio_tokens = audio_tokens + } + } + + public init( + prompt_tokens: Int? = nil, + completion_tokens: Int? = nil, + total_tokens: Int? = nil, + prompt_tokens_details: PromptTokensDetails? = nil, + completion_tokens_details: CompletionTokensDetails? = nil + ) { + self.prompt_tokens = prompt_tokens + self.completion_tokens = completion_tokens + self.total_tokens = total_tokens + self.prompt_tokens_details = prompt_tokens_details + self.completion_tokens_details = completion_tokens_details } } - var id: String? - var object: String - var model: String - var usage: Usage - var choices: [Choice] + public var id: String? + public var object: String + public var model: String + public var usage: Usage + public var choices: [Choice] + + public init( + id: String? = nil, + object: String, + model: String, + usage: Usage, + choices: [Choice] + ) { + self.id = id + self.object = object + self.model = model + self.usage = usage + self.choices = choices + } } - struct RequestBody: Encodable, Equatable { - typealias ClaudeCacheControl = ClaudeChatCompletionsService.RequestBody.CacheControl - - struct Message: Encodable, Equatable { - enum MessageContent: Encodable, Equatable { - struct TextContentPart: Encodable, Equatable { - var type = "text" - var text: String - var cache_control: ClaudeCacheControl? + public struct RequestBody: Codable, Equatable { + public typealias ClaudeCacheControl = ClaudeChatCompletionsService.RequestBody.CacheControl + + public struct Message: Codable, Equatable { + public enum MessageContent: Codable, Equatable { + public struct TextContentPart: Codable, Equatable { + public var type = "text" + public var text: String + public var cache_control: ClaudeCacheControl? + + public init( + type: String = "text", + text: String, + cache_control: ClaudeCacheControl? = nil + ) { + self.type = type + self.text = text + self.cache_control = cache_control + } } - struct ImageContentPart: Encodable, Equatable { - struct ImageURL: Encodable, Equatable { - var url: String - var detail: String? + public struct ImageContentPart: Codable, Equatable { + public struct ImageURL: Codable, Equatable { + public var url: String + public var detail: String? + + public init(url: String, detail: String? = nil) { + self.url = url + self.detail = detail + } } - var type = "image_url" - var image_url: ImageURL + public var type = "image_url" + public var image_url: ImageURL + + public init(type: String = "image_url", image_url: ImageURL) { + self.type = type + self.image_url = image_url + } } - struct AudioContentPart: Encodable, Equatable { - struct InputAudio: Encodable, Equatable { - var data: String - var format: String + public struct AudioContentPart: Codable, Equatable { + public struct InputAudio: Codable, Equatable { + public var data: String + public var format: String + + public init(data: String, format: String) { + self.data = data + self.format = format + } } - var type = "input_audio" - var input_audio: InputAudio + public var type = "input_audio" + public var input_audio: InputAudio + + public init(type: String = "input_audio", input_audio: InputAudio) { + self.type = type + self.input_audio = input_audio + } } - enum ContentPart: Encodable, Equatable { + public enum ContentPart: Codable, Equatable { case text(TextContentPart) case image(ImageContentPart) case audio(AudioContentPart) - func encode(to encoder: any Encoder) throws { + public func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() switch self { case let .text(text): @@ -212,12 +346,58 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI try container.encode(audio) } } + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + var errors: [Error] = [] + + do { + let text = try container.decode(String.self) + self = .text(.init(text: text)) + return + } catch { + errors.append(error) + } + + do { + let text = try container.decode(TextContentPart.self) + self = .text(text) + return + } catch { + errors.append(error) + } + + do { + let image = try container.decode(ImageContentPart.self) + self = .image(image) + return + } catch { + errors.append(error) + } + + do { + let audio = try container.decode(AudioContentPart.self) + self = .audio(audio) + return + } catch { + errors.append(error) + } + + struct E: Error, LocalizedError { + let errors: [Error] + + var errorDescription: String? { + "Failed to decode ContentPart: \(errors.map { $0.localizedDescription }.joined(separator: "; "))" + } + } + throw E(errors: errors) + } } case contentParts([ContentPart]) case text(String) - func encode(to encoder: any Encoder) throws { + public func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() switch self { case let .contentParts(parts): @@ -226,65 +406,159 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI try container.encode(text) } } + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + var errors: [Error] = [] + + do { + let parts = try container.decode([ContentPart].self) + self = .contentParts(parts) + return + } catch { + errors.append(error) + } + + do { + let text = try container.decode(String.self) + self = .text(text) + return + } catch { + errors.append(error) + } + + struct E: Error, LocalizedError { + let errors: [Error] + + var errorDescription: String? { + "Failed to decode MessageContent: \(errors.map { $0.localizedDescription }.joined(separator: "; "))" + } + } + throw E(errors: errors) + } } /// The role of the message. - var role: MessageRole + public var role: MessageRole /// The content of the message. - var content: MessageContent + public var content: MessageContent /// When we want to reply to a function call with the result, we have to provide the /// name of the function call, and include the result in `content`. /// /// - important: It's required when the role is `function`. - var name: String? + public var name: String? /// Tool calls in an assistant message. - var tool_calls: [MessageToolCall]? + public var tool_calls: [MessageToolCall]? /// When we want to call a tool, we have to provide the id of the call. /// /// - important: It's required when the role is `tool`. - var tool_call_id: String? + public var tool_call_id: String? /// When the bot wants to call a function, it will reply with a function call. /// /// Deprecated. - var function_call: MessageFunctionCall? + public var function_call: MessageFunctionCall? + + public init( + role: MessageRole, + content: MessageContent, + name: String? = nil, + tool_calls: [MessageToolCall]? = nil, + tool_call_id: String? = nil, + function_call: MessageFunctionCall? = nil + ) { + self.role = role + self.content = content + self.name = name + self.tool_calls = tool_calls + self.tool_call_id = tool_call_id + self.function_call = function_call + } } - struct MessageFunctionCall: Codable, Equatable { + public struct MessageFunctionCall: Codable, Equatable, Sendable { /// The name of the - var name: String? + public var name: String? /// A JSON string. - var arguments: String? + public var arguments: String? + + public init(name: String? = nil, arguments: String? = nil) { + self.name = name + self.arguments = arguments + } } - struct MessageToolCall: Codable, Equatable { + public struct MessageToolCall: Codable, Equatable, Sendable { /// When it's returned as a data chunk, use the index to identify the tool call. - var index: Int? + public var index: Int? /// The id of the tool call. - var id: String? + public var id: String? /// The type of the tool. - var type: String? + public var type: String? /// The function call. - var function: MessageFunctionCall? + public var function: MessageFunctionCall? + + public init( + index: Int? = nil, + id: String? = nil, + type: String? = nil, + function: MessageFunctionCall? = nil + ) { + self.index = index + self.id = id + self.type = type + self.function = function + } } - struct Tool: Encodable, Equatable { - var type: String = "function" - var function: ChatGPTFunctionSchema + public struct Tool: Codable, Equatable, Sendable { + public var type: String = "function" + public var function: ChatGPTFunctionSchema + + public init(type: String, function: ChatGPTFunctionSchema) { + self.type = type + self.function = function + } } - struct StreamOptions: Encodable, Equatable { - var include_usage: Bool = true + public struct StreamOptions: Codable, Equatable, Sendable { + public var include_usage: Bool = true + + public init(include_usage: Bool = true) { + self.include_usage = include_usage + } } - var model: String - var messages: [Message] - var temperature: Double? - var stream: Bool? - var stop: [String]? - var max_completion_tokens: Int? - var tool_choice: FunctionCallStrategy? - var tools: [Tool]? - var stream_options: StreamOptions? + public var model: String + public var messages: [Message] + public var temperature: Double? + public var stream: Bool? + public var stop: [String]? + public var max_completion_tokens: Int? + public var tool_choice: FunctionCallStrategy? + public var tools: [Tool]? + public var stream_options: StreamOptions? + + public init( + model: String, + messages: [Message], + temperature: Double? = nil, + stream: Bool? = nil, + stop: [String]? = nil, + max_completion_tokens: Int? = nil, + tool_choice: FunctionCallStrategy? = nil, + tools: [Tool]? = nil, + stream_options: StreamOptions? = nil + ) { + self.model = model + self.messages = messages + self.temperature = temperature + self.stream = stream + self.stop = stop + self.max_completion_tokens = max_completion_tokens + self.tool_choice = tool_choice + self.tools = tools + self.stream_options = stream_options + } } var apiKey: String @@ -505,7 +779,7 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI default: return } - + let join = JoinJSON() let jsonBody = model.info.customBodyInfo.jsonBody .trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Tool/Sources/OpenAIService/ChatGPTService.swift b/Tool/Sources/OpenAIService/ChatGPTService.swift index 25ba3929..45d6490d 100644 --- a/Tool/Sources/OpenAIService/ChatGPTService.swift +++ b/Tool/Sources/OpenAIService/ChatGPTService.swift @@ -68,6 +68,12 @@ public enum ChatGPTResponse: Equatable { case partialText(String) case partialReasoning(String) case toolCalls([ChatMessage.ToolCall]) + case usage( + promptTokens: Int, + completionTokens: Int, + cachedTokens: Int, + otherUsage: [String: Int] + ) } public typealias ChatGPTResponseStream = AsyncThrowingStream @@ -196,7 +202,7 @@ public class ChatGPTService: ChatGPTServiceType { switch content { case let .partialText(text): continuation.yield(ChatGPTResponse.partialText(text)) - + case let .partialReasoning(text): continuation.yield(ChatGPTResponse.partialReasoning(text)) @@ -222,6 +228,20 @@ public class ChatGPTService: ChatGPTServiceType { } } } + case let .usage( + promptTokens, + completionTokens, + cachedTokens, + otherUsage + ): + continuation.yield( + .usage( + promptTokens: promptTokens, + completionTokens: completionTokens, + cachedTokens: cachedTokens, + otherUsage: otherUsage + ) + ) } } @@ -257,6 +277,12 @@ extension ChatGPTService { case partialReasoning(String) case partialText(String) case partialToolCalls([Int: ChatMessage.ToolCall]) + case usage( + promptTokens: Int, + completionTokens: Int, + cachedTokens: Int, + otherUsage: [String: Int] + ) } enum FunctionCallResult { @@ -345,14 +371,19 @@ extension ChatGPTService { if let content = delta.content { continuation.yield(.partialText(content)) } - + if let reasoning = delta.reasoningContent { continuation.yield(.partialReasoning(reasoning)) } } Logger.service.info("ChatGPT usage: \(usage)") - + continuation.yield(.usage( + promptTokens: usage.promptTokens, + completionTokens: usage.completionTokens, + cachedTokens: usage.cachedTokens, + otherUsage: usage.otherUsage + )) continuation.finish() } catch let error as CancellationError { continuation.finish(throwing: error) diff --git a/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift b/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift index 3f6c0afe..710a2ff0 100644 --- a/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift +++ b/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift @@ -39,7 +39,7 @@ public extension ChatGPTConfiguration { } } -public class OverridingChatGPTConfiguration: ChatGPTConfiguration { +public final class OverridingChatGPTConfiguration: ChatGPTConfiguration { public struct Overriding: Codable { public var temperature: Double? public var modelId: String? diff --git a/Tool/Sources/Workspace/WorkspacePool.swift b/Tool/Sources/Workspace/WorkspacePool.swift index 12d86106..28a7643d 100644 --- a/Tool/Sources/Workspace/WorkspacePool.swift +++ b/Tool/Sources/Workspace/WorkspacePool.swift @@ -97,7 +97,7 @@ public class WorkspacePool { -> (workspace: Workspace, filespace: Filespace) { // If we can get the workspace URL directly. - if let currentWorkspaceURL = await XcodeInspector.shared.safe.realtimeActiveWorkspaceURL { + if let currentWorkspaceURL = XcodeInspector.shared.realtimeActiveWorkspaceURL { if let existed = workspaces[currentWorkspaceURL] { // Reuse the existed workspace. let filespace = try existed.createFilespaceIfNeeded( diff --git a/Tool/Sources/XcodeInspector/AppInstanceInspector.swift b/Tool/Sources/XcodeInspector/AppInstanceInspector.swift index 6e04309a..ab358b7e 100644 --- a/Tool/Sources/XcodeInspector/AppInstanceInspector.swift +++ b/Tool/Sources/XcodeInspector/AppInstanceInspector.swift @@ -1,7 +1,7 @@ import AppKit import Foundation -public class AppInstanceInspector: ObservableObject { +open class AppInstanceInspector: @unchecked Sendable { let runningApplication: NSRunningApplication public let processIdentifier: pid_t public let bundleURL: URL? diff --git a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift index 3150ed49..5e73578b 100644 --- a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift +++ b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift @@ -1,17 +1,20 @@ -import AppKit +@preconcurrency import AppKit import AsyncPassthroughSubject import AXExtension import AXNotificationStream import Combine import Foundation +import Perception -public final class XcodeAppInstanceInspector: AppInstanceInspector { - public struct AXNotification { +@XcodeInspectorActor +@Perceptible +public final class XcodeAppInstanceInspector: AppInstanceInspector, @unchecked Sendable { + public struct AXNotification: Sendable { public var kind: AXNotificationKind public var element: AXUIElement } - public enum AXNotificationKind { + public enum AXNotificationKind: Sendable { case titleChanged case applicationActivated case applicationDeactivated @@ -64,20 +67,70 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { } } - @Published public fileprivate(set) var focusedWindow: XcodeWindowInspector? - @Published public fileprivate(set) var documentURL: URL? = nil - @Published public fileprivate(set) var workspaceURL: URL? = nil - @Published public fileprivate(set) var projectRootURL: URL? = nil - @Published public fileprivate(set) var workspaces = [WorkspaceIdentifier: Workspace]() - @Published public private(set) var completionPanel: AXUIElement? - public var realtimeWorkspaces: [WorkspaceIdentifier: WorkspaceInfo] { - updateWorkspaceInfo() - return workspaces.mapValues(\.info) + @MainActor + public fileprivate(set) var focusedWindow: XcodeWindowInspector? { + didSet { + if runningApplication.isActive { + NotificationCenter.default.post(name: .focusedWindowDidChange, object: self) + } + } + } + + @MainActor + public fileprivate(set) var documentURL: URL? = nil { + didSet { + if runningApplication.isActive { + NotificationCenter.default.post(name: .activeDocumentURLDidChange, object: self) + } + } + } + + @MainActor + public fileprivate(set) var workspaceURL: URL? = nil { + didSet { + if runningApplication.isActive { + NotificationCenter.default.post(name: .activeWorkspaceURLDidChange, object: self) + } + } } - public let axNotifications = AsyncPassthroughSubject() + @MainActor + public fileprivate(set) var projectRootURL: URL? = nil { + didSet { + if runningApplication.isActive { + NotificationCenter.default.post(name: .activeProjectRootURLDidChange, object: self) + } + } + } - public var realtimeDocumentURL: URL? { + @MainActor + public fileprivate(set) var workspaces = [WorkspaceIdentifier: Workspace]() { + didSet { + if runningApplication.isActive { + NotificationCenter.default.post(name: .xcodeWorkspacesDidChange, object: self) + } + } + } + + @MainActor + public private(set) var completionPanel: AXUIElement? { + didSet { + if runningApplication.isActive { + NotificationCenter.default.post(name: .completionPanelDidChange, object: self) + } + } + } + + private let observer = XcodeInspector.createObserver() + + public nonisolated var realtimeWorkspaces: [WorkspaceIdentifier: WorkspaceInfo] { + Self.fetchVisibleWorkspaces(runningApplication).mapValues { $0.info } + } + + public nonisolated let axNotifications = AsyncPassthroughSubject() + + public nonisolated + var realtimeDocumentURL: URL? { guard let window = appElement.focusedWindow, window.identifier == "Xcode.WorkspaceWindow" else { return nil } @@ -85,7 +138,8 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { return WorkspaceXcodeWindowInspector.extractDocumentURL(windowElement: window) } - public var realtimeWorkspaceURL: URL? { + public nonisolated + var realtimeWorkspaceURL: URL? { guard let window = appElement.focusedWindow, window.identifier == "Xcode.WorkspaceWindow" else { return nil } @@ -93,7 +147,8 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { return WorkspaceXcodeWindowInspector.extractWorkspaceURL(windowElement: window) } - public var realtimeProjectURL: URL? { + public nonisolated + var realtimeProjectURL: URL? { let workspaceURL = realtimeWorkspaceURL let documentURL = realtimeDocumentURL return WorkspaceXcodeWindowInspector.extractProjectURL( @@ -122,8 +177,7 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { return result } - private var longRunningTasks = Set>() - private var focusedWindowObservations = Set() + @PerceptionIgnored private var longRunningTasks = Set>() deinit { axNotifications.finish() @@ -134,27 +188,28 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { super.init(runningApplication: runningApplication) Task { @XcodeInspectorActor in - observeFocusedWindow() + await observeFocusedWindow() observeAXNotifications() try await Task.sleep(nanoseconds: 3_000_000_000) // Sometimes the focused window may not be ready on app launch. - if !(focusedWindow is WorkspaceXcodeWindowInspector) { - observeFocusedWindow() + if await !(focusedWindow is WorkspaceXcodeWindowInspector) { + await observeFocusedWindow() } } } - @XcodeInspectorActor func refresh() { - if let focusedWindow = focusedWindow as? WorkspaceXcodeWindowInspector { - focusedWindow.refresh() - } else { - observeFocusedWindow() + Task { @MainActor in + if let focusedWindow = focusedWindow as? WorkspaceXcodeWindowInspector { + await focusedWindow.refresh() + } else { + observeFocusedWindow() + } } } - @XcodeInspectorActor + @MainActor private func observeFocusedWindow() { if let window = appElement.focusedWindow { if window.identifier == "Xcode.WorkspaceWindow" { @@ -164,49 +219,40 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { axNotifications: axNotifications ) - focusedWindowObservations.forEach { $0.cancel() } - focusedWindowObservations.removeAll() + focusedWindow = window + documentURL = window.documentURL + workspaceURL = window.workspaceURL + projectRootURL = window.projectRootURL - Task { @MainActor in - focusedWindow = window - documentURL = window.documentURL - workspaceURL = window.workspaceURL - projectRootURL = window.projectRootURL + observer.observe { [weak self] in + let url = window.documentURL + if url != .init(fileURLWithPath: "/") { + self?.documentURL = url + } } - window.$documentURL - .filter { $0 != .init(fileURLWithPath: "/") } - .receive(on: DispatchQueue.main) - .sink { [weak self] url in - self?.documentURL = url - }.store(in: &focusedWindowObservations) - window.$workspaceURL - .filter { $0 != .init(fileURLWithPath: "/") } - .receive(on: DispatchQueue.main) - .sink { [weak self] url in + observer.observe { [weak self] in + let url = window.workspaceURL + if url != .init(fileURLWithPath: "/") { self?.workspaceURL = url - }.store(in: &focusedWindowObservations) - window.$projectRootURL - .filter { $0 != .init(fileURLWithPath: "/") } - .receive(on: DispatchQueue.main) - .sink { [weak self] url in - self?.projectRootURL = url - }.store(in: &focusedWindowObservations) + } + } + observer.observe { [weak self] in + let url = window.projectRootURL + if url != .init(fileURLWithPath: "/") { + self?.projectRootURL = url + } + } } else { let window = XcodeWindowInspector(uiElement: window) - Task { @MainActor in - focusedWindow = window - } + focusedWindow = window } } else { - Task { @MainActor in - focusedWindow = nil - } + focusedWindow = nil } } - @XcodeInspectorActor func observeAXNotifications() { longRunningTasks.forEach { $0.cancel() } longRunningTasks = [] @@ -245,7 +291,7 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { self.axNotifications.send(.init(kind: event, element: notification.element)) if event == .focusedWindowChanged { - observeFocusedWindow() + await observeFocusedWindow() } if event == .focusedUIElementChanged || event == .applicationDeactivated { @@ -254,7 +300,7 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { guard let self else { return } try await Task.sleep(nanoseconds: 2_000_000_000) try Task.checkCancellation() - self.updateWorkspaceInfo() + await self.updateWorkspaceInfo() } } @@ -265,21 +311,21 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { await MainActor.run { self.completionPanel = notification.element self.completionPanel?.setMessagingTimeout(1) - self.axNotifications.send(.init( - kind: .xcodeCompletionPanelChanged, - element: notification.element - )) } + self.axNotifications.send(.init( + kind: .xcodeCompletionPanelChanged, + element: notification.element + )) } case .uiElementDestroyed: if isCompletionPanel(notification.element) { await MainActor.run { self.completionPanel = nil - self.axNotifications.send(.init( - kind: .xcodeCompletionPanelChanged, - element: notification.element - )) } + self.axNotifications.send(.init( + kind: .xcodeCompletionPanelChanged, + element: notification.element + )) } default: continue } @@ -289,14 +335,16 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { longRunningTasks.insert(observeAXNotificationTask) - updateWorkspaceInfo() + Task { @MainActor in + updateWorkspaceInfo() + } } } // MARK: - Workspace Info extension XcodeAppInstanceInspector { - public enum WorkspaceIdentifier: Hashable { + public enum WorkspaceIdentifier: Hashable, Sendable { case url(URL) case unknown } @@ -318,7 +366,7 @@ extension XcodeAppInstanceInspector { } } - public struct WorkspaceInfo { + public struct WorkspaceInfo: Sendable { public let tabs: Set public func combined(with info: WorkspaceInfo) -> WorkspaceInfo { @@ -326,16 +374,15 @@ extension XcodeAppInstanceInspector { } } + @MainActor func updateWorkspaceInfo() { let workspaceInfoInVisibleSpace = Self.fetchVisibleWorkspaces(runningApplication) let workspaces = Self.updateWorkspace(workspaces, with: workspaceInfoInVisibleSpace) - Task { @MainActor in - self.workspaces = workspaces - } + self.workspaces = workspaces } /// Use the project path as the workspace identifier. - static func workspaceIdentifier(_ window: AXUIElement) -> WorkspaceIdentifier { + nonisolated static func workspaceIdentifier(_ window: AXUIElement) -> WorkspaceIdentifier { if let url = WorkspaceXcodeWindowInspector.extractWorkspaceURL(windowElement: window) { return WorkspaceIdentifier.url(url) } @@ -343,7 +390,7 @@ extension XcodeAppInstanceInspector { } /// With Accessibility API, we can ONLY get the information of visible windows. - static func fetchVisibleWorkspaces( + nonisolated static func fetchVisibleWorkspaces( _ app: NSRunningApplication ) -> [WorkspaceIdentifier: Workspace] { let app = AXUIElementCreateApplication(app.processIdentifier) @@ -380,7 +427,7 @@ extension XcodeAppInstanceInspector { return dict } - static func updateWorkspace( + nonisolated static func updateWorkspace( _ old: [WorkspaceIdentifier: Workspace], with new: [WorkspaceIdentifier: Workspace] ) -> [WorkspaceIdentifier: Workspace] { @@ -427,7 +474,7 @@ public extension AXUIElement { if description == "editor area" { return self } return firstChild(where: { $0.description == "editor area" }) }() else { return [] } - + var tabBars = [AXUIElement]() editArea.traverse { element, _ in let description = element.description @@ -442,7 +489,7 @@ public extension AXUIElement { return .skipDescendantsAndSiblings } - + if element.identifier == "editor context" { return .skipDescendantsAndSiblings } @@ -458,7 +505,7 @@ public extension AXUIElement { if description == "Debug Area" { return .skipDescendants } - + if description == "debug bar" { return .skipDescendants } @@ -468,20 +515,20 @@ public extension AXUIElement { return tabBars } - + var debugArea: AXUIElement? { guard let editArea: AXUIElement = { if description == "editor area" { return self } return firstChild(where: { $0.description == "editor area" }) }() else { return nil } - + var debugArea: AXUIElement? editArea.traverse { element, _ in let description = element.description if description == "Tab Bar" { return .skipDescendants } - + if element.identifier == "editor context" { return .skipDescendantsAndSiblings } @@ -498,7 +545,7 @@ public extension AXUIElement { debugArea = element return .skipDescendants } - + if description == "debug bar" { return .skipDescendants } @@ -509,3 +556,4 @@ public extension AXUIElement { return debugArea } } + diff --git a/Tool/Sources/XcodeInspector/SourceEditor.swift b/Tool/Sources/XcodeInspector/SourceEditor.swift index c1495402..6ddd5b95 100644 --- a/Tool/Sources/XcodeInspector/SourceEditor.swift +++ b/Tool/Sources/XcodeInspector/SourceEditor.swift @@ -1,4 +1,4 @@ -import AppKit +@preconcurrency import AppKit import AsyncPassthroughSubject import AXNotificationStream import Foundation @@ -6,10 +6,10 @@ import Logger import SuggestionBasic /// Representing a source editor inside Xcode. -public class SourceEditor { +public class SourceEditor: @unchecked Sendable { public typealias Content = EditorInformation.SourceEditorContent - public struct AXNotification: Hashable { + public struct AXNotification: Hashable, Sendable { public var kind: AXNotificationKind public var element: AXUIElement @@ -18,7 +18,7 @@ public class SourceEditor { } } - public enum AXNotificationKind: Hashable, Equatable { + public enum AXNotificationKind: Hashable, Equatable, Sendable { case selectedTextChanged case valueChanged case scrollPositionChanged @@ -82,7 +82,7 @@ public class SourceEditor { private func observeAXNotifications() { observeAXNotificationsTask?.cancel() - observeAXNotificationsTask = Task { @XcodeInspectorActor [weak self] in + observeAXNotificationsTask = Task { [weak self] in guard let self else { return } await withThrowingTaskGroup(of: Void.self) { [weak self] group in guard let self else { return } diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index 51defded..3f97c637 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -4,12 +4,40 @@ import AXExtension import Combine import Foundation import Logger +import Perception import Preferences import SuggestionBasic +import SwiftNavigation import Toast public extension Notification.Name { - static let accessibilityAPIMalfunctioning = Notification.Name("accessibilityAPIMalfunctioning") + static let accessibilityAPIMalfunctioning = Notification + .Name("XcodeInspector.accessibilityAPIMalfunctioning") + static let activeApplicationDidChange = Notification + .Name("XcodeInspector.activeApplicationDidChange") + static let previousActiveApplicationDidChange = Notification + .Name("XcodeInspector.previousActiveApplicationDidChange") + static let activeXcodeDidChange = Notification + .Name("XcodeInspector.activeXcodeDidChange") + static let latestActiveXcodeDidChange = Notification + .Name("XcodeInspector.latestActiveXcodeDidChange") + static let xcodesDidChange = Notification.Name("XcodeInspector.xcodesDidChange") + static let activeProjectRootURLDidChange = Notification + .Name("XcodeInspector.activeProjectRootURLDidChange") + static let activeDocumentURLDidChange = Notification + .Name("XcodeInspector.activeDocumentURLDidChange") + static let activeWorkspaceURLDidChange = Notification + .Name("XcodeInspector.activeWorkspaceURLDidChange") + static let focusedWindowDidChange = Notification + .Name("XcodeInspector.focusedWindowDidChange") + static let focusedEditorDidChange = Notification + .Name("XcodeInspector.focusedEditorDidChange") + static let focusedElementDidChange = Notification + .Name("XcodeInspector.focusedElementDidChange") + static let completionPanelDidChange = Notification + .Name("XcodeInspector.completionPanelDidChange") + static let xcodeWorkspacesDidChange = Notification + .Name("XcodeInspector.xcodeWorkspacesDidChange") } @globalActor @@ -18,55 +46,133 @@ public enum XcodeInspectorActor: GlobalActor { public static let shared = Actor() } -#warning("TODO: Consider rewriting it with Swift Observation") -public final class XcodeInspector: ObservableObject { - public static let shared = XcodeInspector() - - @XcodeInspectorActor - @dynamicMemberLookup - public class Safe { - var inspector: XcodeInspector { .shared } - nonisolated init() {} - public subscript(dynamicMember member: KeyPath) -> T { - inspector[keyPath: member] +@XcodeInspectorActor +@Perceptible +public final class XcodeInspector: Sendable { + public final class PerceptionObserver: Sendable { + public struct Cancellable { + let token: ObserveToken + public func cancel() { + token.cancel() + } + } + + final class Object: NSObject, Sendable {} + + let object = Object() + + @MainActor + @discardableResult public func observe( + _ block: @Sendable @escaping @MainActor () -> Void + ) -> Cancellable { + let token = object.observe { block() } + return Cancellable(token: token) } } + public nonisolated static func createObserver() -> PerceptionObserver { + PerceptionObserver() + } + + public nonisolated static let shared = XcodeInspector() + private var toast: ToastController { ToastControllerDependencyKey.liveValue } - private var cancellable = Set() - private var activeXcodeObservations = Set>() - private var appChangeObservations = Set>() - private var activeXcodeCancellable = Set() - - #warning("TODO: Find a good way to make XcodeInspector thread safe!") - public var safe = Safe() - - @Published public fileprivate(set) var activeApplication: AppInstanceInspector? - @Published public fileprivate(set) var previousActiveApplication: AppInstanceInspector? - @Published public fileprivate(set) var activeXcode: XcodeAppInstanceInspector? - @Published public fileprivate(set) var latestActiveXcode: XcodeAppInstanceInspector? - @Published public fileprivate(set) var xcodes: [XcodeAppInstanceInspector] = [] - @Published public fileprivate(set) var activeProjectRootURL: URL? = nil - @Published public fileprivate(set) var activeDocumentURL: URL? = nil - @Published public fileprivate(set) var activeWorkspaceURL: URL? = nil - @Published public fileprivate(set) var focusedWindow: XcodeWindowInspector? - @Published public fileprivate(set) var focusedEditor: SourceEditor? - @Published public fileprivate(set) var focusedElement: AXUIElement? - @Published public fileprivate(set) var completionPanel: AXUIElement? + @PerceptionIgnored private var activeXcodeObservations = Set>() + @PerceptionIgnored private var appChangeObservations = Set>() + + @MainActor + public fileprivate(set) var activeApplication: AppInstanceInspector? { + didSet { + NotificationCenter.default.post(name: .activeApplicationDidChange, object: nil) + } + } + + @MainActor + public fileprivate(set) var previousActiveApplication: AppInstanceInspector? { + didSet { + NotificationCenter.default.post(name: .previousActiveApplicationDidChange, object: nil) + } + } + + @MainActor + public fileprivate(set) var activeXcode: XcodeAppInstanceInspector? { + didSet { + NotificationCenter.default.post(name: .activeXcodeDidChange, object: nil) + NotificationCenter.default.post(name: .focusedWindowDidChange, object: nil) + NotificationCenter.default.post(name: .activeDocumentURLDidChange, object: self) + NotificationCenter.default.post(name: .activeWorkspaceURLDidChange, object: self) + NotificationCenter.default.post(name: .activeProjectRootURLDidChange, object: self) + NotificationCenter.default.post(name: .completionPanelDidChange, object: self) + NotificationCenter.default.post(name: .xcodeWorkspacesDidChange, object: self) + } + } + + @MainActor + public fileprivate(set) var latestActiveXcode: XcodeAppInstanceInspector? { + didSet { + _nonIsolatedLatestActiveXcode = latestActiveXcode + NotificationCenter.default.post(name: .latestActiveXcodeDidChange, object: nil) + } + } + + @MainActor + public fileprivate(set) var xcodes: [XcodeAppInstanceInspector] = [] { + didSet { + NotificationCenter.default.post(name: .xcodesDidChange, object: nil) + } + } + + @MainActor + public var activeProjectRootURL: URL? { + (activeXcode ?? latestActiveXcode)?.projectRootURL + } + + @MainActor + public var activeDocumentURL: URL? { + (activeXcode ?? latestActiveXcode)?.documentURL + } + + @MainActor + public var activeWorkspaceURL: URL? { + (activeXcode ?? latestActiveXcode)?.workspaceURL + } + + @MainActor + public var focusedWindow: XcodeWindowInspector? { + (activeXcode ?? latestActiveXcode)?.focusedWindow + } + + @MainActor + public var completionPanel: AXUIElement? { + (activeXcode ?? latestActiveXcode)?.completionPanel + } + + @MainActor + public fileprivate(set) var focusedEditor: SourceEditor? { + didSet { + NotificationCenter.default.post(name: .focusedEditorDidChange, object: nil) + } + } + + @MainActor + public fileprivate(set) var focusedElement: AXUIElement? { + didSet { + NotificationCenter.default.post(name: .focusedElementDidChange, object: nil) + } + } /// Get the content of the source editor. /// /// - note: This method is expensive. It needs to convert index based ranges to line based /// ranges. - @XcodeInspectorActor public func getFocusedEditorContent() async -> EditorInformation? { guard let documentURL = realtimeActiveDocumentURL, let workspaceURL = realtimeActiveWorkspaceURL, - let projectURL = activeProjectRootURL + let projectURL = realtimeActiveProjectURL else { return nil } - let editorContent = focusedEditor?.getContent() + let editorContent = await focusedEditor?.getContent() let language = languageIdentifierFromFileURL(documentURL) let relativePath = documentURL.path.replacingOccurrences(of: projectURL.path, with: "") @@ -99,61 +205,59 @@ public final class XcodeInspector: ObservableObject { ) } - public var realtimeActiveDocumentURL: URL? { - latestActiveXcode?.realtimeDocumentURL ?? activeDocumentURL + @PerceptionIgnored + private nonisolated(unsafe) var _nonIsolatedLatestActiveXcode: XcodeAppInstanceInspector? + + public nonisolated var realtimeActiveDocumentURL: URL? { + _nonIsolatedLatestActiveXcode?.realtimeDocumentURL } - public var realtimeActiveWorkspaceURL: URL? { - latestActiveXcode?.realtimeWorkspaceURL ?? activeWorkspaceURL + public nonisolated var realtimeActiveWorkspaceURL: URL? { + _nonIsolatedLatestActiveXcode?.realtimeWorkspaceURL } - public var realtimeActiveProjectURL: URL? { - latestActiveXcode?.realtimeProjectURL ?? activeProjectRootURL + public nonisolated var realtimeActiveProjectURL: URL? { + _nonIsolatedLatestActiveXcode?.realtimeProjectURL } - init() { + nonisolated init() { AXUIElement.setGlobalMessagingTimeout(3) - Task { @XcodeInspectorActor in - restart() - } + Task { await restart() } } - @XcodeInspectorActor - public func restart(cleanUp: Bool = false) { + public func restart(cleanUp: Bool = false) async { if cleanUp { activeXcodeObservations.forEach { $0.cancel() } activeXcodeObservations.removeAll() - activeXcodeCancellable.forEach { $0.cancel() } - activeXcodeCancellable.removeAll() - activeXcode = nil - latestActiveXcode = nil - activeApplication = nil - activeProjectRootURL = nil - activeDocumentURL = nil - activeWorkspaceURL = nil - focusedWindow = nil - focusedEditor = nil - focusedElement = nil - completionPanel = nil + await MainActor.run { + self.activeXcode = nil + latestActiveXcode = nil + activeApplication = nil + focusedEditor = nil + focusedElement = nil + } } let runningApplications = NSWorkspace.shared.runningApplications - xcodes = runningApplications - .filter { $0.isXcode } - .map(XcodeAppInstanceInspector.init(runningApplication:)) - let activeXcode = xcodes.first(where: \.isActive) - latestActiveXcode = activeXcode ?? xcodes.first - activeApplication = activeXcode ?? runningApplications - .first(where: \.isActive) - .map(AppInstanceInspector.init(runningApplication:)) + + await MainActor.run { + xcodes = runningApplications + .filter { $0.isXcode } + .map(XcodeAppInstanceInspector.init(runningApplication:)) + let activeXcode = xcodes.first(where: \.isActive) + latestActiveXcode = activeXcode ?? xcodes.first + activeApplication = activeXcode ?? runningApplications + .first(where: \.isActive) + .map(AppInstanceInspector.init(runningApplication:)) + } appChangeObservations.forEach { $0.cancel() } appChangeObservations.removeAll() let appChangeTask = Task(priority: .utility) { [weak self] in guard let self else { return } - if let activeXcode { - setActiveXcode(activeXcode) + if let activeXcode = await self.activeXcode { + await setActiveXcode(activeXcode) } await withThrowingTaskGroup(of: Void.self) { [weak self] group in @@ -167,22 +271,22 @@ public final class XcodeInspector: ObservableObject { .userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { continue } if app.isXcode { - if let existed = xcodes.first(where: { + if let existed = await self.xcodes.first(where: { $0.processIdentifier == app.processIdentifier && !$0.isTerminated }) { - Task { @XcodeInspectorActor in - self.setActiveXcode(existed) + Task { + await self.setActiveXcode(existed) } } else { let new = XcodeAppInstanceInspector(runningApplication: app) - Task { @XcodeInspectorActor in + Task { @MainActor in self.xcodes.append(new) - self.setActiveXcode(new) + await self.setActiveXcode(new) } } } else { let appInspector = AppInstanceInspector(runningApplication: app) - Task { @XcodeInspectorActor in + Task { @MainActor in self.previousActiveApplication = self.activeApplication self.activeApplication = appInspector } @@ -201,7 +305,7 @@ public final class XcodeInspector: ObservableObject { else { continue } if app.isXcode { let processIdentifier = app.processIdentifier - Task { @XcodeInspectorActor in + Task { @MainActor in self.xcodes.removeAll { $0.processIdentifier == processIdentifier || $0.isTerminated } @@ -212,7 +316,7 @@ public final class XcodeInspector: ObservableObject { } if let activeXcode = self.xcodes.first(where: \.isActive) { - self.setActiveXcode(activeXcode) + await self.setActiveXcode(activeXcode) } } } @@ -232,8 +336,8 @@ public final class XcodeInspector: ObservableObject { } try await Task.sleep(nanoseconds: 10_000_000_000) - Task { @XcodeInspectorActor in - self.checkForAccessibilityMalfunction("Timer") + Task { + await self.checkForAccessibilityMalfunction("Timer") } } } @@ -255,60 +359,54 @@ public final class XcodeInspector: ObservableObject { appChangeObservations.insert(appChangeTask) } - public func reactivateObservationsToXcode() { - Task { @XcodeInspectorActor in - if let activeXcode { - setActiveXcode(activeXcode) - activeXcode.observeAXNotifications() - } + public func reactivateObservationsToXcode() async { + if let activeXcode = await activeXcode { + await setActiveXcode(activeXcode) + activeXcode.observeAXNotifications() } } - @XcodeInspectorActor - private func setActiveXcode(_ xcode: XcodeAppInstanceInspector) { - previousActiveApplication = activeApplication - activeApplication = xcode + private func setActiveXcode(_ xcode: XcodeAppInstanceInspector) async { + await MainActor.run { + previousActiveApplication = activeApplication + activeApplication = xcode + } xcode.refresh() for task in activeXcodeObservations { task.cancel() } - for cancellable in activeXcodeCancellable { cancellable.cancel() } activeXcodeObservations.removeAll() - activeXcodeCancellable.removeAll() - - activeXcode = xcode - latestActiveXcode = xcode - activeDocumentURL = xcode.documentURL - focusedWindow = xcode.focusedWindow - completionPanel = xcode.completionPanel - activeProjectRootURL = xcode.projectRootURL - activeWorkspaceURL = xcode.workspaceURL - focusedWindow = xcode.focusedWindow - - let setFocusedElement = { @XcodeInspectorActor [weak self] in + await MainActor.run { + activeXcode = xcode + latestActiveXcode = xcode + } + + let setFocusedElement = { [weak self] in guard let self else { return } - focusedElement = xcode.appElement.focusedElement - if let editorElement = focusedElement, editorElement.isSourceEditor { - focusedEditor = .init( - runningApplication: xcode.runningApplication, - element: editorElement - ) - } else if let element = focusedElement, - let editorElement = element.firstParent(where: \.isSourceEditor) - { - focusedEditor = .init( - runningApplication: xcode.runningApplication, - element: editorElement - ) - } else { - focusedEditor = nil + await MainActor.run { + self.focusedElement = xcode.appElement.focusedElement + if let editorElement = self.focusedElement, editorElement.isSourceEditor { + self.focusedEditor = .init( + runningApplication: xcode.runningApplication, + element: editorElement + ) + } else if let element = self.focusedElement, + let editorElement = element.firstParent(where: \.isSourceEditor) + { + self.focusedEditor = .init( + runningApplication: xcode.runningApplication, + element: editorElement + ) + } else { + self.focusedEditor = nil + } } } - setFocusedElement() - let focusedElementChanged = Task { @XcodeInspectorActor in + await setFocusedElement() + let focusedElementChanged = Task { for await notification in await xcode.axNotifications.notifications() { if notification.kind == .focusedUIElementChanged { try Task.checkCancellation() - setFocusedElement() + await setFocusedElement() } } } @@ -318,7 +416,7 @@ public final class XcodeInspector: ObservableObject { if UserDefaults.shared .value(for: \.restartXcodeInspectorIfAccessibilityAPIIsMalfunctioning) { - let malfunctionCheck = Task { @XcodeInspectorActor [weak self] in + let malfunctionCheck = Task { [weak self] in if #available(macOS 13.0, *) { let notifications = await xcode.axNotifications.notifications().filter { $0.kind == .uiElementDestroyed @@ -326,50 +424,30 @@ public final class XcodeInspector: ObservableObject { for await _ in notifications { guard let self else { return } try Task.checkCancellation() - self.checkForAccessibilityMalfunction("Element Destroyed") + await self.checkForAccessibilityMalfunction("Element Destroyed") } } } activeXcodeObservations.insert(malfunctionCheck) - checkForAccessibilityMalfunction("Reactivate Xcode") + await checkForAccessibilityMalfunction("Reactivate Xcode") } - - xcode.$completionPanel.sink { [weak self] element in - Task { @XcodeInspectorActor in self?.completionPanel = element } - }.store(in: &activeXcodeCancellable) - - xcode.$documentURL.sink { [weak self] url in - Task { @XcodeInspectorActor in self?.activeDocumentURL = url } - }.store(in: &activeXcodeCancellable) - - xcode.$workspaceURL.sink { [weak self] url in - Task { @XcodeInspectorActor in self?.activeWorkspaceURL = url } - }.store(in: &activeXcodeCancellable) - - xcode.$projectRootURL.sink { [weak self] url in - Task { @XcodeInspectorActor in self?.activeProjectRootURL = url } - }.store(in: &activeXcodeCancellable) - - xcode.$focusedWindow.sink { [weak self] window in - Task { @XcodeInspectorActor in self?.focusedWindow = window } - }.store(in: &activeXcodeCancellable) } private var lastRecoveryFromAccessibilityMalfunctioningTimeStamp = Date() - @XcodeInspectorActor - private func checkForAccessibilityMalfunction(_ source: String) { + private func checkForAccessibilityMalfunction(_ source: String) async { guard Date().timeIntervalSince(lastRecoveryFromAccessibilityMalfunctioningTimeStamp) > 5 else { return } - if let editor = focusedEditor, !editor.element.isSourceEditor { + if let editor = await focusedEditor, !editor.element.isSourceEditor { NotificationCenter.default.post( name: .accessibilityAPIMalfunctioning, object: "Source Editor Element Corrupted: \(source)" ) - } else if let element = activeXcode?.appElement.focusedElement { + } else if let element = await activeXcode?.appElement.focusedElement { + let focusedElement = await focusedElement if element.description != focusedElement?.description || element.role != focusedElement?.role { @@ -381,8 +459,7 @@ public final class XcodeInspector: ObservableObject { } } - @XcodeInspectorActor - private func recoverFromAccessibilityMalfunctioning(_ source: String?) { + private func recoverFromAccessibilityMalfunctioning(_ source: String?) async { let message = """ Accessibility API malfunction detected: \ \(source ?? ""). @@ -394,9 +471,9 @@ public final class XcodeInspector: ObservableObject { } else { Logger.service.info(message) } - if let activeXcode { + if let activeXcode = await activeXcode { lastRecoveryFromAccessibilityMalfunctioningTimeStamp = Date() - setActiveXcode(activeXcode) + await setActiveXcode(activeXcode) activeXcode.observeAXNotifications() } } diff --git a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift index d2506822..f23087cc 100644 --- a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift @@ -4,8 +4,9 @@ import AXExtension import Combine import Foundation import Logger +import Perception -public class XcodeWindowInspector: ObservableObject { +public class XcodeWindowInspector { public let uiElement: AXUIElement init(uiElement: AXUIElement) { @@ -14,17 +15,23 @@ public class XcodeWindowInspector: ObservableObject { } } -public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { +@XcodeInspectorActor +@Perceptible +public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector, Sendable { let app: NSRunningApplication - @Published public internal(set) var documentURL: URL = .init(fileURLWithPath: "/") - @Published public internal(set) var workspaceURL: URL = .init(fileURLWithPath: "/") - @Published public internal(set) var projectRootURL: URL = .init(fileURLWithPath: "/") - private var focusedElementChangedTask: Task? + @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 { @XcodeInspectorActor in updateURLs() } + Task { @MainActor in updateURLs() } } + @MainActor public init( app: NSRunningApplication, uiElement: AXUIElement, @@ -33,14 +40,14 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { self.app = app super.init(uiElement: uiElement) - focusedElementChangedTask = Task { [weak self, axNotifications] in - await self?.updateURLs() + 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 self?.documentURL == .init(fileURLWithPath: "/") { + if await self?.documentURL == .init(fileURLWithPath: "/") { await self?.updateURLs() } } @@ -60,32 +67,26 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { } } - @XcodeInspectorActor + @MainActor func updateURLs() { let documentURL = Self.extractDocumentURL(windowElement: uiElement) if let documentURL { - Task { @MainActor in - self.documentURL = documentURL - } + self.documentURL = documentURL } let workspaceURL = Self.extractWorkspaceURL(windowElement: uiElement) if let workspaceURL { - Task { @MainActor in - self.workspaceURL = workspaceURL - } + self.workspaceURL = workspaceURL } let projectURL = Self.extractProjectURL( workspaceURL: workspaceURL, documentURL: documentURL ) if let projectURL { - Task { @MainActor in - self.projectRootURL = projectURL - } + projectRootURL = projectURL } } - static func extractDocumentURL( + nonisolated static func extractDocumentURL( windowElement: AXUIElement ) -> URL? { // fetch file path of the frontmost window of Xcode through Accessibility API. @@ -100,7 +101,7 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { return nil } - static func extractWorkspaceURL( + nonisolated static func extractWorkspaceURL( windowElement: AXUIElement ) -> URL? { for child in windowElement.children { @@ -114,7 +115,7 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { return nil } - public static func extractProjectURL( + public nonisolated static func extractProjectURL( workspaceURL: URL?, documentURL: URL? ) -> URL? { @@ -142,8 +143,8 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { return lastGitDirectoryURL ?? firstDirectoryURL ?? workspaceURL } - - static func adjustFileURL(_ url: URL) -> URL { + + nonisolated static func adjustFileURL(_ url: URL) -> URL { if url.pathExtension == "playground", FileManager.default.fileIsDirectory(atPath: url.path) { diff --git a/Version.xcconfig b/Version.xcconfig index 1fa51818..3235a1bd 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,4 +1,4 @@ -APP_VERSION = 0.35.10 -APP_BUILD = 466 +APP_VERSION = 0.35.11 +APP_BUILD = 470 RELEASE_CHANNEL = RELEASE_NUMBER = 1