Skip to content

Commit 8419b80

Browse files
committed
Merge branch 'feature/handle-ax-notifications-universally' into develop
2 parents 4ad9263 + 5bdc538 commit 8419b80

11 files changed

Lines changed: 362 additions & 253 deletions

File tree

Core/Sources/Service/RealtimeSuggestionController.swift

Lines changed: 25 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import ActiveApplicationMonitor
22
import AppKit
33
import AsyncAlgorithms
44
import AXExtension
5-
import AXNotificationStream
5+
import Combine
66
import Foundation
77
import Logger
88
import Preferences
@@ -11,21 +11,16 @@ import Workspace
1111
import XcodeInspector
1212

1313
public actor RealtimeSuggestionController {
14-
private var task: Task<Void, Error>?
14+
private var cancellable: Set<AnyCancellable> = []
1515
private var inflightPrefetchTask: Task<Void, Error>?
16-
private var windowChangeObservationTask: Task<Void, Error>?
17-
private var activeApplicationMonitorTask: Task<Void, Error>?
1816
private var editorObservationTask: Task<Void, Error>?
19-
private var focusedUIElement: AXUIElement?
2017
private var sourceEditor: SourceEditor?
2118

2219
init() {}
2320

2421
deinit {
25-
task?.cancel()
22+
cancellable.forEach { $0.cancel() }
2623
inflightPrefetchTask?.cancel()
27-
windowChangeObservationTask?.cancel()
28-
activeApplicationMonitorTask?.cancel()
2924
editorObservationTask?.cancel()
3025
}
3126

@@ -35,80 +30,35 @@ public actor RealtimeSuggestionController {
3530
}
3631

3732
private func observeXcodeChange() {
38-
task?.cancel()
39-
task = Task { [weak self] in
40-
if ActiveApplicationMonitor.shared.activeXcode != nil {
41-
await self?.handleXcodeChanged()
42-
}
43-
var previousApp = ActiveApplicationMonitor.shared.activeXcode?.info
44-
for await app in ActiveApplicationMonitor.shared.createInfoStream() {
33+
cancellable.forEach { $0.cancel() }
34+
35+
XcodeInspector.shared.$focusedEditor
36+
.sink { [weak self] editor in
4537
guard let self else { return }
46-
try Task.checkCancellation()
47-
defer { previousApp = app }
48-
49-
if let app = ActiveApplicationMonitor.shared.activeXcode,
50-
app.processIdentifier != previousApp?.processIdentifier
51-
{
52-
await self.handleXcodeChanged()
38+
Task {
39+
guard let editor else { return }
40+
await self.handleFocusElementChange(editor)
5341
}
54-
}
55-
}
56-
}
57-
58-
private func handleXcodeChanged() {
59-
guard let app = ActiveApplicationMonitor.shared.activeXcode else { return }
60-
windowChangeObservationTask?.cancel()
61-
windowChangeObservationTask = nil
62-
observeXcodeWindowChangeIfNeeded(app)
63-
}
64-
65-
private func observeXcodeWindowChangeIfNeeded(_ app: NSRunningApplication) {
66-
guard windowChangeObservationTask == nil else { return }
67-
handleFocusElementChange()
68-
69-
let notifications = AXNotificationStream(
70-
app: app,
71-
notificationNames: kAXFocusedUIElementChangedNotification,
72-
kAXMainWindowChangedNotification
73-
)
74-
windowChangeObservationTask = Task { [weak self] in
75-
for await _ in notifications {
76-
guard let self else { return }
77-
try Task.checkCancellation()
78-
await self.handleFocusElementChange()
79-
}
80-
}
42+
}.store(in: &cancellable)
8143
}
8244

83-
private func handleFocusElementChange() {
84-
guard let activeXcode = ActiveApplicationMonitor.shared.activeXcode else { return }
85-
let application = AXUIElementCreateApplication(activeXcode.processIdentifier)
86-
guard let focusElement = application.focusedElement else { return }
87-
let focusElementType = focusElement.description
88-
focusedUIElement = focusElement
89-
45+
private func handleFocusElementChange(_ sourceEditor: SourceEditor) {
9046
Task { // Notify suggestion service for open file.
9147
try await Task.sleep(nanoseconds: 500_000_000)
9248
guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return }
9349
_ = try await Service.shared.workspacePool
9450
.fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL)
9551
}
9652

97-
guard focusElementType == "Source Editor" else { return }
98-
sourceEditor = SourceEditor(runningApplication: activeXcode, element: focusElement)
53+
self.sourceEditor = sourceEditor
54+
55+
let notificationsFromEditor = sourceEditor.axNotifications
9956

10057
editorObservationTask?.cancel()
10158
editorObservationTask = nil
10259

103-
let notificationsFromEditor = AXNotificationStream(
104-
app: activeXcode,
105-
element: focusElement,
106-
notificationNames: kAXValueChangedNotification, kAXSelectedTextChangedNotification
107-
)
108-
10960
editorObservationTask = Task { [weak self] in
110-
guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return }
111-
if let sourceEditor = await self?.sourceEditor {
61+
if let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL {
11262
await PseudoCommandHandler().invalidateRealtimeSuggestionsIfNeeded(
11363
fileURL: fileURL,
11464
sourceEditor: sourceEditor
@@ -119,21 +69,22 @@ public actor RealtimeSuggestionController {
11969
guard let self else { return }
12070
try Task.checkCancellation()
12171

122-
switch notification.name {
123-
case kAXValueChangedNotification:
72+
switch notification.kind {
73+
case .valueChanged:
74+
Logger.service.debug("Receive valueChanged from editor")
12475
await cancelInFlightTasks()
12576
await self.triggerPrefetchDebounced()
126-
await self.notifyEditingFileChange(editor: focusElement)
127-
case kAXSelectedTextChangedNotification:
128-
guard let sourceEditor = await sourceEditor,
129-
let fileURL = XcodeInspector.shared.activeDocumentURL
130-
else { continue }
77+
await self.notifyEditingFileChange(editor: sourceEditor.element)
78+
case .selectedTextChanged:
79+
Logger.service.debug("Receive selectedTextChanged from editor")
80+
guard let fileURL = XcodeInspector.shared.activeDocumentURL
81+
else { break }
13182
await PseudoCommandHandler().invalidateRealtimeSuggestionsIfNeeded(
13283
fileURL: fileURL,
13384
sourceEditor: sourceEditor
13485
)
13586
default:
136-
continue
87+
break
13788
}
13889
}
13990
}
@@ -179,8 +130,6 @@ public actor RealtimeSuggestionController {
179130
}
180131
if Task.isCancelled { return }
181132

182-
// Logger.service.info("Prefetch suggestions.")
183-
184133
// So the editor won't be blocked (after information are cached)!
185134
await PseudoCommandHandler().generateRealtimeSuggestions(sourceEditor: sourceEditor)
186135
}

Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift

Lines changed: 21 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import ActiveApplicationMonitor
22
import AppActivator
33
import AsyncAlgorithms
4-
import AXNotificationStream
54
import ComposableArchitecture
65
import Foundation
6+
import Logger
77
import Preferences
88
import SwiftUI
99
import Toast
@@ -345,39 +345,27 @@ public struct WidgetFeature: ReducerProtocol {
345345
}.cancellable(id: CancelID.observeUserDefaults, cancelInFlight: true)
346346

347347
case .observeWindowChange:
348-
guard let app = xcodeInspector.activeApplication else { return .none }
349-
guard app.isXcode else { return .none }
348+
guard let app = xcodeInspector.activeXcode else { return .none }
350349

351350
let documentURL = state.focusingDocumentURL
352351

353-
let notifications = AXNotificationStream(
354-
app: app.runningApplication,
355-
notificationNames:
356-
kAXApplicationActivatedNotification,
357-
kAXMovedNotification,
358-
kAXResizedNotification,
359-
kAXMainWindowChangedNotification,
360-
kAXFocusedWindowChangedNotification,
361-
kAXFocusedUIElementChangedNotification,
362-
kAXWindowMovedNotification,
363-
kAXWindowResizedNotification,
364-
kAXWindowMiniaturizedNotification,
365-
kAXWindowDeminiaturizedNotification
366-
)
352+
let notifications = app.axNotifications
367353

368354
return .run { send in
369355
await send(.observeEditorChange)
370356
await send(.panel(.switchToAnotherEditorAndUpdateContent))
371357

372358
for await notification in notifications {
359+
Logger.service.debug("Receive \(notification.kind) from Xcode")
360+
373361
try Task.checkCancellation()
374362

375363
// Hide the widgets before switching to another window/editor
376364
// so the transition looks better.
377365
if [
378-
kAXFocusedUIElementChangedNotification,
379-
kAXFocusedWindowChangedNotification,
380-
].contains(notification.name) {
366+
.focusedUIElementChanged,
367+
.focusedWindowChanged,
368+
].contains(notification.kind) {
381369
let newDocumentURL = xcodeInspector.realtimeActiveDocumentURL
382370
if documentURL != newDocumentURL {
383371
await send(.panel(.removeDisplayedContent))
@@ -388,11 +376,11 @@ public struct WidgetFeature: ReducerProtocol {
388376

389377
// update widgets.
390378
if [
391-
kAXFocusedUIElementChangedNotification,
392-
kAXApplicationActivatedNotification,
393-
kAXMainWindowChangedNotification,
394-
kAXFocusedWindowChangedNotification,
395-
].contains(notification.name) {
379+
.focusedUIElementChanged,
380+
.applicationActivated,
381+
.mainWindowChanged,
382+
.focusedWindowChanged,
383+
].contains(notification.kind) {
396384
await send(.updateWindowLocation(animated: false))
397385
await send(.updateWindowOpacity(immediately: false))
398386
await send(.observeEditorChange)
@@ -405,33 +393,20 @@ public struct WidgetFeature: ReducerProtocol {
405393
}.cancellable(id: CancelID.observeWindowChange, cancelInFlight: true)
406394

407395
case .observeEditorChange:
408-
guard let app = xcodeInspector.activeApplication else { return .none }
409-
let appElement = AXUIElementCreateApplication(
410-
app.runningApplication.processIdentifier
411-
)
412-
guard let focusedElement = appElement.focusedElement,
413-
focusedElement.description == "Source Editor",
414-
let scrollView = focusedElement.parent,
415-
let scrollBar = scrollView.verticalScrollBar
416-
else { return .none }
417-
418-
let selectionRangeChange = AXNotificationStream(
419-
app: app.runningApplication,
420-
element: focusedElement,
421-
notificationNames: kAXSelectedTextChangedNotification
422-
)
423-
let scroll = AXNotificationStream(
424-
app: app.runningApplication,
425-
element: scrollBar,
426-
notificationNames: kAXValueChangedNotification
427-
)
396+
guard let editor = xcodeInspector.focusedEditor else { return .none }
397+
398+
let selectionRangeChange = editor.axNotifications
399+
.filter { $0.kind == .selectedTextChanged }
400+
let scroll = editor.axNotifications
401+
.filter { $0.kind == .scrollPositionChanged }
428402

429403
return .run { send in
430404
if #available(macOS 13.0, *) {
431-
for await _ in merge(
405+
for await notification in merge(
432406
selectionRangeChange.debounce(for: Duration.milliseconds(500)),
433407
scroll
434408
) {
409+
Logger.service.debug("Receive \(notification.kind) from editor")
435410
guard xcodeInspector.latestActiveXcode != nil else { return }
436411
try Task.checkCancellation()
437412
await send(.updateWindowLocation(animated: false))

Core/Sources/SuggestionWidget/SuggestionWidgetController.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import ActiveApplicationMonitor
22
import AppKit
33
import AsyncAlgorithms
4-
import AXNotificationStream
54
import ChatTab
65
import Combine
76
import ComposableArchitecture

Pro

Submodule Pro updated from 861b86a to 31e43f1

Tool/Package.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ let package = Package(
6363
.package(url: "https://github.com/GottaGetSwifty/CodableWrappers", from: "2.0.7"),
6464
.package(url: "https://github.com/krzyzanowskim/STTextView", from: "0.8.21"),
6565
.package(url: "https://github.com/google/generative-ai-swift", from: "0.4.4"),
66+
.package(url: "https://github.com/sideeffect-io/AsyncExtensions", from: "0.5.2"),
6667

6768
// TreeSitter
6869
.package(url: "https://github.com/intitni/SwiftTreeSitter.git", branch: "main"),
@@ -167,6 +168,7 @@ let package = Package(
167168
"SuggestionModel",
168169
"AXNotificationStream",
169170
"Logger",
171+
.product(name: "AsyncExtensions", package: "AsyncExtensions"),
170172
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
171173
]
172174
),

Tool/Sources/AXNotificationStream/AXNotificationStream.swift

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ public final class AXNotificationStream: AsyncSequence {
1212
private var continuation: Continuation
1313
private let stream: Stream
1414

15+
private let file: StaticString
16+
private let line: UInt
17+
private let function: StaticString
18+
1519
public func makeAsyncIterator() -> Stream.AsyncIterator {
1620
stream.makeAsyncIterator()
1721
}
@@ -23,16 +27,32 @@ public final class AXNotificationStream: AsyncSequence {
2327
public convenience init(
2428
app: NSRunningApplication,
2529
element: AXUIElement? = nil,
26-
notificationNames: String...
30+
notificationNames: String...,
31+
file: StaticString = #file,
32+
line: UInt = #line,
33+
function: StaticString = #function
2734
) {
28-
self.init(app: app, element: element, notificationNames: notificationNames)
35+
self.init(
36+
app: app,
37+
element: element,
38+
notificationNames: notificationNames,
39+
file: file,
40+
line: line,
41+
function: function
42+
)
2943
}
3044

3145
public init(
3246
app: NSRunningApplication,
3347
element: AXUIElement? = nil,
34-
notificationNames: [String]
48+
notificationNames: [String],
49+
file: StaticString = #file,
50+
line: UInt = #line,
51+
function: StaticString = #function
3552
) {
53+
self.file = file
54+
self.line = line
55+
self.function = function
3656
var cont: Continuation!
3757
stream = Stream { continuation in
3858
cont = continuation
@@ -74,7 +94,7 @@ public final class AXNotificationStream: AsyncSequence {
7494
)
7595
}
7696

77-
Task { [weak self] in
97+
Task { @MainActor [weak self] in
7898
CFRunLoopAddSource(
7999
CFRunLoopGetMain(),
80100
AXObserverGetRunLoopSource(observer),
@@ -101,10 +121,12 @@ public final class AXNotificationStream: AsyncSequence {
101121
Logger.service.error("AXObserver: Action unsupported: \(name)")
102122
pendingRegistrationNames.remove(name)
103123
case .apiDisabled:
104-
Logger.service.error("AXObserver: Accessibility API disabled, will try again later")
124+
Logger.service
125+
.error("AXObserver: Accessibility API disabled, will try again later")
105126
retry -= 1
106127
case .invalidUIElement:
107-
Logger.service.error("AXObserver: Invalid UI element")
128+
Logger.service
129+
.error("AXObserver: Invalid UI element, notification name \(name)")
108130
pendingRegistrationNames.remove(name)
109131
case .invalidUIElementObserver:
110132
Logger.service.error("AXObserver: Invalid UI element observer")
@@ -116,10 +138,13 @@ public final class AXNotificationStream: AsyncSequence {
116138
Logger.service.error("AXObserver: Notification unsupported: \(name)")
117139
pendingRegistrationNames.remove(name)
118140
case .notificationAlreadyRegistered:
141+
Logger.service.info("AXObserver: Notification already registered: \(name)")
119142
pendingRegistrationNames.remove(name)
120143
default:
121144
Logger.service
122-
.error("AXObserver: Unrecognized error \(e) when registering \(name), will try again later")
145+
.error(
146+
"AXObserver: Unrecognized error \(e) when registering \(name), will try again later"
147+
)
123148
}
124149
}
125150
try await Task.sleep(nanoseconds: 1_500_000_000)

0 commit comments

Comments
 (0)