-
Notifications
You must be signed in to change notification settings - Fork 38
Expand file tree
/
Copy pathCodMateApp.swift
More file actions
371 lines (341 loc) · 13.6 KB
/
CodMateApp.swift
File metadata and controls
371 lines (341 loc) · 13.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
import SwiftUI
import GhosttyKit
#if os(macOS)
import AppKit
#endif
@main
struct CodMateApp: App {
#if os(macOS)
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
#endif
@StateObject private var listViewModel: SessionListViewModel
@StateObject private var preferences: SessionPreferencesStore
@State private var settingsSelection: SettingCategory = .general
@State private var extensionsTabSelection: ExtensionsSettingsTab = .commands
@Environment(\.openWindow) private var openWindow
init() {
let prefs = SessionPreferencesStore()
let listVM = SessionListViewModel(preferences: prefs)
_preferences = StateObject(wrappedValue: prefs)
_listViewModel = StateObject(wrappedValue: listVM)
// Prepare user notifications early so banners can show while app is active
SystemNotifier.shared.bootstrap()
// Setup menu bar before windows appear
#if os(macOS)
MenuBarController.shared.configure(viewModel: listVM, preferences: prefs)
#endif
// In App Sandbox, restore security-scoped access to user-selected directories
SecurityScopedBookmarks.shared.restoreAndStartAccess()
// Restore all dynamic bookmarks (e.g., repository directories for Git Review)
SecurityScopedBookmarks.shared.restoreAllDynamicBookmarks()
// Restore and check sandbox permissions for critical directories
Task { @MainActor in
SandboxPermissionsManager.shared.restoreAccess()
}
// Sync launch at login state with system
Task { @MainActor in
LaunchAtLoginService.shared.syncWithPreferences(prefs)
}
// Daily update check (non-App Store builds only)
Task {
_ = await UpdateService.shared.checkIfNeeded(trigger: .appLaunch)
}
// Log startup info to Status Bar
Task { @MainActor in
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
AppLogger.shared.info("CodMate v\(version) started", source: "App")
}
}
var bodyCommands: some Commands {
Group {
CommandGroup(replacing: .appInfo) {
Button("About CodMate") { presentSettings(for: .about) }
}
CommandGroup(replacing: .appSettings) {
Button("Settings…") { presentSettings(for: .general) }
.keyboardShortcut(",", modifiers: [.command])
}
CommandGroup(after: .appSettings) {
Button("Global Search…") {
NotificationCenter.default.post(name: .codMateFocusGlobalSearch, object: nil)
}
.keyboardShortcut("f", modifiers: [.command])
}
// Integrate actions into the system View menu
CommandGroup(after: .sidebar) {
Button(action: {
NotificationCenter.default.post(
name: .codMateRefreshRequested,
object: nil,
userInfo: RefreshRequest.userInfo(for: .context)
)
}) {
Label("Refresh", systemImage: "arrow.clockwise")
}
.keyboardShortcut("r", modifiers: [.command])
Button(action: {
NotificationCenter.default.post(
name: .codMateRefreshRequested,
object: nil,
userInfo: RefreshRequest.userInfo(for: .global)
)
}) {
Label("Full Refresh", systemImage: "arrow.triangle.2.circlepath")
}
.keyboardShortcut("r", modifiers: [.command, .option])
Button(action: {
NotificationCenter.default.post(name: .codMateToggleSidebar, object: nil)
}) {
Label("Toggle Sidebar", systemImage: "sidebar.left")
}
.keyboardShortcut("1", modifiers: [.command])
Button(action: {
NotificationCenter.default.post(name: .codMateToggleList, object: nil)
}) {
Label("Toggle Session List", systemImage: "sidebar.leading")
}
.keyboardShortcut("2", modifiers: [.command])
Divider()
Button(action: {
withAnimation {
if preferences.statusBarVisibility == .hidden {
preferences.statusBarVisibility = .auto
} else {
preferences.statusBarVisibility = .hidden
}
}
}) {
if preferences.statusBarVisibility == .hidden {
Label("Show Status Bar", systemImage: "rectangle.bottomthird.inset.filled")
} else {
Label("Hide Status Bar", systemImage: "rectangle.bottomthird.inset.filled")
}
}
.keyboardShortcut("3", modifiers: [.command])
}
// Override Cmd+Q to use smart quit behavior
CommandGroup(replacing: .appTermination) {
Button("Quit CodMate") {
MenuBarController.shared.handleQuit()
}
.keyboardShortcut("q", modifiers: [.command])
}
}
}
var body: some Scene {
// Use Window instead of WindowGroup to enforce single instance
Window("CodMate", id: "main") {
ContentView(viewModel: listViewModel)
.frame(minWidth: 880, minHeight: 600)
.onReceive(NotificationCenter.default.publisher(for: .codMateOpenSettings)) { note in
let raw = note.userInfo?["category"] as? String
if let raw, let cat = SettingCategory(rawValue: raw) {
settingsSelection = cat
if cat == .mcpServer,
let tab = note.userInfo?["extensionsTab"] as? String,
let parsed = ExtensionsSettingsTab(rawValue: tab) {
extensionsTabSelection = parsed
}
} else {
settingsSelection = .general
}
if !bringWindow(identifier: "CodMateSettingsWindow") {
openWindow(id: "settings")
}
}
.onReceive(NotificationCenter.default.publisher(for: .codMateOpenMainWindow)) { _ in
// Window is singleton, so openWindow is idempotent
if !bringWindow(identifier: "CodMateMainWindow") {
openWindow(id: "main")
}
}
}
.defaultSize(width: 1200, height: 780)
.windowToolbarStyle(.unified) // Prevent toolbar KVO issues with Window singleton
.handlesExternalEvents(matching: []) // Prevent URL scheme from triggering new window creation
.commands { bodyCommands }
#if os(macOS)
Window("Settings", id: "settings") {
SettingsWindowContainer(
preferences: preferences,
listViewModel: listViewModel,
selection: $settingsSelection,
extensionsTab: $extensionsTabSelection
)
}
.defaultSize(width: 800, height: 640)
.windowStyle(.titleBar)
.windowToolbarStyle(.automatic)
.windowResizability(.contentMinSize)
.handlesExternalEvents(matching: []) // Prevent URL scheme from triggering new window creation
#endif
}
private func presentSettings(for category: SettingCategory) {
settingsSelection = category
if category == .mcpServer {
extensionsTabSelection = .mcp
}
#if os(macOS)
NSApplication.shared.activate(ignoringOtherApps: true)
#endif
if !bringWindow(identifier: "CodMateSettingsWindow") {
openWindow(id: "settings")
}
}
private func bringWindow(identifier: String) -> Bool {
#if os(macOS)
let id = NSUserInterfaceItemIdentifier(identifier)
if let window = NSApplication.shared.windows.first(where: { $0.identifier == id }) {
window.makeKeyAndOrderFront(nil)
return true
}
#endif
return false
}
}
private struct SettingsWindowContainer: View {
let preferences: SessionPreferencesStore
let listViewModel: SessionListViewModel
@Binding var selection: SettingCategory
@Binding var extensionsTab: ExtensionsSettingsTab
var body: some View {
SettingsView(preferences: preferences, selection: $selection, extensionsTab: $extensionsTab)
.environmentObject(listViewModel)
}
}
#if os(macOS)
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
private var suppressNextReopenActivation = false
private var suppressResetTask: Task<Void, Never>? = nil
func applicationDidFinishLaunching(_ notification: Notification) {
// Set activation policy based on saved preference
// Default to .visible (show Dock icon) unless user explicitly chose "Menu Bar Only"
let defaults = UserDefaults.standard
let rawVisibility = defaults.string(forKey: "codmate.systemMenu.visibility") ?? "visible"
let visibility = SystemMenuVisibility(rawValue: rawVisibility) ?? .visible
switch visibility {
case .menuOnly:
// Menu bar only mode - hide Dock icon
NSApp.setActivationPolicy(.accessory)
case .hidden, .visible:
// Show Dock icon so user can access the app
NSApp.setActivationPolicy(.regular)
}
// Start CLI Proxy Service if available
Task { @MainActor in
if CLIProxyService.shared.isBinaryInstalled {
do {
try await CLIProxyService.shared.start()
AppLogger.shared.info("CLIProxyAPI started successfully", source: "AppDelegate")
} catch {
// Get detailed logs from the service
let serviceLogs = CLIProxyService.shared.logs
let recentLogs = serviceLogs.split(separator: "\n").suffix(10).joined(separator: "\n")
AppLogger.shared.error("Failed to start CLIProxyAPI: \(error.localizedDescription)", source: "AppDelegate")
if !recentLogs.isEmpty {
AppLogger.shared.error("Recent service logs:\n\(recentLogs)", source: "AppDelegate")
}
CLIProxyService.shared.lastError = error.localizedDescription
}
} else {
AppLogger.shared.warning("CLIProxyAPI binary not installed, service will not start", source: "AppDelegate")
}
}
}
func application(_ application: NSApplication, open urls: [URL]) {
print("🔗 [AppDelegate] Received URLs: \(urls)")
print("🪟 [AppDelegate] Current windows count: \(application.windows.count)")
print("🪟 [AppDelegate] Visible windows: \(application.windows.filter { $0.isVisible }.count)")
let fileURLs = urls.filter { $0.isFileURL }
let nonFileURLs = urls.filter { !$0.isFileURL }
if let directoryURL = firstDirectoryURL(in: fileURLs) {
handleDockFolderDrop(directoryURL)
}
if nonFileURLs.contains(where: { $0.scheme?.lowercased() == "codmate" && ($0.host ?? "").lowercased() == "notify" }) {
suppressNextReopenActivation = true
suppressResetTask?.cancel()
suppressResetTask = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 1_000_000_000)
self?.suppressNextReopenActivation = false
}
}
if !nonFileURLs.isEmpty {
ExternalURLRouter.handle(nonFileURLs)
}
}
func application(_ sender: NSApplication, openFile filename: String) -> Bool {
handleDockFileOpenPaths([filename])
}
func application(_ sender: NSApplication, openFiles filenames: [String]) {
let handled = handleDockFileOpenPaths(filenames)
sender.reply(toOpenOrPrint: handled ? .success : .failure)
}
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool)
-> Bool
{
print("🔄 [AppDelegate] applicationShouldHandleReopen called, hasVisibleWindows: \(flag)")
if suppressNextReopenActivation {
suppressNextReopenActivation = false
return true
}
// Delegate to MenuBarController for unified window activation logic
// This ensures consistent behavior between Dock clicks and menu bar actions
MenuBarController.shared.handleDockIconClick()
// Always return true to prevent the system from creating new windows
// This is particularly important for notification forwarding triggered by URL scheme (codmate://)
return true
}
func applicationWillTerminate(_ notification: Notification) {
// Stop CLI Proxy Service
CLIProxyService.shared.stop()
// Clean up Ghostty sessions
// Note: Ghostty manages its own cleanup via deinit
// No explicit session termination needed here
}
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
// Ghostty sessions will be cleaned up automatically
// No need for confirmation dialog
return .terminateNow
}
private func firstDirectoryURL(in urls: [URL]) -> URL? {
for url in urls {
guard url.isFileURL else { continue }
var isDirectory: ObjCBool = false
if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory),
isDirectory.boolValue {
return url.standardizedFileURL
}
}
return nil
}
@MainActor
@discardableResult
private func handleDockFileOpenPaths(_ paths: [String]) -> Bool {
let urls = paths.map { URL(fileURLWithPath: $0) }
guard let directoryURL = firstDirectoryURL(in: urls) else { return false }
handleDockFolderDrop(directoryURL)
return true
}
@MainActor
private func handleDockFolderDrop(_ url: URL) {
let directory = url.path
let name = url.lastPathComponent
guard !directory.isEmpty else { return }
MenuBarController.shared.handleDockIconClick()
NotificationCenter.default.post(name: .codMateOpenMainWindow, object: nil)
Task {
await waitForMainWindow()
DockOpenCoordinator.shared.enqueueNewProject(directory: directory, name: name)
}
}
@MainActor
private func waitForMainWindow() async {
if MainWindowCoordinator.shared.hasAttachedWindow { return }
for _ in 0..<20 {
try? await Task.sleep(nanoseconds: 100_000_000)
if MainWindowCoordinator.shared.hasAttachedWindow { return }
}
}
}
#endif