diff --git a/CHANGELOG.md b/CHANGELOG.md index b18cf004d..dbb899428 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,8 @@ All notable changes to this project will be documented in this file. Take a look ### Changed -The Readium Swift toolkit now requires a minimum of iOS 13. +* The Readium Swift toolkit now requires a minimum of iOS 13. +* Plenty of completion-based APIs were changed to use `async` functions instead. #### Shared diff --git a/Documentation/Guides/Readium LCP.md b/Documentation/Guides/Readium LCP.md index 01d01b688..089339f7c 100644 --- a/Documentation/Guides/Readium LCP.md +++ b/Documentation/Guides/Readium LCP.md @@ -164,7 +164,7 @@ let lcpService = LCPService(client: LCPClientAdapter()) /// Facade to the private R2LCPClient.framework. class LCPClientAdapter: ReadiumLCP.LCPClient { - func createContext(jsonLicense: String, hashedPassphrase: String, pemCrl: String) throws -> LCPClientContext { + func createContext(jsonLicense: String, hashedPassphrase: LCPPassphraseHash, pemCrl: String) throws -> LCPClientContext { try R2LCPClient.createContext(jsonLicense: jsonLicense, hashedPassphrase: hashedPassphrase, pemCrl: pemCrl) } @@ -172,7 +172,7 @@ class LCPClientAdapter: ReadiumLCP.LCPClient { R2LCPClient.decrypt(data: data, using: context as! DRMContext) } - func findOneValidPassphrase(jsonLicense: String, hashedPassphrases: [String]) -> String? { + func findOneValidPassphrase(jsonLicense: String, hashedPassphrases: [LCPPassphraseHash]) -> LCPPassphraseHash? { R2LCPClient.findOneValidPassphrase(jsonLicense: jsonLicense, hashedPassphrases: hashedPassphrases) } } diff --git a/Documentation/Migration Guide.md b/Documentation/Migration Guide.md index f2dc1f470..d47c43d48 100644 --- a/Documentation/Migration Guide.md +++ b/Documentation/Migration Guide.md @@ -2,6 +2,13 @@ All migration steps necessary in reading apps to upgrade to major versions of the Swift Readium toolkit will be documented in this file. +## Unreleased + +### Async APIs + +Plenty of completion-based APIs were changed to use `async` functions instead. Follow the deprecation warnings to update your codebase. + + ## 3.0.0-alpha.1 ### R2 prefix dropped diff --git a/Sources/Internal/Extensions/Result.swift b/Sources/Internal/Extensions/Result.swift new file mode 100644 index 000000000..c1ca01c76 --- /dev/null +++ b/Sources/Internal/Extensions/Result.swift @@ -0,0 +1,44 @@ +// +// Copyright 2024 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation + +public extension Result { + /// Asynchronous variant of `map`. + @inlinable func map( + _ transform: (Success) async throws -> NewSuccess + ) async rethrows -> Result { + switch self { + case let .success(success): + return try await .success(transform(success)) + case let .failure(error): + return .failure(error) + } + } + + /// Asynchronous variant of `flatMap`. + @inlinable func flatMap( + _ transform: (Success) async throws -> Result + ) async rethrows -> Result { + switch self { + case let .success(success): + return try await transform(success) + case let .failure(error): + return .failure(error) + } + } + + @inlinable func recover( + _ catching: (Failure) async throws -> Self + ) async rethrows -> Self { + switch self { + case let .success(success): + return .success(success) + case let .failure(error): + return try await catching(error) + } + } +} diff --git a/Sources/LCP/Authentications/LCPAuthenticating.swift b/Sources/LCP/Authentications/LCPAuthenticating.swift index 805ac5988..82559c8f9 100644 --- a/Sources/LCP/Authentications/LCPAuthenticating.swift +++ b/Sources/LCP/Authentications/LCPAuthenticating.swift @@ -24,7 +24,13 @@ public protocol LCPAuthenticating { /// presenting dialogs. For example, the host `UIViewController`. /// - completion: Used to return the retrieved passphrase. If the user cancelled, send nil. /// The passphrase may be already hashed. - func retrievePassphrase(for license: LCPAuthenticatedLicense, reason: LCPAuthenticationReason, allowUserInteraction: Bool, sender: Any?, completion: @escaping (String?) -> Void) + @MainActor + func retrievePassphrase( + for license: LCPAuthenticatedLicense, + reason: LCPAuthenticationReason, + allowUserInteraction: Bool, + sender: Any? + ) async -> String? } public enum LCPAuthenticationReason { diff --git a/Sources/LCP/Authentications/LCPDialogAuthentication.swift b/Sources/LCP/Authentications/LCPDialogAuthentication.swift index bc81a45c7..53033de85 100644 --- a/Sources/LCP/Authentications/LCPDialogAuthentication.swift +++ b/Sources/LCP/Authentications/LCPDialogAuthentication.swift @@ -24,21 +24,29 @@ public class LCPDialogAuthentication: LCPAuthenticating, Loggable { self.modalTransitionStyle = modalTransitionStyle } - public func retrievePassphrase(for license: LCPAuthenticatedLicense, reason: LCPAuthenticationReason, allowUserInteraction: Bool, sender: Any?, completion: @escaping (String?) -> Void) { + public func retrievePassphrase( + for license: LCPAuthenticatedLicense, + reason: LCPAuthenticationReason, + allowUserInteraction: Bool, + sender: Any? + ) async -> String? { guard allowUserInteraction, let viewController = sender as? UIViewController else { if !(sender is UIViewController) { log(.error, "Tried to present the LCP dialog without providing a `UIViewController` as `sender`") } - completion(nil) - return + return nil } - let dialogViewController = LCPDialogViewController(license: license, reason: reason, completion: completion) + return await withCheckedContinuation { continuation in + let dialogViewController = LCPDialogViewController(license: license, reason: reason) { passphrase in + continuation.resume(returning: passphrase) + } - let navController = UINavigationController(rootViewController: dialogViewController) - navController.modalPresentationStyle = modalPresentationStyle - navController.modalTransitionStyle = modalTransitionStyle + let navController = UINavigationController(rootViewController: dialogViewController) + navController.modalPresentationStyle = modalPresentationStyle + navController.modalTransitionStyle = modalTransitionStyle - viewController.present(navController, animated: animated) + viewController.present(navController, animated: animated) + } } } diff --git a/Sources/LCP/Authentications/LCPPassphraseAuthentication.swift b/Sources/LCP/Authentications/LCPPassphraseAuthentication.swift index e61070586..49778c4a1 100644 --- a/Sources/LCP/Authentications/LCPPassphraseAuthentication.swift +++ b/Sources/LCP/Authentications/LCPPassphraseAuthentication.swift @@ -19,16 +19,15 @@ public class LCPPassphraseAuthentication: LCPAuthenticating { self.fallback = fallback } - public func retrievePassphrase(for license: LCPAuthenticatedLicense, reason: LCPAuthenticationReason, allowUserInteraction: Bool, sender: Any?, completion: @escaping (String?) -> Void) { + public func retrievePassphrase(for license: LCPAuthenticatedLicense, reason: LCPAuthenticationReason, allowUserInteraction: Bool, sender: Any?) async -> String? { guard reason == .passphraseNotFound else { if let fallback = fallback { - fallback.retrievePassphrase(for: license, reason: reason, allowUserInteraction: allowUserInteraction, sender: sender, completion: completion) + return await fallback.retrievePassphrase(for: license, reason: reason, allowUserInteraction: allowUserInteraction, sender: sender) } else { - completion(nil) + return nil } - return } - completion(passphrase) + return passphrase } } diff --git a/Sources/LCP/Content Protection/LCPContentProtection.swift b/Sources/LCP/Content Protection/LCPContentProtection.swift index 74dcc109a..8840967c0 100644 --- a/Sources/LCP/Content Protection/LCPContentProtection.swift +++ b/Sources/LCP/Content Protection/LCPContentProtection.swift @@ -33,12 +33,13 @@ final class LCPContentProtection: ContentProtection, Loggable { let authentication = credentials.map { LCPPassphraseAuthentication($0, fallback: self.authentication) } ?? self.authentication - service.retrieveLicense( - from: file.file, - authentication: authentication, - allowUserInteraction: allowUserInteraction, - sender: sender - ) { result in + Task { + let result = await service.retrieveLicense( + from: file.file, + authentication: authentication, + allowUserInteraction: allowUserInteraction, + sender: sender + ) if case let .success(license) = result, license == nil { // Not protected with LCP. completion(.success(nil)) @@ -86,14 +87,12 @@ private final class LCPContentProtectionService: ContentProtectionService { self.error = error } - convenience init(result: CancellableResult) { + convenience init(result: Result) { switch result { case let .success(license): self.init(license: license) case let .failure(error): self.init(error: error) - case .cancelled: - self.init() } } diff --git a/Sources/LCP/LCPAcquiredPublication.swift b/Sources/LCP/LCPAcquiredPublication.swift new file mode 100644 index 000000000..903eaf4e1 --- /dev/null +++ b/Sources/LCP/LCPAcquiredPublication.swift @@ -0,0 +1,21 @@ +// +// Copyright 2024 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +import ReadiumShared + +/// Holds information about an LCP protected publication which was acquired from an LCPL. +public struct LCPAcquiredPublication { + /// Path to the downloaded publication. + /// You must move this file to the user library's folder. + public let localURL: FileURL + + /// Filename that should be used for the publication when importing it in the user library. + public let suggestedFilename: String + + /// LCP license document. + public let licenseDocument: LicenseDocument +} diff --git a/Sources/LCP/LCPAcquisition.swift b/Sources/LCP/LCPAcquisition.swift index 629ba7b49..920ff6528 100644 --- a/Sources/LCP/LCPAcquisition.swift +++ b/Sources/LCP/LCPAcquisition.swift @@ -10,8 +10,10 @@ import ReadiumShared /// Represents an on-going LCP acquisition task. /// /// You can cancel the on-going download with `acquisition.cancel()`. -public final class LCPAcquisition: Loggable, Cancellable { +@available(*, deprecated) +public final class LCPAcquisition: Loggable { /// Informations about an acquired publication protected with LCP. + @available(*, unavailable, renamed: "LCPAcquiredPublication") public struct Publication { /// Path to the downloaded publication. /// You must move this file to the user library's folder. @@ -21,6 +23,7 @@ public final class LCPAcquisition: Loggable, Cancellable { public let suggestedFilename: String } + @available(*, unavailable, renamed: "LCPProgress") /// Percent-based progress of the acquisition. public enum Progress { /// Undetermined progress, a spinner should be shown to the user. @@ -30,32 +33,6 @@ public final class LCPAcquisition: Loggable, Cancellable { } /// Cancels the acquisition. - public func cancel() { - cancellable.cancel() - didComplete(with: .cancelled) - } - - let onProgress: (Progress) -> Void - var cancellable = MediatorCancellable() - - private var isCompleted = false - private let completion: (CancellableResult) -> Void - - init(onProgress: @escaping (Progress) -> Void, completion: @escaping (CancellableResult) -> Void) { - self.onProgress = onProgress - self.completion = completion - } - - func didComplete(with result: CancellableResult) { - guard !isCompleted else { - return - } - isCompleted = true - - completion(result) - - if case let .success(publication) = result, (try? publication.localURL.exists()) == true { - log(.warning, "The acquired LCP publication file was not moved in the completion closure. It will be removed from the file system.") - } - } + @available(*, unavailable, message: "This is not needed with the new async variants") + public func cancel() {} } diff --git a/Sources/LCP/LCPClient.swift b/Sources/LCP/LCPClient.swift index e2a9bf8f7..4c18958ff 100644 --- a/Sources/LCP/LCPClient.swift +++ b/Sources/LCP/LCPClient.swift @@ -31,13 +31,13 @@ import Foundation /// } public protocol LCPClient { /// Create a context for a given license/passphrase tuple. - func createContext(jsonLicense: String, hashedPassphrase: String, pemCrl: String) throws -> LCPClientContext + func createContext(jsonLicense: String, hashedPassphrase: LCPPassphraseHash, pemCrl: String) throws -> LCPClientContext /// Decrypt provided content, given a valid context is provided. func decrypt(data: Data, using context: LCPClientContext) -> Data? /// Given an array of possible password hashes, return a valid password hash for the lcpl licence. - func findOneValidPassphrase(jsonLicense: String, hashedPassphrases: [String]) -> String? + func findOneValidPassphrase(jsonLicense: String, hashedPassphrases: [LCPPassphraseHash]) -> LCPPassphraseHash? } public typealias LCPClientContext = Any diff --git a/Sources/LCP/LCPError.swift b/Sources/LCP/LCPError.swift index be971fbab..3fc776d7f 100644 --- a/Sources/LCP/LCPError.swift +++ b/Sources/LCP/LCPError.swift @@ -5,39 +5,58 @@ // import Foundation +import ReadiumShared public enum LCPError: LocalizedError { - // The operation can't be done right now because another License operation is running. + /// The license could not be retrieved because the passphrase is unknown. + case missingPassphrase + + /// The given file is not an LCP License Document (LCPL). + case notALicenseDocument(FileURL) + + /// The operation can't be done right now because another License operation is running. case licenseIsBusy - // An error occured while checking the integrity of the License, it can't be retrieved. + + /// An error occured while checking the integrity of the License, it can't be retrieved. case licenseIntegrity(LCPClientError) - // The status of the License is not valid, it can't be used to decrypt the publication. + + /// The status of the License is not valid, it can't be used to decrypt the publication. case licenseStatus(StatusError) - // Can't read or write the License Document from its container. + + /// Can't read or write the License Document from its container. case licenseContainer(ContainerError) - // The interaction is not available with this License. + + /// The interaction is not available with this License. case licenseInteractionNotAvailable - // This License's profile is not supported by liblcp. + + /// This License's profile is not supported by liblcp. case licenseProfileNotSupported - // Failed to renew the loan. + + /// Failed to renew the loan. case licenseRenew(RenewError) - // Failed to return the loan. + + /// Failed to return the loan. case licenseReturn(ReturnError) - // Failed to retrieve the Certificate Revocation List. + /// Failed to retrieve the Certificate Revocation List. case crlFetching - // Failed to parse information from the License or Status Documents. + /// Failed to parse information from the License or Status Documents. case parsing(ParsingError) - // A network request failed with the given error. + + /// A network request failed with the given error. case network(Error?) - // An unexpected LCP error occured. Please post an issue on r2-lcp-swift with the error message and how to reproduce it. + + /// An unexpected LCP error occured. Please post an issue on r2-lcp-swift with the error message and how to reproduce it. case runtime(String) - // An unknown low-level error was reported. + + /// An unknown low-level error was reported. case unknown(Error?) public var errorDescription: String? { switch self { + case .missingPassphrase: return nil + case .notALicenseDocument: return nil case .licenseIsBusy: return ReadiumLCPLocalizedString("LCPError.licenseIsBusy") case let .licenseIntegrity(error): diff --git a/Sources/LCP/LCPLicense.swift b/Sources/LCP/LCPLicense.swift index 71627996c..b7e20b194 100644 --- a/Sources/LCP/LCPLicense.swift +++ b/Sources/LCP/LCPLicense.swift @@ -19,11 +19,11 @@ public protocol LCPLicense: UserRights { /// Number of remaining characters allowed to be copied by the user. /// If nil, there's no limit. - var charactersToCopyLeft: Int? { get } + func charactersToCopyLeft() async -> Int? /// Number of pages allowed to be printed by the user. /// If nil, there's no limit. - var pagesToPrintLeft: Int? { get } + func pagesToPrintLeft() async -> Int? /// Can the user renew the loaned publication? var canRenewLoan: Bool { get } @@ -36,17 +36,20 @@ public protocol LCPLicense: UserRights { /// /// - Parameter prefersWebPage: Indicates whether the loan should be renewed through a web page if available, /// instead of programmatically. - func renewLoan(with delegate: LCPRenewDelegate, prefersWebPage: Bool, completion: @escaping (CancellableResult) -> Void) + func renewLoan( + with delegate: LCPRenewDelegate, + prefersWebPage: Bool + ) async -> Result /// Can the user return the loaned publication? var canReturnPublication: Bool { get } /// Returns the publication to its provider. - func returnPublication(completion: @escaping (LCPError?) -> Void) + func returnPublication() async -> Result } public extension LCPLicense { - func renewLoan(with delegate: LCPRenewDelegate, completion: @escaping (CancellableResult) -> Void) { - renewLoan(with: delegate, prefersWebPage: false, completion: completion) + func renewLoan(with delegate: LCPRenewDelegate) async -> Result { + await renewLoan(with: delegate, prefersWebPage: false) } } diff --git a/Sources/LCP/LCPLicenseRepository.swift b/Sources/LCP/LCPLicenseRepository.swift new file mode 100644 index 000000000..d14e80948 --- /dev/null +++ b/Sources/LCP/LCPLicenseRepository.swift @@ -0,0 +1,57 @@ +// +// Copyright 2024 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation + +/// The license repository stores registered licenses with their consumed rights (e.g. copy). +public protocol LCPLicenseRepository { + /// Adds a new `licenseDocument` to the repository, using `licenseDocument.id` as the + /// primary key. + /// + /// ## Implementation notes: + /// + /// * When adding a license for the first time, you **must** initialize the consumable user rights with + /// `licenseDocument.rights`. + /// * If the license already exists in the repository, it is updated **without overwriting** the existing + /// consumable user rights. + func addLicense(_ licenseDocument: LicenseDocument) async throws + + /// Returns the `LicenseDocument` saved with the given `id`. + func license(for id: LicenseDocument.ID) async throws -> LicenseDocument? + + /// Returns whether the device is already registered for the license with given `id` . + func isDeviceRegistered(for id: LicenseDocument.ID) async throws -> Bool + + /// Marks the device as registered for the license with given `id`. + func registerDevice(for id: LicenseDocument.ID) async throws + + /// Returns the consumable user rights for the license with given `id`. + func userRights(for id: LicenseDocument.ID) async throws -> LCPConsumableUserRights + + /// Updates the consumable user rights for the license with given `id`. + func updateUserRights( + for id: LicenseDocument.ID, + with changes: (inout LCPConsumableUserRights) -> Void + ) async throws +} + +/// Holds the current state of consumable user rights for a license. +public struct LCPConsumableUserRights { + /// Maximum number of pages left to be printed. + /// + /// If `nil`, there is no limit. + public var print: Int? + + /// Maximum number of characters left to be copied to the clipboard. + /// + /// If `nil`, there is no limit. + public var copy: Int? + + public init(print: Int?, copy: Int?) { + self.print = print + self.copy = copy + } +} diff --git a/Sources/LCP/LCPPassphraseRepository.swift b/Sources/LCP/LCPPassphraseRepository.swift new file mode 100644 index 000000000..f2fbd819f --- /dev/null +++ b/Sources/LCP/LCPPassphraseRepository.swift @@ -0,0 +1,39 @@ +// +// Copyright 2024 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation + +/// Represents an LCP passphrase hash. +public typealias LCPPassphraseHash = String + +/// The passphrase repository stores passphrase hashes associated to a license document, user ID and +/// provider. +public protocol LCPPassphraseRepository { + /// Returns the passphrase hash associated with the given `licenseID`. + func passphrase(for licenseID: LicenseDocument.ID) async throws -> LCPPassphraseHash? + + /// Returns a list of passphrase hashes that may match the given `userID`, and `provider`. + func passphrasesMatching( + userID: User.ID?, + provider: LicenseDocument.Provider + ) async throws -> [LCPPassphraseHash] + + /// Adds a new passphrase hash to the repository. + /// + /// If a passphrase is already associated with the given `licenseID`, it will be updated. + func addPassphrase( + _ hash: LCPPassphraseHash, + for licenseID: LicenseDocument.ID, + userID: User.ID?, + provider: LicenseDocument.Provider + ) async throws +} + +public extension LCPPassphraseRepository { + func addPassphrase(_ hash: LCPPassphraseHash, for license: LicenseDocument) async throws { + try await addPassphrase(hash, for: license.id, userID: license.user.id, provider: license.provider) + } +} diff --git a/Sources/LCP/LCPProgress.swift b/Sources/LCP/LCPProgress.swift new file mode 100644 index 000000000..79c567e21 --- /dev/null +++ b/Sources/LCP/LCPProgress.swift @@ -0,0 +1,15 @@ +// +// Copyright 2024 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation + +/// Percent-based progress of the acquisition. +public enum LCPProgress { + /// Undetermined progress, a spinner should be shown to the user. + case indefinite + /// A finite progress from 0.0 to 1.0, a progress bar should be shown to the user. + case percent(Float) +} diff --git a/Sources/LCP/LCPRenewDelegate.swift b/Sources/LCP/LCPRenewDelegate.swift index 3358296b1..66272b000 100644 --- a/Sources/LCP/LCPRenewDelegate.swift +++ b/Sources/LCP/LCPRenewDelegate.swift @@ -15,13 +15,13 @@ public protocol LCPRenewDelegate { /// /// You can prompt the user for the number of days to renew, for example. /// The returned date should not exceed `maximumDate`. - func preferredEndDate(maximum: Date?, completion: @escaping (CancellableResult) -> Void) + func preferredEndDate(maximum: Date?) async throws -> Date? /// Called when the renew interaction uses an HTML web page. /// /// You should present the URL in a `SFSafariViewController` and call the `completion` callback when the browser /// is dismissed by the user. - func presentWebPage(url: HTTPURL, completion: @escaping (CancellableResult) -> Void) + func presentWebPage(url: HTTPURL) async throws } /// Default `LCPRenewDelegate` implementation using standard views. @@ -37,33 +37,36 @@ public class LCPDefaultRenewDelegate: NSObject, LCPRenewDelegate { self.modalPresentationStyle = modalPresentationStyle } - public func preferredEndDate(maximum: Date?, completion: @escaping (CancellableResult) -> Void) { - completion(.success(nil)) + public func preferredEndDate(maximum: Date?) async throws -> Date? { + nil } - public func presentWebPage(url: HTTPURL, completion: @escaping (CancellableResult) -> Void) { - let safariVC = SFSafariViewController(url: url.url) - safariVC.modalPresentationStyle = modalPresentationStyle - safariVC.presentationController?.delegate = self - safariVC.delegate = self + @MainActor + public func presentWebPage(url: HTTPURL) async throws { + await withCheckedContinuation { continuation in + webPageContinuation = continuation - webPageCallback = completion - presentingViewController.present(safariVC, animated: true) + let safariVC = SFSafariViewController(url: url.url) + safariVC.modalPresentationStyle = modalPresentationStyle + safariVC.presentationController?.delegate = self + safariVC.delegate = self + presentingViewController.present(safariVC, animated: true) + } } - private var webPageCallback: ((CancellableResult) -> Void)? = nil + private var webPageContinuation: CheckedContinuation? = nil } extension LCPDefaultRenewDelegate: UIAdaptivePresentationControllerDelegate { public func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { - webPageCallback?(.success(())) - webPageCallback = nil + webPageContinuation?.resume(returning: ()) + webPageContinuation = nil } } extension LCPDefaultRenewDelegate: SFSafariViewControllerDelegate { public func safariViewControllerDidFinish(_ controller: SFSafariViewController) { - webPageCallback?(.success(())) - webPageCallback = nil + webPageContinuation?.resume(returning: ()) + webPageContinuation = nil } } diff --git a/Sources/LCP/LCPService.swift b/Sources/LCP/LCPService.swift index e765d77df..b00554616 100644 --- a/Sources/LCP/LCPService.swift +++ b/Sources/LCP/LCPService.swift @@ -19,13 +19,22 @@ import UIKit /// when presenting a dialog, for example. public final class LCPService: Loggable { private let licenses: LicensesService - private let passphrases: PassphrasesRepository + + @available(*, unavailable, message: "Provide a `licenseRepository` and `passphraseRepository`, following the migration guide") + public init( + client: LCPClient, + httpClient: HTTPClient = DefaultHTTPClient() + ) { + fatalError() + } /// - Parameter deviceName: Device name used when registering a license to an LSD server. /// If not provided, the device name will be the default `UIDevice.current.name`. public init( client: LCPClient, - httpClient: HTTPClient = DefaultHTTPClient(), + licenseRepository: LCPLicenseRepository, + passphraseRepository: LCPPassphraseRepository, + httpClient: HTTPClient, deviceName: String? = nil ) { // Determine whether the embedded liblcp.a is in production mode, by attempting to open a production license. @@ -40,35 +49,39 @@ public final class LCPService: Loggable { return client.findOneValidPassphrase(jsonLicense: prodLicense, hashedPassphrases: [passphrase]) == passphrase }() - let db = Database.shared - passphrases = db.transactions licenses = LicensesService( isProduction: isProduction, client: client, - licenses: db.licenses, + licenses: licenseRepository, crl: CRLService(httpClient: httpClient), device: DeviceService( deviceName: deviceName ?? UIDevice.current.name, - repository: db.licenses, + repository: licenseRepository, httpClient: httpClient ), httpClient: httpClient, - passphrases: PassphrasesService(client: client, repository: passphrases) + passphrases: PassphrasesService( + client: client, + repository: passphraseRepository + ) ) } /// Returns whether the given `file` is protected by LCP. - public func isLCPProtected(_ file: FileURL) -> Bool { - warnIfMainThread() - return makeLicenseContainerSync(for: file)?.containsLicense() == true + public func isLCPProtected(_ file: FileURL) async -> Bool { + await (try? makeLicenseContainer(for: file)?.containsLicense()) == true } /// Acquires a protected publication from a standalone LCPL file. /// /// You can cancel the on-going download with `acquisition.cancel()`. - @discardableResult - public func acquirePublication(from lcpl: FileURL, onProgress: @escaping (LCPAcquisition.Progress) -> Void = { _ in }, completion: @escaping (CancellableResult) -> Void) -> LCPAcquisition { - licenses.acquirePublication(from: lcpl, onProgress: onProgress, completion: completion) + public func acquirePublication( + from lcpl: FileURL, + onProgress: @escaping (LCPProgress) -> Void = { _ in } + ) async -> Result { + await wrap { + try await licenses.acquirePublication(from: lcpl, onProgress: onProgress) + } } /// Opens the LCP license of a protected publication, to access its DRM metadata and decipher @@ -87,12 +100,11 @@ public final class LCPService: Loggable { from publication: FileURL, authentication: LCPAuthenticating = LCPDialogAuthentication(), allowUserInteraction: Bool = true, - sender: Any? = nil, - completion: @escaping (CancellableResult) -> Void - ) { - licenses.retrieve(from: publication, authentication: authentication, allowUserInteraction: allowUserInteraction, sender: sender) - .map { $0 as LCPLicense? } - .resolve(completion) + sender: Any? = nil + ) async -> Result { + await wrap { + try await licenses.retrieve(from: publication, authentication: authentication, allowUserInteraction: allowUserInteraction, sender: sender) + } } /// Creates a `ContentProtection` instance which can be used with a `Streamer` to unlock @@ -104,4 +116,29 @@ public final class LCPService: Loggable { public func contentProtection(with authentication: LCPAuthenticating = LCPDialogAuthentication()) -> ContentProtection { LCPContentProtection(service: self, authentication: authentication) } + + private func wrap(_ block: () async throws -> Success) async -> Result { + do { + return try await .success(block()) + } catch { + return .failure(.wrap(error)) + } + } + + @available(*, unavailable, message: "Use the async variant.") + @discardableResult + public func acquirePublication(from lcpl: FileURL, onProgress: @escaping (LCPAcquisition.Progress) -> Void = { _ in }, completion: @escaping (CancellableResult) -> Void) -> LCPAcquisition { + fatalError() + } + + @available(*, unavailable, message: "Use the async variant.") + public func retrieveLicense( + from publication: FileURL, + authentication: LCPAuthenticating = LCPDialogAuthentication(), + allowUserInteraction: Bool = true, + sender: Any? = nil, + completion: @escaping (CancellableResult) -> Void + ) { + fatalError() + } } diff --git a/Sources/LCP/License/Container/LCPLLicenseContainer.swift b/Sources/LCP/License/Container/LCPLLicenseContainer.swift index f49f9fb55..c3b277191 100644 --- a/Sources/LCP/License/Container/LCPLLicenseContainer.swift +++ b/Sources/LCP/License/Container/LCPLLicenseContainer.swift @@ -15,20 +15,20 @@ final class LCPLLicenseContainer: LicenseContainer { self.lcpl = lcpl } - func containsLicense() -> Bool { + func containsLicense() async throws -> Bool { true } - func read() throws -> Data { + func read() async throws -> Data { guard let data = try? Data(contentsOf: lcpl.url) else { throw LCPError.licenseContainer(.readFailed(path: ".")) } return data } - func write(_ license: LicenseDocument) throws { + func write(_ license: LicenseDocument) async throws { do { - try license.data.write(to: lcpl.url, options: .atomic) + try license.jsonData.write(to: lcpl.url, options: .atomic) } catch { throw LCPError.licenseContainer(.writeFailed(path: ".")) } diff --git a/Sources/LCP/License/Container/LicenseContainer.swift b/Sources/LCP/License/Container/LicenseContainer.swift index 8b6f6fa7c..7374884f0 100644 --- a/Sources/LCP/License/Container/LicenseContainer.swift +++ b/Sources/LCP/License/Container/LicenseContainer.swift @@ -12,19 +12,13 @@ protocol LicenseContainer { /// Returns whether this container currently contains a License Document. /// /// For example, when fulfilling an EPUB publication, it initially doesn't contain the license. - func containsLicense() -> Bool + func containsLicense() async throws -> Bool - func read() throws -> Data - func write(_ license: LicenseDocument) throws + func read() async throws -> Data + func write(_ license: LicenseDocument) async throws } -func makeLicenseContainer(for file: FileURL, mimetypes: [String] = []) -> Deferred { - deferred(on: .global(qos: .background)) { success, _, _ in - success(makeLicenseContainerSync(for: file, mimetypes: mimetypes)) - } -} - -func makeLicenseContainerSync(for file: FileURL, mimetypes: [String] = []) -> LicenseContainer? { +func makeLicenseContainer(for file: FileURL, mimetypes: [String] = []) -> LicenseContainer? { guard let mediaType = MediaType.of(file, mediaTypes: mimetypes, fileExtensions: []) else { return nil } diff --git a/Sources/LCP/License/Container/ZIPLicenseContainer.swift b/Sources/LCP/License/Container/ZIPLicenseContainer.swift index 0ae22405d..8977f4ea8 100644 --- a/Sources/LCP/License/Container/ZIPLicenseContainer.swift +++ b/Sources/LCP/License/Container/ZIPLicenseContainer.swift @@ -19,14 +19,14 @@ class ZIPLicenseContainer: LicenseContainer { self.pathInZIP = pathInZIP } - func containsLicense() -> Bool { + func containsLicense() async throws -> Bool { guard let archive = Archive(url: zip.url, accessMode: .read) else { return false } return archive[pathInZIP] != nil } - func read() throws -> Data { + func read() async throws -> Data { guard let archive = Archive(url: zip.url, accessMode: .read) else { throw LCPError.licenseContainer(.openFailed) } @@ -46,7 +46,7 @@ class ZIPLicenseContainer: LicenseContainer { return data } - func write(_ license: LicenseDocument) throws { + func write(_ license: LicenseDocument) async throws { guard let archive = Archive(url: zip.url, accessMode: .update) else { throw LCPError.licenseContainer(.openFailed) } @@ -58,7 +58,7 @@ class ZIPLicenseContainer: LicenseContainer { } // Stores the License into the ZIP file - let data = license.data + let data = license.jsonData try archive.addEntry(with: pathInZIP, type: .file, uncompressedSize: UInt32(data.count), provider: { position, size -> Data in data[position ..< size] }) diff --git a/Sources/LCP/License/License.swift b/Sources/LCP/License/License.swift index a82286629..8e9ed128b 100644 --- a/Sources/LCP/License/License.swift +++ b/Sources/LCP/License/License.swift @@ -15,11 +15,11 @@ final class License: Loggable { // Dependencies private let client: LCPClient private let validation: LicenseValidation - private let licenses: LicensesRepository + private let licenses: LCPLicenseRepository private let device: DeviceService private let httpClient: HTTPClient - init(documents: ValidatedDocuments, client: LCPClient, validation: LicenseValidation, licenses: LicensesRepository, device: DeviceService, httpClient: HTTPClient) { + init(documents: ValidatedDocuments, client: LCPClient, validation: LicenseValidation, licenses: LCPLicenseRepository, device: DeviceService, httpClient: HTTPClient) { self.documents = documents self.client = client self.validation = validation @@ -28,7 +28,7 @@ final class License: Loggable { self.httpClient = httpClient validation.observe { [weak self] result in - if case let .success(documents) = result { + if case let .success(documents) = result, let documents = documents { self?.documents = documents } } @@ -54,92 +54,82 @@ extension License: LCPLicense { return client.decrypt(data: data, using: context) } - var charactersToCopyLeft: Int? { + func charactersToCopyLeft() async -> Int? { do { - if let charactersLeft = try licenses.copiesLeft(for: license.id) { - return charactersLeft - } + return try await licenses.userRights(for: license.id).copy } catch { log(.error, error) + return nil } - return nil - } - - var canCopy: Bool { - (charactersToCopyLeft ?? 1) > 0 } - func canCopy(text: String) -> Bool { - guard let charactersLeft = charactersToCopyLeft else { + func canCopy(text: String) async -> Bool { + guard let charactersLeft = await charactersToCopyLeft() else { return true } return text.count <= charactersLeft } - func copy(text: String) -> Bool { - guard var charactersLeft = charactersToCopyLeft else { - return true - } - guard text.count <= charactersLeft else { - return false - } - + func copy(text: String) async -> Bool { do { - charactersLeft = max(0, charactersLeft - text.count) - try licenses.setCopiesLeft(charactersLeft, for: license.id) - } catch { - log(.error, error) - } + var allowed = true + try await licenses.updateUserRights(for: license.id) { rights in + guard let copyLeft = rights.copy else { + return + } + guard text.count <= copyLeft else { + allowed = false + return + } - return true - } + rights.copy = max(0, copyLeft - text.count) + } + + return allowed - // Deprecated - func copy(_ text: String, consumes: Bool) -> String? { - if consumes { - return copy(text: text) ? text : nil - } else { - return canCopy(text: text) ? text : nil + } catch { + log(.error, error) + return false } } - var pagesToPrintLeft: Int? { + func pagesToPrintLeft() async -> Int? { do { - if let pagesLeft = try licenses.printsLeft(for: license.id) { - return pagesLeft - } + return try await licenses.userRights(for: license.id).print } catch { log(.error, error) + return nil } - return nil } - func canPrint(pageCount: Int) -> Bool { - guard let pagesLeft = pagesToPrintLeft else { + func canPrint(pageCount: Int) async -> Bool { + guard let pageLeft = await pagesToPrintLeft() else { return true } - return pageCount <= pagesLeft + return pageCount <= pageLeft } - var canPrint: Bool { - (pagesToPrintLeft ?? 1) > 0 - } + func print(pageCount: Int) async -> Bool { + do { + var allowed = true + try await licenses.updateUserRights(for: license.id) { rights in + guard let printLeft = rights.print else { + return + } + guard pageCount <= printLeft else { + allowed = false + return + } - func print(pageCount: Int) -> Bool { - guard var pagesLeft = pagesToPrintLeft else { - return true - } - guard pagesLeft >= pageCount else { - return false - } + rights.copy = max(0, printLeft - pageCount) + } + + return allowed - do { - pagesLeft = max(0, pagesLeft - pageCount) - try licenses.setPrintsLeft(pagesLeft, for: license.id) } catch { log(.error, error) + return false } - return true } var canRenewLoan: Bool { @@ -150,18 +140,16 @@ extension License: LCPLicense { status?.potentialRights?.end } - func renewLoan(with delegate: LCPRenewDelegate, prefersWebPage: Bool, completion: @escaping (CancellableResult) -> Void) { - func renew() -> Deferred { - deferredCatching { - guard let link = findRenewLink() else { - throw LCPError.licenseInteractionNotAvailable - } + func renewLoan(with delegate: any LCPRenewDelegate, prefersWebPage: Bool) async -> Result { + func renew() async throws -> Data { + guard let link = findRenewLink() else { + throw LCPError.licenseInteractionNotAvailable + } - if link.mediaType.isHTML { - return try renewWithWebPage(link) - } else { - return renewProgrammatically(link) - } + if link.mediaType.isHTML { + return try await renewWithWebPage(link) + } else { + return try await renewProgrammatically(link) } } @@ -189,7 +177,7 @@ extension License: LCPLicense { } // Renew the loan by presenting a web page to the user. - func renewWithWebPage(_ link: Link) throws -> Deferred { + func renewWithWebPage(_ link: Link) async throws -> Data { guard let statusURL = try? license.url(for: .status, preferredType: .lcpStatusDocument), let url = link.url() @@ -197,23 +185,23 @@ extension License: LCPLicense { throw LCPError.licenseInteractionNotAvailable } - return delegate.presentWebPage(url: url) - .flatMap { - // We fetch the Status Document again after the HTML interaction is done, in case it changed the - // License. - self.httpClient.fetch(HTTPRequest(url: statusURL, headers: ["Accept": MediaType.lcpStatusDocument.string])) - .map { $0.body ?? Data() } - .eraseToAnyError() - } + try await delegate.presentWebPage(url: url) + + // We fetch the Status Document again after the HTML interaction is + // done, in case it changed the License. + return try await httpClient + .fetch(HTTPRequest(url: statusURL, headers: ["Accept": MediaType.lcpStatusDocument.string])) + .map { $0.body ?? Data() } + .get() } // Programmatically renew the loan with a PUT request. - func renewProgrammatically(_ link: Link) -> Deferred { + func renewProgrammatically(_ link: Link) async throws -> Data { // Asks the delegate for a renew date if there's an `end` parameter. - func preferredEndDate() -> Deferred { + func preferredEndDate() async throws -> Date? { (link.templateParameters.contains("end")) - ? delegate.preferredEndDate(maximum: maxRenewDate) - : Deferred.success(nil) + ? try await delegate.preferredEndDate(maximum: maxRenewDate) + : nil } func makeRenewURL(from endDate: Date?) throws -> HTTPURL { @@ -228,41 +216,36 @@ extension License: LCPLicense { return url } - return preferredEndDate() - .tryMap(makeRenewURL(from:)) - .flatMap { - self.httpClient.fetch(HTTPRequest(url: $0, method: .put)) - .map { $0.body ?? Data() } - .mapError { error -> RenewError in - switch error.kind { - case .badRequest: - return .renewFailed - case .forbidden: - return .invalidRenewalPeriod(maxRenewDate: self.maxRenewDate) - default: - return .unexpectedServerError - } - } - .eraseToAnyError() + let url = try await makeRenewURL(from: preferredEndDate()) + + return try await httpClient.fetch(HTTPRequest(url: url, method: .put)) + .map { $0.body ?? Data() } + .mapError { error -> RenewError in + switch error.kind { + case .badRequest: + return .renewFailed + case .forbidden: + return .invalidRenewalPeriod(maxRenewDate: self.maxRenewDate) + default: + return .unexpectedServerError + } } + .get() } - renew() - .flatMap(validateStatusDocument) - .mapError(LCPError.wrap) - .resolve { result in - // Trick to make sure the delegate is not deallocated before the end of the renew process. - _ = type(of: delegate) - - completion(result) - } + do { + try await validateStatusDocument(data: renew()) + return .success(()) + } catch { + return .failure(.wrap(error)) + } } var canReturnPublication: Bool { status?.link(for: .return) != nil } - func returnPublication(completion: @escaping (LCPError?) -> Void) { + func returnPublication() async -> Result { guard let status = documents.status, let url = try? status.url( @@ -271,40 +254,34 @@ extension License: LCPLicense { parameters: device.asQueryParameters ) else { - completion(LCPError.licenseInteractionNotAvailable) - return + return .failure(.licenseInteractionNotAvailable) } - httpClient.fetch(HTTPRequest(url: url, method: .put)) - .mapError { error -> ReturnError in - switch error.kind { - case .badRequest: - return .returnFailed - case .forbidden: - return .alreadyReturnedOrExpired - default: - return .unexpectedServerError + do { + let data = try await httpClient.fetch(HTTPRequest(url: url, method: .put)) + .mapError { error -> ReturnError in + switch error.kind { + case .badRequest: + return .returnFailed + case .forbidden: + return .alreadyReturnedOrExpired + default: + return .unexpectedServerError + } } - } - .map { $0.body ?? Data() } - .flatMap(validateStatusDocument) - .mapError(LCPError.wrap) - .resolveWithError(completion) - } + .map { $0.body ?? Data() } + .get() - /// Shortcut to be used in LSD interactions (eg. renew), to validate the returned Status Document. - fileprivate func validateStatusDocument(data: Data) -> Deferred { - validation.validate(.status(data)) - .map { _ in () } // We don't want to forward the Validated Documents - } -} + try await validateStatusDocument(data: data) + return .success(()) -public extension LCPRenewDelegate { - func preferredEndDate(maximum: Date?) -> Deferred { - Deferred { preferredEndDate(maximum: maximum, completion: $0) } + } catch { + return .failure(.wrap(error)) + } } - func presentWebPage(url: HTTPURL) -> Deferred { - Deferred { presentWebPage(url: url, completion: $0) } + /// Shortcut to be used in LSD interactions (eg. renew), to validate the returned Status Document. + fileprivate func validateStatusDocument(data: Data) async throws { + _ = try await validation.validate(.status(data)) } } diff --git a/Sources/LCP/License/LicenseValidation.swift b/Sources/LCP/License/LicenseValidation.swift index fa46913b5..33f645708 100644 --- a/Sources/LCP/License/LicenseValidation.swift +++ b/Sources/LCP/License/LicenseValidation.swift @@ -51,7 +51,7 @@ struct ValidatedDocuments { /// /// Use `validate` to start the validation of a Document. /// Use `observe` to be notified when any validation is done or if an error occurs. -final class LicenseValidation: Loggable { +final actor LicenseValidation: Loggable { // Dependencies for the State's handlers fileprivate let isProduction: Bool fileprivate let client: LCPClient @@ -66,13 +66,12 @@ final class LicenseValidation: Loggable { // List of observers notified when the Documents are validated, or if an error occurred. fileprivate var observers: [(callback: Observer, policy: ObserverPolicy)] = [] - fileprivate let onLicenseValidated: (LicenseDocument) throws -> Void + fileprivate let onLicenseValidated: (LicenseDocument) async throws -> Void // Current state in the validation steps. private(set) var state: State = .start { didSet { log(.debug, "* \(state)") - handle(state) } } @@ -86,7 +85,7 @@ final class LicenseValidation: Loggable { device: DeviceService, httpClient: HTTPClient, passphrases: PassphrasesService, - onLicenseValidated: @escaping (LicenseDocument) throws -> Void + onLicenseValidated: @escaping (LicenseDocument) async throws -> Void ) { self.authentication = authentication self.allowUserInteraction = allowUserInteraction @@ -108,7 +107,7 @@ final class LicenseValidation: Loggable { /// Validates the given License or Status Document. /// If a validation is already running, `LCPError.licenseIsBusy` will be reported. - func validate(_ document: Document) -> Deferred { + func validate(_ document: Document) async throws -> ValidatedDocuments? { let event: Event switch document { case let .license(data): @@ -117,7 +116,8 @@ final class LicenseValidation: Loggable { event = .retrievedStatusData(data) } - return observe(raising: event) + async let _ = raise(event) + return try await observe() } } @@ -268,19 +268,16 @@ extension LicenseValidation { } /// Should be called by the state handlers once they're done, to go to the next State. - private func raise(_ event: Event) throws { + private func raise(_ event: Event) async throws { log(.debug, "-> on \(event)") - guard Thread.isMainThread else { - throw LCPError.runtime("\(type(of: self)): To be safe, events must only be raised from the main thread") - } - try state.transition(event) + await handle(state) } } /// State's handlers extension LicenseValidation { - private func validateLicense(data: Data) throws { + private func validateLicense(data: Data) async throws { let license = try LicenseDocument(data: data) // In test mode, only the basic profile is authorized. @@ -289,34 +286,44 @@ extension LicenseValidation { throw LCPError.licenseProfileNotSupported } - try onLicenseValidated(license) - try raise(.validatedLicense(license)) + try await onLicenseValidated(license) + try await raise(.validatedLicense(license)) } - private func fetchStatus(of license: LicenseDocument) throws { + private func fetchStatus(of license: LicenseDocument) async throws { let url = try license.url(for: .status, preferredType: .lcpStatusDocument) - // Short timeout to avoid blocking the License, since the LSD is optional. - httpClient.fetch(HTTPRequest(url: url, headers: ["Accept": MediaType.lcpStatusDocument.string], timeoutInterval: 5)) - .map { .retrievedStatusData($0.body ?? Data()) } - .eraseToAnyError() - .resolve(raise) + + let data = try await httpClient + .fetch(HTTPRequest( + url: url, + headers: ["Accept": MediaType.lcpStatusDocument.string], + // Short timeout to avoid blocking the License, since the LSD is optional. + timeoutInterval: 5 + )) + .map { $0.body ?? Data() } + .get() + + try await raise(.retrievedStatusData(data)) } - private func validateStatus(data: Data) throws { + private func validateStatus(data: Data) async throws { let status = try StatusDocument(data: data) - try raise(.validatedStatus(status)) + try await raise(.validatedStatus(status)) } - private func fetchLicense(from status: StatusDocument) throws { + private func fetchLicense(from status: StatusDocument) async throws { let url = try status.url(for: .license, preferredType: .lcpLicenseDocument) - // Short timeout to avoid blocking the License, since it can be updated next time. - httpClient.fetch(HTTPRequest(url: url, timeoutInterval: 5)) - .map { .retrievedLicenseData($0.body ?? Data()) } - .eraseToAnyError() - .resolve(raise) + + let data = try await httpClient + // Short timeout to avoid blocking the License, since it can be updated next time. + .fetch(HTTPRequest(url: url, timeoutInterval: 5)) + .map { $0.body ?? Data() } + .get() + + try await raise(.retrievedStatusData(data)) } - private func checkLicenseStatus(of license: LicenseDocument, status: StatusDocument?, statusDocumentTakesPrecedence: Bool) throws { + private func checkLicenseStatus(of license: LicenseDocument, status: StatusDocument?, statusDocumentTakesPrecedence: Bool) async throws { var error: StatusError? let now = Date() @@ -348,16 +355,23 @@ extension LicenseValidation { } } - try raise(.checkedLicenseStatus(error)) + try await raise(.checkedLicenseStatus(error)) } - private func requestPassphrase(for license: LicenseDocument) { - passphrases.request(for: license, authentication: authentication, allowUserInteraction: allowUserInteraction, sender: sender) - .map { .retrievedPassphrase($0) } - .resolve(raise) + private func requestPassphrase(for license: LicenseDocument) async throws { + if let passphrase = try await passphrases.request( + for: license, + authentication: authentication, + allowUserInteraction: allowUserInteraction, + sender: sender + ) { + try await raise(.retrievedPassphrase(passphrase)) + } else { + try await raise(.cancelled) + } } - private func validateIntegrity(of license: LicenseDocument, with passphrase: String) throws { + private func validateIntegrity(of license: LicenseDocument, with passphrase: String) async throws { // 1. Checks the profile let profile = license.encryption.profile guard supportedProfiles.contains(profile) else { @@ -365,74 +379,54 @@ extension LicenseValidation { } // 2. Creates the DRM context - crl.retrieve() - .tryMap { crl -> Event in - let context = try self.client.createContext(jsonLicense: license.json, hashedPassphrase: passphrase, pemCrl: crl) - return .validatedIntegrity(context) - } - .resolve(raise) + let pemCrl = try await crl.retrieve() + let context = try client.createContext(jsonLicense: license.jsonString, hashedPassphrase: passphrase, pemCrl: pemCrl) + + try await raise(.validatedIntegrity(context)) } - private func registerDevice(for license: LicenseDocument, at link: Link) { - device.registerLicense(license, at: link) - .map { data in .registeredDevice(data) } - .resolve(raise) + private func registerDevice(for license: LicenseDocument, at link: Link) async throws { + let data = try await device.registerLicense(license, at: link) + try await raise(.registeredDevice(data)) } - private func handle(_ state: State) { + private func handle(_ state: State) async { // Boring glue to call the handlers when a state occurs do { switch state { case .start: // We are back to start? It means the validation was cancelled by the user. - notifyObservers(.cancelled) + notifyObservers(.success(nil)) case let .validateLicense(data, _): - try validateLicense(data: data) + try await validateLicense(data: data) case let .fetchStatus(license): - try fetchStatus(of: license) + try await fetchStatus(of: license) case let .validateStatus(_, data): - try validateStatus(data: data) + try await validateStatus(data: data) case let .fetchLicense(_, status): - try fetchLicense(from: status) + try await fetchLicense(from: status) case let .checkLicenseStatus(license, status, statusDocumentTakesPrecedence): - try checkLicenseStatus(of: license, status: status, statusDocumentTakesPrecedence: statusDocumentTakesPrecedence) + try await checkLicenseStatus(of: license, status: status, statusDocumentTakesPrecedence: statusDocumentTakesPrecedence) case let .requestPassphrase(license, _): - requestPassphrase(for: license) + try await requestPassphrase(for: license) case let .validateIntegrity(license, _, passphrase): - try validateIntegrity(of: license, with: passphrase) + try await validateIntegrity(of: license, with: passphrase) case let .registerDevice(documents, link): - registerDevice(for: documents.license, at: link) + try await registerDevice(for: documents.license, at: link) case let .valid(documents): notifyObservers(.success(documents)) case let .failure(error): notifyObservers(.failure(error)) } } catch { - try? raise(.failed(error)) - } - } - - /// Syntactic sugar to raise either the given event, or an error wrapped as an Event.failed. - /// Can be used to resolve a Deferred. - private func raise(_ result: CancellableResult) { - switch result { - case let .success(event): - do { - try raise(event) - } catch { - try? raise(.failed(error)) - } - case let .failure(error): - try? raise(.failed(error)) - case .cancelled: - try? raise(Event.cancelled) + try? await raise(.failed(error)) } } } /// Validation observers extension LicenseValidation { - typealias Observer = (CancellableResult) -> Void + typealias Observer = (Result) -> Void enum ObserverPolicy { // The observer is automatically removed when called. @@ -441,33 +435,39 @@ extension LicenseValidation { case always } - /// Observes the validation occured after raising the given event. - private func observe(raising event: Event) -> Deferred { - deferredCatching(on: .main) { completion in - try self.raise(event) - self.observe(.once, completion) + nonisolated func observe(_ policy: ObserverPolicy = .always, _ observer: @escaping Observer) { + Task { + // If the state is already valid or a failure, we notify it to the observer right away. + var notified = true + switch await state { + case let .valid(documents): + observer(.success(documents)) + case let .failure(error): + observer(.failure(error)) + default: + notified = false + } + + guard !notified || policy == .always else { + return + } + await addObserver(observer, policy: policy) } } - func observe(_ policy: ObserverPolicy = .always, _ observer: @escaping Observer) { - // If the state is already valid or a failure, we notify it to the observer right away. - var notified = true - switch state { - case let .valid(documents): - observer(.success(documents)) - case let .failure(error): - observer(.failure(error)) - default: - notified = false - } + private func addObserver(_ observer: @escaping Observer, policy: ObserverPolicy) { + observers.append((observer, policy)) + } - guard !notified || policy == .always else { - return + func observe() async throws -> ValidatedDocuments? { + try await withCheckedThrowingContinuation { continuation in + observe(.once) { result in + continuation.resume(with: result) + } } - observers.append((observer, policy)) } - private func notifyObservers(_ result: CancellableResult) { + private func notifyObservers(_ result: Result) { for observer in observers { observer.callback(result) } diff --git a/Sources/LCP/License/Model/Components/LCP/User.swift b/Sources/LCP/License/Model/Components/LCP/User.swift index aac6a1eeb..92044933a 100644 --- a/Sources/LCP/License/Model/Components/LCP/User.swift +++ b/Sources/LCP/License/Model/Components/LCP/User.swift @@ -7,8 +7,10 @@ import Foundation public struct User { + public typealias ID = String + /// Unique identifier for the User at a specific Provider. - public let id: String? + public let id: ID? /// The User’s e-mail address. public let email: String? /// The User’s name. diff --git a/Sources/LCP/License/Model/LicenseDocument.swift b/Sources/LCP/License/Model/LicenseDocument.swift index a1d9a48f0..84c7cf46f 100644 --- a/Sources/LCP/License/Model/LicenseDocument.swift +++ b/Sources/LCP/License/Model/LicenseDocument.swift @@ -10,6 +10,9 @@ import ReadiumShared /// Document that contains references to the various keys, links to related external resources, rights and restrictions that are applied to the Protected Publication, and user information. /// https://github.com/readium/lcp-specs/blob/master/schema/license.schema.json public struct LicenseDocument { + public typealias ID = String + public typealias Provider = String + // The possible rel of Links. public enum Rel: String { // Location where a Reading System can redirect a User looking for additional information about the User Passphrase. @@ -25,9 +28,9 @@ public struct LicenseDocument { } /// Unique identifier for the Provider (URI). - public let provider: String + public let provider: Provider /// Unique identifier for the License. - public let id: String + public let id: LicenseDocument.ID /// Date when the license was first issued. public let issued: Date /// Date when the license was last updated. @@ -44,12 +47,15 @@ public struct LicenseDocument { public let signature: Signature /// JSON representation used to parse the License Document. - let json: String - let data: Data + public let jsonData: Data + + /// JSON string representation used to parse the License Document. + public let jsonString: String public init(data: Data) throws { - guard let jsonString = String(data: data, encoding: .utf8), - let deserializedJSON = try? JSONSerialization.jsonObject(with: data) + guard + let jsonString = String(data: data, encoding: .utf8), + let deserializedJSON = try? JSONSerialization.jsonObject(with: data) else { throw ParsingError.malformedJSON } @@ -74,8 +80,8 @@ public struct LicenseDocument { user = try User(json: json["user"] as? [String: Any]) rights = try Rights(json: json["rights"] as? [String: Any]) self.signature = try Signature(json: signature) - self.json = jsonString - self.data = data + jsonData = data + self.jsonString = jsonString /// Checks that `links` contains at least one link with `publication` relation. guard link(for: .publication) != nil else { diff --git a/Sources/LCP/Persistence/Database.swift b/Sources/LCP/Persistence/Database.swift index 57f395e4c..bf3bbfcdd 100644 --- a/Sources/LCP/Persistence/Database.swift +++ b/Sources/LCP/Persistence/Database.swift @@ -11,22 +11,17 @@ final class Database { /// Shared instance. static let shared = Database() - /// Connection. let connection: Connection - /// Tables. - let licenses: Licenses! - let transactions: Transactions! private init() { do { - var url = try FileManager.default.url(for: .libraryDirectory, - in: .userDomainMask, - appropriateFor: nil, create: true) - + var url = try FileManager.default.url( + for: .libraryDirectory, + in: .userDomainMask, + appropriateFor: nil, create: true + ) url.appendPathComponent("lcpdatabase.sqlite") connection = try Connection(url.absoluteString) - licenses = Licenses(connection) - transactions = Transactions(connection) } catch { fatalError("Error initializing db.") } diff --git a/Sources/LCP/Persistence/Licenses.swift b/Sources/LCP/Persistence/Licenses.swift deleted file mode 100644 index 3e167919d..000000000 --- a/Sources/LCP/Persistence/Licenses.swift +++ /dev/null @@ -1,113 +0,0 @@ -// -// Copyright 2024 Readium Foundation. All rights reserved. -// Use of this source code is governed by the BSD-style license -// available in the top-level LICENSE file of the project. -// - -import Foundation -import SQLite - -/// Database's Licenses table, in charge of keeping tracks of the -/// licenses attributed to each books and the associated rights. -class Licenses { - /// Table. - let licenses = Table("Licenses") - /// Fields. - let id = Expression("id") - let printsLeft = Expression("printsLeft") - let copiesLeft = Expression("copiesLeft") - let registered = Expression("registered") - - init(_ connection: Connection) { - _ = try? connection.run(licenses.create(temporary: false, ifNotExists: true) { t in - t.column(id, unique: true) - t.column(printsLeft) - t.column(copiesLeft) - }) - - if connection.userVersion == 0 { - _ = try? connection.run(licenses.addColumn(registered, defaultValue: false)) - connection.userVersion = 1 - } - if connection.userVersion == 1 { - // This migration is empty because it got deprecated... - connection.userVersion = 2 - } - } - - private func exists(_ license: LicenseDocument) -> Bool { - let db = Database.shared.connection - let filterLicense = licenses.filter(id == license.id) - return ((try? db.count(filterLicense)) ?? 0) != 0 - } - - private func get(_ column: Expression, for licenseId: String) throws -> Int? { - let db = Database.shared.connection - let query = licenses.select(column).filter(id == licenseId) - for row in try db.prepare(query) { - return try row.get(column) - } - return nil - } - - private func set(_ column: Expression, to value: Int, for licenseId: String) throws { - let db = Database.shared.connection - let filterLicense = licenses.filter(id == licenseId) - try db.run(filterLicense.update(column <- value)) - } -} - -extension Licenses: LicensesRepository { - func addLicense(_ license: LicenseDocument) throws { - let db = Database.shared.connection - guard !exists(license) else { - return - } - - let query = licenses.insert( - id <- license.id, - printsLeft <- license.rights.print, - copiesLeft <- license.rights.copy - ) - try db.run(query) - } - - func copiesLeft(for licenseId: String) throws -> Int? { - try get(copiesLeft, for: licenseId) - } - - func setCopiesLeft(_ quantity: Int, for licenseId: String) throws { - try set(copiesLeft, to: quantity, for: licenseId) - } - - func printsLeft(for licenseId: String) throws -> Int? { - try get(printsLeft, for: licenseId) - } - - func setPrintsLeft(_ quantity: Int, for licenseId: String) throws { - try set(printsLeft, to: quantity, for: licenseId) - } -} - -extension Licenses: DeviceRepository { - func isDeviceRegistered(for license: LicenseDocument) throws -> Bool { - guard exists(license) else { - throw LCPError.runtime("The LCP License doesn't exist in the database") - } - - let db = Database.shared.connection - let query = licenses.filter(id == license.id && registered == true) - let count = try db.count(query) - return count != 0 - } - - func registerDevice(for license: LicenseDocument) throws { - guard exists(license) else { - throw LCPError.runtime("The LCP License doesn't exist in the database") - } - - let db = Database.shared.connection - let filterLicense = licenses.filter(id == license.id) - try db.run(filterLicense.update(registered <- true)) - } -} diff --git a/Sources/LCP/Persistence/SQLiteLCPLicenseRepository.swift b/Sources/LCP/Persistence/SQLiteLCPLicenseRepository.swift new file mode 100644 index 000000000..be5079832 --- /dev/null +++ b/Sources/LCP/Persistence/SQLiteLCPLicenseRepository.swift @@ -0,0 +1,119 @@ +// +// Copyright 2024 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +import SQLite + +public class SQLiteLCPLicenseRepository: LCPLicenseRepository { + let licenses = Table("Licenses") + let id = Expression("id") + let printsLeft = Expression("printsLeft") + let copiesLeft = Expression("copiesLeft") + let registered = Expression("registered") + + private let db: Connection + + public init() { + db = Database.shared.connection + + _ = try? db.run(licenses.create(temporary: false, ifNotExists: true) { t in + t.column(id, unique: true) + t.column(printsLeft) + t.column(copiesLeft) + }) + + if db.userVersion == 0 { + _ = try? db.run(licenses.addColumn(registered, defaultValue: false)) + db.userVersion = 1 + } + if db.userVersion == 1 { + // This migration is empty because it got deprecated... + db.userVersion = 2 + } + } + + public func addLicense(_ licenseDocument: LicenseDocument) async throws { + guard !exists(licenseDocument.id) else { + return + } + + let query = licenses.insert( + id <- licenseDocument.id, + printsLeft <- licenseDocument.rights.print, + copiesLeft <- licenseDocument.rights.copy + ) + try db.run(query) + } + + public func license(for id: LicenseDocument.ID) async throws -> LicenseDocument? { + // FIXME: Was not implemented for this repository + nil + } + + public func isDeviceRegistered(for id: LicenseDocument.ID) async throws -> Bool { + try checkExists(id) + let query = licenses.filter(self.id == id && registered == true) + let count = try db.count(query) + return count != 0 + } + + public func registerDevice(for id: LicenseDocument.ID) async throws { + try checkExists(id) + let filterLicense = licenses.filter(self.id == id) + try db.run(filterLicense.update(registered <- true)) + } + + public func userRights(for id: LicenseDocument.ID) async throws -> LCPConsumableUserRights { + try getRights(for: id) + } + + public func updateUserRights(for id: LicenseDocument.ID, with changes: (inout LCPConsumableUserRights) -> Void) async throws { + try db.transaction { + let rights = try getRights(for: id) + + var newRights = rights + changes(&newRights) + + if rights.copy != newRights.copy { + try set(copiesLeft, to: newRights.copy, for: id) + } + + if rights.print != newRights.print { + try set(printsLeft, to: newRights.print, for: id) + } + } + } + + private func checkExists(_ licenseID: LicenseDocument.ID) throws { + guard exists(licenseID) else { + throw LCPError.runtime("The LCP License doesn't exist in the database") + } + } + + private func exists(_ licenseID: LicenseDocument.ID) -> Bool { + ((try? db.count(licenses.filter(id == licenseID))) ?? 0) != 0 + } + + private func get(_ column: Expression, for licenseId: String) throws -> Int? { + let query = licenses.select(column).filter(id == licenseId) + for row in try db.prepare(query) { + return try row.get(column) + } + return nil + } + + private func set(_ column: Expression, to value: Int?, for licenseId: String) throws { + let filterLicense = licenses.filter(id == licenseId) + try db.run(filterLicense.update(column <- value)) + } + + private func getRights(for id: LicenseDocument.ID) throws -> LCPConsumableUserRights { + try LCPConsumableUserRights( + print: get(printsLeft, for: id), + copy: get(copiesLeft, for: id) + ) + } +} diff --git a/Sources/LCP/Persistence/SQLiteLCPPassphraseRepository.swift b/Sources/LCP/Persistence/SQLiteLCPPassphraseRepository.swift new file mode 100644 index 000000000..0bdd9fe59 --- /dev/null +++ b/Sources/LCP/Persistence/SQLiteLCPPassphraseRepository.swift @@ -0,0 +1,77 @@ +// +// Copyright 2024 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +import ReadiumShared +import SQLite + +public class SQLiteLCPPassphraseRepository: LCPPassphraseRepository, Loggable { + let transactions = Table("Transactions") + let licenseId = Expression("licenseId") + let provider = Expression("origin") + let userId = Expression("userId") + let passphrase = Expression("passphrase") // hashed. + + private let db: Connection + + public init() { + db = Database.shared.connection + + do { + try db.run(transactions.create(temporary: false, ifNotExists: true) { t in + t.column(licenseId) + t.column(provider) + t.column(userId) + t.column(passphrase) + }) + } catch { + log(.error, error) + } + } + + public func passphrase(for licenseID: LicenseDocument.ID) async throws -> LCPPassphraseHash? { + try logAndRethrow { + try db.prepare(transactions.select(passphrase) + .filter(self.licenseId == licenseID) + ) + .compactMap { try $0.get(passphrase) } + .first + } + } + + public func passphrasesMatching(userID: User.ID?, provider: LicenseDocument.Provider) async throws -> [LCPPassphraseHash] { + try logAndRethrow { + try db.prepare(transactions.select(passphrase) + .filter(self.userId == userID && self.provider == provider) + ) + .compactMap { try $0.get(passphrase) } + } + } + + public func addPassphrase(_ hash: LCPPassphraseHash, for licenseID: LicenseDocument.ID, userID: User.ID?, provider: LicenseDocument.Provider) async throws { + try logAndRethrow { + try db.run( + transactions.insert( + or: .replace, + self.passphrase <- hash, + self.licenseId <- licenseID, + self.provider <- provider, + self.userId <- userID + ) + ) + } + } + + private func all() -> [String] { + let query = transactions.select(passphrase) + do { + return try db.prepare(query).compactMap { try $0.get(passphrase) } + } catch { + log(.error, error) + return [] + } + } +} diff --git a/Sources/LCP/Persistence/Transactions.swift b/Sources/LCP/Persistence/Transactions.swift deleted file mode 100644 index e83b036ce..000000000 --- a/Sources/LCP/Persistence/Transactions.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// Copyright 2024 Readium Foundation. All rights reserved. -// Use of this source code is governed by the BSD-style license -// available in the top-level LICENSE file of the project. -// - -import Foundation -import ReadiumShared -import SQLite - -/// Database's TransactionsTable , in charge of keeping tracks of the previous license checking. -class Transactions: Loggable { - /// Table. - let transactions = Table("Transactions") - /// Fields. - let licenseId = Expression("licenseId") - let origin = Expression("origin") - let userId = Expression("userId") - let passphrase = Expression("passphrase") // hashed. - - init(_ connection: Connection) { - do { - try connection.run(transactions.create(temporary: false, ifNotExists: true) { t in - t.column(licenseId) - t.column(origin) - t.column(userId) - t.column(passphrase) - }) - } catch { - log(.error, error) - } - } -} - -extension Transactions: PassphrasesRepository { - func all() -> [String] { - let db = Database.shared.connection - let query = transactions.select(passphrase) - do { - return try db.prepare(query).compactMap { try $0.get(passphrase) } - } catch { - log(.error, error) - return [] - } - } - - func passphrase(forLicenseId licenseId: String) -> String? { - do { - let db = Database.shared.connection - let query = transactions.select(passphrase).filter(self.licenseId == licenseId) - - for row in try db.prepare(query) { - return try row.get(passphrase) - } - } catch { - log(.error, error) - } - - return nil - } - - func passphrases(forUserId userId: String) -> [String] { - let db = Database.shared.connection - let query = transactions.select(passphrase).filter(self.userId == userId) - do { - return try db.prepare(query).compactMap { try $0.get(passphrase) } - } catch { - log(.error, error) - return [] - } - } - - func addPassphrase(_ passphraseHash: String, forLicenseId licenseId: String?, provider: String?, userId: String?) -> Bool { - let db = Database.shared.connection - - let insertQuery = transactions.insert( - self.licenseId <- licenseId ?? "", - origin <- provider ?? "", - self.userId <- userId, - passphrase <- passphraseHash - ) - do { - try db.run(insertQuery) - return true - } catch { - log(.error, error) - return false - } - } -} diff --git a/Sources/LCP/Services/CRLService.swift b/Sources/LCP/Services/CRLService.swift index 91d7676eb..7deb68674 100644 --- a/Sources/LCP/Services/CRLService.swift +++ b/Sources/LCP/Services/CRLService.swift @@ -22,38 +22,41 @@ final class CRLService { } /// Retrieves the CRL either from the cache, or from EDRLab if the cache is outdated. - func retrieve() -> Deferred { + func retrieve() async throws -> String { let localCRL = readLocal() if let (crl, date) = localCRL, daysSince(date) < CRLService.expiration { - return .success(crl) + return crl } // Short timeout to avoid blocking the License, since we can always fall back on the cached CRL. let timeout: TimeInterval? = (localCRL == nil) ? nil : 8 - return fetch(timeout: timeout) - .map(saveLocal) - .catch { error in - // Fallback on the locally cached CRL if available - guard let (crl, _) = localCRL else { - return .failure(error) - } - return .success(crl) + do { + let crl = try await fetch(timeout: timeout) + saveLocal(crl) + return crl + + } catch { + // Fallback on the locally cached CRL if available + guard let (crl, _) = localCRL else { + throw error } + return crl + } } /// Fetches the updated Certificate Revocation List from EDRLab. - private func fetch(timeout: TimeInterval? = nil) -> Deferred { + private func fetch(timeout: TimeInterval? = nil) async throws -> String { let url = HTTPURL(string: "http://crl.edrlab.telesec.de/rl/EDRLab_CA.crl")! - return httpClient.fetch(HTTPRequest(url: url, timeoutInterval: timeout)) + let response = try await httpClient.fetch(HTTPRequest(url: url, timeoutInterval: timeout)) .mapError { _ in LCPError.crlFetching } - .tryMap { - guard let body = $0.body?.base64EncodedString() else { - throw LCPError.crlFetching - } - return "-----BEGIN X509 CRL-----\(body)-----END X509 CRL-----" - } + .get() + + guard let body = response.body?.base64EncodedString() else { + throw LCPError.crlFetching + } + return "-----BEGIN X509 CRL-----\(body)-----END X509 CRL-----" } /// Reads the local CRL. @@ -69,11 +72,10 @@ final class CRLService { } /// Caches the given CRL. - private func saveLocal(_ crl: String) -> String { + private func saveLocal(_ crl: String) { let defaults = UserDefaults.standard defaults.set(crl, forKey: CRLService.crlKey) defaults.set(Date(), forKey: CRLService.dateKey) - return crl } private func daysSince(_ date: Date) -> Int { diff --git a/Sources/LCP/Services/DeviceRepository.swift b/Sources/LCP/Services/DeviceRepository.swift deleted file mode 100644 index 894ea4091..000000000 --- a/Sources/LCP/Services/DeviceRepository.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// Copyright 2024 Readium Foundation. All rights reserved. -// Use of this source code is governed by the BSD-style license -// available in the top-level LICENSE file of the project. -// - -import Foundation - -protocol DeviceRepository { - func isDeviceRegistered(for license: LicenseDocument) throws -> Bool - func registerDevice(for license: LicenseDocument) throws -} diff --git a/Sources/LCP/Services/DeviceService.swift b/Sources/LCP/Services/DeviceService.swift index 185bc8d4b..52ffc038e 100644 --- a/Sources/LCP/Services/DeviceService.swift +++ b/Sources/LCP/Services/DeviceService.swift @@ -8,7 +8,7 @@ import Foundation import ReadiumShared final class DeviceService { - private let repository: DeviceRepository + private let repository: LCPLicenseRepository private let httpClient: HTTPClient /// Returns the device's name. @@ -16,7 +16,7 @@ final class DeviceService { init( deviceName: String, - repository: DeviceRepository, + repository: LCPLicenseRepository, httpClient: HTTPClient ) { name = deviceName @@ -46,25 +46,25 @@ final class DeviceService { /// Registers the device for the given license. /// If the call was made, the updated Status Document data is given to the completion closure. @discardableResult - func registerLicense(_ license: LicenseDocument, at link: Link) -> Deferred { - deferredCatching { - let registered = try self.repository.isDeviceRegistered(for: license) - guard !registered else { - return .success(nil) - } - guard let url = link.url(parameters: self.asQueryParameters) else { - throw LCPError.licenseInteractionNotAvailable + func registerLicense(_ license: LicenseDocument, at link: Link) async throws -> Data? { + let registered = try await repository.isDeviceRegistered(for: license.id) + guard !registered else { + return nil + } + guard let url = link.url(parameters: asQueryParameters) else { + throw LCPError.licenseInteractionNotAvailable + } + + let data = await httpClient.fetch(HTTPRequest(url: url, method: .post)) + .map { response -> Data? in + guard 100 ..< 400 ~= response.statusCode else { + return nil + } + return response.body } - return self.httpClient.fetch(HTTPRequest(url: url, method: .post)) - .tryMap { response in - guard 100 ..< 400 ~= response.statusCode else { - return nil - } + try await repository.registerDevice(for: license.id) - try self.repository.registerDevice(for: license) - return response.body - } - } + return try data.get() } } diff --git a/Sources/LCP/Services/LicensesRepository.swift b/Sources/LCP/Services/LicensesRepository.swift deleted file mode 100644 index 9838674a8..000000000 --- a/Sources/LCP/Services/LicensesRepository.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// Copyright 2024 Readium Foundation. All rights reserved. -// Use of this source code is governed by the BSD-style license -// available in the top-level LICENSE file of the project. -// - -import Foundation - -protocol LicensesRepository { - func addLicense(_ license: LicenseDocument) throws - - func copiesLeft(for licenseId: String) throws -> Int? - func setCopiesLeft(_ quantity: Int, for licenseId: String) throws - - func printsLeft(for licenseId: String) throws -> Int? - func setPrintsLeft(_ quantity: Int, for licenseId: String) throws -} diff --git a/Sources/LCP/Services/LicensesService.swift b/Sources/LCP/Services/LicensesService.swift index 873feac60..4f179e94a 100644 --- a/Sources/LCP/Services/LicensesService.swift +++ b/Sources/LCP/Services/LicensesService.swift @@ -16,13 +16,13 @@ final class LicensesService: Loggable { private let isProduction: Bool private let client: LCPClient - private let licenses: LicensesRepository + private let licenses: LCPLicenseRepository private let crl: CRLService private let device: DeviceService private let httpClient: HTTPClient private let passphrases: PassphrasesService - init(isProduction: Bool, client: LCPClient, licenses: LicensesRepository, crl: CRLService, device: DeviceService, httpClient: HTTPClient, passphrases: PassphrasesService) { + init(isProduction: Bool, client: LCPClient, licenses: LCPLicenseRepository, crl: CRLService, device: DeviceService, httpClient: HTTPClient, passphrases: PassphrasesService) { self.isProduction = isProduction self.client = client self.licenses = licenses @@ -32,147 +32,119 @@ final class LicensesService: Loggable { self.passphrases = passphrases } - func retrieve(from publication: FileURL, authentication: LCPAuthenticating?, allowUserInteraction: Bool, sender: Any?) -> Deferred { - makeLicenseContainer(for: publication) - .flatMap { container in - guard let container = container, container.containsLicense() else { - // Not protected with LCP - return .success(nil) - } + func retrieve( + from publication: FileURL, + authentication: LCPAuthenticating?, + allowUserInteraction: Bool, + sender: Any? + ) async throws -> LCPLicense? { + guard + let container = makeLicenseContainer(for: publication), + try await container.containsLicense() + else { + // Not protected with LCP + return nil + } - return self.retrieve(from: container, authentication: authentication, allowUserInteraction: allowUserInteraction, sender: sender) - .map { $0 as License? } - .mapError(LCPError.wrap) - } + return try await retrieve( + from: container, + authentication: authentication, + allowUserInteraction: allowUserInteraction, + sender: sender + ) } - fileprivate func retrieve(from container: LicenseContainer, authentication: LCPAuthenticating?, allowUserInteraction: Bool, sender: Any?) -> Deferred { - deferredCatching(on: .global(qos: .background)) { - let initialData = try container.read() - - func onLicenseValidated(of license: LicenseDocument) throws { - // Any errors are ignored to avoid blocking the publication. + fileprivate func retrieve( + from container: LicenseContainer, + authentication: LCPAuthenticating?, + allowUserInteraction: Bool, + sender: Any? + ) async throws -> License { + let initialData = try await container.read() + + func onLicenseValidated(of license: LicenseDocument) async throws { + // Any errors are ignored to avoid blocking the publication. + + do { + try await licenses.addLicense(license) + } catch { + log(.error, "Failed to add the LCP License to the local database: \(error)") + } + // Updates the License in the container if needed + if license.jsonData != initialData { do { - try self.licenses.addLicense(license) + try await container.write(license) + log(.debug, "Wrote updated License Document in container") } catch { - self.log(.error, "Failed to add the LCP License to the local database: \(error)") - } - - // Updates the License in the container if needed - if license.data != initialData { - do { - try container.write(license) - self.log(.debug, "Wrote updated License Document in container") - } catch { - self.log(.error, "Failed to write updated License Document in container: \(error)") - } + log(.error, "Failed to write updated License Document in container: \(error)") } } - - let validation = LicenseValidation( - authentication: authentication, - allowUserInteraction: allowUserInteraction, - sender: sender, - isProduction: self.isProduction, - client: self.client, - crl: self.crl, - device: self.device, - httpClient: self.httpClient, - passphrases: self.passphrases, - onLicenseValidated: onLicenseValidated - ) - - return validation.validate(.license(initialData)) - .tryMap { documents in - // Check the license status error if there's any - // Note: Right now we don't want to return a License if it fails the Status check, that's why we attempt to get the DRM context. But it could change if we want to access, for example, the License metadata or perform an LSD interaction, but without being able to decrypt the book. In which case, we could remove this line. - // Note2: The License already gets in this state when we perform a `return` successfully. We can't decrypt anymore but we still have access to the License Documents and LSD interactions. - _ = try documents.getContext() - - return License(documents: documents, client: self.client, validation: validation, licenses: self.licenses, device: self.device, httpClient: self.httpClient) - } } - } - func acquirePublication(from lcpl: FileURL, onProgress: @escaping (LCPAcquisition.Progress) -> Void, completion: @escaping (CancellableResult) -> Void) -> LCPAcquisition { - let acquisition = LCPAcquisition(onProgress: onProgress, completion: completion) + let validation = LicenseValidation( + authentication: authentication, + allowUserInteraction: allowUserInteraction, + sender: sender, + isProduction: isProduction, + client: client, + crl: crl, + device: device, + httpClient: httpClient, + passphrases: passphrases, + onLicenseValidated: onLicenseValidated + ) + + guard let documents = try await validation.validate(.license(initialData)) else { + throw LCPError.missingPassphrase + } - readLicense(from: lcpl).resolve { result in - switch result { - case let .success(license): - guard let license = license else { - acquisition.cancel() - return - } + // Check the license status error if there's any + // Note: Right now we don't want to return a License if it fails the Status check, that's why we attempt to get the DRM context. But it could change if we want to access, for example, the License metadata or perform an LSD interaction, but without being able to decrypt the book. In which case, we could remove this line. + // Note2: The License already gets in this state when we perform a `return` successfully. We can't decrypt anymore but we still have access to the License Documents and LSD interactions. + _ = try documents.getContext() - self.acquirePublication(from: license, using: acquisition) + return License(documents: documents, client: client, validation: validation, licenses: licenses, device: device, httpClient: httpClient) + } - case let .failure(error): - acquisition.didComplete(with: .failure(error)) - case .cancelled: - acquisition.cancel() - } + func acquirePublication( + from lcpl: FileURL, + onProgress: @escaping (LCPProgress) -> Void + ) async throws -> LCPAcquiredPublication { + guard let license = try await readLicense(from: lcpl) else { + throw LCPError.notALicenseDocument(lcpl) } - return acquisition - } + let url = try license.url(for: .publication) - private func readLicense(from lcpl: FileURL) -> Deferred { - makeLicenseContainer(for: lcpl) - .tryMap { container in - guard let container = container, container.containsLicense() else { - // Not protected with LCP - return nil - } + onProgress(.percent(0)) - return try LicenseDocument(data: container.read()) - } - .mapError(LCPError.wrap) + let download = try await httpClient.download( + url, + onProgress: { onProgress(.percent(Float($0))) } + ).get() + + let file = try await injectLicense(license, in: download) + return LCPAcquiredPublication( + localURL: file, + suggestedFilename: suggestedFilename(for: file, license: license), + licenseDocument: license + ) } - private func acquirePublication(from license: LicenseDocument, using acquisition: LCPAcquisition) { - guard !acquisition.cancellable.isCancelled else { - return + private func readLicense(from lcpl: FileURL) async throws -> LicenseDocument? { + guard + let container = makeLicenseContainer(for: lcpl), + try await container.containsLicense() + else { + return nil } - do { - let url = try license.url(for: .publication) - let cancellable = httpClient.download( - url, - onProgress: { acquisition.onProgress(.percent(Float($0))) }, - completion: { result in - switch result { - case let .success(download): - self.injectLicense(license, in: download) - .resolve { result in - switch result { - case let .success(file): - acquisition.didComplete(with: .success(LCPAcquisition.Publication( - localURL: file, - suggestedFilename: self.suggestedFilename(for: file, license: license) - ))) - case let .failure(error): - acquisition.didComplete(with: .failure(LCPError.wrap(error))) - case .cancelled: - acquisition.cancel() - } - } - - case let .failure(error): - acquisition.didComplete(with: .failure(LCPError.wrap(error))) - } - } - ) - acquisition.cancellable.mediate(cancellable) - - } catch { - acquisition.didComplete(with: .failure(.wrap(error))) - } + return try await LicenseDocument(data: container.read()) } /// Injects the given License Document into the `file` acquired using `downloadTask`. - private func injectLicense(_ license: LicenseDocument, in download: HTTPDownload) -> Deferred { + private func injectLicense(_ license: LicenseDocument, in download: HTTPDownload) async throws -> FileURL { var mimetypes: [String] = [ download.mediaType.string, ] @@ -180,16 +152,12 @@ final class LicensesService: Loggable { mimetypes.append(linkType) } - return makeLicenseContainer(for: download.location, mimetypes: mimetypes) - .tryMap(on: .global(qos: .background)) { container -> FileURL in - guard let container = container else { - throw LCPError.licenseContainer(.openFailed) - } + guard let container = makeLicenseContainer(for: download.location, mimetypes: mimetypes) else { + throw LCPError.licenseContainer(.openFailed) + } - try container.write(license) - return download.location - } - .mapError(LCPError.wrap) + try await container.write(license) + return download.location } /// Returns the suggested filename to be used when importing a publication. diff --git a/Sources/LCP/Services/PassphrasesRepository.swift b/Sources/LCP/Services/PassphrasesRepository.swift deleted file mode 100644 index e12887d6c..000000000 --- a/Sources/LCP/Services/PassphrasesRepository.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// Copyright 2024 Readium Foundation. All rights reserved. -// Use of this source code is governed by the BSD-style license -// available in the top-level LICENSE file of the project. -// - -import Foundation - -protocol PassphrasesRepository { - func all() -> [String] - func passphrase(forLicenseId licenseId: String) -> String? - func passphrases(forUserId userId: String) -> [String] - - @discardableResult - func addPassphrase(_ passphraseHash: String, forLicenseId licenseId: String?, provider: String?, userId: String?) -> Bool -} diff --git a/Sources/LCP/Services/PassphrasesService.swift b/Sources/LCP/Services/PassphrasesService.swift index 3392aef27..dd1b9b64e 100644 --- a/Sources/LCP/Services/PassphrasesService.swift +++ b/Sources/LCP/Services/PassphrasesService.swift @@ -10,11 +10,11 @@ import ReadiumShared final class PassphrasesService { private let client: LCPClient - private let repository: PassphrasesRepository + private let repository: LCPPassphraseRepository private let sha256Predicate = NSPredicate(format: "SELF MATCHES[c] %@", "^([a-f0-9]{64})$") - init(client: LCPClient, repository: PassphrasesRepository) { + init(client: LCPClient, repository: LCPPassphraseRepository) { self.client = client self.repository = repository } @@ -23,75 +23,96 @@ final class PassphrasesService { /// If none is found, requests a passphrase from the request delegate (ie. user prompt) until /// one is valid, or the request is cancelled. /// The returned passphrase is nil if the request was cancelled by the user. - func request(for license: LicenseDocument, authentication: LCPAuthenticating?, allowUserInteraction: Bool, sender: Any?) -> Deferred { - deferredCatching { - let candidates = self.possiblePassphrasesFromRepository(for: license) - if let passphrase = self.client.findOneValidPassphrase(jsonLicense: license.json, hashedPassphrases: candidates) { - return .success(passphrase) - } else if let authentication = authentication { - return self.authenticate(for: license, reason: .passphraseNotFound, using: authentication, allowUserInteraction: allowUserInteraction, sender: sender) - } else { - return .cancelled - } + func request( + for license: LicenseDocument, + authentication: LCPAuthenticating?, + allowUserInteraction: Bool, + sender: Any? + ) async throws -> LCPPassphraseHash? { + // Look for an existing passphrase associated with this license. + if + let candidate = try await repository.passphrase(for: license.id), + let passphrase = findValidPassphrase(in: [candidate], for: license) + { + return passphrase } - } - /// Called when the service can't find any valid passphrase in the repository, as a fallback. - private func authenticate(for license: LicenseDocument, reason: LCPAuthenticationReason, using authentication: LCPAuthenticating, allowUserInteraction: Bool, sender: Any?) -> Deferred { - deferred { (success: @escaping (String) -> Void, _, cancel) in - let authenticatedLicense = LCPAuthenticatedLicense(document: license) - authentication.retrievePassphrase( - for: authenticatedLicense, - reason: reason, + // Look for alternative candidates based on the provider and user ID. + let candidates = try await repository.passphrasesMatching( + userID: license.user.id, + provider: license.provider + ) + var passphrase: LCPPassphraseHash? = findValidPassphrase(in: candidates, for: license) + + // Fallback on the provided `LCPAuthenticating` implementation. + if passphrase == nil, let authentication = authentication { + passphrase = try await authenticate( + for: license, + reason: .passphraseNotFound, + using: authentication, allowUserInteraction: allowUserInteraction, sender: sender - ) { passphrase in - if let passphrase = passphrase { - success(passphrase) - } else { - cancel() - } - } + ) } - // Delays a bit to make sure any dialog was dismissed. - .delay(for: 0.3) - .flatMap { clearPassphrase in - let hashedPassphrase = clearPassphrase.sha256() - var passphrases = [hashedPassphrase] - // Note: The C++ LCP lib crashes if we provide a passphrase that is not a valid - // SHA-256 hash. So we check this beforehand. - if self.sha256Predicate.evaluate(with: clearPassphrase) { - passphrases.append(clearPassphrase) - } - - guard let passphrase = self.client.findOneValidPassphrase(jsonLicense: license.json, hashedPassphrases: passphrases) else { - // Tries again if the passphrase is invalid, until cancelled - return self.authenticate(for: license, reason: .invalidPassphrase, using: authentication, allowUserInteraction: allowUserInteraction, sender: sender) - } + if let passphrase = passphrase { // Saves the passphrase to open the publication right away next time - self.repository.addPassphrase(passphrase, forLicenseId: license.id, provider: license.provider, userId: license.user.id) - - return .success(passphrase) + try await repository.addPassphrase(passphrase, for: license) } + + return passphrase } - /// Finds any potential passphrase candidates (eg. similar user ID) for the given license, - /// from the passphrases repository. - private func possiblePassphrasesFromRepository(for license: LicenseDocument) -> [String] { - var passphrases: [String] = [] + private func findValidPassphrase(in hashes: [LCPPassphraseHash], for license: LicenseDocument) -> LCPPassphraseHash? { + guard !hashes.isEmpty else { + return nil + } + return client.findOneValidPassphrase(jsonLicense: license.jsonString, hashedPassphrases: hashes) + } - if let licensePassphrase = repository.passphrase(forLicenseId: license.id) { - passphrases.append(licensePassphrase) + /// Called when the service can't find any valid passphrase in the repository, as a fallback. + private func authenticate( + for license: LicenseDocument, + reason: LCPAuthenticationReason, + using authentication: LCPAuthenticating, + allowUserInteraction: Bool, + sender: Any? + ) async throws -> LCPPassphraseHash? { + let authenticatedLicense = LCPAuthenticatedLicense(document: license) + guard let clearPassphrase = await authentication.retrievePassphrase( + for: authenticatedLicense, + reason: reason, + allowUserInteraction: allowUserInteraction, + sender: sender + ) else { + return nil } - if let userId = license.user.id { - let userPassphrases = repository.passphrases(forUserId: userId) - passphrases.append(contentsOf: userPassphrases) + let hashedPassphrase = clearPassphrase.sha256() + var passphrases = [hashedPassphrase] + // Note: The C++ LCP lib crashes if we provide a passphrase that is not a valid + // SHA-256 hash. So we check this beforehand. + if sha256Predicate.evaluate(with: clearPassphrase) { + passphrases.append(clearPassphrase) } - passphrases.append(contentsOf: repository.all()) + guard let passphrase = client.findOneValidPassphrase( + jsonLicense: license.jsonString, + hashedPassphrases: passphrases + ) else { + // Delays a bit to make sure any dialog was dismissed. + try await Task.sleep(nanoseconds: 300_000_000) + + // Tries again if the passphrase is invalid, until cancelled + return try await authenticate( + for: license, + reason: .invalidPassphrase, + using: authentication, + allowUserInteraction: allowUserInteraction, + sender: sender + ) + } - return passphrases + return passphrase } } diff --git a/Sources/LCP/Toolkit/HTTPClient.swift b/Sources/LCP/Toolkit/HTTPClient.swift deleted file mode 100644 index 38e1a6c3c..000000000 --- a/Sources/LCP/Toolkit/HTTPClient.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// Copyright 2024 Readium Foundation. All rights reserved. -// Use of this source code is governed by the BSD-style license -// available in the top-level LICENSE file of the project. -// - -import Foundation -import ReadiumShared - -extension HTTPClient { - func fetch(_ url: HTTPRequestConvertible) -> Deferred { - deferred { completion in - _ = fetch(url) { result in - completion(CancellableResult(result)) - } - } - } -} diff --git a/Sources/Navigator/EditingAction.swift b/Sources/Navigator/EditingAction.swift index aabe6ddc5..9a0fc7747 100644 --- a/Sources/Navigator/EditingAction.swift +++ b/Sources/Navigator/EditingAction.swift @@ -138,8 +138,6 @@ final class EditingActionsController { /// Verifies that the user has the rights to use the given `action`. private func isActionAllowed(_ action: EditingAction) -> Bool { switch action { - case .copy: - return rights.canCopy case .share: return canShare default: @@ -188,11 +186,12 @@ final class EditingActionsController { } /// Copies the authorized portion of the selection text into the pasteboard. - func copy() { + @MainActor + func copy() async { guard let text = selection?.locator.text.highlight else { return } - guard rights.copy(text: text) else { + guard await rights.copy(text: text) else { delegate?.editingActionsDidPreventCopy(self) return } diff --git a/Sources/Navigator/PDF/PDFDocumentView.swift b/Sources/Navigator/PDF/PDFDocumentView.swift index 4e36494c0..77a047455 100644 --- a/Sources/Navigator/PDF/PDFDocumentView.swift +++ b/Sources/Navigator/PDF/PDFDocumentView.swift @@ -50,7 +50,9 @@ public final class PDFDocumentView: PDFView { } override public func copy(_ sender: Any?) { - editingActions.copy() + Task { + await editingActions.copy() + } } @available(iOS 13.0, *) diff --git a/Sources/Navigator/Toolkit/WebView.swift b/Sources/Navigator/Toolkit/WebView.swift index 76dafcbdc..e37981ba9 100644 --- a/Sources/Navigator/Toolkit/WebView.swift +++ b/Sources/Navigator/Toolkit/WebView.swift @@ -44,7 +44,9 @@ final class WebView: WKWebView { } override func copy(_ sender: Any?) { - editingActions.copy() + Task { + await editingActions.copy() + } } override func didMoveToWindow() { diff --git a/Sources/Shared/Fetcher/HTTPFetcher.swift b/Sources/Shared/Fetcher/HTTPFetcher.swift index beaddb001..0410c5117 100644 --- a/Sources/Shared/Fetcher/HTTPFetcher.swift +++ b/Sources/Shared/Fetcher/HTTPFetcher.swift @@ -54,28 +54,52 @@ public final class HTTPFetcher: Fetcher, Loggable { } /// Cached HEAD response to get the expected content length and other metadata. - private lazy var headResponse: ResourceResult = client.fetchSync(HTTPRequest(url: url, method: .head)) + private lazy var headResponse: ResourceResult = client.fetchWait(HTTPRequest(url: url, method: .head)) .mapError { ResourceError.wrap($0) } /// An HTTP resource is always remote. var file: FileURL? { nil } func stream(range: Range?, consume: @escaping (Data) -> Void, completion: @escaping (ResourceResult) -> Void) -> Cancellable { - var request = HTTPRequest(url: url) - if let range = range { - request.setRange(range) - } - - return client.stream( - request, - receiveResponse: nil, - consume: { data, _ in consume(data) }, - completion: { result in - completion(result.map { _ in }.mapError { ResourceError.wrap($0) }) + let request = { + var request = HTTPRequest(url: url) + if let range = range { + request.setRange(range) } - ) + return request + }() + + return CancellableTask(task: Task { + let result = await client.stream( + request: request, + consume: { data, _ in consume(data) } + ) + completion(result.map { _ in }.mapError { ResourceError.wrap($0) }) + }) } func close() {} } } + +private extension HTTPClient { + // FIXME: Get rid of this hack. + func fetchWait(_ request: HTTPRequestConvertible) -> HTTPResult { + warnIfMainThread() + + let enclosure = Enclosure() + let semaphore = DispatchSemaphore(value: 0) + + Task { + enclosure.value = await fetch(request) + semaphore.signal() + } + _ = semaphore.wait(timeout: .distantFuture) + + return enclosure.value + } +} + +class Enclosure { + var value: HTTPResult! +} diff --git a/Sources/Shared/Logger/Loggable.swift b/Sources/Shared/Logger/Loggable.swift index bfaec3aed..fccf50445 100644 --- a/Sources/Shared/Logger/Loggable.swift +++ b/Sources/Shared/Logger/Loggable.swift @@ -70,6 +70,15 @@ public extension Loggable { Logger.sharedInstance.log(value, at: level, file: defaultFile, line: defaultLine) } + @discardableResult func logAndRethrow(_ block: () throws -> T) rethrows -> T { + do { + return try block() + } catch { + log(.error, error) + throw error + } + } + static func log(_ level: SeverityLevel, _ value: Any?, file: String, line: Int) { Logger.sharedInstance.log(value, at: level, file: file, line: line) } diff --git a/Sources/Shared/Publication/Services/Content Protection/ContentProtectionService+WS.swift b/Sources/Shared/Publication/Services/Content Protection/ContentProtectionService+WS.swift deleted file mode 100644 index a6ec72c8d..000000000 --- a/Sources/Shared/Publication/Services/Content Protection/ContentProtectionService+WS.swift +++ /dev/null @@ -1,146 +0,0 @@ -// -// Copyright 2024 Readium Foundation. All rights reserved. -// Use of this source code is governed by the BSD-style license -// available in the top-level LICENSE file of the project. -// - -import Foundation -import ReadiumInternal - -public extension ContentProtectionService { - var links: [Link] { - handlers.map(\.routeLink) - } - - func get(link requestedLink: Link) -> Resource? { - guard let handler = handlers.first(where: { $0.accepts(link: requestedLink) }) else { - return nil - } - var link = handler.routeLink - link.href = requestedLink.href - - let response: ResourceResult = handler.handle(link: link, for: self) - .flatMap { - guard let jsonResponse = serializeJSONString($0) else { - return .failure(.other(JSONError.serializing(ContentProtectionService.self))) - } - return .success(jsonResponse) - } - - switch response { - case let .success(body): - return DataResource(link: link, string: body) - case let .failure(error): - return FailureResource(link: link, error: error) - } - } -} - -public enum ContentProtectionServiceError: LocalizedError { - case missingParameter(name: String) - - public var errorDescription: String? { - switch self { - case let .missingParameter(name): - return "The `\(name)` parameter is required" - } - } -} - -/// Content Protection's web service route handlers. -private let handlers: [RouteHandler] = [ - ContentProtectionRouteHandler(), - CopyRightsRouteHandler(), - PrintRightsRouteHandler(), -] - -private protocol RouteHandler { - var routeLink: Link { get } - - func accepts(link: Link) -> Bool - - func handle(link: Link, for service: ContentProtectionService) -> ResourceResult -} - -private final class ContentProtectionRouteHandler: RouteHandler { - let routeLink = Link( - href: "/~readium/content-protection", - type: MediaType.readiumContentProtection.string - ) - - func accepts(link: Link) -> Bool { - link.href == routeLink.href - } - - func handle(link: Link, for service: ContentProtectionService) -> ResourceResult { - .success([ - "isRestricted": service.isRestricted, - "error": service.error?.localizedDescription as Any, - "name": service.name?.json as Any, - "rights": [ - "canCopy": service.rights.canCopy, - "canPrint": service.rights.canPrint, - ], - ].compactMapValues { $0 }) - } -} - -private final class CopyRightsRouteHandler: RouteHandler { - /// `text` is the percent-encoded string to copy. - /// `peek` is true or false. When missing, it defaults to false. - let routeLink = Link( - href: "/~readium/rights/copy{?text,peek}", - type: MediaType.readiumRightsCopy.string, - templated: true - ) - - func accepts(link: Link) -> Bool { - link.href.hasPrefix("/~readium/rights/copy") - } - - func handle(link: Link, for service: ContentProtectionService) -> ResourceResult { - let params = AnyURL(string: link.href)?.query ?? URLQuery() - let peek = params.first(named: "peek").flatMap(Bool.init) ?? false - guard let text = params.first(named: "text") else { - return .failure(.badRequest(ContentProtectionServiceError.missingParameter(name: "text"))) - } - - let allowed = peek - ? service.rights.canCopy(text: text) - : service.rights.copy(text: text) - - return allowed - ? .success([:] as [String: Any]) - : .failure(.forbidden(nil)) - } -} - -private final class PrintRightsRouteHandler: RouteHandler { - /// `pageCount` is the number of pages to print, as a positive integer. - /// `peek` is true or false. When missing, it defaults to false. - let routeLink = Link( - href: "/~readium/rights/print{?pageCount,peek}", - type: MediaType.readiumRightsPrint.string, - templated: true - ) - - func accepts(link: Link) -> Bool { - link.href.hasPrefix("/~readium/rights/print") - } - - func handle(link: Link, for service: ContentProtectionService) -> ResourceResult { - let params = AnyURL(string: link.href)?.query ?? URLQuery() - let peek = params.first(named: "peek").flatMap(Bool.init) ?? false - guard let pageCount = params.first(named: "pageCount").flatMap(Int.init) else { - return .failure(.badRequest(ContentProtectionServiceError.missingParameter(name: "pageCount"))) - } - - let allowed = peek - ? service.rights.canPrint(pageCount: pageCount) - : service.rights.print(pageCount: pageCount) - - return allowed - ? .success([:] as [String: Any]) - : .failure(.forbidden(nil)) - } -} diff --git a/Sources/Shared/Publication/Services/Content Protection/UserRights.swift b/Sources/Shared/Publication/Services/Content Protection/UserRights.swift index d725780e4..085524663 100644 --- a/Sources/Shared/Publication/Services/Content Protection/UserRights.swift +++ b/Sources/Shared/Publication/Services/Content Protection/UserRights.swift @@ -8,69 +8,50 @@ import Foundation /// Manages consumption of user rights and permissions. public protocol UserRights { - /// Returns whether the user is currently allowed to copy content to the pasteboard. - /// - /// Navigators and reading apps can use this to know if the "Copy" action should be greyed - /// out or not. This should be called every time the "Copy" action will be displayed, - /// because the value might change during runtime. - var canCopy: Bool { get } - /// Returns whether the user is allowed to copy the given text to the pasteboard. /// - /// This is more specific than the `canCopy` property, and can return `false` if the given text - /// exceeds the allowed amount of characters to copy. + /// It may return `false` if the given text exceeds the allowed amount of characters to copy. /// /// To be used before presenting, for example, a pop-up to share a selected portion of /// content. - func canCopy(text: String) -> Bool + func canCopy(text: String) async -> Bool /// Consumes the given text with the copy right. /// /// Returns whether the user is allowed to copy the given text. - func copy(text: String) -> Bool - - /// Returns whether the user is currently allowed to print the content. - /// - /// Navigators and reading apps can use this to know if the "Print" action should be greyed - /// out or not. - var canPrint: Bool { get } + func copy(text: String) async -> Bool /// Returns whether the user is allowed to print the given amount of pages. /// - /// This is more specific than the `canPrint` property, and can return `false` if the given - /// `pageCount` exceeds the allowed amount of pages to print. + /// It may return `false` if the given `pageCount` exceeds the allowed amount of pages to print. /// /// To be used before attempting to launch a print job, for example. - func canPrint(pageCount: Int) -> Bool + func canPrint(pageCount: Int) async -> Bool /// Consumes the given amount of pages with the print right. /// /// Returns whether the user is allowed to print the given amount of pages. - func print(pageCount: Int) -> Bool + func print(pageCount: Int) async -> Bool } /// A `UserRights` without any restriction. public class UnrestrictedUserRights: UserRights { public init() {} - public var canCopy: Bool { true } - public func canCopy(text: String) -> Bool { true } - public func copy(text: String) -> Bool { true } + public func canCopy(text: String) async -> Bool { true } + public func copy(text: String) async -> Bool { true } - public var canPrint: Bool { true } - public func canPrint(pageCount: Int) -> Bool { true } - public func print(pageCount: Int) -> Bool { true } + public func canPrint(pageCount: Int) async -> Bool { true } + public func print(pageCount: Int) async -> Bool { true } } /// A `UserRights` which forbids all rights. public class AllRestrictedUserRights: UserRights { public init() {} - public var canCopy: Bool { false } - public func canCopy(text: String) -> Bool { false } - public func copy(text: String) -> Bool { false } + public func canCopy(text: String) async -> Bool { false } + public func copy(text: String) async -> Bool { false } - public var canPrint: Bool { false } - public func canPrint(pageCount: Int) -> Bool { false } - public func print(pageCount: Int) -> Bool { false } + public func canPrint(pageCount: Int) async -> Bool { false } + public func print(pageCount: Int) async -> Bool { false } } diff --git a/Sources/Shared/Toolkit/Cancellable.swift b/Sources/Shared/Toolkit/Cancellable.swift index 7d75e35e8..f4af324f1 100644 --- a/Sources/Shared/Toolkit/Cancellable.swift +++ b/Sources/Shared/Toolkit/Cancellable.swift @@ -12,6 +12,19 @@ public protocol Cancellable { func cancel() } +// FIXME: Remove eventually +public struct CancellableTask: Cancellable { + let task: Task + + public init(task: Task) { + self.task = task + } + + public func cancel() { + task.cancel() + } +} + /// A `Cancellable` object saving its cancelled state. public final class CancellableObject: Cancellable { public private(set) var isCancelled = false diff --git a/Sources/Shared/Toolkit/Extensions/URL.swift b/Sources/Shared/Toolkit/Extensions/URL.swift index 29dcfd4c0..b0c904f2e 100644 --- a/Sources/Shared/Toolkit/Extensions/URL.swift +++ b/Sources/Shared/Toolkit/Extensions/URL.swift @@ -63,6 +63,7 @@ extension URL: Loggable { /// Returns the first available URL by appending the given `pathComponent`. /// /// If `pathComponent` is already taken, then it appends a number to it. + // FIXME: Move to Internal public func appendingUniquePathComponent(_ pathComponent: String? = nil) -> URL { /// Returns the first path component matching the given `validation` closure. /// Numbers are appended to the path component until a valid candidate is found. diff --git a/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift b/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift index 5ef384224..d4fac90e3 100644 --- a/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift +++ b/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift @@ -27,7 +27,7 @@ public protocol DefaultHTTPClientDelegate: AnyObject { /// /// You can modify the `request`, for example by adding additional HTTP headers or redirecting to a different URL, /// before calling the `completion` handler with the new request. - func httpClient(_ httpClient: DefaultHTTPClient, willStartRequest request: HTTPRequest, completion: @escaping (HTTPResult) -> Void) + func httpClient(_ httpClient: DefaultHTTPClient, willStartRequest request: HTTPRequest) async -> HTTPResult /// Asks the delegate to recover from an `error` received for the given `request`. /// @@ -37,7 +37,7 @@ public protocol DefaultHTTPClientDelegate: AnyObject { /// * a new request to start /// * the `error` argument, if you cannot recover from it /// * a new `HTTPError` to provide additional information - func httpClient(_ httpClient: DefaultHTTPClient, recoverRequest request: HTTPRequest, fromError error: HTTPError, completion: @escaping (HTTPResult) -> Void) + func httpClient(_ httpClient: DefaultHTTPClient, recoverRequest request: HTTPRequest, fromError error: HTTPError) async -> HTTPResult /// Tells the delegate that we received an HTTP response for the given `request`. /// @@ -59,18 +59,17 @@ public protocol DefaultHTTPClientDelegate: AnyObject { func httpClient( _ httpClient: DefaultHTTPClient, request: HTTPRequest, - didReceive challenge: URLAuthenticationChallenge, - completion: @escaping (URLAuthenticationChallengeResponse) -> Void - ) + didReceive challenge: URLAuthenticationChallenge + ) async -> URLAuthenticationChallengeResponse } public extension DefaultHTTPClientDelegate { - func httpClient(_ httpClient: DefaultHTTPClient, willStartRequest request: HTTPRequest, completion: @escaping (HTTPResult) -> Void) { - completion(.success(request)) + func httpClient(_ httpClient: DefaultHTTPClient, willStartRequest request: HTTPRequest) async -> HTTPResult { + .success(request) } - func httpClient(_ httpClient: DefaultHTTPClient, recoverRequest request: HTTPRequest, fromError error: HTTPError, completion: @escaping (HTTPResult) -> Void) { - completion(.failure(error)) + func httpClient(_ httpClient: DefaultHTTPClient, recoverRequest request: HTTPRequest, fromError error: HTTPError) async -> HTTPResult { + .failure(error) } func httpClient(_ httpClient: DefaultHTTPClient, request: HTTPRequest, didReceiveResponse response: HTTPResponse) {} @@ -79,10 +78,9 @@ public extension DefaultHTTPClientDelegate { func httpClient( _ httpClient: DefaultHTTPClient, request: HTTPRequest, - didReceive challenge: URLAuthenticationChallenge, - completion: @escaping (URLAuthenticationChallengeResponse) -> Void - ) { - completion(.performDefaultHandling) + didReceive challenge: URLAuthenticationChallenge + ) async -> URLAuthenticationChallengeResponse { + .performDefaultHandling } } @@ -155,7 +153,7 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { public weak var delegate: DefaultHTTPClientDelegate? = nil - private let tasks: TaskManager + private let tasks: HTTPTaskManager private let session: URLSession private let userAgent: String @@ -168,7 +166,7 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { userAgent: String? = nil, delegate: DefaultHTTPClientDelegate? = nil ) { - let tasks = TaskManager() + let tasks = HTTPTaskManager() self.userAgent = userAgent ?? DefaultHTTPClient.defaultUserAgent self.delegate = delegate @@ -182,119 +180,103 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { session.invalidateAndCancel() } - public func stream(_ request: HTTPRequestConvertible, receiveResponse: ((HTTPResponse) -> Void)?, consume: @escaping (Data, Double?) -> Void, completion: @escaping (HTTPResult) -> Void) -> Cancellable { - let mediator = MediatorCancellable() - - /// Attempts to start a `request`. - /// Will try to recover from errors using the `delegate` and calling itself again. - func tryStart(_ request: HTTPRequestConvertible) -> HTTPDeferred { - request.httpRequest().deferred - .flatMap(willStartRequest) - .flatMap(requireNotCancelled) - .flatMap { request in - startTask(for: request) - .flatCatch { error in - recoverRequest(request, fromError: error) - .flatMap(requireNotCancelled) - .flatMap { newRequest in - tryStart(newRequest) - } - } - } - } - - /// Will interrupt the flow if the `mediator` received a cancel request. - func requireNotCancelled(_ value: T) -> HTTPDeferred { - if mediator.isCancelled { - return .failure(HTTPError(kind: .cancelled)) - } else { - return .success(value) - } - } - - /// Creates and starts a new task for the `request`, whose cancellable will be exposed through `mediator`. - func startTask(for request: HTTPRequest) -> HTTPDeferred { - deferred { completion in - var request = request - if request.userAgent == nil { - request.userAgent = self.userAgent - } - - let cancellable = self.tasks.start(Task( - request: request, - task: self.session.dataTask(with: request.urlRequest), - receiveResponse: { [weak self] response in - if let self = self { - self.delegate?.httpClient(self, request: request, didReceiveResponse: response) - } - receiveResponse?(response) - }, - receiveChallenge: { [weak self] challenge, completion in - if let self = self, let delegate = self.delegate { - delegate.httpClient(self, request: request, didReceive: challenge, completion: completion) - } else { - completion(.performDefaultHandling) - } - }, - consume: consume, - completion: { [weak self] result in - if let self = self, case let .failure(error) = result { - self.delegate?.httpClient(self, request: request, didFailWithError: error) - } - completion(CancellableResult(result)) + public func stream( + request: any HTTPRequestConvertible, + consume: @escaping (Data, Double?) -> Void + ) async -> HTTPResult { + await request.httpRequest() + .flatMap(willStartRequest) + .flatMap { request in + await startTask(for: request, consume: consume) + .recover { error in + await recover(request, from: error) + .flatMap { newRequest in + await stream(request: newRequest, consume: consume) + } } - )) - - mediator.mediate(cancellable) } - } + } - /// Lets the `delegate` customize the `request` if needed, before actually starting it. - func willStartRequest(_ request: HTTPRequest) -> HTTPDeferred { - deferred { completion in - if let delegate = self.delegate { - delegate.httpClient(self, willStartRequest: request) { result in - let request = result.flatMap { $0.httpRequest() } - completion(CancellableResult(request)) - } - } else { - completion(.success(request)) - } - } + /// Creates and starts a new task for the `request`, whose cancellable will be exposed through `mediator`. + private func startTask(for request: HTTPRequest, consume: @escaping HTTPTask.Consume) async -> HTTPResult { + var request = request + if request.userAgent == nil { + request.userAgent = userAgent } - /// Attempts to recover from a `error` by asking the `delegate` for a new request. - func recoverRequest(_ request: HTTPRequest, fromError error: HTTPError) -> HTTPDeferred { - deferred { completion in - if let delegate = self.delegate { - delegate.httpClient(self, recoverRequest: request, fromError: error) { completion(CancellableResult($0)) } + let result = await tasks.start( + request: request, + task: session.dataTask(with: request.urlRequest), + receiveResponse: { [weak self] response in + if let self = self { + self.delegate?.httpClient(self, request: request, didReceiveResponse: response) + } + }, + receiveChallenge: { [weak self] challenge in + if let self = self, let delegate = self.delegate { + return await delegate.httpClient(self, request: request, didReceive: challenge) } else { - completion(.failure(error)) + return .performDefaultHandling } - } + }, + consume: consume + ) + + if let delegate = delegate, case let .failure(error) = result { + delegate.httpClient(self, request: request, didFailWithError: error) } - tryStart(request) - .resolve(on: .main) { result in - // Convert a `CancellableResult` to an `HTTPResult`, as expected by the `completion` handler. - let result = result.result(withCancelledError: HTTPError(kind: .cancelled)) - completion(result) - } + return result + } - return mediator + /// Lets the `delegate` customize the `request` if needed, before actually starting it. + private func willStartRequest(_ request: HTTPRequest) async -> HTTPResult { + guard let delegate = delegate else { + return .success(request) + } + return await delegate.httpClient(self, willStartRequest: request) + .flatMap { $0.httpRequest() } } - private class TaskManager: NSObject, URLSessionDataDelegate { + /// Attempts to recover from a `error` by asking the `delegate` for a new request. + private func recover(_ request: HTTPRequest, from error: HTTPError) async -> HTTPResult { + if let delegate = delegate { + return await delegate.httpClient(self, recoverRequest: request, fromError: error) + } else { + return .failure(error) + } + } + + private class HTTPTaskManager: NSObject, URLSessionDataDelegate { /// On-going tasks. - @Atomic private var tasks: [Task] = [] + @Atomic private var tasks: [HTTPTask] = [] - func start(_ task: Task) -> Cancellable { + func start( + request: HTTPRequest, + task: URLSessionDataTask, + receiveResponse: @escaping HTTPTask.ReceiveResponse, + receiveChallenge: @escaping HTTPTask.ReceiveChallenge, + consume: @escaping HTTPTask.Consume + ) async -> HTTPResult { + let task = HTTPTask( + request: request, + task: task, + receiveResponse: receiveResponse, + receiveChallenge: receiveChallenge, + consume: consume + ) $tasks.write { $0.append(task) } - task.start() - return task + + return await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + task.start(with: continuation) + } + } onCancel: { + task.cancel() + } } - private func findTask(for urlTask: URLSessionTask) -> Task? { + private func findTask(for urlTask: URLSessionTask) -> HTTPTask? { let task = tasks.first { $0.task == urlTask } if task == nil { log(.error, "Cannot find on-going HTTP task for \(urlTask)") @@ -331,51 +313,69 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { } /// Represents an on-going HTTP task. - private class Task: Cancellable, Loggable { + private class HTTPTask: Cancellable, Loggable { + typealias Continuation = CheckedContinuation, Never> + typealias ReceiveResponse = (HTTPResponse) -> Void + typealias ReceiveChallenge = (URLAuthenticationChallenge) async -> URLAuthenticationChallengeResponse + typealias Consume = (Data, Double?) -> Void + enum TaskError: Error { case byteRangesNotSupported(url: HTTPURL) } private let request: HTTPRequest fileprivate let task: URLSessionTask - private let receiveResponse: (HTTPResponse) -> Void - private let receiveChallenge: (URLAuthenticationChallenge, @escaping (URLAuthenticationChallengeResponse) -> Void) -> Void - private let consume: (Data, Double?) -> Void - private let completion: (HTTPResult) -> Void + private let receiveResponse: ReceiveResponse + private let receiveChallenge: ReceiveChallenge + private let consume: Consume /// States the HTTP task can be in. - private var state: State = .start + private var state: State = .initializing private enum State { + /// Waiting to start the task. + case initializing /// Waiting for the HTTP response. - case start + case start(continuation: Continuation) /// We received a success response, the data will be sent to `consume` progressively. - case stream(HTTPResponse, readBytes: Int64) + case stream(continuation: Continuation, response: HTTPResponse, readBytes: Int64) /// We received an error response, the data will be accumulated in `response.body` to make the final /// `HTTPError`. The body is needed for example when the response is an OPDS Authentication Document. - case failure(kind: HTTPError.Kind, cause: Error?, response: HTTPResponse?) + case failure(continuation: Continuation, kind: HTTPError.Kind, cause: Error?, response: HTTPResponse?) /// The request is terminated. case finished + + var continuation: Continuation? { + switch self { + case .initializing, .finished: + return nil + case let .start(continuation): + return continuation + case let .stream(continuation, _, _): + return continuation + case let .failure(continuation, _, _, _): + return continuation + } + } } init( request: HTTPRequest, task: URLSessionDataTask, - receiveResponse: @escaping (HTTPResponse) -> Void, - receiveChallenge: @escaping (URLAuthenticationChallenge, @escaping (URLAuthenticationChallengeResponse) -> Void) -> Void, - consume: @escaping (Data, Double?) -> Void, - completion: @escaping (HTTPResult) -> Void + receiveResponse: @escaping ReceiveResponse, + receiveChallenge: @escaping ReceiveChallenge, + consume: @escaping Consume ) { self.request = request self.task = task - self.completion = completion self.receiveResponse = receiveResponse self.receiveChallenge = receiveChallenge self.consume = consume } - func start() { + func start(with continuation: Continuation) { log(.info, request) + state = .start(continuation: continuation) task.resume() } @@ -385,16 +385,16 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { private func finish() { switch state { - case .start: - preconditionFailure("finish() called in `start` state") + case .initializing, .start: + preconditionFailure("finish() called in `start` or `initializing` state") - case let .stream(response, _): - completion(.success(response)) + case let .stream(continuation, response, _): + continuation.resume(returning: .success(response)) - case let .failure(kind, cause, response): + case let .failure(continuation, kind, cause, response): let error = HTTPError(kind: kind, cause: cause, response: response) log(.error, "\(request.method) \(request.url) failed with: \(error.localizedDescription)") - completion(.failure(error)) + continuation.resume(returning: .failure(error)) case .finished: break @@ -409,6 +409,7 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { return } guard + let continuation = state.continuation, let urlResponse = urlResponse as? HTTPURLResponse, let url = urlResponse.url?.httpURL else { @@ -419,7 +420,7 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { var response = HTTPResponse(request: request, response: urlResponse, url: url) if let kind = HTTPError.Kind(statusCode: response.statusCode) { - state = .failure(kind: kind, cause: nil, response: response) + state = .failure(continuation: continuation, kind: kind, cause: nil, response: response) // It was a HEAD request? We need to query the resource again to get the error body. The body is needed // for example when the response is an OPDS Authentication Document. @@ -428,7 +429,7 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { modifiedRequest.method = .get session.dataTask(with: modifiedRequest.urlRequest) { data, _, error in response.body = data - self.state = .failure(kind: kind, cause: error, response: response) + self.state = .failure(continuation: continuation, kind: kind, cause: error, response: response) completionHandler(.cancel) }.resume() return @@ -437,12 +438,12 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { } else { guard !request.hasHeader("Range") || response.acceptsByteRanges else { log(.error, "Streaming ranges requires the remote HTTP server to support byte range requests: \(url)") - state = .failure(kind: .other, cause: TaskError.byteRangesNotSupported(url: url), response: response) + state = .failure(continuation: continuation, kind: .other, cause: TaskError.byteRangesNotSupported(url: url), response: response) completionHandler(.cancel) return } - state = .stream(response, readBytes: 0) + state = .stream(continuation: continuation, response: response, readBytes: 0) receiveResponse(response) } @@ -451,23 +452,23 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { func urlSession(_ session: URLSession, didReceive data: Data) { switch state { - case .start, .finished: + case .initializing, .start, .finished: break - case .stream(let response, var readBytes): + case .stream(let continuation, let response, var readBytes): readBytes += Int64(data.count) var progress: Double? = nil if let expectedBytes = response.contentLength { progress = Double(min(readBytes, expectedBytes)) / Double(expectedBytes) } consume(data, progress) - state = .stream(response, readBytes: readBytes) + state = .stream(continuation: continuation, response: response, readBytes: readBytes) - case .failure(let kind, let cause, var response): + case .failure(let continuation, let kind, let cause, var response): var body = response?.body ?? Data() body.append(data) response?.body = body - state = .failure(kind: kind, cause: cause, response: response) + state = .failure(continuation: continuation, kind: kind, cause: cause, response: response) } } @@ -475,15 +476,18 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { if let error = error { if case .failure = state { // No-op, we don't want to overwrite the failure state in this case. + } else if let continuation = state.continuation { + state = .failure(continuation: continuation, kind: HTTPError.Kind(error: error), cause: error, response: nil) } else { - state = .failure(kind: HTTPError.Kind(error: error), cause: error, response: nil) + state = .finished } } finish() } func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { - receiveChallenge(challenge) { response in + Task { + let response = await receiveChallenge(challenge) switch response { case let .useCredential(credential): completion(.useCredential, credential) diff --git a/Sources/Shared/Toolkit/HTTP/HTTPClient.swift b/Sources/Shared/Toolkit/HTTP/HTTPClient.swift index 1b24bba53..909923752 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPClient.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPClient.swift @@ -15,53 +15,30 @@ public protocol HTTPClient: Loggable { /// /// - Parameters: /// - request: Request to the streamed resource. - /// - receiveResponse: Callback called when receiving the initial response, before consuming its body. You can /// also access it in the completion block after consuming the data. /// - consume: Callback called for each chunk of data received. Callers are responsible to accumulate the data /// if needed. - /// - completion: Callback called when the streaming finishes or an error occurs. - /// - Returns: A `Cancellable` interrupting the stream when requested. func stream( - _ request: HTTPRequestConvertible, - receiveResponse: ((HTTPResponse) -> Void)?, - consume: @escaping (_ chunk: Data, _ progress: Double?) -> Void, - completion: @escaping (HTTPResult) -> Void - ) -> Cancellable + request: HTTPRequestConvertible, + consume: @escaping (_ chunk: Data, _ progress: Double?) -> Void + ) async -> HTTPResult } public extension HTTPClient { - func stream(_ request: HTTPRequestConvertible, consume: @escaping (Data, Double?) -> Void, completion: @escaping (HTTPResult) -> Void) -> Cancellable { - stream(request, receiveResponse: nil, consume: consume, completion: completion) - } - /// Fetches the resource from the given `request`. - func fetch(_ request: HTTPRequestConvertible, completion: @escaping (HTTPResult) -> Void) -> Cancellable { + func fetch(_ request: HTTPRequestConvertible) async -> HTTPResult { var data = Data() - return stream(request, - consume: { chunk, _ in data.append(chunk) }, - completion: { result in - completion(result.map { - var response = $0 - response.body = data - return response - }) - }) - } - - /// Fetches a resource synchronously. - func fetchSync(_ request: HTTPRequestConvertible) -> HTTPResult { - warnIfMainThread() - - var result: HTTPResult! - - let semaphore = DispatchSemaphore(value: 0) - _ = fetch(request) { - result = $0 - semaphore.signal() - } - _ = semaphore.wait(timeout: .distantFuture) + let response = await stream( + request: request, + consume: { chunk, _ in data.append(chunk) } + ) - return result! + return response + .map { + var response = $0 + response.body = data + return response + } } /// Fetches the resource and attempts to decode it with the given `decoder`. @@ -69,45 +46,45 @@ public extension HTTPClient { /// If the decoder fails, a `malformedResponse` HTTP error is returned. func fetch( _ request: HTTPRequestConvertible, - decoder: @escaping (HTTPResponse, Data) throws -> T?, - completion: @escaping (HTTPResult) -> Void - ) -> Cancellable { - fetch(request) { response in - let result = response.flatMap { response -> HTTPResult in - guard - let body = response.body, - let result = try? decoder(response, body) - else { - return .failure(HTTPError(kind: .malformedResponse)) + decoder: @escaping (HTTPResponse, Data) throws -> T? + ) async -> HTTPResult { + await fetch(request) + .flatMap { response in + do { + guard + let body = response.body, + let result = try decoder(response, body) + else { + return .failure(HTTPError(kind: .malformedResponse)) + } + return .success(result) + + } catch { + return .failure(HTTPError(kind: .malformedResponse, cause: error)) } - return .success(result) } - completion(result) - } } /// Fetches the resource as a JSON object. - func fetchJSON(_ request: HTTPRequestConvertible, completion: @escaping (HTTPResult<[String: Any]>) -> Void) -> Cancellable { - fetch(request, - decoder: { try JSONSerialization.jsonObject(with: $1) as? [String: Any] }, - completion: completion) + func fetchJSON(_ request: HTTPRequestConvertible) async -> HTTPResult<[String: Any]> { + await fetch(request) { + try JSONSerialization.jsonObject(with: $1) as? [String: Any] + } } /// Fetches the resource as a `String`. - func fetchString(_ request: HTTPRequestConvertible, completion: @escaping (HTTPResult) -> Void) -> Cancellable { - fetch(request, - decoder: { response, body in - let encoding = response.mediaType.encoding ?? .utf8 - return String(data: body, encoding: encoding) - }, - completion: completion) + func fetchString(_ request: HTTPRequestConvertible) async -> HTTPResult { + await fetch(request) { response, body in + let encoding = response.mediaType.encoding ?? .utf8 + return String(data: body, encoding: encoding) + } } /// Fetches the resource as an `UIImage`. - func fetchImage(_ request: HTTPRequestConvertible, completion: @escaping (HTTPResult) -> Void) -> Cancellable { - fetch(request, - decoder: { UIImage(data: $1) }, - completion: completion) + func fetchImage(_ request: HTTPRequestConvertible) async -> HTTPResult { + await fetch(request) { + UIImage(data: $1) + } } /// Downloads the resource at a temporary location. @@ -115,9 +92,8 @@ public extension HTTPClient { /// You are responsible for moving or deleting the downloaded file in the `completion` block. func download( _ request: HTTPRequestConvertible, - onProgress: @escaping (Double) -> Void, - completion: @escaping (HTTPResult) -> Void - ) -> Cancellable { + onProgress: @escaping (Double) -> Void + ) async -> HTTPResult { let location = FileURL( url: URL( fileURLWithPath: NSTemporaryDirectory(), @@ -130,16 +106,11 @@ public extension HTTPClient { try "".write(to: location.url, atomically: true, encoding: .utf8) fileHandle = try FileHandle(forWritingTo: location.url) } catch { - completion(.failure(HTTPError(kind: .ioError, cause: error))) - return CancellableObject() + return .failure(HTTPError(kind: .ioError, cause: error)) } - var suggestedFilename: String? - return stream( - request, - receiveResponse: { response in - suggestedFilename = response.filename - }, + let result = await stream( + request: request, consume: { data, progression in fileHandle.seekToEndOfFile() fileHandle.write(data) @@ -147,34 +118,90 @@ public extension HTTPClient { if let progression = progression { onProgress(progression) } - }, - completion: { result in - if #available(iOS 13.0, *) { - do { - try fileHandle.close() - } catch { - log(.warning, error) - } - } - - switch result { - case let .success(response): - completion(.success(HTTPDownload( - location: location, - suggestedFilename: suggestedFilename ?? response.filename, - mediaType: response.mediaType - ))) - - case let .failure(error): - completion(.failure(error)) - do { - try FileManager.default.removeItem(at: location.url) - } catch { - log(.warning, error) - } - } } ) + + do { + try fileHandle.close() + } catch { + log(.warning, error) + } + + switch result { + case let .success(response): + return .success(HTTPDownload( + location: location, + suggestedFilename: response.filename, + mediaType: response.mediaType + )) + + case let .failure(error): + do { + try FileManager.default.removeItem(at: location.url) + } catch { + log(.warning, error) + } + return .failure(error) + } + } + + @available(*, unavailable, message: "Use the async variant.") + func stream(_ request: HTTPRequestConvertible, receiveResponse: ((HTTPResponse) -> Void)?, consume: @escaping (_ chunk: Data, _ progress: Double?) -> Void, completion: @escaping (HTTPResult) -> Void) -> Cancellable { + CancellableTask(task: Task { + await completion(stream(request: request, consume: consume)) + }) + } + + @available(*, unavailable, message: "Use the async variant.") + func stream(_ request: HTTPRequestConvertible, consume: @escaping (Data, Double?) -> Void, completion: @escaping (HTTPResult) -> Void) -> Cancellable { + CancellableTask(task: Task { + await completion(stream(request: request, consume: consume)) + }) + } + + @available(*, unavailable, message: "Use the async variant.") + func fetch(_ request: HTTPRequestConvertible, completion: @escaping (HTTPResult) -> Void) -> Cancellable { + CancellableTask(task: Task { + await completion(fetch(request)) + }) + } + + @available(*, unavailable, message: "Use the async variant.") + func fetchSync(_ request: HTTPRequestConvertible) -> HTTPResult { + fatalError() + } + + @available(*, unavailable, message: "Use the async variant.") + func fetch( + _ request: HTTPRequestConvertible, + decoder: @escaping (HTTPResponse, Data) throws -> T?, + completion: @escaping (HTTPResult) -> Void + ) -> Cancellable { + fatalError() + } + + @available(*, unavailable, message: "Use the async variant.") + func fetchJSON(_ request: HTTPRequestConvertible, completion: @escaping (HTTPResult<[String: Any]>) -> Void) -> Cancellable { + fatalError() + } + + @available(*, unavailable, message: "Use the async variant.") + func fetchString(_ request: HTTPRequestConvertible, completion: @escaping (HTTPResult) -> Void) -> Cancellable { + fatalError() + } + + @available(*, unavailable, message: "Use the async variant.") + func fetchImage(_ request: HTTPRequestConvertible, completion: @escaping (HTTPResult) -> Void) -> Cancellable { + fatalError() + } + + @available(*, unavailable, message: "Use the async variant.") + func download( + _ request: HTTPRequestConvertible, + onProgress: @escaping (Double) -> Void, + completion: @escaping (HTTPResult) -> Void + ) -> Cancellable { + fatalError() } } diff --git a/Support/Carthage/.xcodegen b/Support/Carthage/.xcodegen index 121d50cd7..01f99039d 100644 --- a/Support/Carthage/.xcodegen +++ b/Support/Carthage/.xcodegen @@ -298,6 +298,7 @@ ../../Sources/Internal/Extensions/Date+ISO8601.swift ../../Sources/Internal/Extensions/Double.swift ../../Sources/Internal/Extensions/NSRegularExpression.swift +../../Sources/Internal/Extensions/Result.swift ../../Sources/Internal/Extensions/String.swift ../../Sources/Internal/Extensions/URL.swift ../../Sources/Internal/JSON.swift @@ -312,10 +313,14 @@ ../../Sources/LCP/Content Protection ../../Sources/LCP/Content Protection/LCPContentProtection.swift ../../Sources/LCP/Content Protection/LCPDecryptor.swift +../../Sources/LCP/LCPAcquiredPublication.swift ../../Sources/LCP/LCPAcquisition.swift ../../Sources/LCP/LCPClient.swift ../../Sources/LCP/LCPError.swift ../../Sources/LCP/LCPLicense.swift +../../Sources/LCP/LCPLicenseRepository.swift +../../Sources/LCP/LCPPassphraseRepository.swift +../../Sources/LCP/LCPProgress.swift ../../Sources/LCP/LCPRenewDelegate.swift ../../Sources/LCP/LCPService.swift ../../Sources/LCP/License @@ -347,25 +352,21 @@ ../../Sources/LCP/Persistence ../../Sources/LCP/Persistence/Connection.swift ../../Sources/LCP/Persistence/Database.swift -../../Sources/LCP/Persistence/Licenses.swift -../../Sources/LCP/Persistence/Transactions.swift +../../Sources/LCP/Persistence/SQLiteLCPLicenseRepository.swift +../../Sources/LCP/Persistence/SQLiteLCPPassphraseRepository.swift ../../Sources/LCP/Resources ../../Sources/LCP/Resources/en.lproj ../../Sources/LCP/Resources/en.lproj/Localizable.strings ../../Sources/LCP/Resources/prod-license.lcpl ../../Sources/LCP/Services ../../Sources/LCP/Services/CRLService.swift -../../Sources/LCP/Services/DeviceRepository.swift ../../Sources/LCP/Services/DeviceService.swift -../../Sources/LCP/Services/LicensesRepository.swift ../../Sources/LCP/Services/LicensesService.swift -../../Sources/LCP/Services/PassphrasesRepository.swift ../../Sources/LCP/Services/PassphrasesService.swift ../../Sources/LCP/Toolkit ../../Sources/LCP/Toolkit/Bundle.swift ../../Sources/LCP/Toolkit/DataCompression.swift ../../Sources/LCP/Toolkit/Deferred.swift -../../Sources/LCP/Toolkit/HTTPClient.swift ../../Sources/LCP/Toolkit/ReadiumLCPLocalizedString.swift ../../Sources/Navigator ../../Sources/Navigator/Audiobook @@ -625,7 +626,6 @@ ../../Sources/Shared/Publication/Services/Content ../../Sources/Shared/Publication/Services/Content Protection ../../Sources/Shared/Publication/Services/Content Protection/ContentProtectionService.swift -../../Sources/Shared/Publication/Services/Content Protection/ContentProtectionService+WS.swift ../../Sources/Shared/Publication/Services/Content Protection/UserRights.swift ../../Sources/Shared/Publication/Services/Content/Content.swift ../../Sources/Shared/Publication/Services/Content/ContentService.swift diff --git a/Support/Carthage/Readium.xcodeproj/project.pbxproj b/Support/Carthage/Readium.xcodeproj/project.pbxproj index 12402cbca..3c6720ba1 100644 --- a/Support/Carthage/Readium.xcodeproj/project.pbxproj +++ b/Support/Carthage/Readium.xcodeproj/project.pbxproj @@ -51,7 +51,6 @@ 222E5BC7A9E632DD6BB9A78E /* URLQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 466FBCC987DE0DE8923187C7 /* URLQuery.swift */; }; 22833AC5883FFCA5B362403E /* ResourceInputStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36E5D8A1F865EDF9A7DAD31D /* ResourceInputStream.swift */; }; 229AD33455CF152539CBF320 /* ProxyFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1F7BC3EC3419CB824E3A70 /* ProxyFetcher.swift */; }; - 22BB9F4F0A3D2B9CA3D9BD0D /* PassphrasesRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C8CDB4833C705FC1D986679 /* PassphrasesRepository.swift */; }; 238F9288A061E26BC1674C4F /* OPDS2Parser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9935832F8ECA0AB7A7A486FC /* OPDS2Parser.swift */; }; 23CC10D0E00FAA9C7E1B6DF2 /* PerResourcePositionsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01CCE64AE9824DCF6D6413BC /* PerResourcePositionsService.swift */; }; 2423CAF5BFE3367019805F72 /* Seekable.swift in Sources */ = {isa = PBXBuildFile; fileRef = A37141BCDFDB6BDBB58CDDD8 /* Seekable.swift */; }; @@ -70,6 +69,7 @@ 2C5436091DD72FDBF6FF136D /* OPFParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61575203A3BEB8E218CAFE38 /* OPFParser.swift */; }; 2D803124497B645ED9EB0786 /* DataInputStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFE69E4A69439FC4C17CCEDB /* DataInputStream.swift */; }; 2E518C960D386F13E0A5E9B7 /* EPUBFixedSpreadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF99DAF66659A218CEC25EAE /* EPUBFixedSpreadView.swift */; }; + 2EEC1F0DF4BA4B8B1820FF9B /* LCPProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F34B9B08BC6FB84CE54A26 /* LCPProgress.swift */; }; 2F0C310DF17E6D7F1F567FD7 /* UserProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 707D6D09349FB31406847ABE /* UserProperties.swift */; }; 2F5F45D1B53B088D84B33658 /* ReadiumCSS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06C4BDFF128C774BCD660419 /* ReadiumCSS.swift */; }; 2FBD9C31AE37009993674C52 /* Preference.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECF32CF55DD942ACB06389C5 /* Preference.swift */; }; @@ -80,6 +80,7 @@ 33E3A544AFA1F9CF71771558 /* ReadiumShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 97BC822B36D72EF548162129 /* ReadiumShared.framework */; }; 345155F5B86FB14EA2C3E4B4 /* Properties+EPUB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BC71BAFF7A20D7903E6EE4D /* Properties+EPUB.swift */; }; 346C4DA09157847639648F56 /* OPDSParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07B5469E40752E598C070E5B /* OPDSParser.swift */; }; + 349F6BB9FDD28532C2B030EC /* LCPPassphraseRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FCA24A94D6376487FECAEF1 /* LCPPassphraseRepository.swift */; }; 357B804EF4CCE20D6BA3FA6A /* LazyResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C3A9CF25E925418A1712C0B /* LazyResource.swift */; }; 36F389148BCED8C5C501FE27 /* AudioSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ECD1D0BE2C4BB5B58E32BFD /* AudioSession.swift */; }; 37F8A777BB5C13CA98545F6E /* DefaultLocatorService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C28B8CD48F8A660141F5983 /* DefaultLocatorService.swift */; }; @@ -87,6 +88,7 @@ 39326587EF76BFD5AD68AED2 /* ReadiumShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 97BC822B36D72EF548162129 /* ReadiumShared.framework */; }; 39B1DDE3571AB3F3CC6824F4 /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDA827FC94F5CB3F9032028F /* JSON.swift */; }; 3AD9E86BB1621CF836919E33 /* ReadiumLocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38984FD65CFF1D54FF7F794F /* ReadiumLocalizedString.swift */; }; + 3B5A8A76665391D2D32CB012 /* LCPAcquiredPublication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C61B620DE6C012805269111 /* LCPAcquiredPublication.swift */; }; 3BB313823F043BA2C7D7D2F7 /* Locator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE7D07E66B7E820D1A509A27 /* Locator.swift */; }; 3BCC36125F640C4F38029C6A /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = CF80CA3985C2D6380D5A9653 /* Localizable.strings */; }; 3C4847FD7D5C5ABCF71A3E7B /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388D85FB7475709CB6CEA59E /* URL.swift */; }; @@ -115,15 +117,12 @@ 50736D15B35B2C53140A9C14 /* ControlFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55BC4119B8937D17ED80B1AB /* ControlFlow.swift */; }; 50A35FBDFC081B2EFF4C01C6 /* LoggerStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72922E22040CEFB3B7BBCDAF /* LoggerStub.swift */; }; 511718FCC42293E9F9ECFE68 /* ZIPInputStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0276C0D645E8013EE0F86FA /* ZIPInputStream.swift */; }; - 51A01B251C751D8F817E2EF8 /* LicensesRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1CBEFCBEB8C144A4429C2E9 /* LicensesRepository.swift */; }; 5240984F642C951743FB153F /* CBZNavigatorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 239A56BB0E6DAF17E0A13447 /* CBZNavigatorViewController.swift */; }; 539DD1F8747838BA96AE2282 /* MediaTypeSnifferContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 300E15AA6D30BBFB7416AC01 /* MediaTypeSnifferContext.swift */; }; 56A9C67C15BD88FBE576ADF8 /* HTTPProblemDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = C05E365EBAFDA0CF841F583B /* HTTPProblemDetails.swift */; }; 56CB87DACCA10F737710BFF6 /* Language.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68FF131876FA3A63025F2662 /* Language.swift */; }; - 5718571D121C8CBF45277A0D /* DeviceRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = B15EC41FF314ABF15AB25CAC /* DeviceRepository.swift */; }; 5730E84475195005D1291672 /* Publication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF03272C07D6951ADC1311E /* Publication.swift */; }; 57583D27AB12063C3D114A47 /* AudioParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9FFEB1FF4B5CD74EB35CD63 /* AudioParser.swift */; }; - 5803D95A1D970EB0F5D24584 /* Transactions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8C7C39F6E671BB20F2EB351 /* Transactions.swift */; }; 58E8C178E0748B7CBEA9D7AC /* CSSLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = C51A36BFDC79EB5377D69582 /* CSSLayout.swift */; }; 58F961D80665E1EFA31BBBF6 /* Connection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33FD18E1CF87271DA6A6A783 /* Connection.swift */; }; 5903F431F5558958EF7CDDA0 /* ZIPFoundation.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8D187A577EBFCFF738D1CDC7 /* ZIPFoundation.xcframework */; }; @@ -222,6 +221,7 @@ 999EF656A5CDAF3BA30C26EF /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD03AFC9C69E785886FB9620 /* Logger.swift */; }; 9A1877FBEAA0BFC4C74AD3BB /* Encryption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87727AC33D368A88A60A12B9 /* Encryption.swift */; }; 9A22C456F6A73F29AD9B0CE8 /* Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11252900E9B0827C0FD2FA4B /* Database.swift */; }; + 9A463F872E1B05B64E026EBB /* LCPLicenseRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3230FB63D7ADDD514D74F7E6 /* LCPLicenseRepository.swift */; }; 9BC4D1F2958D2F7D7BDB88DA /* CursorList.swift in Sources */ = {isa = PBXBuildFile; fileRef = C361F965E7A7962CA3E4C0BA /* CursorList.swift */; }; 9DB9674C11DF356966CBFA79 /* EPUBNavigatorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC9ACC1EB3903149EBF21BC0 /* EPUBNavigatorViewModel.swift */; }; 9E064BC9E99D4F7D8AC3109B /* MediaOverlays.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1F5FEE0323287B9CAA09F03 /* MediaOverlays.swift */; }; @@ -252,6 +252,7 @@ B96E8865DCA4A0CEFDA24DDF /* VisualNavigator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A94DA04D56753CC008F65B1A /* VisualNavigator.swift */; }; B9A750D9AE1856A93AC81089 /* LocatorService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDA8111A330AB4D7187DD743 /* LocatorService.swift */; }; B9C90E1089E9EBAE407EC292 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 212E89D9F2CC639C3E1F81C3 /* Array.swift */; }; + BA6B358A48C437B6A783D018 /* Result.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FD12CFF76C3F2946929CF93 /* Result.swift */; }; BABC5E427D69126DEDE4CBA9 /* PDFDocumentHolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6AD227BF7C477BF13B0BB94 /* PDFDocumentHolder.swift */; }; BAC8616BD37C22BC5541959A /* PotentialRights.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5CE464C519852D38F873ADB /* PotentialRights.swift */; }; BB457884B7AFAEC3F52E8CE3 /* LCPDecryptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68719C5F09F9193E378DF585 /* LCPDecryptor.swift */; }; @@ -269,7 +270,7 @@ C35C6AB637D2048D9B0A3C62 /* OPDSAvailability.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2E780027410F4B6CC872B3D /* OPDSAvailability.swift */; }; C368C73C819F65CE3409D35D /* Fuzi.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFE34EA8AF2D815F7169CA45 /* Fuzi.swift */; }; C39FFB0B372929F24B2FF3DB /* DataExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C9762191DAD823E7C925A5 /* DataExtension.swift */; }; - C3BEB5CC9C6DD065B2CAE1BE /* Licenses.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED568512FD1304D6B9CC79B0 /* Licenses.swift */; }; + C3EA446FD874A8FD6C84F5E2 /* SQLiteLCPLicenseRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 270098AF39D824CC69AAFB3B /* SQLiteLCPLicenseRepository.swift */; }; C3F4CBE80D741D4158CA8407 /* ReadiumInternal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 42FD63C2720614E558522675 /* ReadiumInternal.framework */; }; C4F0A98562FDDB478F7DD0A9 /* LCPLicense.swift in Sources */ = {isa = PBXBuildFile; fileRef = 093629E752DE17264B97C598 /* LCPLicense.swift */; }; C517D6C11D94ECD103D1360E /* UInt64.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57338C29681D4872D425AB81 /* UInt64.swift */; }; @@ -303,7 +304,6 @@ DB423F5860A1C47EF2E18113 /* PDFTapGestureController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DF324AD5E3E30687AC5262D /* PDFTapGestureController.swift */; }; DC0487666F03A3FAFE49D0B9 /* EPUBLicenseContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 500E55D9CA753D6D6AA76D10 /* EPUBLicenseContainer.swift */; }; DD04CA793E06BBAD6A75329F /* EPUBPreferences+Legacy.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF31AEFB5FF0E7892C6D903E /* EPUBPreferences+Legacy.swift */; }; - DD09A1F641C21055E45809D2 /* ContentProtectionService+WS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33C422C1CFB72372FC343AE4 /* ContentProtectionService+WS.swift */; }; DD8E2E0D394399A51F295380 /* Link.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF92954C8C8C3EC50C835CBA /* Link.swift */; }; DDD0C8AC27EF8D1A893DF6CC /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529B55BE6996FCDC1082BF0A /* JSON.swift */; }; DEF1AA526DDAF2D5EA3A6594 /* FileURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FA3AA272772FCF7D6268A74 /* FileURL.swift */; }; @@ -311,11 +311,11 @@ DF1E952C3BFD836DA492E9FA /* HTTPFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049EDB4F925E0AFEDA7318A5 /* HTTPFetcher.swift */; }; DF579A993E41F53DB61116E8 /* Fetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6013A51134BA90F51257864B /* Fetcher.swift */; }; DFA6D21F48B69ED6839BC9AC /* EPUBSpread.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98D8CC7BC117BBFB206D01CC /* EPUBSpread.swift */; }; + DFC94F7A7818F35247031037 /* SQLiteLCPPassphraseRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAC626641AB22732DC4B64C6 /* SQLiteLCPPassphraseRepository.swift */; }; E07EF2EBC81BE8660018A1ED /* FileFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5593049BE86071900520099 /* FileFetcher.swift */; }; E08CABFE57EC96D9F7062AF1 /* PDFKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABAF1D0444B94E2CDD80087D /* PDFKit.swift */; }; E12A731DD41BD2BC3C8076F8 /* TargetAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E564AE6D5137499C81FEBE2 /* TargetAction.swift */; }; E16AA8A927AAE141702F2D3B /* HTMLFontFamilyDeclaration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D80848AADD20D4384D9AF59 /* HTMLFontFamilyDeclaration.swift */; }; - E356D67B77C65D294F60D58A /* HTTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8FA524D4C8D19FBDDE23F5 /* HTTPClient.swift */; }; E408BBB74A13AFB83C953C67 /* Properties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76638D3D1220E4C2620B9A80 /* Properties.swift */; }; E42CCC4CB1D491564664B5B6 /* Configurable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06AD6A912937694B20AD54C9 /* Configurable.swift */; }; E58910A3992CC88DE5BC0AA0 /* AudioLocatorService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEB6D68278E0A593C810E2C0 /* AudioLocatorService.swift */; }; @@ -482,12 +482,12 @@ 239A56BB0E6DAF17E0A13447 /* CBZNavigatorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CBZNavigatorViewController.swift; sourceTree = ""; }; 251275D0DF87F85158A5FEA9 /* Assets */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Assets; path = ../../Sources/Navigator/EPUB/Assets; sourceTree = SOURCE_ROOT; }; 258351CE21165EDED7F87878 /* URLProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLProtocol.swift; sourceTree = ""; }; + 270098AF39D824CC69AAFB3B /* SQLiteLCPLicenseRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLiteLCPLicenseRepository.swift; sourceTree = ""; }; 2732AFC91AB15FA09C60207A /* Locator+Audio.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Locator+Audio.swift"; sourceTree = ""; }; 294E01A2E6FF25539EBC1082 /* Properties+Archive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Properties+Archive.swift"; sourceTree = ""; }; 29AD63CD2A41586290547212 /* NavigationDocumentParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationDocumentParser.swift; sourceTree = ""; }; 2AF56CF04F94B7BE45631897 /* LCPContentProtection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCPContentProtection.swift; sourceTree = ""; }; 2BD6F93E379D0DC6FA1DCDEE /* Navigator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Navigator.swift; sourceTree = ""; }; - 2C8CDB4833C705FC1D986679 /* PassphrasesRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PassphrasesRepository.swift; sourceTree = ""; }; 2CB0BFECA8236412881393AA /* LCPAuthenticating.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCPAuthenticating.swift; sourceTree = ""; }; 2CDB1B325928A873012E6149 /* XML.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XML.swift; sourceTree = ""; }; 2DE48021CF3FED1C3340E458 /* RoutingFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutingFetcher.swift; sourceTree = ""; }; @@ -495,9 +495,9 @@ 2F3481F848A616A9A825A4BD /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = ""; }; 300E15AA6D30BBFB7416AC01 /* MediaTypeSnifferContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaTypeSnifferContext.swift; sourceTree = ""; }; 305833C6F16FAB2E23F40382 /* PDFSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFSettings.swift; sourceTree = ""; }; + 3230FB63D7ADDD514D74F7E6 /* LCPLicenseRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCPLicenseRepository.swift; sourceTree = ""; }; 3231F989F7D7E560DD5364B9 /* Range.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Range.swift; sourceTree = ""; }; 339637CCF01E665F4CB78B01 /* EPUBLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBLayout.swift; sourceTree = ""; }; - 33C422C1CFB72372FC343AE4 /* ContentProtectionService+WS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentProtectionService+WS.swift"; sourceTree = ""; }; 33FD18E1CF87271DA6A6A783 /* Connection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connection.swift; sourceTree = ""; }; 342D5C0FEE79A2ABEE24A43E /* CoreServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreServices.framework; path = System/Library/Frameworks/CoreServices.framework; sourceTree = SDKROOT; }; 34AB954525AC159166C96A36 /* HTMLResourceContentIterator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLResourceContentIterator.swift; sourceTree = ""; }; @@ -513,12 +513,14 @@ 38984FD65CFF1D54FF7F794F /* ReadiumLocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadiumLocalizedString.swift; sourceTree = ""; }; 3B0A149FC97C747F55F6463C /* PublicationCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicationCollection.swift; sourceTree = ""; }; 3B1597E4216CF16380AC2811 /* GCDHTTPServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GCDHTTPServer.swift; sourceTree = ""; }; + 3C61B620DE6C012805269111 /* LCPAcquiredPublication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCPAcquiredPublication.swift; sourceTree = ""; }; 3D33BD0E923EACCCDB91362C /* ManifestTransformer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManifestTransformer.swift; sourceTree = ""; }; 3DA7FFAA3EA2B45961391DDF /* HTTPError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPError.swift; sourceTree = ""; }; 3DF324AD5E3E30687AC5262D /* PDFTapGestureController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFTapGestureController.swift; sourceTree = ""; }; 3DFAC865449A1A225BF534DA /* OPDSAcquisition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPDSAcquisition.swift; sourceTree = ""; }; 3EC9BDFB5AC6D5E7FC8F6A4C /* LCPLLicenseContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCPLLicenseContainer.swift; sourceTree = ""; }; 3F95F3F20D758BE0E7005EA3 /* DifferenceKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = DifferenceKit.xcframework; path = ../../Carthage/Build/DifferenceKit.xcframework; sourceTree = ""; }; + 3FD12CFF76C3F2946929CF93 /* Result.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Result.swift; sourceTree = ""; }; 4031FC7E7A15217731764EB2 /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; 419064D714A90CE07D575629 /* PublicationAsset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicationAsset.swift; sourceTree = ""; }; 41B61198128D628CFB3FD22A /* DiffableDecoration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffableDecoration.swift; sourceTree = ""; }; @@ -602,6 +604,7 @@ 7C28B8CD48F8A660141F5983 /* DefaultLocatorService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultLocatorService.swift; sourceTree = ""; }; 7C3A9CF25E925418A1712C0B /* LazyResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyResource.swift; sourceTree = ""; }; 7EE333717736247C6F846CEF /* AudioPublicationManifestAugmentor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPublicationManifestAugmentor.swift; sourceTree = ""; }; + 7FCA24A94D6376487FECAEF1 /* LCPPassphraseRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCPPassphraseRepository.swift; sourceTree = ""; }; 819D931708B3EE95CF9ADFED /* OPDSCopies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPDSCopies.swift; sourceTree = ""; }; 8240F845F35439807CE8AF65 /* ContentProtectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentProtectionService.swift; sourceTree = ""; }; 8456BF3665A9B9C0AE4CC158 /* Locator+HTML.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Locator+HTML.swift"; sourceTree = ""; }; @@ -618,6 +621,7 @@ 8D187A577EBFCFF738D1CDC7 /* ZIPFoundation.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = ZIPFoundation.xcframework; path = ../../Carthage/Build/ZIPFoundation.xcframework; sourceTree = ""; }; 8DA31089FCAD8DFB9AC46E4E /* Tokenizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tokenizer.swift; sourceTree = ""; }; 90AE9BB78C8A3FA5708F6AE6 /* Resource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Resource.swift; sourceTree = ""; }; + 91F34B9B08BC6FB84CE54A26 /* LCPProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCPProgress.swift; sourceTree = ""; }; 925CDE3176715EBEBF40B21F /* GeneratedCoverService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneratedCoverService.swift; sourceTree = ""; }; 93BF3947EBA8736BF20F36FB /* WebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = ""; }; 9407E818636BEA4550E57F57 /* ReadiumNavigator.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = ReadiumNavigator.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -656,6 +660,7 @@ A8F9AFE740CFFFAD65BA095E /* ContentKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentKey.swift; sourceTree = ""; }; A90EA81ECD9488CB3CBDAB41 /* Archive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Archive.swift; sourceTree = ""; }; A94DA04D56753CC008F65B1A /* VisualNavigator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualNavigator.swift; sourceTree = ""; }; + AAC626641AB22732DC4B64C6 /* SQLiteLCPPassphraseRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLiteLCPPassphraseRepository.swift; sourceTree = ""; }; AB0EF21FADD12D51D0619C0D /* LinkRelation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkRelation.swift; sourceTree = ""; }; AB1F7BC3EC3419CB824E3A70 /* ProxyFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyFetcher.swift; sourceTree = ""; }; AB3E08C8187DCC3099CF9D22 /* Range.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Range.swift; sourceTree = ""; }; @@ -668,7 +673,6 @@ B0276C0D645E8013EE0F86FA /* ZIPInputStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZIPInputStream.swift; sourceTree = ""; }; B1085F2D690A73984E675D54 /* ParseData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseData.swift; sourceTree = ""; }; B15C9123EA383ED81DE0393A /* AVTTSEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVTTSEngine.swift; sourceTree = ""; }; - B15EC41FF314ABF15AB25CAC /* DeviceRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRepository.swift; sourceTree = ""; }; B1A266613929852C4DC2361A /* URLExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLExtensions.swift; sourceTree = ""; }; B2C9762191DAD823E7C925A5 /* DataExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataExtension.swift; sourceTree = ""; }; B421601FB56132514CCD9699 /* Fuzi.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = Fuzi.xcframework; path = ../../Carthage/Build/Fuzi.xcframework; sourceTree = ""; }; @@ -715,7 +719,6 @@ DBAE529EA7B2381BB8762472 /* AudioPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPreferences.swift; sourceTree = ""; }; DBCE9786DD346E6BDB2E50FF /* Assets */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Assets; path = ../../Sources/Streamer/Assets; sourceTree = SOURCE_ROOT; }; DCE34D74E282834684E1C999 /* AudioNavigator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioNavigator.swift; sourceTree = ""; }; - DD8FA524D4C8D19FBDDE23F5 /* HTTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPClient.swift; sourceTree = ""; }; DF89316F77F23DACA2E04696 /* PDFDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFDocument.swift; sourceTree = ""; }; DF92954C8C8C3EC50C835CBA /* Link.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Link.swift; sourceTree = ""; }; E0E6147EF790DE532CE1699D /* CSSProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSSProperties.swift; sourceTree = ""; }; @@ -729,7 +732,6 @@ E6CA450B17BF2F7FDFA4471C /* TransformingResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransformingResource.swift; sourceTree = ""; }; E6E97CCA91F910315C260373 /* ReadiumWebPubParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadiumWebPubParser.swift; sourceTree = ""; }; E76DFDE600369E9D3EF452E1 /* DownloadSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadSession.swift; sourceTree = ""; }; - E8C7C39F6E671BB20F2EB351 /* Transactions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Transactions.swift; sourceTree = ""; }; E8D7AF06866C53D07E094337 /* ResourcesServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourcesServer.swift; sourceTree = ""; }; EC329362A0E8AC6CC018452A /* ReadiumOPDS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = ReadiumOPDS.framework; sourceTree = BUILT_PRODUCTS_DIR; }; EC59A963F316359DF8B119AC /* Metadata+Presentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Metadata+Presentation.swift"; sourceTree = ""; }; @@ -737,7 +739,6 @@ EC96A56AB406203898059B6C /* UserKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserKey.swift; sourceTree = ""; }; EC9ACC1EB3903149EBF21BC0 /* EPUBNavigatorViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBNavigatorViewModel.swift; sourceTree = ""; }; ECF32CF55DD942ACB06389C5 /* Preference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preference.swift; sourceTree = ""; }; - ED568512FD1304D6B9CC79B0 /* Licenses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Licenses.swift; sourceTree = ""; }; EDA827FC94F5CB3F9032028F /* JSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSON.swift; sourceTree = ""; }; EE3E6442F0C7FE2098D71F27 /* Link.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Link.swift; sourceTree = ""; }; EE7B762C97CFC214997EC677 /* Weak.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Weak.swift; sourceTree = ""; }; @@ -745,7 +746,6 @@ EF99DAF66659A218CEC25EAE /* EPUBFixedSpreadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBFixedSpreadView.swift; sourceTree = ""; }; F07214E263C6589987A561F9 /* SQLite.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = SQLite.xcframework; path = ../../Carthage/Build/SQLite.xcframework; sourceTree = ""; }; F1A5323A428424868B1FDAD5 /* MediaTypeSniffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaTypeSniffer.swift; sourceTree = ""; }; - F1CBEFCBEB8C144A4429C2E9 /* LicensesRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LicensesRepository.swift; sourceTree = ""; }; F1F5FEE0323287B9CAA09F03 /* MediaOverlays.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaOverlays.swift; sourceTree = ""; }; F28FCF8F6D010982BAE858FD /* Minizip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Minizip.swift; sourceTree = ""; }; F2E780027410F4B6CC872B3D /* OPDSAvailability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPDSAvailability.swift; sourceTree = ""; }; @@ -924,11 +924,8 @@ isa = PBXGroup; children = ( D93B0556DAAAF429893B0692 /* CRLService.swift */, - B15EC41FF314ABF15AB25CAC /* DeviceRepository.swift */, 616C70674FBF020FE4607617 /* DeviceService.swift */, - F1CBEFCBEB8C144A4429C2E9 /* LicensesRepository.swift */, 56286133DD0AE093F2C5E9FD /* LicensesService.swift */, - 2C8CDB4833C705FC1D986679 /* PassphrasesRepository.swift */, 606416A552192BF66FBDF3C2 /* PassphrasesService.swift */, ); path = Services; @@ -1150,6 +1147,7 @@ 0BA3A3154558D5A169F52911 /* Date+ISO8601.swift */, 2F3481F848A616A9A825A4BD /* Double.swift */, 714F1696AC76F6AFEA1924D5 /* NSRegularExpression.swift */, + 3FD12CFF76C3F2946929CF93 /* Result.swift */, 4031FC7E7A15217731764EB2 /* String.swift */, 388D85FB7475709CB6CEA59E /* URL.swift */, ); @@ -1306,8 +1304,8 @@ children = ( 33FD18E1CF87271DA6A6A783 /* Connection.swift */, 11252900E9B0827C0FD2FA4B /* Database.swift */, - ED568512FD1304D6B9CC79B0 /* Licenses.swift */, - E8C7C39F6E671BB20F2EB351 /* Transactions.swift */, + 270098AF39D824CC69AAFB3B /* SQLiteLCPLicenseRepository.swift */, + AAC626641AB22732DC4B64C6 /* SQLiteLCPPassphraseRepository.swift */, ); path = Persistence; sourceTree = ""; @@ -1481,7 +1479,6 @@ isa = PBXGroup; children = ( 8240F845F35439807CE8AF65 /* ContentProtectionService.swift */, - 33C422C1CFB72372FC343AE4 /* ContentProtectionService+WS.swift */, 567C115FF0939F69AD83AE82 /* UserRights.swift */, ); path = "Content Protection"; @@ -1554,7 +1551,6 @@ F64FBE3CA5C1B0C73A22E86D /* Bundle.swift */, 1EBC685D4A0E07997088DD2D /* DataCompression.swift */, 10CFCE63856A801FB14A0633 /* Deferred.swift */, - DD8FA524D4C8D19FBDDE23F5 /* HTTPClient.swift */, 9883F57707AC488197F4312E /* ReadiumLCPLocalizedString.swift */, ); path = Toolkit; @@ -1681,10 +1677,14 @@ D4358DF9D15D9ADE4F9E8BE4 /* LCP */ = { isa = PBXGroup; children = ( + 3C61B620DE6C012805269111 /* LCPAcquiredPublication.swift */, F622773881411FB8BE686B9F /* LCPAcquisition.swift */, A214B5DC13576FB36935B5EA /* LCPClient.swift */, A5A115134AA0B8F5254C8139 /* LCPError.swift */, 093629E752DE17264B97C598 /* LCPLicense.swift */, + 3230FB63D7ADDD514D74F7E6 /* LCPLicenseRepository.swift */, + 7FCA24A94D6376487FECAEF1 /* LCPPassphraseRepository.swift */, + 91F34B9B08BC6FB84CE54A26 /* LCPProgress.swift */, 230985A228FA74F24735D6BB /* LCPRenewDelegate.swift */, 67DEBFCD9D71243C4ACC3A49 /* LCPService.swift */, 7F42F058A2DC364B554BF7F2 /* Authentications */, @@ -2252,6 +2252,7 @@ 674BEEF110667C3051296E9B /* Double.swift in Sources */, DDD0C8AC27EF8D1A893DF6CC /* JSON.swift in Sources */, F631EA324143E669070523F3 /* NSRegularExpression.swift in Sources */, + BA6B358A48C437B6A783D018 /* Result.swift in Sources */, AD33B3EB90259AD34C6303A5 /* String.swift in Sources */, 3C4847FD7D5C5ABCF71A3E7B /* URL.swift in Sources */, ); @@ -2268,12 +2269,11 @@ 81ADB258F083647221CED24F /* DataCompression.swift in Sources */, 9A22C456F6A73F29AD9B0CE8 /* Database.swift in Sources */, E7D731030584957DAD52683C /* Deferred.swift in Sources */, - 5718571D121C8CBF45277A0D /* DeviceRepository.swift in Sources */, 294217B18570409AB1C317AD /* DeviceService.swift in Sources */, DC0487666F03A3FAFE49D0B9 /* EPUBLicenseContainer.swift in Sources */, E8293787CB5E5CECE38A63B2 /* Encryption.swift in Sources */, 1BF9469B4574D30E5C9BB75E /* Event.swift in Sources */, - E356D67B77C65D294F60D58A /* HTTPClient.swift in Sources */, + 3B5A8A76665391D2D32CB012 /* LCPAcquiredPublication.swift in Sources */, 6719F981514309A65D206A85 /* LCPAcquisition.swift in Sources */, 837C0BC3151E302508B4BC44 /* LCPAuthenticating.swift in Sources */, A13490DA4406382752B8EA2B /* LCPClient.swift in Sources */, @@ -2285,27 +2285,28 @@ 98702AFB56F9C50F7246CDDA /* LCPError.swift in Sources */, 6FFC08925BF26902CF49B830 /* LCPLLicenseContainer.swift in Sources */, C4F0A98562FDDB478F7DD0A9 /* LCPLicense.swift in Sources */, + 9A463F872E1B05B64E026EBB /* LCPLicenseRepository.swift in Sources */, F90CF6CE1D4F5FA195E19D76 /* LCPPassphraseAuthentication.swift in Sources */, + 349F6BB9FDD28532C2B030EC /* LCPPassphraseRepository.swift in Sources */, + 2EEC1F0DF4BA4B8B1820FF9B /* LCPProgress.swift in Sources */, AD87094AA40926939955E9F2 /* LCPRenewDelegate.swift in Sources */, 06CF9F75A9DB1B6241CA7719 /* LCPService.swift in Sources */, C283E515CA6A8EEA1C89AD98 /* License.swift in Sources */, 4AD286114A634A74BE78B1A0 /* LicenseContainer.swift in Sources */, BB9DFD1B35AF515BB1B05B9D /* LicenseDocument.swift in Sources */, 1221E200A377D294050B8F00 /* LicenseValidation.swift in Sources */, - C3BEB5CC9C6DD065B2CAE1BE /* Licenses.swift in Sources */, - 51A01B251C751D8F817E2EF8 /* LicensesRepository.swift in Sources */, 90CFD62B993F6759716C0AF0 /* LicensesService.swift in Sources */, 44152DBECE34F063AD0E93BC /* Link.swift in Sources */, 92570B878B678E9E9138C94F /* Links.swift in Sources */, - 22BB9F4F0A3D2B9CA3D9BD0D /* PassphrasesRepository.swift in Sources */, 2207C27B96F098AAF8B31F2C /* PassphrasesService.swift in Sources */, BAC8616BD37C22BC5541959A /* PotentialRights.swift in Sources */, 969961137E590BAEFBEB9CAB /* ReadiumLCPLocalizedString.swift in Sources */, 7F297EC335D8934E50361D39 /* ReadiumLicenseContainer.swift in Sources */, 6FEE606C7126F68B5018CAD0 /* Rights.swift in Sources */, + C3EA446FD874A8FD6C84F5E2 /* SQLiteLCPLicenseRepository.swift in Sources */, + DFC94F7A7818F35247031037 /* SQLiteLCPPassphraseRepository.swift in Sources */, 21B27CD89562506DDC1D62D1 /* Signature.swift in Sources */, 077AD829863BD952DEBFB5A0 /* StatusDocument.swift in Sources */, - 5803D95A1D970EB0F5D24584 /* Transactions.swift in Sources */, 18217BC157557A5DDA4BA119 /* User.swift in Sources */, 69AA254E4A39D9B49FDFD648 /* UserKey.swift in Sources */, D50FE2B82BB34E2881723BE9 /* ZIPLicenseContainer.swift in Sources */, @@ -2343,7 +2344,6 @@ 25D4D9FFA2286E7FCB1C44E9 /* CancellableResult.swift in Sources */, 0F1AAB56A6ADEDDE2AD7E41E /* Content.swift in Sources */, 6A657BF7E5D33E9A87147A39 /* ContentProtection.swift in Sources */, - DD09A1F641C21055E45809D2 /* ContentProtectionService+WS.swift in Sources */, 97FB26E379FB3DFB5AAA7BA1 /* ContentProtectionService.swift in Sources */, 0B9AC6EF44DA518E9F37FB49 /* ContentService.swift in Sources */, 5F0FFFDC1DF711F1C07194B3 /* ContentTokenizer.swift in Sources */, diff --git a/TestApp/Sources/App/AppModule.swift b/TestApp/Sources/App/AppModule.swift index 3def5ed0e..db3875b6e 100644 --- a/TestApp/Sources/App/AppModule.swift +++ b/TestApp/Sources/App/AppModule.swift @@ -29,8 +29,8 @@ final class AppModule { init() throws { let httpClient = DefaultHTTPClient() - let file = Paths.library.appendingPathComponent("database.db") - let db = try Database(file: file) + let file = Paths.library.appendingPath("database.db", isDirectory: false) + let db = try Database(file: file.url) print("Created database at \(file.path)") let books = BookRepository(db: db) @@ -86,7 +86,9 @@ extension AppModule: ReaderModuleDelegate {} extension AppModule: OPDSModuleDelegate { func opdsDownloadPublication(_ publication: Publication?, at link: Link, sender: UIViewController) async throws -> Book { - let url = try link.url(relativeTo: publication?.baseURL) - return try await library.importPublication(from: url.url, sender: sender) + guard let url = try link.url(relativeTo: publication?.baseURL).absoluteURL else { + throw OPDSError.invalidURL(link.href) + } + return try await library.importPublication(from: url, sender: sender) } } diff --git a/TestApp/Sources/AppDelegate.swift b/TestApp/Sources/AppDelegate.swift index 6c79ecd58..a799e347f 100644 --- a/TestApp/Sources/AppDelegate.swift +++ b/TestApp/Sources/AppDelegate.swift @@ -5,6 +5,7 @@ // import Combine +import ReadiumShared import UIKit @UIApplicationMain @@ -52,9 +53,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } func application(_ application: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + guard let url = url.absoluteURL else { + return false + } + Task { try! await app.library.importPublication(from: url, sender: window!.rootViewController!) } + return true } } diff --git a/TestApp/Sources/Common/Paths.swift b/TestApp/Sources/Common/Paths.swift index c5799178c..69e3bb70e 100644 --- a/TestApp/Sources/Common/Paths.swift +++ b/TestApp/Sources/Common/Paths.swift @@ -11,27 +11,27 @@ import ReadiumShared final class Paths { private init() {} - static let home: URL = - .init(fileURLWithPath: NSHomeDirectory(), isDirectory: true) + static let home: FileURL = + URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true).fileURL! - static let temporary: URL = - .init(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + static let temporary: FileURL = + URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true).fileURL! - static let documents: URL = - FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + static let documents: FileURL = + FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.fileURL! - static let library: URL = - FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first! + static let library: FileURL = + FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first!.fileURL! - static let covers: URL = { - let url = library.appendingPathComponent("Covers") - try! FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + static let covers: FileURL = { + let url = library.appendingPath("Covers", isDirectory: true) + try! FileManager.default.createDirectory(at: url.url, withIntermediateDirectories: true) return url }() - static func makeDocumentURL(for source: URL? = nil, title: String?, mediaType: MediaType) -> URL { + static func makeDocumentURL(for source: FileURL? = nil, title: String?, mediaType: MediaType) -> FileURL { // Is the file already in Documents/? - if let source = source, source.standardizedFileURL.deletingLastPathComponent() == documents.standardizedFileURL { + if let source = source, documents.isParent(of: source) { return source } else { let title = title.takeIf { !$0.isEmpty } ?? UUID().uuidString @@ -41,12 +41,42 @@ final class Paths { } } - static func makeTemporaryURL() -> URL { + static func makeTemporaryURL() -> FileURL { temporary.appendingUniquePathComponent() } /// Returns whether the given `url` locates a file that is under the app's home directory. - static func isAppFile(at url: URL) -> Bool { - home.isParentOf(url) + static func isAppFile(at url: FileURL) -> Bool { + home.isParent(of: url) + } +} + +extension FileURL { + func appendingUniquePathComponent(_ pathComponent: String? = nil) -> FileURL { + /// Returns the first path component matching the given `validation` closure. + /// Numbers are appended to the path component until a valid candidate is found. + func uniquify(_ pathComponent: String?, validation: (String) -> Bool) -> String { + let pathComponent = pathComponent ?? UUID().uuidString + var ext = (pathComponent as NSString).pathExtension + if !ext.isEmpty { + ext = ".\(ext)" + } + let pathComponentWithoutExtension = (pathComponent as NSString).deletingPathExtension + + var candidate = pathComponent + var i = 0 + while !validation(candidate) { + i += 1 + candidate = "\(pathComponentWithoutExtension) \(i)\(ext)" + } + return candidate + } + + let pathComponent = uniquify(pathComponent) { candidate in + let destination = appendingPath(candidate, isDirectory: false) + return !((try? destination.url.checkResourceIsReachable()) ?? false) + } + + return appendingPath(pathComponent, isDirectory: false) } } diff --git a/TestApp/Sources/Common/Toolkit/Extensions/HTTPClient.swift b/TestApp/Sources/Common/Toolkit/Extensions/HTTPClient.swift deleted file mode 100644 index 72105615e..000000000 --- a/TestApp/Sources/Common/Toolkit/Extensions/HTTPClient.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// Copyright 2024 Readium Foundation. All rights reserved. -// Use of this source code is governed by the BSD-style license -// available in the top-level LICENSE file of the project. -// - -import Combine -import Foundation -import ReadiumShared - -struct HTTPDownload { - let file: URL - let response: HTTPResponse -} - -extension HTTPClient { - func fetch(_ request: HTTPRequestConvertible) -> AnyPublisher { - var cancellable: ReadiumShared.Cancellable? = nil - return Future { promise in - cancellable = self.fetch(request, completion: promise) - } - .handleEvents(receiveCancel: { cancellable?.cancel() }) - .eraseToAnyPublisher() - } - - func download(_ request: HTTPRequestConvertible, progress: @escaping (Double) -> Void) async throws -> HTTPDownload { - try await withCheckedThrowingContinuation { cont in - do { - let (destination, handle) = try openTemporaryFileForWriting() - var cancellable: ReadiumShared.Cancellable? = nil - - cancellable = stream(request, - consume: { data, progression in - if Task.isCancelled { - cancellable?.cancel() - try? handle.close() - try? FileManager.default.removeItem(at: destination) - return - } - if let progression = progression { - progress(progression) - } - handle.write(data) - }, - completion: { result in - do { - try handle.close() - try cont.resume(returning: HTTPDownload(file: destination, response: result.get())) - } catch { - try? FileManager.default.removeItem(at: destination) - cont.resume(throwing: HTTPError(error: error)) - } - }) - } catch { - cont.resume(throwing: HTTPError(error: error)) - } - } - } - - private func openTemporaryFileForWriting() throws -> (URL, FileHandle) { - let destination = Paths.makeTemporaryURL() - // Makes sure the file exists. - try "".write(to: destination, atomically: true, encoding: .utf8) - do { - let handle = try FileHandle(forWritingTo: destination) - return (destination, handle) - } catch { - throw HTTPError(kind: .other, cause: error) - } - } -} diff --git a/TestApp/Sources/Data/Book.swift b/TestApp/Sources/Data/Book.swift index 9cbd7b549..1360677aa 100644 --- a/TestApp/Sources/Data/Book.swift +++ b/TestApp/Sources/Data/Book.swift @@ -65,8 +65,8 @@ struct Book: Codable { self.preferencesJSON = preferencesJSON } - var cover: URL? { - coverPath.map { Paths.covers.appendingPathComponent($0) } + var cover: FileURL? { + coverPath.map { Paths.covers.appendingPath($0, isDirectory: false) } } func preferences() throws -> P? { diff --git a/TestApp/Sources/Library/DRM/DRMLibraryService.swift b/TestApp/Sources/Library/DRM/DRMLibraryService.swift index 813978005..fc9d4428f 100644 --- a/TestApp/Sources/Library/DRM/DRMLibraryService.swift +++ b/TestApp/Sources/Library/DRM/DRMLibraryService.swift @@ -9,7 +9,7 @@ import Foundation import ReadiumShared struct DRMFulfilledPublication { - let localURL: URL + let localURL: FileURL let suggestedFilename: String } @@ -19,8 +19,8 @@ protocol DRMLibraryService { var contentProtection: ContentProtection? { get } /// Returns whether this DRM can fulfill the given file into a protected publication. - func canFulfill(_ file: URL) -> Bool + func canFulfill(_ file: FileURL) -> Bool /// Fulfills the given file to the fully protected publication. - func fulfill(_ file: URL) async throws -> DRMFulfilledPublication? + func fulfill(_ file: FileURL) async throws -> DRMFulfilledPublication? } diff --git a/TestApp/Sources/Library/DRM/LCPLibraryService.swift b/TestApp/Sources/Library/DRM/LCPLibraryService.swift index 587e45dc3..68fe579ba 100644 --- a/TestApp/Sources/Library/DRM/LCPLibraryService.swift +++ b/TestApp/Sources/Library/DRM/LCPLibraryService.swift @@ -14,42 +14,37 @@ import UIKit class LCPLibraryService: DRMLibraryService { - private var lcpService = LCPService(client: LCPClient()) + private var lcpService = LCPService( + client: LCPClient(), + licenseRepository: SQLiteLCPLicenseRepository(), + passphraseRepository: SQLiteLCPPassphraseRepository(), + httpClient: DefaultHTTPClient() + ) lazy var contentProtection: ContentProtection? = lcpService.contentProtection() - func canFulfill(_ file: URL) -> Bool { - file.pathExtension.lowercased() == "lcpl" + func canFulfill(_ file: FileURL) -> Bool { + file.pathExtension?.lowercased() == "lcpl" } - func fulfill(_ file: URL) async throws -> DRMFulfilledPublication? { - try await withCheckedThrowingContinuation { cont in - lcpService.acquirePublication(from: FileURL(url: file)!) { result in - // Removes the license file, but only if it's in the App directory (e.g. Inbox/). - // Otherwise we might delete something from a shared location (e.g. iCloud). - if Paths.isAppFile(at: file) { - try? FileManager.default.removeItem(at: file) - } - - switch result { - case let .success(pub): - cont.resume(returning: DRMFulfilledPublication( - localURL: pub.localURL.url, - suggestedFilename: pub.suggestedFilename - )) - case let .failure(error): - cont.resume(throwing: error) - case .cancelled: - cont.resume(returning: nil) - } - } + func fulfill(_ file: FileURL) async throws -> DRMFulfilledPublication? { + let pub = try await lcpService.acquirePublication(from: file).get() + // Removes the license file, but only if it's in the App directory (e.g. Inbox/). + // Otherwise we might delete something from a shared location (e.g. iCloud). + if Paths.isAppFile(at: file) { + try? FileManager.default.removeItem(at: file.url) } + + return DRMFulfilledPublication( + localURL: pub.localURL, + suggestedFilename: pub.suggestedFilename + ) } } /// Facade to the private R2LCPClient.framework. class LCPClient: ReadiumLCP.LCPClient { - func createContext(jsonLicense: String, hashedPassphrase: String, pemCrl: String) throws -> LCPClientContext { + func createContext(jsonLicense: String, hashedPassphrase: LCPPassphraseHash, pemCrl: String) throws -> LCPClientContext { try R2LCPClient.createContext(jsonLicense: jsonLicense, hashedPassphrase: hashedPassphrase, pemCrl: pemCrl) } @@ -57,7 +52,7 @@ R2LCPClient.decrypt(data: data, using: context as! DRMContext) } - func findOneValidPassphrase(jsonLicense: String, hashedPassphrases: [String]) -> String? { + func findOneValidPassphrase(jsonLicense: String, hashedPassphrases: [LCPPassphraseHash]) -> LCPPassphraseHash? { R2LCPClient.findOneValidPassphrase(jsonLicense: jsonLicense, hashedPassphrases: hashedPassphrases) } } diff --git a/TestApp/Sources/Library/LibraryError.swift b/TestApp/Sources/Library/LibraryError.swift index 4accfe732..27e185113 100644 --- a/TestApp/Sources/Library/LibraryError.swift +++ b/TestApp/Sources/Library/LibraryError.swift @@ -13,7 +13,7 @@ enum LibraryError: LocalizedError { case bookDeletionFailed(Error?) case importFailed(Error) case openFailed(Error) - case downloadFailed(Error) + case downloadFailed(Error?) case cancelled var errorDescription: String? { @@ -27,7 +27,7 @@ enum LibraryError: LocalizedError { case let .openFailed(error): return String(format: NSLocalizedString("library_error_openFailed", comment: "Error message used when a low-level error occured while opening a publication"), error.localizedDescription) case let .downloadFailed(error): - return String(format: NSLocalizedString("library_error_downloadFailed", comment: "Error message when the download of a publication failed"), error.localizedDescription) + return String(format: NSLocalizedString("library_error_downloadFailed", comment: "Error message when the download of a publication failed"), error?.localizedDescription ?? "None") default: return nil } diff --git a/TestApp/Sources/Library/LibraryModule.swift b/TestApp/Sources/Library/LibraryModule.swift index fbdbb6b8e..9b432a2ba 100644 --- a/TestApp/Sources/Library/LibraryModule.swift +++ b/TestApp/Sources/Library/LibraryModule.swift @@ -21,7 +21,7 @@ protocol LibraryModuleAPI { /// Imports a new publication to the library, either from: /// - a local file URL /// - a remote URL which will be downloaded - func importPublication(from url: URL, sender: UIViewController) async throws -> Book + func importPublication(from url: AbsoluteURL, sender: UIViewController) async throws -> Book } protocol LibraryModuleDelegate: ModuleDelegate { @@ -50,7 +50,7 @@ final class LibraryModule: LibraryModuleAPI { return library }() - func importPublication(from url: URL, sender: UIViewController) async throws -> Book { + func importPublication(from url: AbsoluteURL, sender: UIViewController) async throws -> Book { try await library.importPublication(from: url, sender: sender) } } diff --git a/TestApp/Sources/Library/LibraryService.swift b/TestApp/Sources/Library/LibraryService.swift index b6faeaa82..da61175e5 100644 --- a/TestApp/Sources/Library/LibraryService.swift +++ b/TestApp/Sources/Library/LibraryService.swift @@ -54,8 +54,8 @@ final class LibraryService: Loggable { } /// Opens the Readium 2 Publication at the given `url`. - private func openPublication(at url: URL, allowUserInteraction: Bool, sender: UIViewController?) async throws -> (Publication, MediaType) { - let asset = FileAsset(file: FileURL(url: url)!) + private func openPublication(at url: FileURL, allowUserInteraction: Bool, sender: UIViewController?) async throws -> (Publication, MediaType) { + let asset = FileAsset(file: url) guard let mediaType = asset.mediaType() else { throw LibraryError.openFailed(Publication.OpeningError.unsupportedFormat) } @@ -90,6 +90,9 @@ final class LibraryService: Loggable { /// Imports a bunch of publications. func importPublications(from sourceURLs: [URL], sender: UIViewController) async throws { for url in sourceURLs { + guard let url = url.absoluteURL else { + continue + } try await importPublication(from: url, sender: sender) } } @@ -102,12 +105,12 @@ final class LibraryService: Loggable { /// DRM services are used to fulfill the publication, in case the URL locates a licensing /// document. @discardableResult - func importPublication(from sourceURL: URL, sender: UIViewController, progress: @escaping (Double) -> Void = { _ in }) async throws -> Book { + func importPublication(from sourceURL: AbsoluteURL, sender: UIViewController, progress: @escaping (Double) -> Void = { _ in }) async throws -> Book { // Necessary to read URL exported from the Files app, for example. - let shouldRelinquishAccess = sourceURL.startAccessingSecurityScopedResource() + let shouldRelinquishAccess = sourceURL.url.startAccessingSecurityScopedResource() defer { if shouldRelinquishAccess { - sourceURL.stopAccessingSecurityScopedResource() + sourceURL.url.stopAccessingSecurityScopedResource() } } @@ -117,27 +120,33 @@ final class LibraryService: Loggable { let coverPath = try importCover(of: pub) url = try moveToDocuments( from: url, - title: pub.metadata.title ?? url.lastPathComponent, + title: pub.metadata.title ?? url.lastPathSegment, mediaType: mediaType ) return try await insertBook(at: url, publication: pub, mediaType: mediaType, coverPath: coverPath) } - /// Downloads `sourceURL` if it locates a remote file. - private func downloadIfNeeded(_ url: URL, progress: @escaping (Double) -> Void) async throws -> URL { - guard !url.isFileURL, url.scheme != nil else { + /// Downloads `url` if it locates a remote file. + private func downloadIfNeeded(_ url: AbsoluteURL, progress: @escaping (Double) -> Void) async throws -> FileURL { + if let url = url.fileURL { return url + } else if let url = url.httpURL { + return try await download(url, progress: progress) + } else { + throw LibraryError.downloadFailed(nil) } + } + private func download(_ url: HTTPURL, progress: @escaping (Double) -> Void) async throws -> FileURL { do { - return try await httpClient.download(url, progress: progress).file + return try await httpClient.download(url, onProgress: progress).get().location } catch { throw LibraryError.downloadFailed(error) } } /// Fulfills the given `url` if it's a DRM license file. - private func fulfillIfNeeded(_ url: URL) async throws -> URL { + private func fulfillIfNeeded(_ url: FileURL) async throws -> FileURL { guard let drmService = drmLibraryServices.first(where: { $0.canFulfill(url) }) else { return url } @@ -154,16 +163,16 @@ final class LibraryService: Loggable { } /// Moves the given `sourceURL` to the user Documents/ directory. - private func moveToDocuments(from source: URL, title: String, mediaType: MediaType) throws -> URL { + private func moveToDocuments(from source: FileURL, title: String, mediaType: MediaType) throws -> FileURL { let destination = Paths.makeDocumentURL(title: title, mediaType: mediaType) do { // If the source file is part of the app folder, we can move it. Otherwise we make a // copy, to avoid deleting files from iCloud, for example. if Paths.isAppFile(at: source) { - try FileManager.default.moveItem(at: source, to: destination) + try FileManager.default.moveItem(at: source.url, to: destination.url) } else { - try FileManager.default.copyItem(at: source, to: destination) + try FileManager.default.copyItem(at: source.url, to: destination.url) } return destination } catch { @@ -179,23 +188,23 @@ final class LibraryService: Loggable { let coverURL = Paths.covers.appendingUniquePathComponent() do { - try cover.write(to: coverURL) - return coverURL.lastPathComponent + try cover.write(to: coverURL.url) + return coverURL.lastPathSegment } catch { throw LibraryError.importFailed(error) } } /// Inserts the given `book` in the bookshelf. - private func insertBook(at url: URL, publication: Publication, mediaType: MediaType, coverPath: String?) async throws -> Book { + private func insertBook(at url: FileURL, publication: Publication, mediaType: MediaType, coverPath: String?) async throws -> Book { let book = Book( identifier: publication.metadata.identifier, - title: publication.metadata.title ?? url.lastPathComponent, + title: publication.metadata.title ?? url.lastPathSegment, authors: publication.metadata.authors .map(\.name) .joined(separator: ", "), type: mediaType.string, - path: (url.isFileURL || url.scheme == nil) ? url.lastPathComponent : url.absoluteString, + path: Paths.documents.relativize(url)!.string, coverPath: coverPath ) @@ -233,12 +242,12 @@ final class LibraryService: Loggable { } } - private func removeBookFile(at url: URL) throws { - guard Paths.documents.isParentOf(url) else { + private func removeBookFile(at url: FileURL) throws { + guard Paths.documents.isParent(of: url) else { return } do { - try FileManager.default.removeItem(at: url) + try FileManager.default.removeItem(at: url.url) } catch { throw LibraryError.bookDeletionFailed(error) } @@ -246,30 +255,24 @@ final class LibraryService: Loggable { } private extension Book { - func url() throws -> URL { - // Absolute URL. - if let url = URL(string: path), url.scheme != nil { - return url + func url() throws -> FileURL { + guard let url = AnyURL(string: path) else { + throw LibraryError.bookNotFound } - // Absolute file path. - if path.hasPrefix("/") { - return URL(fileURLWithPath: path) - } + switch url { + case let .absolute(url): + guard let url = url.fileURL else { + throw LibraryError.bookNotFound + } + return url - do { + case let .relative(relativeURL): // Path relative to Documents/. - let files = FileManager.default - let documents = try files.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) - - let documentURL = documents.appendingPathComponent(path) - guard (try? documentURL.checkResourceIsReachable()) == true else { + guard let url = Paths.documents.resolve(relativeURL) else { throw LibraryError.bookNotFound } - return documentURL - - } catch { - throw LibraryError.bookNotFound + return url } } } diff --git a/TestApp/Sources/Library/LibraryViewController.swift b/TestApp/Sources/Library/LibraryViewController.swift index c66337f79..5f2db63de 100644 --- a/TestApp/Sources/Library/LibraryViewController.swift +++ b/TestApp/Sources/Library/LibraryViewController.swift @@ -212,7 +212,7 @@ extension LibraryViewController: UICollectionViewDelegateFlowLayout, UICollectio // Load image and then apply the shadow. if let coverURL = book.cover, - let data = try? Data(contentsOf: coverURL), + let data = try? Data(contentsOf: coverURL.url), let cover = UIImage(data: data) { cell.coverImageView.image = cover diff --git a/TestApp/Sources/OPDS/OPDSModule.swift b/TestApp/Sources/OPDS/OPDSModule.swift index fe7640b14..2477a974c 100644 --- a/TestApp/Sources/OPDS/OPDSModule.swift +++ b/TestApp/Sources/OPDS/OPDSModule.swift @@ -9,6 +9,10 @@ import Foundation import ReadiumShared import UIKit +enum OPDSError: Error { + case invalidURL(String) +} + /// The OPDS module handles the presentation of OPDS catalogs. protocol OPDSModuleAPI { var delegate: OPDSModuleDelegate? { get } diff --git a/TestApp/Sources/Reader/Common/DRM/Base.lproj/DRM.storyboard b/TestApp/Sources/Reader/Common/DRM/Base.lproj/DRM.storyboard index 0169c5769..2056e72cd 100644 --- a/TestApp/Sources/Reader/Common/DRM/Base.lproj/DRM.storyboard +++ b/TestApp/Sources/Reader/Common/DRM/Base.lproj/DRM.storyboard @@ -1,35 +1,33 @@ - - - - + + - + - + - + - + - + - + - + - +