refactor(apple): make InvitationError typed and localize NFC prompts

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.
This commit is contained in:
2026-07-27 16:07:19 +02:00
parent cbb535d998
commit 4730554c2c
15 changed files with 130 additions and 56 deletions

View File

@@ -32,3 +32,18 @@ custom_rules:
regex: '\b(Text|Label|Button|Toggle|Link|NavigationLink|Section|Picker|Stepper|TextField|SecureField|DisclosureGroup|Menu|GroupBox)\("[^"]' 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." message: "Pass a typed L10n.* accessor (or Text(verbatim:)), not a raw string literal."
severity: warning 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

View File

@@ -21,13 +21,13 @@ final class UiMessageControllerTests: XCTestCase {
func testErrorSuppressesUserCancellation() { func testErrorSuppressesUserCancellation() {
let c = UiMessageController() let c = UiMessageController()
c.error(InvitationError.message("QR scanning was cancelled")) c.error(InvitationError.cancelled)
XCTAssertNil(c.current) // cancellations are swallowed XCTAssertNil(c.current) // cancellations are swallowed
} }
func testErrorShowsNonCancellation() { func testErrorShowsNonCancellation() {
let c = UiMessageController() let c = UiMessageController()
c.error(InvitationError.message("The transfer was refused")) c.error(InvitationError.raw("The transfer was refused"))
XCTAssertEqual(c.current?.tone, .error) XCTAssertEqual(c.current?.tone, .error)
} }
} }
@@ -36,20 +36,23 @@ final class UiMessageControllerTests: XCTestCase {
final class UserFacingErrorTests: XCTestCase { final class UserFacingErrorTests: XCTestCase {
func testIsUserCancellation() { func testIsUserCancellation() {
XCTAssertTrue(InvitationError.message("NFC reading was cancelled").isUserCancellation) XCTAssertTrue(InvitationError.cancelled.isUserCancellation)
XCTAssertTrue(InvitationError.message("User canceled the picker").isUserCancellation) XCTAssertTrue(InvitationError.raw("User canceled the picker").isUserCancellation)
XCTAssertFalse(InvitationError.message("A database error occurred").isUserCancellation) XCTAssertFalse(InvitationError.raw("A database error occurred").isUserCancellation)
} }
func testToUiTextMapsKnownReasons() { func testToUiTextMapsKnownReasons() {
XCTAssertEqual(InvitationError.message("The transfer was refused").toUiText(), .resource(L10n.Error.permission)) // Typed cases map directly at the UI boundary.
XCTAssertEqual(InvitationError.message("invalid ticket").toUiText(), .resource(L10n.Error.invalidTicket)) XCTAssertEqual(InvitationError.shareEmpty.toUiText(), .resource(L10n.Error.shareEmpty))
XCTAssertEqual(InvitationError.message("Select at least one file to share").toUiText(), .resource(L10n.Error.shareEmpty)) XCTAssertEqual(InvitationError.cameraUnavailable.toUiText(), .resource(L10n.Error.camera))
XCTAssertEqual(InvitationError.message("Camera access is required").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() { 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() { func testToUiTextMapsTypedTransferFailures() {

View File

@@ -156,7 +156,7 @@ final class CoreRepository: ObservableObject, CoreGateway {
return .failure(CoreNetworkLifecycleError.transitionInProgress) return .failure(CoreNetworkLifecycleError.transitionInProgress)
} }
guard !sources.isEmpty else { guard !sources.isEmpty else {
return .failure(InvitationError.message("Select at least one file to share")) return .failure(InvitationError.shareEmpty)
} }
return await runCore { return await runCore {
let result = try self.requireCore().shareFiles( let result = try self.requireCore().shareFiles(
@@ -353,7 +353,7 @@ final class CoreRepository: ObservableObject, CoreGateway {
private nonisolated func requireCore() throws -> VnidropCore { private nonisolated func requireCore() throws -> VnidropCore {
guard let core = self.core else { guard let core = self.core else {
throw InvitationError.message("Initialize the core first.") throw InvitationError.coreNotInitialized
} }
return core return core
} }

View File

@@ -23,23 +23,42 @@ final class ExternalInvitationController: ObservableObject {
} }
func reportOpenFailure(message: String) { 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 { enum InvitationError: LocalizedError {
case empty case empty
case tooLarge case tooLarge
case invalidEncoding 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? { var errorDescription: String? {
switch self { if case .raw(let reason) = self { return reason }
case .empty: return "The invitation is empty" return String(describing: self)
case .tooLarge: return "The invitation is too large"
case .invalidEncoding: return "The invitation is not valid text"
case .message(let m): return m
}
} }
} }

View File

@@ -52,7 +52,7 @@ extension FileSystemService {
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false } func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false }
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error> { func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error> {
.failure(InvitationError.message("Revealing the receive folder is not supported")) .failure(InvitationError.unsupportedOperation)
} }
func discardPickedFiles(_ files: [PickedShareFile]) async {} func discardPickedFiles(_ files: [PickedShareFile]) async {}

View File

@@ -154,7 +154,7 @@ final class SendModel: ObservableObject {
} }
func onFilePickFailed(_ reason: String) { func onFilePickFailed(_ reason: String) {
messages.error(InvitationError.message(reason.isEmpty ? "selection failed" : reason)) messages.error(reason.isEmpty ? InvitationError.selectionFailed : InvitationError.raw(reason))
} }
func clearSelectedSource() { func clearSelectedSource() {

View File

@@ -20,7 +20,7 @@ protocol BugReportService {
/// Offline-safe no-op used until the diagnostics transport is configured. /// Offline-safe no-op used until the diagnostics transport is configured.
struct NoopBugReportService: BugReportService { struct NoopBugReportService: BugReportService {
func submit(_ draft: BugReportDraft, deviceInfo: DeviceInfo?) async -> Result<Void, Error> { func submit(_ draft: BugReportDraft, deviceInfo: DeviceInfo?) async -> Result<Void, Error> {
.failure(InvitationError.message("Bug reporting is not configured")) .failure(InvitationError.bugReportingUnavailable)
} }
func previewLogBytes() async -> Int { 0 } func previewLogBytes() async -> Int { 0 }
} }

View File

@@ -206,7 +206,7 @@ final class SettingsModel: ObservableObject {
} }
func onReceiveFolderPicked(_ folder: ReceiveFolder) { preferences.setReceiveFolder(folder) } 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() } func resetReceiveFolder() { preferences.resetReceiveFolder() }
/// Whether the current receive folder is the platform default (so the reset /// Whether the current receive folder is the platform default (so the reset
@@ -475,7 +475,7 @@ final class SettingsModel: ObservableObject {
loadStorageUsage() loadStorageUsage()
messages.show(UiMessage(text: .resource(L10n.Storage.transfersDeleted), tone: .success)) messages.show(UiMessage(text: .resource(L10n.Storage.transfersDeleted), tone: .success))
} else { } else {
messages.error(InvitationError.message("Could not delete \(failures) transfer records")) messages.error(InvitationError.deleteRecordsFailed)
} }
} }
} }

View File

@@ -32,10 +32,10 @@ struct IosFileSystemService: FileSystemService {
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error> { func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error> {
guard canRevealReceiveFolder(folder) else { 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 { 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 let opened = await withCheckedContinuation { continuation in
DispatchQueue.main.async { 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 { func discardPickedFiles(_ files: [PickedShareFile]) async {
@@ -62,7 +62,7 @@ struct IosFileSystemService: FileSystemService {
accessPolicy: ShareAccessPolicy accessPolicy: ShareAccessPolicy
) async -> Result<Share, Error> { ) async -> Result<Share, Error> {
guard !files.isEmpty else { 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() } let sources = files.map { $0.toIosShareSource() }
return await repository.shareSources( return await repository.shareSources(

View File

@@ -42,7 +42,7 @@ struct MacFileSystemService: FileSystemService {
accessPolicy: ShareAccessPolicy accessPolicy: ShareAccessPolicy
) async -> Result<Share, Error> { ) async -> Result<Share, Error> {
guard !files.isEmpty else { 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 // 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 // captured at pick time) and hold it across the whole share call. The core

View File

@@ -29,7 +29,7 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID
picker.delegate = self picker.delegate = self
picker.modalPresentationStyle = .formSheet picker.modalPresentationStyle = .formSheet
guard let presenter = topPresenter() else { 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) presenter.present(picker, animated: true)
} }
@@ -37,12 +37,12 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID
func scanQrCode(onResult: @escaping (Result<String, Error>) -> Void) { func scanQrCode(onResult: @escaping (Result<String, Error>) -> Void) {
cancel() cancel()
guard let presenter = topPresenter() else { 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 ensureCameraAccess { [weak self] granted in
guard let self else { return } guard let self else { return }
guard granted else { 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 let scanner = QrScannerViewController { result in
self.qrController = nil self.qrController = nil
@@ -57,7 +57,7 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID
func readNfcInvitation(onResult: @escaping (Result<String, Error>) -> Void) { func readNfcInvitation(onResult: @escaping (Result<String, Error>) -> Void) {
cancel() cancel()
guard NFCNDEFReaderSession.readingAvailable else { 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 let reader = InvitationNfcReader { [weak self] result in
self?.nfcReader = nil self?.nfcReader = nil
@@ -80,7 +80,7 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID
let result = documentResult let result = documentResult
documentResult = nil documentResult = nil
result?(Result { 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() let started = url.startAccessingSecurityScopedResource()
defer { if started { url.stopAccessingSecurityScopedResource() } } defer { if started { url.stopAccessingSecurityScopedResource() } }
let data = try Data(contentsOf: url) let data = try Data(contentsOf: url)
@@ -157,19 +157,19 @@ final class QrScannerViewController: UIViewController, AVCaptureMetadataOutputOb
} }
func cancelScan() { func cancelScan() {
finish(.failure(InvitationError.message("QR scanning was cancelled"))) finish(.failure(InvitationError.cancelled))
} }
private func configureSession() { private func configureSession() {
guard let device = AVCaptureDevice.default(for: .video), guard let device = AVCaptureDevice.default(for: .video),
let input = try? AVCaptureDeviceInput(device: device), let input = try? AVCaptureDeviceInput(device: device),
session.canAddInput(input) else { session.canAddInput(input) else {
return finish(.failure(InvitationError.message("No camera is available"))) return finish(.failure(InvitationError.cameraUnavailable))
} }
session.addInput(input) session.addInput(input)
let output = AVCaptureMetadataOutput() let output = AVCaptureMetadataOutput()
guard session.canAddOutput(output) else { guard session.canAddOutput(output) else {
return finish(.failure(InvitationError.message("Could not configure the QR scanner"))) return finish(.failure(InvitationError.cameraUnavailable))
} }
session.addOutput(output) session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: .main) output.setMetadataObjectsDelegate(self, queue: .main)
@@ -220,7 +220,7 @@ final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate, @unchec
func start() { func start() {
let reader = NFCNDEFReaderSession(delegate: self, queue: .main, invalidateAfterFirstRead: true) 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 session = reader
reader.begin() reader.begin()
} }
@@ -233,7 +233,7 @@ final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate, @unchec
func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) { func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
if finished { return } if finished { return }
let cancelled = (error as NSError).code == 200 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]) { func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {
@@ -242,7 +242,7 @@ final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate, @unchec
.flatMap { $0.records } .flatMap { $0.records }
.compactMap { payloadAsInvitation($0) } .compactMap { payloadAsInvitation($0) }
.first .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 return ticket
} }
session.invalidate() session.invalidate()

View File

@@ -22,7 +22,7 @@ final class MacReceiveInvitationActions: ReceiveInvitationActions {
} }
panel.begin { response in panel.begin { response in
guard response == .OK, let url = panel.url else { guard response == .OK, let url = panel.url else {
onResult(.failure(InvitationError.message("cancelled"))) onResult(.failure(InvitationError.cancelled))
return return
} }
onResult(Result { onResult(Result {
@@ -33,11 +33,11 @@ final class MacReceiveInvitationActions: ReceiveInvitationActions {
} }
func scanQrCode(onResult: @escaping (Result<String, Error>) -> Void) { func scanQrCode(onResult: @escaping (Result<String, Error>) -> Void) {
onResult(.failure(InvitationError.message("QR scanning is unavailable on macOS"))) onResult(.failure(InvitationError.qrUnavailable))
} }
func readNfcInvitation(onResult: @escaping (Result<String, Error>) -> Void) { func readNfcInvitation(onResult: @escaping (Result<String, Error>) -> Void) {
onResult(.failure(InvitationError.message("NFC is unavailable on macOS"))) onResult(.failure(InvitationError.nfcUnavailable))
} }
func cancel() {} func cancel() {}

View File

@@ -36,7 +36,7 @@ final class IosTransferShareActions: NSObject, TransferShareActions {
func writeInvitationToNfc(ticket: String, onResult: @escaping (Result<Void, Error>) -> Void) { func writeInvitationToNfc(ticket: String, onResult: @escaping (Result<Void, Error>) -> Void) {
cancelNfcWrite() cancelNfcWrite()
guard NFCNDEFReaderSession.readingAvailable else { guard NFCNDEFReaderSession.readingAvailable else {
onResult(.failure(InvitationError.message("NFC is unavailable on this device"))) onResult(.failure(InvitationError.nfcUnavailable))
return return
} }
let writer = InvitationNfcWriter(ticket: ticket) { [weak self] result in let writer = InvitationNfcWriter(ticket: ticket) { [weak self] result in
@@ -55,7 +55,7 @@ final class IosTransferShareActions: NSObject, TransferShareActions {
@MainActor @MainActor
private func present(_ controller: UIViewController) throws { private func present(_ controller: UIViewController) throws {
guard let presenter = topPresenter() else { guard let presenter = topPresenter() else {
throw InvitationError.message("Could not find an iOS view controller") throw InvitationError.viewControllerUnavailable
} }
presenter.present(controller, animated: true) presenter.present(controller, animated: true)
} }
@@ -76,7 +76,7 @@ final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate, @unchec
func start() { func start() {
let reader = NFCNDEFReaderSession(delegate: self, queue: .main, invalidateAfterFirstRead: false) 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 session = reader
reader.begin() reader.begin()
} }
@@ -89,14 +89,14 @@ final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate, @unchec
func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) { func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
if finished { return } if finished { return }
let cancelled = (error as NSError).code == 200 // readerSessionInvalidationErrorUserCanceled 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, didDetectNDEFs messages: [NFCNDEFMessage]) {}
func readerSession(_ session: NFCNDEFReaderSession, didDetect tags: [NFCNDEFTag]) { func readerSession(_ session: NFCNDEFReaderSession, didDetect tags: [NFCNDEFTag]) {
guard let firstTag = tags.first else { 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 // CoreNFC completion handlers run on the session's `.main` queue; these
// framework values are safe to use there. // 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)) } if let queryError { return self.finish(.failure(queryError)) }
switch status { switch status {
case .notSupported: case .notSupported:
self.finish(.failure(InvitationError.message("This NFC tag does not support NDEF"))) self.finish(.failure(InvitationError.nfcFailed))
case .readOnly: case .readOnly:
self.finish(.failure(InvitationError.message("This NFC tag is read-only"))) self.finish(.failure(InvitationError.nfcFailed))
default: default:
guard let message = self.invitationMessage() else { 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 tag.writeNDEF(message) { writeError in
if let writeError { if let writeError {
self.finish(.failure(writeError)) self.finish(.failure(writeError))
} else { } else {
session.alertMessage = "Invitation written" session.alertMessage = String(localized: L10n.Transfer.nfcWritten)
session.invalidate() session.invalidate()
self.finish(.success(())) self.finish(.success(()))
} }

View File

@@ -17,7 +17,7 @@ final class MacTransferShareActions: TransferShareActions {
panel.allowedContentTypes = [] panel.allowedContentTypes = []
panel.begin { response in panel.begin { response in
guard response == .OK, let url = panel.url else { guard response == .OK, let url = panel.url else {
onResult(.failure(InvitationError.message("cancelled"))) onResult(.failure(InvitationError.cancelled))
return return
} }
onResult(Result { try ticket.write(to: url, atomically: true, encoding: .utf8) }) onResult(Result { try ticket.write(to: url, atomically: true, encoding: .utf8) })
@@ -28,7 +28,7 @@ final class MacTransferShareActions: TransferShareActions {
do { do {
let url = try writeTemporaryInvitation(ticket: ticket, transferName: transferName) let url = try writeTemporaryInvitation(ticket: ticket, transferName: transferName)
guard let view = NSApp.keyWindow?.contentView else { guard let view = NSApp.keyWindow?.contentView else {
onResult(.failure(InvitationError.message("No window available"))) onResult(.failure(InvitationError.noWindowAvailable))
return return
} }
let picker = NSSharingServicePicker(items: [url]) let picker = NSSharingServicePicker(items: [url])
@@ -40,7 +40,7 @@ final class MacTransferShareActions: TransferShareActions {
} }
func writeInvitationToNfc(ticket: String, onResult: @escaping (Result<Void, Error>) -> Void) { func writeInvitationToNfc(ticket: String, onResult: @escaping (Result<Void, Error>) -> Void) {
onResult(.failure(InvitationError.message("NFC is unavailable on macOS"))) onResult(.failure(InvitationError.nfcUnavailable))
} }
func cancelNfcWrite() {} func cancelNfcWrite() {}

View File

@@ -5,6 +5,9 @@ import VnidropCore
/// `ui/feedback/UserFacingError.kt`. Never exposes raw `reason=` blobs. /// `ui/feedback/UserFacingError.kt`. Never exposes raw `reason=` blobs.
extension Error { extension Error {
func toUiText() -> UiText { func toUiText() -> UiText {
if let invitation = self as? InvitationError {
return invitation.uiText
}
if let vni = self as? VnidropError { if let vni = self as? VnidropError {
switch vni { switch vni {
case .Ticket: case .Ticket:
@@ -40,6 +43,7 @@ extension Error {
/// True when the user intentionally backed out of a flow. /// True when the user intentionally backed out of a flow.
var isUserCancellation: Bool { 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 } if let vni = self as? VnidropError, case .Cancelled = vni { return true }
let haystack = technicalDetail.lowercased() let haystack = technicalDetail.lowercased()
if haystack.isEmpty { 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 /// 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 /// surfacing the raw core code (e.g. `destination_exists`). Unknown codes fall back
/// to the substring hints, then a generic message. /// to the substring hints, then a generic message.