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)\("[^"]'
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

View File

@@ -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() {

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -52,7 +52,7 @@ extension FileSystemService {
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false }
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 {}

View File

@@ -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() {

View File

@@ -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<Void, Error> {
.failure(InvitationError.message("Bug reporting is not configured"))
.failure(InvitationError.bugReportingUnavailable)
}
func previewLogBytes() async -> Int { 0 }
}

View File

@@ -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)
}
}
}

View File

@@ -32,10 +32,10 @@ struct IosFileSystemService: FileSystemService {
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error> {
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<Share, Error> {
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(

View File

@@ -42,7 +42,7 @@ struct MacFileSystemService: FileSystemService {
accessPolicy: ShareAccessPolicy
) async -> Result<Share, Error> {
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

View File

@@ -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<String, Error>) -> 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<String, Error>) -> 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()

View File

@@ -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<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) {
onResult(.failure(InvitationError.message("NFC is unavailable on macOS")))
onResult(.failure(InvitationError.nfcUnavailable))
}
func cancel() {}

View File

@@ -36,7 +36,7 @@ final class IosTransferShareActions: NSObject, TransferShareActions {
func writeInvitationToNfc(ticket: String, onResult: @escaping (Result<Void, Error>) -> 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(()))
}

View File

@@ -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, Error>) -> Void) {
onResult(.failure(InvitationError.message("NFC is unavailable on macOS")))
onResult(.failure(InvitationError.nfcUnavailable))
}
func cancelNfcWrite() {}

View File

@@ -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.