listless

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

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") + } +}