← All repositories

listless

A simple list app for Apple platforms

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


commit e43cf89cf1a1f2d828ac0f8949293788d77606c6
parent 97866551bb875f5440cd5df5af792b508f2506e3
Author: Michael Camilleri <[email protected]>
Date:   Sat, 28 Feb 2026 01:24:05 +0900

Add debugging for CloudKit

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

Diffstat

MListless/Sync/CloudKitSyncMonitor.swift | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MListlessiOS/Views/TaskListView.swift | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

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

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

@@ -1,13 +1,32 @@
 import CoreData
 import Foundation
+import os
+
+struct SyncDiagnosticEntry: Identifiable {
+    let id = UUID()
+    let timestamp: Date
+    let level: String
+    let message: String
+}
 
 @MainActor
 final class CloudKitSyncMonitor: ObservableObject {
     @Published private(set) var transientErrorMessage: String?
     @Published var actionableAlert: SyncAlertItem?
+    @Published private(set) var lastSuccessfulSyncDate: Date?
+    @Published private(set) var lastCloudKitErrorDomain: String?
+    @Published private(set) var lastCloudKitErrorCode: Int?
+    @Published private(set) var lastCloudKitErrorDescription: String?
+    @Published private(set) var recentDiagnostics: [SyncDiagnosticEntry] = []
 
     private var monitoringTask: Task<Void, Never>?
     private var deferredTask: Task<Void, Never>?
+    private let logger = Logger(subsystem: "net.inqk.listless", category: "CloudKitSync")
+    private let maxDiagnosticEntries = 80
+
+    var hasDiagnosticsIssue: Bool {
+        transientErrorMessage != nil || actionableAlert != nil || lastCloudKitErrorDescription != nil
+    }
 
     deinit {
         monitoringTask?.cancel()
@@ -16,6 +35,8 @@ final class CloudKitSyncMonitor: ObservableObject {
 
     func startMonitoring(container: NSPersistentCloudKitContainer) {
         guard monitoringTask == nil else { return }
+        logger.info("Starting CloudKit event monitoring")
+        appendDiagnostic(level: "info", "Starting CloudKit event monitoring")
 
         monitoringTask = Task { [weak self] in
             guard let self else { return }
@@ -29,15 +50,30 @@ final class CloudKitSyncMonitor: ObservableObject {
                         notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey]
                         as? NSPersistentCloudKitContainer.Event
                 else {
+                    self.logger.error("Received CloudKit eventChangedNotification without event payload")
+                    self.appendDiagnostic(
+                        level: "error",
+                        "Received eventChangedNotification without event payload"
+                    )
                     continue
                 }
 
+                let eventDescription =
+                    "CloudKit event: type=\(self.eventTypeName(event.type)) endDatePresent=\(event.endDate != nil)"
+                self.logger.debug("\(eventDescription, privacy: .public)")
+                self.appendDiagnostic(level: "debug", eventDescription)
+
                 if let error = event.error {
+                    self.logCloudKitError(error)
                     self.handle(issue: CloudKitErrorClassifier.classify(error))
                 } else if self.isSuccessfulSyncCompletion(event) {
                     self.deferredTask?.cancel()
                     self.deferredTask = nil
                     self.transientErrorMessage = nil
+                    self.lastSuccessfulSyncDate = event.endDate ?? Date()
+                    let successDescription = "CloudKit sync succeeded: type=\(self.eventTypeName(event.type))"
+                    self.logger.info("\(successDescription, privacy: .public)")
+                    self.appendDiagnostic(level: "info", successDescription)
                 }
             }
         }
@@ -54,17 +90,30 @@ final class CloudKitSyncMonitor: ObservableObject {
     private func handle(issue: SyncIssue) {
         switch issue {
         case .transient(let message):
+            logger.warning("Transient sync issue: \(message, privacy: .public)")
+            appendDiagnostic(level: "warning", "Transient sync issue: \(message)")
             showTransient(message)
 
         case .deferred(let message):
+            logger.warning("Deferred sync issue scheduled: \(message, privacy: .public)")
+            appendDiagnostic(level: "warning", "Deferred sync issue scheduled: \(message)")
             guard deferredTask == nil else { return }
             deferredTask = Task {
                 try? await Task.sleep(for: .seconds(60))
                 guard !Task.isCancelled else { return }
                 showTransient(message)
+                self.logger.warning("Deferred sync issue surfaced: \(message, privacy: .public)")
+                self.appendDiagnostic(level: "warning", "Deferred sync issue surfaced: \(message)")
             }
 
         case .alert(let alert):
+            logger.error(
+                "Actionable sync alert: title=\(alert.title, privacy: .public) message=\(alert.message, privacy: .public)"
+            )
+            appendDiagnostic(
+                level: "error",
+                "Actionable sync alert: \(alert.title) - \(alert.message)"
+            )
             actionableAlert = alert
         }
     }
@@ -85,4 +134,39 @@ final class CloudKitSyncMonitor: ObservableObject {
             return false
         }
     }
+
+    private func eventTypeName(_ type: NSPersistentCloudKitContainer.EventType) -> String {
+        switch type {
+        case .setup:
+            return "setup"
+        case .import:
+            return "import"
+        case .export:
+            return "export"
+        @unknown default:
+            return "unknown"
+        }
+    }
+
+    private func logCloudKitError(_ error: Error) {
+        let nsError = error as NSError
+        lastCloudKitErrorDomain = nsError.domain
+        lastCloudKitErrorCode = nsError.code
+        lastCloudKitErrorDescription = nsError.localizedDescription
+        let message =
+            "CloudKit event error: domain=\(nsError.domain) code=\(nsError.code) description=\(nsError.localizedDescription) userInfo=\(String(describing: nsError.userInfo))"
+        logger.error(
+            """
+            \(message, privacy: .public)
+            """
+        )
+        appendDiagnostic(level: "error", message)
+    }
+
+    private func appendDiagnostic(level: String, _ message: String) {
+        recentDiagnostics.append(SyncDiagnosticEntry(timestamp: Date(), level: level, message: message))
+        if recentDiagnostics.count > maxDiagnosticEntries {
+            recentDiagnostics.removeFirst(recentDiagnostics.count - maxDiagnosticEntries)
+        }
+    }
 }

diff --git a/ListlessiOS/Views/TaskListView.swift b/ListlessiOS/Views/TaskListView.swift

@@ -1,4 +1,5 @@
 import SwiftUI
+import UIKit
 
 struct TaskListView: View, TaskListViewProtocol {
     struct FocusStateData {
@@ -12,6 +13,7 @@ struct TaskListView: View, TaskListViewProtocol {
         var pullToCreate = PullToCreateState()
         var pullUpOffset: CGFloat = 0
         var isDragging: Bool = false
+        var isShowingSyncDiagnostics = false
         var clearingTaskIDs: Set<UUID> = []
         var rowFrames: [UUID: CGRect] = [:]
     }
@@ -107,6 +109,13 @@ struct TaskListView: View, TaskListViewProtocol {
         )
     }
 
+    private var isShowingSyncDiagnosticsStateBinding: Binding<Bool> {
+        Binding(
+            get: { iState.isShowingSyncDiagnostics },
+            set: { iState.isShowingSyncDiagnostics = $0 }
+        )
+    }
+
     var vStackSpacing: CGFloat { 12 }
     var pullCreateThreshold: CGFloat { 70 }
     var isCompletelyEmpty: Bool { activeTasks.isEmpty && completedTasks.isEmpty }
@@ -154,6 +163,25 @@ struct TaskListView: View, TaskListViewProtocol {
             .overlay(alignment: .top) {
                 syncErrorBanner
             }
+            .overlay(alignment: .topTrailing) {
+                if syncMonitor.hasDiagnosticsIssue {
+                    Button {
+                        iState.isShowingSyncDiagnostics = true
+                    } label: {
+                        Label("Sync Details", systemImage: "exclamationmark.icloud")
+                            .font(.caption)
+                            .padding(.horizontal, 10)
+                            .padding(.vertical, 7)
+                            .background(.thinMaterial)
+                            .clipShape(Capsule())
+                    }
+                    .padding(.top, 10)
+                    .padding(.trailing, 12)
+                }
+            }
+            .sheet(isPresented: isShowingSyncDiagnosticsStateBinding) {
+                SyncDiagnosticsView(syncMonitor: syncMonitor)
+            }
             .alert(
                 item: Binding(
                     get: { syncMonitor.actionableAlert },
@@ -320,3 +348,102 @@ struct TaskListView: View, TaskListViewProtocol {
         )
     }
 }
+
+private struct SyncDiagnosticsView: View {
+    @ObservedObject var syncMonitor: CloudKitSyncMonitor
+    @Environment(\.dismiss) private var dismiss
+
+    private static let timestampFormatter: DateFormatter = {
+        let formatter = DateFormatter()
+        formatter.dateStyle = .none
+        formatter.timeStyle = .medium
+        return formatter
+    }()
+
+    var body: some View {
+        NavigationStack {
+            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)
+                        }
+                    }
+                }
+            }
+            .navigationTitle("Sync Diagnostics")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .topBarLeading) {
+                    Button("Close") { dismiss() }
+                }
+                ToolbarItem(placement: .topBarTrailing) {
+                    Button("Copy") {
+                        UIPasteboard.general.string = diagnosticDump
+                    }
+                }
+            }
+        }
+    }
+
+    @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")
+    }
+}