diff --git a/Copilot for Xcode.xcodeproj/project.pbxproj b/Copilot for Xcode.xcodeproj/project.pbxproj index 20a790e..56e21c3 100644 --- a/Copilot for Xcode.xcodeproj/project.pbxproj +++ b/Copilot for Xcode.xcodeproj/project.pbxproj @@ -13,7 +13,6 @@ 3ABBEA2D2C8BA00B00C61D61 /* copilot-language-server in Resources */ = {isa = PBXBuildFile; fileRef = 3ABBEA282C8B9FE100C61D61 /* copilot-language-server */; }; 424ACA212CA4697200FA20F2 /* Credits.rtf in Resources */ = {isa = PBXBuildFile; fileRef = 424ACA202CA4697200FA20F2 /* Credits.rtf */; }; 427C63282C6E868B000E557C /* OpenSettingsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427C63272C6E868B000E557C /* OpenSettingsCommand.swift */; }; - 42888D512C66B10100DEF835 /* AuthStatusChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42888D502C66B10100DEF835 /* AuthStatusChecker.swift */; }; 5EC511E32C90CE7400632BAB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C8189B1D2938973000C9DCDA /* Assets.xcassets */; }; 5EC511E42C90CE9800632BAB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C8189B1D2938973000C9DCDA /* Assets.xcassets */; }; 5EC511E52C90CFD600632BAB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C861E6142994F6080056CB02 /* Assets.xcassets */; }; @@ -192,7 +191,6 @@ 3ABBEA2A2C8BA00300C61D61 /* copilot-language-server-arm64 */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; name = "copilot-language-server-arm64"; path = "Server/node_modules/@github/copilot-language-server/native/darwin-arm64/copilot-language-server-arm64"; sourceTree = SOURCE_ROOT; }; 424ACA202CA4697200FA20F2 /* Credits.rtf */ = {isa = PBXFileReference; lastKnownFileType = text.rtf; path = Credits.rtf; sourceTree = ""; }; 427C63272C6E868B000E557C /* OpenSettingsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenSettingsCommand.swift; sourceTree = ""; }; - 42888D502C66B10100DEF835 /* AuthStatusChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStatusChecker.swift; sourceTree = ""; }; C8009BFE2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleRealtimeSuggestionsCommand.swift; sourceTree = ""; }; C8009C022941C576007AA7E8 /* SyncTextSettingsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncTextSettingsCommand.swift; sourceTree = ""; }; C800DBB0294C624D00B04CAC /* PrefetchSuggestionsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefetchSuggestionsCommand.swift; sourceTree = ""; }; @@ -425,7 +423,6 @@ C81291D52994FE6900196E12 /* Main.storyboard */, C861E6142994F6080056CB02 /* Assets.xcassets */, C861E6192994F6080056CB02 /* ExtensionService.entitlements */, - 42888D502C66B10100DEF835 /* AuthStatusChecker.swift */, ); path = ExtensionService; sourceTree = ""; @@ -769,7 +766,6 @@ C89E75C32A46FB32000DD64F /* AppDelegate+Menu.swift in Sources */, C8738B712BE4F8B700609E7F /* XPCController.swift in Sources */, C861E6202994F63A0056CB02 /* ServiceDelegate.swift in Sources */, - 42888D512C66B10100DEF835 /* AuthStatusChecker.swift in Sources */, C861E6112994F6070056CB02 /* AppDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Core/Sources/HostApp/GitHubCopilotViewModel.swift b/Core/Sources/HostApp/GitHubCopilotViewModel.swift index 9f86034..978fe2a 100644 --- a/Core/Sources/HostApp/GitHubCopilotViewModel.swift +++ b/Core/Sources/HostApp/GitHubCopilotViewModel.swift @@ -1,5 +1,7 @@ +import Foundation import GitHubCopilotService import ComposableArchitecture +import Status import SwiftUI struct SignInResponse { @@ -79,6 +81,7 @@ class GitHubCopilotViewModel: ObservableObject { do { let service = try getGitHubCopilotAuthService() status = try await service.signOut() + broadcastStatusChange() } catch { toast(error.localizedDescription, .error) } @@ -118,6 +121,7 @@ class GitHubCopilotViewModel: ObservableObject { waitingForSignIn = false self.username = username self.status = status + broadcastStatusChange() } catch let error as GitHubCopilotError { if case .languageServerError(.timeout) = error { // TODO figure out how to extend the default timeout on a Chime LSP request @@ -131,4 +135,11 @@ class GitHubCopilotViewModel: ObservableObject { } } } + + func broadcastStatusChange() { + DistributedNotificationCenter.default().post( + name: .authStatusDidChange, + object: nil + ) + } } diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift index 7fcc593..3fb9afa 100644 --- a/Core/Sources/Service/Service.swift +++ b/Core/Sources/Service/Service.swift @@ -32,7 +32,6 @@ 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 44d6476..29780d4 100644 --- a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift +++ b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift @@ -30,7 +30,6 @@ struct PresentInWindowSuggestionPresenter { Task { @MainActor in let controller = Service.shared.guiController.widgetController controller.markAsProcessing(isProcessing) - Service.shared.markAsProcessing(isProcessing) } } diff --git a/Docs/downloaded-from-internet.png b/Docs/downloaded-from-internet.png deleted file mode 100644 index cd4b1ea..0000000 Binary files a/Docs/downloaded-from-internet.png and /dev/null differ diff --git a/ExtensionService/AppDelegate+Menu.swift b/ExtensionService/AppDelegate+Menu.swift index 0c9da82..9453d31 100644 --- a/ExtensionService/AppDelegate+Menu.swift +++ b/ExtensionService/AppDelegate+Menu.swift @@ -27,10 +27,6 @@ extension AppDelegate { .init("toggleIgnoreLanguageMenuItem") } - fileprivate var copilotStatusMenuItemIdentifier: NSUserInterfaceItemIdentifier { - .init("copilotStatusMenuItem") - } - @MainActor @objc func buildStatusBarMenu() { let statusBar = NSStatusBar.system @@ -52,7 +48,7 @@ extension AppDelegate { keyEquivalent: "" ) - let openCopilotForXcode = NSMenuItem( + let openCopilotForXcodeItem = NSMenuItem( title: "Open \(hostAppName) Settings", action: #selector(openCopilotForXcode), keyEquivalent: "" @@ -97,12 +93,11 @@ extension AppDelegate { ) toggleIgnoreLanguage.identifier = toggleIgnoreLanguageMenuItemIdentifier; - let copilotStatus = NSMenuItem( + authMenuItem = NSMenuItem( title: "Copilot Connection: Checking...", - action: nil, + action: #selector(openCopilotForXcode), keyEquivalent: "" ) - copilotStatus.identifier = copilotStatusMenuItemIdentifier let openDocs = NSMenuItem( title: "View Copilot Documentation...", @@ -116,13 +111,13 @@ extension AppDelegate { keyEquivalent: "" ) - statusBarMenu.addItem(openCopilotForXcode) + statusBarMenu.addItem(openCopilotForXcodeItem) statusBarMenu.addItem(.separator()) statusBarMenu.addItem(checkForUpdate) statusBarMenu.addItem(toggleCompletions) statusBarMenu.addItem(toggleIgnoreLanguage) statusBarMenu.addItem(.separator()) - statusBarMenu.addItem(copilotStatus) + statusBarMenu.addItem(authMenuItem) statusBarMenu.addItem(statusMenuItem) statusBarMenu.addItem(.separator()) statusBarMenu.addItem(openDocs) @@ -165,14 +160,6 @@ extension AppDelegate: NSMenuDelegate { } } - statusChecker.updateStatusInBackground(notify: { (status: String, isOk: Bool) in - if let statusItem = menu.items.first(where: { item in - item.identifier == self.copilotStatusMenuItemIdentifier - }) { - statusItem.title = "Copilot Connection: \(isOk ? "Connected" : status)" - } - }) - case xcodeInspectorDebugMenuIdentifier: let inspector = XcodeInspector.shared menu.items.removeAll() diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift index 1f8ad9f..dd4e888 100644 --- a/ExtensionService/AppDelegate.swift +++ b/ExtensionService/AppDelegate.swift @@ -32,25 +32,20 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { let service = Service.shared var statusBarItem: NSStatusItem! var statusMenuItem: NSMenuItem! + var authMenuItem: NSMenuItem! var xpcController: XPCController? let updateChecker = UpdateChecker( hostBundle: Bundle(url: locateHostBundleURL(url: Bundle.main.bundleURL)), checkerDelegate: ExtensionUpdateCheckerDelegate() ) - 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, @@ -63,7 +58,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { buildStatusBarMenu() watchServiceStatus() watchAXStatus() - updateStatusBarItem() // set the initial status + watchAuthStatus() + setInitialStatusBarStatus() } @objc func quit() { @@ -183,16 +179,44 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } } + func watchAuthStatus() { + let notifications = DistributedNotificationCenter.default().notifications(named: .authStatusDidChange) + Task { [weak self] in + for await _ in notifications { + guard let self else { return } + await self.forceAuthStatusCheck() + } + } + } + + func setInitialStatusBarStatus() { + Task { + let authStatus = await Status.shared.getAuthStatus() + if authStatus == .unknown { + // temporarily kick off a language server instance to prime the initial auth status + await forceAuthStatusCheck() + } + updateStatusBarItem() + } + } + + func forceAuthStatusCheck() async { + do { + let service = try GitHubCopilotService() + _ = try await service.checkStatus() + try await service.shutdown() + try await service.exit() + } catch { + Logger.service.error("Failed to read auth status: \(error)") + } + } + 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 + let image = status.icon.nsImage self.statusBarItem.button?.image = image + self.authMenuItem.title = status.authMessage if let message = status.message { // TODO switch to attributedTitle to enable line breaks and color. self.statusMenuItem.title = message @@ -201,6 +225,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } else { self.statusMenuItem.isHidden = true } + self.markAsProcessing(status.inProgress) } } @@ -209,7 +234,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { // No longer in progress progressView?.removeFromSuperview() progressView = nil - statusBarItem.button?.image = idleIcon return } if progressView != nil { diff --git a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-16.png b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-16.png index d6daae3..6add79d 100644 Binary files a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-16.png and b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-16.png differ diff --git a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-32.png b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-32.png index 07efd7f..f6cf654 100644 Binary files a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-32.png and b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-32.png differ diff --git a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-48.png b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-48.png index 1e1cbac..9c76a88 100644 Binary files a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-48.png and b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-48.png differ diff --git a/ExtensionService/Assets.xcassets/WarningIcon.imageset/Contents.json b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/Contents.json similarity index 75% rename from ExtensionService/Assets.xcassets/WarningIcon.imageset/Contents.json rename to ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/Contents.json index ae91a65..60c5e84 100644 --- a/ExtensionService/Assets.xcassets/WarningIcon.imageset/Contents.json +++ b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/Contents.json @@ -1,16 +1,17 @@ { "images" : [ { - "filename" : "copilot-warning-24.png", + "filename" : "copilot-16.png", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "copilot-warning-48.png", + "filename" : "copilot-32.png", "idiom" : "universal", "scale" : "2x" }, { + "filename" : "copilot-48.png", "idiom" : "universal", "scale" : "3x" } diff --git a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/copilot-16.png b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/copilot-16.png new file mode 100644 index 0000000..0884ac6 Binary files /dev/null and b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/copilot-16.png differ diff --git a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/copilot-32.png b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/copilot-32.png new file mode 100644 index 0000000..74aa2b4 Binary files /dev/null and b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/copilot-32.png differ diff --git a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/copilot-48.png b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/copilot-48.png new file mode 100644 index 0000000..f7e21ee Binary files /dev/null and b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/copilot-48.png differ diff --git a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Contents.json b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Contents.json new file mode 100644 index 0000000..60c5e84 --- /dev/null +++ b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "copilot-16.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "copilot-32.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "copilot-48.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/copilot-16.png b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/copilot-16.png new file mode 100644 index 0000000..6497d37 Binary files /dev/null and b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/copilot-16.png differ diff --git a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/copilot-32.png b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/copilot-32.png new file mode 100644 index 0000000..c0073a7 Binary files /dev/null and b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/copilot-32.png differ diff --git a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/copilot-48.png b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/copilot-48.png new file mode 100644 index 0000000..0daf8ca Binary files /dev/null and b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/copilot-48.png differ diff --git a/ExtensionService/Assets.xcassets/WarningIcon.imageset/copilot-warning-24.png b/ExtensionService/Assets.xcassets/WarningIcon.imageset/copilot-warning-24.png deleted file mode 100644 index f7dc5a8..0000000 Binary files a/ExtensionService/Assets.xcassets/WarningIcon.imageset/copilot-warning-24.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/WarningIcon.imageset/copilot-warning-48.png b/ExtensionService/Assets.xcassets/WarningIcon.imageset/copilot-warning-48.png deleted file mode 100644 index 48254ca..0000000 Binary files a/ExtensionService/Assets.xcassets/WarningIcon.imageset/copilot-warning-48.png and /dev/null differ diff --git a/ExtensionService/AuthStatusChecker.swift b/ExtensionService/AuthStatusChecker.swift deleted file mode 100644 index 6da7071..0000000 --- a/ExtensionService/AuthStatusChecker.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// AuthStatusChecker.swift -// ExtensionService -// -// Responsible for checking the logged in status of the user. -// - -import Foundation -import GitHubCopilotService - -class AuthStatusChecker { - var authService: GitHubCopilotAuthServiceType? - - public func updateStatusInBackground(notify: @escaping (_ status: String, _ isOk: Bool) -> Void) { - Task { - do { - let status = try await self.getCurrentAuthStatus() - Task { @MainActor in - notify(status.description, status == .ok) - } - } catch { - Task { @MainActor in - notify("\(error)", false) - } - } - } - } - - func getCurrentAuthStatus() async throws -> GitHubCopilotAccountStatus { - let service = try getAuthService() - let status = try await service.checkStatus() - return status - } - - func getAuthService() throws -> GitHubCopilotAuthServiceType { - if let service = authService { return service } - let service = try GitHubCopilotService() - authService = service - return service - } -} diff --git a/README.md b/README.md index 255c74f..586350b 100644 --- a/README.md +++ b/README.md @@ -21,19 +21,21 @@ Use of the GitHub Copilot Xcode Extension is subject to [GitHub's Pre-Release Te ## Getting Started -1. Download the `dmg` from +1. Install via [Homebrew](https://brew.sh/): + + ```sh + brew install --cask github-copilot-for-xcode + ``` + + Or download the `dmg` from [the latest release](https://github.com/github/CopilotForXcode/releases/latest/download/GitHubCopilotForXcode.dmg). - Updates can be downloaded and installed by the app. + Drag `GitHub Copilot for Xcode` into the `Applications` folder: -1. Open the `dmg` and drag the `GitHub Copilot for Xcode.app` into the `Applications` folder.

Screenshot of opened dmg

-1. On the first opening the application it will warn that it was downloaded from the internet. Click `Open` to proceed. -

- Screenshot of downloaded from the internet warning -

+ Updates can be downloaded and installed by the app. 1. A background item will be added to enable Copilot to start when Xcode is opened.

diff --git a/Server/package-lock.json b/Server/package-lock.json index 46be953..88a089c 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.243.0" + "@github/copilot-language-server": "^1.245.0" } }, "node_modules/@github/copilot-language-server": { - "version": "1.243.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.243.0.tgz", - "integrity": "sha512-d0suQuTzzLAvG8KjdbcIQoeCxJfxF88L2fcvAiRXHCr2PAnFZYnNnLWa5qfr7IpC7a91SAGx5AibRuKTQZibTg==", + "version": "1.245.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.245.0.tgz", + "integrity": "sha512-K/PSxLMQFhnM8CIhL0mF/FAEzB2EYqOEu0Ai0gvFEy+fMl3FvhxFkagb6w1qaQ0dQJSY5qExT88sABn3wDyHiA==", "bin": { "copilot-language-server": "dist/language-server.js" } diff --git a/Server/package.json b/Server/package.json index 63ff48b..68b97a1 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.243.0" + "@github/copilot-language-server": "^1.245.0" } } diff --git a/Tool/Package.swift b/Tool/Package.swift index 678bf82..7b97c58 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -237,6 +237,7 @@ let package = Package( "Terminal", "BuiltinExtension", "ConversationServiceProvider", + "Status", .product(name: "LanguageServerProtocol", package: "LanguageServerProtocol"), .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), ] diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CLSErrorInfo.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CLSErrorInfo.swift new file mode 100644 index 0000000..ea745a6 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CLSErrorInfo.swift @@ -0,0 +1,46 @@ +import LanguageServerProtocol + +public enum CLSErrorCode: Int { + // defined by JSON-RPC + case parseError = -32700 + case invalidRequest = -32600 + case methodNotFound = -32601 + case invalidParams = -32602 + case internalError = -32603 + + // defined by LSP (see https://microsoft.github.io/language-server-protocol/specification/#responseMessage) + case serverNotInitialized = -32002 + case requestFailed = -32803; + case serverCancelled = -32802; + case contentModified = -32801; + case requestCancelled = -32800; + + // used by the Copilot Language Server + case noCopilotToken = 1000 + case deviceFlowFailed = 1001 + case copilotNotAvailable = 1002 +} + +public struct CLSErrorInfo { + public let code: Int + public let message: String + public let data: Codable? + + public init?(for error: ServerError) { + if case .serverError(let code, let message, let data) = error { + self.code = code + self.message = message + self.data = data + } else { + return nil + } + } + + public var clsErrorCode: CLSErrorCode? { + CLSErrorCode(rawValue: code) + } + + public var affectsAuthStatus: Bool { + clsErrorCode == CLSErrorCode.noCopilotToken + } +} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotAuthStatusWatcher.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotAuthStatusWatcher.swift new file mode 100644 index 0000000..ab8b959 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotAuthStatusWatcher.swift @@ -0,0 +1,21 @@ +import Foundation + +class CopilotAuthStatusWatcher { + static let pollInterval: TimeInterval = 30 + private var timer: Timer? + + public init(_ service: GitHubCopilotService) { + Task { @MainActor in + self.timer = Timer.scheduledTimer(withTimeInterval: Self.pollInterval, repeats: true) { [weak service] _ in + service?.updateStatusInBackground() + } + } + } + + deinit { + let t = timer + Task { @MainActor in + t?.invalidate() + } + } +} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift index 18273d0..f97aec1 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift @@ -5,6 +5,7 @@ import LanguageClient import LanguageServerProtocol import Logger import ProcessEnv +import Status /// A clone of the `LocalProcessServer`. /// We need it because the original one does not allow us to handle custom notifications. @@ -279,6 +280,9 @@ extension CustomJSONRPCLanguageServer { return true case "statusNotification": Logger.gitHubCopilot.info("\(anyNotification.method): \(debugDescription)") + if let payload = GitHubCopilotNotification.StatusNotification.decode(fromParams: anyNotification.params) { + Task { await Status.shared.updateCLSStatus(payload.status.clsStatus, message: payload.message) } + } block(nil) return true case "featureFlagsNotification": diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotAccountStatus.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotAccountStatus.swift index fab47ca..b4d28d1 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotAccountStatus.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotAccountStatus.swift @@ -6,6 +6,7 @@ public enum GitHubCopilotAccountStatus: String, Codable, CustomStringConvertible case notAuthorized = "NotAuthorized" case notSignedIn = "NotSignedIn" case ok = "OK" + case failedToGetToken = "FailedToGetToken" public var description: String { switch self { @@ -19,6 +20,8 @@ public enum GitHubCopilotAccountStatus: String, Codable, CustomStringConvertible return "Not Signed In" case .ok: return "OK" + case .failedToGetToken: + return "Failed to Get Token" } } } diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift index 1c7f252..9b5bd1f 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift @@ -1,6 +1,7 @@ import Foundation import JSONRPC import LanguageServerProtocol +import Status import SuggestionBasic struct GitHubCopilotDoc: Codable { @@ -66,7 +67,13 @@ public func editorConfiguration() -> JSONValue { if let proxyAuthorization = proxyAuthorization { d["proxyAuthorization"] = .string(proxyAuthorization) } - d["proxyStrictSSL"] = .bool(UserDefaults.shared.value(for: \.gitHubCopilotUseStrictSSL)) + let proxyStrictSSL = UserDefaults.shared.value(for: \.gitHubCopilotUseStrictSSL) + d["proxyStrictSSL"] = .bool(proxyStrictSSL) + if proxy.isEmpty && proxyStrictSSL == false { + // Setting the proxy to an empty string avoids the lanaguage server + // ignoring the proxyStrictSSL setting. + d["proxy"] = .string("") + } return .hash(d) } @@ -95,6 +102,7 @@ enum GitHubCopilotRequest { struct CheckStatus: GitHubCopilotRequestType { struct Response: Codable { var status: GitHubCopilotAccountStatus + var user: String? } var request: ClientRequest { @@ -339,3 +347,40 @@ enum GitHubCopilotRequest { } } +// MARK: Notifications + +public enum GitHubCopilotNotification { + + public struct StatusNotification: Codable { + public enum StatusKind : String, Codable { + case normal = "Normal" + case inProgress = "InProgress" + case error = "Error" + case warning = "Warning" + case inactive = "Inactive" + + public var clsStatus: CLSStatus.Status { + switch self { + case .normal: + .normal + case .inProgress: + .inProgress + case .error: + .error + case .warning: + .warning + case .inactive: + .inactive + } + } + } + + public var status: StatusKind + public var message: String + + public static func decode(fromParams params: JSONValue?) -> StatusNotification? { + try? JSONDecoder().decode(Self.self, from: (try? JSONEncoder().encode(params)) ?? Data()) + } + } + +} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift index abed0f0..e6d64c1 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -7,6 +7,7 @@ import LanguageClient import LanguageServerProtocol import Logger import Preferences +import Status import SuggestionBasic public protocol GitHubCopilotAuthServiceType { @@ -281,6 +282,7 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, private var ongoingTasks = Set>() private var serverNotificationHandler: ServerNotificationHandler = ServerNotificationHandlerImpl.shared private var cancellables = Set() + private var statusWatcher: CopilotAuthStatusWatcher? override init(designatedServer: any GitHubCopilotLSP) { super.init(designatedServer: designatedServer) @@ -291,6 +293,7 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, localProcessServer?.notificationPublisher.sink(receiveValue: { [weak self] notification in self?.serverNotificationHandler.handleNotification(notification) }).store(in: &cancellables) + updateStatusInBackground() } @GitHubCopilotSuggestionActor @@ -309,7 +312,7 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, func sendRequest(maxTry: Int = 5) async throws -> [CodeSuggestion] { do { - let completions = try await server + let completions = try await self .sendRequest(GitHubCopilotRequest.InlineCompletion(doc: .init( textDocument: .init(uri: fileURL.path, version: 1), position: cursorPosition, @@ -407,7 +410,7 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, source: .panel, workspaceFolder: workspaceFolder) do { - let _ = try await server.sendRequest( + let _ = try await sendRequest( GitHubCopilotRequest.CreateConversation(params: params) ) } catch { @@ -420,7 +423,7 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, public func createTurn(_ message: String, workDoneToken: String, conversationId: String, doc: Doc?) async throws { do { let params = TurnCreateParams(workDoneToken: workDoneToken, conversationId: conversationId, message: message, doc: doc) - let _ = try await server.sendRequest( + let _ = try await sendRequest( GitHubCopilotRequest.CreateTurn(params: params) ) } catch { @@ -433,7 +436,7 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, public func rateConversation(turnId: String, rating: ConversationRating) async throws { do { let params = ConversationRatingParams(turnId: turnId, rating: rating) - let _ = try await server.sendRequest( + let _ = try await sendRequest( GitHubCopilotRequest.ConversationRating(params: params) ) } catch { @@ -445,7 +448,7 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, public func copyCode(turnId: String, codeBlockIndex: Int, copyType: CopyKind, copiedCharacters: Int, totalCharacters: Int, copiedText: String) async throws { let params = CopyCodeParams(turnId: turnId, codeBlockIndex: codeBlockIndex, copyType: copyType, copiedCharacters: copiedCharacters, totalCharacters: totalCharacters, copiedText: copiedText) do { - let _ = try await server.sendRequest( + let _ = try await sendRequest( GitHubCopilotRequest.CopyCode(params: params) ) } catch { @@ -468,21 +471,21 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, @GitHubCopilotSuggestionActor public func notifyShown(_ completion: CodeSuggestion) async { - _ = try? await server.sendRequest( + _ = try? await sendRequest( GitHubCopilotRequest.NotifyShown(completionUUID: completion.id) ) } @GitHubCopilotSuggestionActor public func notifyAccepted(_ completion: CodeSuggestion, acceptedLength: Int? = nil) async { - _ = try? await server.sendRequest( + _ = try? await sendRequest( GitHubCopilotRequest.NotifyAccepted(completionUUID: completion.id, acceptedLength: acceptedLength) ) } @GitHubCopilotSuggestionActor public func notifyRejected(_ completions: [CodeSuggestion]) async { - _ = try? await server.sendRequest( + _ = try? await sendRequest( GitHubCopilotRequest.NotifyRejected(completionUUIDs: completions.map(\.id)) ) } @@ -494,7 +497,7 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, ) async throws { let languageId = languageIdentifierFromFileURL(fileURL) let uri = "file://\(fileURL.path)" -// Logger.service.debug("Open \(uri), \(content.count)") + // Logger.service.debug("Open \(uri), \(content.count)") try await server.sendNotification( .didOpenTextDocument( DidOpenTextDocumentParams( @@ -516,7 +519,7 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, version: Int ) async throws { let uri = "file://\(fileURL.path)" -// Logger.service.debug("Change \(uri), \(content.count)") + // Logger.service.debug("Change \(uri), \(content.count)") try await server.sendNotification( .didChangeTextDocument( DidChangeTextDocumentParams( @@ -535,14 +538,14 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, @GitHubCopilotSuggestionActor public func notifySaveTextDocument(fileURL: URL) async throws { let uri = "file://\(fileURL.path)" -// Logger.service.debug("Save \(uri)") + // Logger.service.debug("Save \(uri)") try await server.sendNotification(.didSaveTextDocument(.init(uri: uri))) } @GitHubCopilotSuggestionActor public func notifyCloseTextDocument(fileURL: URL) async throws { let uri = "file://\(fileURL.path)" -// Logger.service.debug("Close \(uri)") + // Logger.service.debug("Close \(uri)") try await server.sendNotification(.didCloseTextDocument(.init(uri: uri))) } @@ -554,7 +557,9 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, @GitHubCopilotSuggestionActor public func checkStatus() async throws -> GitHubCopilotAccountStatus { do { - return try await server.sendRequest(GitHubCopilotRequest.CheckStatus()).status + let response = try await sendRequest(GitHubCopilotRequest.CheckStatus()) + await updateServiceAuthStatus(response) + return response.status } catch let error as ServerError { throw GitHubCopilotError.languageServerError(error) } catch { @@ -562,10 +567,38 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, } } + public func updateStatusInBackground() { + Task { @GitHubCopilotSuggestionActor in + try? await checkStatus() + } + } + + private func updateServiceAuthStatus(_ status: GitHubCopilotRequest.CheckStatus.Response) async { + Logger.gitHubCopilot.info("check status response: \(status)") + if status.status == .ok || status.status == .maybeOk { + await Status.shared.updateAuthStatus(.loggedIn, username: status.user) + await unwatchAuthStatus() + } else { + await Status.shared.updateAuthStatus(.notLoggedIn, message: status.status.description) + await watchAuthStatus() + } + } + + @GitHubCopilotSuggestionActor + private func watchAuthStatus() { + guard statusWatcher == nil else { return } + statusWatcher = CopilotAuthStatusWatcher(self) + } + + @GitHubCopilotSuggestionActor + private func unwatchAuthStatus() { + statusWatcher = nil + } + @GitHubCopilotSuggestionActor public func signInInitiate() async throws -> (verificationUri: String, userCode: String) { do { - let result = try await server.sendRequest(GitHubCopilotRequest.SignInInitiate()) + let result = try await sendRequest(GitHubCopilotRequest.SignInInitiate()) return (result.verificationUri, result.userCode) } catch let error as ServerError { throw GitHubCopilotError.languageServerError(error) @@ -576,11 +609,10 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, @GitHubCopilotSuggestionActor public func signInConfirm(userCode: String) async throws - -> (username: String, status: GitHubCopilotAccountStatus) + -> (username: String, status: GitHubCopilotAccountStatus) { do { - let result = try await server - .sendRequest(GitHubCopilotRequest.SignInConfirm(userCode: userCode)) + let result = try await sendRequest(GitHubCopilotRequest.SignInConfirm(userCode: userCode)) return (result.user, result.status) } catch let error as ServerError { throw GitHubCopilotError.languageServerError(error) @@ -592,7 +624,7 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, @GitHubCopilotSuggestionActor public func signOut() async throws -> GitHubCopilotAccountStatus { do { - return try await server.sendRequest(GitHubCopilotRequest.SignOut()).status + return try await sendRequest(GitHubCopilotRequest.SignOut()).status } catch let error as ServerError { throw GitHubCopilotError.languageServerError(error) } catch { @@ -603,13 +635,59 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, @GitHubCopilotSuggestionActor public func version() async throws -> String { do { - return try await server.sendRequest(GitHubCopilotRequest.GetVersion()).version + return try await sendRequest(GitHubCopilotRequest.GetVersion()).version } catch let error as ServerError { throw GitHubCopilotError.languageServerError(error) } catch { throw error } } + + @GitHubCopilotSuggestionActor + public func shutdown() async throws { + let stream = AsyncThrowingStream { continuation in + if let localProcessServer { + localProcessServer.shutdown() { err in + continuation.finish(throwing: err) + } + } else { + continuation.finish(throwing: GitHubCopilotError.languageServerError(ServerError.serverUnavailable)) + } + } + for try await _ in stream { + return + } + } + + @GitHubCopilotSuggestionActor + public func exit() async throws { + let stream = AsyncThrowingStream { continuation in + if let localProcessServer { + localProcessServer.exit() { err in + continuation.finish(throwing: err) + } + } else { + continuation.finish(throwing: GitHubCopilotError.languageServerError(ServerError.serverUnavailable)) + } + } + for try await _ in stream { + return + } + } + + private func sendRequest(_ endpoint: E) async throws -> E.Response { + do { + return try await server.sendRequest(endpoint) + } catch let error as ServerError { + if let info = CLSErrorInfo(for: error) { + // update the auth status if the error indicates it may have changed, and then rethrow + if info.affectsAuthStatus && !(endpoint is GitHubCopilotRequest.CheckStatus) { + updateStatusInBackground() + } + } + throw error + } + } } extension InitializingServer: GitHubCopilotLSP { diff --git a/Tool/Sources/Status/Status.swift b/Tool/Sources/Status/Status.swift index 1d97416..5cc7e9a 100644 --- a/Tool/Sources/Status/Status.swift +++ b/Tool/Sources/Status/Status.swift @@ -13,15 +13,63 @@ public enum ExtensionPermissionStatus { case notGranted = 0 } +public struct CLSStatus: Equatable { + public enum Status { + case unknown + case normal + case inProgress + case error + case warning + case inactive + } + + public let status: Status + public let message: String + + public var isInactiveStatus: Bool { + status == .inactive && !message.isEmpty + } + + public var isErrorStatus: Bool { + (status == .warning || status == .error) && !message.isEmpty + } +} + +public struct AuthStatus: Equatable { + public enum Status { + case unknown + case loggedIn + case notLoggedIn + } + + public let status: Status + public let username: String? + public let message: String? +} + public extension Notification.Name { + static let authStatusDidChange = Notification.Name("com.github.CopilotForXcode.authStatusDidChange") 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 struct Icon { + public let name: String + + public init(name: String) { + self.name = name + } + + public var nsImage: NSImage? { + NSImage(named: name) + } + } + + public let icon: Icon + public let inProgress: Bool public let message: String? public let url: String? + public let authMessage: String } public final actor Status { @@ -29,6 +77,12 @@ public final actor Status { private var extensionStatus: ExtensionPermissionStatus = .unknown private var axStatus: ObservedAXStatus = .unknown + private var clsStatus = CLSStatus(status: .unknown, message: "") + private var authStatus = AuthStatus(status: .unknown, username: nil, message: nil) + + private let okIcon = StatusResponse.Icon(name: "MenuBarIcon") + private let errorIcon = StatusResponse.Icon(name: "MenuBarWarningIcon") + private let inactiveIcon = StatusResponse.Icon(name: "MenuBarInactiveIcon") private init() {} @@ -44,6 +98,20 @@ public final actor Status { broadcast() } + public func updateCLSStatus(_ status: CLSStatus.Status, message: String) { + let newStatus = CLSStatus(status: status, message: message) + guard newStatus != clsStatus else { return } + clsStatus = newStatus + broadcast() + } + + public func updateAuthStatus(_ status: AuthStatus.Status, username: String? = nil, message: String? = nil) { + let newStatus = AuthStatus(status: status, username: username, message: message) + guard newStatus != authStatus else { return } + authStatus = newStatus + broadcast() + } + public func getAXStatus() -> ObservedAXStatus { // if Xcode is running, return the observed status if isXcodeRunning() { @@ -62,13 +130,44 @@ public final actor Status { return !xcode.isEmpty } + public func getAuthStatus() -> AuthStatus.Status { + return authStatus.status + } + public func getStatus() -> StatusResponse { + let (authIcon, authMessage) = getAuthStatusInfo() + let (icon, message, url) = getExtensionStatusInfo() + return .init( + icon: authIcon ?? icon ?? okIcon, + inProgress: clsStatus.status == .inProgress, + message: message, + url: url, + authMessage: authMessage + ) + } + + private func getAuthStatusInfo() -> (authIcon: StatusResponse.Icon?, authMessage: String) { + switch authStatus.status { + case .unknown, + .loggedIn: + (authIcon: nil, authMessage: "Logged in as \(authStatus.username ?? "")") + case .notLoggedIn: + (authIcon: errorIcon, authMessage: authStatus.message ?? "Not logged in") + } + } + + private func getExtensionStatusInfo() -> (icon: StatusResponse.Icon?, message: String?, url: String?) { + if clsStatus.isInactiveStatus { + return (icon: inactiveIcon, message: clsStatus.message, url: nil) + } else if clsStatus.isErrorStatus { + return (icon: errorIcon, message: clsStatus.message, url: nil) + } + 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, + return ( + icon: errorIcon, message: """ Extension is not enabled. Enable GitHub Copilot under Xcode and then restart Xcode. @@ -79,11 +178,10 @@ public final actor Status { switch getAXStatus() { case .granted: - return .init(icon: "MenuBarIcon", system: false, message: nil, url: nil) + return (icon: nil, message: nil, url: nil) case .notGranted: - return .init( - icon: "exclamationmark.circle", - system: true, + return ( + icon: errorIcon, message: """ Accessibility permission not granted. \ Click to open System Preferences. @@ -91,9 +189,8 @@ public final actor Status { url: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" ) case .unknown: - return .init( - icon: "exclamationmark.circle", - system: true, + return ( + icon: errorIcon, message: """ Accessibility permission not granted or Copilot restart needed. """,