← All repositories

listless

A simple list app for Apple platforms

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


commit 40cf91ecdd28388ba9fbb15d2bb73ed4cb1cbd70
parent f64cab2d0b52b6c34414f506e709e4f772b3b558
Author: Michael Camilleri <[email protected]>
Date:   Sun,  1 Mar 2026 07:21:04 +0900

Improve sync debugging on macOS

Co-Authored-By: Codex GPT 5.3 <[email protected]>

Diffstat

MListless/Sync/PersistenceController.swift | 9+++++++++
MListlessMac/ListlessMacApp.swift | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

2 files changed, 140 insertions(+), 0 deletions(-)

diff --git a/Listless/Sync/PersistenceController.swift b/Listless/Sync/PersistenceController.swift

@@ -69,6 +69,15 @@ final class PersistenceController {
                 fatalError("Failed to retrieve persistent store description")
             }
 
+#if DEBUG
+            if let storeURL = description.url {
+                // Keep debug builds isolated from TestFlight/App Store local Core Data files.
+                description.url = storeURL.deletingLastPathComponent().appendingPathComponent(
+                    "Listless-Debug.sqlite"
+                )
+            }
+#endif
+
             description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
                 containerIdentifier: "iCloud.net.inqk.listless"
             )

diff --git a/ListlessMac/ListlessMacApp.swift b/ListlessMac/ListlessMacApp.swift

@@ -11,6 +11,7 @@ private enum MenuSelectors {
 @MainActor
 class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
     private let persistenceController: PersistenceController
+    private var syncDiagnosticsWindow: NSWindow?
 
     override init() {
         let isUITesting = ProcessInfo.processInfo.arguments.contains("UI_TESTING")
@@ -46,6 +47,7 @@ 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
         }
     }
@@ -99,6 +101,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
         MenuCoordinator.shared.clearCompletedTasks?()
     }
 
+    @objc private func handleShowSyncDiagnostics() {
+        openSyncDiagnosticsWindow()
+    }
+
     private func openNewWindow() {
         let defaultContentSize = NSSize(width: 400, height: 350)
         let rootView = TaskListView(
@@ -126,6 +132,33 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
         NSApp.activate()
     }
 
+    private func openSyncDiagnosticsWindow() {
+        if let window = syncDiagnosticsWindow {
+            window.makeKeyAndOrderFront(nil)
+            NSApp.activate()
+            return
+        }
+
+        let defaultContentSize = NSSize(width: 760, height: 520)
+        let rootView = SyncDiagnosticsWindowView(syncMonitor: persistenceController.syncMonitor)
+        let window = NSWindow(
+            contentRect: NSRect(origin: .zero, size: defaultContentSize),
+            styleMask: [.titled, .closable, .miniaturizable, .resizable],
+            backing: .buffered,
+            defer: false
+        )
+        window.contentViewController = NSHostingController(rootView: rootView)
+        window.title = "Sync Diagnostics"
+        window.setContentSize(defaultContentSize)
+        window.minSize = NSSize(width: 480, height: 320)
+        window.isReleasedWhenClosed = false
+        window.isRestorable = false
+        window.center()
+        window.makeKeyAndOrderFront(nil)
+        syncDiagnosticsWindow = window
+        NSApp.activate()
+    }
+
     // MARK: - Main Menu
 
     private func installMainMenu() {
@@ -243,6 +276,14 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
         mainMenu.addItem(viewMenuItem)
 
         let windowMenu = NSMenu(title: "Window")
+        let syncDiagnosticsItem = NSMenuItem(
+            title: "Sync Diagnostics",
+            action: #selector(handleShowSyncDiagnostics),
+            keyEquivalent: ""
+        )
+        syncDiagnosticsItem.target = self
+        windowMenu.addItem(syncDiagnosticsItem)
+        windowMenu.addItem(NSMenuItem.separator())
         windowMenu.addItem(withTitle: "Minimize", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m")
         windowMenu.addItem(withTitle: "Zoom", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "")
         windowMenu.addItem(NSMenuItem.separator())
@@ -275,3 +316,93 @@ enum ListlessMacMain {
         }
     }
 }
+
+private struct SyncDiagnosticsWindowView: View {
+    @ObservedObject var syncMonitor: CloudKitSyncMonitor
+
+    private static let timestampFormatter: DateFormatter = {
+        let formatter = DateFormatter()
+        formatter.dateStyle = .none
+        formatter.timeStyle = .medium
+        return formatter
+    }()
+
+    var body: some View {
+        List {
+            Section("Status") {
+                row("Transient Banner", syncMonitor.transientErrorMessage ?? "None")
+                row("Last Error Domain", syncMonitor.lastCloudKitErrorDomain ?? "None")
+                row("Last Error Code", syncMonitor.lastCloudKitErrorCode.map(String.init) ?? "None")
+                row("Last Error Description", syncMonitor.lastCloudKitErrorDescription ?? "None")
+                row(
+                    "Last Success",
+                    syncMonitor.lastSuccessfulSyncDate.map(Self.timestampFormatter.string(from:)) ?? "None"
+                )
+            }
+
+            Section("Recent Events") {
+                if syncMonitor.recentDiagnostics.isEmpty {
+                    Text("No events captured yet.")
+                        .foregroundStyle(.secondary)
+                } else {
+                    ForEach(syncMonitor.recentDiagnostics.reversed()) { entry in
+                        VStack(alignment: .leading, spacing: 4) {
+                            Text(
+                                "\(Self.timestampFormatter.string(from: entry.timestamp)) [\(entry.level.uppercased())]"
+                            )
+                            .font(.caption.monospaced())
+                            .foregroundStyle(.secondary)
+
+                            Text(entry.message)
+                                .font(.caption.monospaced())
+                                .textSelection(.enabled)
+                        }
+                        .padding(.vertical, 2)
+                    }
+                }
+            }
+        }
+        .textSelection(.enabled)
+        .toolbar {
+            ToolbarItem {
+                Button("Copy") {
+                    let pasteboard = NSPasteboard.general
+                    pasteboard.clearContents()
+                    pasteboard.setString(diagnosticDump, forType: .string)
+                }
+            }
+        }
+    }
+
+    @ViewBuilder
+    private func row(_ title: String, _ value: String) -> some View {
+        VStack(alignment: .leading, spacing: 4) {
+            Text(title)
+                .font(.caption)
+                .foregroundStyle(.secondary)
+            Text(value)
+                .font(.body.monospaced())
+                .textSelection(.enabled)
+        }
+        .padding(.vertical, 2)
+    }
+
+    private var diagnosticDump: String {
+        var lines: [String] = []
+        lines.append("Transient Banner: \(syncMonitor.transientErrorMessage ?? "None")")
+        lines.append("Last Error Domain: \(syncMonitor.lastCloudKitErrorDomain ?? "None")")
+        lines.append("Last Error Code: \(syncMonitor.lastCloudKitErrorCode.map(String.init) ?? "None")")
+        lines.append("Last Error Description: \(syncMonitor.lastCloudKitErrorDescription ?? "None")")
+        lines.append(
+            "Last Success: \(syncMonitor.lastSuccessfulSyncDate.map(Self.timestampFormatter.string(from:)) ?? "None")"
+        )
+        lines.append("")
+        lines.append("Recent Events:")
+        for entry in syncMonitor.recentDiagnostics {
+            lines.append(
+                "\(Self.timestampFormatter.string(from: entry.timestamp)) [\(entry.level.uppercased())] \(entry.message)"
+            )
+        }
+        return lines.joined(separator: "\n")
+    }
+}