listless

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

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