From c256218e82cb59e6d84ea5b94fbcbfa7d2c11966 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 5 Jan 2023 00:58:19 +0800 Subject: [PATCH 1/4] Reset real-time suggestions state on Xcode quit --- Core/Sources/Service/XPCService.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift index ad47e2a0..3a797303 100644 --- a/Core/Sources/Service/XPCService.swift +++ b/Core/Sources/Service/XPCService.swift @@ -26,7 +26,10 @@ public class XPCService: NSObject, XPCServiceProtocol { deinit { let identifier = ObjectIdentifier(self) - Task { + Task { @ServiceActor in + for (_, workspace) in workspaces { + workspace.isRealtimeSuggestionEnabled = false + } await AutoTrigger.shared.stop(by: identifier) } } From 9db9cc4927e2879ed65337849ce6af32d0b8b48c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 5 Jan 2023 01:08:03 +0800 Subject: [PATCH 2/4] Update README.md --- README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 107d1882..cde25ad2 100644 --- a/README.md +++ b/README.md @@ -45,15 +45,16 @@ The first time the commands run, the extension will ask for 2 types of permissio **About real-time suggestions** -The implementation won't feel as smooth as that of VSCode. +- The on/off state is not persisted, they will be reset every time Xcode is quit. (For technical reasons, you always need to run a random command to start the XPCService, it will be confusing if it's persisted.) +- The implementation won't feel as smooth as that of VSCode. + + The magic behind it is that it will keep calling the command from the menu when you are not typing or clicking the mouse. So it will have to listen to those events, I am not sure if people like it. -The magic behind it is that it will keep calling the command from the menu when you are not typing, or clicking mouse. So it will have to listen to those events, I am not sure if people like it. - -Hope that next year, Apple can spend some time on Xcode Extensions. + Hope that next year, Apple can spend some time on Xcode Extensions. ## Prevent Suggestions Being Committed -Since the suggestions are presented as comments, they are in your code. If you are not careful enough, they can be committed to your git repo. To avoid that, I would recommend adding a pre-commit git hook to prevent this from happening. Maybe later I will add an command for that. +Since the suggestions are presented as comments, they are in your code. If you are not careful enough, they can be committed to your git repo. To avoid that, I would recommend adding a pre-commit git hook to prevent this from happening. ```sh #!/bin/sh From ca61d5b2ed1525b860738eb0bfc2d844adb29e8c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 5 Jan 2023 15:30:41 +0800 Subject: [PATCH 3/4] Persist real-time suggestion states --- Core/Sources/Client/XPCService.swift | 5 ++- Core/Sources/Service/AutoTrigger.swift | 51 ++++++++++++++++++---- Core/Sources/Service/CGEventObserver.swift | 4 +- Core/Sources/Service/Environment.swift | 20 ++++++--- Core/Sources/Service/Workspace.swift | 7 ++- Core/Sources/Service/XPCService.swift | 45 ++++++++----------- Core/Sources/XPCShared/UserDefaults.swift | 1 + XPCService/main.swift | 2 + 8 files changed, 90 insertions(+), 45 deletions(-) diff --git a/Core/Sources/Client/XPCService.swift b/Core/Sources/Client/XPCService.swift index 12c6c5fb..0d042e15 100644 --- a/Core/Sources/Client/XPCService.swift +++ b/Core/Sources/Client/XPCService.swift @@ -1,5 +1,6 @@ import Foundation import XPCShared +import os.log var asyncService: AsyncXPCService? var shared = XPCService() @@ -30,11 +31,11 @@ class XPCService { connection.remoteObjectInterface = NSXPCInterface(with: XPCServiceProtocol.self) connection.invalidationHandler = { [weak self] in - print("XPCService Invalidated") + os_log(.info, "XPCService Invalidated") self?.isInvalidated = true } connection.interruptionHandler = { [weak self] in - print("XPCService interrupted") + os_log(.info, "XPCService interrupted") } connection.resume() return connection diff --git a/Core/Sources/Service/AutoTrigger.swift b/Core/Sources/Service/AutoTrigger.swift index babce6f4..b85419ae 100644 --- a/Core/Sources/Service/AutoTrigger.swift +++ b/Core/Sources/Service/AutoTrigger.swift @@ -1,14 +1,17 @@ +import AppKit import Foundation import XPCShared +import os.log -actor AutoTrigger { - static let shared = AutoTrigger() +public actor AutoTrigger { + public static let shared = AutoTrigger() - private var listeners = Set() + private var listeners = Set() var eventObserver: CGEventObserverType = CGEventObserver() var task: Task? private init() { + // Occasionally cleanup workspaces. Task { @ServiceActor in while !Task.isCancelled { try await Task.sleep(nanoseconds: 8 * 60 * 60 * 1_000_000_000) @@ -21,15 +24,42 @@ actor AutoTrigger { } } } + + // Start the auto trigger if Xcode is running. + Task { + for xcode in await Environment.runningXcodes() { + await start(by: xcode.processIdentifier) + } + let sequence = NSWorkspace.shared.notificationCenter + .notifications(named: NSWorkspace.didLaunchApplicationNotification) + for await notification in sequence { + try Task.checkCancellation() + guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { continue } + guard app.bundleIdentifier == "com.apple.dt.Xcode" else { continue } + await start(by: app.processIdentifier) + } + } + + // Remove listener if Xcode is terminated. + Task { + let sequence = NSWorkspace.shared.notificationCenter + .notifications(named: NSWorkspace.didTerminateApplicationNotification) + for await notification in sequence { + try Task.checkCancellation() + guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { continue } + guard app.bundleIdentifier == "com.apple.dt.Xcode" else { continue } + await stop(by: app.processIdentifier) + } + } } - func start(by listener: ObjectIdentifier) { + func start(by listener: AnyHashable) { + os_log(.info, "Add auto trigger listener: %@.", listener as CVarArg) listeners.insert(listener) if task == nil { task = Task { [stream = eventObserver.stream] in var triggerTask: Task? - try? await Environment.triggerAction("Prefetch Suggestions") for await _ in stream { triggerTask?.cancel() if Task.isCancelled { break } @@ -47,10 +77,11 @@ actor AutoTrigger { try? await Task.sleep(nanoseconds: 2_000_000_000) if Task.isCancelled { return } let fileURL = try? await Environment.fetchCurrentFileURL() - guard let folderURL = try? await Environment.fetchCurrentProjectRootURL(fileURL), - let workspace = workspaces[folderURL], - workspace.isRealtimeSuggestionEnabled + guard let folderURL = try? await Environment.fetchCurrentProjectRootURL(fileURL) else { return } + let workspace = workspaces[folderURL] ?? Workspace(projectRootURL: folderURL) + workspaces[folderURL] = workspace + guard workspace.isRealtimeSuggestionEnabled else { return } if Task.isCancelled { return } try? await Environment.triggerAction("Prefetch Suggestions") } @@ -60,9 +91,11 @@ actor AutoTrigger { eventObserver.activateIfPossible() } - func stop(by listener: ObjectIdentifier) { + func stop(by listener: AnyHashable) { + os_log(.info, "Remove auto trigger listener: %@.", listener as CVarArg) listeners.remove(listener) guard listeners.isEmpty else { return } + os_log(.info, "Auto trigger is stopped.") task?.cancel() task = nil eventObserver.deactivate() diff --git a/Core/Sources/Service/CGEventObserver.swift b/Core/Sources/Service/CGEventObserver.swift index 0c876417..71541393 100644 --- a/Core/Sources/Service/CGEventObserver.swift +++ b/Core/Sources/Service/CGEventObserver.swift @@ -1,5 +1,6 @@ import Cocoa import Foundation +import os.log public protocol CGEventObserverType { @discardableResult @@ -38,7 +39,7 @@ final class CGEventObserver: CGEventObserverType { retryTask?.cancel() retryTask = nil guard let port = port else { return } - print("Deactivate") + os_log(.info, "CGEventObserver deactivated.") CFMachPortInvalidate(port) self.port = nil } @@ -95,6 +96,7 @@ final class CGEventObserver: CGEventObserverType { self.port = port let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, port, 0) CFRunLoopAddSource(RunLoop.main.getCFRunLoop(), runLoopSource, .commonModes) + os_log(.info, "CGEventObserver activated.") return true } } diff --git a/Core/Sources/Service/Environment.swift b/Core/Sources/Service/Environment.swift index 4dad286c..2303685b 100644 --- a/Core/Sources/Service/Environment.swift +++ b/Core/Sources/Service/Environment.swift @@ -17,6 +17,19 @@ private struct FailedToFetchFileURLError: Error, LocalizedError { enum Environment { static var now = { Date() } + static var runningXcodes: () async -> [NSRunningApplication] = { + var xcodes = [NSRunningApplication]() + var retryCount = 0 + // Sometimes runningApplications returns 0 items. + while xcodes.isEmpty, retryCount < 3 { + xcodes = NSRunningApplication + .runningApplications(withBundleIdentifier: "com.apple.dt.Xcode") + try? await Task.sleep(nanoseconds: 1_000_000) + retryCount += 1 + } + return xcodes + } + static var isXcodeActive: () async -> Bool = { var activeXcodes = [NSRunningApplication]() var retryCount = 0 @@ -24,11 +37,8 @@ enum Environment { while activeXcodes.isEmpty, retryCount < 3 { activeXcodes = NSRunningApplication .runningApplications(withBundleIdentifier: "com.apple.dt.Xcode") - .sorted { lhs, _ in - if lhs.isActive { return true } - return false - } - if retryCount > 0 { try? await Task.sleep(nanoseconds: 1_000_000) } + .filter { $0.isActive } + try? await Task.sleep(nanoseconds: 1_000_000) retryCount += 1 } return !activeXcodes.isEmpty diff --git a/Core/Sources/Service/Workspace.swift b/Core/Sources/Service/Workspace.swift index 55437e11..f515f45c 100644 --- a/Core/Sources/Service/Workspace.swift +++ b/Core/Sources/Service/Workspace.swift @@ -45,7 +45,12 @@ final class Workspace { } var filespaces = [URL: Filespace]() - var isRealtimeSuggestionEnabled = false + var isRealtimeSuggestionEnabled: Bool { + (UserDefaults.shared.dictionary( + forKey: SettingsKey.realtimeSuggestionState + )?[projectRootURL.absoluteString]) as? Bool ?? false + } + var realtimeSuggestionRequests = Set>() private lazy var service: CopilotSuggestionServiceType = Environment diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift index 3a797303..97c98c67 100644 --- a/Core/Sources/Service/XPCService.swift +++ b/Core/Sources/Service/XPCService.swift @@ -2,6 +2,7 @@ import AppKit import CopilotService import Foundation import LanguageServerProtocol +import os.log import XPCShared @globalActor enum ServiceActor { @@ -16,24 +17,6 @@ public class XPCService: NSObject, XPCServiceProtocol { @ServiceActor lazy var authService: CopilotAuthServiceType = Environment.createAuthService() - override public init() { - super.init() - let identifier = ObjectIdentifier(self) - Task { - await AutoTrigger.shared.start(by: identifier) - } - } - - deinit { - let identifier = ObjectIdentifier(self) - Task { @ServiceActor in - for (_, workspace) in workspaces { - workspace.isRealtimeSuggestionEnabled = false - } - await AutoTrigger.shared.stop(by: identifier) - } - } - public func checkStatus(withReply reply: @escaping (String?, Error?) -> Void) { Task { @ServiceActor in do { @@ -113,7 +96,7 @@ public class XPCService: NSObject, XPCServiceProtocol { } reply(try JSONEncoder().encode(updatedContent), nil) } catch { - print(error) + os_log(.error, "%@", error.localizedDescription) reply(nil, NSError.from(error)) } } @@ -140,7 +123,7 @@ public class XPCService: NSObject, XPCServiceProtocol { } reply(try JSONEncoder().encode(updatedContent), nil) } catch { - print(error) + os_log(.error, "%@", error.localizedDescription) reply(nil, NSError.from(error)) } } @@ -167,7 +150,7 @@ public class XPCService: NSObject, XPCServiceProtocol { } reply(try JSONEncoder().encode(updatedContent), nil) } catch { - print(error) + os_log(.error, "%@", error.localizedDescription) reply(nil, NSError.from(error)) } } @@ -191,7 +174,7 @@ public class XPCService: NSObject, XPCServiceProtocol { ) reply(try JSONEncoder().encode(updatedContent), nil) } catch { - print(error) + os_log(.error, "%@", error.localizedDescription) reply(nil, NSError.from(error)) } } @@ -218,7 +201,7 @@ public class XPCService: NSObject, XPCServiceProtocol { } reply(try JSONEncoder().encode(updatedContent), nil) } catch { - print(error) + os_log(.error, "%@", error.localizedDescription) reply(nil, NSError.from(error)) } } @@ -247,7 +230,7 @@ public class XPCService: NSObject, XPCServiceProtocol { } reply(try JSONEncoder().encode(updatedContent), nil) } catch { - print(error) + os_log(.error, "%@", error.localizedDescription) reply(nil, NSError.from(error)) } } @@ -257,11 +240,19 @@ public class XPCService: NSObject, XPCServiceProtocol { Task { @ServiceActor in let fileURL = try await Environment.fetchCurrentFileURL() let workspace = try await fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) - workspace.isRealtimeSuggestionEnabled = enabled + if var state = UserDefaults.shared.dictionary(forKey: SettingsKey.realtimeSuggestionState) { + state[workspace.projectRootURL.absoluteString] = enabled + UserDefaults.shared.set(state, forKey: SettingsKey.realtimeSuggestionState) + } else { + UserDefaults.shared.set( + [workspace.projectRootURL.absoluteString: enabled], + forKey: SettingsKey.realtimeSuggestionState + ) + } reply(nil) } } - + public func prefetchRealtimeSuggestions( editorContent: Data, withReply reply: @escaping () -> Void @@ -282,7 +273,7 @@ public class XPCService: NSObject, XPCServiceProtocol { ) reply() } catch { - print(error) + os_log(.error, "%@", error.localizedDescription) reply() } } diff --git a/Core/Sources/XPCShared/UserDefaults.swift b/Core/Sources/XPCShared/UserDefaults.swift index edf371da..f3a0df71 100644 --- a/Core/Sources/XPCShared/UserDefaults.swift +++ b/Core/Sources/XPCShared/UserDefaults.swift @@ -6,4 +6,5 @@ public extension UserDefaults { public enum SettingsKey { public static let nodePath = "NodePath" + public static let realtimeSuggestionState = "RealtimeSuggestionState" } diff --git a/XPCService/main.swift b/XPCService/main.swift index 85bcd610..7c46390d 100644 --- a/XPCService/main.swift +++ b/XPCService/main.swift @@ -1,4 +1,5 @@ import Foundation +import Service let listener = NSXPCListener( machServiceName: Bundle.main.object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as! String @@ -7,4 +8,5 @@ let listener = NSXPCListener( let delegate = ServiceDelegate() listener.delegate = delegate listener.resume() +_ = AutoTrigger.shared RunLoop.main.run() From 93f08bd0b47c185e2af8800e280ebfe1defa4363 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 5 Jan 2023 15:32:50 +0800 Subject: [PATCH 4/4] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cde25ad2..8407cfc1 100644 --- a/README.md +++ b/README.md @@ -38,14 +38,14 @@ The first time the commands run, the extension will ask for 2 types of permissio - Previous Suggestion: If there is more than 1 suggestion, switch to the previous one. - Accept Suggestion: Add the suggestion to the code. - Reject Suggestion: Remove the suggestion comments. -- Turn On Real-time Suggestions: When turn on, Copilot will auto-insert suggestion comments to your code while editing. You have to manually turn it on for every open window of Xcode. +- Turn On Real-time Suggestions: When turn on, Copilot will auto-insert suggestion comments to your code while editing. - Turn Off Real-time Suggestions: Turns the real-time suggestions off. - Real-time Suggestions: It is an entry point only for Copilot for Xcode. When suggestions are successfully fetched, Copilot for Xcode will run this command to present the suggestions. - Prefetch Suggestions: It is an entry point only for Copilot for Xcode. In the background, Copilot for Xcode will occasionally run this command to prefetch real-time suggestions. **About real-time suggestions** -- The on/off state is not persisted, they will be reset every time Xcode is quit. (For technical reasons, you always need to run a random command to start the XPCService, it will be confusing if it's persisted.) +- The on/off state is persisted, make sure you turn it off manually if you no longer want it. - The implementation won't feel as smooth as that of VSCode. The magic behind it is that it will keep calling the command from the menu when you are not typing or clicking the mouse. So it will have to listen to those events, I am not sure if people like it.