Skip to content

Commit cf7535c

Browse files
committed
Split XcodeInspector.swift into multiple files
1 parent a3aff20 commit cf7535c

File tree

5 files changed

+481
-457
lines changed

5 files changed

+481
-457
lines changed
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import AppKit
2+
import Foundation
3+
4+
public class AppInstanceInspector: ObservableObject {
5+
public let appElement: AXUIElement
6+
public let runningApplication: NSRunningApplication
7+
public var isActive: Bool { runningApplication.isActive }
8+
public var isXcode: Bool { runningApplication.isXcode }
9+
public var isExtensionService: Bool { runningApplication.isCopilotForXcodeExtensionService }
10+
11+
init(runningApplication: NSRunningApplication) {
12+
self.runningApplication = runningApplication
13+
appElement = AXUIElementCreateApplication(runningApplication.processIdentifier)
14+
}
15+
}
16+
Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
import AppKit
2+
import AXExtension
3+
import AXNotificationStream
4+
import Combine
5+
import Foundation
6+
7+
public final class XcodeAppInstanceInspector: AppInstanceInspector {
8+
@Published public var focusedWindow: XcodeWindowInspector?
9+
@Published public var documentURL: URL? = nil
10+
@Published public var workspaceURL: URL? = nil
11+
@Published public var projectRootURL: URL? = nil
12+
@Published public var workspaces = [WorkspaceIdentifier: Workspace]()
13+
public var realtimeWorkspaces: [WorkspaceIdentifier: WorkspaceInfo] {
14+
updateWorkspaceInfo()
15+
return workspaces.mapValues(\.info)
16+
}
17+
18+
@Published public private(set) var completionPanel: AXUIElement?
19+
20+
public var realtimeDocumentURL: URL? {
21+
guard let window = appElement.focusedWindow,
22+
window.identifier == "Xcode.WorkspaceWindow"
23+
else { return nil }
24+
25+
return WorkspaceXcodeWindowInspector.extractDocumentURL(windowElement: window)
26+
}
27+
28+
public var realtimeWorkspaceURL: URL? {
29+
guard let window = appElement.focusedWindow,
30+
window.identifier == "Xcode.WorkspaceWindow"
31+
else { return nil }
32+
33+
return WorkspaceXcodeWindowInspector.extractWorkspaceURL(windowElement: window)
34+
}
35+
36+
public var realtimeProjectURL: URL? {
37+
let workspaceURL = realtimeWorkspaceURL
38+
let documentURL = realtimeDocumentURL
39+
return WorkspaceXcodeWindowInspector.extractProjectURL(
40+
workspaceURL: workspaceURL,
41+
documentURL: documentURL
42+
)
43+
}
44+
45+
var _version: String?
46+
public var version: String? {
47+
if let _version { return _version }
48+
guard let plistPath = runningApplication.bundleURL?
49+
.appendingPathComponent("Contents")
50+
.appendingPathComponent("version.plist")
51+
.path
52+
else { return nil }
53+
guard let plistData = FileManager.default.contents(atPath: plistPath) else { return nil }
54+
var format = PropertyListSerialization.PropertyListFormat.xml
55+
guard let plistDict = try? PropertyListSerialization.propertyList(
56+
from: plistData,
57+
options: .mutableContainersAndLeaves,
58+
format: &format
59+
) as? [String: AnyObject] else { return nil }
60+
let result = plistDict["CFBundleShortVersionString"] as? String
61+
_version = result
62+
return result
63+
}
64+
65+
private var longRunningTasks = Set<Task<Void, Error>>()
66+
private var focusedWindowObservations = Set<AnyCancellable>()
67+
68+
deinit {
69+
for task in longRunningTasks { task.cancel() }
70+
}
71+
72+
override init(runningApplication: NSRunningApplication) {
73+
super.init(runningApplication: runningApplication)
74+
75+
observeFocusedWindow()
76+
observeAXNotifications()
77+
78+
Task {
79+
try await Task.sleep(nanoseconds: 3_000_000_000)
80+
// Sometimes the focused window may not be ready on app launch.
81+
if !(focusedWindow is WorkspaceXcodeWindowInspector) {
82+
observeFocusedWindow()
83+
}
84+
}
85+
}
86+
87+
func observeFocusedWindow() {
88+
if let window = appElement.focusedWindow {
89+
if window.identifier == "Xcode.WorkspaceWindow" {
90+
let window = WorkspaceXcodeWindowInspector(
91+
app: runningApplication,
92+
uiElement: window
93+
)
94+
focusedWindow = window
95+
96+
// should find a better solution to do this thread safe
97+
Task { @MainActor in
98+
focusedWindowObservations.forEach { $0.cancel() }
99+
focusedWindowObservations.removeAll()
100+
101+
documentURL = window.documentURL
102+
workspaceURL = window.workspaceURL
103+
projectRootURL = window.projectRootURL
104+
105+
window.$documentURL
106+
.filter { $0 != .init(fileURLWithPath: "/") }
107+
.sink { [weak self] url in
108+
self?.documentURL = url
109+
}.store(in: &focusedWindowObservations)
110+
window.$workspaceURL
111+
.filter { $0 != .init(fileURLWithPath: "/") }
112+
.sink { [weak self] url in
113+
self?.workspaceURL = url
114+
}.store(in: &focusedWindowObservations)
115+
window.$projectRootURL
116+
.filter { $0 != .init(fileURLWithPath: "/") }
117+
.sink { [weak self] url in
118+
self?.projectRootURL = url
119+
}.store(in: &focusedWindowObservations)
120+
}
121+
} else {
122+
let window = XcodeWindowInspector(uiElement: window)
123+
focusedWindow = window
124+
}
125+
} else {
126+
focusedWindow = nil
127+
}
128+
}
129+
130+
func refresh() {
131+
if let focusedWindow = focusedWindow as? WorkspaceXcodeWindowInspector {
132+
focusedWindow.refresh()
133+
} else {
134+
observeFocusedWindow()
135+
}
136+
}
137+
138+
func observeAXNotifications() {
139+
longRunningTasks.forEach { $0.cancel() }
140+
longRunningTasks = []
141+
142+
let focusedWindowChanged = Task {
143+
let notification = AXNotificationStream(
144+
app: runningApplication,
145+
notificationNames: kAXFocusedWindowChangedNotification
146+
)
147+
for await _ in notification {
148+
try Task.checkCancellation()
149+
observeFocusedWindow()
150+
}
151+
}
152+
153+
longRunningTasks.insert(focusedWindowChanged)
154+
155+
updateWorkspaceInfo()
156+
let updateTabsTask = Task { @MainActor in
157+
let notification = AXNotificationStream(
158+
app: runningApplication,
159+
notificationNames: kAXFocusedUIElementChangedNotification,
160+
kAXApplicationDeactivatedNotification
161+
)
162+
if #available(macOS 13.0, *) {
163+
for await _ in notification.debounce(for: .seconds(2)) {
164+
try Task.checkCancellation()
165+
updateWorkspaceInfo()
166+
}
167+
} else {
168+
for await _ in notification {
169+
try Task.checkCancellation()
170+
updateWorkspaceInfo()
171+
}
172+
}
173+
}
174+
175+
longRunningTasks.insert(updateTabsTask)
176+
177+
let completionPanelTask = Task {
178+
let stream = AXNotificationStream(
179+
app: runningApplication,
180+
notificationNames: kAXCreatedNotification, kAXUIElementDestroyedNotification
181+
)
182+
183+
for await event in stream {
184+
// We can only observe the creation and closing of the parent
185+
// of the completion panel.
186+
let isCompletionPanel = {
187+
event.element.firstChild { element in
188+
element.identifier == "_XC_COMPLETION_TABLE_"
189+
} != nil
190+
}
191+
switch event.name {
192+
case kAXCreatedNotification:
193+
if isCompletionPanel() {
194+
completionPanel = event.element
195+
}
196+
case kAXUIElementDestroyedNotification:
197+
if isCompletionPanel() {
198+
completionPanel = nil
199+
}
200+
default: break
201+
}
202+
203+
try Task.checkCancellation()
204+
}
205+
}
206+
207+
longRunningTasks.insert(completionPanelTask)
208+
}
209+
}
210+
211+
// MARK: - Workspace Info
212+
213+
extension XcodeAppInstanceInspector {
214+
public enum WorkspaceIdentifier: Hashable {
215+
case url(URL)
216+
case unknown
217+
}
218+
219+
public class Workspace {
220+
public let element: AXUIElement
221+
public var info: WorkspaceInfo
222+
223+
/// When a window is closed, all it's properties will be set to nil.
224+
/// Since we can't get notification for window closing,
225+
/// we will use it to check if the window is closed.
226+
var isValid: Bool {
227+
element.parent != nil
228+
}
229+
230+
init(element: AXUIElement) {
231+
self.element = element
232+
info = .init(tabs: [])
233+
}
234+
}
235+
236+
public struct WorkspaceInfo {
237+
public let tabs: Set<String>
238+
239+
public func combined(with info: WorkspaceInfo) -> WorkspaceInfo {
240+
return .init(tabs: tabs.union(info.tabs))
241+
}
242+
}
243+
244+
func updateWorkspaceInfo() {
245+
let workspaceInfoInVisibleSpace = Self.fetchVisibleWorkspaces(runningApplication)
246+
workspaces = Self.updateWorkspace(workspaces, with: workspaceInfoInVisibleSpace)
247+
}
248+
249+
/// Use the project path as the workspace identifier.
250+
static func workspaceIdentifier(_ window: AXUIElement) -> WorkspaceIdentifier {
251+
if let url = WorkspaceXcodeWindowInspector.extractWorkspaceURL(windowElement: window) {
252+
return WorkspaceIdentifier.url(url)
253+
}
254+
return WorkspaceIdentifier.unknown
255+
}
256+
257+
/// With Accessibility API, we can ONLY get the information of visible windows.
258+
static func fetchVisibleWorkspaces(
259+
_ app: NSRunningApplication
260+
) -> [WorkspaceIdentifier: Workspace] {
261+
let app = AXUIElementCreateApplication(app.processIdentifier)
262+
let windows = app.windows.filter { $0.identifier == "Xcode.WorkspaceWindow" }
263+
264+
var dict = [WorkspaceIdentifier: Workspace]()
265+
266+
for window in windows {
267+
let workspaceIdentifier = workspaceIdentifier(window)
268+
269+
let tabs = {
270+
guard let editArea = window.firstChild(where: { $0.description == "editor area" })
271+
else { return Set<String>() }
272+
var allTabs = Set<String>()
273+
let tabBars = editArea.children { $0.description == "tab bar" }
274+
for tabBar in tabBars {
275+
let tabs = tabBar.children { $0.roleDescription == "tab" }
276+
for tab in tabs {
277+
allTabs.insert(tab.title)
278+
}
279+
}
280+
return allTabs
281+
}()
282+
283+
let workspace = Workspace(element: window)
284+
workspace.info = .init(tabs: tabs)
285+
dict[workspaceIdentifier] = workspace
286+
}
287+
return dict
288+
}
289+
290+
static func updateWorkspace(
291+
_ old: [WorkspaceIdentifier: Workspace],
292+
with new: [WorkspaceIdentifier: Workspace]
293+
) -> [WorkspaceIdentifier: Workspace] {
294+
var updated = old.filter { $0.value.isValid } // remove closed windows.
295+
for (identifier, workspace) in new {
296+
if let existed = updated[identifier] {
297+
existed.info = workspace.info
298+
} else {
299+
updated[identifier] = workspace
300+
}
301+
}
302+
return updated
303+
}
304+
}
305+

0 commit comments

Comments
 (0)