← All repositories

listless

A simple list app for Apple platforms

git clone https://code.inqk.net/listless.git


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 {