Skip to content

Async HTTPClient and LCP APIs #438

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
May 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions Documentation/Guides/Readium LCP.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,15 +164,15 @@ 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)
}

func decrypt(data: Data, using context: LCPClientContext) -> Data? {
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)
}
}
Expand Down
7 changes: 7 additions & 0 deletions Documentation/Migration Guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions Sources/Internal/Extensions/Result.swift
Original file line number Diff line number Diff line change
@@ -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<NewSuccess>(
_ transform: (Success) async throws -> NewSuccess
) async rethrows -> Result<NewSuccess, Failure> {
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<NewSuccess>(
_ transform: (Success) async throws -> Result<NewSuccess, Failure>
) async rethrows -> Result<NewSuccess, Failure> {
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)
}
}
}
8 changes: 7 additions & 1 deletion Sources/LCP/Authentications/LCPAuthenticating.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
24 changes: 16 additions & 8 deletions Sources/LCP/Authentications/LCPDialogAuthentication.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
9 changes: 4 additions & 5 deletions Sources/LCP/Authentications/LCPPassphraseAuthentication.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
17 changes: 8 additions & 9 deletions Sources/LCP/Content Protection/LCPContentProtection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -86,14 +87,12 @@ private final class LCPContentProtectionService: ContentProtectionService {
self.error = error
}

convenience init(result: CancellableResult<LCPLicense?, LCPError>) {
convenience init(result: Result<LCPLicense?, LCPError>) {
switch result {
case let .success(license):
self.init(license: license)
case let .failure(error):
self.init(error: error)
case .cancelled:
self.init()
}
}

Expand Down
21 changes: 21 additions & 0 deletions Sources/LCP/LCPAcquiredPublication.swift
Original file line number Diff line number Diff line change
@@ -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
}
35 changes: 6 additions & 29 deletions Sources/LCP/LCPAcquisition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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<Publication, LCPError>) -> Void

init(onProgress: @escaping (Progress) -> Void, completion: @escaping (CancellableResult<Publication, LCPError>) -> Void) {
self.onProgress = onProgress
self.completion = completion
}

func didComplete(with result: CancellableResult<Publication, LCPError>) {
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() {}
}
4 changes: 2 additions & 2 deletions Sources/LCP/LCPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 32 additions & 13 deletions Sources/LCP/LCPError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading
Loading