Skip to content
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

Add support for streaming LCP publications #557

Merged
merged 13 commits into from
Mar 7, 2025
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
24 changes: 14 additions & 10 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Changelog

All notable changes to this project will be documented in this file. Take a look at [the migration guide](Documentation/Migration%20Guide.md) to upgrade between two major versions.
All notable changes to this project will be documented in this file. Take a look at [the migration guide](docs/Migration%20Guide.md) to upgrade between two major versions.

**Warning:** Features marked as *alpha* may change or be removed in a future release without notice. Use with caution.

Expand All @@ -21,6 +21,10 @@ All notable changes to this project will be documented in this file. Take a look

* Support for streaming ZIP packages over HTTP. This lets you open a remote EPUB, audiobook, or any other ZIP-based publication without needing to download it first.

#### LCP

* Support for streaming an LCP-protected publication from its License Document (LCPL). [Take a look at the LCP guide for more information](docs/Guides/Readium%20LCP.md#streaming-an-lcp-protected-package).

### Deprecated

* The `close()` and `Closeable` APIs are now deprecated. Resources are automatically released upon `deinit`, which aligns better with Swift.
Expand All @@ -39,7 +43,7 @@ All notable changes to this project will be documented in this file. Take a look
## [3.0.0-beta.2]

* The Readium Swift toolkit now requires a minimum of iOS 13.4.
* All the libraries are now available on a dedicated [Readium CocoaPods Specs repository](https://github.com/readium/podspecs). Take a look at [the migration guide](Documentation/Migration%20Guide.md) to migrate.
* All the libraries are now available on a dedicated [Readium CocoaPods Specs repository](https://github.com/readium/podspecs). Take a look at [the migration guide](docs/Migration%20Guide.md) to migrate.

### Added

Expand Down Expand Up @@ -135,7 +139,7 @@ All notable changes to this project will be documented in this file. Take a look

#### LCP

* The Readium LCP persistence layer was extracted to allow applications to provide their own implementations. Take a look at [the migration guide](Documentation/Migration%20Guide.md) for guidance.
* The Readium LCP persistence layer was extracted to allow applications to provide their own implementations. Take a look at [the migration guide](docs/Migration%20Guide.md) for guidance.

### Fixed

Expand All @@ -158,7 +162,7 @@ All notable changes to this project will be documented in this file. Take a look
#### Shared

* `Link` and `Locator`'s `href` are normalized as valid URLs to improve interoperability with the Readium Web toolkits.
* **You MUST migrate your database if you were persisting HREFs and Locators**. Take a look at [the migration guide](Documentation/Migration%20Guide.md) for guidance.
* **You MUST migrate your database if you were persisting HREFs and Locators**. Take a look at [the migration guide](docs/Migration%20Guide.md) for guidance.
* Links are not resolved to the `self` URL of a manifest anymore. However, you can still normalize the HREFs yourselves by calling `Manifest.normalizeHREFsToSelf()`.
* `Publication.localizedTitle` is now optional, as we cannot guarantee a publication will always have a title.

Expand Down Expand Up @@ -341,8 +345,8 @@ All notable changes to this project will be documented in this file. Take a look

* New `VisualNavigatorDelegate` APIs to handle keyboard events (contributed by [@lukeslu](https://github.com/readium/swift-toolkit/pull/267)).
* This can be used to turn pages with the arrow keys, for example.
* [Support for custom fonts with the EPUB navigator](Documentation/Guides/EPUB%20Fonts.md).
* A brand new user preferences API for configuring the EPUB and PDF Navigators. This new API is easier and safer to use. To learn how to integrate it in your app, [please refer to the user guide](Documentation/Guides/Navigator%20Preferences.md) and [migration guide](Documentation/Migration%20Guide.md).
* [Support for custom fonts with the EPUB navigator](docs/Guides/EPUB%20Fonts.md).
* A brand new user preferences API for configuring the EPUB and PDF Navigators. This new API is easier and safer to use. To learn how to integrate it in your app, [please refer to the user guide](docs/Guides/Navigator%20Preferences.md) and [migration guide](docs/Migration%20Guide.md).
* New EPUB user preferences:
* `fontWeight` - Base text font weight.
* `textNormalization` - Normalize font style, weight and variants, which improves accessibility.
Expand All @@ -369,11 +373,11 @@ All notable changes to this project will be documented in this file. Take a look

#### Streamer

* `PublicationServer` is deprecated. See the [the migration guide](Documentation/Migration%20Guide.md#2.5.0) to migrate the HTTP server.
* `PublicationServer` is deprecated. See the [the migration guide](docs/Migration%20Guide.md#2.5.0) to migrate the HTTP server.

#### Navigator

* The EPUB `UserSettings` component is deprecated and replaced by the new Preferences API. [Take a look at the user guide](Documentation/Guides/Navigator%20Preferences.md) and [migration guide](Documentation/Migration%20Guide.md).
* The EPUB `UserSettings` component is deprecated and replaced by the new Preferences API. [Take a look at the user guide](docs/Guides/Navigator%20Preferences.md) and [migration guide](docs/Migration%20Guide.md).

### Changed

Expand All @@ -398,11 +402,11 @@ All notable changes to this project will be documented in this file. Take a look
#### Shared

* Support for the accessibility metadata in RWPM per [Schema.org Accessibility Properties for Discoverability Vocabulary](https://www.w3.org/2021/a11y-discov-vocab/latest/).
* [Extract the raw content (text, images, etc.) of a publication](Documentation/Guides/Content.md).
* [Extract the raw content (text, images, etc.) of a publication](docs/Guides/Content.md).

#### Navigator

* [A brand new text-to-speech implementation](Documentation/Guides/TTS.md).
* [A brand new text-to-speech implementation](docs/Guides/TTS.md).

#### Streamer

Expand Down
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ let package = Package(
.package(url: "https://github.com/ra1028/DifferenceKit.git", from: "1.3.0"),
.package(url: "https://github.com/readium/Fuzi.git", from: "4.0.0"),
.package(url: "https://github.com/readium/GCDWebServer.git", from: "4.0.0"),
.package(url: "https://github.com/readium/ZIPFoundation.git", from: "2.0.0"),
.package(url: "https://github.com/readium/ZIPFoundation.git", from: "3.0.0"),
.package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.7.0"),
.package(url: "https://github.com/stephencelis/SQLite.swift.git", from: "0.15.0"),
],
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

[Readium Mobile](https://github.com/readium/mobile) is a toolkit for ebooks, audiobooks and comics written in Swift & Kotlin.

:point_up: **Take a look at the [guide to get started](docs/Guides/Getting%20Started.md).** A [Test App](TestApp) demonstrates how to integrate the Readium Swift toolkit in your own reading app.
> [!TIP]
> **Take a look at the [guide to get started](docs/Guides/Getting%20Started.md).** A [Test App](TestApp) demonstrates how to integrate the Readium Swift toolkit in your own reading app.

This toolkit is a modular project, which follows the [Readium Architecture](https://github.com/readium/architecture).

Expand Down
11 changes: 11 additions & 0 deletions Sources/Internal/Extensions/Result.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ public extension Result {
try? get()
}

func get(or def: Success) -> Success {
(try? get()) ?? def
}

func `catch`(_ recover: (Failure) -> Self) -> Self {
if case let .failure(error) = self {
return recover(error)
}
return self
}

func eraseToAnyError() -> Result<Success, Error> {
mapError { $0 as Error }
}
Expand Down
206 changes: 182 additions & 24 deletions Sources/LCP/Content Protection/LCPContentProtection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,46 +10,131 @@ import ReadiumShared
final class LCPContentProtection: ContentProtection, Loggable {
private let service: LCPService
private let authentication: LCPAuthenticating
private let assetRetriever: AssetRetriever

init(service: LCPService, authentication: LCPAuthenticating) {
init(service: LCPService, authentication: LCPAuthenticating, assetRetriever: AssetRetriever) {
self.service = service
self.authentication = authentication
self.assetRetriever = assetRetriever
}

func open(
asset: Asset,
credentials: String?,
allowUserInteraction: Bool,
sender: Any?
) async -> Result<ContentProtectionAsset, ContentProtectionOpenError> {
switch asset {
case let .resource(resource):
return await openLicense(
using: resource,
credentials: credentials,
allowUserInteraction: allowUserInteraction,
sender: sender
)

case let .container(container):
return await openPublication(
in: container,
credentials: credentials,
allowUserInteraction: allowUserInteraction,
sender: sender
)
}
}

func openLicense(
using asset: ResourceAsset,
credentials: String?,
allowUserInteraction: Bool,
sender: Any?
) async -> Result<ContentProtectionAsset, ContentProtectionOpenError> {
guard asset.format.conformsTo(.lcpLicense) else {
return .failure(.assetNotSupported(DebugError("The asset does not appear to be an LCP License")))
}

return await asset.resource.readAsLCPL()
.mapError { .reading($0) }
.asyncFlatMap { licenseDocument in

await assetRetriever.retrieve(link: licenseDocument.publicationLink)
.flatMap { publicationAsset in
switch publicationAsset {
case .resource:
return .failure(.assetNotSupported(DebugError("Cannot open the LCP-protected publication as a Container")))
case let .container(container):
return .success(container)
}
}
.asyncFlatMap {
await makeLCPAsset(
from: $0,
license: retrieveLicense(
in: .resource(asset),
credentials: credentials,
allowUserInteraction: allowUserInteraction,
sender: sender
)
)
}
}
}

func openPublication(
in asset: ContainerAsset,
credentials: String?,
allowUserInteraction: Bool,
sender: Any?
) async -> Result<ContentProtectionAsset, ContentProtectionOpenError> {
guard asset.format.conformsTo(.lcp) else {
return .failure(.assetNotSupported(DebugError("The asset does not appear to be protected with LCP")))
}
guard
case var .container(asset) = asset,
asset.container.sourceURL?.scheme == .file
else {
return .failure(.assetNotSupported(DebugError("Only container asset of local files are currently supported with LCP")))
}

return await parseEncryptionData(in: asset)
// FIXME: Alternative to storing the license in the file?
// guard asset.container.sourceURL?.scheme == .file else {
// return .failure(.assetNotSupported(DebugError("Only container asset of local files are currently supported with LCP")))
// }

return await makeLCPAsset(
from: asset,
license: retrieveLicense(
in: .container(asset),
credentials: credentials,
allowUserInteraction: allowUserInteraction,
sender: sender
)
)
}

private func retrieveLicense(
in asset: Asset,
credentials: String?,
allowUserInteraction: Bool,
sender: Any?
) async -> Result<LCPLicense, LCPError> {
let authentication = credentials.map { LCPPassphraseAuthentication($0, fallback: self.authentication) }
?? self.authentication

return await service.retrieveLicense(
from: asset,
authentication: authentication,
allowUserInteraction: allowUserInteraction,
sender: sender
)
}

func makeLCPAsset(
from asset: ContainerAsset,
license: Result<LCPLicense, LCPError>
) async -> Result<ContentProtectionAsset, ContentProtectionOpenError> {
await parseEncryptionData(in: asset)
.mapError { ContentProtectionOpenError.reading(.decoding($0)) }
.asyncFlatMap { encryptionData in
let authentication = credentials.map { LCPPassphraseAuthentication($0, fallback: self.authentication) }
?? self.authentication

let license = await self.service.retrieveLicense(
from: .container(asset),
authentication: authentication,
allowUserInteraction: allowUserInteraction,
sender: sender
)
var asset = asset

if let license = try? license.get() {
let decryptor = LCPDecryptor(license: license, encryptionData: encryptionData)
asset.container = asset.container
.map(transform: decryptor.decrypt(at:resource:))
}
let decryptor = LCPDecryptor(license: license.getOrNil(), encryptionData: encryptionData)
asset.container = asset.container
.map(transform: decryptor.decrypt(at:resource:))

let cpAsset = ContentProtectionAsset(
asset: .container(asset),
Expand All @@ -74,12 +159,20 @@ private final class LCPContentProtectionService: ContentProtectionService {
self.error = error
}

convenience init(result: Result<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)
switch error {
case .missingPassphrase:
// We don't expose errors due to user cancellation.
self.init()

default:
self.init(error: error)
}
}
}

Expand All @@ -104,3 +197,68 @@ public extension Publication {
findService(LCPContentProtectionService.self)?.license
}
}

private extension AssetRetriever {
func retrieve(link: Link) async -> Result<Asset, ContentProtectionOpenError> {
guard let url = link.url() else {
return .failure(.reading(.decoding("The LCP License Document does not contain a valid HTTP URL to the protected publication")))
}

return await retrieve(
url: url,
mediaType: link.mediaType
)
.mapError { error in
switch error {
case .formatNotSupported, .schemeNotSupported:
return .assetNotSupported(error)
case let .reading(error):
return .reading(error)
}
}
}

func retrieve(url: HTTPURL, mediaType: MediaType?) async -> Result<Asset, AssetRetrieveURLError> {
if let format = mediaType?.lcpFormat {
return await retrieve(url: url, format: format)
} else {
return await retrieve(url: url, hints: FormatHints(mediaType: mediaType))
}
}
}

private extension MediaType {
/// To avoid sniffing the media type of the known protected package,
/// we rely only on the link media type. This is fine because we already
/// know that the files are protected with LCP and so we don't need to
/// refine the format.
var lcpFormat: Format? {
if matches(.epub) {
return Format(
specifications: .zip, .epub, .lcp,
mediaType: .epub,
fileExtension: "epub"
)
} else if matches(.lcpProtectedPDF) {
return Format(
specifications: .zip, .rpf, .lcp,
mediaType: .lcpProtectedPDF,
fileExtension: "lcpdf"
)
} else if matches(.lcpProtectedAudiobook) {
return Format(
specifications: .zip, .rpf, .lcp,
mediaType: .lcpProtectedAudiobook,
fileExtension: "lcpa"
)
} else if matches(.divina) {
return Format(
specifications: .zip, .rpf, .lcp,
mediaType: .divina,
fileExtension: "divina"
)
} else {
return nil
}
}
}
Loading