Skip to content

Commit 071bcb7

Browse files
authored
Add support for streaming ZIP packages over HTTP (#537)
1 parent 6ba50b6 commit 071bcb7

35 files changed

+673
-217
lines changed

CHANGELOG.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@ All notable changes to this project will be documented in this file. Take a look
44

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

7-
<!-- ## [Unreleased] -->
7+
## [Unreleased]
8+
9+
### Added
10+
11+
#### Shared
12+
13+
* 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.
14+
815

916
## [3.0.0-beta.2]
1017

Cartfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ github "krzyzanowskim/CryptoSwift" ~> 1.8.0
22
github "ra1028/DifferenceKit" ~> 1.3.0
33
github "readium/Fuzi" ~> 4.0.0
44
github "readium/GCDWebServer" ~> 4.0.0
5-
github "readium/ZIPFoundation" ~> 1.0.0
5+
github "readium/ZIPFoundation" ~> 2.0.0
66
# There's a regression with 2.7.4 in SwiftSoup, because they used iOS 13 APIs without bumping the deployment target.
77
github "scinfu/SwiftSoup" == 2.7.1
88
github "stephencelis/SQLite.swift" ~> 0.15.0

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ help:
1111

1212
.PHONY: carthage-project
1313
carthage-project:
14+
rm -rf $(SCRIPTS_PATH)/node_modules/
1415
xcodegen -s Support/Carthage/project.yml --use-cache --cache-path Support/Carthage/.xcodegen
1516

1617
.PHONY: scripts

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ let package = Package(
2727
.package(url: "https://github.com/ra1028/DifferenceKit.git", from: "1.3.0"),
2828
.package(url: "https://github.com/readium/Fuzi.git", from: "4.0.0"),
2929
.package(url: "https://github.com/readium/GCDWebServer.git", from: "4.0.0"),
30-
.package(url: "https://github.com/readium/ZIPFoundation.git", from: "1.0.0"),
30+
.package(url: "https://github.com/readium/ZIPFoundation.git", from: "2.0.0"),
3131
.package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.7.0"),
3232
.package(url: "https://github.com/stephencelis/SQLite.swift.git", from: "0.15.0"),
3333
],
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//
2+
// Copyright 2025 Readium Foundation. All rights reserved.
3+
// Use of this source code is governed by the BSD-style license
4+
// available in the top-level LICENSE file of the project.
5+
//
6+
7+
import Foundation
8+
9+
public extension Data {
10+
/// Reads a sub-range of `self` after shifting the given absolute range
11+
/// to be relative to `self`.
12+
subscript(_ range: Range<UInt64>, offsetBy dataStartOffset: UInt64) -> Data? {
13+
let range = range.clampedToInt()
14+
15+
let lower = Int(range.lowerBound) - Int(dataStartOffset) + startIndex
16+
let upper = lower + range.count
17+
guard lower >= 0, upper <= count else {
18+
return nil
19+
}
20+
assert(indices.contains(lower))
21+
assert(indices.contains(upper - 1))
22+
return self[lower ..< upper]
23+
}
24+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
//
2+
// Copyright 2025 Readium Foundation. All rights reserved.
3+
// Use of this source code is governed by the BSD-style license
4+
// available in the top-level LICENSE file of the project.
5+
//
6+
7+
public extension Numeric {
8+
var kB: Self {
9+
self * 1024
10+
}
11+
12+
var MB: Self {
13+
self * 1024 * 1024
14+
}
15+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
//
2+
// Copyright 2025 Readium Foundation. All rights reserved.
3+
// Use of this source code is governed by the BSD-style license
4+
// available in the top-level LICENSE file of the project.
5+
//
6+
7+
import Foundation
8+
9+
public extension Range where Bound == UInt64 {
10+
func clampedToInt() -> Range<UInt64> {
11+
clamped(to: 0 ..< UInt64(Int.max))
12+
}
13+
}

Sources/LCP/License/Container/ZIPLicenseContainer.swift

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ class ZIPLicenseContainer: LicenseContainer {
2121

2222
func containsLicense() async throws -> Bool {
2323
do {
24-
let archive = try Archive(url: zip.url, accessMode: .read)
25-
return archive[pathInZIP] != nil
24+
let archive = try await Archive(url: zip.url, accessMode: .read)
25+
return try await archive.get(pathInZIP) != nil
2626
} catch {
2727
throw LCPError.licenseContainer(.openFailed(error))
2828
}
@@ -31,17 +31,19 @@ class ZIPLicenseContainer: LicenseContainer {
3131
func read() async throws -> Data {
3232
let archive: Archive
3333
do {
34-
archive = try Archive(url: zip.url, accessMode: .read)
34+
archive = try await Archive(url: zip.url, accessMode: .read)
3535
} catch {
3636
throw LCPError.licenseContainer(.openFailed(error))
3737
}
38-
guard let entry = archive[pathInZIP] else {
39-
throw LCPError.licenseContainer(.fileNotFound(pathInZIP))
40-
}
4138

4239
var data = Data()
40+
4341
do {
44-
_ = try archive.extract(entry) { part in
42+
guard let entry = try await archive.get(pathInZIP) else {
43+
throw LCPError.licenseContainer(.fileNotFound(pathInZIP))
44+
}
45+
46+
_ = try await archive.extract(entry) { part in
4547
data.append(part)
4648
}
4749
} catch {
@@ -54,20 +56,20 @@ class ZIPLicenseContainer: LicenseContainer {
5456
func write(_ license: LicenseDocument) async throws {
5557
let archive: Archive
5658
do {
57-
archive = try Archive(url: zip.url, accessMode: .update)
59+
archive = try await Archive(url: zip.url, accessMode: .update)
5860
} catch {
5961
throw LCPError.licenseContainer(.openFailed(error))
6062
}
6163

6264
do {
6365
// Removes the old License if it already exists in the archive, otherwise we get duplicated entries
64-
if let oldLicense = archive[pathInZIP] {
65-
try archive.remove(oldLicense)
66+
if let oldLicense = try await archive.get(pathInZIP) {
67+
try await archive.remove(oldLicense)
6668
}
6769

6870
// Stores the License into the ZIP file
6971
let data = license.jsonData
70-
try archive.addEntry(with: pathInZIP, type: .file, uncompressedSize: Int64(data.count), provider: { position, size -> Data in
72+
try await archive.addEntry(with: pathInZIP, type: .file, uncompressedSize: Int64(data.count), provider: { position, size -> Data in
7173
data[position ..< Int64(size)]
7274
})
7375
} catch {

Sources/Navigator/EPUB/EPUBSpreadView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ class EPUBSpreadView: UIView, Loggable, PageView {
150150
/// Evaluates the given JavaScript into the resource's HTML page.
151151
@discardableResult
152152
func evaluateScript(_ script: String, inHREF href: AnyURL? = nil) async -> Result<Any, Error> {
153-
log(.debug, "Evaluate script: \(script)")
153+
log(.trace, "Evaluate script: \(script)")
154154
return await withCheckedContinuation { continuation in
155155
webView.evaluateJavaScript(script) { res, error in
156156
if let error = error {

Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift

Lines changed: 70 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
//
66

77
import Foundation
8+
import ReadiumInternal
89

910
/// Wraps an existing `Resource` and buffers its content.
1011
///
@@ -18,15 +19,23 @@ import Foundation
1819
/// apparent when reading forward and consecutively – e.g. when downloading the
1920
/// resource by chunks. The buffer is ignored when reading backward or far
2021
/// ahead.
21-
public actor BufferingResource: Resource {
22+
public actor BufferingResource: Resource, Loggable {
2223
private nonisolated let resource: Resource
23-
private let bufferSize: UInt64
24+
25+
/// The buffer containing the current bytes read from the wrapped
26+
/// `Resource`, with the range it covers.
27+
private var buffer: Buffer
2428

2529
/// - Parameter bufferSize: Size of the buffer chunks to read.
26-
public init(resource: Resource, bufferSize: UInt64 = 8192) {
27-
assert(bufferSize > 0)
30+
public init(resource: Resource, bufferSize: Int = 8192) {
31+
precondition(bufferSize > 0)
2832
self.resource = resource
29-
self.bufferSize = bufferSize
33+
buffer = Buffer(maxSize: bufferSize)
34+
}
35+
36+
@available(*, deprecated, message: "Use an Int bufferSize instead.")
37+
public init(resource: Resource, bufferSize: UInt64) {
38+
self.init(resource: resource, bufferSize: Int(bufferSize))
3039
}
3140

3241
public nonisolated var sourceURL: AbsoluteURL? { resource.sourceURL }
@@ -39,9 +48,6 @@ public actor BufferingResource: Resource {
3948
resource.close()
4049
}
4150

42-
/// The buffer containing the current bytes read from the wrapped `Resource`, with the range it covers.
43-
private var buffer: (data: Data, range: Range<UInt64>)? = nil
44-
4551
private var cachedLength: ReadResult<UInt64?>?
4652

4753
public func estimatedLength() async -> ReadResult<UInt64?> {
@@ -69,74 +75,78 @@ public actor BufferingResource: Resource {
6975
consume(Data())
7076
return .success(())
7177
}
78+
if let data = buffer.get(range: requestedRange) {
79+
log(.trace, "Used buffer for \(requestedRange) (\(requestedRange.count) bytes)")
80+
consume(data)
81+
return .success(())
82+
}
7283

73-
// Round up the range to be read to the next `bufferSize`, because we
74-
// will buffer the excess.
75-
let readUpperBound = min(requestedRange.upperBound.ceilMultiple(of: bufferSize), length)
76-
var readRange: Range<UInt64> = requestedRange.lowerBound ..< readUpperBound
84+
// Calculate the readRange to cover at least buffer.maxSize bytes.
85+
// Adjust the start if near the end of the resource.
86+
var readStart = requestedRange.lowerBound
87+
var readEnd = requestedRange.upperBound
88+
let missingBytesToMatchBufferSize = buffer.maxSize - requestedRange.count
89+
if missingBytesToMatchBufferSize > 0 {
90+
readEnd = min(readEnd + UInt64(missingBytesToMatchBufferSize), length)
91+
}
92+
if readEnd - readStart < buffer.maxSize {
93+
readStart = UInt64(max(0, Int(readEnd) - buffer.maxSize))
94+
}
95+
let readRange = readStart ..< readEnd
96+
log(.trace, "Requested \(requestedRange) (\(requestedRange.count) bytes), will read range \(readRange) (\(readRange.count) bytes) of resource with length \(length)")
97+
98+
// Fallback on reading the requested range from the original resource.
99+
return await resource.read(range: readRange)
100+
.flatMap { data in
101+
buffer.set(data, at: readRange.lowerBound)
102+
103+
guard let data = data[requestedRange, offsetBy: readRange.lowerBound] else {
104+
return .failure(.decoding("Cannot extract the requested range from the read range"))
105+
}
77106

78-
// Attempt to serve parts or all of the request using the buffer.
79-
if let buffer = buffer {
80-
// Everything already buffered?
81-
if buffer.range.contains(requestedRange) {
82-
let data = extractRange(requestedRange, in: buffer.data, startingAt: buffer.range.lowerBound)
83107
consume(data)
84108
return .success(())
85-
86-
// Beginning of requested data is buffered?
87-
} else if buffer.range.contains(requestedRange.lowerBound) {
88-
var data = buffer.data
89-
let bufferStart = buffer.range.lowerBound
90-
readRange = buffer.range.upperBound ..< readRange.upperBound
91-
92-
return await resource.read(range: readRange)
93-
.map { readData in
94-
data += readData
95-
// Shift the current buffer to the tail of the read data.
96-
saveBuffer(from: data, range: readRange)
97-
consume(extractRange(requestedRange, in: data, startingAt: bufferStart))
98-
return ()
99-
}
100109
}
110+
}
111+
112+
private struct Buffer {
113+
let maxSize: Int
114+
private var data: Data = .init()
115+
private var startOffset: UInt64 = 0
116+
117+
init(maxSize: Int) {
118+
self.maxSize = maxSize
101119
}
102120

103-
// Fallback on reading the requested range from the original resource.
104-
return await resource.read(range: readRange)
105-
.map { data in
106-
saveBuffer(from: data, range: readRange)
107-
consume(data[0 ..< requestedRange.count])
108-
return ()
121+
mutating func set(_ data: Data, at offset: UInt64) {
122+
var data = data
123+
var offset = offset
124+
125+
// Truncates the beginning of the data to maxSize.
126+
if data.count > maxSize {
127+
offset += UInt64(data.count - maxSize)
128+
data = Data(data.suffix(maxSize))
109129
}
110-
}
111130

112-
/// Keeps the last chunk of the given `data` as the buffer for next reads.
113-
///
114-
/// - Parameters:
115-
/// - data: Data read from the original resource.
116-
/// - range: Range of the read data in the resource.
117-
private func saveBuffer(from data: Data, range: Range<UInt64>) {
118-
let lastChunk = Data(data.suffix(Int(bufferSize)))
119-
buffer = (
120-
data: lastChunk,
121-
range: (range.upperBound - UInt64(lastChunk.count)) ..< range.upperBound
122-
)
123-
}
131+
self.data = data
132+
startOffset = offset
133+
}
124134

125-
/// Reads a sub-range of the given `data` after shifting the given absolute (to the resource) ranges to be relative
126-
/// to `data`.
127-
private func extractRange(_ requestedRange: Range<UInt64>, in data: Data, startingAt dataStartOffset: UInt64) -> Data {
128-
let lower = (requestedRange.lowerBound - dataStartOffset)
129-
let upper = lower + (requestedRange.upperBound - requestedRange.lowerBound)
130-
assert(lower >= 0)
131-
assert(upper <= data.count)
132-
return data[lower ..< upper]
135+
func get(range: Range<UInt64>) -> Data? {
136+
data[range, offsetBy: startOffset]
137+
}
133138
}
134139
}
135140

136141
public extension Resource {
137142
/// Wraps this resource in a `BufferingResource` to improve reading
138143
/// performances.
139-
func buffered(size: UInt64 = 8192) -> BufferingResource {
144+
func buffered(size: Int = 8192) -> BufferingResource {
140145
BufferingResource(resource: self, bufferSize: size)
141146
}
147+
148+
@available(*, deprecated, message: "Use an Int bufferSize instead.")
149+
func buffered(size: UInt64) -> BufferingResource {
150+
buffered(size: Int(size))
151+
}
142152
}

0 commit comments

Comments
 (0)