ListlessMac/ListlessMacApp.swift (19288B)
1import AppKit
2import SwiftUI
3
4private 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
12class 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
79
80
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
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
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
457enum 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}