crossmate

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

DiagnosticsView.swift (14186B)


      1 import CloudKit
      2 import LinkPresentation
      3 import SwiftUI
      4 import UIKit
      5 import UniformTypeIdentifiers
      6 
      7 private enum TimestampTimeZone {
      8     case local
      9     case utc
     10 }
     11 
     12 private enum TimestampFormatter {
     13     private static let localFormatter: DateFormatter = {
     14         let formatter = DateFormatter()
     15         formatter.dateStyle = .none
     16         formatter.timeStyle = .medium
     17         return formatter
     18     }()
     19 
     20     private static let utcFormatter: DateFormatter = {
     21         let formatter = DateFormatter()
     22         formatter.dateStyle = .none
     23         formatter.timeStyle = .medium
     24         formatter.dateFormat = "h:mm:ss a 'UTC'"
     25         formatter.timeZone = TimeZone(secondsFromGMT: 0)
     26         return formatter
     27     }()
     28 
     29     static func string(from date: Date, in timeZone: TimestampTimeZone) -> String {
     30         switch timeZone {
     31         case .local:
     32             return localFormatter.string(from: date)
     33         case .utc:
     34             return utcFormatter.string(from: date)
     35         }
     36     }
     37 }
     38 
     39 struct DiagnosticsView: View {
     40     @Environment(\.syncEngine) private var syncEngine
     41     @Environment(SyncMonitor.self) private var syncMonitor
     42     @Environment(EventLog.self) private var eventLog
     43 
     44     @State private var isSyncing = false
     45     @State private var isAuditingStorage = false
     46 
     47     /// The on-screen list is a quick glance, not the archive — the buffer now
     48     /// spans a full day (tens of thousands of entries during a co-solve), and
     49     /// rendering it all just buries the tail. The shared file keeps everything.
     50     private let visibleEventLimit = 50
     51 
     52     var body: some View {
     53         List {
     54             Section("Status") {
     55                 row("Version", versionText)
     56                 row("Account Status", accountStatusText)
     57                 row("Engine Running", boolText(syncMonitor.snapshot?.engineRunning))
     58                 row("Pending Changes", syncMonitor.snapshot.map { String($0.pendingChangesCount) } ?? "Unknown")
     59                 row("Sharing Health", sharingHealthText)
     60                 row(
     61                     "Last Success",
     62                     syncMonitor.lastSuccessAt.map { TimestampFormatter.string(from: $0, in: .local) } ?? "None"
     63                 )
     64                 row("Last Error Phase", syncMonitor.lastErrorPhase ?? "None")
     65                 row("Last Error Domain", syncMonitor.lastErrorDomain ?? "None")
     66                 row(
     67                     "Last Error Code",
     68                     syncMonitor.lastErrorCode.map(String.init) ?? "None"
     69                 )
     70                 row("Last Error Description", syncMonitor.lastErrorDescription ?? "None")
     71             }
     72 
     73             Section("Actions") {
     74                 Button {
     75                     Task { await runFullSync() }
     76                 } label: {
     77                     HStack {
     78                         Text("Sync Now")
     79                         if isSyncing {
     80                             Spacer()
     81                             ProgressView()
     82                         }
     83                     }
     84                 }
     85                 .disabled(isSyncing || isAuditingStorage)
     86 
     87                 Button("Probe Container") {
     88                     Task { await probeContainer() }
     89                 }
     90                 .disabled(isSyncing || isAuditingStorage)
     91 
     92                 Button {
     93                     Task { await auditCloudStorage() }
     94                 } label: {
     95                     HStack {
     96                         Text("Audit Cloud Storage")
     97                         if isAuditingStorage {
     98                             Spacer()
     99                             ProgressView()
    100                         }
    101                     }
    102                 }
    103                 .disabled(isSyncing || isAuditingStorage)
    104 
    105                 Button("Reset Sync State", role: .destructive) {
    106                     Task { await resetSyncState() }
    107                 }
    108                 .disabled(isSyncing || isAuditingStorage)
    109             }
    110 
    111             Section {
    112                 if eventLog.entries.isEmpty {
    113                     Text("No events captured yet.")
    114                         .foregroundStyle(.secondary)
    115                 } else {
    116                     ForEach(eventLog.entries.suffix(visibleEventLimit).reversed()) { entry in
    117                         VStack(alignment: .leading, spacing: 4) {
    118                             Text(
    119                                 "\(TimestampFormatter.string(from: entry.timestamp, in: .local)) [\(entry.level.uppercased())]"
    120                             )
    121                             .font(.caption.monospaced())
    122                             .foregroundStyle(.secondary)
    123 
    124                             Text(entry.message)
    125                                 .font(.caption.monospaced())
    126                                 .textSelection(.enabled)
    127                         }
    128                         .padding(.vertical, 2)
    129                     }
    130                 }
    131             } header: {
    132                 Text("Recent Events")
    133             } footer: {
    134                 if eventLog.entries.count > visibleEventLimit {
    135                     Text("Showing the last \(visibleEventLimit) of \(eventLog.entries.count) events. Share Full Log includes the complete history.")
    136                 }
    137             }
    138         }
    139         .navigationTitle("Diagnostics Log")
    140         .navigationBarTitleDisplayMode(.inline)
    141         .toolbar {
    142             ToolbarItemGroup(placement: .topBarTrailing) {
    143                 DiagnosticsShareButton(snapshot: { diagnosticDump })
    144             }
    145         }
    146         .task {
    147             guard let syncEngine else { return }
    148             let snapshot = await syncEngine.diagnosticSnapshot()
    149             syncMonitor.updateSnapshot(snapshot)
    150         }
    151     }
    152 
    153     // MARK: - Actions
    154 
    155     private func resetSyncState() async {
    156         guard let syncEngine else { return }
    157         await syncEngine.resetSyncState()
    158         syncMonitor.note("Sync state reset (zone/subscription flags and tokens cleared)")
    159         let snapshot = await syncEngine.diagnosticSnapshot()
    160         syncMonitor.updateSnapshot(snapshot)
    161     }
    162 
    163     private func probeContainer() async {
    164         guard let syncEngine else { return }
    165         syncMonitor.note("starting container probe")
    166         let results = await syncEngine.probeContainer()
    167         for (name, result) in results {
    168             syncMonitor.note("probe[\(name)]: \(result)")
    169         }
    170         syncMonitor.note("Container probe complete")
    171     }
    172 
    173     private func auditCloudStorage() async {
    174         guard !isAuditingStorage, let syncEngine else { return }
    175         isAuditingStorage = true
    176         defer { isAuditingStorage = false }
    177 
    178         await syncEngine.auditPrivateCloudStorage { message in
    179             syncMonitor.note(message)
    180         }
    181     }
    182 
    183     private func runFullSync() async {
    184         guard !isSyncing, let syncEngine else { return }
    185         isSyncing = true
    186         defer { isSyncing = false }
    187 
    188         await syncMonitor.run("manual fetch") {
    189             try await syncEngine.fetchChanges()
    190         }
    191         await syncMonitor.run("manual private ping fetch") {
    192             _ = try await syncEngine.fetchPushPingsDirect(scope: .private)
    193         }
    194         await syncMonitor.run("manual shared ping fetch") {
    195             _ = try await syncEngine.fetchPushPingsDirect(scope: .shared)
    196         }
    197         await syncMonitor.run("manual push") {
    198             try await syncEngine.pushChanges()
    199         }
    200         let snapshot = await syncEngine.diagnosticSnapshot()
    201         syncMonitor.updateSnapshot(snapshot)
    202     }
    203 
    204     // MARK: - Subviews
    205 
    206     @ViewBuilder
    207     private func row(_ title: String, _ value: String) -> some View {
    208         VStack(alignment: .leading, spacing: 4) {
    209             Text(title)
    210                 .font(.caption)
    211                 .foregroundStyle(.secondary)
    212             Text(value)
    213                 .font(.body.monospaced())
    214                 .textSelection(.enabled)
    215         }
    216         .padding(.vertical, 2)
    217     }
    218 
    219     private var accountStatusText: String { DiagnosticsReport.accountStatusText(syncMonitor) }
    220 
    221     private func boolText(_ value: Bool?) -> String { DiagnosticsReport.boolText(value) }
    222 
    223     private var sharingHealthText: String {
    224         guard let snapshot = syncMonitor.snapshot else { return "Unknown" }
    225         guard snapshot.engineRunning, snapshot.accountStatus == .available else {
    226             return "Unavailable"
    227         }
    228         let count = snapshot.pendingInvitationCount
    229         return count == 0
    230             ? "Ready"
    231             : "\(count) invitation\(count == 1 ? "" : "s") waiting for CloudKit"
    232     }
    233 
    234     private var versionText: String { DiagnosticsReport.versionText }
    235 
    236     private var diagnosticDump: DiagnosticsDump {
    237         DiagnosticsReport.dump(syncMonitor: syncMonitor, eventLog: eventLog)
    238     }
    239 }
    240 
    241 /// The share button, in UIKit deliberately. SwiftUI's `ShareLink` resolves its
    242 /// item — for a file, render *and* disk write — before it will present the
    243 /// sheet, which put a count-the-seconds stall between the tap and the menu.
    244 /// `UIActivityViewController` + a promised `NSItemProvider` has the semantics
    245 /// the button needs: the sheet presents instantly (the configuration's
    246 /// metadata provider titles the header without loading the item), and the
    247 /// promised representation is loaded on a background queue only once the user
    248 /// picks an activity — the render cost lands inside the chosen activity's own
    249 /// progress UI, where a wait reads as normal instead of broken.
    250 ///
    251 /// The button must be the UIKit view itself (a presentation anchor hidden
    252 /// behind a native toolbar Button never gets installed in the window —
    253 /// toolbars drop button backgrounds), so it mimics the toolbar's native
    254 /// styling instead: intrinsic sizing via `sizeThatFits` and the label-colour
    255 /// tint the surrounding monochrome toolbar items use.
    256 private struct DiagnosticsShareButton: UIViewRepresentable {
    257     /// Runs at tap time on the main actor; must stay cheap (header strings
    258     /// plus a copy-on-write take of the entries buffer).
    259     let snapshot: @MainActor () -> DiagnosticsDump
    260 
    261     func makeUIView(context: Context) -> UIButton {
    262         let button = UIButton(type: .system)
    263         button.setImage(UIImage(systemName: "square.and.arrow.up"), for: .normal)
    264         button.tintColor = .label
    265         button.accessibilityLabel = "Share Full Log"
    266         button.addAction(
    267             UIAction { [snapshot, weak button] _ in
    268                 MainActor.assumeIsolated {
    269                     guard let button else { return }
    270                     let configuration = DiagnosticsShareItem.configuration(for: snapshot())
    271                     let controller = UIActivityViewController(activityItemsConfiguration: configuration)
    272                     // iPad presents the share sheet as a popover and traps
    273                     // without an anchor.
    274                     if let popover = controller.popoverPresentationController {
    275                         popover.sourceView = button
    276                         popover.sourceRect = button.bounds
    277                     }
    278                     var presenter = button.window?.rootViewController
    279                     while let presented = presenter?.presentedViewController {
    280                         presenter = presented
    281                     }
    282                     presenter?.present(controller, animated: true)
    283                 }
    284             },
    285             for: .touchUpInside
    286         )
    287         return button
    288     }
    289 
    290     func updateUIView(_ uiView: UIButton, context: Context) {}
    291 
    292     func sizeThatFits(
    293         _ proposal: ProposedViewSize,
    294         uiView: UIButton,
    295         context: Context
    296     ) -> CGSize? {
    297         uiView.intrinsicContentSize
    298     }
    299 }
    300 
    301 /// Renders the diagnostics dump only after the user picks an activity in the
    302 /// share sheet — the promised data representation is not loaded before then.
    303 /// The dump inputs are snapshotted at tap time, so the file reflects the log
    304 /// as the user saw it.
    305 ///
    306 /// The item is a promise rather than a concrete `file:` URL on purpose:
    307 /// Save to Files treats a raw URL item as "copy this exact file" and pins its
    308 /// name, whereas a promised representation is a file the destination creates,
    309 /// so the sheet's rename field works, with `suggestedName` carrying the name.
    310 ///
    311 /// The promise must be *data*, not a file. The share sheet fails to grant
    312 /// consumers a sandbox extension for a promised file's URL (Apple DTS,
    313 /// developer.apple.com/forums/thread/714354), so a promised file
    314 /// representation makes Save to Files dismiss without saving and share
    315 /// extensions fail to load the attachment. A data promise crosses to the
    316 /// consumer directly and the consumer writes its own file, so no sandbox
    317 /// handoff is involved.
    318 private enum DiagnosticsShareItem {
    319     private static let fileName = "crossmate-diagnostics.txt"
    320 
    321     static func configuration(for dump: DiagnosticsDump) -> UIActivityItemsConfiguration {
    322         let provider = NSItemProvider()
    323         provider.suggestedName = fileName
    324         // Registered as generic data, not .plainText: messaging apps classify
    325         // a plain-text item as message text — Signal loads it as a string and
    326         // packages an oversize-text attachment under its own name, ignoring
    327         // suggestedName. A generic-data item routes through their file path,
    328         // where the materialised file keeps the suggested name. The .txt in
    329         // suggestedName still types the file at its destination.
    330         provider.registerDataRepresentation(for: .data, visibility: .all) { completion in
    331             completion(Data(dump.rendered().utf8), nil)
    332             return nil
    333         }
    334 
    335         let configuration = UIActivityItemsConfiguration(itemProviders: [provider])
    336         // Without metadata the sheet has nothing to title its header with
    337         // until the promise is loaded; supply it up front so presenting stays
    338         // instant and the header never shows a placeholder name.
    339         configuration.metadataProvider = { key in
    340             switch key {
    341             case .title:
    342                 return fileName
    343             case .linkPresentationMetadata:
    344                 let metadata = LPLinkMetadata()
    345                 metadata.title = fileName
    346                 return metadata
    347             default:
    348                 return nil
    349             }
    350         }
    351         return configuration
    352     }
    353 }