mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +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)\("[^"]'
|
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
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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() {}
|
||||||
|
|||||||
@@ -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(()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user