mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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() {}
|
||||
|
||||
@@ -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(()))
|
||||
}
|
||||
|
||||
@@ -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() {}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user