Skip to content

Commit ad8196c

Browse files
committed
refactor: implement strategy pattern for multi-editor support
- Extract generic AppMonitor with EditorStrategy protocol - XcodeMonitor now inherits from AppMonitor for specialized features - Consolidate window inspection into XcodeStrategy - Reduce XcodeMonitor complexity from 252 to 58 lines - Maintain UI compatibility while enabling future multi-editor support
1 parent a2fc5b5 commit ad8196c

5 files changed

Lines changed: 338 additions & 271 deletions

File tree

AS2/AS2/AppMonitor.swift

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import Cocoa
2+
import Foundation
3+
import ApplicationServices
4+
5+
protocol WindowInspector: AnyObject {
6+
var documentURL: URL? { get }
7+
var workspaceURL: URL? { get }
8+
var projectURL: URL? { get }
9+
var isMainWorkWindow: Bool { get }
10+
11+
func refresh()
12+
}
13+
14+
protocol EditorStrategy {
15+
var displayName: String { get }
16+
var bundleIdentifier: String { get }
17+
18+
func shouldMonitor(_ app: NSRunningApplication) -> Bool
19+
func createWindowInspector(processId: pid_t, windowElement: AXUIElement) -> WindowInspector?
20+
}
21+
22+
struct AppInstance {
23+
let app: NSRunningApplication
24+
let strategy: EditorStrategy
25+
var windowInspector: WindowInspector?
26+
27+
var displayName: String {
28+
strategy.displayName
29+
}
30+
31+
var processId: pid_t {
32+
app.processIdentifier
33+
}
34+
}
35+
36+
@MainActor
37+
class AppMonitor: ObservableObject {
38+
@Published var monitoredApps: [String: AppInstance] = [:]
39+
@Published var activeApp: AppInstance?
40+
@Published var accessibilityPermissionGranted: Bool = false
41+
42+
private let strategies: [String: EditorStrategy]
43+
private let workspace = NSWorkspace.shared
44+
45+
init(strategies: [EditorStrategy]) {
46+
self.strategies = Dictionary(uniqueKeysWithValues: strategies.map { ($0.bundleIdentifier, $0) })
47+
48+
checkAccessibilityPermission()
49+
setupMonitoring()
50+
findExistingApps()
51+
startPeriodicCheck()
52+
}
53+
54+
private func startPeriodicCheck() {
55+
Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in
56+
Task { @MainActor in
57+
self?.refreshActiveState()
58+
}
59+
}
60+
}
61+
62+
@MainActor
63+
public func refreshActiveState() {
64+
let frontmostApp = workspace.frontmostApplication
65+
let currentlyActivePID = frontmostApp?.processIdentifier ?? -1
66+
67+
print("🔄 Periodic check - Frontmost: \(frontmostApp?.localizedName ?? "None") (PID: \(currentlyActivePID))")
68+
69+
let shouldBeActive = monitoredApps.values.first { instance in
70+
instance.processId == currentlyActivePID
71+
}
72+
73+
if shouldBeActive?.processId != activeApp?.processId {
74+
print("🔄 State correction needed!")
75+
print(" - Should be active: \(shouldBeActive?.processId ?? -1)")
76+
print(" - Currently tracked as active: \(activeApp?.processId ?? -1)")
77+
78+
activeApp = shouldBeActive
79+
80+
if let newActive = shouldBeActive, accessibilityPermissionGranted {
81+
monitorAppWindows(for: newActive)
82+
}
83+
} else if let currentActive = shouldBeActive, accessibilityPermissionGranted {
84+
monitorAppWindows(for: currentActive)
85+
}
86+
}
87+
88+
private func setupMonitoring() {
89+
NotificationCenter.default.addObserver(
90+
forName: NSWorkspace.didActivateApplicationNotification,
91+
object: nil,
92+
queue: .main
93+
) { [weak self] notification in
94+
self?.handleApplicationActivated(notification)
95+
}
96+
97+
NotificationCenter.default.addObserver(
98+
forName: NSWorkspace.didTerminateApplicationNotification,
99+
object: nil,
100+
queue: .main
101+
) { [weak self] notification in
102+
self?.handleApplicationTerminated(notification)
103+
}
104+
}
105+
106+
private func checkAccessibilityPermission() {
107+
accessibilityPermissionGranted = AXIsProcessTrusted()
108+
print("🔐 Accessibility Permission: \(accessibilityPermissionGranted ? "✅ Granted" : "❌ Not Granted")")
109+
110+
if !accessibilityPermissionGranted {
111+
print("💡 Request accessibility permission...")
112+
requestAccessibilityPermission()
113+
}
114+
}
115+
116+
private func requestAccessibilityPermission() {
117+
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true]
118+
AXIsProcessTrustedWithOptions(options as CFDictionary)
119+
}
120+
121+
private func findExistingApps() {
122+
let runningApps = workspace.runningApplications
123+
124+
for app in runningApps {
125+
if let strategy = findStrategy(for: app) {
126+
let instance = AppInstance(app: app, strategy: strategy, windowInspector: nil)
127+
monitoredApps[makeKey(for: app)] = instance
128+
print("📱 Found \(strategy.displayName): PID \(app.processIdentifier)")
129+
}
130+
}
131+
132+
let frontmostApp = workspace.frontmostApplication
133+
print("🔍 Current frontmost app: \(frontmostApp?.localizedName ?? "Unknown") (PID: \(frontmostApp?.processIdentifier ?? -1))")
134+
135+
activeApp = monitoredApps.values.first { instance in
136+
instance.processId == frontmostApp?.processIdentifier
137+
}
138+
139+
if let active = activeApp {
140+
print("✅ Active app: \(active.displayName) PID \(active.processId)")
141+
}
142+
143+
if accessibilityPermissionGranted, let activeApp = activeApp {
144+
Task { @MainActor in
145+
monitorAppWindows(for: activeApp)
146+
}
147+
}
148+
}
149+
150+
@MainActor
151+
private func monitorAppWindows(for appInstance: AppInstance) {
152+
guard accessibilityPermissionGranted else {
153+
print("❌ Cannot monitor windows - no accessibility permission")
154+
return
155+
}
156+
157+
let axApp = AXUIElementCreateApplication(appInstance.processId)
158+
159+
var focusedWindowElement: AnyObject?
160+
let result = AXUIElementCopyAttributeValue(axApp, kAXFocusedWindowAttribute as CFString, &focusedWindowElement)
161+
162+
guard result == .success, let windowElement = focusedWindowElement else {
163+
print("❌ Could not get focused window from \(appInstance.displayName)")
164+
return
165+
}
166+
167+
guard CFGetTypeID(windowElement) == AXUIElementGetTypeID() else {
168+
print("❌ Focused window element is not an AXUIElement")
169+
return
170+
}
171+
172+
let axWindowElement = windowElement as! AXUIElement
173+
174+
print("🪟 Found focused window for \(appInstance.displayName), creating inspector...")
175+
176+
if let windowInspector = appInstance.strategy.createWindowInspector(
177+
processId: appInstance.processId,
178+
windowElement: axWindowElement
179+
) {
180+
if windowInspector.isMainWorkWindow {
181+
print("✅ Found main work window for \(appInstance.displayName)")
182+
var updatedInstance = appInstance
183+
updatedInstance.windowInspector = windowInspector
184+
monitoredApps[makeKey(for: appInstance.app)] = updatedInstance
185+
186+
if activeApp?.processId == appInstance.processId {
187+
activeApp = updatedInstance
188+
}
189+
} else {
190+
print("📝 Found other window for \(appInstance.displayName) (not main work window)")
191+
}
192+
}
193+
}
194+
195+
private func findStrategy(for app: NSRunningApplication) -> EditorStrategy? {
196+
return strategies.values.first { strategy in
197+
strategy.shouldMonitor(app)
198+
}
199+
}
200+
201+
private func makeKey(for app: NSRunningApplication) -> String {
202+
return "\(app.bundleIdentifier ?? "unknown")_\(app.processIdentifier)"
203+
}
204+
205+
private func handleApplicationActivated(_ notification: Notification) {
206+
guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return }
207+
208+
print("📱 App activated: \(app.localizedName ?? "Unknown") (PID: \(app.processIdentifier))")
209+
210+
DispatchQueue.main.async { [weak self] in
211+
guard let self = self else { return }
212+
213+
if let strategy = self.findStrategy(for: app) {
214+
print("🔄 \(strategy.displayName) activated: PID \(app.processIdentifier)")
215+
216+
let key = self.makeKey(for: app)
217+
let instance = AppInstance(app: app, strategy: strategy, windowInspector: nil)
218+
self.monitoredApps[key] = instance
219+
self.activeApp = instance
220+
} else {
221+
if let previousActive = self.activeApp {
222+
print("📱 \(app.localizedName ?? "Unknown app") became active, \(previousActive.displayName) (PID: \(previousActive.processId)) backgrounded")
223+
self.activeApp = nil
224+
}
225+
}
226+
}
227+
}
228+
229+
private func handleApplicationTerminated(_ notification: Notification) {
230+
guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return }
231+
232+
if findStrategy(for: app) != nil {
233+
let key = makeKey(for: app)
234+
print("❌ App terminated: \(app.localizedName ?? "Unknown") PID \(app.processIdentifier)")
235+
monitoredApps.removeValue(forKey: key)
236+
237+
if activeApp?.processId == app.processIdentifier {
238+
activeApp = monitoredApps.values.first { $0.app.isActive }
239+
}
240+
}
241+
}
242+
}

AS2/AS2/ContentView.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import SwiftUI
33
struct ContentView: View {
44
@StateObject private var xcodeMonitor = XcodeMonitor()
55

6+
// Generic app monitor for multi-editor support (future use)
7+
// @StateObject private var appMonitor = AppMonitor(strategies: [XcodeStrategy(), VSCodeStrategy()])
8+
69
var body: some View {
710
VStack(spacing: 20) {
811
Text("🔍 Xcode Monitor")

0 commit comments

Comments
 (0)