listless

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

commit 4ddaf159f80766a977db14ac5628084c32639808
parent 7c20caec64b2dd46da5c82c43e9119cfd7e5308a
Author: Michael Camilleri <[email protected]>
Date:   Thu, 12 Mar 2026 17:55:48 +0900

Fix race condition in window/menu interaction

There was a potential race condition due to the use of a shared menu
coordinator with multiple windows. This commit implements separate menu
coordinators for each window.

Co-Authored-By: Claude 4.6 Opus <[email protected]>

Diffstat:
MListlessMac/Helpers/AppCommands.swift | 3+--
MListlessMac/ListlessMacApp.swift | 43++++++++++++++++++++++++++++---------------
MListlessMac/Views/TaskListView.swift | 6++++--
3 files changed, 33 insertions(+), 19 deletions(-)

diff --git a/ListlessMac/Helpers/AppCommands.swift b/ListlessMac/Helpers/AppCommands.swift @@ -1,10 +1,9 @@ import Foundation // Bridges SwiftUI view state to AppKit menu items without using SwiftUI's Commands API. +// One instance per window; AppDelegate resolves the key window's coordinator at dispatch time. @MainActor final class MenuCoordinator { - static let shared = MenuCoordinator() - private init() {} // Actions — set by TaskListView on each relevant state change. var newTask: (() -> Void)? diff --git a/ListlessMac/ListlessMacApp.swift b/ListlessMac/ListlessMacApp.swift @@ -12,6 +12,12 @@ private enum MenuSelectors { class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { private let persistenceController: PersistenceController private var syncDiagnosticsWindow: NSWindow? + private let coordinators = NSMapTable<NSWindow, MenuCoordinator>.weakToStrongObjects() + + private var keyWindowCoordinator: MenuCoordinator? { + guard let window = NSApp.keyWindow else { return nil } + return coordinators.object(forKey: window) + } override init() { let isUITesting = ProcessInfo.processInfo.arguments.contains("UI_TESTING") @@ -52,9 +58,14 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { // menu opens and when keyboard shortcuts are evaluated. func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { - let coord = MenuCoordinator.shared switch menuItem.action { - case #selector(handleNewWindow): return true + case #selector(handleNewWindow), #selector(handleShowSyncDiagnostics): + return true + default: + break + } + guard let coord = keyWindowCoordinator else { return false } + switch menuItem.action { case #selector(cut(_:)): return coord.canCutSelectedTask case #selector(copy(_:)): return coord.canCopySelectedTask case #selector(paste(_:)): return coord.canPasteAfterSelectedTask @@ -65,7 +76,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { menuItem.title = coord.markCompletedTitle return coord.canMarkSelectedTaskCompleted case #selector(handleClearCompleted): return coord.canClearCompletedTasks - case #selector(handleShowSyncDiagnostics): return true default: return true } } @@ -75,28 +85,28 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { @objc private func handleNewTask() { if NSApp.windows.filter({ $0.isVisible }).isEmpty { openNewWindow() - DispatchQueue.main.async { - MenuCoordinator.shared.newTask?() + Task { @MainActor in + keyWindowCoordinator?.newTask?() } } else { - MenuCoordinator.shared.newTask?() + keyWindowCoordinator?.newTask?() } } @objc func cut(_ sender: Any?) { - MenuCoordinator.shared.cutSelectedTask?() + keyWindowCoordinator?.cutSelectedTask?() } @objc func copy(_ sender: Any?) { - MenuCoordinator.shared.copySelectedTask?() + keyWindowCoordinator?.copySelectedTask?() } @objc func paste(_ sender: Any?) { - MenuCoordinator.shared.pasteAfterSelectedTask?() + keyWindowCoordinator?.pasteAfterSelectedTask?() } @objc private func handleDeleteTask() { - MenuCoordinator.shared.deleteSelectedTask?() + keyWindowCoordinator?.deleteSelectedTask?() } @objc private func handleNewWindow() { @@ -104,19 +114,19 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { } @objc private func handleMoveUp() { - MenuCoordinator.shared.moveSelectedTaskUp?() + keyWindowCoordinator?.moveSelectedTaskUp?() } @objc private func handleMoveDown() { - MenuCoordinator.shared.moveSelectedTaskDown?() + keyWindowCoordinator?.moveSelectedTaskDown?() } @objc private func handleMarkCompleted() { - MenuCoordinator.shared.markSelectedTaskCompleted?() + keyWindowCoordinator?.markSelectedTaskCompleted?() } @objc private func handleClearCompleted() { - MenuCoordinator.shared.clearCompletedTasks?() + keyWindowCoordinator?.clearCompletedTasks?() } @objc func handleShowSyncDiagnostics() { @@ -125,9 +135,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { private func openNewWindow() { let defaultContentSize = NSSize(width: 400, height: 350) + let menuCoordinator = MenuCoordinator() let rootView = TaskListView( store: TaskStore(persistenceController: persistenceController), - syncMonitor: persistenceController.syncMonitor + syncMonitor: persistenceController.syncMonitor, + menuCoordinator: menuCoordinator ) .environment(\.managedObjectContext, persistenceController.viewContext) @@ -145,6 +157,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { window.titlebarAppearsTransparent = true window.isReleasedWhenClosed = false window.isRestorable = false + coordinators.setObject(menuCoordinator, forKey: window) let referenceWindow = NSApp.orderedWindows.first { existingWindow in existingWindow.isVisible && existingWindow.title == "Items" } diff --git a/ListlessMac/Views/TaskListView.swift b/ListlessMac/Views/TaskListView.swift @@ -21,6 +21,7 @@ struct TaskListView: View, TaskListViewProtocol { @Environment(\.managedObjectContext) var managedObjectContext let store: TaskStore + let menuCoordinator: MenuCoordinator @ObservedObject var syncMonitor: CloudKitSyncMonitor @FetchRequest( sortDescriptors: [], @@ -118,7 +119,7 @@ struct TaskListView: View, TaskListViewProtocol { } func updateMenuCoordinator() { - let coord = MenuCoordinator.shared + let coord = menuCoordinator coord.newTask = { createNewTask(); focusedField = nil } coord.copySelectedTask = { guard let taskID = selectedTaskID, @@ -157,9 +158,10 @@ struct TaskListView: View, TaskListViewProtocol { coord.canClearCompletedTasks = !completedTasks.isEmpty } - init(store: TaskStore, syncMonitor: CloudKitSyncMonitor) { + init(store: TaskStore, syncMonitor: CloudKitSyncMonitor, menuCoordinator: MenuCoordinator) { self.store = store self.syncMonitor = syncMonitor + self.menuCoordinator = menuCoordinator } func isRowLifted(_ taskID: UUID) -> Bool {