crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit 5f2b38ccd525a3400a329ec1a29df68634c706c8
parent e906a4d8af0dd987864faad69764bcc58016adad
Author: Michael Camilleri <[email protected]>
Date:   Sat,  2 May 2026 08:06:51 +0900

Add performance logging

This adds a crude performance logger to allow for diagnostics to be
performed on TestFlight builds.

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

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 8++++----
MCrossmate/CrossmateApp.swift | 2++
MCrossmate/Models/PlayerSession.swift | 87++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
MCrossmate/Services/AppServices.swift | 11+++++++++++
ACrossmate/Services/DebuggingMonitors.swift | 182+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Sync/MoveBuffer.swift | 42++++++++++++++++++++++++++++++++++++++++--
DCrossmate/Sync/SyncMonitor.swift | 78------------------------------------------------------------------------------
MCrossmate/Views/DiagnosticsView.swift | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Views/GridView.swift | 14++++++++++++++
MCrossmate/Views/SettingsView.swift | 3+++
10 files changed, 429 insertions(+), 91 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -65,7 +65,7 @@ C944A5BD871C6ECC64DE8A5B /* CalendarDayCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0CAA5E17BD406AFEEF96196 /* CalendarDayCell.swift */; }; CC250D6BA9B41CB722D8A62E /* CloudService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56BC76178319D0D669CD50FF /* CloudService.swift */; }; CCF2B0EBE9F414B5CA6AADBA /* NameBroadcaster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4803C2E84FC5B3BFE593171D /* NameBroadcaster.swift */; }; - CE033A7502E71066DB51EF0D /* SyncMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC00F4D5060EDA4859A6B3F1 /* SyncMonitor.swift */; }; + CCF3867C32C3F36E4F69A59E /* DebuggingMonitors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16E1DA8C1B4E73AFB779CC06 /* DebuggingMonitors.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 */; }; @@ -99,6 +99,7 @@ 0BF60C84D92A9024AC1A53FC /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = "<group>"; }; 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializer.swift; sourceTree = "<group>"; }; 14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossmateApp.swift; sourceTree = "<group>"; }; + 16E1DA8C1B4E73AFB779CC06 /* DebuggingMonitors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebuggingMonitors.swift; sourceTree = "<group>"; }; 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRosterTests.swift; sourceTree = "<group>"; }; 19D51F4A1CAEB849A09EC2C1 /* PresencePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresencePublisher.swift; sourceTree = "<group>"; }; 1F2BE43E18B1CC6AAD27DC6D /* NYTBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTBrowseView.swift; sourceTree = "<group>"; }; @@ -143,7 +144,6 @@ 9A4B7C6A8A23C6E4CCEC759F /* BundledBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundledBrowseView.swift; sourceTree = "<group>"; }; 9B2289E9F6A78502581886CD /* NameBroadcasterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameBroadcasterTests.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>"; }; ACD511B95227C7D57F32A2AC /* PresencePublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresencePublisherTests.swift; sourceTree = "<group>"; }; AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleView.swift; sourceTree = "<group>"; }; @@ -195,7 +195,6 @@ 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */, 5C74683332956B0D1CA37589 /* ShareController.swift */, 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */, - AC00F4D5060EDA4859A6B3F1 /* SyncMonitor.swift */, 9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */, ); path = Sync; @@ -346,6 +345,7 @@ children = ( CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */, 56BC76178319D0D669CD50FF /* CloudService.swift */, + 16E1DA8C1B4E73AFB779CC06 /* DebuggingMonitors.swift */, 70AD1A006E6D03E4429E3BF0 /* DriveMonitor.swift */, 462CE0FD356F6137C9BFD30F /* ImportService.swift */, 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */, @@ -496,6 +496,7 @@ B94919176DEC6EC31637B037 /* ClueList.swift in Sources */, DE9E4FAB098731A650F2D306 /* CrossmateApp.swift in Sources */, C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */, + CCF3867C32C3F36E4F69A59E /* DebuggingMonitors.swift in Sources */, 1CC2D062086FDC5894BFEFA2 /* DiagnosticsView.swift in Sources */, 978F91DBAE94BC5DA1D94705 /* DriveMonitor.swift in Sources */, 98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */, @@ -537,7 +538,6 @@ AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */, 740F5EC3331CA9DCCDA682F0 /* SuccessPanel.swift in Sources */, 82918A74836E5076CBFA1592 /* SyncEngine.swift in Sources */, - CE033A7502E71066DB51EF0D /* SyncMonitor.swift in Sources */, F46733AB3C72749A4A992667 /* SyncState+Helpers.swift in Sources */, 7FFEACFC672925A0968ACC1C /* XD.swift in Sources */, 9CB8808193A4A106D721D767 /* XDFileType.swift in Sources */, diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -21,6 +21,7 @@ struct CrossmateApp: App { .environment(\.managedObjectContext, services.persistence.viewContext) .environment(services.driveMonitor) .environment(services.syncMonitor) + .environment(services.performanceMonitor) .environment(\.syncEngine, services.syncEngine) .environment(services.nytAuth) .environment(\.nytPuzzleFetcher, services.nytFetcher) @@ -313,6 +314,7 @@ private struct PuzzleDisplayView: View { do { let (game, mutator) = try store.loadGame(id: gameID) let newSession = PlayerSession(game: game, mutator: mutator) + newSession.performanceMonitor = services.performanceMonitor if mutator.isShared { Task { await AppDelegate.requestNotificationAuthorizationIfNeeded() } roster = services.makePlayerRoster(for: gameID, preferences: preferences) diff --git a/Crossmate/Models/PlayerSession.swift b/Crossmate/Models/PlayerSession.swift @@ -34,6 +34,11 @@ final class PlayerSession { @ObservationIgnored var onCompletionStateChanged: ((Game.CompletionState) -> Void)? + @ObservationIgnored + var performanceMonitor: PerformanceMonitor? + + var renderProbeID: Int? + /// Rebus mode lets the player type a multi-character value into a single /// cell (e.g. "STAR" or "♥"). While active, keyboard input accumulates in /// `rebusBuffer` rather than going straight to `Game.squares`; on commit @@ -208,16 +213,30 @@ final class PlayerSession { // MARK: - Input func enter(_ letter: String) { - let cell = puzzle.cells[selectedRow][selectedCol] + let start = ContinuousClock.now + let inputRow = selectedRow + let inputCol = selectedCol + let cell = puzzle.cells[inputRow][inputCol] guard !cell.isBlock else { return } - mutator.setLetter(letter, atRow: selectedRow, atCol: selectedCol, pencil: isPencilMode) + let expectedEntry = letter.uppercased() + startRenderProbe( + "render.enter", + row: inputRow, + col: inputCol, + expectedEntry: expectedEntry, + detail: "letter=\(expectedEntry)" + ) + mutator.setLetter(letter, atRow: inputRow, atCol: inputCol, pencil: isPencilMode) let completionState = game.completionState publishTerminalCompletionState(completionState) - guard completionState != .solved else { return } - advance() + if completionState != .solved { + advance() + } + recordInputDuration("input.enter", start: start, detail: "letter=\(letter)") } func deleteBackward() { + let start = ContinuousClock.now // If the cursor is on an empty cell or a revealed (locked) cell, // retreat first — revealed cells can't be cleared in place, so delete // tunnels past them to the previous editable cell. `clearLetter` @@ -228,7 +247,16 @@ final class PlayerSession { if currentEmpty || currentMark.isRevealed { retreat() } + let inputRow = selectedRow + let inputCol = selectedCol + startRenderProbe( + "render.delete", + row: inputRow, + col: inputCol, + expectedEntry: "" + ) mutator.clearLetter(atRow: selectedRow, atCol: selectedCol) + recordInputDuration("input.delete", start: start) } // MARK: - Rebus @@ -250,14 +278,27 @@ final class PlayerSession { } func commitRebus() { + let start = ContinuousClock.now let value = rebusBuffer + let inputRow = selectedRow + let inputCol = selectedCol isRebusActive = false rebusBuffer = "" - mutator.setLetter(value, atRow: selectedRow, atCol: selectedCol, pencil: isPencilMode) + let expectedEntry = value.uppercased() + startRenderProbe( + "render.rebusCommit", + row: inputRow, + col: inputCol, + expectedEntry: expectedEntry, + detail: "length=\(expectedEntry.count)" + ) + mutator.setLetter(value, atRow: inputRow, atCol: inputCol, pencil: isPencilMode) let completionState = game.completionState publishTerminalCompletionState(completionState) - guard completionState != .solved else { return } - advance() + if completionState != .solved { + advance() + } + recordInputDuration("input.rebusCommit", start: start, detail: "length=\(value.count)") } // MARK: - Word geometry @@ -317,6 +358,38 @@ final class PlayerSession { onCompletionStateChanged?(state) } + private func recordInputDuration( + _ name: String, + start: ContinuousClock.Instant, + detail: String = "" + ) { + let duration = start.duration(to: .now) + let milliseconds = Double(duration.components.seconds) * 1000 + + Double(duration.components.attoseconds) / 1_000_000_000_000_000 + performanceMonitor?.record( + name, + durationMS: milliseconds, + detail: detail, + thresholdMS: 6 + ) + } + + private func startRenderProbe( + _ name: String, + row: Int, + col: Int, + expectedEntry: String, + detail: String = "" + ) { + guard let performanceMonitor else { return } + renderProbeID = performanceMonitor.beginRenderProbe( + name: name, + position: GridPosition(row: row, col: col), + expectedEntry: expectedEntry, + detail: detail + ) + } + private func advance() { let (dr, dc) = step(for: direction) let r = selectedRow + dr diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -8,6 +8,7 @@ final class AppServices { let store: GameStore let syncEngine: SyncEngine let syncMonitor: SyncMonitor + let performanceMonitor: PerformanceMonitor let nytAuth: NYTAuthService let driveMonitor: DriveMonitor let nytFetcher: NYTPuzzleFetcher @@ -36,6 +37,8 @@ final class AppServices { let syncEngine = SyncEngine(container: self.ckContainer, persistence: persistence) self.syncEngine = syncEngine self.syncMonitor = SyncMonitor() + let performanceMonitor = PerformanceMonitor() + self.performanceMonitor = performanceMonitor self.nytAuth = NYTAuthService() self.driveMonitor = DriveMonitor() self.nytFetcher = NYTPuzzleFetcher { NYTAuthService.currentCookie() } @@ -53,6 +56,14 @@ final class AppServices { sink: { moves in await syncEngine.enqueueMoves(moves) }, + performanceSink: { [performanceMonitor] name, durationMS, detail, thresholdMS in + await performanceMonitor.record( + name, + durationMS: durationMS, + detail: detail, + thresholdMS: thresholdMS + ) + }, afterFlush: { gameIDs in await store.replayCellCaches(for: gameIDs) let result = await store.createSnapshotsIfNeeded(for: gameIDs) diff --git a/Crossmate/Services/DebuggingMonitors.swift b/Crossmate/Services/DebuggingMonitors.swift @@ -0,0 +1,182 @@ +import CloudKit +import Foundation +import Observation + +struct PerformanceDiagnosticEntry: Identifiable, Sendable { + let id = UUID() + let timestamp: Date + let name: String + let durationMS: Double + let detail: String +} + +@MainActor +@Observable +final class PerformanceMonitor { + private(set) var entries: [PerformanceDiagnosticEntry] = [] + private(set) var totalEvents = 0 + private(set) var suppressedEvents = 0 + + private let maxEntries = 120 + private var nextRenderProbeID = 1 + private var pendingRenderProbes: [Int: RenderProbe] = [:] + + private struct RenderProbe { + let name: String + let position: GridPosition + let expectedEntry: String + let startedAt: ContinuousClock.Instant + let detail: String + } + + func record( + _ name: String, + durationMS: Double, + detail: String = "", + thresholdMS: Double = 8 + ) { + totalEvents += 1 + guard durationMS >= thresholdMS else { + suppressedEvents += 1 + return + } + entries.append(PerformanceDiagnosticEntry( + timestamp: Date(), + name: name, + durationMS: durationMS, + detail: detail + )) + if entries.count > maxEntries { + entries.removeFirst(entries.count - maxEntries) + } + } + + func note(_ name: String, detail: String = "") { + entries.append(PerformanceDiagnosticEntry( + timestamp: Date(), + name: name, + durationMS: 0, + detail: detail + )) + if entries.count > maxEntries { + entries.removeFirst(entries.count - maxEntries) + } + } + + func clear() { + entries.removeAll() + totalEvents = 0 + suppressedEvents = 0 + pendingRenderProbes.removeAll() + } + + func beginRenderProbe( + name: String, + position: GridPosition, + expectedEntry: String, + detail: String = "" + ) -> Int { + let id = nextRenderProbeID + nextRenderProbeID += 1 + pendingRenderProbes[id] = RenderProbe( + name: name, + position: position, + expectedEntry: expectedEntry, + startedAt: .now, + detail: detail + ) + return id + } + + func completeRenderProbe(id: Int, position: GridPosition, entry: String) { + guard let probe = pendingRenderProbes[id], + probe.position == position, + probe.expectedEntry == entry + else { return } + pendingRenderProbes.removeValue(forKey: id) + let duration = probe.startedAt.duration(to: .now) + let milliseconds = Double(duration.components.seconds) * 1000 + + Double(duration.components.attoseconds) / 1_000_000_000_000_000 + record( + probe.name, + durationMS: milliseconds, + detail: probe.detail, + thresholdMS: 6 + ) + } +} + +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 userInfoSummary = nsError.userInfo + .map { "\($0.key)=\($0.value)" } + .joined(separator: " | ") + let message = "\(phase) failed: domain=\(nsError.domain) code=\(nsError.code) \(nsError.localizedDescription) | userInfo: \(userInfoSummary)" + 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 + } + + func run(_ phase: String, _ body: @Sendable () async throws -> Void) async { + recordStart(phase) + do { + try await body() + recordSuccess(phase) + } catch { + recordError(phase, error) + } + } + + 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/Sync/MoveBuffer.swift b/Crossmate/Sync/MoveBuffer.swift @@ -31,6 +31,7 @@ actor MoveBuffer { private let sessionPingStaleInterval: TimeInterval private let persistence: PersistenceController private let sink: @Sendable ([Move]) async -> Void + private let performanceSink: (@Sendable (String, Double, String, Double) async -> Void)? private let afterFlush: (@Sendable (Set<UUID>) async -> Void)? private let sessionPingSink: (@Sendable (UUID, String) async -> Void)? @@ -58,6 +59,7 @@ actor MoveBuffer { sessionPingStaleInterval: TimeInterval = 30 * 60, persistence: PersistenceController, sink: @escaping @Sendable ([Move]) async -> Void, + performanceSink: (@Sendable (String, Double, String, Double) async -> Void)? = nil, afterFlush: (@Sendable (Set<UUID>) async -> Void)? = nil, sessionPingSink: (@Sendable (UUID, String) async -> Void)? = nil ) { @@ -65,6 +67,7 @@ actor MoveBuffer { self.sessionPingStaleInterval = sessionPingStaleInterval self.persistence = persistence self.sink = sink + self.performanceSink = performanceSink self.afterFlush = afterFlush self.sessionPingSink = sessionPingSink } @@ -161,22 +164,57 @@ actor MoveBuffer { order.removeAll(keepingCapacity: true) lastCell = nil - pendingSinkMoves.append(contentsOf: persistAndAssignLamports( + let persistStart = ContinuousClock.now + let persistedMoves = persistAndAssignLamports( snapshot: snapshot, order: snapshotOrder - )) + ) + pendingSinkMoves.append(contentsOf: persistedMoves) + await recordPerformance( + "moveBuffer.persist", + start: persistStart, + detail: "moves=\(persistedMoves.count) pendingCloudKit=\(pendingSinkMoves.count)", + thresholdMS: 4 + ) } guard runAfterFlush, !pendingSinkMoves.isEmpty else { return } let moves = pendingSinkMoves pendingSinkMoves.removeAll(keepingCapacity: true) + let sinkStart = ContinuousClock.now await sink(moves) + await recordPerformance( + "moveBuffer.cloudKitEnqueue", + start: sinkStart, + detail: "moves=\(moves.count)", + thresholdMS: 4 + ) if let afterFlush { + let afterFlushStart = ContinuousClock.now await afterFlush(Set(moves.map { $0.gameID })) + await recordPerformance( + "moveBuffer.afterFlush", + start: afterFlushStart, + detail: "games=\(Set(moves.map { $0.gameID }).count)", + thresholdMS: 8 + ) } } + private func recordPerformance( + _ name: String, + start: ContinuousClock.Instant, + detail: String, + thresholdMS: Double + ) async { + guard let performanceSink else { return } + let duration = start.duration(to: .now) + let milliseconds = Double(duration.components.seconds) * 1000 + + Double(duration.components.attoseconds) / 1_000_000_000_000_000 + await performanceSink(name, milliseconds, detail, thresholdMS) + } + /// Allocates lamports from each game's `lamportHighWater`, writes /// `MoveEntity` rows, and bumps the high-water — all inside a single /// background-context transaction so a crash can't leave the high-water diff --git a/Crossmate/Sync/SyncMonitor.swift b/Crossmate/Sync/SyncMonitor.swift @@ -1,78 +0,0 @@ -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 userInfoSummary = nsError.userInfo - .map { "\($0.key)=\($0.value)" } - .joined(separator: " | ") - let message = "\(phase) failed: domain=\(nsError.domain) code=\(nsError.code) \(nsError.localizedDescription) | userInfo: \(userInfoSummary)" - 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 - } - - func run(_ phase: String, _ body: @Sendable () async throws -> Void) async { - recordStart(phase) - do { - try await body() - recordSuccess(phase) - } catch { - recordError(phase, error) - } - } - - 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/DiagnosticsView.swift b/Crossmate/Views/DiagnosticsView.swift @@ -273,3 +273,96 @@ struct ShareDiagnosticsView: View { return lines.joined(separator: "\n") } } + +struct PerformanceDiagnosticsView: View { + @Environment(PerformanceMonitor.self) private var performanceMonitor + + private static let timestampFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .medium + return formatter + }() + + var body: some View { + List { + Section("Summary") { + row("Recorded Slow Events", String(performanceMonitor.entries.count)) + row("Measured Events", String(performanceMonitor.totalEvents)) + row("Suppressed Fast Events", String(performanceMonitor.suppressedEvents)) + } + + Section("Actions") { + Button("Clear Log", role: .destructive) { + performanceMonitor.clear() + } + } + + Section("Recent Slow Events") { + if performanceMonitor.entries.isEmpty { + Text("No slow events captured yet.") + .foregroundStyle(.secondary) + } else { + ForEach(performanceMonitor.entries.reversed()) { entry in + VStack(alignment: .leading, spacing: 4) { + Text( + "\(Self.timestampFormatter.string(from: entry.timestamp)) \(entry.name) \(formatMS(entry.durationMS))" + ) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + + if !entry.detail.isEmpty { + Text(entry.detail) + .font(.caption.monospaced()) + .textSelection(.enabled) + } + } + .padding(.vertical, 2) + } + } + } + } + .navigationTitle("Performance Diagnostics") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + 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 func formatMS(_ value: Double) -> String { + value == 0 ? "note" : String(format: "%.2f ms", value) + } + + private var diagnosticDump: String { + var lines: [String] = [] + lines.append("Recorded Slow Events: \(performanceMonitor.entries.count)") + lines.append("Measured Events: \(performanceMonitor.totalEvents)") + lines.append("Suppressed Fast Events: \(performanceMonitor.suppressedEvents)") + lines.append("") + lines.append("Recent Slow Events:") + for entry in performanceMonitor.entries { + let detail = entry.detail.isEmpty ? "" : " \(entry.detail)" + lines.append( + "\(Self.timestampFormatter.string(from: entry.timestamp)) \(entry.name) \(formatMS(entry.durationMS))\(detail)" + ) + } + return lines.joined(separator: "\n") + } +} diff --git a/Crossmate/Views/GridView.swift b/Crossmate/Views/GridView.swift @@ -3,6 +3,7 @@ import SwiftUI struct GridView: View { @Bindable var session: PlayerSession var roster: PlayerRoster? = nil + @Environment(PerformanceMonitor.self) private var performanceMonitor private let spacing: CGFloat = 1 @@ -42,11 +43,24 @@ struct GridView: View { .onTapGesture { session.select(row: r, col: c) } + .onChange(of: entryProbeValue(id: session.renderProbeID, entry: session.game.squares[r][c].entry)) { _, value in + guard let id = value.id else { return } + performanceMonitor.completeRenderProbe(id: id, position: pos, entry: value.entry) + } } } .background(Color.black) } + private struct EntryProbeValue: Equatable { + let id: Int? + let entry: String + } + + private func entryProbeValue(id: Int?, entry: String) -> EntryProbeValue { + EntryProbeValue(id: id, entry: entry) + } + /// Builds the focused-cell outline map and the word-tint map from each /// peer's selection. Conflicts (two peers on the same cell or word) are /// resolved by keeping the most recent `updatedAt`. diff --git a/Crossmate/Views/SettingsView.swift b/Crossmate/Views/SettingsView.swift @@ -26,6 +26,9 @@ struct SettingsView: View { NavigationLink("Share Diagnostics") { ShareDiagnosticsView() } + NavigationLink("Performance Diagnostics") { + PerformanceDiagnosticsView() + } Button("Reset Database", role: .destructive) { showResetConfirmation = true