diff --git a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved index 31438e5..71d9e18 100644 --- a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/devm33/CGEventOverride", "state" : { - "revision" : "571d36d63e68fac30e4a350600cd186697936f74", - "version" : "1.2.3" + "branch" : "devm33/fix-stale-AXIsProcessTrusted", + "revision" : "06a9bf1f8f8d47cca221344101cc0274f04cc513" } }, { diff --git a/Core/Package.swift b/Core/Package.swift index 1d08edb..385746c 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -50,7 +50,7 @@ let package = Package( // quick hack to support custom UserDefaults // https://github.com/sindresorhus/KeyboardShortcuts .package(url: "https://github.com/devm33/KeyboardShortcuts", branch: "main"), - .package(url: "https://github.com/devm33/CGEventOverride", from: "1.2.1"), + .package(url: "https://github.com/devm33/CGEventOverride", branch: "devm33/fix-stale-AXIsProcessTrusted"), .package(url: "https://github.com/devm33/Highlightr", branch: "master"), ], targets: [ @@ -83,6 +83,7 @@ let package = Package( .product(name: "UserDefaultsObserver", package: "Tool"), .product(name: "AppMonitoring", package: "Tool"), .product(name: "SuggestionBasic", package: "Tool"), + .product(name: "Status", package: "Tool"), .product(name: "ChatTab", package: "Tool"), .product(name: "Logger", package: "Tool"), .product(name: "ChatAPIService", package: "Tool"), diff --git a/Core/Sources/HostApp/General.swift b/Core/Sources/HostApp/General.swift index 98578f6..5bcfe18 100644 --- a/Core/Sources/HostApp/General.swift +++ b/Core/Sources/HostApp/General.swift @@ -2,6 +2,7 @@ import Client import ComposableArchitecture import Foundation import LaunchAgentManager +import Status import SwiftUI import XPCShared import Logger @@ -11,7 +12,7 @@ struct General { @ObservableState struct State: Equatable { var xpcServiceVersion: String? - var isAccessibilityPermissionGranted: Bool? + var isAccessibilityPermissionGranted: ObservedAXStatus = .unknown var isReloading = false } @@ -20,7 +21,7 @@ struct General { case setupLaunchAgentIfNeeded case openExtensionManager case reloadStatus - case finishReloading(xpcServiceVersion: String, permissionGranted: Bool) + case finishReloading(xpcServiceVersion: String, permissionGranted: ObservedAXStatus) case failedReloading case retryReloading } @@ -35,7 +36,7 @@ struct General { case .appear: return .run { send in await send(.setupLaunchAgentIfNeeded) - for await _ in DistributedNotificationCenter.default().notifications(named: NSNotification.Name("com.apple.accessibility.api")) { + for await _ in DistributedNotificationCenter.default().notifications(named: .serviceStatusDidChange) { await send(.reloadStatus) } } diff --git a/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift b/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift index 828f492..8d0b51c 100644 --- a/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift +++ b/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift @@ -1,20 +1,6 @@ import ComposableArchitecture import SwiftUI -struct ActivityIndicatorView: NSViewRepresentable { - func makeNSView(context _: Context) -> NSProgressIndicator { - let progressIndicator = NSProgressIndicator() - progressIndicator.style = .spinning - progressIndicator.controlSize = .small - progressIndicator.startAnimation(nil) - return progressIndicator - } - - func updateNSView(_: NSProgressIndicator, context _: Context) { - // No-op - } -} - struct CopilotConnectionView: View { @AppStorage("username") var username: String = "" @Environment(\.toast) var toast @@ -38,6 +24,9 @@ struct CopilotConnectionView: View { title: "GitHub Account Status Permissions", subtitle: "GitHub Connection: \(viewModel.status?.description ?? "Loading...")" ) { + if viewModel.isRunningAction || waitingForSignIn { + ProgressView().controlSize(.small) + } Button("Refresh Connection") { viewModel.checkStatus() } @@ -72,9 +61,6 @@ struct CopilotConnectionView: View { viewModel.isSignInAlertPresented = false } } - if viewModel.isRunningAction || waitingForSignIn { - ActivityIndicatorView() - } } } diff --git a/Core/Sources/HostApp/GeneralSettings/GeneralSettingsView.swift b/Core/Sources/HostApp/GeneralSettings/GeneralSettingsView.swift index 8511859..2ce752a 100644 --- a/Core/Sources/HostApp/GeneralSettings/GeneralSettingsView.swift +++ b/Core/Sources/HostApp/GeneralSettings/GeneralSettingsView.swift @@ -9,8 +9,14 @@ struct GeneralSettingsView: View { let store: StoreOf var accessibilityPermissionSubtitle: String { - guard let granted = store.isAccessibilityPermissionGranted else { return "Loading..." } - return granted ? "Granted" : "Not Granted. Required to run. Click to open System Preferences." + switch store.isAccessibilityPermissionGranted { + case .granted: + return "Granted" + case .notGranted: + return "Not Granted. Required to run. Click to open System Preferences." + case .unknown: + return "" + } } var body: some View { diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift index 71225aa..e285ba5 100644 --- a/Core/Sources/Service/RealtimeSuggestionController.swift +++ b/Core/Sources/Service/RealtimeSuggestionController.swift @@ -6,6 +6,7 @@ import Combine import Foundation import Logger import Preferences +import Status import QuartzCore import Workspace import XcodeInspector @@ -124,10 +125,12 @@ public actor RealtimeSuggestionController { do { try await XcodeInspector.shared.safe.latestActiveXcode? .triggerCopilotCommand(name: "Sync Text Settings") + await Status.shared.updateExtensionStatus(.succeeded) } catch { if filespace.codeMetadata.uti?.isEmpty ?? true { filespace.codeMetadata.uti = nil } + await Status.shared.updateExtensionStatus(.failed) } } } diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift index 3fb9afa..7fcc593 100644 --- a/Core/Sources/Service/Service.swift +++ b/Core/Sources/Service/Service.swift @@ -32,6 +32,7 @@ public final class Service { let globalShortcutManager: GlobalShortcutManager let keyBindingManager: KeyBindingManager let xcodeThemeController: XcodeThemeController = .init() + public var markAsProcessing: (Bool) -> Void = { _ in } @Dependency(\.toast) var toast var cancellable = Set() diff --git a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift index 29780d4..44d6476 100644 --- a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift +++ b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift @@ -30,6 +30,7 @@ struct PresentInWindowSuggestionPresenter { Task { @MainActor in let controller = Service.shared.guiController.widgetController controller.markAsProcessing(isProcessing) + Service.shared.markAsProcessing(isProcessing) } } diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift index 2228a1d..a2ef2ca 100644 --- a/Core/Sources/Service/XPCService.swift +++ b/Core/Sources/Service/XPCService.swift @@ -4,6 +4,7 @@ import GitHubCopilotService import LanguageServerProtocol import Logger import Preferences +import Status import XPCShared public class XPCService: NSObject, XPCServiceProtocol { @@ -16,8 +17,10 @@ public class XPCService: NSObject, XPCServiceProtocol { ) } - public func getXPCServiceAccessibilityPermission(withReply reply: @escaping (Bool) -> Void) { - reply(AXIsProcessTrusted()) + public func getXPCServiceAccessibilityPermission(withReply reply: @escaping (ObservedAXStatus) -> Void) { + Task { + reply(await Status.shared.getAXStatus()) + } } // MARK: - Suggestion diff --git a/ExtensionService/AppDelegate+Menu.swift b/ExtensionService/AppDelegate+Menu.swift index 1b34f72..0c9da82 100644 --- a/ExtensionService/AppDelegate+Menu.swift +++ b/ExtensionService/AppDelegate+Menu.swift @@ -1,6 +1,7 @@ import AppKit import Foundation import Preferences +import Status import SuggestionBasic import XcodeInspector import Logger @@ -14,10 +15,6 @@ extension AppDelegate { .init("xcodeInspectorDebugMenu") } - fileprivate var accessibilityAPIPermissionMenuItemIdentifier: NSUserInterfaceItemIdentifier { - .init("accessibilityAPIPermissionMenuItem") - } - fileprivate var sourceEditorDebugMenu: NSUserInterfaceItemIdentifier { .init("sourceEditorDebugMenu") } @@ -72,12 +69,12 @@ extension AppDelegate { xcodeInspectorDebug.submenu = xcodeInspectorDebugMenu xcodeInspectorDebug.isHidden = false - let accessibilityAPIPermission = NSMenuItem( - title: "Accessibility Permission: N/A", - action: nil, + statusMenuItem = NSMenuItem( + title: "", + action: #selector(openStatusLink), keyEquivalent: "" ) - accessibilityAPIPermission.identifier = accessibilityAPIPermissionMenuItemIdentifier + statusMenuItem.isHidden = true let quitItem = NSMenuItem( title: "Quit", @@ -126,7 +123,7 @@ extension AppDelegate { statusBarMenu.addItem(toggleIgnoreLanguage) statusBarMenu.addItem(.separator()) statusBarMenu.addItem(copilotStatus) - statusBarMenu.addItem(accessibilityAPIPermission) + statusBarMenu.addItem(statusMenuItem) statusBarMenu.addItem(.separator()) statusBarMenu.addItem(openDocs) statusBarMenu.addItem(openForum) @@ -160,7 +157,7 @@ extension AppDelegate: NSMenuDelegate { item.identifier == toggleIgnoreLanguageMenuItemIdentifier }) { if let lang = DisabledLanguageList.shared.activeDocumentLanguage { - toggleLanguage.title = "\(DisabledLanguageList.shared.isEnabled(lang) ? "Disable" : "Enable") Completions For \(lang.rawValue)" + toggleLanguage.title = "\(DisabledLanguageList.shared.isEnabled(lang) ? "Disable" : "Enable") Completions for \(lang.rawValue)" toggleLanguage.action = #selector(toggleIgnoreLanguage) } else { toggleLanguage.title = "No Active Document" @@ -168,14 +165,6 @@ extension AppDelegate: NSMenuDelegate { } } - if let accessibilityAPIPermission = menu.items.first(where: { item in - item.identifier == accessibilityAPIPermissionMenuItemIdentifier - }) { - AXIsProcessTrusted() - accessibilityAPIPermission.title = - "Accessibility Permission: \(AXIsProcessTrusted() ? "Granted" : "Not Granted")" - } - statusChecker.updateStatusInBackground(notify: { (status: String, isOk: Bool) in if let statusItem = menu.items.first(where: { item in item.identifier == self.copilotStatusMenuItemIdentifier @@ -321,6 +310,15 @@ private extension AppDelegate { } } } + + @objc func openStatusLink() { + Task { + let status = await Status.shared.getStatus() + if let s = status.url, let url = URL(string: s) { + NSWorkspace.shared.open(url) + } + } + } } private extension NSMenuItem { diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift index 8b8304f..1f8ad9f 100644 --- a/ExtensionService/AppDelegate.swift +++ b/ExtensionService/AppDelegate.swift @@ -6,6 +6,7 @@ import Logger import Preferences import Service import ServiceManagement +import Status import SwiftUI import UpdateChecker import UserDefaultsObserver @@ -30,6 +31,7 @@ class ExtensionUpdateCheckerDelegate: UpdateCheckerDelegate { class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { let service = Service.shared var statusBarItem: NSStatusItem! + var statusMenuItem: NSMenuItem! var xpcController: XPCController? let updateChecker = UpdateChecker( @@ -39,21 +41,29 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { let statusChecker: AuthStatusChecker = AuthStatusChecker() var xpcExtensionService: XPCExtensionService? private var cancellables = Set() + private var progressView: NSProgressIndicator? + private var idleIcon = NSImage(named: "MenuBarIcon") func applicationDidFinishLaunching(_: Notification) { if ProcessInfo.processInfo.environment["IS_UNIT_TEST"] == "YES" { return } _ = XcodeInspector.shared + service.markAsProcessing = { [weak self] in + guard let self = self else { return } + self.markAsProcessing($0) + } service.start() AXIsProcessTrustedWithOptions([ kAXTrustedCheckOptionPrompt.takeRetainedValue() as NSString: true, ] as CFDictionary) setupQuitOnUpdate() setupQuitOnUserTerminated() - setupQuitOnFeatureFlag() xpcController = .init() Logger.service.info("XPC Service started.") NSApp.setActivationPolicy(.accessory) buildStatusBarMenu() + watchServiceStatus() + watchAXStatus() + updateStatusBarItem() // set the initial status } @objc func quit() { @@ -132,15 +142,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } } - func setupQuitOnFeatureFlag() { - FeatureFlagNotifierImpl.shared.featureFlagsDidChange.sink { [weak self] (flags) in - if flags.x != true { - Logger.service.info("Xcode feature flag not granted, quitting.") - self?.quit() - } - }.store(in: &cancellables) - } - func requestAccessoryAPIPermission() { AXIsProcessTrustedWithOptions([ kAXTrustedCheckOptionPrompt.takeRetainedValue() as NSString: true, @@ -161,6 +162,70 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { xpcExtensionService = service return service } + + func watchServiceStatus() { + let notifications = NotificationCenter.default.notifications(named: .serviceStatusDidChange) + Task { [weak self] in + for await _ in notifications { + guard let self else { return } + self.updateStatusBarItem() + } + } + } + + func watchAXStatus() { + let osNotifications = DistributedNotificationCenter.default().notifications(named: NSNotification.Name("com.apple.accessibility.api")) + Task { [weak self] in + for await _ in osNotifications { + guard let self else { return } + self.updateStatusBarItem() + } + } + } + + func updateStatusBarItem() { + Task { @MainActor in + let status = await Status.shared.getStatus() + let image = if status.system { + NSImage(systemSymbolName: status.icon, accessibilityDescription: nil) + } else { + NSImage(named: status.icon) + } + idleIcon = image + self.statusBarItem.button?.image = image + if let message = status.message { + // TODO switch to attributedTitle to enable line breaks and color. + self.statusMenuItem.title = message + self.statusMenuItem.isHidden = false + self.statusMenuItem.isEnabled = status.url != nil + } else { + self.statusMenuItem.isHidden = true + } + } + } + + func markAsProcessing(_ isProcessing: Bool) { + if !isProcessing { + // No longer in progress + progressView?.removeFromSuperview() + progressView = nil + statusBarItem.button?.image = idleIcon + return + } + if progressView != nil { + // Already in progress + return + } + let progress = NSProgressIndicator() + progress.style = .spinning + progress.sizeToFit() + progress.frame = statusBarItem.button?.bounds ?? .zero + progress.isIndeterminate = true + progress.startAnimation(nil) + statusBarItem.button?.addSubview(progress) + statusBarItem.button?.image = nil + progressView = progress + } } extension NSRunningApplication { diff --git a/ExtensionService/Assets.xcassets/WarningIcon.imageset/Contents.json b/ExtensionService/Assets.xcassets/WarningIcon.imageset/Contents.json new file mode 100644 index 0000000..ae91a65 --- /dev/null +++ b/ExtensionService/Assets.xcassets/WarningIcon.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images" : [ + { + "filename" : "copilot-warning-24.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "copilot-warning-48.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/ExtensionService/Assets.xcassets/WarningIcon.imageset/copilot-warning-24.png b/ExtensionService/Assets.xcassets/WarningIcon.imageset/copilot-warning-24.png new file mode 100644 index 0000000..f7dc5a8 Binary files /dev/null and b/ExtensionService/Assets.xcassets/WarningIcon.imageset/copilot-warning-24.png differ diff --git a/ExtensionService/Assets.xcassets/WarningIcon.imageset/copilot-warning-48.png b/ExtensionService/Assets.xcassets/WarningIcon.imageset/copilot-warning-48.png new file mode 100644 index 0000000..48254ca Binary files /dev/null and b/ExtensionService/Assets.xcassets/WarningIcon.imageset/copilot-warning-48.png differ diff --git a/Server/package-lock.json b/Server/package-lock.json index c3c586a..46be953 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -8,13 +8,13 @@ "name": "@github/copilot-xcode", "version": "0.0.1", "dependencies": { - "@github/copilot-language-server": "^1.241.0" + "@github/copilot-language-server": "^1.243.0" } }, "node_modules/@github/copilot-language-server": { - "version": "1.241.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.241.0.tgz", - "integrity": "sha512-jjyAc+vUgi9fgDQlXLVA4JvoMZOTZK6I5BgAm1hbfi93iSS8mZF4zGnikC9EfEuLg/anJdc1EMxdFQeBdUOC9A==", + "version": "1.243.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.243.0.tgz", + "integrity": "sha512-d0suQuTzzLAvG8KjdbcIQoeCxJfxF88L2fcvAiRXHCr2PAnFZYnNnLWa5qfr7IpC7a91SAGx5AibRuKTQZibTg==", "bin": { "copilot-language-server": "dist/language-server.js" } diff --git a/Server/package.json b/Server/package.json index 586ad87..63ff48b 100644 --- a/Server/package.json +++ b/Server/package.json @@ -4,6 +4,6 @@ "description": "Package for downloading @github/copilot-language-server", "private": true, "dependencies": { - "@github/copilot-language-server": "^1.241.0" + "@github/copilot-language-server": "^1.243.0" } } diff --git a/Tool/Package.swift b/Tool/Package.swift index 4051f8b..678bf82 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -17,6 +17,7 @@ let package = Package( .library(name: "SuggestionBasic", targets: ["SuggestionBasic"]), .library(name: "Toast", targets: ["Toast"]), .library(name: "SharedUIComponents", targets: ["SharedUIComponents"]), + .library(name: "Status", targets: ["Status"]), .library(name: "UserDefaultsObserver", targets: ["UserDefaultsObserver"]), .library(name: "Workspace", targets: ["Workspace", "WorkspaceSuggestionService"]), .library( @@ -68,7 +69,7 @@ let package = Package( targets: [ // MARK: - Helpers - .target(name: "XPCShared", dependencies: ["SuggestionBasic", "Logger"]), + .target(name: "XPCShared", dependencies: ["SuggestionBasic", "Logger", "Status"]), .target(name: "Configs"), @@ -128,6 +129,7 @@ let package = Package( dependencies: [ "Preferences", "Logger", + "Status", ] ), @@ -141,6 +143,7 @@ let package = Package( "Toast", "Preferences", "AsyncPassthroughSubject", + "Status", .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), ] ), @@ -206,6 +209,8 @@ let package = Package( // MARK: - Services + .target(name: "Status"), + .target(name: "SuggestionProvider", dependencies: [ "SuggestionBasic", "UserDefaultsObserver", diff --git a/Tool/Sources/AXNotificationStream/AXNotificationStream.swift b/Tool/Sources/AXNotificationStream/AXNotificationStream.swift index 89fca01..2a7fa16 100644 --- a/Tool/Sources/AXNotificationStream/AXNotificationStream.swift +++ b/Tool/Sources/AXNotificationStream/AXNotificationStream.swift @@ -3,6 +3,7 @@ import ApplicationServices import Foundation import Logger import Preferences +import Status public final class AXNotificationStream: AsyncSequence { public typealias Stream = AsyncStream @@ -125,6 +126,7 @@ public final class AXNotificationStream: AsyncSequence { switch e { case .success: pendingRegistrationNames.remove(name) + await Status.shared.updateAXStatus(.granted) case .actionUnsupported: Logger.service.error("AXObserver: Action unsupported: \(name)") pendingRegistrationNames.remove(name) @@ -132,6 +134,7 @@ public final class AXNotificationStream: AsyncSequence { Logger.service .error("AXObserver: Accessibility API disabled, will try again later") retry -= 1 + await Status.shared.updateAXStatus(.notGranted) case .invalidUIElement: Logger.service .error("AXObserver: Invalid UI element, notification name \(name)") diff --git a/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift b/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift index 6729787..f14572a 100644 --- a/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift +++ b/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift @@ -5,7 +5,6 @@ public struct FeatureFlags: Hashable, Codable { public var rt: Bool public var sn: Bool public var chat: Bool - public var x: Bool? public var xc: Bool? } diff --git a/Tool/Sources/Status/Status.swift b/Tool/Sources/Status/Status.swift new file mode 100644 index 0000000..1d97416 --- /dev/null +++ b/Tool/Sources/Status/Status.swift @@ -0,0 +1,116 @@ +import AppKit +import Foundation + +public enum ExtensionPermissionStatus { + case unknown + case succeeded + case failed +} + +@objc public enum ObservedAXStatus: Int { + case unknown = -1 + case granted = 1 + case notGranted = 0 +} + +public extension Notification.Name { + static let serviceStatusDidChange = Notification.Name("com.github.CopilotForXcode.serviceStatusDidChange") +} + +public struct StatusResponse { + public let icon: String + public let system: Bool // Temporary workaround for status images + public let message: String? + public let url: String? +} + +public final actor Status { + public static let shared = Status() + + private var extensionStatus: ExtensionPermissionStatus = .unknown + private var axStatus: ObservedAXStatus = .unknown + + private init() {} + + public func updateExtensionStatus(_ status: ExtensionPermissionStatus) { + guard status != extensionStatus else { return } + extensionStatus = status + broadcast() + } + + public func updateAXStatus(_ status: ObservedAXStatus) { + guard status != axStatus else { return } + axStatus = status + broadcast() + } + + public func getAXStatus() -> ObservedAXStatus { + // if Xcode is running, return the observed status + if isXcodeRunning() { + return axStatus + } else if AXIsProcessTrusted() { + // if Xcode is not running but AXIsProcessTrusted() is true, return granted + return .granted + } else { + // otherwise, return the last observed status, which may be unknown + return axStatus + } + } + + private func isXcodeRunning() -> Bool { + let xcode = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.dt.Xcode") + return !xcode.isEmpty + } + + public func getStatus() -> StatusResponse { + if extensionStatus == .failed { + // TODO differentiate between the permission not being granted and the + // extension just getting disabled by Xcode. + return .init( + icon: "exclamationmark.circle", + system: true, + message: """ + Extension is not enabled. Enable GitHub Copilot under Xcode + and then restart Xcode. + """, + url: "x-apple.systempreferences:com.apple.ExtensionsPreferences" + ) + } + + switch getAXStatus() { + case .granted: + return .init(icon: "MenuBarIcon", system: false, message: nil, url: nil) + case .notGranted: + return .init( + icon: "exclamationmark.circle", + system: true, + message: """ + Accessibility permission not granted. \ + Click to open System Preferences. + """, + url: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" + ) + case .unknown: + return .init( + icon: "exclamationmark.circle", + system: true, + message: """ + Accessibility permission not granted or Copilot restart needed. + """, + url: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" + ) + } + } + + private func broadcast() { + NotificationCenter.default.post( + name: .serviceStatusDidChange, + object: nil + ) + // Can remove DistributedNotificationCenter if the settings UI moves in-process + DistributedNotificationCenter.default().post( + name: .serviceStatusDidChange, + object: nil + ) + } +} diff --git a/Tool/Sources/XPCShared/XPCExtensionService.swift b/Tool/Sources/XPCShared/XPCExtensionService.swift index 7f395aa..3541ab6 100644 --- a/Tool/Sources/XPCShared/XPCExtensionService.swift +++ b/Tool/Sources/XPCShared/XPCExtensionService.swift @@ -1,5 +1,6 @@ import Foundation import Logger +import Status public enum XPCExtensionServiceError: Swift.Error, LocalizedError { case failedToGetServiceEndpoint @@ -48,7 +49,7 @@ public class XPCExtensionService { } } - public func getXPCServiceAccessibilityPermission() async throws -> Bool { + public func getXPCServiceAccessibilityPermission() async throws -> ObservedAXStatus { try await withXPCServiceConnected { service, continuation in service.getXPCServiceAccessibilityPermission { isGranted in diff --git a/Tool/Sources/XPCShared/XPCServiceProtocol.swift b/Tool/Sources/XPCShared/XPCServiceProtocol.swift index 9612057..c59d70b 100644 --- a/Tool/Sources/XPCShared/XPCServiceProtocol.swift +++ b/Tool/Sources/XPCShared/XPCServiceProtocol.swift @@ -1,4 +1,5 @@ import Foundation +import Status import SuggestionBasic @objc(XPCServiceProtocol) @@ -53,7 +54,7 @@ public protocol XPCServiceProtocol { ) func getXPCServiceVersion(withReply reply: @escaping (String, String) -> Void) - func getXPCServiceAccessibilityPermission(withReply reply: @escaping (Bool) -> Void) + func getXPCServiceAccessibilityPermission(withReply reply: @escaping (ObservedAXStatus) -> Void) func postNotification(name: String, withReply reply: @escaping () -> Void) func send(endpoint: String, requestBody: Data, reply: @escaping (Data?, Error?) -> Void) func quit(reply: @escaping () -> Void) diff --git a/Tool/Sources/XcodeInspector/SourceEditor.swift b/Tool/Sources/XcodeInspector/SourceEditor.swift index 09c7da0..953cee3 100644 --- a/Tool/Sources/XcodeInspector/SourceEditor.swift +++ b/Tool/Sources/XcodeInspector/SourceEditor.swift @@ -3,6 +3,7 @@ import AsyncPassthroughSubject import AXNotificationStream import Foundation import Logger +import Status import SuggestionBasic /// Representing a source editor inside Xcode. @@ -54,7 +55,7 @@ public class SourceEditor { /// - note: This method is expensive. It needs to convert index based ranges to line based /// ranges. public func getContent() -> Content { - let content = element.value + let content = getElementValueAndRecordStatus() let selectionRange = element.selectedTextRange let (lines, selections) = cache.get(content: content, selectedTextRange: selectionRange) @@ -73,6 +74,19 @@ public class SourceEditor { ) } + private func getElementValueAndRecordStatus() -> String { + do { + let value: String = try element.copyValue(key: kAXValueAttribute) + Task { await Status.shared.updateAXStatus(.granted) } + return value + } catch AXError.apiDisabled { + Task { await Status.shared.updateAXStatus(.notGranted) } + } catch { + // ignore + } + return "" + } + public init(runningApplication: NSRunningApplication, element: AXUIElement) { self.runningApplication = runningApplication self.element = element diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index 51defde..daa66c6 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -5,6 +5,7 @@ import Combine import Foundation import Logger import Preferences +import Status import SuggestionBasic import Toast @@ -285,7 +286,21 @@ public final class XcodeInspector: ObservableObject { let setFocusedElement = { @XcodeInspectorActor [weak self] in guard let self else { return } - focusedElement = xcode.appElement.focusedElement + + func getFocusedElementAndRecordStatus(_ element: AXUIElement) -> AXUIElement? { + do { + let focused: AXUIElement = try element.copyValue(key: kAXFocusedUIElementAttribute) + Task { await Status.shared.updateAXStatus(.granted) } + return focused + } catch AXError.apiDisabled { + Task { await Status.shared.updateAXStatus(.notGranted) } + } catch { + // ignore + } + return nil + } + + focusedElement = getFocusedElementAndRecordStatus(xcode.appElement) if let editorElement = focusedElement, editorElement.isSourceEditor { focusedEditor = .init( runningApplication: xcode.runningApplication,