crossmate

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

commit 535e4411450cb470533d752522e438ef498a81ec
parent 0a6cf0cddbb01cbee0719888333f1ec7f066e560
Author: Michael Camilleri <[email protected]>
Date:   Fri, 17 Jul 2026 10:59:07 +0900

Allow renaming the diagnostics log in Save to Files

Since the share button moved to UIKit, saving the diagnostics log to
iCloud Drive left the filename fixed — the sheet's rename field no
longer worked. The share item resolved to a concrete file URL, which
'Save to Files' treats as an existing file to copy as-is, pinning its
name; the previous ShareLink path handed over a promised file, which the
destination creates itself, so the name stayed editable.

This commit replaces the UIActivityItemProvider with a
UIActivityItemsConfiguration whose NSItemProvider promises the
plain-text file. The render and disk write still run only after the user
picks an activity, so the sheet continues to present instantly. The
filename travels both as suggestedName and as the temp file's own last
path component, covering consumers that ignore the suggestion, and the
configuration's metadataProvider titles the sheet header without loading
the item, replacing the placeholder file and LPLinkMetadata override
that patched the same gap on the old path.

Co-Authored-By: Claude Fable 5 <[email protected]>

Diffstat:
MCrossmate/Views/Settings/DiagnosticsView.swift | 90++++++++++++++++++++++++++++++++++++++++++-------------------------------------
1 file changed, 48 insertions(+), 42 deletions(-)

diff --git a/Crossmate/Views/Settings/DiagnosticsView.swift b/Crossmate/Views/Settings/DiagnosticsView.swift @@ -2,6 +2,7 @@ import CloudKit import LinkPresentation import SwiftUI import UIKit +import UniformTypeIdentifiers private enum TimestampTimeZone { case local @@ -204,10 +205,11 @@ struct DiagnosticsView: View { /// 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 +/// `UIActivityViewController` + a promised `NSItemProvider` has the semantics +/// the button needs: the sheet presents instantly (the configuration's +/// metadata provider titles the header without loading the item), and the +/// file representation is loaded on a background queue 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 @@ -229,11 +231,8 @@ private struct DiagnosticsShareButton: UIViewRepresentable { UIAction { [snapshot, weak button] _ in MainActor.assumeIsolated { guard let button else { return } - let provider = DiagnosticsActivityItemProvider(dump: snapshot()) - let controller = UIActivityViewController( - activityItems: [provider], - applicationActivities: nil - ) + let configuration = DiagnosticsShareItem.configuration(for: snapshot()) + let controller = UIActivityViewController(activityItemsConfiguration: configuration) // iPad presents the share sheet as a popover and traps // without an anchor. if let popover = controller.popoverPresentationController { @@ -264,10 +263,17 @@ private struct DiagnosticsShareButton: UIViewRepresentable { } /// 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 { +/// share sheet — the promised file representation is not loaded before then. +/// The dump inputs are snapshotted at tap time, so the file reflects the log +/// as the user saw it. +/// +/// The item is a promise rather than a concrete `file:` URL on purpose: +/// Save to Files treats a raw URL item as "copy this exact file" and pins its +/// name, whereas a promised representation is a file the destination creates, +/// so the sheet's rename field works. The name travels two ways — via +/// `suggestedName`, and as the temp file's own last path component for +/// consumers that ignore the suggestion. +private enum DiagnosticsShareItem { private static let fileName = "crossmate-diagnostics.txt" private static var fileURL: URL { @@ -275,36 +281,36 @@ private final class DiagnosticsActivityItemProvider: UIActivityItemProvider, @un .appendingPathComponent(fileName) } - private let dump: DiagnosticsDump - - init(dump: DiagnosticsDump) { - self.dump = dump - Self.ensurePlaceholderFile() - 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 url - } catch { - // No file, but the rendered text still gives the tester - // something to send. - return dump.rendered() + static func configuration(for dump: DiagnosticsDump) -> UIActivityItemsConfiguration { + let provider = NSItemProvider() + provider.suggestedName = fileName + provider.registerFileRepresentation(for: .plainText, visibility: .all) { completion in + do { + let url = fileURL + try dump.rendered().write(to: url, atomically: true, encoding: .utf8) + completion(url, false, nil) + } catch { + completion(nil, false, error) + } + return nil } - } - - override func activityViewControllerLinkMetadata(_ activityViewController: UIActivityViewController) -> LPLinkMetadata? { - let metadata = LPLinkMetadata() - metadata.originalURL = Self.fileURL - metadata.url = Self.fileURL - metadata.title = Self.fileName - return metadata - } - private static func ensurePlaceholderFile() { - guard !FileManager.default.fileExists(atPath: fileURL.path) else { return } - try? "".write(to: fileURL, atomically: true, encoding: .utf8) + let configuration = UIActivityItemsConfiguration(itemProviders: [provider]) + // Without metadata the sheet has nothing to title its header with + // until the promise is loaded; supply it up front so presenting stays + // instant and the header never shows a placeholder name. + configuration.metadataProvider = { key in + switch key { + case .title: + return fileName + case .linkPresentationMetadata: + let metadata = LPLinkMetadata() + metadata.title = fileName + return metadata + default: + return nil + } + } + return configuration } }