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,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() {}