commit b2122609e4db8d334a922ca9e46d73b085c72be1
parent fcf2241bd54e354a9280aab0b766f5f4132df3af
Author: Michael Camilleri <[email protected]>
Date: Fri, 3 Jul 2026 14:38:27 +0900
Present the diagnostics share sheet before rendering the log
The previous commit moved the dump's rendering off the main actor, but
the share sheet still would not appear until the file existed —
ShareLink resolves a Transferable in full before presenting, so tapping
the share button sat on a countable-seconds stall even for a modest log.
To a beta tester that reads as a broken button.
This commit replaces the ShareLink with a UIKit path that has the right
semantics: a small representable button presents
UIActivityViewController with a UIActivityItemProvider, so the sheet
opens instantly against a placeholder and the provider renders and
writes the file on a background operation only once the user picks an
activity — the wait lands inside the chosen action's own progress UI,
where it reads as normal.
Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat:
1 file changed, 87 insertions(+), 25 deletions(-)
diff --git a/Crossmate/Views/Settings/DiagnosticsView.swift b/Crossmate/Views/Settings/DiagnosticsView.swift
@@ -1,7 +1,6 @@
import CloudKit
-import CoreTransferable
import SwiftUI
-import UniformTypeIdentifiers
+import UIKit
private enum TimestampTimeZone {
case local
@@ -124,15 +123,7 @@ struct DiagnosticsView: View {
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .topBarTrailing) {
- ShareLink(
- item: DiagnosticsShareFile(snapshot: { diagnosticDump }),
- preview: SharePreview(
- "Crossmate Diagnostics",
- image: Image(systemName: "doc.text")
- )
- ) {
- Label("Share Full Log", systemImage: "square.and.arrow.up")
- }
+ DiagnosticsShareButton(snapshot: { diagnosticDump })
}
}
.task {
@@ -271,23 +262,94 @@ private struct DiagnosticsDump: Sendable {
}
}
-/// Renders the diagnostic dump only when the user actually shares it. The
-/// 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 {
+/// The share button, in UIKit deliberately. SwiftUI's `ShareLink` resolves its
+/// item — for a file, render *and* disk write — before it will present the
+/// sheet, which put a count-the-seconds stall between the tap and the menu.
+/// `UIActivityViewController` + `UIActivityItemProvider` has the semantics the
+/// button needs: the sheet presents instantly against a placeholder, and the
+/// provider is consulted on a background operation only once the user picks
+/// an activity — the render cost lands inside the chosen activity's own
+/// progress UI, where a wait reads as normal instead of broken.
+///
+/// The button must be the UIKit view itself (a presentation anchor hidden
+/// behind a native toolbar Button never gets installed in the window —
+/// toolbars drop button backgrounds), so it mimics the toolbar's native
+/// styling instead: intrinsic sizing via `sizeThatFits` and the label-colour
+/// tint the surrounding monochrome toolbar items use.
+private struct DiagnosticsShareButton: UIViewRepresentable {
+ /// Runs at tap time on the main actor; must stay cheap (header strings
+ /// plus a copy-on-write take of the entries buffer).
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")
+ func makeUIView(context: Context) -> UIButton {
+ let button = UIButton(type: .system)
+ button.setImage(UIImage(systemName: "square.and.arrow.up"), for: .normal)
+ button.tintColor = .label
+ button.accessibilityLabel = "Share Full Log"
+ button.addAction(
+ UIAction { [snapshot, weak button] _ in
+ MainActor.assumeIsolated {
+ guard let button else { return }
+ let provider = DiagnosticsActivityItemProvider(dump: snapshot())
+ let controller = UIActivityViewController(
+ activityItems: [provider],
+ applicationActivities: nil
+ )
+ // iPad presents the share sheet as a popover and traps
+ // without an anchor.
+ if let popover = controller.popoverPresentationController {
+ popover.sourceView = button
+ popover.sourceRect = button.bounds
+ }
+ var presenter = button.window?.rootViewController
+ while let presented = presenter?.presentedViewController {
+ presenter = presented
+ }
+ presenter?.present(controller, animated: true)
+ }
+ },
+ for: .touchUpInside
+ )
+ return button
+ }
+
+ func updateUIView(_ uiView: UIButton, context: Context) {}
+
+ func sizeThatFits(
+ _ proposal: ProposedViewSize,
+ uiView: UIButton,
+ context: Context
+ ) -> CGSize? {
+ uiView.intrinsicContentSize
+ }
+}
+
+/// Writes the diagnostics file only after the user picks an activity in the
+/// share sheet — `UIActivityViewController` calls `item` on a background
+/// operation at that point, never before the sheet is up. The dump inputs are
+/// snapshotted at tap time, so the file reflects the log as the user saw it.
+private final class DiagnosticsActivityItemProvider: UIActivityItemProvider, @unchecked Sendable {
+ private static var fileURL: URL {
+ FileManager.default.temporaryDirectory
+ .appendingPathComponent("crossmate-diagnostics.txt")
+ }
+
+ private let dump: DiagnosticsDump
+
+ init(dump: DiagnosticsDump) {
+ self.dump = dump
+ super.init(placeholderItem: Self.fileURL)
+ }
+
+ override var item: Any {
+ let url = Self.fileURL
+ do {
try dump.rendered().write(to: url, atomically: true, encoding: .utf8)
- return SentTransferredFile(url)
+ return url
+ } catch {
+ // No file, but the rendered text still gives the tester
+ // something to send.
+ return dump.rendered()
}
}
}