Files
vnidrop/apple/VniDrop/UI/Feedback/UserFacingError.swift
cdricms 35f06a0b6b fix(apple): localize receiver failure reasons
The receiver row showed the core's raw reason code (e.g. destination_exists),
breaking the never-expose-raw-reason-blobs rule. Map the core reason codes to the
existing L10n.Error.* messages via receiverReasonUiText, with a generic fallback so
a raw code is never surfaced.
2026-07-24 18:45:02 +02:00

183 lines
6.7 KiB
Swift

import Foundation
import VnidropCore
/// Maps technical failures to stable, user-facing catalog keys. Ported from
/// `ui/feedback/UserFacingError.kt`. Never exposes raw `reason=` blobs.
extension Error {
func toUiText() -> UiText {
if let vni = self as? VnidropError {
switch vni {
case .Ticket:
return .resource(L10n.Error.invalidTicket)
case .Permission:
return .resource(L10n.Error.permission)
case .Filesystem:
return .resource(L10n.Error.filesystem)
case .FilesystemPermission:
return .resource(L10n.Error.filesystem)
case .DestinationExists:
return .resource(L10n.Error.destinationExists)
case .StorageFull:
return .resource(L10n.Error.storageFull)
case .Network:
return .resource(L10n.Error.network)
case .Transfer(let reason):
return transferUiText(reason)
case .Repository:
return .resource(L10n.Error.repository)
case .Cancelled:
return .resource(L10n.Error.generic)
case .InvalidInput:
return .resource(L10n.Error.invalidInput)
case .Initialization(let reason):
return initializationUiText(reason)
case .Internal(let reason):
return reasonHints(reason) ?? .resource(L10n.Error.generic)
}
}
return reasonHints(technicalDetail) ?? .resource(L10n.Error.generic)
}
/// True when the user intentionally backed out of a flow.
var isUserCancellation: Bool {
if let vni = self as? VnidropError, case .Cancelled = vni { return true }
let haystack = technicalDetail.lowercased()
if haystack.isEmpty {
// URLError / CocoaError cancellation without a message.
if let urlError = self as? URLError, urlError.code == .cancelled { return true }
return (self as NSError).code == NSUserCancelledError
}
return haystack.contains("cancelled")
|| haystack.contains("canceled")
|| haystack.contains("user cancelled")
|| haystack.contains("user canceled")
}
/// Prefers a `VnidropError` reason; else the localized description.
var technicalDetail: String {
if let vni = self as? VnidropError {
switch vni {
case .Initialization(let r), .Ticket(let r), .Filesystem(let r), .FilesystemPermission(let r),
.DestinationExists(let r), .StorageFull(let r), .Network(let r),
.Transfer(let r), .Permission(let r), .Repository(let r), .Cancelled(let r),
.InvalidInput(let r), .Internal(let r):
return r
}
}
return (self as? LocalizedError)?.errorDescription ?? (self as NSError).localizedDescription
}
var canRetryWithoutChangingInput: Bool {
guard let vni = self as? VnidropError else { return true }
switch vni {
case .FilesystemPermission, .DestinationExists, .InvalidInput: return false
default: return true
}
}
}
/// 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.
func receiverReasonUiText(_ reason: String) -> UiText {
switch reason {
case "destination_exists":
return .resource(L10n.Error.destinationExists)
case "filesystem", "filesystem_permission_denied":
return .resource(L10n.Error.filesystem)
case "permission_denied", "approval-required", "approval-expired",
"unknown-transfer", "missing-endpoint-id", "invalid-receipt":
return .resource(L10n.Error.permission)
case "storage_full":
return .resource(L10n.Error.storageFull)
case "network":
return .resource(L10n.Error.network)
case "invalid_ticket":
return .resource(L10n.Error.invalidTicket)
case "transfer":
return .resource(L10n.Error.transfer)
case "repository", "repository-error":
return .resource(L10n.Error.repository)
case "invalid_input":
return .resource(L10n.Error.invalidInput)
case "initialization":
return .resource(L10n.Error.initialization)
case "cancelled", "internal":
return .resource(L10n.Error.generic)
default:
return reasonHints(reason) ?? .resource(L10n.Error.generic)
}
}
private func transferUiText(_ reason: String) -> UiText {
let detail = reason.lowercased()
if detail.contains("refused") || detail.contains("denied") || detail.contains("not approved") {
return .resource(L10n.Error.permission)
}
return .resource(L10n.Error.transfer)
}
private func initializationUiText(_ reason: String) -> UiText {
let detail = reason.lowercased()
if detail.contains("native") && detail.contains("library") {
return .resource(L10n.Error.missingNativeLibrary)
}
if detail.contains("socket") || detail.contains("bind") {
return .resource(L10n.Error.socketBind)
}
return .resource(L10n.Error.initialization)
}
private func reasonHints(_ detailRaw: String) -> UiText? {
let detail = detailRaw.lowercased()
if detail.isEmpty { return nil }
if detail.contains("still starting") || detail.contains("starting up") {
return .resource(L10n.Error.startingUp)
}
if detail.contains("empty") && (detail.contains("invitation") || detail.contains("ticket") || detail.contains("qr")) {
return .resource(L10n.Error.invitationEmpty)
}
if detail.contains("select at least one") || detail.contains("no files found") {
return .resource(L10n.Error.shareEmpty)
}
if detail.contains("camera") {
return .resource(L10n.Error.camera)
}
if detail.contains("nfc") || detail.contains("ndef")
|| (detail.contains("read-only") && detail.contains("tag"))
|| detail.contains("tag is too small") || detail.contains("no nfc tag") {
return .resource(L10n.Error.nfc)
}
if detail.contains("native") && detail.contains("library") {
return .resource(L10n.Error.missingNativeLibrary)
}
if detail.contains("socket") || detail.contains("bind") {
return .resource(L10n.Error.socketBind)
}
if detail.contains("device information") || detail.contains("device info") {
return .resource(L10n.Error.deviceInfo)
}
if detail.contains("refused") || detail.contains("denied") || detail.contains("permission")
|| detail.contains("not approved") || detail.contains("waiting for approval") {
return .resource(L10n.Error.permission)
}
if detail.contains("invalid ticket") || detail.contains("ticket error")
|| detail.contains("could not be read") || detail.contains("malformed")
|| detail.contains("invitation could not be opened") {
return .resource(L10n.Error.invalidTicket)
}
if detail.contains("selected")
&& (detail.contains("file") || detail.contains("folder") || detail.contains("document") || detail.contains("open")) {
return .resource(L10n.Error.selectionFailed)
}
if detail.contains("could not open the selected") || detail.contains("could not open selected") {
return .resource(L10n.Error.selectionFailed)
}
if detail.contains("document picker") || detail.contains("folder picker") || detail.contains("file descriptor")
|| detail.contains("view controller") {
return .resource(L10n.Error.selectionFailed)
}
return nil
}