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