Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Copilot for Xcode.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -166,6 +167,7 @@
C87B03A8293B262600C77EAE /* NextSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextSuggestionCommand.swift; sourceTree = "<group>"; };
C87B03AA293B262E00C77EAE /* PreviousSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviousSuggestionCommand.swift; sourceTree = "<group>"; };
C887BC832965D96000931567 /* DEVELOPMENT.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = DEVELOPMENT.md; sourceTree = "<group>"; };
C89E75C22A46FB32000DD64F /* AppDelegate+Menu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+Menu.swift"; sourceTree = "<group>"; };
C8A3AE512A2883430046E809 /* Python.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = Python.xcframework; sourceTree = "<group>"; };
C8A3AE582A2885A70046E809 /* InitializePython.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InitializePython.swift; sourceTree = "<group>"; };
C8A3AE5A2A288AF90046E809 /* site-packages */ = {isa = PBXFileReference; lastKnownFileType = folder; path = "site-packages"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
);
Expand Down
61 changes: 47 additions & 14 deletions Core/Sources/AXNotificationStream/AXNotificationStream.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import AppKit
import ApplicationServices
import Foundation
import Logger

public final class AXNotificationStream: AsyncSequence {
public typealias Stream = AsyncStream<Element>
Expand All @@ -18,7 +19,7 @@ public final class AXNotificationStream: AsyncSequence {
deinit {
continuation.finish()
}

public convenience init(
app: NSRunningApplication,
element: AXUIElement? = nil,
Expand Down Expand Up @@ -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)
}
}
}
}

4 changes: 4 additions & 0 deletions Core/Sources/HostApp/DebugView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ final class DebugSettings: ObservableObject {
@AppStorage(\.triggerActionWithAccessibilityAPI) var triggerActionWithAccessibilityAPI
@AppStorage(\.alwaysAcceptSuggestionWithAccessibilityAPI)
var alwaysAcceptSuggestionWithAccessibilityAPI
@AppStorage(\.enableXcodeInspectorDebugMenu) var enableXcodeInspectorDebugMenu
init() {}
}

Expand Down Expand Up @@ -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()
}
Expand Down
2 changes: 1 addition & 1 deletion Core/Sources/Service/ScheduledCleaner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
35 changes: 25 additions & 10 deletions Core/Sources/XcodeInspector/XcodeInspector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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?
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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,
Expand All @@ -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)
}
Expand Down
151 changes: 151 additions & 0 deletions ExtensionService/AppDelegate+Menu.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading