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