diff --git a/.tx/config b/.tx/config index a72754ad..a17057a0 100644 --- a/.tx/config +++ b/.tx/config @@ -1,8 +1,14 @@ [main] host = https://www.transifex.com +[o:nextcloud:p:nextcloud:r:nextcloudkit] +file_filter = translations/nextcloudkit/.xcstrings +source_file = Sources/NextcloudKit/Localizable.xcstrings +source_lang = en +type = XCSTRINGS + [o:nextcloud:p:nextcloud:r:nextcloudkitui] -file_filter = translations/.xcstrings +file_filter = translations/nextcloudkitui/.xcstrings source_file = Sources/NextcloudKitUI/Localizable.xcstrings source_lang = en type = XCSTRINGS diff --git a/AGENTS.md b/AGENTS.md index daa9879b..5cbc0db4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ Networking is built on Alamofire; responses are parsed with SwiftyJSON and Swift | Directory | Description | Agent Action | |-----------------|-----------------------------------------------------|----------------------| | `.github` | GitHub CI workflows (build & test, lint, REUSE compliance, documentation). | Keep all four checks green: build & test (`xcode.yml`), SwiftLint (`lint.yml`), REUSE compliance (`reuse.yml`), DocC build (`documentation.yml`). | -| `Sources/NextcloudKit` | Core library. Server APIs live in one `NextcloudKit+.swift` extension file per API area (e.g. `NextcloudKit+Share.swift`), next to core types like `NextcloudKit`, `NKCommon`, `NKSession` and `NKError`. Shared helpers live in `Extensions/`, `Log/`, `Utils/` and `TypeIdentifiers/`. | Add new server APIs as a dedicated `NextcloudKit+.swift` extension file. | +| `Sources/NextcloudKit` | Core library. Server APIs live in one `NextcloudKit+.swift` extension file per API area (e.g. `NextcloudKit+Share.swift`), next to core types like `NextcloudKit`, `NKCommon`, `NKSession` and `NKError`. Shared helpers live in `Extensions/`, `Log/`, `Utils/` and `TypeIdentifiers/`. `Localizable.xcstrings` holds the user-facing strings: `NKError.localizedGenericMessage`, file name rule messages and log level names. NKError's own descriptions (the presets and `getErrorDescription(for:)`) stay plain English, for logs. | Add new server APIs as a dedicated `NextcloudKit+.swift` extension file. Only add new localized strings to `Localizable.xcstrings` in English, with `NSLocalizedString(…, bundle: .module, comment:)` since `String(localized:)` needs newer deployment targets. | | `Sources/NextcloudKit/Models` | `NK*` data models returned by the APIs. | Place each new model in its own file; group related models for one feature in a subfolder (see `Models/Governance`). | | `Sources/NextcloudKitUI` | SwiftUI login and account UI used by client apps. Depends on CodeScanner for QR-code login. | Only add new localized strings to `Localizable.xcstrings` in English. | | `Tests/NextcloudKitUnitTests` | Unit tests; network requests are mocked with Mocker (see `LoginUnitTests.swift`). | Prefer this target for new tests. Try to add unit tests for new features, where applicable and makes sense; do not overcomplicate them. | diff --git a/REUSE.toml b/REUSE.toml index 4c92d24e..167d6a73 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -24,7 +24,7 @@ SPDX-FileCopyrightText = "2023 Nextcloud GmbH and Nextcloud contributors" SPDX-License-Identifier = "GPL-3.0-or-later" [[annotations]] -path = [".tx/config", "Sources/NextcloudKitUI/Documentation.docc/Documentation.md", "Sources/NextcloudKitUI/Documentation.docc/theme-settings.json", "Sources/NextcloudKitUI/Localizable.xcstrings"] +path = [".tx/config", "Sources/NextcloudKitUI/Documentation.docc/Documentation.md", "Sources/NextcloudKitUI/Documentation.docc/theme-settings.json", "Sources/NextcloudKitUI/Localizable.xcstrings", "Sources/NextcloudKit/Localizable.xcstrings"] precedence = "aggregate" SPDX-FileCopyrightText = "2026 Nextcloud GmbH and Nextcloud contributors" SPDX-License-Identifier = "GPL-3.0-or-later" diff --git a/Sources/NextcloudKit/Localizable.xcstrings b/Sources/NextcloudKit/Localizable.xcstrings new file mode 100644 index 00000000..4d5f5d87 --- /dev/null +++ b/Sources/NextcloudKit/Localizable.xcstrings @@ -0,0 +1,325 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "\"%@\" is a forbidden name." : { + "comment" : "Error message when the server reserves a name. %@ is the name.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "\"%@\" is a forbidden name." + } + } + } + }, + "\".%@\" is a forbidden file extension." : { + "comment" : "Error message when the server forbids a file extension. %@ is the extension.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "\".%@\" is a forbidden file extension." + } + } + } + }, + "A secure connection to the server couldn't be established." : { + "comment" : "Error message for TLS and certificate failures.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A secure connection to the server couldn't be established." + } + } + } + }, + "An item with this name already exists." : { + "comment" : "Error message when a file or folder with the same name already exists.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "An item with this name already exists." + } + } + } + }, + "Can't reach the server. Check the address or try again later." : { + "comment" : "Error message when the server cannot be reached.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Can't reach the server. Check the address or try again later." + } + } + } + }, + "Compact" : { + "comment" : "Log level that logs only the essentials.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Compact" + } + } + } + }, + "Disabled" : { + "comment" : "Log level that turns logging off.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disabled" + } + } + } + }, + "File name cannot be empty." : { + "comment" : "Error message when a file or folder name is empty.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "File name cannot be empty." + } + } + } + }, + "Name contains an invalid character: \"%@\"." : { + "comment" : "Error message when a name contains a forbidden character. %@ is the character.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Name contains an invalid character: \"%@\"." + } + } + } + }, + "Name must not contain spaces at the beginning or end." : { + "comment" : "Error message when a name starts or ends with a space.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Name must not contain spaces at the beginning or end." + } + } + } + }, + "Normal" : { + "comment" : "Default log level.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Normal" + } + } + } + }, + "Something went wrong. Please try again." : { + "comment" : "Error message when the cause of the error is unknown.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Something went wrong. Please try again." + } + } + } + }, + "The file is locked. Try again later." : { + "comment" : "Error message when the file is locked on the server.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The file is locked. Try again later." + } + } + } + }, + "The file is too large for the server." : { + "comment" : "Error message when the server rejects a file because of its size.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The file is too large for the server." + } + } + } + }, + "The item could not be found on the server." : { + "comment" : "Error message when the requested file, folder or share does not exist on the server.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The item could not be found on the server." + } + } + } + }, + "The item was changed on the server. Please refresh and try again." : { + "comment" : "Error message when the request conflicts with the current state on the server.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The item was changed on the server. Please refresh and try again." + } + } + } + }, + "The request was cancelled." : { + "comment" : "Error message when a request was cancelled.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The request was cancelled." + } + } + } + }, + "The server is temporarily unavailable. Try again later." : { + "comment" : "Error message when the server is unavailable, e.g. in maintenance mode.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The server is temporarily unavailable. Try again later." + } + } + } + }, + "The server ran into a problem. Try again later." : { + "comment" : "Error message for internal server errors.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The server ran into a problem. Try again later." + } + } + } + }, + "The server sent an unexpected response. Try again later." : { + "comment" : "Error message when the server's response cannot be read.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The server sent an unexpected response. Try again later." + } + } + } + }, + "The server took too long to respond. Try again later." : { + "comment" : "Error message when a request to the server timed out.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The server took too long to respond. Try again later." + } + } + } + }, + "There is not enough storage space left on the server." : { + "comment" : "Error message when the account's storage quota is used up.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "There is not enough storage space left on the server." + } + } + } + }, + "There is not enough storage space left on this device." : { + "comment" : "Error message when the device runs out of storage space.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "There is not enough storage space left on this device." + } + } + } + }, + "This name isn't allowed." : { + "comment" : "Error message when the server rejects a file or folder name.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This name isn't allowed." + } + } + } + }, + "Too many requests. Please wait a moment and try again." : { + "comment" : "Error message when the server rate-limits the requests.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Too many requests. Please wait a moment and try again." + } + } + } + }, + "Verbose" : { + "comment" : "Log level that logs everything, for debugging.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verbose" + } + } + } + }, + "You don't have permission to do this." : { + "comment" : "Error message when the server forbids the action.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You don't have permission to do this." + } + } + } + }, + "You're offline. Please check your internet connection." : { + "comment" : "Error message when the device has no internet connection.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You're offline. Please check your internet connection." + } + } + } + }, + "Your login is no longer valid. Please log in again." : { + "comment" : "Error message when the server rejects the account's credentials.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your login is no longer valid. Please log in again." + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Sources/NextcloudKit/Log/NKLogFileManager.swift b/Sources/NextcloudKit/Log/NKLogFileManager.swift index a63ee3f9..0fa9b1fa 100644 --- a/Sources/NextcloudKit/Log/NKLogFileManager.swift +++ b/Sources/NextcloudKit/Log/NKLogFileManager.swift @@ -25,10 +25,10 @@ public enum NKLogLevel: Int, CaseIterable, Identifiable, Comparable { // For Picker display public var displayText: String { switch self { - case .disabled: return NSLocalizedString("_disabled_", comment: "") - case .compact: return NSLocalizedString("_compact_", comment: "") - case .normal: return NSLocalizedString("_normal_", comment: "") - case .verbose: return NSLocalizedString("_verbose_", comment: "") + case .disabled: return NSLocalizedString("Disabled", bundle: .module, comment: "Log level that turns logging off.") + case .compact: return NSLocalizedString("Compact", bundle: .module, comment: "Log level that logs only the essentials.") + case .normal: return NSLocalizedString("Normal", bundle: .module, comment: "Default log level.") + case .verbose: return NSLocalizedString("Verbose", bundle: .module, comment: "Log level that logs everything, for debugging.") } } diff --git a/Sources/NextcloudKit/NKCommon.swift b/Sources/NextcloudKit/NKCommon.swift index 89a6f31f..0976ba1c 100644 --- a/Sources/NextcloudKit/NKCommon.swift +++ b/Sources/NextcloudKit/NKCommon.swift @@ -162,7 +162,7 @@ public struct NKCommon: Sendable { @inline(__always) func checkCancelled() throws { if Task.isCancelled { - throw NKError(errorCode: -5, errorDescription: "Chunking was cancelled.") + throw NKError(errorCode: -5, errorDescription: "Chunking was cancelled.", kind: .cancelled) } } @@ -247,11 +247,11 @@ public struct NKCommon: Sendable { case -2: throw NKError(errorCode: -2, errorDescription: "Failed to open the output chunk file for writing.") case -3: - throw NKError(errorCode: -3, errorDescription: "There is not enough available disk space to proceed.") + throw NKError(errorCode: -3, errorDescription: "There is not enough available disk space to proceed.", kind: .localStorageFull) case -4: throw NKError(errorCode: -4, errorDescription: "Failed to write data to chunk file.") case -5: - throw NKError(errorCode: -5, errorDescription: "Chunking was cancelled.") + throw NKError(errorCode: -5, errorDescription: "Chunking was cancelled.", kind: .cancelled) default: continue } diff --git a/Sources/NextcloudKit/NKError+Kind.swift b/Sources/NextcloudKit/NKError+Kind.swift new file mode 100644 index 00000000..534e6104 --- /dev/null +++ b/Sources/NextcloudKit/NKError+Kind.swift @@ -0,0 +1,213 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Alamofire +import Foundation + +public extension NKError { + /// What went wrong, independent of how the server or the system worded it. + /// + /// Choose the message shown to users from this, and keep ``NKError/errorDescription`` for logs. + enum Kind: Sendable, CaseIterable { + case offline + case timeout + case serverUnreachable + case secureConnection + case unauthorized + case forbidden + case notFound + /// `MKCOL` on an existing folder, or `COPY`/`MOVE` onto an existing item without overwriting it. + case alreadyExists + /// The server doesn't accept the file or folder name. + case invalidName + case conflict + case locked + case quotaExceeded + case tooLarge + case rateLimited + /// HTTP 503, which includes maintenance mode. + case unavailable + case serverError + case invalidResponse + case localStorageFull + case cancelled + case unknown + } +} + +extension NKError.Kind { + /// Classifies the WebDAV exceptions that the HTTP status alone doesn't tell apart. + init?(davException: String?) { + guard davException == "OCA\\DAV\\Connector\\Sabre\\Exception\\InvalidPath" else { return nil } + + self = .invalidName + } + + /// Classifies an ``NKError/errorCode``: a URL loading error, an HTTP status or an OCS status code. + /// + /// Returns `nil` for `0`, which means success. The request method tells some WebDAV statuses apart. + init?(code: Int, method: String? = nil) { + switch code { + case 0: + return nil + + case ..<0: + self.init(urlErrorCode: code) + + case 996: + self = .serverError + + case 997: + self = .unauthorized + + case 998: + self = .notFound + + default: + self.init(httpStatusCode: code, method: method) + } + } + + /// Classifies any error, using its domain to tell codes apart. + init(error: Error) { + if let error = error as? NKError { + self = error.kind ?? .unknown + } else if error is CancellationError { + self = .cancelled + } else if error is DecodingError { + self = .invalidResponse + } else if let error = error as? AFError { + self.init(afError: error) + } else { + self.init(nsError: error as NSError) + } + } +} + +extension NKError.Kind { + private init(urlErrorCode code: Int) { + switch code { + case NSURLErrorCancelled: + self = .cancelled + + case NSURLErrorNotConnectedToInternet, NSURLErrorDataNotAllowed, NSURLErrorInternationalRoamingOff, NSURLErrorCallIsActive: + self = .offline + + case NSURLErrorTimedOut: + self = .timeout + + case NSURLErrorBadURL, NSURLErrorUnsupportedURL, NSURLErrorCannotFindHost, NSURLErrorCannotConnectToHost, NSURLErrorNetworkConnectionLost, NSURLErrorDNSLookupFailed: + self = .serverUnreachable + + case NSURLErrorSecureConnectionFailed, NSURLErrorServerCertificateHasBadDate, NSURLErrorServerCertificateUntrusted, NSURLErrorServerCertificateHasUnknownRoot, + NSURLErrorServerCertificateNotYetValid, NSURLErrorClientCertificateRejected, NSURLErrorClientCertificateRequired, NSURLErrorAppTransportSecurityRequiresSecureConnection: + self = .secureConnection + + case NSURLErrorUserCancelledAuthentication, NSURLErrorUserAuthenticationRequired: + self = .unauthorized + + case NSURLErrorBadServerResponse, NSURLErrorZeroByteResource, NSURLErrorCannotDecodeRawData, NSURLErrorCannotDecodeContentData, + NSURLErrorCannotParseResponse, NSURLErrorHTTPTooManyRedirects, NSURLErrorRedirectToNonExistentLocation: + self = .invalidResponse + + case NSURLErrorResourceUnavailable: + self = .unavailable + + case NSURLErrorDataLengthExceedsMaximum: + self = .tooLarge + + default: + self = .unknown + } + } + + private init(httpStatusCode: Int, method: String? = nil) { + let method = method?.uppercased() + + switch httpStatusCode { + case 401: + self = .unauthorized + + case 403: + self = .forbidden + + case 404, 410: + self = .notFound + + case 405 where method == "MKCOL": + self = .alreadyExists + + case 408, 504: + self = .timeout + + case 409: + self = .conflict + + case 412 where method == "COPY" || method == "MOVE": + self = .alreadyExists + + case 412: + self = .conflict + + case 413: + self = .tooLarge + + case 423: + self = .locked + + case 429: + self = .rateLimited + + case 502: + self = .serverUnreachable + + case 503: + self = .unavailable + + case 507: + self = .quotaExceeded + + case 500...511: + self = .serverError + + default: + self = .unknown + } + } + + private init(afError: AFError) { + if afError.isExplicitlyCancelledError { + self = .cancelled + } else if afError.isServerTrustEvaluationError { + self = .secureConnection + } else if let code = afError.responseCode { + self.init(httpStatusCode: code) + } else if afError.isResponseSerializationError { + self = .invalidResponse + } else if let underlyingError = afError.underlyingError { + self.init(error: underlyingError) + } else { + self = .unknown + } + } + + private init(nsError: NSError) { + switch (nsError.domain, nsError.code) { + case (NSURLErrorDomain, _): + self.init(urlErrorCode: nsError.code) + + case (NSCocoaErrorDomain, NSFileWriteOutOfSpaceError), (NSPOSIXErrorDomain, Int(ENOSPC)): + self = .localStorageFull + + case (NSCocoaErrorDomain, NSUserCancelledError): + self = .cancelled + + case (NSCocoaErrorDomain, NSPropertyListReadCorruptError): + self = .invalidResponse + + default: + self = .unknown + } + } +} diff --git a/Sources/NextcloudKit/NKError+localizedGenericMessage.swift b/Sources/NextcloudKit/NKError+localizedGenericMessage.swift new file mode 100644 index 00000000..f852918b --- /dev/null +++ b/Sources/NextcloudKit/NKError+localizedGenericMessage.swift @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +public extension NKError { + /// The message to show users: the share API's own message when there is one, otherwise a translated message for ``kind``. + var localizedGenericMessage: String { + if let userFriendlyServerMessage { + return userFriendlyServerMessage + } + + switch kind ?? .unknown { + case .offline: + return NSLocalizedString("You're offline. Please check your internet connection.", bundle: .module, comment: "Error message when the device has no internet connection.") + + case .timeout: + return NSLocalizedString("The server took too long to respond. Try again later.", bundle: .module, comment: "Error message when a request to the server timed out.") + + case .serverUnreachable: + return NSLocalizedString("Can't reach the server. Check the address or try again later.", bundle: .module, comment: "Error message when the server cannot be reached.") + + case .secureConnection: + return NSLocalizedString("A secure connection to the server couldn't be established.", bundle: .module, comment: "Error message for TLS and certificate failures.") + + case .unauthorized: + return NSLocalizedString("Your login is no longer valid. Please log in again.", bundle: .module, comment: "Error message when the server rejects the account's credentials.") + + case .forbidden: + return NSLocalizedString("You don't have permission to do this.", bundle: .module, comment: "Error message when the server forbids the action.") + + case .notFound: + return NSLocalizedString("The item could not be found on the server.", bundle: .module, comment: "Error message when the requested file, folder or share does not exist on the server.") + + case .alreadyExists: + return NSLocalizedString("An item with this name already exists.", bundle: .module, comment: "Error message when a file or folder with the same name already exists.") + + case .invalidName: + return NSLocalizedString("This name isn't allowed.", bundle: .module, comment: "Error message when the server rejects a file or folder name.") + + case .conflict: + return NSLocalizedString("The item was changed on the server. Please refresh and try again.", bundle: .module, comment: "Error message when the request conflicts with the current state on the server.") + + case .locked: + return NSLocalizedString("The file is locked. Try again later.", bundle: .module, comment: "Error message when the file is locked on the server.") + + case .quotaExceeded: + return NSLocalizedString("There is not enough storage space left on the server.", bundle: .module, comment: "Error message when the account's storage quota is used up.") + + case .tooLarge: + return NSLocalizedString("The file is too large for the server.", bundle: .module, comment: "Error message when the server rejects a file because of its size.") + + case .rateLimited: + return NSLocalizedString("Too many requests. Please wait a moment and try again.", bundle: .module, comment: "Error message when the server rate-limits the requests.") + + case .unavailable: + return NSLocalizedString("The server is temporarily unavailable. Try again later.", bundle: .module, comment: "Error message when the server is unavailable, e.g. in maintenance mode.") + + case .serverError: + return NSLocalizedString("The server ran into a problem. Try again later.", bundle: .module, comment: "Error message for internal server errors.") + + case .invalidResponse: + return NSLocalizedString("The server sent an unexpected response. Try again later.", bundle: .module, comment: "Error message when the server's response cannot be read.") + + case .localStorageFull: + return NSLocalizedString("There is not enough storage space left on this device.", bundle: .module, comment: "Error message when the device runs out of storage space.") + + case .cancelled: + return NSLocalizedString("The request was cancelled.", bundle: .module, comment: "Error message when a request was cancelled.") + + case .unknown: + return NSLocalizedString("Something went wrong. Please try again.", bundle: .module, comment: "Error message when the cause of the error is unknown.") + } + } +} diff --git a/Sources/NextcloudKit/NKError.swift b/Sources/NextcloudKit/NKError.swift index 7ccef8f7..1649ba8f 100644 --- a/Sources/NextcloudKit/NKError.swift +++ b/Sources/NextcloudKit/NKError.swift @@ -30,7 +30,9 @@ extension OCSPath { static var ocsMetaCode: Self { ["ocs", "meta", "statuscode"] } static var ocsMetaMsg: Self { ["ocs", "meta", "message"] } static var ocsDataMsg: Self { ["ocs", "data", "message"] } + static var ocsData: Self { ["ocs", "data"] } static var ocsXMLMsg: Self { ["d:error", "s:message"] } + static var davException: Self { ["d:error", "s:exception"] } } public struct NKError: Error, Equatable, Sendable { @@ -41,128 +43,146 @@ public struct NKError: Error, Equatable, Sendable { public let error: Error public let responseData: Data? - public static let urlError = NKError(errorCode: NSURLErrorBadURL, errorDescription: NSLocalizedString("_invalid_url_", value: "Invalid server url", comment: "")) - public static let invalidResponseError = NKError(errorCode: NSURLErrorBadServerResponse, errorDescription: NSLocalizedString("_error_response_", value: "Invalid response", comment: "")) - public static let xmlError = NKError(errorCode: NSURLErrorBadServerResponse, errorDescription: NSLocalizedString("_error_decode_xml_", value: "Invalid response, error decoding XML", comment: "")) - public static let invalidDate = NKError(errorCode: NSURLErrorBadServerResponse, errorDescription: NSLocalizedString("_invalid_date_format_", value: "Invalid date format", comment: "")) - public static let invalidData = NKError(errorCode: NSURLErrorCannotDecodeContentData, errorDescription: NSLocalizedString("_invalid_data_format_", value: "Invalid data format", comment: "")) - public static let unauthorizedError = NKError(errorCode: 401, errorDescription: NSLocalizedString("_unauthorized_", value: "Unauthorized", comment: "")) - public static let unavailableError = NKError(errorCode: 503, errorDescription: NSLocalizedString("_Unavailable_", value: "Unavailable", comment: "")) - public static let forbiddenError = NKError(errorCode: 403, errorDescription: NSLocalizedString("_forbidden_", value: "Forbidden", comment: "")) - public static let cancelled = NKError(errorCode: -999, errorDescription: NSLocalizedString("_cancelled_", value: "Cancelled", comment: "")) - - public static let errorChunkFileNull = NKError(errorCode: -9994, errorDescription: NSLocalizedString("_error_file_null_", value: "File not found", comment: "")) - public static let errorChunkFilesEmpty = NKError(errorCode: -9995, errorDescription: NSLocalizedString("_chunk_files_empty_", value: "Files not found", comment: "")) - public static let errorChunkCreateFolder = NKError(errorCode: -9996, errorDescription: NSLocalizedString("_error_create_folder_", value: "Create folder error", comment: "")) - public static let errorChunkMoveFile = NKError(errorCode: -9997, errorDescription: NSLocalizedString("_error_move_folder_", value: "Move file error", comment: "")) - public static let errorChunkNoEnoughMemory = NKError(errorCode: -9998, errorDescription: NSLocalizedString("_no_enough_memory_", value: "No enough memory", comment: "")) + /// What went wrong, for choosing the message shown to users; `nil` for ``success``. + public let kind: Kind? + /// The OCS message the server sent with the error. + private let serverMessage: String? + /// The URL path of the request that failed, when known. + private var requestPath: String? + + public static let urlError = NKError(errorCode: NSURLErrorBadURL, errorDescription: "Invalid server URL") + public static let invalidResponseError = NKError(errorCode: NSURLErrorBadServerResponse, errorDescription: "Invalid response") + public static let xmlError = NKError(errorCode: NSURLErrorBadServerResponse, errorDescription: "Invalid response, error decoding XML") + public static let invalidDate = NKError(errorCode: NSURLErrorBadServerResponse, errorDescription: "Invalid date format") + public static let invalidData = NKError(errorCode: NSURLErrorCannotDecodeContentData, errorDescription: "Invalid data format") + public static let unauthorizedError = NKError(errorCode: 401, errorDescription: "Unauthorized") + public static let unavailableError = NKError(errorCode: 503, errorDescription: "Unavailable") + public static let forbiddenError = NKError(errorCode: 403, errorDescription: "Forbidden") + public static let cancelled = NKError(errorCode: -999, errorDescription: "Cancelled") + + public static let errorChunkFileNull = NKError(errorCode: -9994, errorDescription: "File not found") + public static let errorChunkFilesEmpty = NKError(errorCode: -9995, errorDescription: "Files not found") + public static let errorChunkCreateFolder = NKError(errorCode: -9996, errorDescription: "Create folder error") + public static let errorChunkMoveFile = NKError(errorCode: -9997, errorDescription: "Move file error") + public static let errorChunkNoEnoughMemory = NKError(errorCode: -9998, errorDescription: "Not enough memory", kind: .localStorageFull) public static let success = NKError(errorCode: 0, errorDescription: "") - /// Returns a localized user-facing description for a known error code. + /// The server's message where Nextcloud writes it for users: the share API and unified sharing's property updates. + /// Other server texts are technical. + public var userFriendlyServerMessage: String? { + guard let requestPath else { return nil } + + let isShareAPI = requestPath.contains("/apps/files_sharing/api/v1/shares") + let isUnifiedShareProperty = requestPath.contains("/apps/sharing/api/v1/share/") && requestPath.hasSuffix("/property") + + return isShareAPI || isUnifiedShareProperty ? serverMessage : nil + } + + /// Returns the English description of a known error code, for logs. /// /// - Parameter code: The HTTP, URL loading, WebDAV, OCS, or internal error code. - /// - Returns: A localized description when the code is known, otherwise `nil`. + /// - Returns: A description when the code is known, otherwise `nil`. public static func getErrorDescription(for code: Int) -> String? { switch code { case -9999: - return NSLocalizedString("_internal_server_", value: "Internal error", comment: "") + return "Internal error" case -1001: - return NSLocalizedString("_time_out_", value: "Time out", comment: "") + return "Time out" case -1004: - return NSLocalizedString("_server_down_", value: "The server appears to be down", comment: "") + return "The server appears to be down" case -1005: - return NSLocalizedString("_not_possible_connect_to_server_", value: "It is not possible to connect to the server at this time", comment: "") + return "It is not possible to connect to the server at this time" case -1009: - return NSLocalizedString("_not_connected_internet_", value: "Server connection error", comment: "") + return "Server connection error" case -1011: - return NSLocalizedString("_error_", value: "Generic error", comment: "") + return "Generic error" case -1012: - return NSLocalizedString("_not_possible_connect_to_server_", value: "It is not possible to connect to the server at this time", comment: "") + return "It is not possible to connect to the server at this time" case -1013: - return NSLocalizedString("_user_authentication_required_", value: "User authentication required", comment: "") + return "User authentication required" case -1200: - return NSLocalizedString("_ssl_connection_error_", value: "Connection SSL error, try again", comment: "") + return "Connection SSL error, try again" case -1202: - return NSLocalizedString("_ssl_certificate_untrusted_", value: "The certificate for this server is invalid", comment: "") + return "The certificate for this server is invalid" case 0: return "" case 101: - return NSLocalizedString("_forbidden_characters_from_server_", value: "The name contains at least one invalid character", comment: "") + return "The name contains at least one invalid character" case 200: - return NSLocalizedString("_transfer_stopped_", value: "Transfer stopped", comment: "") + return "Transfer stopped" case 207: - return NSLocalizedString("_error_multi_status_", value: "WebDAV multistatus", comment: "") + return "WebDAV multistatus" case 304: - return NSLocalizedString("_error_not_modified_", value: "Resource not modified", comment: "") + return "Resource not modified" case 400: - return NSLocalizedString("_bad_request_", value: "Bad request", comment: "") + return "Bad request" case 401: - return NSLocalizedString("_unauthorized_", value: "Unauthorized", comment: "") + return "Unauthorized" case 403: - return NSLocalizedString("_error_not_permission_", value: "You don't have permission to complete the operation", comment: "") + return "You don't have permission to complete the operation" case 404: - return NSLocalizedString("_error_not_found_", value: "The requested resource could not be found", comment: "") + return "The requested resource could not be found" case 405: - return NSLocalizedString("_method_not_allowed_", value: "The requested method is not supported", comment: "") + return "The requested method is not supported" case 408: - return NSLocalizedString("_request_timeout_", value: "Request timeout", comment: "") + return "Request timeout" case 409: - return NSLocalizedString("_error_conflict_", value: "The request could not be completed due to a conflict with the current state of the resource", comment: "") + return "The request could not be completed due to a conflict with the current state of the resource" case 412: - return NSLocalizedString("_error_precondition_", value: "The server does not meet one of the preconditions that the requester", comment: "") + return "The server does not meet one of the preconditions of the request" case 413: - return NSLocalizedString("_request_entity_too_large_", value: "The file is too large", comment: "") + return "The file is too large" case 417: - return NSLocalizedString("_expectation_failed_", value: "Expectation failed", comment: "") + return "Expectation failed" case 423: - return NSLocalizedString("_webdav_locked_", value: "WebDAV Locked: Trying to access locked resource", comment: "") + return "WebDAV Locked: Trying to access locked resource" case 429: - return NSLocalizedString("_too_many_requests_", value: "Too many requests", comment: "") + return "Too many requests" case 500: - return NSLocalizedString("_internal_server_", value: "Internal server error", comment: "") + return "Internal server error" case 502: - return NSLocalizedString("_bad_gateway_", value: "Bad gateway", comment: "") + return "Bad gateway" case 503: - return NSLocalizedString("_server_maintenance_mode_", value: "Server is currently in maintenance mode", comment: "") + return "Server is currently in maintenance mode" case 504: - return NSLocalizedString("_gateway_timeout_", value: "Gateway timeout", comment: "") + return "Gateway timeout" case 507: - return NSLocalizedString("_user_over_quota_", value: "Storage quota is reached", comment: "") + return "Storage quota is reached" case NSURLErrorCannotDecodeContentData: - return NSLocalizedString("_invalid_data_format_", value: "Invalid data format", comment: "") + return "Invalid data format" default: return nil @@ -180,19 +200,26 @@ public struct NKError: Error, Equatable, Sendable { let description = HTTPURLResponse.localizedString(forStatusCode: statusCode) if description.isEmpty { - return NSLocalizedString("_error_", value: "Generic error", comment: "") + return "Generic error" } return description } + private static func firstNonEmpty(_ candidates: String?...) -> String? { + candidates + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .first { !$0.isEmpty } + } + /// Creates an `NKError` from an explicit code and description. /// /// - Parameters: /// - errorCode: The error code. - /// - errorDescription: The user-facing error description. + /// - errorDescription: The error description, mostly for logs. /// - responseData: Optional raw response data associated with the error. - public init(errorCode: Int = 0, errorDescription: String = "", responseData: Data? = nil) { + /// - kind: The kind of failure; derived from `errorCode` when `nil`. + public init(errorCode: Int = 0, errorDescription: String = "", responseData: Data? = nil, kind: Kind? = nil) { self.errorCode = errorCode self.errorDescription = errorDescription self.error = NSError( @@ -201,6 +228,8 @@ public struct NKError: Error, Equatable, Sendable { userInfo: [NSLocalizedDescriptionKey: self.errorDescription] ) self.responseData = responseData + self.kind = kind ?? Kind(code: errorCode) + self.serverMessage = nil } /// Creates an `NKError` from a generic Swift `Error`. @@ -213,6 +242,8 @@ public struct NKError: Error, Equatable, Sendable { self.errorDescription = error.localizedDescription self.error = error self.responseData = responseData + self.kind = Kind(error: error) + self.serverMessage = nil } /// Creates an `NKError` from an `NSError`. @@ -225,6 +256,8 @@ public struct NKError: Error, Equatable, Sendable { self.errorDescription = nsError.localizedDescription self.error = nsError self.responseData = responseData + self.kind = Kind(error: nsError) + self.serverMessage = nil } /// Creates an `NKError` from an OCS JSON response. @@ -237,13 +270,9 @@ public struct NKError: Error, Equatable, Sendable { let statuscode = rootJson[.ocsMetaCode].int ?? fallbackStatusCode ?? NSURLErrorCannotDecodeContentData errorCode = 200..<300 ~= statuscode ? 0 : statuscode - if let dataMsg = rootJson[.ocsDataMsg].string { - errorDescription = dataMsg - } else if let metaMsg = rootJson[.ocsMetaMsg].string { - errorDescription = metaMsg - } else { - errorDescription = NKError.getErrorDescription(for: statuscode) ?? NKError.httpFallbackDescription(for: statuscode) - } + let message = NKError.firstNonEmpty(rootJson[.ocsDataMsg].string, rootJson[.ocsData].string, rootJson[.ocsMetaMsg].string) + + errorDescription = message ?? NKError.getErrorDescription(for: statuscode) ?? NKError.httpFallbackDescription(for: statuscode) self.responseData = responseData self.error = NSError( @@ -251,6 +280,8 @@ public struct NKError: Error, Equatable, Sendable { code: self.errorCode, userInfo: [NSLocalizedDescriptionKey: self.errorDescription] ) + self.kind = errorCode == 0 ? nil : Kind(code: statuscode) + self.serverMessage = message } /// Creates an `NKError` from an HTTP status code. @@ -272,6 +303,8 @@ public struct NKError: Error, Equatable, Sendable { ) self.responseData = responseData + self.kind = Kind(code: statusCode) + self.serverMessage = nil } /// Creates an `NKError` from an HTTP response. @@ -289,8 +322,9 @@ public struct NKError: Error, Equatable, Sendable { /// - Parameters: /// - xmlData: The raw XML response data. /// - fallbackStatusCode: The fallback HTTP status code used when the OCS status code is missing. + /// - method: The HTTP method of the request, which tells some WebDAV statuses apart. /// - responseData: Optional raw response data associated with the error. - init(xmlData: Data, fallbackStatusCode: Int? = nil, responseData: Data? = nil) { + init(xmlData: Data, fallbackStatusCode: Int? = nil, method: String? = nil, responseData: Data? = nil) { let xml = XML.parse(xmlData) let statuscode = xml[.ocsMetaCode].int ?? fallbackStatusCode ?? NSURLErrorCannotDecodeContentData errorCode = 200..<300 ~= statuscode ? 0 : statuscode @@ -311,6 +345,8 @@ public struct NKError: Error, Equatable, Sendable { code: self.errorCode, userInfo: [NSLocalizedDescriptionKey: self.errorDescription] ) + self.kind = errorCode == 0 ? nil : Kind(davException: xml[.davException].text) ?? Kind(code: statuscode, method: method) + self.serverMessage = nil } /// Creates an `NKError` from an Alamofire response and optional Alamofire error. @@ -320,22 +356,21 @@ public struct NKError: Error, Equatable, Sendable { /// - afResponse: The Alamofire response. /// - responseData: Optional raw response data associated with the error. public init(error: AFError?, afResponse: T, responseData: Data? = nil) { + let dataResponse = afResponse as? Alamofire.DataResponse + if let errorCode = afResponse.response?.statusCode { - guard let dataResponse = afResponse as? Alamofire.DataResponse, - let errorData = dataResponse.data - else { + if let errorData = dataResponse?.data { + if let errorJson = try? JSON(data: errorData) { + self.init(rootJson: errorJson, fallbackStatusCode: errorCode, responseData: responseData) + } else { + self.init(xmlData: errorData, fallbackStatusCode: errorCode, method: dataResponse?.request?.httpMethod, responseData: responseData) + } + } else { self.init( statusCode: errorCode, fallbackDescription: Self.httpFallbackDescription(for: errorCode), responseData: responseData ) - return - } - - if let errorJson = try? JSON(data: errorData) { - self.init(rootJson: errorJson, fallbackStatusCode: errorCode, responseData: responseData) - } else { - self.init(xmlData: errorData, fallbackStatusCode: errorCode, responseData: responseData) } } else if let error = error { @@ -361,6 +396,8 @@ public struct NKError: Error, Equatable, Sendable { } else { self.init(errorCode: 0, errorDescription: "", responseData: responseData) } + + requestPath = dataResponse?.request?.url?.path } public static func == (lhs: NKError, rhs: NKError) -> Bool { diff --git a/Sources/NextcloudKit/NextcloudKit+Login.swift b/Sources/NextcloudKit/NextcloudKit+Login.swift index a4c0114a..240f6a99 100644 --- a/Sources/NextcloudKit/NextcloudKit+Login.swift +++ b/Sources/NextcloudKit/NextcloudKit+Login.swift @@ -286,12 +286,12 @@ public extension NextcloudKit { } guard let endpointString, let endpointURL = URL(string: endpointString) else { - continuation.resume(throwing: NKError.urlError) + continuation.resume(throwing: NKError.invalidResponseError) return } guard let loginString, let loginURL = URL(string: loginString) else { - continuation.resume(throwing: NKError.urlError) + continuation.resume(throwing: NKError.invalidResponseError) return } diff --git a/Sources/NextcloudKit/NextcloudKit+UnifiedSharing.swift b/Sources/NextcloudKit/NextcloudKit+UnifiedSharing.swift index cd4e03b0..4d963928 100644 --- a/Sources/NextcloudKit/NextcloudKit+UnifiedSharing.swift +++ b/Sources/NextcloudKit/NextcloudKit+UnifiedSharing.swift @@ -97,7 +97,7 @@ public extension NextcloudKit { switch response.result { case .failure(let error): - return (account, nil, response, unifiedShareError(from: response, error: error)) + return (account, nil, response, NKError(error: error, afResponse: response, responseData: response.data)) case .success(let data): do { let wrap = try JSONDecoder().decode(NKOCSWrapper<[NKUnifiedShareRecipient]>.self, from: data) @@ -135,7 +135,7 @@ public extension NextcloudKit { switch response.result { case .failure(let error): - return (account, nil, response, unifiedShareError(from: response, error: error)) + return (account, nil, response, NKError(error: error, afResponse: response, responseData: response.data)) case .success(let data): do { let wrap = try JSONDecoder().decode(NKOCSWrapper.self, from: data) @@ -176,7 +176,7 @@ public extension NextcloudKit { switch response.result { case .failure(let error): - return (account, nil, response, unifiedShareError(from: response, error: error)) + return (account, nil, response, NKError(error: error, afResponse: response, responseData: response.data)) case .success(let data): do { let wrap = try JSONDecoder().decode(NKOCSWrapper.self, from: data) @@ -294,7 +294,7 @@ public extension NextcloudKit { switch response.result { case .failure(let error): - return (account, response, unifiedShareError(from: response, error: error)) + return (account, response, NKError(error: error, afResponse: response, responseData: response.data)) case .success: return (account, response, .success) } @@ -591,7 +591,7 @@ public extension NextcloudKit { ) -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { switch response.result { case .failure(let error): - return (account, nil, response, unifiedShareError(from: response, error: error)) + return (account, nil, response, NKError(error: error, afResponse: response, responseData: response.data)) case .success(let data): do { let wrap = try JSONDecoder().decode(NKOCSWrapper.self, from: data) @@ -605,16 +605,6 @@ public extension NextcloudKit { } } - /// Unified-sharing error bodies carry the message as the whole `ocs.data` string. - private func unifiedShareError(from response: AFDataResponse, error: AFError) -> NKError { - if let data = response.data, - let wrap = try? JSONDecoder().decode(NKOCSWrapper.self, from: data), - !wrap.ocs.data.isEmpty { - return NKError(errorCode: wrap.ocs.meta.statuscode, errorDescription: wrap.ocs.data, responseData: data) - } - - return NKError(error: error, afResponse: response, responseData: response.data) - } /// Decode an OCS response containing an array of `Share`. private func decodeUnifiedShareList(response: AFDataResponse, @@ -622,7 +612,7 @@ public extension NextcloudKit { ) -> (account: String, shares: [NKUnifiedShare]?, responseData: AFDataResponse?, error: NKError) { switch response.result { case .failure(let error): - return (account, nil, response, unifiedShareError(from: response, error: error)) + return (account, nil, response, NKError(error: error, afResponse: response, responseData: response.data)) case .success(let data): do { let wrap = try JSONDecoder().decode(NKOCSWrapper<[NKUnifiedShare]>.self, from: data) diff --git a/Sources/NextcloudKit/Utils/FileNameValidator.swift b/Sources/NextcloudKit/Utils/FileNameValidator.swift index 083b761b..e35bd7c3 100644 --- a/Sources/NextcloudKit/Utils/FileNameValidator.swift +++ b/Sources/NextcloudKit/Utils/FileNameValidator.swift @@ -12,27 +12,27 @@ public final class FileNameValidator: Sendable { private let capabilities: NKCapabilities.Capabilities public func fileEmptyNameError() -> NKError { - NKError(errorCode: NSURLErrorCannotCreateFile, errorDescription: NSLocalizedString("_file_name_empty_", value: "File name cannot be empty.", comment: "")) + NKError(errorCode: NSURLErrorCannotCreateFile, errorDescription: NSLocalizedString("File name cannot be empty.", bundle: .module, comment: "Error message when a file or folder name is empty.")) } public func fileWithSpaceError() -> NKError { - NKError(errorCode: NSURLErrorCannotCreateFile, errorDescription: NSLocalizedString("_file_name_validator_error_space_", value: "Name must not contain spaces at the beginning or end.", comment: "")) + NKError(errorCode: NSURLErrorCannotCreateFile, errorDescription: NSLocalizedString("Name must not contain spaces at the beginning or end.", bundle: .module, comment: "Error message when a name starts or ends with a space.")) } public func fileReservedNameError(templateString: String) -> NKError { - let errorMessageTemplate = NSLocalizedString("_file_name_validator_error_reserved_name_", value: "\"%@\" is a forbidden name.", comment: "") + let errorMessageTemplate = NSLocalizedString("\"%@\" is a forbidden name.", bundle: .module, comment: "Error message when the server reserves a name. %@ is the name.") let errorMessage = String(format: errorMessageTemplate, templateString) return NKError(errorCode: NSURLErrorCannotCreateFile, errorDescription: errorMessage) } public func fileForbiddenFileExtensionError(templateString: String) -> NKError { - let errorMessageTemplate = NSLocalizedString("_file_name_validator_error_forbidden_file_extension_", value: ".\"%@\" is a forbidden file extension.", comment: "") + let errorMessageTemplate = NSLocalizedString("\".%@\" is a forbidden file extension.", bundle: .module, comment: "Error message when the server forbids a file extension. %@ is the extension.") let errorMessage = String(format: errorMessageTemplate, templateString) return NKError(errorCode: NSURLErrorCannotCreateFile, errorDescription: errorMessage) } public func fileInvalidCharacterError(templateString: String) -> NKError { - let errorMessageTemplate = NSLocalizedString("_file_name_validator_error_invalid_character_", value: "Name contains an invalid character: \"%@\".", comment: "") + let errorMessageTemplate = NSLocalizedString("Name contains an invalid character: \"%@\".", bundle: .module, comment: "Error message when a name contains a forbidden character. %@ is the character.") let errorMessage = String(format: errorMessageTemplate, templateString) return NKError(errorCode: NSURLErrorCannotCreateFile, errorDescription: errorMessage) } diff --git a/Sources/NextcloudKitUI/Localizable.xcstrings b/Sources/NextcloudKitUI/Localizable.xcstrings index 6b023a60..3527724c 100644 --- a/Sources/NextcloudKitUI/Localizable.xcstrings +++ b/Sources/NextcloudKitUI/Localizable.xcstrings @@ -7130,6 +7130,17 @@ } } }, + "No server was found at this address." : { + "comment" : "Login error message when the entered address does not point to a Nextcloud server.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No server was found at this address." + } + } + } + }, "Not Shared Yet" : { "comment" : "Empty-state title shown when a file or folder has no unified shares.", "localizations" : { diff --git a/Sources/NextcloudKitUI/Views/Login/LoginFlowModel.swift b/Sources/NextcloudKitUI/Views/Login/LoginFlowModel.swift index 8446a66e..2c1f1b34 100644 --- a/Sources/NextcloudKitUI/Views/Login/LoginFlowModel.swift +++ b/Sources/NextcloudKitUI/Views/Login/LoginFlowModel.swift @@ -161,9 +161,24 @@ final class LoginFlowModel: QRCodeParsing, URLSanitizing { cancel() if let error { - let message = (error as? NKError)?.errorDescription ?? error.localizedDescription - present(error: message) + let nkError = error as? NKError ?? NKError(error: error) + + nkLog(error: "Login failed: \(nkError.errorCode) \(nkError.errorDescription)") + present(error: message(for: nkError)) + } + } + + /// + /// A 404 or an unreadable response during login means there is no Nextcloud instance at the entered address. + /// + private func message(for error: NKError) -> String { + let isInstanceNotFound = error.kind == .notFound || error.kind == .invalidResponse + + if isInstanceNotFound { + return String(localized: "No server was found at this address.", bundle: .module, comment: "Login error message when the entered address does not point to a Nextcloud server.") } + + return error.localizedGenericMessage } private func present(error: String?) { @@ -210,7 +225,8 @@ final class LoginFlowModel: QRCodeParsing, URLSanitizing { if result.error == .success, let token = result.token { addAccount(credentials.host, credentials.user, token) } else { - present(error: result.error.errorDescription) + nkLog(error: "One-time login failed: \(result.error.errorCode) \(result.error.errorDescription)") + present(error: message(for: result.error)) } } } diff --git a/Sources/NextcloudKitUI/Views/Unified Share/Edit/UnifiedShareEditModel.swift b/Sources/NextcloudKitUI/Views/Unified Share/Edit/UnifiedShareEditModel.swift index cd34829a..654d399d 100644 --- a/Sources/NextcloudKitUI/Views/Unified Share/Edit/UnifiedShareEditModel.swift +++ b/Sources/NextcloudKitUI/Views/Unified Share/Edit/UnifiedShareEditModel.swift @@ -31,7 +31,13 @@ public class UnifiedShareEditModel { /// Last property-update error text, keyed by property class (shown under the field). var propertyErrors: [String: String] = [:] /// Transient mutation failure, shown as an alert while the form stays usable. - var mutationError: NKError? + var mutationError: NKError? { + didSet { + if let mutationError { + nkLog(error: "Could not update share: \(mutationError.errorCode) \(mutationError.errorDescription)") + } + } + } /// Changes on a failed permission mutation so controls are recreated from server values. var permissionResetRevision = 0 /// Per-property revisions used to restore editors after a rejected mutation. @@ -451,8 +457,8 @@ public class UnifiedShareEditModel { pendingProperties.remove(propertyClass) guard let share = result.share else { - let description = result.error.errorDescription - propertyErrors[propertyClass] = description.isEmpty ? String(localized: "Failed to update share.", bundle: .module) : description + nkLog(error: "Could not update share property \(propertyClass): \(result.error.errorCode) \(result.error.errorDescription)") + propertyErrors[propertyClass] = result.error.localizedGenericMessage propertyResetRevisions[propertyClass, default: 0] += 1 return diff --git a/Sources/NextcloudKitUI/Views/Unified Share/Edit/UnifiedShareEditView.swift b/Sources/NextcloudKitUI/Views/Unified Share/Edit/UnifiedShareEditView.swift index ec4a4792..b5fbca09 100644 --- a/Sources/NextcloudKitUI/Views/Unified Share/Edit/UnifiedShareEditView.swift +++ b/Sources/NextcloudKitUI/Views/Unified Share/Edit/UnifiedShareEditView.swift @@ -226,7 +226,7 @@ public struct UnifiedShareEditView: View { Button(String(localized: "OK", bundle: .module), role: .cancel) {} } message: { if let error = model.mutationError { - Text(error.errorDescription) + Text(error.localizedGenericMessage) } } .confirmationDialog( diff --git a/Sources/NextcloudKitUI/Views/Unified Share/List/UnifiedShareListModel.swift b/Sources/NextcloudKitUI/Views/Unified Share/List/UnifiedShareListModel.swift index 79984e6f..4cb00822 100644 --- a/Sources/NextcloudKitUI/Views/Unified Share/List/UnifiedShareListModel.swift +++ b/Sources/NextcloudKitUI/Views/Unified Share/List/UnifiedShareListModel.swift @@ -9,7 +9,7 @@ import NextcloudKit enum UnifiedShareListState { case loading case loaded([NKUnifiedShare]) - case error(Error) + case error(NKError) } @MainActor @@ -67,6 +67,7 @@ public class UnifiedShareListModel { if case .loaded = state { onError?(result.error) } else { + nkLog(error: "Could not load shares: \(result.error.errorCode) \(result.error.errorDescription)") state = .error(result.error) } diff --git a/Sources/NextcloudKitUI/Views/Unified Share/List/UnifiedShareListView.swift b/Sources/NextcloudKitUI/Views/Unified Share/List/UnifiedShareListView.swift index 8e91ba91..0e56a34a 100644 --- a/Sources/NextcloudKitUI/Views/Unified Share/List/UnifiedShareListView.swift +++ b/Sources/NextcloudKitUI/Views/Unified Share/List/UnifiedShareListView.swift @@ -98,7 +98,7 @@ public struct UnifiedShareListView: View { ContentUnavailableView { Label(String(localized: "Couldn't load shares", bundle: .module), systemImage: "exclamationmark.triangle") } description: { - Text(error.localizedDescription) + Text(error.localizedGenericMessage) } actions: { Button(String(localized: "Retry", bundle: .module)) { model.load() diff --git a/Tests/NextcloudKitUnitTests/NKErrorKindTests.swift b/Tests/NextcloudKitUnitTests/NKErrorKindTests.swift new file mode 100644 index 00000000..91e341b6 --- /dev/null +++ b/Tests/NextcloudKitUnitTests/NKErrorKindTests.swift @@ -0,0 +1,204 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Alamofire +import Foundation +import SwiftyJSON +import Testing +@testable import NextcloudKit + +/// Verifies that `NKError` classifies failures into a `Kind` and only passes on the share API's messages. +@Suite("NKError kind and server message") +struct NKErrorKindTests { + static let urlErrorCases: [(Int, NKError.Kind)] = [ + (NSURLErrorNotConnectedToInternet, .offline), + (NSURLErrorTimedOut, .timeout), + (NSURLErrorCannotFindHost, .serverUnreachable), + (NSURLErrorBadURL, .serverUnreachable), + (NSURLErrorServerCertificateUntrusted, .secureConnection), + (NSURLErrorUserAuthenticationRequired, .unauthorized), + (NSURLErrorCannotParseResponse, .invalidResponse), + (NSURLErrorCancelled, .cancelled), + (NSURLErrorUnknown, .unknown) + ] + + static let httpStatusCases: [(Int, NKError.Kind)] = [ + (400, .unknown), + (401, .unauthorized), + (403, .forbidden), + (404, .notFound), + (409, .conflict), + (412, .conflict), + (413, .tooLarge), + (423, .locked), + (429, .rateLimited), + (500, .serverError), + (502, .serverUnreachable), + (503, .unavailable), + (504, .timeout), + (507, .quotaExceeded) + ] + + static let ocsStatusCases: [(Int, NKError.Kind?)] = [ + (200, nil), + (403, .forbidden), + (500, .serverError), + (997, .unauthorized) + ] + + private static let davURL = URL(string: "https://cloud.example.com/remote.php/dav/files/alice/Documents")! + private static let shareURL = URL(string: "https://cloud.example.com/ocs/v2.php/apps/files_sharing/api/v1/shares/42")! + private static let unifiedSharePropertyURL = URL(string: "https://cloud.example.com/ocs/v2.php/apps/sharing/api/v1/share/abc/property")! + + private func davErrorBody(exception: String, message: String) -> Data { + Data(""" + + + \(exception) + \(message) + + """.utf8) + } + + private func ocsErrorBody(statusCode: Int, message: String) -> Data { + Data(#"{"ocs":{"meta":{"status":"failure","statuscode":\#(statusCode),"message":"\#(message)"},"data":[]}}"#.utf8) + } + + private func responseError(url: URL = NKErrorKindTests.davURL, statusCode: Int, method: String, body: Data?) -> NKError { + var request = URLRequest(url: url) + request.httpMethod = method + + let response = HTTPURLResponse(url: url, statusCode: statusCode, httpVersion: "HTTP/1.1", headerFields: nil) + let dataResponse = AFDataResponse(request: request, response: response, data: body, metrics: nil, serializationDuration: 0, result: .failure(.responseValidationFailed(reason: .unacceptableStatusCode(code: statusCode)))) + + return NKError(error: dataResponse.error, afResponse: dataResponse, responseData: body) + } + + @Test("Classifies URL loading errors", arguments: NKErrorKindTests.urlErrorCases) + func urlErrors(code: Int, kind: NKError.Kind) { + #expect(NKError(nsError: NSError(domain: NSURLErrorDomain, code: code)).kind == kind) + } + + @Test("Classifies HTTP statuses without changing the description", arguments: NKErrorKindTests.httpStatusCases) + func httpStatuses(statusCode: Int, kind: NKError.Kind) { + let error = NKError(statusCode: statusCode, fallbackDescription: "Fallback") + + #expect(error.kind == kind) + #expect(error.errorDescription.hasPrefix("\(statusCode): ")) + } + + @Test("Classifies OCS status codes, including OCS v1 ones", arguments: NKErrorKindTests.ocsStatusCases) + func ocsStatusCodes(statusCode: Int, kind: NKError.Kind?) throws { + let json = try JSON(data: ocsErrorBody(statusCode: statusCode, message: "Message")) + + #expect(NKError(rootJson: json, fallbackStatusCode: 200).kind == kind) + } + + @Test("Uses the request method to tell WebDAV statuses apart") + func requestMethod() { + let body = davErrorBody(exception: "Sabre\\DAV\\Exception\\MethodNotAllowed", message: "The resource you tried to create already exists") + + #expect(responseError(statusCode: 405, method: "MKCOL", body: body).kind == .alreadyExists) + #expect(responseError(statusCode: 405, method: "PROPFIND", body: body).kind == .unknown) + #expect(responseError(statusCode: 412, method: "MOVE", body: body).kind == .alreadyExists) + #expect(responseError(statusCode: 412, method: "COPY", body: body).kind == .alreadyExists) + #expect(responseError(statusCode: 412, method: "PUT", body: body).kind == .conflict) + } + + @Test("Keeps WebDAV messages in the logs only") + func davMessage() { + let error = responseError(statusCode: 409, method: "MKCOL", body: davErrorBody(exception: "Sabre\\DAV\\Exception\\Conflict", message: "Parent node does not exist")) + + #expect(error.kind == .conflict) + #expect(error.userFriendlyServerMessage == nil) + #expect(error.errorDescription == "Parent node does not exist") + } + + @Test("Recognizes a rejected file or folder name") + func invalidPath() { + let body = davErrorBody(exception: "OCA\\DAV\\Connector\\Sabre\\Exception\\InvalidPath", message: "\"/\" is not allowed inside a file or folder name.") + let error = responseError(statusCode: 400, method: "MKCOL", body: body) + + #expect(error.kind == .invalidName) + #expect(error.userFriendlyServerMessage == nil) + } + + @Test("Passes on the share API's messages") + func shareAPIMessage() { + let error = responseError(url: Self.shareURL, statusCode: 403, method: "PUT", body: ocsErrorBody(statusCode: 403, message: "Public upload disabled by the administrator")) + + #expect(error.kind == .forbidden) + #expect(error.userFriendlyServerMessage == "Public upload disabled by the administrator") + } + + @Test("Passes on unified sharing's property validation messages") + func unifiedSharePropertyMessage() { + let body = Data(#"{"ocs":{"meta":{"status":"failure","statuscode":400,"message":""},"data":"Password needs to be at least 10 characters long"}}"#.utf8) + let error = responseError(url: Self.unifiedSharePropertyURL, statusCode: 400, method: "PUT", body: body) + + #expect(error.userFriendlyServerMessage == "Password needs to be at least 10 characters long") + #expect(error.errorDescription == "Password needs to be at least 10 characters long") + } + + @Test("Keeps the messages of other OCS APIs in the logs only", arguments: [ + "https://cloud.example.com/ocs/v2.php/apps/sharing/api/v1/share/abc", + "https://cloud.example.com/ocs/v2.php/apps/sharing/api/v1/share/abc/state", + "https://cloud.example.com/ocs/v2.php/apps/files_sharing/api/v1/sharees" + ]) + func otherOCSMessages(url: String) throws { + let error = responseError(url: try #require(URL(string: url)), statusCode: 404, method: "GET", body: ocsErrorBody(statusCode: 404, message: "Share not found")) + + #expect(error.kind == .notFound) + #expect(error.userFriendlyServerMessage == nil) + #expect(error.errorDescription == "Share not found") + } + + @Test("Derives the kind of explicit and preset errors") + func explicitErrors() { + #expect(NKError.success.kind == nil) + #expect(NKError.cancelled.kind == .cancelled) + #expect(NKError.unauthorizedError.kind == .unauthorized) + #expect(NKError.forbiddenError.kind == .forbidden) + #expect(NKError.unavailableError.kind == .unavailable) + #expect(NKError.invalidData.kind == .invalidResponse) + #expect(NKError.xmlError.kind == .invalidResponse) + #expect(NKError.errorChunkNoEnoughMemory.kind == .localStorageFull) + #expect(NKError(errorCode: -99999, errorDescription: "_internal_error_").kind == .unknown) + #expect(NKError(errorCode: -3, errorDescription: "No space", kind: .localStorageFull).kind == .localStorageFull) + } + + @Test("Classifies errors that do not come from a response") + func otherErrors() { + let decodingError = DecodingError.dataCorrupted(DecodingError.Context(codingPath: [], debugDescription: "Broken")) + + #expect(NKError(error: decodingError).kind == .invalidResponse) + #expect(NKError(error: CancellationError()).kind == .cancelled) + #expect(NKError(error: AFError.explicitlyCancelled).kind == .cancelled) + #expect(NKError(nsError: NSError(domain: NSCocoaErrorDomain, code: NSFileWriteOutOfSpaceError)).kind == .localStorageFull) + #expect(NKError(error: NKError.forbiddenError).kind == .forbidden) + } + + @Test("Classifies transport failures without a response") + func transportFailure() { + let dataResponse = AFDataResponse(request: URLRequest(url: Self.davURL), response: nil, data: nil, metrics: nil, serializationDuration: 0, result: .failure(.sessionTaskFailed(error: URLError(.notConnectedToInternet)))) + let error = NKError(error: dataResponse.error, afResponse: dataResponse) + + #expect(error.kind == .offline) + #expect(error.errorCode == NSURLErrorNotConnectedToInternet) + } + + @Test("Classifies stored error codes") + func storedCodes() { + #expect(NKError.Kind(code: 0) == nil) + #expect(NKError.Kind(code: 507) == .quotaExceeded) + #expect(NKError.Kind(code: NSURLErrorNotConnectedToInternet) == .offline) + #expect(NKError.Kind(code: 997) == .unauthorized) + #expect(NKError.Kind(code: 405, method: "MKCOL") == .alreadyExists) + } + + @Test("Equality still compares only the code and the description") + func equality() { + #expect(NKError(errorCode: 404, errorDescription: "Gone", kind: .notFound) == NKError(errorCode: 404, errorDescription: "Gone", kind: .unknown)) + } +} diff --git a/Tests/NextcloudKitUnitTests/NKErrorLocalizedGenericMessageTests.swift b/Tests/NextcloudKitUnitTests/NKErrorLocalizedGenericMessageTests.swift new file mode 100644 index 00000000..d9cdf020 --- /dev/null +++ b/Tests/NextcloudKitUnitTests/NKErrorLocalizedGenericMessageTests.swift @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Alamofire +import Foundation +import Testing +@testable import NextcloudKit + +/// Tests for the message ``NKError/localizedGenericMessage`` shows to users. +struct NKErrorLocalizedGenericMessageTests { + @Test(arguments: NKError.Kind.allCases) + func kindMessage(kind: NKError.Kind) { + let message = NKError(errorCode: -1, errorDescription: "Technical description", kind: kind).localizedGenericMessage + + #expect(!message.isEmpty) + #expect(message != "Technical description") + } + + @Test func distinctMessagePerKind() { + let messages = Set(NKError.Kind.allCases.map { NKError(errorCode: -1, kind: $0).localizedGenericMessage }) + + #expect(messages.count == NKError.Kind.allCases.count) + } + + @Test func prefersUserFriendlyServerMessage() throws { + let url = try #require(URL(string: "https://cloud.example.com/ocs/v2.php/apps/files_sharing/api/v1/shares")) + let body = Data(#"{"ocs":{"meta":{"statuscode":400,"message":"Password needs to be at least 10 characters long"},"data":[]}}"#.utf8) + let response = HTTPURLResponse(url: url, statusCode: 400, httpVersion: "HTTP/1.1", headerFields: nil) + let dataResponse = AFDataResponse(request: URLRequest(url: url), response: response, data: body, metrics: nil, serializationDuration: 0, result: .failure(.responseValidationFailed(reason: .unacceptableStatusCode(code: 400)))) + + #expect(NKError(error: dataResponse.error, afResponse: dataResponse).localizedGenericMessage == "Password needs to be at least 10 characters long") + } +}