listless

A simple list app for Apple platforms
Log | Files | Refs | README | LICENSE

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 }