commit eb13c77f1af8ad246309c67e8bd1c513045b4822
parent b45286dd0d490f5e790edd96d7a9074fb637cf12
Author: Michael Camilleri <[email protected]>
Date: Mon, 6 Jul 2026 05:03:46 +0900
Add error reporting to the Invite Players screen
This commit reworks how the Invite Players screen surfaces a failure.
Previously every error was dumped verbatim in small red monospace text
under an 'Error' heading, exposing raw CloudKit detail and offering only
a 'Copy Error' button. Now an unexpected failure shows a single plain
message and a 'Report Error' button that opens Mail pre-addressed to the
Crossmate e-mail address with a diagnostics log attached — the
underlying detail is still available through 'Copy Error Details', and a
device with no Mail account falls back to copying the full report.
Expected ShareError cases keep their own wording and offer no report
affordance.
The attached log reuses the dump that already backs the Diagnostics
screen's 'Share Full Log', extracted into DiagnosticsReport so both
paths share one builder and one scrubbing policy. The share error is
scrubbed at its source in describe, so neither the report nor the
clipboard leaks a full game UUID, a zone or record name, or a share-link
token.
Separately, this commit stops writing the authorID field on the
seat-ticket Ping record. The v4 schema no longer defines that field, so
the write failed with 'Cannot create or modify field authorID in Ping in
production schema' and blocked share-link creation. The value was never
read back — the ticket's author data lives in its payload — so removing
the write restores sharing with no loss.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
7 files changed, 303 insertions(+), 87 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -96,6 +96,7 @@
689DAEC70934027E76E8116E /* KeyboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FDE73AD7C543B29C8E493F8 /* KeyboardView.swift */; };
6A1CA96FF48CBEEE78EA6D34 /* FriendModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B766E872B12DC79ECCD80941 /* FriendModelTests.swift */; };
6AE88D9E1918508DBF2A91E1 /* NotificationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D2FD896D75863554E31654C /* NotificationState.swift */; };
+ 6BD5C5B6BED06651312E2D01 /* MailComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F8AAA9540C80F787A1465C0 /* MailComposeView.swift */; };
6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = B135C285570F91181595B405 /* CellMark.swift */; };
6C091D30AAC9F63B7CE6FB58 /* AnnouncementCenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978A96CC6F550ED7A73F8D96 /* AnnouncementCenterTests.swift */; };
6D2AF361587E43D807BA212F /* NYTLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1747D4DCB4BCC831069BBE07 /* NYTLoginView.swift */; };
@@ -217,6 +218,7 @@
ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */; };
ED6C21CD9F5AB286B69A02E4 /* GridStateMerger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14B05C19BD4705876B3DF0EC /* GridStateMerger.swift */; };
F15591B48E4155CB19C1F084 /* TimeLogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7F35A7BFE52279BC24677F5 /* TimeLogTests.swift */; };
+ F1854B64C63447B7C7232097 /* DiagnosticsReport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D36E66AE489DC5C5FB85E19 /* DiagnosticsReport.swift */; };
F2F7CB23DA62BF714632B097 /* PushRequestAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAF6E3F3558E128E7A482A61 /* PushRequestAuthenticator.swift */; };
F34EDFD45E2F5006807DDAC7 /* PuzzleCatalogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8560440C548752EE93E0ED9 /* PuzzleCatalogTests.swift */; };
F46733AB3C72749A4A992667 /* SyncState+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */; };
@@ -275,6 +277,7 @@
0DF9C2FEF0D3584864DFC967 /* PuzzleNotificationText+GameEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PuzzleNotificationText+GameEntity.swift"; sourceTree = "<group>"; };
0E230B327585E1E3A2921C92 /* GameStoreCompletionLockTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreCompletionLockTests.swift; sourceTree = "<group>"; };
0EB332831AB173ACF6BFEC59 /* SessionMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionMonitor.swift; sourceTree = "<group>"; };
+ 0F8AAA9540C80F787A1465C0 /* MailComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailComposeView.swift; sourceTree = "<group>"; };
0FD9A43789D0ED123F7A99B0 /* CheckResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckResult.swift; sourceTree = "<group>"; };
10064D171DB7C48D3DE1E769 /* InviteCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteCoordinator.swift; sourceTree = "<group>"; };
11BF168D5C1CD85DAE5CAF9E /* PlayerSelectionPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSelectionPublisher.swift; sourceTree = "<group>"; };
@@ -297,6 +300,7 @@
298A9C54A1CC753E860E174E /* FriendsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendsView.swift; sourceTree = "<group>"; };
2A832061C19BA0F073617CA2 /* ImportedBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportedBrowseView.swift; sourceTree = "<group>"; };
2D2FD896D75863554E31654C /* NotificationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationState.swift; sourceTree = "<group>"; };
+ 2D36E66AE489DC5C5FB85E19 /* DiagnosticsReport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticsReport.swift; sourceTree = "<group>"; };
2D5B1E8E12B86DF6CA478F65 /* BadgeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeCoordinator.swift; sourceTree = "<group>"; };
2DD9C72266D1BAC43C8976C0 /* JournalUploadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JournalUploadTests.swift; sourceTree = "<group>"; };
2E2702C74378FD2F14D1CE33 /* FriendControllerNicknameReplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendControllerNicknameReplayTests.swift; sourceTree = "<group>"; };
@@ -501,6 +505,7 @@
07E5E4B165E374FEE732068B /* CloudDiagnostics.swift */,
16AAC1E8D2CB3B5117159934 /* CloudQuery.swift */,
44F86F0F1883A93F9622FB67 /* CloudZones.swift */,
+ 2D36E66AE489DC5C5FB85E19 /* DiagnosticsReport.swift */,
8FDE03B4A77A8095ED2C23AB /* EngagementCoordinator.swift */,
9B3E0C62CBF5ADF7C01C1D54 /* EngagementMessageAuthenticator.swift */,
E655698481325C92EF5C348B /* FriendController.swift */,
@@ -746,6 +751,7 @@
065CD67A1D9F7B63AE6B42D6 /* FriendAvatarView.swift */,
6B1F07B5DDE2A8B49B28392A /* GridThumbnailView.swift */,
836B8D4B351C9225162A82C0 /* Layouts.swift */,
+ 0F8AAA9540C80F787A1465C0 /* MailComposeView.swift */,
A61768D423564A9080414A38 /* NoticeView.swift */,
);
path = Components;
@@ -1105,6 +1111,7 @@
C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */,
C472EF02D8C7B0AC1D2284B8 /* CustomButtons.swift in Sources */,
CCF3867C32C3F36E4F69A59E /* DebuggingMonitors.swift in Sources */,
+ F1854B64C63447B7C7232097 /* DiagnosticsReport.swift in Sources */,
0F2992C16A3A658DEA0F707E /* DiagnosticsView.swift in Sources */,
978F91DBAE94BC5DA1D94705 /* DriveMonitor.swift in Sources */,
CABF8BFAA30B9F26C482FAB9 /* EngagementCoordinator.swift in Sources */,
@@ -1150,6 +1157,7 @@
38C913D00ED762BD9E355A2D /* KeychainHelper.swift in Sources */,
CF1DC343A5D3110EDFA703AB /* LastUpdatedView.swift in Sources */,
8D8A9F70731C98DD00BE1DA5 /* Layouts.swift in Sources */,
+ 6BD5C5B6BED06651312E2D01 /* MailComposeView.swift in Sources */,
AB6D98C7A78D91D7BEFB4A4C /* MarketingPuzzleScreenshotView.swift in Sources */,
91703E54DB4679C1911BF994 /* Moves.swift in Sources */,
4D90B39AD2F79959FB8089EE /* MovesUpdater.swift in Sources */,
diff --git a/Crossmate.xcodeproj/xcshareddata/xcschemes/Crossmate.xcscheme b/Crossmate.xcodeproj/xcshareddata/xcschemes/Crossmate.xcscheme
@@ -4,7 +4,8 @@
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
- buildImplicitDependencies = "YES">
+ buildImplicitDependencies = "YES"
+ runPostActionsOnFailure = "NO">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
@@ -44,7 +45,8 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
- shouldUseLaunchSchemeArgsEnv = "YES">
+ shouldUseLaunchSchemeArgsEnv = "YES"
+ onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
@@ -68,6 +70,8 @@
</BuildableReference>
</TestableReference>
</Testables>
+ <CommandLineArguments>
+ </CommandLineArguments>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
@@ -89,6 +93,8 @@
ReferencedContainer = "container:Crossmate.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
+ <CommandLineArguments>
+ </CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
@@ -106,6 +112,8 @@
ReferencedContainer = "container:Crossmate.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
+ <CommandLineArguments>
+ </CommandLineArguments>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
diff --git a/Crossmate/Sync/DiagnosticsReport.swift b/Crossmate/Sync/DiagnosticsReport.swift
@@ -0,0 +1,100 @@
+import CloudKit
+import Foundation
+
+/// Builds the shared diagnostics dump used by both the Diagnostics screen's
+/// "Share Full Log" and the Invite screen's "Report Error". Keeping one builder
+/// means a report sent from either place carries the same header + event log.
+enum DiagnosticsReport {
+ /// Standard header (version, account, sync state, last error) followed by the
+ /// full event log. `leadingLines` are prepended verbatim so a caller can pin
+ /// a specific failure that the sync monitor wouldn't otherwise capture (e.g.
+ /// a share error surfaced only in the invite sheet).
+ @MainActor
+ static func dump(
+ syncMonitor: SyncMonitor,
+ eventLog: EventLog,
+ leadingLines: [String] = []
+ ) -> DiagnosticsDump {
+ var lines: [String] = []
+ if !leadingLines.isEmpty {
+ lines.append(contentsOf: leadingLines)
+ lines.append("")
+ }
+ lines.append("Version: \(versionText)")
+ lines.append("Account Status: \(accountStatusText(syncMonitor))")
+ lines.append("Engine Running: \(boolText(syncMonitor.snapshot?.engineRunning))")
+ lines.append("Pending Changes: \(syncMonitor.snapshot.map { String($0.pendingChangesCount) } ?? "Unknown")")
+ lines.append("Last Success: \(syncMonitor.lastSuccessAt.map { utcFormatter.string(from: $0) } ?? "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("Recent Event Count: \(eventLog.entries.count)")
+ return DiagnosticsDump(headerLines: lines, entries: eventLog.entries)
+ }
+
+ /// Marketing version and build number from the bundle. The build number is
+ /// the commit count (set by the release script), so it pins a pasted log to
+ /// an exact commit — the key lever for debugging what a tester is running.
+ static var versionText: String {
+ let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
+ let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown"
+ return "\(version) (\(build))"
+ }
+
+ @MainActor
+ static func accountStatusText(_ syncMonitor: SyncMonitor) -> 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"
+ }
+ }
+
+ static func boolText(_ value: Bool?) -> String {
+ guard let value else { return "Unknown" }
+ return value ? "Yes" : "No"
+ }
+
+ private static let utcFormatter: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.dateStyle = .none
+ formatter.timeStyle = .medium
+ formatter.dateFormat = "h:mm:ss a 'UTC'"
+ formatter.timeZone = TimeZone(secondsFromGMT: 0)
+ return formatter
+ }()
+}
+
+/// 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.
+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 entries {
+ lines.append(
+ "\(formatter.string(from: entry.timestamp)) [\(entry.level.uppercased())] \(entry.message)"
+ )
+ }
+ return lines.joined(separator: "\n")
+ }
+}
diff --git a/Crossmate/Sync/ShareController.swift b/Crossmate/Sync/ShareController.swift
@@ -818,7 +818,6 @@ final class ShareController {
ticket = CKRecord(recordType: "Ping", recordID: ticketID)
}
ticket["kind"] = Self.ticketPingKind as CKRecordValue
- ticket["authorID"] = try await container.userRecordID().recordName as CKRecordValue
try Self.setTicketPayload(
TicketPayload(
version: Self.countedTicketVersion,
@@ -834,7 +833,6 @@ final class ShareController {
throw error
}
serverTicket["kind"] = Self.ticketPingKind as CKRecordValue
- serverTicket["authorID"] = try await container.userRecordID().recordName as CKRecordValue
try Self.setTicketPayload(
TicketPayload(
version: Self.countedTicketVersion,
diff --git a/Crossmate/Views/Components/MailComposeView.swift b/Crossmate/Views/Components/MailComposeView.swift
@@ -0,0 +1,63 @@
+import MessageUI
+import SwiftUI
+
+/// A plain-text attachment for a `MailComposeView`.
+struct MailAttachment {
+ let data: Data
+ let mimeType: String
+ let fileName: String
+
+ /// Convenience for the common case: a UTF-8 `.txt` payload.
+ static func text(_ string: String, fileName: String) -> MailAttachment {
+ MailAttachment(data: Data(string.utf8), mimeType: "text/plain", fileName: fileName)
+ }
+}
+
+/// Wraps `MFMailComposeViewController` for presentation via `.sheet`. Callers
+/// should gate presentation on `MFMailComposeViewController.canSendMail()` and
+/// provide their own fallback when it returns false.
+struct MailComposeView: UIViewControllerRepresentable {
+ let recipients: [String]
+ let subject: String
+ let body: String
+ var attachment: MailAttachment?
+ let onFinish: () -> Void
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(onFinish: onFinish)
+ }
+
+ func makeUIViewController(context: Context) -> MFMailComposeViewController {
+ let controller = MFMailComposeViewController()
+ controller.mailComposeDelegate = context.coordinator
+ controller.setToRecipients(recipients)
+ controller.setSubject(subject)
+ controller.setMessageBody(body, isHTML: false)
+ if let attachment {
+ controller.addAttachmentData(
+ attachment.data,
+ mimeType: attachment.mimeType,
+ fileName: attachment.fileName
+ )
+ }
+ return controller
+ }
+
+ func updateUIViewController(_ controller: MFMailComposeViewController, context: Context) {}
+
+ final class Coordinator: NSObject, MFMailComposeViewControllerDelegate {
+ private let onFinish: () -> Void
+
+ init(onFinish: @escaping () -> Void) {
+ self.onFinish = onFinish
+ }
+
+ func mailComposeController(
+ _ controller: MFMailComposeViewController,
+ didFinishWith result: MFMailComposeResult,
+ error: Error?
+ ) {
+ onFinish()
+ }
+ }
+}
diff --git a/Crossmate/Views/GameList/GameShareItem.swift b/Crossmate/Views/GameList/GameShareItem.swift
@@ -1,4 +1,5 @@
import Foundation
+import MessageUI
import SwiftUI
struct GameShareSheet: View {
@@ -8,6 +9,9 @@ struct GameShareSheet: View {
@Environment(\.inviteFriend) private var inviteFriend
@Environment(\.dismiss) private var dismiss
+ @Environment(\.syncEngine) private var syncEngine
+ @Environment(SyncMonitor.self) private var syncMonitor
+ @Environment(EventLog.self) private var eventLog
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \FriendEntity.createdAt, ascending: true)],
predicate: NSPredicate(format: "isBlocked == NO"),
@@ -16,7 +20,7 @@ struct GameShareSheet: View {
private var friends: FetchedResults<FriendEntity>
@State private var shareURL: URL?
- @State private var errorMessage: String?
+ @State private var shareError: ShareErrorInfo?
@State private var isLoadingExistingLink = true
@State private var isCreating = false
@State private var didLoadExistingLink = false
@@ -24,6 +28,10 @@ struct GameShareSheet: View {
@State private var invitingAuthorID: String?
@State private var invitedAuthorIDs: Set<String>
@State private var isInviteLimitReached = false
+ @State private var mailReport: MailReport?
+ @State private var reportFallbackNote: String?
+
+ private static let supportEmail = "[email protected]"
init(gameID: UUID, title: String, shareController: ShareController) {
self.gameID = gameID
@@ -212,16 +220,27 @@ struct GameShareSheet: View {
}
}
- if let errorMessage {
- Section("Error") {
- Text(errorMessage)
- .font(.caption.monospaced())
+ if let shareError {
+ Section {
+ Text(shareError.message)
+ .font(.footnote)
.foregroundStyle(.red)
- .textSelection(.enabled)
- Button {
- UIPasteboard.general.string = errorMessage
- } label: {
- Label("Copy Error", systemImage: "doc.on.doc")
+ if shareError.isReportable {
+ Button {
+ Task { await reportError(shareError) }
+ } label: {
+ Label("Report Error", systemImage: "envelope")
+ }
+ Button {
+ UIPasteboard.general.string = shareError.detail
+ } label: {
+ Label("Copy Error Details", systemImage: "doc.on.doc")
+ }
+ if let reportFallbackNote {
+ Text(reportFallbackNote)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
}
}
}
@@ -253,6 +272,16 @@ struct GameShareSheet: View {
withAnimation(.snappy) { invitedAuthorIDs.formUnion(invited) }
}
}
+ .sheet(item: $mailReport) { report in
+ MailComposeView(
+ recipients: [Self.supportEmail],
+ subject: report.subject,
+ body: report.body,
+ attachment: .text(report.attachmentText, fileName: "crossmate-diagnostics.txt"),
+ onFinish: { mailReport = nil }
+ )
+ .ignoresSafeArea()
+ }
}
}
@@ -301,7 +330,7 @@ struct GameShareSheet: View {
private func invite(_ authorID: String) async {
guard !authorID.isEmpty, let inviteFriend else { return }
withAnimation(.snappy) { invitingAuthorID = authorID }
- errorMessage = nil
+ shareError = nil
defer { withAnimation(.snappy) { invitingAuthorID = nil } }
do {
@@ -314,7 +343,7 @@ struct GameShareSheet: View {
if case ShareController.ShareError.collaborationLimitReached = error {
withAnimation(.snappy) { isInviteLimitReached = true }
}
- errorMessage = describe(error)
+ shareError = ShareErrorInfo(error, diagnostic: describe(error))
}
}
@@ -322,7 +351,7 @@ struct GameShareSheet: View {
guard !didLoadExistingLink else { return }
didLoadExistingLink = true
isLoadingExistingLink = true
- errorMessage = nil
+ shareError = nil
defer { isLoadingExistingLink = false }
do {
@@ -330,7 +359,7 @@ struct GameShareSheet: View {
shareURL = (try await shareController.existingShareLink(for: gameID))
.map { ShareLinkShortener.shortURL(for: $0, title: title, shape: shape) }
} catch {
- errorMessage = describe(error)
+ shareError = ShareErrorInfo(error, diagnostic: describe(error))
}
}
@@ -338,7 +367,7 @@ struct GameShareSheet: View {
guard !isCreating, !isLoadingExistingLink else { return }
isCreating = true
didCopy = false
- errorMessage = nil
+ shareError = nil
defer { isCreating = false }
do {
@@ -348,15 +377,87 @@ struct GameShareSheet: View {
shape: shareController.gridSilhouette(for: gameID)
)
} catch {
- errorMessage = describe(error)
+ shareError = ShareErrorInfo(error, diagnostic: describe(error))
+ }
+ }
+
+ /// Pairs a short, user-facing message with the full diagnostic. The message
+ /// is shown on screen; the diagnostic is what the Copy/Report buttons carry
+ /// so a report still contains the underlying CloudKit detail.
+ private struct ShareErrorInfo {
+ let message: String
+ let detail: String
+ /// True for unexpected failures (CloudKit, network) worth reporting.
+ /// Our own `ShareError` cases are expected states with clear copy, so
+ /// they get no Report/Copy affordance.
+ let isReportable: Bool
+
+ init(_ error: Error, diagnostic: String) {
+ if error is ShareController.ShareError {
+ message = error.localizedDescription
+ isReportable = false
+ } else {
+ message = "Something went wrong sharing this puzzle. If it keeps happening, report the error so it can be fixed."
+ isReportable = true
+ }
+ detail = diagnostic
}
}
+ /// Identifiable payload driving the mail-compose sheet.
+ private struct MailReport: Identifiable {
+ let id = UUID()
+ let subject: String
+ let body: String
+ let attachmentText: String
+ }
+
+ /// Builds a diagnostics report for the failure and hands it to Mail,
+ /// pre-addressed to support with the log attached. Falls back to copying the
+ /// full report when the device has no Mail account configured.
+ private func reportError(_ info: ShareErrorInfo) async {
+ reportFallbackNote = nil
+ // Freshen the snapshot so the report's header reflects current sync
+ // state, not whatever the Diagnostics screen last loaded (it may never
+ // have been opened this session).
+ if let syncEngine {
+ let snapshot = await syncEngine.diagnosticSnapshot()
+ syncMonitor.updateSnapshot(snapshot)
+ }
+ let dump = DiagnosticsReport.dump(
+ syncMonitor: syncMonitor,
+ eventLog: eventLog,
+ leadingLines: [
+ "Puzzle: \(title)",
+ "Failure while sharing this puzzle:",
+ // Already scrubbed at its source in `describe(_:)`.
+ info.detail
+ ]
+ )
+ let rendered = dump.rendered()
+
+ guard MFMailComposeViewController.canSendMail() else {
+ UIPasteboard.general.string = rendered
+ reportFallbackNote = "Mail isn't set up on this device. The full report was copied — please email it to \(Self.supportEmail)."
+ return
+ }
+
+ mailReport = MailReport(
+ subject: "Crossmate error report",
+ body: "I hit a problem sharing a puzzle in Crossmate. The diagnostics log is attached.\n\n",
+ attachmentText: rendered
+ )
+ }
+
private func describe(_ error: Error) -> String {
let nsError = error as NSError
let userInfo = nsError.userInfo
.map { "\($0.key)=\($0.value)" }
.joined(separator: " | ")
- return "domain=\(nsError.domain) code=\(nsError.code) \(nsError.localizedDescription)\n\(userInfo)"
+ let raw = "domain=\(nsError.domain) code=\(nsError.code) \(nsError.localizedDescription)\n\(userInfo)"
+ // Scrub before this reaches the Copy button or the emailed report, so a
+ // shared error can't leak full game UUIDs, zone/record names, or a
+ // share-link token — the same policy the event log already enforces.
+ return LogScrubber.scrub(raw)
}
}
diff --git a/Crossmate/Views/Settings/DiagnosticsView.swift b/Crossmate/Views/Settings/DiagnosticsView.swift
@@ -189,76 +189,14 @@ struct DiagnosticsView: View {
.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 var accountStatusText: String { DiagnosticsReport.accountStatusText(syncMonitor) }
- private func boolText(_ value: Bool?) -> String {
- guard let value else { return "Unknown" }
- return value ? "Yes" : "No"
- }
+ private func boolText(_ value: Bool?) -> String { DiagnosticsReport.boolText(value) }
- /// Marketing version and build number from the bundle. The build number is
- /// the commit count (set by the release script), so it pins a pasted log to
- /// an exact commit — the key lever for debugging what a tester is running.
- private var versionText: String {
- let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
- let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown"
- return "\(version) (\(build))"
- }
+ private var versionText: String { DiagnosticsReport.versionText }
private var diagnosticDump: DiagnosticsDump {
- var lines: [String] = []
- lines.append("Version: \(versionText)")
- lines.append("Account Status: \(accountStatusText)")
- lines.append("Engine Running: \(boolText(syncMonitor.snapshot?.engineRunning))")
- lines.append("Pending Changes: \(syncMonitor.snapshot.map { String($0.pendingChangesCount) } ?? "Unknown")")
- lines.append(
- "Last Success: \(syncMonitor.lastSuccessAt.map { TimestampFormatter.string(from: $0, in: .utc) } ?? "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("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 entries {
- lines.append(
- "\(formatter.string(from: entry.timestamp)) [\(entry.level.uppercased())] \(entry.message)"
- )
- }
- return lines.joined(separator: "\n")
+ DiagnosticsReport.dump(syncMonitor: syncMonitor, eventLog: eventLog)
}
}