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:
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