diff --git a/Copilot for Xcode.xcodeproj/project.pbxproj b/Copilot for Xcode.xcodeproj/project.pbxproj index b83f3f4d..3d0f22db 100644 --- a/Copilot for Xcode.xcodeproj/project.pbxproj +++ b/Copilot for Xcode.xcodeproj/project.pbxproj @@ -38,6 +38,7 @@ C87B03AC293B2CF300C77EAE /* XcodeKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C81458902939EFDC00135263 /* XcodeKit.framework */; }; C87B03AD293B2CF300C77EAE /* XcodeKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = C81458902939EFDC00135263 /* XcodeKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; C882175C294187EF00A22FD3 /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = C882175B294187EF00A22FD3 /* Client */; }; + C89E75C32A46FB32000DD64F /* AppDelegate+Menu.swift in Sources */ = {isa = PBXBuildFile; fileRef = C89E75C22A46FB32000DD64F /* AppDelegate+Menu.swift */; }; C8A3AE592A2885A70046E809 /* InitializePython.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8A3AE582A2885A70046E809 /* InitializePython.swift */; }; C8C8B60929AFA35F00034BEE /* CopilotForXcodeExtensionService.app in Embed XPCService */ = {isa = PBXBuildFile; fileRef = C861E60E2994F6070056CB02 /* CopilotForXcodeExtensionService.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; C8DCF00029CE11D500FDDDD7 /* ChatWithSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8DCEFFF29CE11D500FDDDD7 /* ChatWithSelection.swift */; }; @@ -166,6 +167,7 @@ C87B03A8293B262600C77EAE /* NextSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextSuggestionCommand.swift; sourceTree = ""; }; C87B03AA293B262E00C77EAE /* PreviousSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviousSuggestionCommand.swift; sourceTree = ""; }; C887BC832965D96000931567 /* DEVELOPMENT.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = DEVELOPMENT.md; sourceTree = ""; }; + C89E75C22A46FB32000DD64F /* AppDelegate+Menu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+Menu.swift"; sourceTree = ""; }; C8A3AE512A2883430046E809 /* Python.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = Python.xcframework; sourceTree = ""; }; C8A3AE582A2885A70046E809 /* InitializePython.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InitializePython.swift; sourceTree = ""; }; C8A3AE5A2A288AF90046E809 /* site-packages */ = {isa = PBXFileReference; lastKnownFileType = folder; path = "site-packages"; sourceTree = ""; }; @@ -322,6 +324,7 @@ C81291D92994FE7900196E12 /* Info.plist */, C861E61F2994F6390056CB02 /* ServiceDelegate.swift */, C861E6102994F6070056CB02 /* AppDelegate.swift */, + C89E75C22A46FB32000DD64F /* AppDelegate+Menu.swift */, C8A3AE582A2885A70046E809 /* InitializePython.swift */, C81291D52994FE6900196E12 /* Main.storyboard */, C861E6142994F6080056CB02 /* Assets.xcassets */, @@ -583,6 +586,7 @@ buildActionMask = 2147483647; files = ( C8A3AE592A2885A70046E809 /* InitializePython.swift in Sources */, + C89E75C32A46FB32000DD64F /* AppDelegate+Menu.swift in Sources */, C861E6202994F63A0056CB02 /* ServiceDelegate.swift in Sources */, C861E6112994F6070056CB02 /* AppDelegate.swift in Sources */, ); diff --git a/Core/Sources/AXNotificationStream/AXNotificationStream.swift b/Core/Sources/AXNotificationStream/AXNotificationStream.swift index f0a0387b..ab338be8 100644 --- a/Core/Sources/AXNotificationStream/AXNotificationStream.swift +++ b/Core/Sources/AXNotificationStream/AXNotificationStream.swift @@ -1,6 +1,7 @@ import AppKit import ApplicationServices import Foundation +import Logger public final class AXNotificationStream: AsyncSequence { public typealias Stream = AsyncStream @@ -18,7 +19,7 @@ public final class AXNotificationStream: AsyncSequence { deinit { continuation.finish() } - + public convenience init( app: NSRunningApplication, element: AXUIElement? = nil, @@ -72,24 +73,56 @@ public final class AXNotificationStream: AsyncSequence { .commonModes ) } - - Task { - for name in notificationNames { - var error = AXError.cannotComplete - var retryCount = 0 - while error == AXError.cannotComplete, retryCount < 5 { - error = AXObserverAddNotification(observer, observingElement, name as CFString, &continuation) - if error == .cannotComplete { - try await Task.sleep(nanoseconds: 1_000_000_000) - } - retryCount += 1 - } - } + + Task { [weak self] in CFRunLoopAddSource( CFRunLoopGetMain(), AXObserverGetRunLoopSource(observer), .commonModes ) + var pendingRegistrationNames = Set(notificationNames) + var retry = 0 + while !pendingRegistrationNames.isEmpty, retry < 100 { + guard let self else { return } + retry += 1 + for name in notificationNames { + let e = AXObserverAddNotification( + observer, + observingElement, + name as CFString, + &self.continuation + ) + switch e { + case .success: + pendingRegistrationNames.remove(name) + case .actionUnsupported: + Logger.service.error("AXObserver: Action unsupported: \(name)") + pendingRegistrationNames.remove(name) + case .apiDisabled: + Logger.service.error("AXObserver: Accessibility API disabled, will try again later") + retry -= 1 + case .invalidUIElement: + Logger.service.error("AXObserver: Invalid UI element") + pendingRegistrationNames.remove(name) + case .invalidUIElementObserver: + Logger.service.error("AXObserver: Invalid UI element observer") + pendingRegistrationNames.remove(name) + case .cannotComplete: + Logger.service + .error("AXObserver: Failed to observe \(name), will try again later") + case .notificationUnsupported: + Logger.service.error("AXObserver: Notification unsupported: \(name)") + pendingRegistrationNames.remove(name) + case .notificationAlreadyRegistered: + pendingRegistrationNames.remove(name) + default: + Logger.service + .error("AXObserver: Unrecognized error \(e) when registering \(name), will try again later") + } + } + try await Task.sleep(nanoseconds: 1_500_000_000) + } } } } + diff --git a/Core/Sources/HostApp/DebugView.swift b/Core/Sources/HostApp/DebugView.swift index 6da0793e..59afa477 100644 --- a/Core/Sources/HostApp/DebugView.swift +++ b/Core/Sources/HostApp/DebugView.swift @@ -10,6 +10,7 @@ final class DebugSettings: ObservableObject { @AppStorage(\.triggerActionWithAccessibilityAPI) var triggerActionWithAccessibilityAPI @AppStorage(\.alwaysAcceptSuggestionWithAccessibilityAPI) var alwaysAcceptSuggestionWithAccessibilityAPI + @AppStorage(\.enableXcodeInspectorDebugMenu) var enableXcodeInspectorDebugMenu init() {} } @@ -40,6 +41,9 @@ struct DebugSettingsView: View { Toggle(isOn: $settings.alwaysAcceptSuggestionWithAccessibilityAPI) { Text("Always accept suggestion with AccessibilityAPI") } + Toggle(isOn: $settings.enableXcodeInspectorDebugMenu) { + Text("Enable Xcode inspector debug menu") + } } .padding() } diff --git a/Core/Sources/Service/ScheduledCleaner.swift b/Core/Sources/Service/ScheduledCleaner.swift index c6b9a832..28d51d68 100644 --- a/Core/Sources/Service/ScheduledCleaner.swift +++ b/Core/Sources/Service/ScheduledCleaner.swift @@ -34,7 +34,7 @@ public final class ScheduledCleaner { XcodeAppInstanceInspector.WorkspaceInfo ]() ) { result, xcode in - let infos = xcode.workspaces + let infos = xcode.realtimeWorkspaces for (id, info) in infos { if let existed = result[id] { result[id] = existed.combined(with: info) diff --git a/Core/Sources/XcodeInspector/XcodeInspector.swift b/Core/Sources/XcodeInspector/XcodeInspector.swift index 65e729f4..f8a5d788 100644 --- a/Core/Sources/XcodeInspector/XcodeInspector.swift +++ b/Core/Sources/XcodeInspector/XcodeInspector.swift @@ -37,7 +37,7 @@ public final class XcodeInspector: ObservableObject { if let activeXcode { setActiveXcode(activeXcode) } - + let sequence = NSWorkspace.shared.notificationCenter .notifications(named: NSWorkspace.didActivateApplicationNotification) for await notification in sequence { @@ -90,7 +90,7 @@ public final class XcodeInspector: ObservableObject { @MainActor func setActiveXcode(_ xcode: XcodeAppInstanceInspector) { xcode.refresh() - + for task in activeXcodeObservations { task.cancel() } for cancellable in activeXcodeCancellable { cancellable.cancel() } activeXcodeObservations.removeAll() @@ -178,6 +178,10 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { @Published public var documentURL: URL = .init(fileURLWithPath: "/") @Published public var projectURL: URL = .init(fileURLWithPath: "/") @Published public var workspaces = [WorkspaceIdentifier: WorkspaceInfo]() + public var realtimeWorkspaces: [WorkspaceIdentifier: WorkspaceInfo] { + Self.fetchWorkspaceInfo(runningApplication) + } + @Published public private(set) var completionPanel: AXUIElement? var _version: String? @@ -211,7 +215,15 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { super.init(runningApplication: runningApplication) observeFocusedWindow() - observe() + observeAXNotifications() + + Task { + 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() + } + } } func observeFocusedWindow() { @@ -246,16 +258,19 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { focusedWindow = nil } } - + func refresh() { - (focusedWindow as? WorkspaceXcodeWindowInspector)?.refresh() - observe() + if let focusedWindow = focusedWindow as? WorkspaceXcodeWindowInspector { + focusedWindow.refresh() + } else { + observeFocusedWindow() + } } - - func observe() { + + func observeAXNotifications() { longRunningTasks.forEach { $0.cancel() } longRunningTasks = [] - + let focusedWindowChanged = Task { let notification = AXNotificationStream( app: runningApplication, @@ -277,7 +292,7 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { kAXApplicationDeactivatedNotification ) if #available(macOS 13.0, *) { - for await _ in notification.debounce(for: .seconds(5)) { + for await _ in notification.debounce(for: .seconds(2)) { try Task.checkCancellation() workspaces = Self.fetchWorkspaceInfo(runningApplication) } diff --git a/ExtensionService/AppDelegate+Menu.swift b/ExtensionService/AppDelegate+Menu.swift new file mode 100644 index 00000000..a3a26425 --- /dev/null +++ b/ExtensionService/AppDelegate+Menu.swift @@ -0,0 +1,151 @@ +import AppKit +import Foundation +import Preferences +import XcodeInspector + +extension AppDelegate { + fileprivate var statusBarMenuIdentifier: NSUserInterfaceItemIdentifier { + .init("statusBarMenu") + } + + fileprivate var xcodeInspectorDebugMenuIdentifier: NSUserInterfaceItemIdentifier { + .init("xcodeInspectorDebugMenu") + } + + @objc func buildStatusBarMenu() { + let statusBar = NSStatusBar.system + statusBarItem = statusBar.statusItem( + withLength: NSStatusItem.squareLength + ) + statusBarItem.button?.image = NSImage(named: "MenuBarIcon") + + let statusBarMenu = NSMenu(title: "Status Bar Menu") + statusBarMenu.identifier = statusBarMenuIdentifier + statusBarItem.menu = statusBarMenu + + let hostAppName = Bundle.main.object(forInfoDictionaryKey: "HOST_APP_NAME") as? String + ?? "Copilot for Xcode" + + let copilotName = NSMenuItem( + title: hostAppName, + action: nil, + keyEquivalent: "" + ) + + let checkForUpdate = NSMenuItem( + title: "Check for Updates", + action: #selector(checkForUpdate), + keyEquivalent: "" + ) + + let openCopilotForXcode = NSMenuItem( + title: "Open \(hostAppName)", + action: #selector(openCopilotForXcode), + keyEquivalent: "" + ) + + let openGlobalChat = NSMenuItem( + title: "Open Chat", + action: #selector(openGlobalChat), + keyEquivalent: "" + ) + + let xcodeInspectorDebug = NSMenuItem( + title: "Xcode Inspector Debug", + action: nil, + keyEquivalent: "" + ) + + let xcodeInspectorDebugMenu = NSMenu(title: "Xcode Inspector Debug") + xcodeInspectorDebugMenu.identifier = xcodeInspectorDebugMenuIdentifier + xcodeInspectorDebug.submenu = xcodeInspectorDebugMenu + xcodeInspectorDebug.isHidden = false + + let quitItem = NSMenuItem( + title: "Quit", + action: #selector(quit), + keyEquivalent: "" + ) + quitItem.target = self + + statusBarMenu.addItem(copilotName) + statusBarMenu.addItem(openCopilotForXcode) + statusBarMenu.addItem(checkForUpdate) + statusBarMenu.addItem(.separator()) + statusBarMenu.addItem(openGlobalChat) + statusBarMenu.addItem(.separator()) + statusBarMenu.addItem(xcodeInspectorDebug) + statusBarMenu.addItem(quitItem) + + statusBarMenu.delegate = self + xcodeInspectorDebugMenu.delegate = self + } +} + +extension AppDelegate: NSMenuDelegate { + func menuWillOpen(_ menu: NSMenu) { + switch menu.identifier { + case statusBarMenuIdentifier: + if let xcodeInspectorDebug = menu.items.first(where: { item in + item.submenu?.identifier == xcodeInspectorDebugMenuIdentifier + }) { + xcodeInspectorDebug.isHidden = !UserDefaults.shared + .value(for: \.enableXcodeInspectorDebugMenu) + } + case xcodeInspectorDebugMenuIdentifier: + let inspector = XcodeInspector.shared + menu.items.removeAll() + menu.items.append(.text("Active Project: \(inspector.activeProjectURL)")) + menu.items.append(.text("Active Document: \(inspector.activeDocumentURL)")) + for xcode in inspector.xcodes { + let item = NSMenuItem( + title: "Xcode \(xcode.runningApplication.processIdentifier)", + action: nil, + keyEquivalent: "" + ) + menu.addItem(item) + let xcodeMenu = NSMenu() + item.submenu = xcodeMenu + xcodeMenu.items.append(.text("Is Active: \(xcode.isActive)")) + xcodeMenu.items.append(.text("Active Project: \(xcode.projectURL)")) + xcodeMenu.items.append(.text("Active Document: \(xcode.documentURL)")) + + for (key, workspace) in xcode.realtimeWorkspaces { + let workspaceItem = NSMenuItem( + title: "Workspace \(key)", + action: nil, + keyEquivalent: "" + ) + xcodeMenu.items.append(workspaceItem) + let workspaceMenu = NSMenu() + workspaceItem.submenu = workspaceMenu + let tabsItem = NSMenuItem( + title: "Tabs", + action: nil, + keyEquivalent: "" + ) + workspaceMenu.addItem(tabsItem) + let tabsMenu = NSMenu() + tabsItem.submenu = tabsMenu + for tab in workspace.tabs { + tabsMenu.addItem(.text(tab)) + } + } + } + default: + break + } + } +} + +private extension NSMenuItem { + static func text(_ text: String) -> NSMenuItem { + let item = NSMenuItem( + title: text, + action: nil, + keyEquivalent: "" + ) + item.isEnabled = false + return item + } +} diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift index 2e9780a2..d7bed6bb 100644 --- a/ExtensionService/AppDelegate.swift +++ b/ExtensionService/AppDelegate.swift @@ -1,4 +1,4 @@ -import AppKit + import Environment import FileChangeChecker import LaunchAgentManager @@ -20,9 +20,9 @@ let serviceIdentifier = bundleIdentifierBase + ".ExtensionService" @main class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { let scheduledCleaner = ScheduledCleaner() - private var statusBarItem: NSStatusItem! - private var xpcListener: (NSXPCListener, ServiceDelegate)? - private let updateChecker = + var statusBarItem: NSStatusItem! + var xpcListener: (NSXPCListener, ServiceDelegate)? + let updateChecker = UpdateChecker( hostBundle: locateHostBundleURL(url: Bundle.main.bundleURL) .flatMap(Bundle.init(url:)) @@ -53,59 +53,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } } - @objc private func buildStatusBarMenu() { - let statusBar = NSStatusBar.system - statusBarItem = statusBar.statusItem( - withLength: NSStatusItem.squareLength - ) - statusBarItem.button?.image = NSImage(named: "MenuBarIcon") - - let statusBarMenu = NSMenu(title: "Status Bar Menu") - statusBarItem.menu = statusBarMenu - - let hostAppName = Bundle.main.object(forInfoDictionaryKey: "HOST_APP_NAME") as? String - ?? "Copilot for Xcode" - - let copilotName = NSMenuItem( - title: hostAppName, - action: nil, - keyEquivalent: "" - ) - - let checkForUpdate = NSMenuItem( - title: "Check for Updates", - action: #selector(checkForUpdate), - keyEquivalent: "" - ) - - let openCopilotForXcode = NSMenuItem( - title: "Open \(hostAppName)", - action: #selector(openCopilotForXcode), - keyEquivalent: "" - ) - - let openGlobalChat = NSMenuItem( - title: "Open Chat", - action: #selector(openGlobalChat), - keyEquivalent: "" - ) - - let quitItem = NSMenuItem( - title: "Quit", - action: #selector(quit), - keyEquivalent: "" - ) - quitItem.target = self - - statusBarMenu.addItem(copilotName) - statusBarMenu.addItem(openCopilotForXcode) - statusBarMenu.addItem(checkForUpdate) - statusBarMenu.addItem(.separator()) - statusBarMenu.addItem(openGlobalChat) - statusBarMenu.addItem(.separator()) - statusBarMenu.addItem(quitItem) - } - @objc func quit() { Task { @MainActor in await scheduledCleaner.closeAllChildProcesses() diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index 95adc247..5dee92c5 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -361,5 +361,9 @@ public extension UserDefaultPreferenceKeys { var animationCCrashSuggestion: FeatureFlag { .init(defaultValue: true, key: "FeatureFlag-AnimationCCrashSuggestion") } + + var enableXcodeInspectorDebugMenu: FeatureFlag { + .init(defaultValue: false, key: "FeatureFlag-EnableXcodeInspectorDebugMenu") + } } diff --git a/Version.xcconfig b/Version.xcconfig index 3a637c79..154c9b82 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,2 +1,2 @@ -APP_VERSION = 0.19.0 -APP_BUILD = 190 +APP_VERSION = 0.19.1 +APP_BUILD = 191 diff --git a/appcast.xml b/appcast.xml index 1d448803..03a63913 100644 --- a/appcast.xml +++ b/appcast.xml @@ -3,6 +3,18 @@ Copilot for Xcode + + 0.19.1 + Sat, 24 Jun 2023 22:34:24 +0800 + 191 + 0.19.1 + 12.0 + + https://github.com/intitni/CopilotForXcode/releases/tag/0.19.1 + + + + 0.19.0 Sat, 24 Jun 2023 00:44:54 +0800