Skip to content

Commit 497bc6b

Browse files
authored
Add support for streaming LCP publications (#557)
1 parent ce263fa commit 497bc6b

33 files changed

+549
-269
lines changed

CHANGELOG.md

+14-10
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Changelog
22

3-
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.
3+
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.
44

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

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

2222
* 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.
2323

24+
#### LCP
25+
26+
* 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).
27+
2428
### Deprecated
2529

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

4145
* The Readium Swift toolkit now requires a minimum of iOS 13.4.
42-
* 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.
46+
* 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.
4347

4448
### Added
4549

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

136140
#### LCP
137141

138-
* 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.
142+
* 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.
139143

140144
### Fixed
141145

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

160164
* `Link` and `Locator`'s `href` are normalized as valid URLs to improve interoperability with the Readium Web toolkits.
161-
* **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.
165+
* **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.
162166
* Links are not resolved to the `self` URL of a manifest anymore. However, you can still normalize the HREFs yourselves by calling `Manifest.normalizeHREFsToSelf()`.
163167
* `Publication.localizedTitle` is now optional, as we cannot guarantee a publication will always have a title.
164168

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

342346
* New `VisualNavigatorDelegate` APIs to handle keyboard events (contributed by [@lukeslu](https://github.com/readium/swift-toolkit/pull/267)).
343347
* This can be used to turn pages with the arrow keys, for example.
344-
* [Support for custom fonts with the EPUB navigator](Documentation/Guides/EPUB%20Fonts.md).
345-
* 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).
348+
* [Support for custom fonts with the EPUB navigator](docs/Guides/EPUB%20Fonts.md).
349+
* 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).
346350
* New EPUB user preferences:
347351
* `fontWeight` - Base text font weight.
348352
* `textNormalization` - Normalize font style, weight and variants, which improves accessibility.
@@ -369,11 +373,11 @@ All notable changes to this project will be documented in this file. Take a look
369373

370374
#### Streamer
371375

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

374378
#### Navigator
375379

376-
* 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).
380+
* 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).
377381

378382
### Changed
379383

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

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

403407
#### Navigator
404408

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

407411
#### Streamer
408412

Package.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ let package = Package(
2828
.package(url: "https://github.com/ra1028/DifferenceKit.git", from: "1.3.0"),
2929
.package(url: "https://github.com/readium/Fuzi.git", from: "4.0.0"),
3030
.package(url: "https://github.com/readium/GCDWebServer.git", from: "4.0.0"),
31-
.package(url: "https://github.com/readium/ZIPFoundation.git", from: "2.0.0"),
31+
.package(url: "https://github.com/readium/ZIPFoundation.git", from: "3.0.0"),
3232
.package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.7.0"),
3333
.package(url: "https://github.com/stephencelis/SQLite.swift.git", from: "0.15.0"),
3434
],

README.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

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

5-
: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.
5+
> [!TIP]
6+
> **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.
67
78
This toolkit is a modular project, which follows the [Readium Architecture](https://github.com/readium/architecture).
89

Sources/Internal/Extensions/Result.swift

+11
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,17 @@ public extension Result {
1111
try? get()
1212
}
1313

14+
func get(or def: Success) -> Success {
15+
(try? get()) ?? def
16+
}
17+
18+
func `catch`(_ recover: (Failure) -> Self) -> Self {
19+
if case let .failure(error) = self {
20+
return recover(error)
21+
}
22+
return self
23+
}
24+
1425
func eraseToAnyError() -> Result<Success, Error> {
1526
mapError { $0 as Error }
1627
}

Sources/LCP/Content Protection/LCPContentProtection.swift

+182-24
Original file line numberDiff line numberDiff line change
@@ -10,46 +10,131 @@ import ReadiumShared
1010
final class LCPContentProtection: ContentProtection, Loggable {
1111
private let service: LCPService
1212
private let authentication: LCPAuthenticating
13+
private let assetRetriever: AssetRetriever
1314

14-
init(service: LCPService, authentication: LCPAuthenticating) {
15+
init(service: LCPService, authentication: LCPAuthenticating, assetRetriever: AssetRetriever) {
1516
self.service = service
1617
self.authentication = authentication
18+
self.assetRetriever = assetRetriever
1719
}
1820

1921
func open(
2022
asset: Asset,
2123
credentials: String?,
2224
allowUserInteraction: Bool,
2325
sender: Any?
26+
) async -> Result<ContentProtectionAsset, ContentProtectionOpenError> {
27+
switch asset {
28+
case let .resource(resource):
29+
return await openLicense(
30+
using: resource,
31+
credentials: credentials,
32+
allowUserInteraction: allowUserInteraction,
33+
sender: sender
34+
)
35+
36+
case let .container(container):
37+
return await openPublication(
38+
in: container,
39+
credentials: credentials,
40+
allowUserInteraction: allowUserInteraction,
41+
sender: sender
42+
)
43+
}
44+
}
45+
46+
func openLicense(
47+
using asset: ResourceAsset,
48+
credentials: String?,
49+
allowUserInteraction: Bool,
50+
sender: Any?
51+
) async -> Result<ContentProtectionAsset, ContentProtectionOpenError> {
52+
guard asset.format.conformsTo(.lcpLicense) else {
53+
return .failure(.assetNotSupported(DebugError("The asset does not appear to be an LCP License")))
54+
}
55+
56+
return await asset.resource.readAsLCPL()
57+
.mapError { .reading($0) }
58+
.asyncFlatMap { licenseDocument in
59+
60+
await assetRetriever.retrieve(link: licenseDocument.publicationLink)
61+
.flatMap { publicationAsset in
62+
switch publicationAsset {
63+
case .resource:
64+
return .failure(.assetNotSupported(DebugError("Cannot open the LCP-protected publication as a Container")))
65+
case let .container(container):
66+
return .success(container)
67+
}
68+
}
69+
.asyncFlatMap {
70+
await makeLCPAsset(
71+
from: $0,
72+
license: retrieveLicense(
73+
in: .resource(asset),
74+
credentials: credentials,
75+
allowUserInteraction: allowUserInteraction,
76+
sender: sender
77+
)
78+
)
79+
}
80+
}
81+
}
82+
83+
func openPublication(
84+
in asset: ContainerAsset,
85+
credentials: String?,
86+
allowUserInteraction: Bool,
87+
sender: Any?
2488
) async -> Result<ContentProtectionAsset, ContentProtectionOpenError> {
2589
guard asset.format.conformsTo(.lcp) else {
2690
return .failure(.assetNotSupported(DebugError("The asset does not appear to be protected with LCP")))
2791
}
28-
guard
29-
case var .container(asset) = asset,
30-
asset.container.sourceURL?.scheme == .file
31-
else {
32-
return .failure(.assetNotSupported(DebugError("Only container asset of local files are currently supported with LCP")))
33-
}
3492

35-
return await parseEncryptionData(in: asset)
93+
// FIXME: Alternative to storing the license in the file?
94+
// guard asset.container.sourceURL?.scheme == .file else {
95+
// return .failure(.assetNotSupported(DebugError("Only container asset of local files are currently supported with LCP")))
96+
// }
97+
98+
return await makeLCPAsset(
99+
from: asset,
100+
license: retrieveLicense(
101+
in: .container(asset),
102+
credentials: credentials,
103+
allowUserInteraction: allowUserInteraction,
104+
sender: sender
105+
)
106+
)
107+
}
108+
109+
private func retrieveLicense(
110+
in asset: Asset,
111+
credentials: String?,
112+
allowUserInteraction: Bool,
113+
sender: Any?
114+
) async -> Result<LCPLicense, LCPError> {
115+
let authentication = credentials.map { LCPPassphraseAuthentication($0, fallback: self.authentication) }
116+
?? self.authentication
117+
118+
return await service.retrieveLicense(
119+
from: asset,
120+
authentication: authentication,
121+
allowUserInteraction: allowUserInteraction,
122+
sender: sender
123+
)
124+
}
125+
126+
func makeLCPAsset(
127+
from asset: ContainerAsset,
128+
license: Result<LCPLicense, LCPError>
129+
) async -> Result<ContentProtectionAsset, ContentProtectionOpenError> {
130+
await parseEncryptionData(in: asset)
36131
.mapError { ContentProtectionOpenError.reading(.decoding($0)) }
37132
.asyncFlatMap { encryptionData in
38-
let authentication = credentials.map { LCPPassphraseAuthentication($0, fallback: self.authentication) }
39-
?? self.authentication
40-
41-
let license = await self.service.retrieveLicense(
42-
from: .container(asset),
43-
authentication: authentication,
44-
allowUserInteraction: allowUserInteraction,
45-
sender: sender
46-
)
133+
var asset = asset
47134

48-
if let license = try? license.get() {
49-
let decryptor = LCPDecryptor(license: license, encryptionData: encryptionData)
50-
asset.container = asset.container
51-
.map(transform: decryptor.decrypt(at:resource:))
52-
}
135+
let decryptor = LCPDecryptor(license: license.getOrNil(), encryptionData: encryptionData)
136+
asset.container = asset.container
137+
.map(transform: decryptor.decrypt(at:resource:))
53138

54139
let cpAsset = ContentProtectionAsset(
55140
asset: .container(asset),
@@ -74,12 +159,20 @@ private final class LCPContentProtectionService: ContentProtectionService {
74159
self.error = error
75160
}
76161

77-
convenience init(result: Result<LCPLicense?, LCPError>) {
162+
convenience init(result: Result<LCPLicense, LCPError>) {
78163
switch result {
79164
case let .success(license):
80165
self.init(license: license)
166+
81167
case let .failure(error):
82-
self.init(error: error)
168+
switch error {
169+
case .missingPassphrase:
170+
// We don't expose errors due to user cancellation.
171+
self.init()
172+
173+
default:
174+
self.init(error: error)
175+
}
83176
}
84177
}
85178

@@ -104,3 +197,68 @@ public extension Publication {
104197
findService(LCPContentProtectionService.self)?.license
105198
}
106199
}
200+
201+
private extension AssetRetriever {
202+
func retrieve(link: Link) async -> Result<Asset, ContentProtectionOpenError> {
203+
guard let url = link.url() else {
204+
return .failure(.reading(.decoding("The LCP License Document does not contain a valid HTTP URL to the protected publication")))
205+
}
206+
207+
return await retrieve(
208+
url: url,
209+
mediaType: link.mediaType
210+
)
211+
.mapError { error in
212+
switch error {
213+
case .formatNotSupported, .schemeNotSupported:
214+
return .assetNotSupported(error)
215+
case let .reading(error):
216+
return .reading(error)
217+
}
218+
}
219+
}
220+
221+
func retrieve(url: HTTPURL, mediaType: MediaType?) async -> Result<Asset, AssetRetrieveURLError> {
222+
if let format = mediaType?.lcpFormat {
223+
return await retrieve(url: url, format: format)
224+
} else {
225+
return await retrieve(url: url, hints: FormatHints(mediaType: mediaType))
226+
}
227+
}
228+
}
229+
230+
private extension MediaType {
231+
/// To avoid sniffing the media type of the known protected package,
232+
/// we rely only on the link media type. This is fine because we already
233+
/// know that the files are protected with LCP and so we don't need to
234+
/// refine the format.
235+
var lcpFormat: Format? {
236+
if matches(.epub) {
237+
return Format(
238+
specifications: .zip, .epub, .lcp,
239+
mediaType: .epub,
240+
fileExtension: "epub"
241+
)
242+
} else if matches(.lcpProtectedPDF) {
243+
return Format(
244+
specifications: .zip, .rpf, .lcp,
245+
mediaType: .lcpProtectedPDF,
246+
fileExtension: "lcpdf"
247+
)
248+
} else if matches(.lcpProtectedAudiobook) {
249+
return Format(
250+
specifications: .zip, .rpf, .lcp,
251+
mediaType: .lcpProtectedAudiobook,
252+
fileExtension: "lcpa"
253+
)
254+
} else if matches(.divina) {
255+
return Format(
256+
specifications: .zip, .rpf, .lcp,
257+
mediaType: .divina,
258+
fileExtension: "divina"
259+
)
260+
} else {
261+
return nil
262+
}
263+
}
264+
}

0 commit comments

Comments
 (0)