ListlessMacApp.swift (19288B)
1 import AppKit 2 import SwiftUI 3 4 private enum MenuSelectors { 5 static let showSettingsWindow = Selector(("showSettingsWindow:")) 6 static let closeAll = Selector(("closeAll:")) 7 static let undo = Selector(("undo:")) 8 static let redo = Selector(("redo:")) 9 } 10 11 @MainActor 12 class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { 13 private let persistenceController: PersistenceController 14 private var syncDiagnosticsWindow: NSWindow? 15 private let coordinators = NSMapTable<NSWindow, WindowCoordinator>.weakToStrongObjects() 16 private static let appearanceModeKey = "appearanceMode" 17 private static let colorThemeKey = "colorTheme" 18 19 private let keyValueSyncBridge = KeyValueSyncBridge(keys: ["listName", "colorTheme"]) 20 21 private var keyWindowCoordinator: WindowCoordinator? { 22 guard let window = NSApp.keyWindow else { return nil } 23 return coordinators.object(forKey: window) 24 } 25 26 func coordinator(for window: NSWindow) -> WindowCoordinator? { 27 coordinators.object(forKey: window) 28 } 29 30 override init() { 31 let args = ProcessInfo.processInfo.arguments 32 let isUITesting = args.contains("UI_TESTING") 33 persistenceController = isUITesting ? PersistenceController(inMemory: true) : .shared 34 super.init() 35 36 if isUITesting, args.contains("SCREENSHOT_LIGHT") { 37 UserDefaults.standard.set(1, forKey: Self.appearanceModeKey) 38 } else if isUITesting, args.contains("SCREENSHOT_DARK") { 39 UserDefaults.standard.set(2, forKey: Self.appearanceModeKey) 40 } 41 } 42 43 func applicationDidFinishLaunching(_ notification: Notification) { 44 NSWindow.allowsAutomaticWindowTabbing = false 45 applyAppearanceMode(UserDefaults.standard.integer(forKey: Self.appearanceModeKey)) 46 keyValueSyncBridge.start() 47 installMainMenu() 48 if ProcessInfo.processInfo.arguments.contains("UI_TESTING") { 49 promoteAndShowWindowIfNeeded() 50 } 51 } 52 53 func applicationDidBecomeActive(_ notification: Notification) { 54 promoteAndShowWindowIfNeeded() 55 } 56 57 func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { 58 if NSApp.activationPolicy() == .accessory { 59 return false 60 } 61 if !flag { 62 promoteAndShowWindowIfNeeded() 63 } 64 return true 65 } 66 67 private func promoteAndShowWindowIfNeeded() { 68 NSApp.setActivationPolicy(.regular) 69 if !NSApp.windows.contains(where: { $0.isVisible }) { 70 openNewWindow() 71 } 72 } 73 74 func applicationDidUnhide(_ notification: Notification) { 75 NSApp.keyWindow?.makeFirstResponder(nil) 76 } 77 78 // MARK: - NSMenuItemValidation 79 // AppKit calls this automatically for each item targeting self, both when the 80 // menu opens and when keyboard shortcuts are evaluated. 81 82 func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { 83 switch menuItem.action { 84 case #selector(handleNewWindow), #selector(handleShowSyncDiagnostics): 85 return true 86 case #selector(handleAppearanceSystem), #selector(handleAppearanceLight), #selector(handleAppearanceDark): 87 let currentMode = UserDefaults.standard.integer(forKey: Self.appearanceModeKey) 88 let itemMode: Int = switch menuItem.action { 89 case #selector(handleAppearanceLight): 1 90 case #selector(handleAppearanceDark): 2 91 default: 0 92 } 93 menuItem.state = (currentMode == itemMode) ? .on : .off 94 return true 95 case #selector(handleThemeSelection(_:)): 96 let currentTheme = UserDefaults.standard.integer(forKey: Self.colorThemeKey) 97 menuItem.state = (currentTheme == menuItem.tag) ? .on : .off 98 return true 99 default: 100 break 101 } 102 guard let coord = keyWindowCoordinator else { return false } 103 switch menuItem.action { 104 case #selector(selectAll(_:)): return coord.canSelectAllItems 105 case #selector(cut(_:)): return coord.canCutSelectedItem 106 case #selector(copy(_:)): return coord.canCopySelectedItem 107 case #selector(paste(_:)): return coord.canPasteAfterSelectedItem 108 case #selector(handleDeleteItem): return coord.canDeleteSelectedItem 109 case #selector(handleMoveUp): return coord.canMoveSelectedItemUp 110 case #selector(handleMoveDown): return coord.canMoveSelectedItemDown 111 case #selector(handleMarkCompleted): 112 menuItem.title = coord.markCompletedTitle 113 return coord.canMarkSelectedItemCompleted 114 case #selector(handleClearCompleted): return coord.canClearCompletedItems 115 default: return true 116 } 117 } 118 119 // MARK: - Actions 120 121 @objc private func handleNewItem() { 122 if NSApp.windows.filter({ $0.isVisible }).isEmpty { 123 openNewWindow() 124 Task { @MainActor in 125 keyWindowCoordinator?.newItem?() 126 } 127 } else { 128 keyWindowCoordinator?.newItem?() 129 } 130 } 131 132 @objc func selectAll(_ sender: Any?) { 133 keyWindowCoordinator?.selectAllItems?() 134 } 135 136 @objc func cut(_ sender: Any?) { 137 keyWindowCoordinator?.cutSelectedItem?() 138 } 139 140 @objc func copy(_ sender: Any?) { 141 keyWindowCoordinator?.copySelectedItem?() 142 } 143 144 @objc func paste(_ sender: Any?) { 145 keyWindowCoordinator?.pasteAfterSelectedItem?() 146 } 147 148 @objc private func handleDeleteItem() { 149 keyWindowCoordinator?.deleteSelectedItem?() 150 } 151 152 @objc private func handleNewWindow() { 153 openNewWindow() 154 } 155 156 @objc private func handleMoveUp() { 157 keyWindowCoordinator?.moveSelectedItemUp?() 158 } 159 160 @objc private func handleMoveDown() { 161 keyWindowCoordinator?.moveSelectedItemDown?() 162 } 163 164 @objc private func handleMarkCompleted() { 165 keyWindowCoordinator?.markSelectedItemCompleted?() 166 } 167 168 @objc private func handleClearCompleted() { 169 keyWindowCoordinator?.clearCompletedItems?() 170 } 171 172 @objc func handleShowSyncDiagnostics() { 173 openSyncDiagnosticsWindow() 174 } 175 176 @objc private func handleContactSupport() { 177 NSWorkspace.shared.open(URL(string: "mailto:[email protected]?subject=Listless%20Support")!) 178 } 179 180 @objc private func handleAppearanceSystem() { setAppearanceMode(0) } 181 @objc private func handleAppearanceLight() { setAppearanceMode(1) } 182 @objc private func handleAppearanceDark() { setAppearanceMode(2) } 183 184 @objc private func handleThemeSelection(_ sender: NSMenuItem) { 185 UserDefaults.standard.set(sender.tag, forKey: Self.colorThemeKey) 186 } 187 188 private func setAppearanceMode(_ mode: Int) { 189 UserDefaults.standard.set(mode, forKey: Self.appearanceModeKey) 190 applyAppearanceMode(mode) 191 } 192 193 private func applyAppearanceMode(_ mode: Int) { 194 NSApp.appearance = switch mode { 195 case 1: NSAppearance(named: .aqua) 196 case 2: NSAppearance(named: .darkAqua) 197 default: nil 198 } 199 } 200 201 private func openNewWindow() { 202 let defaultContentSize = NSSize(width: 400, height: 350) 203 let windowCoordinator = WindowCoordinator() 204 let rootView = ItemListView( 205 store: ItemStore(persistenceController: persistenceController), 206 syncMonitor: persistenceController.syncMonitor, 207 windowCoordinator: windowCoordinator 208 ) 209 .environment(\.managedObjectContext, persistenceController.viewContext) 210 211 let window = NSWindow( 212 contentRect: NSRect(origin: .zero, size: defaultContentSize), 213 styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], 214 backing: .buffered, 215 defer: false 216 ) 217 window.contentViewController = NSHostingController(rootView: rootView) 218 window.title = "Items" 219 window.setContentSize(defaultContentSize) 220 window.minSize = NSSize(width: 320, height: 240) 221 window.titleVisibility = .hidden 222 window.titlebarAppearsTransparent = false 223 window.isReleasedWhenClosed = false 224 window.isRestorable = false 225 coordinators.setObject(windowCoordinator, forKey: window) 226 let referenceWindow = NSApp.orderedWindows.first { existingWindow in 227 existingWindow.isVisible && existingWindow.title == "Items" 228 } 229 position(window, relativeTo: referenceWindow) 230 NSApp.activate(ignoringOtherApps: true) 231 window.makeKeyAndOrderFront(nil) 232 window.makeFirstResponder(nil) 233 } 234 235 private func position(_ window: NSWindow, relativeTo referenceWindow: NSWindow?) { 236 guard let referenceWindow else { 237 window.center() 238 return 239 } 240 241 let offset: CGFloat = 28 242 var origin = NSPoint( 243 x: referenceWindow.frame.origin.x + offset, 244 y: referenceWindow.frame.origin.y - offset 245 ) 246 247 if let visibleFrame = referenceWindow.screen?.visibleFrame ?? NSScreen.main?.visibleFrame { 248 origin.x = min(max(origin.x, visibleFrame.minX), visibleFrame.maxX - window.frame.width) 249 origin.y = min(max(origin.y, visibleFrame.minY), visibleFrame.maxY - window.frame.height) 250 } 251 252 window.setFrameOrigin(origin) 253 } 254 255 private func openSyncDiagnosticsWindow() { 256 if let window = syncDiagnosticsWindow { 257 window.makeKeyAndOrderFront(nil) 258 NSApp.activate() 259 return 260 } 261 262 let defaultContentSize = NSSize(width: 760, height: 520) 263 let rootView = SyncDiagnosticsView(syncMonitor: persistenceController.syncMonitor) 264 let window = NSWindow( 265 contentRect: NSRect(origin: .zero, size: defaultContentSize), 266 styleMask: [.titled, .closable, .miniaturizable, .resizable], 267 backing: .buffered, 268 defer: false 269 ) 270 window.contentViewController = NSHostingController(rootView: rootView) 271 window.title = "iCloud Diagnostics" 272 window.setContentSize(defaultContentSize) 273 window.minSize = NSSize(width: 480, height: 320) 274 window.isReleasedWhenClosed = false 275 window.isRestorable = false 276 window.center() 277 window.makeKeyAndOrderFront(nil) 278 syncDiagnosticsWindow = window 279 NSApp.activate() 280 } 281 282 // MARK: - Main Menu 283 284 private func installMainMenu() { 285 let mainMenu = NSMenu() 286 let appName = ProcessInfo.processInfo.processName 287 288 let appMenu = NSMenu() 289 appMenu.addItem(withTitle: "About \(appName)", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "") 290 appMenu.addItem(NSMenuItem.separator()) 291 292 let settingsItem = NSMenuItem( 293 title: "Settingsā¦", 294 action: MenuSelectors.showSettingsWindow, 295 keyEquivalent: "," 296 ) 297 settingsItem.target = nil 298 appMenu.addItem(settingsItem) 299 appMenu.addItem(NSMenuItem.separator()) 300 301 let servicesItem = NSMenuItem(title: "Services", action: nil, keyEquivalent: "") 302 let servicesMenu = NSMenu(title: "Services") 303 servicesItem.submenu = servicesMenu 304 appMenu.addItem(servicesItem) 305 NSApp.servicesMenu = servicesMenu 306 307 appMenu.addItem(NSMenuItem.separator()) 308 appMenu.addItem(withTitle: "Hide \(appName)", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h") 309 310 let hideOthersItem = NSMenuItem( 311 title: "Hide Others", 312 action: #selector(NSApplication.hideOtherApplications(_:)), 313 keyEquivalent: "h" 314 ) 315 hideOthersItem.keyEquivalentModifierMask = [.command, .option] 316 appMenu.addItem(hideOthersItem) 317 appMenu.addItem(withTitle: "Show All", action: #selector(NSApplication.unhideAllApplications(_:)), keyEquivalent: "") 318 appMenu.addItem(NSMenuItem.separator()) 319 appMenu.addItem(withTitle: "Quit \(appName)", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") 320 321 let appMenuItem = NSMenuItem() 322 appMenuItem.submenu = appMenu 323 mainMenu.addItem(appMenuItem) 324 325 let fileMenu = NSMenu(title: "File") 326 let newItemEntity = NSMenuItem(title: "New Item", action: #selector(handleNewItem), keyEquivalent: "n") 327 newItemEntity.keyEquivalentModifierMask = [.command] 328 newItemEntity.target = self 329 fileMenu.addItem(newItemEntity) 330 fileMenu.addItem(NSMenuItem.separator()) 331 332 let newWindowItem = NSMenuItem(title: "New Window", action: #selector(handleNewWindow), keyEquivalent: "n") 333 newWindowItem.keyEquivalentModifierMask = [.command, .shift] 334 newWindowItem.target = self 335 fileMenu.addItem(newWindowItem) 336 fileMenu.addItem(NSMenuItem.separator()) 337 fileMenu.addItem(withTitle: "Close", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w") 338 339 let closeAllItem = NSMenuItem(title: "Close All", action: MenuSelectors.closeAll, keyEquivalent: "w") 340 closeAllItem.keyEquivalentModifierMask = [.command, .option] 341 closeAllItem.target = nil 342 fileMenu.addItem(closeAllItem) 343 344 let fileMenuItem = NSMenuItem(title: "File", action: nil, keyEquivalent: "") 345 fileMenuItem.submenu = fileMenu 346 mainMenu.addItem(fileMenuItem) 347 348 let editMenu = NSMenu(title: "Edit") 349 editMenu.addItem(withTitle: "Undo", action: MenuSelectors.undo, keyEquivalent: "z") 350 let redoItem = NSMenuItem(title: "Redo", action: MenuSelectors.redo, keyEquivalent: "z") 351 redoItem.keyEquivalentModifierMask = [.command, .shift] 352 editMenu.addItem(redoItem) 353 editMenu.addItem(NSMenuItem.separator()) 354 editMenu.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x") 355 editMenu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c") 356 editMenu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v") 357 editMenu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a") 358 let deleteItem = NSMenuItem(title: "Delete", action: #selector(handleDeleteItem), keyEquivalent: "\u{08}") 359 deleteItem.keyEquivalentModifierMask = [] 360 deleteItem.target = self 361 editMenu.addItem(deleteItem) 362 363 editMenu.addItem(NSMenuItem.separator()) 364 365 let moveUpItem = NSMenuItem(title: "Move Up", action: #selector(handleMoveUp), keyEquivalent: "\u{F700}") 366 moveUpItem.keyEquivalentModifierMask = [.command] 367 moveUpItem.target = self 368 editMenu.addItem(moveUpItem) 369 370 let moveDownItem = NSMenuItem(title: "Move Down", action: #selector(handleMoveDown), keyEquivalent: "\u{F701}") 371 moveDownItem.keyEquivalentModifierMask = [.command] 372 moveDownItem.target = self 373 editMenu.addItem(moveDownItem) 374 375 let markCompletedItem = NSMenuItem(title: "Mark as Complete", action: #selector(handleMarkCompleted), keyEquivalent: " ") 376 markCompletedItem.keyEquivalentModifierMask = [] 377 markCompletedItem.target = self 378 editMenu.addItem(markCompletedItem) 379 380 editMenu.addItem(NSMenuItem.separator()) 381 382 let clearCompletedItem = NSMenuItem(title: "Clear Completed", action: #selector(handleClearCompleted), keyEquivalent: "") 383 clearCompletedItem.target = self 384 editMenu.addItem(clearCompletedItem) 385 386 let editMenuItem = NSMenuItem(title: "Edit", action: nil, keyEquivalent: "") 387 editMenuItem.submenu = editMenu 388 mainMenu.addItem(editMenuItem) 389 390 let viewMenu = NSMenu(title: "View") 391 392 let appearanceMenu = NSMenu(title: "Appearance") 393 let systemItem = NSMenuItem(title: "System", action: #selector(handleAppearanceSystem), keyEquivalent: "") 394 systemItem.target = self 395 appearanceMenu.addItem(systemItem) 396 let lightItem = NSMenuItem(title: "Light", action: #selector(handleAppearanceLight), keyEquivalent: "") 397 lightItem.target = self 398 appearanceMenu.addItem(lightItem) 399 let darkItem = NSMenuItem(title: "Dark", action: #selector(handleAppearanceDark), keyEquivalent: "") 400 darkItem.target = self 401 appearanceMenu.addItem(darkItem) 402 let appearanceMenuItem = NSMenuItem(title: "Appearance", action: nil, keyEquivalent: "") 403 appearanceMenuItem.submenu = appearanceMenu 404 viewMenu.addItem(appearanceMenuItem) 405 viewMenu.addItem(NSMenuItem.separator()) 406 407 let themeMenu = NSMenu(title: "Theme") 408 for theme in ColorTheme.displayOrder { 409 let item = NSMenuItem(title: theme.displayName, action: #selector(handleThemeSelection(_:)), keyEquivalent: "") 410 item.tag = theme.rawValue 411 item.target = self 412 themeMenu.addItem(item) 413 } 414 let themeMenuItem = NSMenuItem(title: "Theme", action: nil, keyEquivalent: "") 415 themeMenuItem.submenu = themeMenu 416 viewMenu.addItem(themeMenuItem) 417 418 viewMenu.addItem(NSMenuItem.separator()) 419 420 let fullScreenItem = NSMenuItem(title: "Enter Full Screen", action: #selector(NSWindow.toggleFullScreen(_:)), keyEquivalent: "f") 421 fullScreenItem.keyEquivalentModifierMask = [.command, .control] 422 viewMenu.addItem(fullScreenItem) 423 let viewMenuItem = NSMenuItem(title: "View", action: nil, keyEquivalent: "") 424 viewMenuItem.submenu = viewMenu 425 mainMenu.addItem(viewMenuItem) 426 427 let windowMenu = NSMenu(title: "Window") 428 windowMenu.addItem(withTitle: "Minimize", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m") 429 windowMenu.addItem(withTitle: "Zoom", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "") 430 windowMenu.addItem(NSMenuItem.separator()) 431 let syncDiagnosticsItem = NSMenuItem( 432 title: "iCloud Diagnostics", 433 action: #selector(handleShowSyncDiagnostics), 434 keyEquivalent: "" 435 ) 436 syncDiagnosticsItem.target = self 437 windowMenu.addItem(syncDiagnosticsItem) 438 windowMenu.addItem(NSMenuItem.separator()) 439 windowMenu.addItem(withTitle: "Bring All to Front", action: #selector(NSApplication.arrangeInFront(_:)), keyEquivalent: "") 440 let windowMenuItem = NSMenuItem(title: "Window", action: nil, keyEquivalent: "") 441 windowMenuItem.submenu = windowMenu 442 mainMenu.addItem(windowMenuItem) 443 NSApp.windowsMenu = windowMenu 444 445 let helpMenu = NSMenu(title: "Help") 446 helpMenu.addItem(withTitle: "Contact Support", action: #selector(handleContactSupport), keyEquivalent: "") 447 let helpMenuItem = NSMenuItem(title: "Help", action: nil, keyEquivalent: "") 448 helpMenuItem.submenu = helpMenu 449 mainMenu.addItem(helpMenuItem) 450 NSApp.helpMenu = helpMenu 451 452 NSApp.mainMenu = mainMenu 453 } 454 } 455 456 @main 457 enum ListlessMacMain { 458 static func main() { 459 let app = NSApplication.shared 460 let delegate = AppDelegate() 461 app.setActivationPolicy(.accessory) 462 app.delegate = delegate 463 withExtendedLifetime(delegate) { 464 app.run() 465 } 466 } 467 }