crossmate

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

commit fcf2241bd54e354a9280aab0b766f5f4132df3af
parent 45d598f0b3409015d32510a2ac64ecf9ca631754
Author: Michael Camilleri <[email protected]>
Date:   Fri,  3 Jul 2026 14:21:18 +0900

Improve speed of diagnostics log sharing

Diffstat:
MCrossmate/Views/Settings/DiagnosticsView.swift | 38++++++++++++++++++++++++++++++++------
1 file changed, 32 insertions(+), 6 deletions(-)

diff --git a/Crossmate/Views/Settings/DiagnosticsView.swift b/Crossmate/Views/Settings/DiagnosticsView.swift @@ -125,7 +125,7 @@ struct DiagnosticsView: View { .toolbar { ToolbarItemGroup(placement: .topBarTrailing) { ShareLink( - item: DiagnosticsShareFile(render: { diagnosticDump }), + item: DiagnosticsShareFile(snapshot: { diagnosticDump }), preview: SharePreview( "Crossmate Diagnostics", image: Image(systemName: "doc.text") @@ -224,7 +224,7 @@ struct DiagnosticsView: View { return "\(version) (\(build))" } - private var diagnosticDump: String { + private var diagnosticDump: DiagnosticsDump { var lines: [String] = [] lines.append("Version: \(versionText)") lines.append("Account Status: \(accountStatusText)") @@ -238,11 +238,33 @@ struct DiagnosticsView: View { lines.append("Last Error Code: \(syncMonitor.lastErrorCode.map(String.init) ?? "None")") lines.append("Last Error Description: \(syncMonitor.lastErrorDescription ?? "None")") lines.append("Recent Event Count: \(eventLog.entries.count)") + return DiagnosticsDump(headerLines: lines, entries: eventLog.entries) + } +} + +/// The dump's inputs, captured on the main actor so the expensive per-entry +/// formatting can run off it. `EventLogEntry` is a Sendable value type, so +/// taking the buffer is a cheap copy-on-write retain, not a data copy. +private struct DiagnosticsDump: Sendable { + let headerLines: [String] + let entries: [EventLogEntry] + + /// Formats the full dump. Runs nonisolated on purpose: tens of thousands + /// of entries make this the slow part of a share, and it sits between the + /// share tap and the sheet appearing — on the main actor it both stalls + /// the UI and queues behind it. The formatter is created locally so the + /// loop shares nothing across the isolation boundary. + func rendered() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "h:mm:ss a 'UTC'" + formatter.timeZone = TimeZone(secondsFromGMT: 0) + + var lines = headerLines lines.append("") lines.append("Recent Events (UTC):") - for entry in eventLog.entries { + for entry in entries { lines.append( - "\(TimestampFormatter.string(from: entry.timestamp, in: .utc)) [\(entry.level.uppercased())] \(entry.message)" + "\(formatter.string(from: entry.timestamp)) [\(entry.level.uppercased())] \(entry.message)" ) } return lines.joined(separator: "\n") @@ -253,14 +275,18 @@ struct DiagnosticsView: View { /// buffer spans a full day (tens of thousands of entries during a co-solve), /// so building the joined text and writing it to disk eagerly — as the view /// once did on every log event — is multi-MB of wasted work per second. +/// Only the cheap input snapshot runs on the main actor; the formatting and +/// disk write happen in the export's own background context, since ShareLink +/// won't present the sheet until this closure returns. private struct DiagnosticsShareFile: Transferable { - let render: @MainActor () -> String + let snapshot: @MainActor () -> DiagnosticsDump static var transferRepresentation: some TransferRepresentation { FileRepresentation(exportedContentType: .plainText) { file in + let dump = await file.snapshot() let url = FileManager.default.temporaryDirectory .appendingPathComponent("crossmate-diagnostics.txt") - try await file.render().write(to: url, atomically: true, encoding: .utf8) + try dump.rendered().write(to: url, atomically: true, encoding: .utf8) return SentTransferredFile(url) } }