Skip to content
Draft
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,19 @@ All notable changes to this project will be documented in this file. Take a look
#### Shared

* EPUB HREFs that are not percent-encoded but carry a fragment or query (e.g. `chapter one.xhtml#section`, with a space in the filename) now keep their `#fragment`/`?query` instead of encoding the separators into the path. This fixes table of contents and Media Overlays links failing to resolve and navigate in poorly-authored EPUBs.
* [#579](https://github.com/readium/swift-toolkit/issues/579) Reading a range past the end of a ZIP entry now returns the clamped bytes instead of failing, per the `Streamable` contract. `BufferingResource` also no longer extends its read-ahead past the end of the resource, which HTTP servers reject with a 416 error.
* `ReadError.wrap()` now passes through errors that are already `ReadError`s, instead of obscuring them in a `.decoding` case. A new `ReadError.isCancellation` helper identifies errors caused by a cancelled task or HTTP request.

#### Navigator

* Fixed custom `EditingAction`s sometimes missing from the text-selection menu for double-tap (single word) selections (contributed by [@raphi011](https://github.com/readium/swift-toolkit/pull/822)).
* Fixed memory leak in the `AudioNavigator`.
* [#802](https://github.com/readium/swift-toolkit/issues/802) Fixed fonts declared with `fontFamilyDeclarations` never loading in the EPUB navigator. Font fetches were CORS-gated by WebKit (contributed by [@atani](https://github.com/readium/swift-toolkit/pull/845)).
* [#579](https://github.com/readium/swift-toolkit/issues/579) The `AudioNavigator` now reports the `.loading` state while the player is stalled on an empty buffer, and forwards resource loading errors to `NavigatorDelegate.navigator(_:didFailToLoadResourceAt:withError:)` instead of swallowing them. It also keeps the current resource cached across loading requests, instead of re-downloading the beginning of a track whenever the player reissues a request.

#### LCP

* [#579](https://github.com/readium/swift-toolkit/issues/579) Streamed LCP audiobooks now start playing almost immediately. Resources encrypted with AES-CBC are decrypted and served in chunks, instead of being fully downloaded and decrypted upfront.


## [3.10.0] - 2026-06-24
Expand Down
139 changes: 97 additions & 42 deletions Sources/LCP/Content Protection/LCPDecryptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import ReadiumShared
private let lcpScheme = "http://readium.org/2014/01/lcp"

/// Decrypts a resource protected with LCP.
final class LCPDecryptor {
final class LCPDecryptor: Loggable {
enum Error: Swift.Error {
case emptyDecryptedData
case invalidCBCData
Expand Down Expand Up @@ -62,7 +62,7 @@ final class LCPDecryptor {
///
/// Can be used when it's impossible to map a read range (byte range request) to the encrypted
/// resource, for example when the resource is deflated before encryption.
private class FullLCPResource: TransformingResource {
private class FullLCPResource: TransformingResource, Loggable {
private let license: LCPLicense
private let encryption: ReadiumShared.Encryption

Expand All @@ -84,7 +84,7 @@ final class LCPDecryptor {
/// A LCP resource used to read content encrypted with the CBC algorithm.
///
/// Supports random access for byte range requests, but the resource MUST NOT be deflated.
private class CBCLCPResource: Resource {
private class CBCLCPResource: Resource, Loggable {
private let resource: Resource
private let license: LCPLicense
private let encryption: ReadiumShared.Encryption
Expand Down Expand Up @@ -141,63 +141,118 @@ final class LCPDecryptor {
}
}

/// Number of plaintext bytes decrypted and delivered per `consume`
/// call when streaming.
private static let chunkSize: UInt64 = 256 * 1024

func stream(range: Range<UInt64>?, consume: @escaping (Data) -> Void) async -> ReadResult<Void> {
guard let range = range else {
var plainTextSize: UInt64?
switch await self.plainTextSize {
case let .success(size):
plainTextSize = size
case let .failure(error):
guard range == nil else {
return .failure(error)
}
}

guard let plainTextSize = plainTextSize else {
// Without the plaintext size, we can't compute the chunks to
// decrypt; fall back on reading and decrypting the whole
// resource in one shot.
guard range == nil else {
return failure(.noPlainTextSize)
}
return await license.decryptFully(data: resource.read(), isDeflated: encryption.isDeflated)
.map {
consume($0)
return ()
}
}

return await resource.estimatedLength().asyncFlatMap { encryptedLength in
let requestedRange = range ?? 0 ..< plainTextSize
let clampedRange = min(requestedRange.lowerBound, plainTextSize) ..< min(requestedRange.upperBound, plainTextSize)
guard !clampedRange.isEmpty else {
return .success(())
}

return await resource.estimatedLength().asyncFlatMap { [self] encryptedLength in
guard let encryptedLength = encryptedLength else {
return failure(.requiredEstimatedLength)
}
guard let rangeFirst = range.first, let rangeLast = range.last else {
return failure(.invalidRange(range))
}

// Encrypted data is shifted by AESBlockSize, because of IV and because the
// previous block must be provided to perform XOR on intermediate blocks.
let encryptedStart = rangeFirst.floorMultiple(of: AESBlockSize)
let encryptedEndExclusive = min(
(rangeLast + 1).ceilMultiple(of: AESBlockSize) + AESBlockSize,
encryptedLength
)

return await resource.read(range: encryptedStart ..< encryptedEndExclusive)
.combine(plainTextSize)
.flatMap { encryptedData, plainTextSize in
do {
guard let plainTextSize = plainTextSize else {
return failure(.noPlainTextSize)
}
guard let bytes = try license.decipher(encryptedData) else {
return failure(.emptyDecryptedData)
}
// Decrypting in chunks lets the caller process the beginning
// of the resource without waiting for the whole range, which
// matters when streaming a large track from the network.
var chunkStart = clampedRange.lowerBound
while chunkStart < clampedRange.upperBound {
guard !Task.isCancelled else {
return .failure(.cancelled)
}

// Exclude the bytes added to match a multiple of AESBlockSize.
let sliceStart = (rangeFirst - encryptedStart)
let chunkEnd = min(chunkStart + Self.chunkSize, clampedRange.upperBound)
let result = await decrypt(
range: chunkStart ..< chunkEnd,
encryptedLength: encryptedLength,
plainTextSize: plainTextSize
)
switch result {
case let .success(chunk):
consume(chunk)
chunkStart = chunkEnd
case let .failure(error):
return .failure(error)
}
}

let isLastBlockRead = encryptedLength - encryptedEndExclusive <= AESBlockSize
let rangeLength = isLastBlockRead
// Use decrypted length to ensure `rangeLast` doesn't exceed decrypted length - 1.
? min(rangeLast, plainTextSize - 1) - rangeFirst + 1
// The last block won't be read, so there's no need to compute the length
: rangeLast - rangeFirst + 1
return .success(())
}
}

// Keep only enough bytes to fit the length-corrected request in order to never
// include padding.
let sliceEnd = sliceStart + rangeLength
/// Decrypts a single chunk of plaintext located at `range`.
private func decrypt(
range: Range<UInt64>,
encryptedLength: UInt64,
plainTextSize: UInt64
) async -> ReadResult<Data> {
guard let rangeFirst = range.first, let rangeLast = range.last else {
return failure(.invalidRange(range))
}

consume(bytes[sliceStart ..< sliceEnd])
return .success(())
} catch {
return .failure(.decoding(error))
// Encrypted data is shifted by AESBlockSize, because of IV and because the
// previous block must be provided to perform XOR on intermediate blocks.
let encryptedStart = rangeFirst.floorMultiple(of: AESBlockSize)
let encryptedEndExclusive = min(
(rangeLast + 1).ceilMultiple(of: AESBlockSize) + AESBlockSize,
encryptedLength
)

return await resource.read(range: encryptedStart ..< encryptedEndExclusive)
.flatMap { [self] encryptedData in
do {
guard let bytes = try license.decipher(encryptedData) else {
return failure(.emptyDecryptedData)
}

// Exclude the bytes added to match a multiple of AESBlockSize.
let sliceStart = (rangeFirst - encryptedStart)

let isLastBlockRead = encryptedLength - encryptedEndExclusive <= AESBlockSize
let rangeLength = isLastBlockRead
// Use decrypted length to ensure `rangeLast` doesn't exceed decrypted length - 1.
? min(rangeLast, plainTextSize - 1) - rangeFirst + 1
// The last block won't be read, so there's no need to compute the length
: rangeLast - rangeFirst + 1

// Keep only enough bytes to fit the length-corrected request in order to never
// include padding.
let sliceEnd = sliceStart + rangeLength

return .success(bytes[sliceStart ..< sliceEnd])
} catch {
return .failure(.decoding(error))
}
}
}
}

private func failure<T>(_ error: LCPDecryptor.Error) -> ReadResult<T> {
Expand Down
59 changes: 54 additions & 5 deletions Sources/Navigator/Audiobook/AudioNavigator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,18 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo

/// Returns whether the resource is currently playing or not.
public var state: MediaPlaybackState {
MediaPlaybackState(player.timeControlStatus)
let state = MediaPlaybackState(player.timeControlStatus)
if
state == .playing,
let item = player.currentItem,
item.isPlaybackBufferEmpty, !item.isPlaybackLikelyToKeepUp
{
// As `automaticallyWaitsToMinimizeStalling` is disabled, the
// player reports `.playing` even when it is stalled on an empty
// buffer waiting for data.
return .loading
}
return state
}

/// Current playback info.
Expand Down Expand Up @@ -250,10 +261,23 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo
private var rateObserver: NSKeyValueObservation?
private var timeControlStatusObserver: NSKeyValueObservation?
private var currentItemObserver: NSKeyValueObservation?
private var itemStatusObserver: NSKeyValueObservation?
private var itemLikelyToKeepUpObserver: NSKeyValueObservation?
private var timeObserver: Any?
private var playerItemEndObserver: Any?

private lazy var mediaLoader = PublicationMediaLoader(publication: publication)
private lazy var mediaLoader: PublicationMediaLoader = {
let loader = PublicationMediaLoader(publication: publication)
loader.onLoadingError = { [weak self] href, error in
Task { @MainActor in
guard let self = self, let href = href.relativeURL else {
return
}
self.delegate?.navigator(self, didFailToLoadResourceAt: href, withError: error)
}
}
return loader
}()

private lazy var player: AVPlayer = {
let player = AVPlayer()
Expand All @@ -269,8 +293,7 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo
queue: .main
) { [weak self] time in
if let self = self {
let time = time.secondsOrZero
self.playbackDidChange(time)
self.playbackDidChange(time.secondsOrZero)
}
}

Expand All @@ -294,7 +317,8 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo
self?.playbackDidChange()
}

currentItemObserver = player.observe(\.currentItem, options: [.new, .old]) { [weak self] _, _ in
currentItemObserver = player.observe(\.currentItem, options: [.new, .old]) { [weak self] player, _ in
self?.observe(currentItem: player.currentItem)
self?.playbackDidChange()
}

Expand All @@ -319,6 +343,31 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo
return player
}()

private func observe(currentItem item: AVPlayerItem?) {
itemLikelyToKeepUpObserver = item?.observe(\.isPlaybackLikelyToKeepUp) { [weak self] _, _ in
self?.playbackDidChange()
}

itemStatusObserver = item?.observe(\.status) { [weak self] item, _ in
guard let self = self, item.status == .failed else {
return
}

let itemError = item.error
log(.error, "Failed to load the player item: \(String(describing: itemError))")

let href = publication.readingOrder[resourceIndex].url().relativeURL

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getOrNil(resourceIndex)? might be better.

Task { @MainActor in
guard let href = href else {
return
}
let error: ReadError = itemError.flatMap { .wrap($0) }
?? .decoding("The AVPlayerItem failed to load", cause: itemError)
self.delegate?.navigator(self, didFailToLoadResourceAt: href, withError: error)
}
}
}

private func shouldPlayNextResource(completion: @escaping (Bool) -> Void) {
guard let delegate = delegate else {
completion(true)
Expand Down
29 changes: 26 additions & 3 deletions Sources/Navigator/Audiobook/PublicationMediaLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate, Log

private let publication: Publication

/// Called when a resource failed to be served to the player, e.g. to
/// forward the error to the `NavigatorDelegate`.
var onLoadingError: ((AnyURL, ReadError) -> Void)?

private let tasks = CancellableTasks()

init(publication: Publication) {
Expand Down Expand Up @@ -64,6 +68,13 @@ final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate, Log
else {
return nil
}

// Only the resources of other entries are evicted, as the player
// routinely abandons its requests to issue new ones for the same
// entry. Dropping the current resource would throw away its buffered
// data and force re-downloading the beginning of the entry.
resources = resources.filter { requests[$0.key] != nil }

resources[href] = (link, resource)
return (link, resource)
}
Expand Down Expand Up @@ -100,8 +111,9 @@ final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate, Log
let req = reqs.remove(at: index)
req.task.cancel()

// The resource is intentionally kept in `resources`, to reuse its
// buffered data with the next loading requests for the same entry.
if reqs.isEmpty {
resources.removeValue(forKey: href)
requests.removeValue(forKey: href)
} else {
requests[href] = reqs
Expand Down Expand Up @@ -139,7 +151,7 @@ final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate, Log
using resource: Resource,
link: Link
) {
tasks.add {
tasks.add { [self] in
infoRequest.isByteRangeAccessSupported = true
infoRequest.contentType = link.mediaType?.uti

Expand All @@ -150,6 +162,7 @@ final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate, Log

case let .failure(error):
log(.error, error)
report(error, forHREF: link.url())
request.finishLoading(with: error)
}
}
Expand All @@ -163,7 +176,7 @@ final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate, Log
range = UInt64(dataRequest.currentOffset) ..< (UInt64(dataRequest.currentOffset) + UInt64(dataRequest.requestedLength))
}

let task = Task {
let task = Task { [self] in
let result = await resource.stream(
range: range,
consume: { dataRequest.respond(with: $0) }
Expand All @@ -174,6 +187,7 @@ final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate, Log
case .success:
request.finishLoading()
case let .failure(error):
self?.report(error, forHREF: link.url())
request.finishLoading(with: error)
}

Expand All @@ -184,6 +198,15 @@ final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate, Log
registerRequest(request, task: task, for: link.url())
}

private func report(_ error: ReadError, forHREF href: AnyURL) {
// Cancellation is not an error worth reporting, it occurs whenever
// the player abandons a data request, e.g. when seeking.
guard !error.isCancellation else {
return
}
onLoadingError?(href, error)
}

func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest) {
finishRequest(loadingRequest)
}
Expand Down
Loading