commit 0b3b5073a635a57d9e456304c9b5fa4910f89375
parent ee7824c3e04964a634e0b988e91d9df7073b6b9f
Author: Michael Camilleri <[email protected]>
Date: Mon, 13 Apr 2026 00:36:17 +0900
Add sync monitor
After deploying a prototype to TestFlight for testing across my personal
devices, there is an issue with iCloud syncing. This commit adds
diagnostics that can be accessed via the Settings screen for further
analysis.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
Diffstat:
7 files changed, 346 insertions(+), 15 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -20,6 +20,7 @@
54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */; };
5AB52A0BA2934922EB94E5D1 /* UbiquityMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9876B49C8BE966CC2A37BF52 /* UbiquityMonitor.swift */; };
6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = B135C285570F91181595B405 /* CellMark.swift */; };
+ 6BFB5945FCCDEC64C431C2AC /* SyncDiagnosticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C90BA83B6DC7F435A7CF24 /* SyncDiagnosticsView.swift */; };
765B50552B13175F91A25EA1 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB4BB9E160C3A59C653E7A9 /* GridView.swift */; };
77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACC295195602B3DDF7BB3895 /* PersistenceController.swift */; };
7FFEACFC672925A0968ACC1C /* XD.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9031A1574C21866940F6A2C /* XD.swift */; };
@@ -39,6 +40,7 @@
C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = F93AC31640C40FCC039570A3 /* CrossmateModel.xcdatamodeld */; };
C6E0E5128565D3B822A41605 /* PendingChangePayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = E512D95B4518EE3DE6E350C0 /* PendingChangePayload.swift */; };
C7370BCAD585EEFD366204E3 /* GridThumbnailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */; };
+ CE033A7502E71066DB51EF0D /* SyncMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC00F4D5060EDA4859A6B3F1 /* SyncMonitor.swift */; };
CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */; };
CFCA3C2C3CF6D88AE844D7AD /* CellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */; };
D219A9ACC7C1FB305DA6A4CE /* NYTLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */; };
@@ -94,6 +96,7 @@
9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SyncState+Helpers.swift"; sourceTree = "<group>"; };
9A4B7C6A8A23C6E4CCEC759F /* BundledBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundledBrowseView.swift; sourceTree = "<group>"; };
A253416F4FEA271A80B22A73 /* NYTAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTAuthService.swift; sourceTree = "<group>"; };
+ AC00F4D5060EDA4859A6B3F1 /* SyncMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncMonitor.swift; sourceTree = "<group>"; };
ACC295195602B3DDF7BB3895 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; };
AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleView.swift; sourceTree = "<group>"; };
B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTPuzzleFetcher.swift; sourceTree = "<group>"; };
@@ -105,6 +108,7 @@
CAB4BB9E160C3A59C653E7A9 /* GridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridView.swift; sourceTree = "<group>"; };
D97CBA409832A24D64DF0F5C /* Crossmate Unit Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "Crossmate Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridThumbnailView.swift; sourceTree = "<group>"; };
+ D9C90BA83B6DC7F435A7CF24 /* SyncDiagnosticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncDiagnosticsView.swift; sourceTree = "<group>"; };
DB55FC337CF72C650373210A /* PlayerColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerColor.swift; sourceTree = "<group>"; };
DB851649DE78AAAC5A928C52 /* Square.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Square.swift; sourceTree = "<group>"; };
E512D95B4518EE3DE6E350C0 /* PendingChangePayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingChangePayload.swift; sourceTree = "<group>"; };
@@ -131,6 +135,7 @@
E512D95B4518EE3DE6E350C0 /* PendingChangePayload.swift */,
0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */,
73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */,
+ AC00F4D5060EDA4859A6B3F1 /* SyncMonitor.swift */,
9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */,
);
path = Sync;
@@ -224,6 +229,7 @@
07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */,
AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */,
74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */,
+ D9C90BA83B6DC7F435A7CF24 /* SyncDiagnosticsView.swift */,
);
path = Views;
sourceTree = "<group>";
@@ -401,7 +407,9 @@
CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */,
54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */,
AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */,
+ 6BFB5945FCCDEC64C431C2AC /* SyncDiagnosticsView.swift in Sources */,
82918A74836E5076CBFA1592 /* SyncEngine.swift in Sources */,
+ CE033A7502E71066DB51EF0D /* SyncMonitor.swift in Sources */,
F46733AB3C72749A4A992667 /* SyncState+Helpers.swift in Sources */,
5AB52A0BA2934922EB94E5D1 /* UbiquityMonitor.swift in Sources */,
7FFEACFC672925A0968ACC1C /* XD.swift in Sources */,
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -7,6 +7,7 @@ struct CrossmateApp: App {
@State private var store: GameStore
@State private var syncEngine: SyncEngine
+ @State private var syncMonitor = SyncMonitor()
@State private var nytAuth = NYTAuthService()
@State private var ubiquityMonitor = UbiquityMonitor()
private let persistence: PersistenceController
@@ -25,9 +26,14 @@ struct CrossmateApp: App {
var body: some Scene {
WindowGroup {
- RootView(store: store, syncEngine: syncEngine, appDelegate: appDelegate)
- .environment(nytAuth)
- .environment(ubiquityMonitor)
+ RootView(
+ store: store,
+ syncEngine: syncEngine,
+ syncMonitor: syncMonitor,
+ appDelegate: appDelegate
+ )
+ .environment(nytAuth)
+ .environment(ubiquityMonitor)
}
}
}
@@ -59,6 +65,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate, @unchecked Sendable {
struct RootView: View {
let store: GameStore
let syncEngine: SyncEngine
+ let syncMonitor: SyncMonitor
let appDelegate: AppDelegate
@Environment(NYTAuthService.self) private var nytAuth
@@ -73,6 +80,7 @@ struct RootView: View {
GameListView(
store: store,
syncEngine: syncEngine,
+ syncMonitor: syncMonitor,
appDelegate: appDelegate,
lastVisitedGameID: $lastVisitedGameID,
navigationPath: $navigationPath
@@ -81,7 +89,8 @@ struct RootView: View {
GameDestinationView(
gameID: gameID,
store: store,
- syncEngine: syncEngine
+ syncEngine: syncEngine,
+ syncMonitor: syncMonitor
)
.onAppear {
lastVisitedGameID = gameID
@@ -96,8 +105,10 @@ struct RootView: View {
ubiquityMonitor.start()
// Wire app delegate → sync engine fetch
- appDelegate.onRemoteNotification = { [syncEngine] in
- try? await syncEngine.fetchChanges()
+ appDelegate.onRemoteNotification = { [syncEngine, syncMonitor] in
+ await Self.run("remote-notification fetch", monitor: syncMonitor) {
+ try await syncEngine.fetchChanges()
+ }
}
// Wire sync engine → store → mutator (single inbox)
@@ -106,19 +117,50 @@ struct RootView: View {
}
// Bootstrap and initial sync
- try? await syncEngine.bootstrap()
- try? await syncEngine.fetchChanges()
- try? await syncEngine.pushChanges()
+ await Self.run("bootstrap", monitor: syncMonitor) {
+ try await syncEngine.bootstrap()
+ }
+ await Self.run("initial fetch", monitor: syncMonitor) {
+ try await syncEngine.fetchChanges()
+ }
+ await Self.run("initial push", monitor: syncMonitor) {
+ try await syncEngine.pushChanges()
+ }
+ await refreshSnapshot()
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
Task {
- try? await syncEngine.fetchChanges()
- try? await syncEngine.pushChanges()
+ await Self.run("foreground fetch", monitor: syncMonitor) {
+ try await syncEngine.fetchChanges()
+ }
+ await Self.run("foreground push", monitor: syncMonitor) {
+ try await syncEngine.pushChanges()
+ }
+ await refreshSnapshot()
}
}
}
}
+
+ private func refreshSnapshot() async {
+ let snapshot = await syncEngine.diagnosticSnapshot()
+ syncMonitor.updateSnapshot(snapshot)
+ }
+
+ static func run(
+ _ phase: String,
+ monitor: SyncMonitor,
+ _ body: @Sendable () async throws -> Void
+ ) async {
+ await MainActor.run { monitor.recordStart(phase) }
+ do {
+ try await body()
+ await MainActor.run { monitor.recordSuccess(phase) }
+ } catch {
+ await MainActor.run { monitor.recordError(phase, error) }
+ }
+ }
}
// MARK: - Game Destination
@@ -128,21 +170,25 @@ private struct GameDestinationView: View {
let gameID: UUID
let store: GameStore
let syncEngine: SyncEngine
+ let syncMonitor: SyncMonitor
@State private var session: PlayerSession?
@State private var loadError: String?
- init(gameID: UUID, store: GameStore, syncEngine: SyncEngine) {
+ init(gameID: UUID, store: GameStore, syncEngine: SyncEngine, syncMonitor: SyncMonitor) {
self.gameID = gameID
self.store = store
self.syncEngine = syncEngine
+ self.syncMonitor = syncMonitor
do {
let (game, mutator) = try store.loadGame(id: gameID)
let playerSession = PlayerSession(game: game, mutator: mutator)
- mutator.onLocalMutation = { [syncEngine] in
+ mutator.onLocalMutation = { [syncEngine, syncMonitor] in
Task {
- try? await syncEngine.pushChanges()
+ await RootView.run("local mutation push", monitor: syncMonitor) {
+ try await syncEngine.pushChanges()
+ }
}
}
self._session = State(initialValue: playerSession)
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -519,4 +519,39 @@ actor SyncEngine {
letterAuthorID: record["letterAuthorID"] as? String
)
}
+
+ // MARK: - Diagnostics
+
+ struct DiagnosticSnapshot: Sendable {
+ let accountStatus: CKAccountStatus
+ let zoneCreated: Bool
+ let subscriptionCreated: Bool
+ let outboxCount: Int
+ let hasDatabaseToken: Bool
+ let hasZoneToken: Bool
+ }
+
+ func diagnosticSnapshot() async -> DiagnosticSnapshot {
+ let accountStatus: CKAccountStatus
+ do {
+ accountStatus = try await container.accountStatus()
+ } catch {
+ accountStatus = .couldNotDetermine
+ }
+
+ let context = persistence.container.newBackgroundContext()
+ return context.performAndWait {
+ let state = SyncStateEntity.current(in: context)
+ let request = NSFetchRequest<PendingChangeEntity>(entityName: "PendingChangeEntity")
+ let outboxCount = (try? context.count(for: request)) ?? 0
+ return DiagnosticSnapshot(
+ accountStatus: accountStatus,
+ zoneCreated: state.zoneCreated,
+ subscriptionCreated: state.subscriptionCreated,
+ outboxCount: outboxCount,
+ hasDatabaseToken: state.privateDatabaseToken != nil,
+ hasZoneToken: state.privateZoneToken != nil
+ )
+ }
+ }
}
diff --git a/Crossmate/Sync/SyncMonitor.swift b/Crossmate/Sync/SyncMonitor.swift
@@ -0,0 +1,65 @@
+import CloudKit
+import Foundation
+import Observation
+
+struct SyncDiagnosticEntry: Identifiable, Sendable {
+ let id = UUID()
+ let timestamp: Date
+ let level: String
+ let message: String
+}
+
+@MainActor
+@Observable
+final class SyncMonitor {
+ private(set) var lastSuccessAt: Date?
+ private(set) var lastErrorPhase: String?
+ private(set) var lastErrorDomain: String?
+ private(set) var lastErrorCode: Int?
+ private(set) var lastErrorDescription: String?
+ private(set) var entries: [SyncDiagnosticEntry] = []
+ private(set) var snapshot: SyncEngine.DiagnosticSnapshot?
+
+ private let maxEntries = 80
+
+ func recordStart(_ phase: String) {
+ append(level: "info", "Starting \(phase)")
+ }
+
+ func recordSuccess(_ phase: String) {
+ lastSuccessAt = Date()
+ append(level: "info", "\(phase) succeeded")
+ }
+
+ func recordError(_ phase: String, _ error: Error) {
+ let nsError = error as NSError
+ lastErrorPhase = phase
+ lastErrorDomain = nsError.domain
+ lastErrorCode = nsError.code
+ lastErrorDescription = nsError.localizedDescription
+ let message = "\(phase) failed: domain=\(nsError.domain) code=\(nsError.code) \(nsError.localizedDescription)"
+ append(level: "error", message)
+ }
+
+ func note(_ message: String) {
+ append(level: "info", message)
+ }
+
+ func updateSnapshot(_ snapshot: SyncEngine.DiagnosticSnapshot) {
+ self.snapshot = snapshot
+ }
+
+ func clearLastError() {
+ lastErrorPhase = nil
+ lastErrorDomain = nil
+ lastErrorCode = nil
+ lastErrorDescription = nil
+ }
+
+ private func append(level: String, _ message: String) {
+ entries.append(SyncDiagnosticEntry(timestamp: Date(), level: level, message: message))
+ if entries.count > maxEntries {
+ entries.removeFirst(entries.count - maxEntries)
+ }
+ }
+}
diff --git a/Crossmate/Views/GameListView.swift b/Crossmate/Views/GameListView.swift
@@ -3,6 +3,7 @@ import SwiftUI
struct GameListView: View {
let store: GameStore
let syncEngine: SyncEngine
+ let syncMonitor: SyncMonitor
let appDelegate: AppDelegate
@Binding var lastVisitedGameID: UUID?
@Binding var navigationPath: NavigationPath
@@ -93,7 +94,7 @@ struct GameListView: View {
}
}
.sheet(isPresented: $showingSettings) {
- SettingsView()
+ SettingsView(syncEngine: syncEngine, syncMonitor: syncMonitor)
}
.sheet(isPresented: $showingNewGame) {
NewGameSheet(store: store) { id in
diff --git a/Crossmate/Views/SettingsView.swift b/Crossmate/Views/SettingsView.swift
@@ -1,6 +1,9 @@
import SwiftUI
struct SettingsView: View {
+ let syncEngine: SyncEngine
+ let syncMonitor: SyncMonitor
+
@Environment(NYTAuthService.self) private var nytAuth
@Environment(\.dismiss) private var dismiss
@@ -18,6 +21,12 @@ struct SettingsView: View {
signInView
}
}
+
+ Section("iCloud Sync") {
+ NavigationLink("Diagnostics") {
+ SyncDiagnosticsView(syncEngine: syncEngine, syncMonitor: syncMonitor)
+ }
+ }
}
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
diff --git a/Crossmate/Views/SyncDiagnosticsView.swift b/Crossmate/Views/SyncDiagnosticsView.swift
@@ -0,0 +1,167 @@
+import CloudKit
+import SwiftUI
+
+struct SyncDiagnosticsView: View {
+ let syncEngine: SyncEngine
+ let syncMonitor: SyncMonitor
+
+ @State private var isSyncing = false
+
+ private static let timestampFormatter: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.dateStyle = .none
+ formatter.timeStyle = .medium
+ return formatter
+ }()
+
+ var body: some View {
+ List {
+ Section("Status") {
+ row("Account Status", accountStatusText)
+ row("Zone Created", boolText(syncMonitor.snapshot?.zoneCreated))
+ row("Subscription Created", boolText(syncMonitor.snapshot?.subscriptionCreated))
+ row("Pending Outbox", syncMonitor.snapshot.map { String($0.outboxCount) } ?? "Unknown")
+ row("Database Token", boolText(syncMonitor.snapshot?.hasDatabaseToken))
+ row("Zone Token", boolText(syncMonitor.snapshot?.hasZoneToken))
+ row(
+ "Last Success",
+ syncMonitor.lastSuccessAt.map(Self.timestampFormatter.string(from:)) ?? "None"
+ )
+ row("Last Error Phase", syncMonitor.lastErrorPhase ?? "None")
+ row("Last Error Domain", syncMonitor.lastErrorDomain ?? "None")
+ row(
+ "Last Error Code",
+ syncMonitor.lastErrorCode.map(String.init) ?? "None"
+ )
+ row("Last Error Description", syncMonitor.lastErrorDescription ?? "None")
+ }
+
+ Section("Actions") {
+ Button {
+ Task { await runFullSync() }
+ } label: {
+ HStack {
+ Text("Sync Now")
+ if isSyncing {
+ Spacer()
+ ProgressView()
+ }
+ }
+ }
+ .disabled(isSyncing)
+ }
+
+ Section("Recent Events") {
+ if syncMonitor.entries.isEmpty {
+ Text("No events captured yet.")
+ .foregroundStyle(.secondary)
+ } else {
+ ForEach(syncMonitor.entries.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("iCloud Diagnostics")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button("Copy") {
+ UIPasteboard.general.string = diagnosticDump
+ }
+ }
+ }
+ .task {
+ let snapshot = await syncEngine.diagnosticSnapshot()
+ syncMonitor.updateSnapshot(snapshot)
+ }
+ }
+
+ // MARK: - Actions
+
+ private func runFullSync() async {
+ guard !isSyncing else { return }
+ isSyncing = true
+ defer { isSyncing = false }
+
+ await RootView.run("manual bootstrap", monitor: syncMonitor) {
+ try await syncEngine.bootstrap()
+ }
+ await RootView.run("manual fetch", monitor: syncMonitor) {
+ try await syncEngine.fetchChanges()
+ }
+ await RootView.run("manual push", monitor: syncMonitor) {
+ try await syncEngine.pushChanges()
+ }
+ let snapshot = await syncEngine.diagnosticSnapshot()
+ syncMonitor.updateSnapshot(snapshot)
+ }
+
+ // MARK: - Subviews
+
+ @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 accountStatusText: String {
+ guard let status = syncMonitor.snapshot?.accountStatus else { return "Unknown" }
+ switch status {
+ case .available: return "Available"
+ case .noAccount: return "No Account"
+ case .restricted: return "Restricted"
+ case .couldNotDetermine: return "Could Not Determine"
+ case .temporarilyUnavailable: return "Temporarily Unavailable"
+ @unknown default: return "Unknown"
+ }
+ }
+
+ private func boolText(_ value: Bool?) -> String {
+ guard let value else { return "Unknown" }
+ return value ? "Yes" : "No"
+ }
+
+ private var diagnosticDump: String {
+ var lines: [String] = []
+ lines.append("Account Status: \(accountStatusText)")
+ lines.append("Zone Created: \(boolText(syncMonitor.snapshot?.zoneCreated))")
+ lines.append("Subscription Created: \(boolText(syncMonitor.snapshot?.subscriptionCreated))")
+ lines.append("Pending Outbox: \(syncMonitor.snapshot.map { String($0.outboxCount) } ?? "Unknown")")
+ lines.append("Database Token: \(boolText(syncMonitor.snapshot?.hasDatabaseToken))")
+ lines.append("Zone Token: \(boolText(syncMonitor.snapshot?.hasZoneToken))")
+ lines.append(
+ "Last Success: \(syncMonitor.lastSuccessAt.map(Self.timestampFormatter.string(from:)) ?? "None")"
+ )
+ lines.append("Last Error Phase: \(syncMonitor.lastErrorPhase ?? "None")")
+ lines.append("Last Error Domain: \(syncMonitor.lastErrorDomain ?? "None")")
+ lines.append("Last Error Code: \(syncMonitor.lastErrorCode.map(String.init) ?? "None")")
+ lines.append("Last Error Description: \(syncMonitor.lastErrorDescription ?? "None")")
+ lines.append("")
+ lines.append("Recent Events:")
+ for entry in syncMonitor.entries {
+ lines.append(
+ "\(Self.timestampFormatter.string(from: entry.timestamp)) [\(entry.level.uppercased())] \(entry.message)"
+ )
+ }
+ return lines.joined(separator: "\n")
+ }
+}