From 4730554c2c12fbd03f09f3934ce50013fbe28b5a Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:07:19 +0200 Subject: [PATCH] refactor(apple): make InvitationError typed and localize NFC prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the free-form InvitationError.message(String) case with semantic cases mapped to L10n keys at the UI boundary (Error.uiText), so user-facing error text is localized instead of substring-matched from English blobs. .raw(String) remains only for genuinely dynamic system/core messages. Localize the CoreNFC alertMessage prompts via existing L10n keys, and add SwiftLint rules (raw_alert_message, raw_invitation_error) to catch raw alert strings and literal .raw("…") errors going forward. --- apple/.swiftlint.yml | 15 ++++++++ apple/Tests/UiFeedbackTests.swift | 23 +++++++----- apple/VniDrop/Core/CoreRepository.swift | 4 +- .../Core/ExternalInvitationController.swift | 35 ++++++++++++++---- apple/VniDrop/Core/FileSystemService.swift | 2 +- apple/VniDrop/Features/Send/SendModel.swift | 2 +- .../Features/Settings/BugReportService.swift | 2 +- .../Features/Settings/SettingsModel.swift | 4 +- .../Platform/FileSystemService+iOS.swift | 8 ++-- .../Platform/FileSystemService+macOS.swift | 2 +- .../ReceiveInvitationActions+iOS.swift | 22 +++++------ .../ReceiveInvitationActions+macOS.swift | 6 +-- .../Platform/TransferShareActions+iOS.swift | 18 ++++----- .../Platform/TransferShareActions+macOS.swift | 6 +-- .../VniDrop/UI/Feedback/UserFacingError.swift | 37 +++++++++++++++++++ 15 files changed, 130 insertions(+), 56 deletions(-) diff --git a/apple/.swiftlint.yml b/apple/.swiftlint.yml index b9a3f8e..66fd294 100644 --- a/apple/.swiftlint.yml +++ b/apple/.swiftlint.yml @@ -32,3 +32,18 @@ custom_rules: regex: '\b(Text|Label|Button|Toggle|Link|NavigationLink|Section|Picker|Stepper|TextField|SecureField|DisclosureGroup|Menu|GroupBox)\("[^"]' message: "Pass a typed L10n.* accessor (or Text(verbatim:)), not a raw string literal." severity: warning + raw_alert_message: + name: "Raw NFC/alert message" + # User-facing UIKit/CoreNFC prompts (e.g. NFCReaderSession.alertMessage) must + # be localized, not hardcoded English. + regex: '\balertMessage\s*=\s*"' + message: "Assign a localized value (String(localized: L10n.*)), not a raw string literal." + severity: warning + raw_invitation_error: + name: "Raw InvitationError literal" + # InvitationError.raw is the escape hatch for genuinely dynamic system/core + # messages; a string literal here is a loose user-facing string that belongs + # in a typed InvitationError case mapped to L10n in UserFacingError.swift. + regex: 'InvitationError\.raw\("' + message: "Add a typed InvitationError case + L10n mapping instead of a literal .raw(\"…\")." + severity: warning diff --git a/apple/Tests/UiFeedbackTests.swift b/apple/Tests/UiFeedbackTests.swift index fdbbfbe..9250f79 100644 --- a/apple/Tests/UiFeedbackTests.swift +++ b/apple/Tests/UiFeedbackTests.swift @@ -21,13 +21,13 @@ final class UiMessageControllerTests: XCTestCase { func testErrorSuppressesUserCancellation() { let c = UiMessageController() - c.error(InvitationError.message("QR scanning was cancelled")) + c.error(InvitationError.cancelled) XCTAssertNil(c.current) // cancellations are swallowed } func testErrorShowsNonCancellation() { let c = UiMessageController() - c.error(InvitationError.message("The transfer was refused")) + c.error(InvitationError.raw("The transfer was refused")) XCTAssertEqual(c.current?.tone, .error) } } @@ -36,20 +36,23 @@ final class UiMessageControllerTests: XCTestCase { final class UserFacingErrorTests: XCTestCase { func testIsUserCancellation() { - XCTAssertTrue(InvitationError.message("NFC reading was cancelled").isUserCancellation) - XCTAssertTrue(InvitationError.message("User canceled the picker").isUserCancellation) - XCTAssertFalse(InvitationError.message("A database error occurred").isUserCancellation) + XCTAssertTrue(InvitationError.cancelled.isUserCancellation) + XCTAssertTrue(InvitationError.raw("User canceled the picker").isUserCancellation) + XCTAssertFalse(InvitationError.raw("A database error occurred").isUserCancellation) } func testToUiTextMapsKnownReasons() { - XCTAssertEqual(InvitationError.message("The transfer was refused").toUiText(), .resource(L10n.Error.permission)) - XCTAssertEqual(InvitationError.message("invalid ticket").toUiText(), .resource(L10n.Error.invalidTicket)) - XCTAssertEqual(InvitationError.message("Select at least one file to share").toUiText(), .resource(L10n.Error.shareEmpty)) - XCTAssertEqual(InvitationError.message("Camera access is required").toUiText(), .resource(L10n.Error.camera)) + // Typed cases map directly at the UI boundary. + XCTAssertEqual(InvitationError.shareEmpty.toUiText(), .resource(L10n.Error.shareEmpty)) + XCTAssertEqual(InvitationError.cameraUnavailable.toUiText(), .resource(L10n.Error.camera)) + XCTAssertEqual(InvitationError.nfcFailed.toUiText(), .resource(L10n.Error.nfc)) + // Dynamic `.raw` payloads still fall through the substring hints. + XCTAssertEqual(InvitationError.raw("The transfer was refused").toUiText(), .resource(L10n.Error.permission)) + XCTAssertEqual(InvitationError.raw("invalid ticket").toUiText(), .resource(L10n.Error.invalidTicket)) } func testToUiTextFallsBackToGeneric() { - XCTAssertEqual(InvitationError.message("something entirely unexpected").toUiText(), .resource(L10n.Error.generic)) + XCTAssertEqual(InvitationError.raw("something entirely unexpected").toUiText(), .resource(L10n.Error.generic)) } func testToUiTextMapsTypedTransferFailures() { diff --git a/apple/VniDrop/Core/CoreRepository.swift b/apple/VniDrop/Core/CoreRepository.swift index ed9c81a..997feba 100644 --- a/apple/VniDrop/Core/CoreRepository.swift +++ b/apple/VniDrop/Core/CoreRepository.swift @@ -156,7 +156,7 @@ final class CoreRepository: ObservableObject, CoreGateway { return .failure(CoreNetworkLifecycleError.transitionInProgress) } guard !sources.isEmpty else { - return .failure(InvitationError.message("Select at least one file to share")) + return .failure(InvitationError.shareEmpty) } return await runCore { let result = try self.requireCore().shareFiles( @@ -353,7 +353,7 @@ final class CoreRepository: ObservableObject, CoreGateway { private nonisolated func requireCore() throws -> VnidropCore { guard let core = self.core else { - throw InvitationError.message("Initialize the core first.") + throw InvitationError.coreNotInitialized } return core } diff --git a/apple/VniDrop/Core/ExternalInvitationController.swift b/apple/VniDrop/Core/ExternalInvitationController.swift index d0a82cc..2850e39 100644 --- a/apple/VniDrop/Core/ExternalInvitationController.swift +++ b/apple/VniDrop/Core/ExternalInvitationController.swift @@ -23,23 +23,42 @@ final class ExternalInvitationController: ObservableObject { } func reportOpenFailure(message: String) { - continuation?.yield(.failure(InvitationError.message(message))) + continuation?.yield(.failure(InvitationError.raw(message))) } } +/// Semantic, UI-agnostic invitation/transfer failures. Cases carry no display +/// text: `Error.toUiText()` (UI layer) maps each case to a localized `L10n` key, +/// so there are no free-form English strings to keep in sync or substring-match. +/// `.raw` is the escape hatch for genuinely dynamic system/core messages (e.g. a +/// `CoreNFC` `localizedDescription` or a picker's failure reason), never shown +/// verbatim — it is still routed through `reasonHints`. enum InvitationError: LocalizedError { case empty case tooLarge case invalidEncoding - case message(String) + case shareEmpty + case cancelled + case coreNotInitialized + case unsupportedOperation + case noWindowAvailable + case viewControllerUnavailable + case filesystemUnavailable + case invalidInvitationURL + case nfcUnavailable + case nfcFailed + case cameraUnavailable + case qrUnavailable + case bugReportingUnavailable + case selectionFailed + case deleteRecordsFailed + case raw(String) + /// Developer/log-facing only — never surfaced to users. Derived from the case + /// so there are no hand-written English blobs; `.raw` passes its payload through. var errorDescription: String? { - switch self { - case .empty: return "The invitation is empty" - case .tooLarge: return "The invitation is too large" - case .invalidEncoding: return "The invitation is not valid text" - case .message(let m): return m - } + if case .raw(let reason) = self { return reason } + return String(describing: self) } } diff --git a/apple/VniDrop/Core/FileSystemService.swift b/apple/VniDrop/Core/FileSystemService.swift index 96b218d..401d388 100644 --- a/apple/VniDrop/Core/FileSystemService.swift +++ b/apple/VniDrop/Core/FileSystemService.swift @@ -52,7 +52,7 @@ extension FileSystemService { func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false } func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result { - .failure(InvitationError.message("Revealing the receive folder is not supported")) + .failure(InvitationError.unsupportedOperation) } func discardPickedFiles(_ files: [PickedShareFile]) async {} diff --git a/apple/VniDrop/Features/Send/SendModel.swift b/apple/VniDrop/Features/Send/SendModel.swift index eda9c95..951bb77 100644 --- a/apple/VniDrop/Features/Send/SendModel.swift +++ b/apple/VniDrop/Features/Send/SendModel.swift @@ -154,7 +154,7 @@ final class SendModel: ObservableObject { } func onFilePickFailed(_ reason: String) { - messages.error(InvitationError.message(reason.isEmpty ? "selection failed" : reason)) + messages.error(reason.isEmpty ? InvitationError.selectionFailed : InvitationError.raw(reason)) } func clearSelectedSource() { diff --git a/apple/VniDrop/Features/Settings/BugReportService.swift b/apple/VniDrop/Features/Settings/BugReportService.swift index 5bdfad9..17be101 100644 --- a/apple/VniDrop/Features/Settings/BugReportService.swift +++ b/apple/VniDrop/Features/Settings/BugReportService.swift @@ -20,7 +20,7 @@ protocol BugReportService { /// Offline-safe no-op used until the diagnostics transport is configured. struct NoopBugReportService: BugReportService { func submit(_ draft: BugReportDraft, deviceInfo: DeviceInfo?) async -> Result { - .failure(InvitationError.message("Bug reporting is not configured")) + .failure(InvitationError.bugReportingUnavailable) } func previewLogBytes() async -> Int { 0 } } diff --git a/apple/VniDrop/Features/Settings/SettingsModel.swift b/apple/VniDrop/Features/Settings/SettingsModel.swift index b5d3c3f..8158926 100644 --- a/apple/VniDrop/Features/Settings/SettingsModel.swift +++ b/apple/VniDrop/Features/Settings/SettingsModel.swift @@ -206,7 +206,7 @@ final class SettingsModel: ObservableObject { } func onReceiveFolderPicked(_ folder: ReceiveFolder) { preferences.setReceiveFolder(folder) } - func onReceiveFolderPickFailed(_ reason: String) { messages.error(InvitationError.message(reason)) } + func onReceiveFolderPickFailed(_ reason: String) { messages.error(InvitationError.raw(reason)) } func resetReceiveFolder() { preferences.resetReceiveFolder() } /// Whether the current receive folder is the platform default (so the reset @@ -475,7 +475,7 @@ final class SettingsModel: ObservableObject { loadStorageUsage() messages.show(UiMessage(text: .resource(L10n.Storage.transfersDeleted), tone: .success)) } else { - messages.error(InvitationError.message("Could not delete \(failures) transfer records")) + messages.error(InvitationError.deleteRecordsFailed) } } } diff --git a/apple/VniDrop/Platform/FileSystemService+iOS.swift b/apple/VniDrop/Platform/FileSystemService+iOS.swift index b0fcc51..5827dc9 100644 --- a/apple/VniDrop/Platform/FileSystemService+iOS.swift +++ b/apple/VniDrop/Platform/FileSystemService+iOS.swift @@ -32,10 +32,10 @@ struct IosFileSystemService: FileSystemService { func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result { guard canRevealReceiveFolder(folder) else { - return .failure(InvitationError.message("The receive folder is not VniDrop Documents")) + return .failure(InvitationError.filesystemUnavailable) } guard let url = URL(string: "shareddocuments://\(folder.value)") else { - return .failure(InvitationError.message("The Files location URL is unavailable")) + return .failure(InvitationError.filesystemUnavailable) } let opened = await withCheckedContinuation { continuation in DispatchQueue.main.async { @@ -44,7 +44,7 @@ struct IosFileSystemService: FileSystemService { } } } - return opened ? .success(()) : .failure(InvitationError.message("Could not open VniDrop Documents in Files")) + return opened ? .success(()) : .failure(InvitationError.filesystemUnavailable) } func discardPickedFiles(_ files: [PickedShareFile]) async { @@ -62,7 +62,7 @@ struct IosFileSystemService: FileSystemService { accessPolicy: ShareAccessPolicy ) async -> Result { guard !files.isEmpty else { - return .failure(InvitationError.message("Select at least one file to share")) + return .failure(InvitationError.shareEmpty) } let sources = files.map { $0.toIosShareSource() } return await repository.shareSources( diff --git a/apple/VniDrop/Platform/FileSystemService+macOS.swift b/apple/VniDrop/Platform/FileSystemService+macOS.swift index c2130d2..9bf6135 100644 --- a/apple/VniDrop/Platform/FileSystemService+macOS.swift +++ b/apple/VniDrop/Platform/FileSystemService+macOS.swift @@ -42,7 +42,7 @@ struct MacFileSystemService: FileSystemService { accessPolicy: ShareAccessPolicy ) async -> Result { guard !files.isEmpty else { - return .failure(InvitationError.message("Select at least one file to share")) + return .failure(InvitationError.shareEmpty) } // Re-acquire security-scoped access to every picked source (from the bookmark // captured at pick time) and hold it across the whole share call. The core diff --git a/apple/VniDrop/Platform/ReceiveInvitationActions+iOS.swift b/apple/VniDrop/Platform/ReceiveInvitationActions+iOS.swift index e0610d0..b83a445 100644 --- a/apple/VniDrop/Platform/ReceiveInvitationActions+iOS.swift +++ b/apple/VniDrop/Platform/ReceiveInvitationActions+iOS.swift @@ -29,7 +29,7 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID picker.delegate = self picker.modalPresentationStyle = .formSheet guard let presenter = topPresenter() else { - return onResult(.failure(InvitationError.message("Could not find an iOS view controller"))) + return onResult(.failure(InvitationError.viewControllerUnavailable)) } presenter.present(picker, animated: true) } @@ -37,12 +37,12 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID func scanQrCode(onResult: @escaping (Result) -> Void) { cancel() guard let presenter = topPresenter() else { - return onResult(.failure(InvitationError.message("Could not find an iOS view controller"))) + return onResult(.failure(InvitationError.viewControllerUnavailable)) } ensureCameraAccess { [weak self] granted in guard let self else { return } guard granted else { - return onResult(.failure(InvitationError.message("Camera access is required to scan QR codes"))) + return onResult(.failure(InvitationError.cameraUnavailable)) } let scanner = QrScannerViewController { result in self.qrController = nil @@ -57,7 +57,7 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID func readNfcInvitation(onResult: @escaping (Result) -> Void) { cancel() guard NFCNDEFReaderSession.readingAvailable else { - return onResult(.failure(InvitationError.message("NFC reading is unavailable on this device"))) + return onResult(.failure(InvitationError.nfcUnavailable)) } let reader = InvitationNfcReader { [weak self] result in self?.nfcReader = nil @@ -80,7 +80,7 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID let result = documentResult documentResult = nil result?(Result { - guard let url = urls.first else { throw InvitationError.message("The selected invitation URL was invalid") } + guard let url = urls.first else { throw InvitationError.invalidInvitationURL } let started = url.startAccessingSecurityScopedResource() defer { if started { url.stopAccessingSecurityScopedResource() } } let data = try Data(contentsOf: url) @@ -157,19 +157,19 @@ final class QrScannerViewController: UIViewController, AVCaptureMetadataOutputOb } func cancelScan() { - finish(.failure(InvitationError.message("QR scanning was cancelled"))) + finish(.failure(InvitationError.cancelled)) } private func configureSession() { guard let device = AVCaptureDevice.default(for: .video), let input = try? AVCaptureDeviceInput(device: device), session.canAddInput(input) else { - return finish(.failure(InvitationError.message("No camera is available"))) + return finish(.failure(InvitationError.cameraUnavailable)) } session.addInput(input) let output = AVCaptureMetadataOutput() guard session.canAddOutput(output) else { - return finish(.failure(InvitationError.message("Could not configure the QR scanner"))) + return finish(.failure(InvitationError.cameraUnavailable)) } session.addOutput(output) output.setMetadataObjectsDelegate(self, queue: .main) @@ -220,7 +220,7 @@ final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate, @unchec func start() { let reader = NFCNDEFReaderSession(delegate: self, queue: .main, invalidateAfterFirstRead: true) - reader.alertMessage = "Hold your iPhone near a VniDrop invitation tag" + reader.alertMessage = String(localized: L10n.Receive.nfcWaiting) session = reader reader.begin() } @@ -233,7 +233,7 @@ final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate, @unchec func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) { if finished { return } let cancelled = (error as NSError).code == 200 - finish(.failure(InvitationError.message(cancelled ? "NFC reading was cancelled" : error.localizedDescription))) + finish(.failure(cancelled ? InvitationError.cancelled : InvitationError.raw(error.localizedDescription))) } func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) { @@ -242,7 +242,7 @@ final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate, @unchec .flatMap { $0.records } .compactMap { payloadAsInvitation($0) } .first - guard let ticket else { throw InvitationError.message("This NFC tag does not contain a VniDrop invitation") } + guard let ticket else { throw InvitationError.nfcFailed } return ticket } session.invalidate() diff --git a/apple/VniDrop/Platform/ReceiveInvitationActions+macOS.swift b/apple/VniDrop/Platform/ReceiveInvitationActions+macOS.swift index 2586941..e2ab9f5 100644 --- a/apple/VniDrop/Platform/ReceiveInvitationActions+macOS.swift +++ b/apple/VniDrop/Platform/ReceiveInvitationActions+macOS.swift @@ -22,7 +22,7 @@ final class MacReceiveInvitationActions: ReceiveInvitationActions { } panel.begin { response in guard response == .OK, let url = panel.url else { - onResult(.failure(InvitationError.message("cancelled"))) + onResult(.failure(InvitationError.cancelled)) return } onResult(Result { @@ -33,11 +33,11 @@ final class MacReceiveInvitationActions: ReceiveInvitationActions { } func scanQrCode(onResult: @escaping (Result) -> Void) { - onResult(.failure(InvitationError.message("QR scanning is unavailable on macOS"))) + onResult(.failure(InvitationError.qrUnavailable)) } func readNfcInvitation(onResult: @escaping (Result) -> Void) { - onResult(.failure(InvitationError.message("NFC is unavailable on macOS"))) + onResult(.failure(InvitationError.nfcUnavailable)) } func cancel() {} diff --git a/apple/VniDrop/Platform/TransferShareActions+iOS.swift b/apple/VniDrop/Platform/TransferShareActions+iOS.swift index c9fe5fd..11e090f 100644 --- a/apple/VniDrop/Platform/TransferShareActions+iOS.swift +++ b/apple/VniDrop/Platform/TransferShareActions+iOS.swift @@ -36,7 +36,7 @@ final class IosTransferShareActions: NSObject, TransferShareActions { func writeInvitationToNfc(ticket: String, onResult: @escaping (Result) -> Void) { cancelNfcWrite() guard NFCNDEFReaderSession.readingAvailable else { - onResult(.failure(InvitationError.message("NFC is unavailable on this device"))) + onResult(.failure(InvitationError.nfcUnavailable)) return } let writer = InvitationNfcWriter(ticket: ticket) { [weak self] result in @@ -55,7 +55,7 @@ final class IosTransferShareActions: NSObject, TransferShareActions { @MainActor private func present(_ controller: UIViewController) throws { guard let presenter = topPresenter() else { - throw InvitationError.message("Could not find an iOS view controller") + throw InvitationError.viewControllerUnavailable } presenter.present(controller, animated: true) } @@ -76,7 +76,7 @@ final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate, @unchec func start() { let reader = NFCNDEFReaderSession(delegate: self, queue: .main, invalidateAfterFirstRead: false) - reader.alertMessage = "Hold your iPhone near a writable NFC tag" + reader.alertMessage = String(localized: L10n.Transfer.nfcWaiting) session = reader reader.begin() } @@ -89,14 +89,14 @@ final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate, @unchec func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) { if finished { return } let cancelled = (error as NSError).code == 200 // readerSessionInvalidationErrorUserCanceled - finish(.failure(InvitationError.message(cancelled ? "NFC writing was cancelled" : error.localizedDescription))) + finish(.failure(cancelled ? InvitationError.cancelled : InvitationError.raw(error.localizedDescription))) } func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {} func readerSession(_ session: NFCNDEFReaderSession, didDetect tags: [NFCNDEFTag]) { guard let firstTag = tags.first else { - return finish(.failure(InvitationError.message("No NFC tag was detected"))) + return finish(.failure(InvitationError.nfcFailed)) } // CoreNFC completion handlers run on the session's `.main` queue; these // framework values are safe to use there. @@ -109,18 +109,18 @@ final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate, @unchec if let queryError { return self.finish(.failure(queryError)) } switch status { case .notSupported: - self.finish(.failure(InvitationError.message("This NFC tag does not support NDEF"))) + self.finish(.failure(InvitationError.nfcFailed)) case .readOnly: - self.finish(.failure(InvitationError.message("This NFC tag is read-only"))) + self.finish(.failure(InvitationError.nfcFailed)) default: guard let message = self.invitationMessage() else { - return self.finish(.failure(InvitationError.message("Could not encode the invitation for NFC"))) + return self.finish(.failure(InvitationError.nfcFailed)) } tag.writeNDEF(message) { writeError in if let writeError { self.finish(.failure(writeError)) } else { - session.alertMessage = "Invitation written" + session.alertMessage = String(localized: L10n.Transfer.nfcWritten) session.invalidate() self.finish(.success(())) } diff --git a/apple/VniDrop/Platform/TransferShareActions+macOS.swift b/apple/VniDrop/Platform/TransferShareActions+macOS.swift index 20878b9..7854f42 100644 --- a/apple/VniDrop/Platform/TransferShareActions+macOS.swift +++ b/apple/VniDrop/Platform/TransferShareActions+macOS.swift @@ -17,7 +17,7 @@ final class MacTransferShareActions: TransferShareActions { panel.allowedContentTypes = [] panel.begin { response in guard response == .OK, let url = panel.url else { - onResult(.failure(InvitationError.message("cancelled"))) + onResult(.failure(InvitationError.cancelled)) return } onResult(Result { try ticket.write(to: url, atomically: true, encoding: .utf8) }) @@ -28,7 +28,7 @@ final class MacTransferShareActions: TransferShareActions { do { let url = try writeTemporaryInvitation(ticket: ticket, transferName: transferName) guard let view = NSApp.keyWindow?.contentView else { - onResult(.failure(InvitationError.message("No window available"))) + onResult(.failure(InvitationError.noWindowAvailable)) return } let picker = NSSharingServicePicker(items: [url]) @@ -40,7 +40,7 @@ final class MacTransferShareActions: TransferShareActions { } func writeInvitationToNfc(ticket: String, onResult: @escaping (Result) -> Void) { - onResult(.failure(InvitationError.message("NFC is unavailable on macOS"))) + onResult(.failure(InvitationError.nfcUnavailable)) } func cancelNfcWrite() {} diff --git a/apple/VniDrop/UI/Feedback/UserFacingError.swift b/apple/VniDrop/UI/Feedback/UserFacingError.swift index 77cab8d..25f1ede 100644 --- a/apple/VniDrop/UI/Feedback/UserFacingError.swift +++ b/apple/VniDrop/UI/Feedback/UserFacingError.swift @@ -5,6 +5,9 @@ import VnidropCore /// `ui/feedback/UserFacingError.kt`. Never exposes raw `reason=` blobs. extension Error { func toUiText() -> UiText { + if let invitation = self as? InvitationError { + return invitation.uiText + } if let vni = self as? VnidropError { switch vni { case .Ticket: @@ -40,6 +43,7 @@ extension Error { /// True when the user intentionally backed out of a flow. var isUserCancellation: Bool { + if let invitation = self as? InvitationError, case .cancelled = invitation { return true } if let vni = self as? VnidropError, case .Cancelled = vni { return true } let haystack = technicalDetail.lowercased() if haystack.isEmpty { @@ -76,6 +80,39 @@ extension Error { } } +/// Maps each semantic `InvitationError` case to a localized user-facing message. +/// This is the sole `InvitationError` → `L10n` boundary: no substring guessing, +/// except for `.raw`, whose dynamic payload still falls through `reasonHints`. +extension InvitationError { + var uiText: UiText { + switch self { + case .empty: + return .resource(L10n.Error.invitationEmpty) + case .tooLarge, .unsupportedOperation, .noWindowAvailable, + .viewControllerUnavailable, .qrUnavailable, .bugReportingUnavailable, .cancelled: + return .resource(L10n.Error.generic) + case .invalidEncoding, .invalidInvitationURL: + return .resource(L10n.Error.invalidTicket) + case .shareEmpty: + return .resource(L10n.Error.shareEmpty) + case .coreNotInitialized: + return .resource(L10n.Error.startingUp) + case .filesystemUnavailable: + return .resource(L10n.Error.filesystem) + case .nfcUnavailable, .nfcFailed: + return .resource(L10n.Error.nfc) + case .cameraUnavailable: + return .resource(L10n.Error.camera) + case .selectionFailed: + return .resource(L10n.Error.selectionFailed) + case .deleteRecordsFailed: + return .resource(L10n.Error.repository) + case .raw(let reason): + return reasonHints(reason) ?? .resource(L10n.Error.generic) + } + } +} + /// Maps a receiver delivery/refusal reason code to a user-facing message, never /// surfacing the raw core code (e.g. `destination_exists`). Unknown codes fall back /// to the substring hints, then a generic message.