diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 1d53792a9..d8b4d3c55 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -7,17 +7,17 @@ on: env: platform: ${{ 'iOS Simulator' }} - device: ${{ 'iPhone 12' }} + device: ${{ 'iPhone 15' }} commit_sha: ${{ github.sha }} + DEVELOPER_DIR: /Applications/Xcode_15.0.1.app/Contents/Developer jobs: build: name: Build - runs-on: macos-12 + runs-on: macos-13 if: ${{ !github.event.pull_request.draft }} env: scheme: ${{ 'Readium-Package' }} - DEVELOPER_DIR: /Applications/Xcode_13.4.1.app/Contents/Developer steps: - name: Checkout @@ -42,7 +42,7 @@ jobs: lint: name: Lint - runs-on: macos-12 + runs-on: macos-13 if: ${{ !github.event.pull_request.draft }} env: scripts: ${{ 'Sources/Navigator/EPUB/Scripts' }} @@ -76,7 +76,7 @@ jobs: int-dev: name: Integration (Local) - runs-on: macos-12 + runs-on: macos-13 if: ${{ !github.event.pull_request.draft }} defaults: run: @@ -98,7 +98,7 @@ jobs: int-spm: name: Integration (Swift Package Manager) - runs-on: macos-12 + runs-on: macos-13 if: ${{ !github.event.pull_request.draft }} defaults: run: @@ -126,7 +126,7 @@ jobs: int-carthage: name: Integration (Carthage) - runs-on: macos-12 + runs-on: macos-13 if: ${{ !github.event.pull_request.draft }} defaults: run: @@ -157,7 +157,7 @@ jobs: int-cocoapods: name: Integration (CocoaPods) if: github.event_name == 'push' - runs-on: macos-12 + runs-on: macos-13 defaults: run: working-directory: TestApp diff --git a/CHANGELOG.md b/CHANGELOG.md index d86bf9580..61d80119a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ All notable changes to this project will be documented in this file. Take a look +## [3.0.0-alpha.1] + +### Changed + +* The `R2Shared`, `R2Streamer` and `R2Navigator` packages are now called `ReadiumShared`, `ReadiumStreamer` and `ReadiumNavigator`. +* Many APIs now expect one of the new URL types (`RelativeURL`, `AbsoluteURL`, `HTTPURL` and `FileURL`). This is helpful because: + * It validates at compile time that we provide a URL that is supported. + * The API's capabilities are better documented, e.g. a download API could look like this : `download(url: HTTPURL) -> FileURL`. + +#### 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. +* 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. + + ## [2.7.1] ### Added @@ -672,3 +689,4 @@ progression. Now if no reading progression is set, the `effectiveReadingProgress [2.6.1]: https://github.com/readium/swift-toolkit/compare/2.6.0...2.6.1 [2.7.0]: https://github.com/readium/swift-toolkit/compare/2.6.1...2.7.0 [2.7.1]: https://github.com/readium/swift-toolkit/compare/2.7.0...2.7.1 +[3.0.0-alpha.1]: https://github.com/readium/swift-toolkit/compare/2.7.1...3.0.0-alpha.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..9753e914a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,24 @@ +# Contributing to the Readium Swift Toolkit + +First and foremost, thanks for your interest! πŸ™ We need contributors like you to help bring this project to fruition. + +We welcome many kind of contributions such as improving the documentation, submitting bug reports and feature requests, or writing code. + +## Writing code + +### Coding standard + +We use [`SwiftFormat`](https://github.com/nicklockwood/SwiftFormat) to ensure code formatting and avoid bikeshedding. + +Before submitting a PR, save yourself some trouble by automatically formatting the code with `make format` from the project's root directory. + +### Modifying the EPUB Navigator's JavaScript layer + +The EPUB navigator injects a set of JavaScript files into a publication's resources, exposing a JavaScript API to the `WKWebView` under the `readium` global namespace. The JavaScript source code is located under [`Sources/Navigator/EPUB/Scripts`](Sources/Navigator/EPUB/Scripts). + +`index-reflowable.js` is the root of the bundle injected in a reflowable EPUB's resources, while `index-fixed.js` is used for a fixed-layout EPUB's resources. + +In the case of fixed-layout EPUBs, the publication resources are actually loaded inside an `iframe` in one of [our HTML wrapper pages](Sources/Navigator/EPUB/Assets/) (`fxl-spread-one.html` for single pages, `fxl-spread-two.html` when displaying two pages side-by-side). The matching `index-fixed-wrapper-one.js` and `index-fixed-wrapper-two.js` are injected in the HTML wrappers. + +If you make any changes to the JavaScript files, you must regenerate the bundles embedded in the application. First, make sure you have [`corepack` installed](https://pnpm.io/installation#using-corepack). Then, run `make scripts` from the project's root directory. + diff --git a/Documentation/Guides/Getting Started.md b/Documentation/Guides/Getting Started.md new file mode 100644 index 000000000..d94f614a2 --- /dev/null +++ b/Documentation/Guides/Getting Started.md @@ -0,0 +1,136 @@ +# Getting started + +The Readium Swift toolkit enables you to develop reading apps for iOS and iPadOS. It provides built-in support for multiple publication formats such as EPUB, PDF, audiobooks, and comics. + +:warning: Readium offers only low-level tools. You are responsible for creating a user interface for reading and managing books, as well as a data layer to store the user's publications. The Test App is an example of such integration. + +## Design principles + +The toolkit has been designed following these core tenets: + +* **Modular**: It is divided into separate modules that can be used independently. +* **Extensible**: Integrators should be able to support a custom DRM, publication format or inject their own stylesheets without modifying the toolkit itself. +* **Opiniated**: We adhere to open standards but sometimes interpret them for practicality. + +## Packages + +### Main packages + +* `R2Shared` contains shared `Publication` models and utilities. +* `R2Streamer` parses publication files (e.g. an EPUB) into a `Publication` object. +* [`R2Navigator` renders the content of a publication](Navigator/Navigator.md). + +### Specialized packages + +* `ReadiumOPDS` parses [OPDS catalog feeds](https://opds.io) (both OPDS 1 and 2). +* [`ReadiumLCP` downloads and decrypts LCP-protected publications](Readium%20LCP.md). + +### Adapters to third-party dependencies + +* `ReadiumAdapterGCDWebServer` provides an HTTP server built with [GCDWebServer](https://github.com/swisspol/GCDWebServer). + +## Overview of the shared models (`R2Shared`) + +The Readium toolkit provides models used as exchange types between packages. + +### Publication models + +#### Publication + +`Publication` and its sub-components represent a single publication – ebook, audiobook or comic. It is loosely based on the [Readium Web Publication Manifest](https://readium.org/webpub-manifest/). + +A `Publication` instance: + +* holds the metadata of a publication, such as its author or table of contents, +* allows to read the contents of a publication, e.g. XHTML or audio resources, +* provides additional services, for example content extraction or text search. + +#### Link + + +A [`Link` object](https://readium.org/webpub-manifest/#24-the-link-object) holds a pointer (URL) to a resource or service along with additional metadata, such as its media type or title. + +The `Publication` contains several `Link` collections, for example: + +* `readingOrder` lists the publication resources arranged in the order they should be read. +* `resources` contains secondary resources necessary for rendering the `readingOrder`, such as an image or a font file. +* `tableOfContents` represents the table of contents as a tree of `Link` objects. +* `links` exposes additional resources, such as a canonical link to the manifest or a search web service. + +#### Locator + +A [`Locator` object](https://readium.org/architecture/models/locators/) represents a precise location in a publication resource in a format that can be stored and shared across reading systems. It is more accurate than a `Link` and contains additional information about the location, e.g. progression percentage, position or textual context. + +`Locator` objects are used for various features, including: + +* reporting the current progression in the publication +* saving bookmarks, highlights and annotations +* navigating search results + +### Data models + +#### Publication Asset + +A `PublicationAsset` is an interface representing a single file or package holding the content of a `Publication`. A default implementation `FileAsset` grants access to a publication stored locally. + +#### Resource + +A `Resource` provides read access to a single resource of a publication, such as a file or an entry in an archive. + +`Resource` instances are usually created by a `Fetcher`. The toolkit ships with various implementations supporting different data access protocols such as local files, HTTP, etc. + +#### Fetcher + +A `Fetcher` provides read access to a collection of resources. `Fetcher` instances are created by a `PublicationAsset` to provide access to the content of a publication. + +`Publication` objects internally use a `Fetcher` to expose their content. + +## Opening a publication (`R2Streamer`) + +To retrieve a `Publication` object from a publication file like an EPUB or audiobook, begin by creating a `PublicationAsset` object used to read the file. Readium provides a `FileAsset` implementation for reading a publication stored on the local file system. + +```swift +let file = URL(fileURLWithPath: "path/to/book.epub") +let asset = FileAsset(file: file) +``` + +Then, use a `Streamer` instance to parse the asset and create a `Publication` object. + +```swift +let streamer = Streamer() + +streamer.open(asset: asset, allowUserInteraction: false) { result in + switch result { + case .success(let publication): + print("Opened \(publication.metadata.title)") + case .failure(let error): + alert(error.localizedDescription) + case .cancelled: + // The user cancelled the opening, for example by dismissing a password pop-up. + break + } +} +``` + +The `allowUserInteraction` parameter is useful when supporting a DRM like Readium LCP. It indicates if the toolkit can prompt the user for credentials when the publication is protected. + +## Accessing the metadata of a publication + +After opening a publication, you may want to read its metadata to insert a new entity into your bookshelf database, for instance. The `publication.metadata` object contains everything you need, including `title`, `authors` and the `published` date. + +You can retrieve the publication cover using `publication.cover`. Avoid calling this from the main thread to prevent blocking the user interface. + +## Rendering the publication on the screen (`R2Navigator`) + +You can use a Readium navigator to present the publication to the user. The `Navigator` renders resources on the screen and offers APIs and user interactions for navigating the contents. + +```swift +let navigator = try EPUBNavigatorViewController( + publication: publication, + initialLocation: lastReadLocation, + httpServer: GCDHTTPServer.shared +) + +hostViewController.present(navigator, animated: true) +``` +Please refer to the [Navigator guide](Navigator/Navigator.md) for more information. diff --git a/Documentation/Guides/Assets/settings-flow.svg b/Documentation/Guides/Navigator/Assets/settings-flow.svg similarity index 100% rename from Documentation/Guides/Assets/settings-flow.svg rename to Documentation/Guides/Navigator/Assets/settings-flow.svg diff --git a/Documentation/Guides/EPUB Fonts.md b/Documentation/Guides/Navigator/EPUB Fonts.md similarity index 100% rename from Documentation/Guides/EPUB Fonts.md rename to Documentation/Guides/Navigator/EPUB Fonts.md diff --git a/Documentation/Guides/Navigator/Navigator.md b/Documentation/Guides/Navigator/Navigator.md new file mode 100644 index 000000000..736713535 --- /dev/null +++ b/Documentation/Guides/Navigator/Navigator.md @@ -0,0 +1,194 @@ +# Navigator + +You can use a Readium Navigator to present the publication to the user. The `Navigator` renders resources on the screen and offers APIs and user interactions for navigating the contents. + +:warning: Navigators do not have user interfaces besides the view that displays the publication. Applications are responsible for providing a user interface with bookmark buttons, a progress bar, etc. + +## Default implementations + +The Readium toolkit comes with several `Navigator` implementations for different publication profiles. Some are `UIViewController`s, designed to be added to your view hierarchy, while others are chromeless and can be used in the background. + +| Navigator | Profile | Formats | +|-------------------------------|-------------|-----------------------------------------------------------------------| +| `EPUBNavigatorViewController` | `epub` | EPUB (`.epub`), Readium Web Publication (`.webpub`) | +| `PDFNavigatorViewController` | `pdf` | PDF (`.pdf`), LCP-protected PDF (`.lcpdf`) | +| `CBZNavigatorViewController` | `divina` | Zipped Comic Book (`cbz`), Readium Divina (`.divina`) | +| `AudioNavigator` | `audiobook` | Zipped Audio Book (`.zab`), Readium Audiobook (`.audiobook`, `.lcpa`) | + +To find out which Navigator is compatible with a publication, refer to its [profile](https://readium.org/webpub-manifest/profiles/). Use `publication.conformsTo()` to identify the publication's profile. + +```swift +if publication.conformsTo(.epub) { + let navigator = try EPUBNavigatorViewController( + publication: publication, + initialLocation: lastReadLocation, + httpServer: GCDHTTPServer.shared + ) + + hostViewController.present(navigator, animated: true) +} +``` + +## Navigator APIs + +Navigators implement a set of shared interfaces to help reuse the reading logic across publication profiles. For example, instead of using specific implementations like `EPUBNavigatorViewController`, use the `Navigator` interface to create a location history manager compatible with all Navigator types. + +You can create custom Navigators and easily integrate them into your app with minimal modifications by implementing these interfaces. + +### `Navigator` interface + +All Navigators implement the `Navigator` interface, which provides the foundation for navigating resources in a `Publication`. You can use it to move through the publication's content or to find the current position. + +Note that this interface does not specify how the content is presented to the user. + +### `VisualNavigator` interface + +Navigators rendering the content visually on the screen implement the `VisualNavigator` interface. This interface offers details about the presentation style (e.g., scrolled, right-to-left, etc.) and allows monitoring input events like taps or keyboard strokes. + +### `SelectableNavigator` interface + +Navigators enabling users to select parts of the content implement `SelectableNavigator`. You can use it to extract the `Locator` and content of the selected portion. + +### `DecorableNavigator` interface + +A Decorable Navigator is able to render decorations over a publication, such as highlights or margin icons. + +[See the corresponding proposal for more information](https://readium.org/architecture/proposals/008-decorator-api.html). + +## Instantiating a Navigator + +### Visual Navigators + +The Visual Navigators are implemented as `UIViewController` and must be added to your iOS view hierarchy to render the publication contents. + +```swift +let navigator = try EPUBNavigatorViewController( + publication: publication, + initialLocation: lastReadLocation, + httpServer: GCDHTTPServer.shared +) + +hostViewController.present(navigator, animated: true) +``` + +:point_up: The HTTP server is used to serve the publication resources to the Navigator. You may use your own implementation, or the recommended `GCDHTTPServer` which is part of the `ReadiumAdapterGCDWebServer` package. + +### Audio Navigator + +The `AudioNavigator` is chromeless and does not provide any user interface, allowing you to create your own custom UI. + +```swift +let navigator = AudioNavigator( + publication: publication, + initialLocation: lastReadLocation +) + +navigator.play() +``` + +## Navigating the contents of the publication + +The `Navigator` interface offers various `go` APIs for navigating the publication. For instance: + +* to the previous or next pages: `navigator.goForward()` or `navigator.goBackward()` +* to a link from the `publication.tableOfContents` or `publication.readingOrder`: `navigator.go(to: link)` +* to a locator from a search result: `navigator.go(to: locator)` + +## Reading progression + +### Saving and restoring the last read location + +Navigators don't store any data permanently. Therefore, it is your responsibility to save the last read location in your database and restore it when creating a new Navigator. + +You can observe the current position in the publication by implementing a `NavigatorDelegate`. + +```swift +navigator.delegate = MyNavigatorDelegate() + +class MyNavigatorDelegate: NavigatorDelegate { + + override func navigator(_ navigator: Navigator, locationDidChange locator: Locator) { + if let position = locator.locations.position { + print("At position \(position) on \(publication.positions.count)") + } + if let progression = locator.locations.progression { + return "Progression in the current resource: \(progression)%" + } + if let totalProgression = locator.locations.totalProgression { + return "Total progression in the publication: \(progression)%" + } + + // Save the position in your bookshelf database + database.saveLastReadLocation(locator.jsonString) + } +} +``` + +The `Locator` object may be serialized to JSON in your database, and deserialized to set the initial location when creating the navigator. + +```swift +let lastReadLocation = Locator(jsonString: dabase.lastReadLocation()) + +let navigator = try EPUBNavigatorViewController( + publication: publication, + initialLocation: lastReadLocation, + httpServer: GCDHTTPServer.shared +) +``` + +### Bookmarking the current location + +Use a Navigator's `currentLocation` property to persists the current position, for instance as a bookmark. + +After the user selects a bookmark from your user interface, navigate to it using `navigator.go(bookmark.locator)`. + +### Displaying a progression slider + +To display a percentage-based progression slider, use the `locations.totalProgression` property of the `currentLocation`. This property holds the total progression across an entire publication. + +Given a progression from 0 to 1, you can obtain a `Locator` object from the `Publication`. This can be used to navigate to a specific percentage within the publication. + +```swift +if let locator = publication.locate(progression: 0.5) { + navigator.go(to: locator) +} +``` + +### Displaying the number of positions + +:warning: Readium does not have the concept of pages, as they are not useful when dealing with reflowable publications across different screen sizes. Instead, we use [**positions**](https://readium.org/architecture/models/locators/positions/) which remain stable even when the user changes the font size or device. + +Not all Navigators provide positions, but most `VisualNavigator` implementations do. Verify if `publication.positions` is not empty to determine if it is supported. + +To find the total positions in the publication, use `publication.positions.count`. You can get the current position with `navigator.currentLocation?.locations.position`. + +## Navigating with edge taps and keyboard arrows + +Readium provides a `DirectionalNavigationAdapter` helper to turn pages using arrow and space keys or screen taps. + +You can use it from your `VisualNavigatorDelegate` implementation: + +```swift +extension MyReader: VisualNavigatorDelegate { + + func navigator(_ navigator: VisualNavigator, didTapAt point: CGPoint) { + // Turn pages when tapping the edge of the screen. + guard !DirectionalNavigationAdapter(navigator: navigator).didTap(at: point) else { + return + } + + toggleNavigationBar() + } + + func navigator(_ navigator: VisualNavigator, didPressKey event: KeyEvent) { + // Turn pages when pressing the arrow keys. + DirectionalNavigationAdapter(navigator: navigator).didPressKey(event: event) + } +} +``` + +`DirectionalNavigationAdapter` offers a lot of customization options. Take a look at its API. + +## User preferences + +Readium Navigators support user preferences, such as font size or background color. Take a look at [the Preferences API guide](Preferences.md) for more information. diff --git a/Documentation/Guides/Navigator Preferences.md b/Documentation/Guides/Navigator/Preferences.md similarity index 98% rename from Documentation/Guides/Navigator Preferences.md rename to Documentation/Guides/Navigator/Preferences.md index 2b3890994..0109aaca2 100644 --- a/Documentation/Guides/Navigator Preferences.md +++ b/Documentation/Guides/Navigator/Preferences.md @@ -1,7 +1,5 @@ # Configuring the Navigator -:warning: The Navigator Setting API is brand new and currently only available with `EPUBNavigatorViewController` and `PDFNavigatorViewController`. - Take a look at the [migration guide](../Migration%20Guide.md) if you are already using the legacy EPUB settings. ## Overview @@ -163,7 +161,7 @@ This stateless view displays the actual preferences for a fixed-layout publicati @ViewBuilder func fixedLayoutUserPreferences( commit: @escaping () -> Void, scroll: AnyPreference? = nil, - fit: AnyEnumPreference? = nil, + fit: AnyEnumPreference? = nil, pageSpacing: AnyRangePreference? = nil ) -> some View { if let scroll = scroll { diff --git a/Documentation/Guides/Navigator/SwiftUI.md b/Documentation/Guides/Navigator/SwiftUI.md new file mode 100644 index 000000000..5ca3b13fe --- /dev/null +++ b/Documentation/Guides/Navigator/SwiftUI.md @@ -0,0 +1,136 @@ +# Integrating the Navigator with SwiftUI + +The Navigator is built with UIKit and provides `UIViewController` implementations. Nevertheless, you can integrate them into a SwiftUI view hierarchy using Apple's [`UIViewRepresentable`](https://developer.apple.com/documentation/swiftui/uiviewrepresentable). + +## SwiftUI wrapper for a Navigator's `UIViewController` + +Here is a basic example of a `UIViewControllerRepresentable` implementation that hosts a Navigator. + +```swift +/// SwiftUI wrapper for the `ReaderViewController`. +struct ReaderViewControllerWrapper: UIViewControllerRepresentable { + let viewController: ReaderViewController + + func makeUIViewController(context: Context) -> ReaderViewController { + viewController + } + + func updateUIViewController(_ uiViewController: ReaderViewController, context: Context) {} +} + +/// Host view controller for a Readium Navigator. +class ReaderViewController: UIViewController { + + /// View model provided by your application. + private let viewModel: ReaderViewModel + + /// Readium Navigator instance. + private let navigator: Navigator & UIViewController + + init(viewModel: ReaderViewModel, navigator: Navigator & UIViewController) { + self.viewModel = viewModel + self.navigator = navigator + + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init?(coder: NSCoder) not implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + addChild(navigator) + navigator.view.frame = view.bounds + navigator.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(navigator.view) + navigator.didMove(toParent: self) + } + + /// Handler for a custom editing action. + @objc func makeHighlight(_ sender: Any) { + viewModel.makeHighlight() + } +} +``` + +Note that we could use a `Navigator` instance directly, without a parent `ReaderViewController`. However, a host view controller is necessary if you want to use custom text selection menu items and capture events in the UIKit responder chain. For instance, when configuring your EPUB Navigator with: + +```swift +var config = EPUBNavigatorViewController.Configuration() +config.editingActions.append( + EditingAction( + title: "Highlight", + action: #selector(makeHighlight) + ) +) + +let navigator = try EPUBNavigatorViewController( + publication: publication, + initialLocation: locator, + config: config, + ... +) +``` + +## Embedding the `ReaderViewControllerWrapper` in a SwiftUI view + +```swift +struct ReaderView: View { + + /// View model provided by your application. + @ObservedObject var viewModel: ReaderViewModel + + let viewControllerWrapper: ReaderViewControllerWrapper + + var body: some View { + viewControllerWrapper + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea(.all) + .navigationTitle(viewModel.book.title) + .navigationBarTitleDisplayMode(.inline) + .navigationBarHidden(viewModel.isFullscreen) + .statusBarHidden(viewModel.isFullscreen) + } +} +``` + +## Assembling the Navigator and SwiftUI objects + +Now, let's construct an EPUB navigator and assemble the SwiftUI view hierarchy to bring all the pieces together. + +```swift +var config = EPUBNavigatorViewController.Configuration() +config.editingActions.append( + EditingAction( + title: "Highlight", + action: #selector(highlightSelection) + ) +) + +let navigator = try EPUBNavigatorViewController( + publication: publication, + initialLocation: locator, + config: config, + ... +) + +// View model provided by your application. +let viewModel = ReaderViewModel() + +let view = ReaderView( + viewModel: viewModel, + viewControllerWrapper: ReaderViewControllerWrapper( + viewController: ReaderViewController( + viewModel: viewModel, + navigator: navigator + ) + ) +) +``` + +## Handling touch and keyboard events + +You still need to implement the `VisualNavigatorDelegate` protocol to handle gestures in the navigator. Avoid using SwiftUI touch modifiers, as they will prevent the user from interacting with the book. diff --git a/Documentation/Guides/README.md b/Documentation/Guides/README.md new file mode 100644 index 000000000..bf45e146c --- /dev/null +++ b/Documentation/Guides/README.md @@ -0,0 +1,10 @@ +# User guides + +* [Getting Started](Getting%20Started.md) +* [Extracting the content of a publication](Content.md) +* [Text-to-speech](TTS.md) +* [Supporting Readium LCP](Readium%20LCP.md) +* [Navigator](Navigator/Navigator.md) + * [Configuring the Navigator](Navigator/Preferences.md) + * [Font families in the EPUB navigator](Navigator/EPUB%20Fonts.md) + * [Integrating the Navigator with SwiftUI](Navigator/SwiftUI.md) \ No newline at end of file diff --git a/Documentation/Guides/Readium LCP.md b/Documentation/Guides/Readium LCP.md new file mode 100644 index 000000000..01d01b688 --- /dev/null +++ b/Documentation/Guides/Readium LCP.md @@ -0,0 +1,370 @@ +# Supporting Readium LCP + +You can use the Readium Swift toolkit to download and read publications that are protected with the [Readium LCP](https://www.edrlab.org/readium-lcp/) DRM. + +:point_up: To use LCP with the Readium toolkit, you must first obtain the `R2LCPClient` private library by contacting [EDRLab](https://www.edrlab.org/contact/). + +## Overview + +An LCP publication is protected with a *user passphrase* and distributed using an LCP License Document (`.lcpl`) . + +The user flow typically goes as follows: + +1. The user imports a `.lcpl` file into your application. +2. The application uses the Readium toolkit to download the protected publication from the `.lcpl` file to the user's bookshelf. The downloaded file can be a `.epub`, `.lcpdf` (PDF), or `.lcpa` (audiobook) package. +3. The user opens the protected publication from the bookshelf. +4. If the passphrase isn't already recorded in the `ReadiumLCP` internal database, the user will be asked to enter it to unlock the contents. +5. The publication is decrypted and rendered on the screen. + +## Setup + +To support LCP in your application, you require two components: + +* The `ReadiumLCP` package from the toolkit provides APIs for downloading and decrypting protected publications. Import it as you would any other Readium package, such as `R2Navigator`. +* The private `R2LCPClient` library customized for your application [is available from EDRLab](https://www.edrlab.org/contact/). They will provide instructions for integrating the `R2LCPClient` framework into your application. + +### File formats + +Readium LCP specifies new file formats. + +| Name | File extension | Media type | +|------|----------------|------------| +| [License Document](https://readium.org/lcp-specs/releases/lcp/latest.html#32-content-conformance) | `.lcpl` | `application/vnd.readium.lcp.license.v1.0+json` | +| [LCP for PDF package](https://readium.org/lcp-specs/notes/lcp-for-pdf.html) | `.lcpdf` | `application/pdf+lcp` | +| [LCP for Audiobooks package](https://readium.org/lcp-specs/notes/lcp-for-audiobooks.html) | `.lcpa` | `application/audiobook+lcp` | + +:point_up: EPUB files protected by LCP are supported without a special file extension or media type because EPUB accommodates any DRM scheme in its specification. + +To support these formats in your application, you need to [register them in your `Info.plist`](https://developer.apple.com/documentation/uniformtypeidentifiers/defining_file_and_data_types_for_your_app) as imported types. + +```xml + + UTImportedTypeDeclarations + + + UTTypeIdentifier + org.readium.lcpl + UTTypeConformsTo + + public.content + public.data + public.json + + UTTypeDescription + LCP License Document + UTTypeTagSpecification + + public.filename-extension + + lcpl + + public.mime-type + application/vnd.readium.lcp.license.v1.0+json + + + + UTTypeIdentifier + org.readium.lcpdf + UTTypeConformsTo + + public.content + public.data + public.archive + public.zip-archive + + UTTypeDescription + LCP for PDF package + UTTypeTagSpecification + + public.filename-extension + + lcpdf + + public.mime-type + application/pdf+lcp + + + + UTTypeIdentifier + org.readium.lcpa + UTTypeConformsTo + + public.content + public.data + public.archive + public.zip-archive + + UTTypeDescription + LCP for Audiobooks package + UTTypeTagSpecification + + public.filename-extension + + lcpa + + public.mime-type + application/audiobook+lcp + + + + +``` + +Next, declare the imported types as [Document Types](https://help.apple.com/xcode/mac/current/#/devddd273fdd) in the `Info.plist` to have your application listed in the "Open with..." dialogs. + +```xml + + CFBundleDocumentTypes + + + CFBundleTypeName + LCP License Document + CFBundleTypeRole + Viewer + LSItemContentTypes + + org.readium.lcpl + + + + CFBundleTypeName + LCP for PDF package + CFBundleTypeRole + Viewer + LSItemContentTypes + + org.readium.lcpdf + + + + CFBundleTypeName + LCP for Audiobooks package + CFBundleTypeRole + Viewer + LSItemContentTypes + + org.readium.lcpa + + + + +``` + +:point_up: If EPUB is not included in your document types, now is a good time to add it. + +## Initializing the `LCPService` + +`ReadiumLCP` offers an `LCPService` object that exposes its API. Since the `ReadiumLCP` package is not linked with `R2LCPClient`, you need to create your own adapter when setting up the `LCPService`. + +```swift +import R2LCPClient +import ReadiumLCP + +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 { + 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? { + R2LCPClient.findOneValidPassphrase(jsonLicense: jsonLicense, hashedPassphrases: hashedPassphrases) + } +} +``` + +## Acquiring a publication from a License Document (LCPL) + +Users need to import a License Document into your application to download the protected publication (`.epub`, `.lcpdf`, or `.lcpa`). + +The `LCPService` offers an API to retrieve the full publication from an LCPL on the filesystem. + +```swift +let acquisition = lcpService.acquirePublication( + from: lcplURL, + onProgress: { progress in + switch progress { + case .indefinite: + // Display an activity indicator. + case .percent(let percent): + // Display a progress bar with percent from 0 to 1. + } + }, + completion: { result in + switch result { + case let .success(publication): + // Import the `publication.localURL` file as any publication. + case let .failure(error): + // Display the error message + case .cancelled: + // The acquisition was cancelled before completion. + } + } +) +``` + +If the user wants to cancel the download, call `cancel()` on the object returned by `LCPService.acquirePublication()`. + +After the download is completed, import the `publication.localURL` file into the bookshelf like any other publication file. + +## Opening a publication protected with LCP + +### Initializing the `Streamer` + +A publication protected with LCP can be opened using the `Streamer` component, just like a non-protected publication. However, you must provide a [`ContentProtection`](https://readium.org/architecture/proposals/006-content-protection.html) implementation when initializing the `Streamer` to enable LCP. Luckily, `LCPService` has you covered. + +```swift +let authentication = LCPDialogAuthentication() + +let streamer = Streamer( + contentProtections: [ + lcpService.contentProtection(with: authentication) + ] +) +``` + +An LCP package is secured with a *user passphrase* for decrypting the content. The `LCPAuthenticating` protocol used by `LCPService.contentProtection(with:)` provides the passphrase when needed. You can use the default `LCPDialogAuthentication` which displays a pop-up to enter the passphrase, or implement your own method for passphrase retrieval. + +:point_up: The user will be prompted once per passphrase since `ReadiumLCP` stores known passphrases on the device. + +### Opening the publication + +You are now ready to open the publication file with your `Streamer` instance. + +```swift +streamer.open( + asset: FileAsset(url: publicationURL), + allowUserInteraction: true, + sender: hostViewController, + completion: { result in + switch result { + case .success(let publication): + // Import or present the publication. + case .failure(let error): + // Present the error. + case .cancelled: + // The operation was cancelled. + } + } +) +``` + +The `allowUserInteraction` and `sender` arguments are forwarded to the `LCPAuthenticating` implementation when the passphrase is unknown. `LCPDialogAuthentication` shows a pop-up only if `allowUserInteraction` is `true`, using the `sender` as the pop-up's host `UIViewController`. + +When importing the publication to the bookshelf, set `allowUserInteraction` to `false` as you don't need the passphrase for accessing the publication metadata and cover. If you intend to present the publication using a Navigator, set `allowUserInteraction` to `true` as decryption will be required. + +:point_up: To check if a publication is protected with LCP before opening it, you can use `LCPService.isLCPProtected()`. + +### Using the opened `Publication` + +After obtaining a `Publication` instance, you can access the publication's metadata to import it into the user's bookshelf. The user passphrase is not needed for reading the metadata or cover. + +However, if you want to display the publication with a Navigator, verify it is not restricted. It could be restricted if the user passphrase is unknown or if the license is no longer valid (e.g., expired loan, revoked purchase, etc.). + +```swift +if publication.isRestricted { + if let error = publication.protectionError as? LCPError { + // The user is not allowed to open the publication. You should display the error. + } else { + // We don't have the user passphrase. + // You may use `publication` to access its metadata, but not to render its content. + } +} else { + // The publication is not restricted, you may render it with a Navigator component. +} +``` + +## Obtaining information on an LCP license + +An LCP License Document contains metadata such as its expiration date, the remaining number of characters to copy and the user name. You can access this information using an `LCPLicense` object. + +Use the `LCPService` to retrieve the `LCPLicense` instance for a publication. + +```swift +lcpService.retrieveLicense( + from: publicationURL, + authentication: LCPDialogAuthentication(), + allowUserInteraction: true, + sender: hostViewController +) { result in + switch result { + case .success(let lcpLicense): + if let lcpLicense = lcpLicense { + if let user = lcpLicense.license.user.name { + print("The publication was acquired by \(user)") + } + if let endDate = lcpLicense.license.rights.end { + print("The loan expires on \(endDate)") + } + if let copyLeft = lcpLicense.charactersToCopyLeft { + print("You can copy up to \(copyLeft) characters remaining.") + } + } else { + // The file was not protected by LCP. + } + case .failure(let error): + // Display the error. + case .cancelled: + // The operation was cancelled. + } +} +``` + +If you have already opened a `Publication` with the `Streamer`, you can directly obtain the `LCPLicense` using `publication.lcpLicense`. + +## Managing a loan + +Readium LCP allows borrowing publications for a specific period. Use the `LCPLicense` object to manage a loan and retrieve its end date using `lcpLicense.license.rights.end`. + +### Returning a loan + +Some loans can be returned before the end date. You can confirm this by using `lcpLicense.canReturnPublication`. To return the publication, execute: + +```swift +lcpLicense.returnPublication { error in + if let error = error { + // Present the error. + } else { + // The publication was returned. + } +} +``` + +### Renewing a loan + +The loan end date may also be extended. You can confirm this by using `lcpLicense.canRenewLoan`. + +Readium LCP supports [two types of renewal interactions](https://readium.org/lcp-specs/releases/lsd/latest#35-renewing-a-license): + +* Programmatic: You show your own user interface. +* Interactive: You display a web view, and the Readium LSD server manages the renewal for you. + +You need to support both interactions by implementing the `LCPRenewDelegate` protocol. A default implementation is available with `LCPDefaultRenewDelegate`. + +```swift +lcpLicense.renewLoan( + with: LCPDefaultRenewDelegate( + presentingViewController: hostViewController + ) +) { result in + switch result { + case .success, .cancelled: + // The publication was renewed. + case let .failure(error): + // Display the error. + } +} +``` + +## Handling `LCPError` + +The APIs may fail with an `LCPError`. These errors **must** be displayed to the user with a suitable message. + +`LCPError` implements `LocalizedError`, enabling you to retrieve a user-friendly message. It's recommended to override the LCP localized strings in your app to translate them. These strings can be found at [Sources/LCP/Resources/en.lproj/Localizable.strings](https://github.com/readium/swift-toolkit/blob/main/Sources/LCP/Resources/en.lproj/Localizable.strings). + +:warning: In the next major update, `LCPError` will no longer be localized. Applications will need to provide their own localized error messages. If you are adding LCP to a new app, consider treating `LCPError` as non-localized from the start to ease future migration. diff --git a/Documentation/Migration Guide.md b/Documentation/Migration Guide.md index 7d880f2a0..f2dc1f470 100644 --- a/Documentation/Migration Guide.md +++ b/Documentation/Migration Guide.md @@ -2,6 +2,73 @@ All migration steps necessary in reading apps to upgrade to major versions of the Swift Readium toolkit will be documented in this file. +## 3.0.0-alpha.1 + +### R2 prefix dropped + +The `R2` prefix is now deprecated. The `R2Shared`, `R2Streamer` and `R2Navigator` packages were renamed as `ReadiumShared`, `ReadiumStreamer` and `ReadiumNavigator`. + +You will need to update your imports, as well as the dependencies you include in your project: + +* Swift Package Manager: There's nothing to do. +* Carthage: + * Update the Carthage dependencies and make sure the new `ReadiumShared.xcframework`, `ReadiumStreamer.xcframework` and `ReadiumNavigator.xcframework` were built. + * Replace the old frameworks with the new ones in your project. +* CocoaPods: + * Update the `pod` statements in your `Podfile` with the following, before running `pod install`: + ``` + pod 'ReadiumShared', podspec: 'https://raw.githubusercontent.com/readium/swift-toolkit/3.0.0/Support/CocoaPods/ReadiumShared.podspec' + pod 'ReadiumStreamer', podspec: 'https://raw.githubusercontent.com/readium/swift-toolkit/3.0.0/Support/CocoaPods/ReadiumStreamer.podspec' + pod 'ReadiumNavigator', podspec: 'https://raw.githubusercontent.com/readium/swift-toolkit/3.0.0/Support/CocoaPods/ReadiumNavigator.podspec' + ``` + +### Migration of HREFs and Locators (bookmarks, annotations, etc.) + + :warning: This requires a database migration in your application, if you were persisting `Locator` objects. + + In Readium v2.x, a `Link` or `Locator`'s `href` could be either: + + * a valid absolute URL for a streamed publication, e.g. `https://domain.com/isbn/dir/my%20chapter.html`, + * a percent-decoded path for a local archive such as an EPUB, e.g. `/dir/my chapter.html`. + * Note that it was relative to the root of the archive (`/`). + + To improve the interoperability with other Readium toolkits (in particular the Readium Web Toolkits, which only work in a streaming context) **Readium v3 now generates and expects valid URLs** for `Locator` and `Link`'s `href`. + + * `https://domain.com/isbn/dir/my%20chapter.html` is left unchanged, as it was already a valid URL. + * `/dir/my chapter.html` becomes the relative URL path `dir/my%20chapter.html` + * We dropped the `/` prefix to avoid issues when resolving to a base URL. + * Special characters are percent-encoded. + + **You must migrate the HREFs or Locators stored in your database** when upgrading to Readium 3. To assist you, two helpers are provided: `AnyURL(legacyHREF:)` and `Locator(legacyJSONString:)`. + + Here's an example of a [GRDB migration](https://swiftpackageindex.com/groue/grdb.swift/master/documentation/grdb/migrations) that can serve as inspiration: + + ```swift + migrator.registerMigration("normalizeHREFs") { db in + let normalizedRows: [(id: Int, href: String, locator: String)] = + try Row.fetchAll(db, sql: "SELECT id, href, locator FROM bookmarks") + .compactMap { row in + guard + let normalizedHREF = AnyURL(legacyHREF: row["href"])?.string, + let normalizedLocator = try Locator(legacyJSONString: row["locator"])?.jsonString + else { + return nil + } + return (row["id"], normalizedHREF, normalizedLocator) + } + + let updateStmt = try db.makeStatement(sql: "UPDATE bookmarks SET href = :href, locator = :locator WHERE id = :id") + for (id, href, locator) in normalizedRows { + try updateStmt.execute(arguments: [ + "id": id, + "href": href + "locator": locator + ]) + } +} +``` + + ## 2.7.0 ### `AudioNavigator` is now stable @@ -56,7 +123,7 @@ Instead, the EPUB, PDF and CBZ navigators expect an instance of `HTTPServer` upo You can implement your own HTTP server using a third-party library. But the easiest way to migrate is to use the one provided in the new Readium package `ReadiumAdapterGCDWebServer`. ```swift -import R2Navigator +import ReadiumNavigator import ReadiumAdapterGCDWebServer let navigator = try EPUBNavigatorViewController( @@ -167,15 +234,15 @@ Then, rebuild the libraries using `carthage update --platform ios --use-xcframew If you are using CocoaPods, you will need to update the URL to the Podspecs in your `Podfile`: ```diff -+ pod 'R2Shared', podspec: 'https://raw.githubusercontent.com/readium/swift-toolkit/2.2.0/Support/CocoaPods/ReadiumShared.podspec' -+ pod 'R2Streamer', podspec: 'https://raw.githubusercontent.com/readium/swift-toolkit/2.2.0/Support/CocoaPods/ReadiumStreamer.podspec' -+ pod 'R2Navigator', podspec: 'https://raw.githubusercontent.com/readium/swift-toolkit/2.2.0/Support/CocoaPods/ReadiumNavigator.podspec' ++ pod 'ReadiumShared', podspec: 'https://raw.githubusercontent.com/readium/swift-toolkit/2.2.0/Support/CocoaPods/ReadiumShared.podspec' ++ pod 'ReadiumStreamer', podspec: 'https://raw.githubusercontent.com/readium/swift-toolkit/2.2.0/Support/CocoaPods/ReadiumStreamer.podspec' ++ pod 'ReadiumNavigator', podspec: 'https://raw.githubusercontent.com/readium/swift-toolkit/2.2.0/Support/CocoaPods/ReadiumNavigator.podspec' + pod 'ReadiumOPDS', podspec: 'https://raw.githubusercontent.com/readium/swift-toolkit/2.2.0/Support/CocoaPods/ReadiumOPDS.podspec' + pod 'ReadiumLCP', podspec: 'https://raw.githubusercontent.com/readium/swift-toolkit/2.2.0/Support/CocoaPods/ReadiumLCP.podspec' -- pod 'R2Shared', podspec: 'https://raw.githubusercontent.com/readium/r2-shared-swift/2.2.0/R2Shared.podspec' -- pod 'R2Streamer', podspec: 'https://raw.githubusercontent.com/readium/r2-streamer-swift/2.2.0/R2Streamer.podspec' -- pod 'R2Navigator', podspec: 'https://raw.githubusercontent.com/readium/r2-navigator-swift/2.2.0/R2Navigator.podspec' +- pod 'ReadiumShared', podspec: 'https://raw.githubusercontent.com/readium/r2-shared-swift/2.2.0/ReadiumShared.podspec' +- pod 'ReadiumStreamer', podspec: 'https://raw.githubusercontent.com/readium/r2-streamer-swift/2.2.0/ReadiumStreamer.podspec' +- pod 'ReadiumNavigator', podspec: 'https://raw.githubusercontent.com/readium/r2-navigator-swift/2.2.0/ReadiumNavigator.podspec' - pod 'ReadiumOPDS', podspec: 'https://raw.githubusercontent.com/readium/r2-opds-swift/2.2.0/ReadiumOPDS.podspec' - pod 'ReadiumLCP', podspec: 'https://raw.githubusercontent.com/readium/r2-lcp-swift/2.2.0/ReadiumLCP.podspec' ``` @@ -275,7 +342,7 @@ Migrating a project to XCFrameworks is [explained on Carthage's repository](http #### Troubleshooting -If after migrating to XCFrameworks you experience some build issues like **Could not find module 'R2Shared' for target 'X'**, try building the `r2-shared-swift` target with Xcode manually, before building your app. If you know of a better way to handle this, [please share it with the community](https://github.com/readium/r2-testapp-swift/issues/new). +If after migrating to XCFrameworks you experience some build issues like **Could not find module 'ReadiumShared' for target 'X'**, try building the `r2-shared-swift` target with Xcode manually, before building your app. If you know of a better way to handle this, [please share it with the community](https://github.com/readium/r2-testapp-swift/issues/new). ### LCP diff --git a/MAINTAINING.md b/MAINTAINING.md new file mode 100644 index 000000000..af381da8a --- /dev/null +++ b/MAINTAINING.md @@ -0,0 +1,41 @@ +# Maintaining the Readium Swift toolkit + +## Releasing a new version + +You are ready to release a new version of the Swift toolkit? Great, follow these steps: + +1. Figure out the next version using the [semantic versioning scheme](https://semver.org). +2. Test a migration from the last released version. + 1. Create a **temporary** Git tag for `develop` with the next version tag (e.g. `3.0.1`). + 2. Clone the `swift-toolkit` from the previous version (`main` branch). + 3. Under `TestApp`, initialize it with the next toolkit version: + ```shell + make spm version=3.0.1 lcp=... + ``` + 4. Try to run the Test App, adjusting the integration if needed. + 5. Delete the Git tag created previously. +3. Update the [migration guide](Documentation/Migration%20Guide.md) in case of breaking changes. +4. Issue the new release. + 1. Create a branch with the same name as the future tag, from `develop`. + 2. Bump the version numbers in the `Support/CocoaPods/*.podspec` files. + * :warning: Don't forget to use `:tag` in the `Podspec` files instead of `:branch`, [for example](https://github.com/readium/swift-toolkit/pull/353/commits/a0714589b3da928dd923ba78f379116715797333#diff-b726fa4aff3ea878dedf3e0f78607c09975ef5412966dc1b547d9b5e9e4b0d9cL9). + 3. Bump the version numbers in `README.md`. + 4. Bump the version numbers in `TestApp/Sources/Info.plist`. + 5. Close the version in the `CHANGELOG.md`, [for example](https://github.com/readium/swift-toolkit/pull/353/commits/a0714589b3da928dd923ba78f379116715797333#diff-06572a96a58dc510037d5efa622f9bec8519bc1beab13c9f251e97e657a9d4ed). + 6. Create a PR to merge in `develop` and verify the CI workflows. + 7. Squash and merge the PR. + 8. Tag the new version from `develop`. + ```shell + git checkout develop + git pull + git tag -a 3.0.1 -m 3.0.1 + git push --tags + ``` +5. Verify you can fetch the new version from the latest Test App with `make spm|carthage|cocoapods version=3.0.1` +7. Announce the release. + 1. Create a new release on GitHub. + 2. Publish a new TestFlight beta with LCP enabled. + * Click on "External Groups" > "Public Beta", then add the new build so that it's available to everyone. +8. Merge `develop` into `main`. +9. :warning: Revert to `:branch => "develop"` in the `Podspec` files in `develop`. + diff --git a/Makefile b/Makefile index de047df94..2a129cdc5 100644 --- a/Makefile +++ b/Makefile @@ -18,16 +18,22 @@ scripts: @which corepack >/dev/null 2>&1 || (echo "ERROR: corepack is required, please install it first\nhttps://pnpm.io/installation#using-corepack"; exit 1) cd $(SCRIPTS_PATH); \ + rm -rf "node_modules"; \ corepack install; \ pnpm install --frozen-lockfile; \ pnpm run format; \ pnpm run lint; \ pnpm run bundle +.PHONY: update-scripts +update-scripts: + @which corepack >/dev/null 2>&1 || (echo "ERROR: corepack is required, please install it first\nhttps://pnpm.io/installation#using-corepack"; exit 1) + pnpm install --dir "$(SCRIPTS_PATH)" + .PHONY: test test: - # To limit to a particular test suite: -only-testing:R2SharedTests - xcodebuild test -scheme "Readium-Package" -destination "platform=iOS Simulator,name=iPhone 12" | xcbeautify -q + # To limit to a particular test suite: -only-testing:ReadiumSharedTests + xcodebuild test -scheme "Readium-Package" -destination "platform=iOS Simulator,name=iPhone 15" | xcbeautify -q .PHONY: lint-format lint-format: diff --git a/Package.swift b/Package.swift index 5cc1d423a..388a3cf51 100644 --- a/Package.swift +++ b/Package.swift @@ -12,9 +12,9 @@ let package = Package( defaultLocalization: "en", platforms: [.iOS(.v11)], products: [ - .library(name: "R2Shared", targets: ["R2Shared"]), - .library(name: "R2Streamer", targets: ["R2Streamer"]), - .library(name: "R2Navigator", targets: ["R2Navigator"]), + .library(name: "ReadiumShared", targets: ["ReadiumShared"]), + .library(name: "ReadiumStreamer", targets: ["ReadiumStreamer"]), + .library(name: "ReadiumNavigator", targets: ["ReadiumNavigator"]), .library(name: "ReadiumOPDS", targets: ["ReadiumOPDS"]), .library(name: "ReadiumLCP", targets: ["ReadiumLCP"]), @@ -37,7 +37,7 @@ let package = Package( ], targets: [ .target( - name: "R2Shared", + name: "ReadiumShared", dependencies: ["ReadiumInternal", "Fuzi", "SwiftSoup", "Zip"], path: "Sources/Shared", exclude: [ @@ -53,8 +53,8 @@ let package = Package( ] ), .testTarget( - name: "R2SharedTests", - dependencies: ["R2Shared"], + name: "ReadiumSharedTests", + dependencies: ["ReadiumShared"], path: "Tests/SharedTests", resources: [ .copy("Fixtures"), @@ -62,13 +62,13 @@ let package = Package( ), .target( - name: "R2Streamer", + name: "ReadiumStreamer", dependencies: [ "CryptoSwift", "Fuzi", .product(name: "ReadiumGCDWebServer", package: "GCDWebServer"), "Zip", - "R2Shared", + "ReadiumShared", ], path: "Sources/Streamer", resources: [ @@ -76,8 +76,8 @@ let package = Package( ] ), .testTarget( - name: "R2StreamerTests", - dependencies: ["R2Streamer"], + name: "ReadiumStreamerTests", + dependencies: ["ReadiumStreamer"], path: "Tests/StreamerTests", resources: [ .copy("Fixtures"), @@ -85,10 +85,10 @@ let package = Package( ), .target( - name: "R2Navigator", + name: "ReadiumNavigator", dependencies: [ "ReadiumInternal", - "R2Shared", + "ReadiumShared", "DifferenceKit", "SwiftSoup", ], @@ -102,8 +102,8 @@ let package = Package( ] ), .testTarget( - name: "R2NavigatorTests", - dependencies: ["R2Navigator"], + name: "ReadiumNavigatorTests", + dependencies: ["ReadiumNavigator"], path: "Tests/NavigatorTests" ), @@ -111,7 +111,7 @@ let package = Package( name: "ReadiumOPDS", dependencies: [ "Fuzi", - "R2Shared", + "ReadiumShared", ], path: "Sources/OPDS" ), @@ -129,7 +129,7 @@ let package = Package( dependencies: [ "CryptoSwift", "ZIPFoundation", - "R2Shared", + "ReadiumShared", .product(name: "SQLite", package: "SQLite.swift"), ], path: "Sources/LCP", @@ -152,7 +152,7 @@ let package = Package( name: "ReadiumAdapterGCDWebServer", dependencies: [ .product(name: "ReadiumGCDWebServer", package: "GCDWebServer"), - "R2Shared", + "ReadiumShared", ], path: "Sources/Adapters/GCDWebServer" ), diff --git a/README.md b/README.md index 4ac7a19cc..695ee61e8 100644 --- a/README.md +++ b/README.md @@ -2,26 +2,26 @@ [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](Documentation/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). -* [`R2Shared`](Sources/Shared) – Shared `Publication` models and utilities -* [`R2Streamer`](Sources/Streamer) – Publication parsers and local HTTP server -* [`R2Navigator`](Sources/Navigator) – Plain `UIViewController` classes rendering publications +* [`ReadiumShared`](Sources/Shared) – Shared `Publication` models and utilities +* [`ReadiumStreamer`](Sources/Streamer) – Publication parsers and local HTTP server +* [`ReadiumNavigator`](Sources/Navigator) – Plain `UIViewController` classes rendering publications * [`ReadiumOPDS`](Sources/OPDS) – Parsers for OPDS catalog feeds * [`ReadiumLCP`](Sources/LCP) – Service and models for [Readium LCP](https://www.edrlab.org/readium-lcp/) -A [Test App](TestApp) demonstrates how to integrate the Readium Swift toolkit in your own reading app - ## Minimum Requirements -| Readium | iOS | Swift compiler | Xcode | -|-----------|------|----------------|-------| -| `develop` | 11.0 | 5.6.1 | 13.4 | -| 2.5.1 | 11.0 | 5.6.1 | 13.4 | -| 2.5.0 | 10.0 | 5.6.1 | 13.4 | -| 2.4.0 | 10.0 | 5.3.2 | 12.4 | +| Readium | iOS | Swift compiler | Xcode | +|-----------|------|----------------|--------| +| `develop` | 11.0 | 5.9 | 15.0.1 | +| 2.5.1 | 11.0 | 5.6.1 | 13.4 | +| 2.5.0 | 10.0 | 5.6.1 | 13.4 | +| 2.4.0 | 10.0 | 5.3.2 | 12.4 | ## Using Readium @@ -44,7 +44,7 @@ If you're stuck, find more information at [developer.apple.com](https://develope Add the following to your `Cartfile`: ``` -github "readium/swift-toolkit" ~> 2.7.1 +github "readium/swift-toolkit" ~> 3.0.0-alpha.1 ``` Then, [follow the usual Carthage steps](https://github.com/Carthage/Carthage#adding-frameworks-to-an-application) to add the Readium libraries to your project. @@ -53,9 +53,9 @@ Note that Carthage will build all Readium modules and their dependencies, but yo Refer to the following table to know which dependencies are required for each Readium library. -| | `R2Shared` | `R2Streamer` | `R2Navigator` | `ReadiumOPDS` | `ReadiumLCP` | +| | `ReadiumShared` | `ReadiumStreamer` | `ReadiumNavigator` | `ReadiumOPDS` | `ReadiumLCP` | |-----------------------|:------------------:|:------------------:|:------------------:|:------------------:|:------------------:| -| **`R2Shared`** | | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | +| **`ReadiumShared`** | | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | | **`ReadiumInternal`** | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | | `CryptoSwift` | | :heavy_check_mark: | | | :heavy_check_mark: | | `DifferenceKit` | | | :heavy_check_mark: | | | @@ -71,14 +71,14 @@ Refer to the following table to know which dependencies are required for each Re Add the following `pod` statements to your `Podfile` for the Readium libraries you want to use: ``` -pod 'R2Shared', podspec: 'https://raw.githubusercontent.com/readium/swift-toolkit/2.7.1/Support/CocoaPods/ReadiumShared.podspec' -pod 'R2Streamer', podspec: 'https://raw.githubusercontent.com/readium/swift-toolkit/2.7.1/Support/CocoaPods/ReadiumStreamer.podspec' -pod 'R2Navigator', podspec: 'https://raw.githubusercontent.com/readium/swift-toolkit/2.7.1/Support/CocoaPods/ReadiumNavigator.podspec' -pod 'ReadiumOPDS', podspec: 'https://raw.githubusercontent.com/readium/swift-toolkit/2.7.1/Support/CocoaPods/ReadiumOPDS.podspec' -pod 'ReadiumLCP', podspec: 'https://raw.githubusercontent.com/readium/swift-toolkit/2.7.1/Support/CocoaPods/ReadiumLCP.podspec' -pod 'ReadiumInternal', podspec: 'https://raw.githubusercontent.com/readium/swift-toolkit/2.7.1/Support/CocoaPods/ReadiumInternal.podspec' - -# Required if you use R2Streamer. +pod 'ReadiumShared', podspec: 'https://raw.githubusercontent.com/readium/swift-toolkit/3.0.0-alpha.1/Support/CocoaPods/ReadiumShared.podspec' +pod 'ReadiumStreamer', podspec: 'https://raw.githubusercontent.com/readium/swift-toolkit/3.0.0-alpha.1/Support/CocoaPods/ReadiumStreamer.podspec' +pod 'ReadiumNavigator', podspec: 'https://raw.githubusercontent.com/readium/swift-toolkit/3.0.0-alpha.1/Support/CocoaPods/ReadiumNavigator.podspec' +pod 'ReadiumOPDS', podspec: 'https://raw.githubusercontent.com/readium/swift-toolkit/3.0.0-alpha.1/Support/CocoaPods/ReadiumOPDS.podspec' +pod 'ReadiumLCP', podspec: 'https://raw.githubusercontent.com/readium/swift-toolkit/3.0.0-alpha.1/Support/CocoaPods/ReadiumLCP.podspec' +pod 'ReadiumInternal', podspec: 'https://raw.githubusercontent.com/readium/swift-toolkit/3.0.0-alpha.1/Support/CocoaPods/ReadiumInternal.podspec' + +# Required if you use ReadiumStreamer. pod 'ReadiumGCDWebServer', podspec: 'https://raw.githubusercontent.com/readium/GCDWebServer/4.0.0/GCDWebServer.podspec' ``` diff --git a/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift b/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift index 4b3198215..185061979 100644 --- a/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift +++ b/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift @@ -5,13 +5,14 @@ // import Foundation -import R2Shared import ReadiumGCDWebServer +import ReadiumShared import UIKit public enum GCDHTTPServerError: Error { case failedToStartServer(cause: Error) case serverNotStarted + case invalidEndpoint(HTTPServerEndpoint) case nullServerURL } @@ -29,14 +30,14 @@ public class GCDHTTPServer: HTTPServer, Loggable { private let server = ReadiumGCDWebServer() /// Mapping between endpoints and their handlers. - private var handlers: [HTTPServerEndpoint: EndpointHandler] = [:] + private var handlers: [HTTPURL: EndpointHandler] = [:] /// Mapping between endpoints and resource transformers. - private var transformers: [HTTPServerEndpoint: [ResourceTransformer]] = [:] + private var transformers: [HTTPURL: [ResourceTransformer]] = [:] private enum State { case stopped - case started(port: UInt, baseURL: URL) + case started(port: UInt, baseURL: HTTPURL) } private var state: State = .stopped @@ -124,12 +125,11 @@ public class GCDHTTPServer: HTTPServer, Loggable { } queue.async { [self] in - var path = request.path.removingPrefix("/") - path = path.removingPercentEncoding ?? path - // Remove anchors and query params - let pathWithoutAnchor = path.components(separatedBy: .init(charactersIn: "#?")).first ?? path + guard let url = request.url.httpURL else { + fatalError("Expected an HTTP URL") + } - func transform(resource: Resource, at endpoint: HTTPServerEndpoint) -> Resource { + func transform(resource: Resource, at endpoint: HTTPURL) -> Resource { guard let transformers = transformers[endpoint], !transformers.isEmpty else { return resource } @@ -140,39 +140,49 @@ public class GCDHTTPServer: HTTPServer, Loggable { return resource } + let pathWithoutAnchor = url.removingQuery().removingFragment() + for (endpoint, handler) in handlers { if endpoint == pathWithoutAnchor { - let request = HTTPServerRequest(url: request.url, href: nil) + let request = HTTPServerRequest(url: url, href: nil) let resource = handler.resourceHandler(request) - completion(request, - transform(resource: resource, at: endpoint), - handler.failureHandler) + completion( + request, + transform(resource: resource, at: endpoint), + handler.failureHandler + ) return - } else if path.hasPrefix(endpoint.addingSuffix("/")) { + } else if let href = endpoint.relativize(url) { let request = HTTPServerRequest( - url: request.url, - href: path.removingPrefix(endpoint.removingSuffix("/")) + url: url, + href: href ) let resource = handler.resourceHandler(request) - completion(request, - transform(resource: resource, at: endpoint), - handler.failureHandler) + completion( + request, + transform(resource: resource, at: endpoint), + handler.failureHandler + ) return } } log(.warning, "Resource not found for request \(request)") - completion(HTTPServerRequest(url: request.url, href: nil), - FailureResource(link: Link(href: request.url.absoluteString), - error: .notFound(nil)), - nil) + completion( + HTTPServerRequest(url: url, href: nil), + FailureResource( + link: Link(href: request.url.absoluteString), + error: .notFound(nil) + ), + nil + ) } } // MARK: HTTPServer - public func serve(at endpoint: HTTPServerEndpoint, handler: @escaping (HTTPServerRequest) -> Resource) throws -> URL { + public func serve(at endpoint: HTTPServerEndpoint, handler: @escaping (HTTPServerRequest) -> Resource) throws -> HTTPURL { try serve(at: endpoint, handler: handler, failureHandler: nil) } @@ -180,36 +190,49 @@ public class GCDHTTPServer: HTTPServer, Loggable { at endpoint: HTTPServerEndpoint, handler: @escaping (HTTPServerRequest) -> Resource, failureHandler: FailureHandler? - ) throws -> URL { + ) throws -> HTTPURL { try queue.sync(flags: .barrier) { if case .stopped = state { try start() } - guard case let .started(port: _, baseURL: baseURL) = state else { - throw GCDHTTPServerError.serverNotStarted - } - - handlers[endpoint] = EndpointHandler(resourceHandler: handler, - failureHandler: failureHandler) - - return baseURL.appendingPathComponent(endpoint) + let url = try url(for: endpoint) + handlers[url] = EndpointHandler( + resourceHandler: handler, + failureHandler: failureHandler + ) + return url } } - public func transformResources(at endpoint: HTTPServerEndpoint, with transformer: @escaping ResourceTransformer) { - queue.sync(flags: .barrier) { - var trs = transformers[endpoint] ?? [] + public func transformResources(at endpoint: HTTPServerEndpoint, with transformer: @escaping ResourceTransformer) throws { + try queue.sync(flags: .barrier) { + let url = try url(for: endpoint) + var trs = transformers[url] ?? [] trs.append(transformer) - transformers[endpoint] = trs + transformers[url] = trs } } - public func remove(at endpoint: HTTPServerEndpoint) { - queue.sync(flags: .barrier) { - handlers.removeValue(forKey: endpoint) - transformers.removeValue(forKey: endpoint) + public func remove(at endpoint: HTTPServerEndpoint) throws { + try queue.sync(flags: .barrier) { + let url = try url(for: endpoint) + handlers.removeValue(forKey: url) + transformers.removeValue(forKey: url) + } + } + + private func url(for endpoint: HTTPServerEndpoint) throws -> HTTPURL { + guard case let .started(port: _, baseURL: baseURL) = state else { + throw GCDHTTPServerError.serverNotStarted + } + guard + let endpointPath = RelativeURL(string: endpoint.addingSuffix("/")), + let endpointURL = baseURL.resolve(endpointPath) + else { + throw GCDHTTPServerError.invalidEndpoint(endpoint) } + return endpointURL } // MARK: Server lifecycle @@ -262,7 +285,7 @@ public class GCDHTTPServer: HTTPServer, Loggable { throw GCDHTTPServerError.failedToStartServer(cause: error) } - guard let baseURL = server.serverURL else { + guard let baseURL = server.serverURL?.httpURL else { stop() throw GCDHTTPServerError.nullServerURL } diff --git a/Sources/Adapters/GCDWebServer/ResourceResponse.swift b/Sources/Adapters/GCDWebServer/ResourceResponse.swift index a34033c90..241ca0cf0 100644 --- a/Sources/Adapters/GCDWebServer/ResourceResponse.swift +++ b/Sources/Adapters/GCDWebServer/ResourceResponse.swift @@ -5,8 +5,8 @@ // import Foundation -import R2Shared import ReadiumGCDWebServer +import ReadiumShared /// Errors thrown by the `WebServerResourceResponse` /// diff --git a/Sources/Internal/Extensions/Array.swift b/Sources/Internal/Extensions/Array.swift index 80607d098..5d7136707 100644 --- a/Sources/Internal/Extensions/Array.swift +++ b/Sources/Internal/Extensions/Array.swift @@ -57,6 +57,10 @@ public extension Array where Element: Hashable { array.removeAll { other in other == element } return array } + + @inlinable mutating func remove(_ element: Element) { + removeAll { other in other == element } + } } public extension Array where Element: Equatable { diff --git a/Sources/Internal/Extensions/String.swift b/Sources/Internal/Extensions/String.swift index 1f9609c87..38eca64a2 100644 --- a/Sources/Internal/Extensions/String.swift +++ b/Sources/Internal/Extensions/String.swift @@ -84,4 +84,11 @@ public extension String { } return index } + + func orNilIfEmpty() -> String? { + guard !isEmpty else { + return nil + } + return self + } } diff --git a/Sources/Internal/Extensions/URL.swift b/Sources/Internal/Extensions/URL.swift new file mode 100644 index 000000000..8f84ff7e2 --- /dev/null +++ b/Sources/Internal/Extensions/URL.swift @@ -0,0 +1,36 @@ +// +// 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 URL { + /// Removes the fragment portion of the receiver and returns it. + mutating func removeFragment() -> String? { + var fragment: String? + guard let result = copy({ + fragment = $0.fragment + $0.fragment = nil + }) else { + return nil + } + self = result + return fragment + } + + /// Creates a copy of the receiver after removing its fragment portion. + func removingFragment() -> URL? { + copy { $0.fragment = nil } + } + + /// Creates a copy of the receiver after modifying its components. + func copy(_ changes: (inout URLComponents) -> Void) -> URL? { + guard var components = URLComponents(url: self, resolvingAgainstBaseURL: true) else { + return nil + } + changes(&components) + return components.url + } +} diff --git a/Sources/LCP/Authentications/LCPDialogAuthentication.swift b/Sources/LCP/Authentications/LCPDialogAuthentication.swift index 851295ae7..bc81a45c7 100644 --- a/Sources/LCP/Authentications/LCPDialogAuthentication.swift +++ b/Sources/LCP/Authentications/LCPDialogAuthentication.swift @@ -5,7 +5,7 @@ // import Foundation -import R2Shared +import ReadiumShared import UIKit /// An `LCPAuthenticating` implementation presenting a dialog to the user. diff --git a/Sources/LCP/Authentications/LCPDialogViewController.swift b/Sources/LCP/Authentications/LCPDialogViewController.swift index d61ab371e..e14c71e27 100644 --- a/Sources/LCP/Authentications/LCPDialogViewController.swift +++ b/Sources/LCP/Authentications/LCPDialogViewController.swift @@ -68,9 +68,9 @@ final class LCPDialogViewController: UIViewController { switch reason { case .passphraseNotFound: - label.text = R2LCPLocalizedString("dialog.reason.passphraseNotFound") + label.text = ReadiumLCPLocalizedString("dialog.reason.passphraseNotFound") case .invalidPassphrase: - label.text = R2LCPLocalizedString("dialog.reason.invalidPassphrase") + label.text = ReadiumLCPLocalizedString("dialog.reason.invalidPassphrase") passphraseField.layer.borderWidth = 1 passphraseField.layer.borderColor = UIColor.red.cgColor } @@ -84,12 +84,12 @@ final class LCPDialogViewController: UIViewController { let leftItem = UIBarButtonItem(customView: label) navigationItem.leftBarButtonItem = leftItem - promptLabel.text = R2LCPLocalizedString("dialog.prompt.message1") - messageLabel.text = String(format: R2LCPLocalizedString("dialog.prompt.message2"), provider) - forgotPassphraseButton.setTitle(R2LCPLocalizedString("dialog.prompt.forgotPassphrase"), for: .normal) - supportButton.setTitle(R2LCPLocalizedString("dialog.prompt.support"), for: .normal) - continueButton.setTitle(R2LCPLocalizedString("dialog.prompt.continue"), for: .normal) - passphraseField.placeholder = R2LCPLocalizedString("dialog.prompt.passphrase") + promptLabel.text = ReadiumLCPLocalizedString("dialog.prompt.message1") + messageLabel.text = String(format: ReadiumLCPLocalizedString("dialog.prompt.message2"), provider) + forgotPassphraseButton.setTitle(ReadiumLCPLocalizedString("dialog.prompt.forgotPassphrase"), for: .normal) + supportButton.setTitle(ReadiumLCPLocalizedString("dialog.prompt.support"), for: .normal) + continueButton.setTitle(ReadiumLCPLocalizedString("dialog.prompt.continue"), for: .normal) + passphraseField.placeholder = ReadiumLCPLocalizedString("dialog.prompt.passphrase") hintLabel.text = license.hint navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -142,16 +142,16 @@ final class LCPDialogViewController: UIViewController { if let scheme = url.scheme { switch scheme { case "http", "https": - return R2LCPLocalizedString("dialog.support.website") + return ReadiumLCPLocalizedString("dialog.support.website") case "tel": - return R2LCPLocalizedString("dialog.support.phone") + return ReadiumLCPLocalizedString("dialog.support.phone") case "mailto": - return R2LCPLocalizedString("dialog.support.mail") + return ReadiumLCPLocalizedString("dialog.support.mail") default: break } } - return R2LCPLocalizedString("dialog.support") + return ReadiumLCPLocalizedString("dialog.support") }() let action = UIAlertAction(title: title, style: .default) { _ in @@ -159,7 +159,7 @@ final class LCPDialogViewController: UIViewController { } alert.addAction(action) } - alert.addAction(UIAlertAction(title: R2LCPLocalizedString("dialog.cancel"), style: .cancel)) + alert.addAction(UIAlertAction(title: ReadiumLCPLocalizedString("dialog.cancel"), style: .cancel)) if let popover = alert.popoverPresentationController, let sender = sender as? UIView { popover.sourceView = sender diff --git a/Sources/LCP/Content Protection/LCPContentProtection.swift b/Sources/LCP/Content Protection/LCPContentProtection.swift index 6c42ed81f..74dcc109a 100644 --- a/Sources/LCP/Content Protection/LCPContentProtection.swift +++ b/Sources/LCP/Content Protection/LCPContentProtection.swift @@ -5,7 +5,7 @@ // import Foundation -import R2Shared +import ReadiumShared final class LCPContentProtection: ContentProtection, Loggable { private let service: LCPService @@ -34,7 +34,7 @@ final class LCPContentProtection: ContentProtection, Loggable { ?? self.authentication service.retrieveLicense( - from: file.url, + from: file.file, authentication: authentication, allowUserInteraction: allowUserInteraction, sender: sender diff --git a/Sources/LCP/Content Protection/LCPDecryptor.swift b/Sources/LCP/Content Protection/LCPDecryptor.swift index 40200755e..a61a5a5f1 100644 --- a/Sources/LCP/Content Protection/LCPDecryptor.swift +++ b/Sources/LCP/Content Protection/LCPDecryptor.swift @@ -5,9 +5,7 @@ // import Foundation -import R2Shared - -private typealias R2Link = R2Shared.Link +import ReadiumShared private let lcpScheme = "http://readium.org/2014/01/lcp" private let AESBlockSize: UInt64 = 16 // bytes @@ -184,7 +182,7 @@ private extension LCPLicense { } } -private extension R2Link { +private extension ReadiumShared.Link { var isDeflated: Bool { properties.encryption?.compression?.lowercased() == "deflate" } diff --git a/Sources/LCP/Deprecated.swift b/Sources/LCP/Deprecated.swift deleted file mode 100644 index 75d5395e4..000000000 --- a/Sources/LCP/Deprecated.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// 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 R2Shared - -public extension LCPService { - /// Imports a protected publication from a standalone LCPL file. - @available(*, unavailable, message: "Use `acquirePublication()` instead", renamed: "acquirePublication") - func importPublication(from lcpl: URL, authentication: LCPAuthenticating?, sender: Any?, completion: @escaping (CancellableResult) -> Void) -> Observable { - fatalError("Not available anymore") - } - - @available(*, unavailable, message: "Use `acquirePublication()` instead", renamed: "acquirePublication") - func importPublication(from lcpl: URL, authentication: LCPAuthenticating?, completion: @escaping (CancellableResult) -> Void) -> Observable { - fatalError("Not available anymore") - } -} - -/// LCP service factory. -@available(*, unavailable, message: "Use `LCPService()` instead", renamed: "LCPService") -public func R2MakeLCPService() -> LCPService { - fatalError("Not implemented") -} - -@available(*, unavailable, message: "Remove all the code in `handleLcpPublication` and use `LCPLibraryService.loadPublication` instead, in the latest version of r2-testapp-swift") -public final class LcpSession {} - -public final class LcpLicense { - @available(*, unavailable, message: "Replace all the LCP code in `publication(at:)` by `LCPService.importPublication` (see `LCPLibraryService.fulfill` in the latest version)") - public init(withLicenseDocumentAt url: URL) throws {} - - @available(*, unavailable) - public init(withLicenseDocumentIn url: URL) throws {} - - @available(*, unavailable, message: "Removing the LCP license is not needed anymore, delete the LCP-related code in `remove(publication:)`") - public func removeDataBaseItem() throws {} - - @available(*, unavailable, message: "Removing the LCP license is not needed anymore, delete the LCP-related code in `remove(publication:)`") - public static func removeDataBaseItem(licenseID: String) throws {} -} - -@available(*, unavailable, message: "Remove `promptPassphrase` and implement the protocol `LCPAuthenticating` instead (see LCPLibraryService in the latest version)") -public enum LcpError: Error {} - -@available(*, unavailable, renamed: "LCPAcquisition.Publication") -public typealias LCPImportedPublication = LCPAcquisition.Publication diff --git a/Sources/LCP/LCPAcquisition.swift b/Sources/LCP/LCPAcquisition.swift index 580391a9c..629ba7b49 100644 --- a/Sources/LCP/LCPAcquisition.swift +++ b/Sources/LCP/LCPAcquisition.swift @@ -5,7 +5,7 @@ // import Foundation -import R2Shared +import ReadiumShared /// Represents an on-going LCP acquisition task. /// @@ -15,7 +15,7 @@ public final class LCPAcquisition: Loggable, Cancellable { public struct Publication { /// Path to the downloaded publication. /// You must move this file to the user library's folder. - public let localURL: URL + public let localURL: FileURL /// Filename that should be used for the publication when importing it in the user library. public let suggestedFilename: String @@ -54,7 +54,7 @@ public final class LCPAcquisition: Loggable, Cancellable { completion(result) - if case let .success(publication) = result, (try? publication.localURL.checkResourceIsReachable()) == true { + 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.") } } diff --git a/Sources/LCP/LCPError.swift b/Sources/LCP/LCPError.swift index c3f609b43..be971fbab 100644 --- a/Sources/LCP/LCPError.swift +++ b/Sources/LCP/LCPError.swift @@ -39,51 +39,51 @@ public enum LCPError: LocalizedError { public var errorDescription: String? { switch self { case .licenseIsBusy: - return R2LCPLocalizedString("LCPError.licenseIsBusy") + return ReadiumLCPLocalizedString("LCPError.licenseIsBusy") case let .licenseIntegrity(error): let description: String = { switch error { case .licenseOutOfDate: - return R2LCPLocalizedString("LCPClientError.licenseOutOfDate") + return ReadiumLCPLocalizedString("LCPClientError.licenseOutOfDate") case .certificateRevoked: - return R2LCPLocalizedString("LCPClientError.certificateRevoked") + return ReadiumLCPLocalizedString("LCPClientError.certificateRevoked") case .certificateSignatureInvalid: - return R2LCPLocalizedString("LCPClientError.certificateSignatureInvalid") + return ReadiumLCPLocalizedString("LCPClientError.certificateSignatureInvalid") case .licenseSignatureDateInvalid: - return R2LCPLocalizedString("LCPClientError.licenseSignatureDateInvalid") + return ReadiumLCPLocalizedString("LCPClientError.licenseSignatureDateInvalid") case .licenseSignatureInvalid: - return R2LCPLocalizedString("LCPClientError.licenseSignatureInvalid") + return ReadiumLCPLocalizedString("LCPClientError.licenseSignatureInvalid") case .contextInvalid: - return R2LCPLocalizedString("LCPClientError.contextInvalid") + return ReadiumLCPLocalizedString("LCPClientError.contextInvalid") case .contentKeyDecryptError: - return R2LCPLocalizedString("LCPClientError.contentKeyDecryptError") + return ReadiumLCPLocalizedString("LCPClientError.contentKeyDecryptError") case .userKeyCheckInvalid: - return R2LCPLocalizedString("LCPClientError.userKeyCheckInvalid") + return ReadiumLCPLocalizedString("LCPClientError.userKeyCheckInvalid") case .contentDecryptError: - return R2LCPLocalizedString("LCPClientError.contentDecryptError") + return ReadiumLCPLocalizedString("LCPClientError.contentDecryptError") case .unknown: - return R2LCPLocalizedString("LCPClientError.unknown") + return ReadiumLCPLocalizedString("LCPClientError.unknown") } }() - return R2LCPLocalizedString("LCPError.licenseIntegrity", description) + return ReadiumLCPLocalizedString("LCPError.licenseIntegrity", description) case let .licenseStatus(error): return error.localizedDescription case .licenseContainer: - return R2LCPLocalizedString("LCPError.licenseContainer") + return ReadiumLCPLocalizedString("LCPError.licenseContainer") case .licenseInteractionNotAvailable: - return R2LCPLocalizedString("LCPError.licenseInteractionNotAvailable") + return ReadiumLCPLocalizedString("LCPError.licenseInteractionNotAvailable") case .licenseProfileNotSupported: - return R2LCPLocalizedString("LCPError.licenseProfileNotSupported") + return ReadiumLCPLocalizedString("LCPError.licenseProfileNotSupported") case .crlFetching: - return R2LCPLocalizedString("LCPError.crlFetching") + return ReadiumLCPLocalizedString("LCPError.crlFetching") case let .licenseRenew(error): return error.localizedDescription case let .licenseReturn(error): return error.localizedDescription case .parsing: - return R2LCPLocalizedString("LCPError.parsing") + return ReadiumLCPLocalizedString("LCPError.parsing") case let .network(error): - return error?.localizedDescription ?? R2LCPLocalizedString("LCPError.network") + return error?.localizedDescription ?? ReadiumLCPLocalizedString("LCPError.network") case let .runtime(error): return error case let .unknown(error): @@ -107,20 +107,20 @@ public enum StatusError: LocalizedError { switch self { case let .cancelled(date): - return R2LCPLocalizedString("StatusError.cancelled", dateFormatter.string(from: date)) + return ReadiumLCPLocalizedString("StatusError.cancelled", dateFormatter.string(from: date)) case let .returned(date): - return R2LCPLocalizedString("StatusError.returned", dateFormatter.string(from: date)) + return ReadiumLCPLocalizedString("StatusError.returned", dateFormatter.string(from: date)) case let .expired(start: start, end: end): if start > Date() { - return R2LCPLocalizedString("StatusError.expired.start", dateFormatter.string(from: start)) + return ReadiumLCPLocalizedString("StatusError.expired.start", dateFormatter.string(from: start)) } else { - return R2LCPLocalizedString("StatusError.expired.end", dateFormatter.string(from: end)) + return ReadiumLCPLocalizedString("StatusError.expired.end", dateFormatter.string(from: end)) } case let .revoked(date, devicesCount): - return R2LCPLocalizedString("StatusError.revoked", dateFormatter.string(from: date), devicesCount) + return ReadiumLCPLocalizedString("StatusError.revoked", dateFormatter.string(from: date), devicesCount) } } } @@ -137,11 +137,11 @@ public enum RenewError: LocalizedError { public var errorDescription: String? { switch self { case .renewFailed: - return R2LCPLocalizedString("RenewError.renewFailed") + return ReadiumLCPLocalizedString("RenewError.renewFailed") case .invalidRenewalPeriod(maxRenewDate: _): - return R2LCPLocalizedString("RenewError.invalidRenewalPeriod") + return ReadiumLCPLocalizedString("RenewError.invalidRenewalPeriod") case .unexpectedServerError: - return R2LCPLocalizedString("RenewError.unexpectedServerError") + return ReadiumLCPLocalizedString("RenewError.unexpectedServerError") } } } @@ -158,11 +158,11 @@ public enum ReturnError: LocalizedError { public var errorDescription: String? { switch self { case .returnFailed: - return R2LCPLocalizedString("ReturnError.returnFailed") + return ReadiumLCPLocalizedString("ReturnError.returnFailed") case .alreadyReturnedOrExpired: - return R2LCPLocalizedString("ReturnError.alreadyReturnedOrExpired") + return ReadiumLCPLocalizedString("ReturnError.alreadyReturnedOrExpired") case .unexpectedServerError: - return R2LCPLocalizedString("ReturnError.unexpectedServerError") + return ReadiumLCPLocalizedString("ReturnError.unexpectedServerError") } } } diff --git a/Sources/LCP/LCPLicense.swift b/Sources/LCP/LCPLicense.swift index d3ede3609..71627996c 100644 --- a/Sources/LCP/LCPLicense.swift +++ b/Sources/LCP/LCPLicense.swift @@ -5,7 +5,7 @@ // import Foundation -import R2Shared +import ReadiumShared /// Opened license, used to decipher a protected publication and manage its license. public protocol LCPLicense: UserRights { diff --git a/Sources/LCP/LCPRenewDelegate.swift b/Sources/LCP/LCPRenewDelegate.swift index 8ef65ed77..3358296b1 100644 --- a/Sources/LCP/LCPRenewDelegate.swift +++ b/Sources/LCP/LCPRenewDelegate.swift @@ -5,7 +5,7 @@ // import Foundation -import R2Shared +import ReadiumShared import SafariServices import UIKit @@ -21,7 +21,7 @@ public protocol LCPRenewDelegate { /// /// You should present the URL in a `SFSafariViewController` and call the `completion` callback when the browser /// is dismissed by the user. - func presentWebPage(url: URL, completion: @escaping (CancellableResult) -> Void) + func presentWebPage(url: HTTPURL, completion: @escaping (CancellableResult) -> Void) } /// Default `LCPRenewDelegate` implementation using standard views. @@ -41,8 +41,8 @@ public class LCPDefaultRenewDelegate: NSObject, LCPRenewDelegate { completion(.success(nil)) } - public func presentWebPage(url: URL, completion: @escaping (CancellableResult) -> Void) { - let safariVC = SFSafariViewController(url: url) + public func presentWebPage(url: HTTPURL, completion: @escaping (CancellableResult) -> Void) { + let safariVC = SFSafariViewController(url: url.url) safariVC.modalPresentationStyle = modalPresentationStyle safariVC.presentationController?.delegate = self safariVC.delegate = self diff --git a/Sources/LCP/LCPService.swift b/Sources/LCP/LCPService.swift index 8a8d3f08e..56abf7998 100644 --- a/Sources/LCP/LCPService.swift +++ b/Sources/LCP/LCPService.swift @@ -5,7 +5,7 @@ // import Foundation -import R2Shared +import ReadiumShared /// Service used to acquire and open publications protected with LCP. /// @@ -47,7 +47,7 @@ public final class LCPService: Loggable { } /// Returns whether the given `file` is protected by LCP. - public func isLCPProtected(_ file: URL) -> Bool { + public func isLCPProtected(_ file: FileURL) -> Bool { warnIfMainThread() return makeLicenseContainerSync(for: file)?.containsLicense() == true } @@ -56,7 +56,7 @@ public final class LCPService: Loggable { /// /// You can cancel the on-going download with `acquisition.cancel()`. @discardableResult - public func acquirePublication(from lcpl: URL, onProgress: @escaping (LCPAcquisition.Progress) -> Void = { _ in }, completion: @escaping (CancellableResult) -> Void) -> LCPAcquisition { + public func acquirePublication(from lcpl: FileURL, onProgress: @escaping (LCPAcquisition.Progress) -> Void = { _ in }, completion: @escaping (CancellableResult) -> Void) -> LCPAcquisition { licenses.acquirePublication(from: lcpl, onProgress: onProgress, completion: completion) } @@ -73,7 +73,7 @@ public final class LCPService: Loggable { /// - sender: Free object that can be used by reading apps to give some UX context when /// presenting dialogs with `LCPAuthenticating`. public func retrieveLicense( - from publication: URL, + from publication: FileURL, authentication: LCPAuthenticating = LCPDialogAuthentication(), allowUserInteraction: Bool = true, sender: Any? = nil, diff --git a/Sources/LCP/License/Container/EPUBLicenseContainer.swift b/Sources/LCP/License/Container/EPUBLicenseContainer.swift index 96c069d47..b0293ab3d 100644 --- a/Sources/LCP/License/Container/EPUBLicenseContainer.swift +++ b/Sources/LCP/License/Container/EPUBLicenseContainer.swift @@ -5,10 +5,11 @@ // import Foundation +import ReadiumShared /// Access a License Document stored in an EPUB archive, under META-INF/license.lcpl. final class EPUBLicenseContainer: ZIPLicenseContainer { - init(epub: URL) { + init(epub: FileURL) { super.init(zip: epub, pathInZIP: "META-INF/license.lcpl") } } diff --git a/Sources/LCP/License/Container/LCPLLicenseContainer.swift b/Sources/LCP/License/Container/LCPLLicenseContainer.swift index fc0df24ef..f49f9fb55 100644 --- a/Sources/LCP/License/Container/LCPLLicenseContainer.swift +++ b/Sources/LCP/License/Container/LCPLLicenseContainer.swift @@ -5,12 +5,13 @@ // import Foundation +import ReadiumShared /// Access to a License Document packaged as a standalone LCPL file. final class LCPLLicenseContainer: LicenseContainer { - private let lcpl: URL + private let lcpl: FileURL - init(lcpl: URL) { + init(lcpl: FileURL) { self.lcpl = lcpl } @@ -19,7 +20,7 @@ final class LCPLLicenseContainer: LicenseContainer { } func read() throws -> Data { - guard let data = try? Data(contentsOf: lcpl) else { + guard let data = try? Data(contentsOf: lcpl.url) else { throw LCPError.licenseContainer(.readFailed(path: ".")) } return data @@ -27,7 +28,7 @@ final class LCPLLicenseContainer: LicenseContainer { func write(_ license: LicenseDocument) throws { do { - try license.data.write(to: lcpl, options: .atomic) + try license.data.write(to: lcpl.url, options: .atomic) } catch { throw LCPError.licenseContainer(.writeFailed(path: ".")) } diff --git a/Sources/LCP/License/Container/LicenseContainer.swift b/Sources/LCP/License/Container/LicenseContainer.swift index d76dc012c..8b6f6fa7c 100644 --- a/Sources/LCP/License/Container/LicenseContainer.swift +++ b/Sources/LCP/License/Container/LicenseContainer.swift @@ -5,7 +5,7 @@ // import Foundation -import R2Shared +import ReadiumShared /// Encapsulates the read/write access to the packaged License Document (eg. in an EPUB container, or a standalone LCPL file) protocol LicenseContainer { @@ -18,13 +18,13 @@ protocol LicenseContainer { func write(_ license: LicenseDocument) throws } -func makeLicenseContainer(for file: URL, mimetypes: [String] = []) -> Deferred { +func makeLicenseContainer(for file: FileURL, mimetypes: [String] = []) -> Deferred { deferred(on: .global(qos: .background)) { success, _, _ in success(makeLicenseContainerSync(for: file, mimetypes: mimetypes)) } } -func makeLicenseContainerSync(for file: URL, mimetypes: [String] = []) -> LicenseContainer? { +func makeLicenseContainerSync(for file: FileURL, mimetypes: [String] = []) -> LicenseContainer? { guard let mediaType = MediaType.of(file, mediaTypes: mimetypes, fileExtensions: []) else { return nil } diff --git a/Sources/LCP/License/Container/ReadiumLicenseContainer.swift b/Sources/LCP/License/Container/ReadiumLicenseContainer.swift index 15e88c5f8..8ca4d4282 100644 --- a/Sources/LCP/License/Container/ReadiumLicenseContainer.swift +++ b/Sources/LCP/License/Container/ReadiumLicenseContainer.swift @@ -5,10 +5,11 @@ // import Foundation +import ReadiumShared /// Access a License Document stored in a webpub, audiobook or LCPDF package. final class ReadiumLicenseContainer: ZIPLicenseContainer { - init(path: URL) { + init(path: FileURL) { super.init(zip: path, pathInZIP: "license.lcpl") } } diff --git a/Sources/LCP/License/Container/ZIPLicenseContainer.swift b/Sources/LCP/License/Container/ZIPLicenseContainer.swift index 9554b28b0..0ae22405d 100644 --- a/Sources/LCP/License/Container/ZIPLicenseContainer.swift +++ b/Sources/LCP/License/Container/ZIPLicenseContainer.swift @@ -5,28 +5,29 @@ // import Foundation +import ReadiumShared import ZIPFoundation /// Access to a License Document stored in a ZIP archive. /// Meant to be subclassed to customize the pathInZIP property, eg. EPUBLicenseContainer. class ZIPLicenseContainer: LicenseContainer { - private let zip: URL + private let zip: FileURL private let pathInZIP: String - init(zip: URL, pathInZIP: String) { + init(zip: FileURL, pathInZIP: String) { self.zip = zip self.pathInZIP = pathInZIP } func containsLicense() -> Bool { - guard let archive = Archive(url: zip, accessMode: .read) else { + guard let archive = Archive(url: zip.url, accessMode: .read) else { return false } return archive[pathInZIP] != nil } func read() throws -> Data { - guard let archive = Archive(url: zip, accessMode: .read) else { + guard let archive = Archive(url: zip.url, accessMode: .read) else { throw LCPError.licenseContainer(.openFailed) } guard let entry = archive[pathInZIP] else { @@ -46,7 +47,7 @@ class ZIPLicenseContainer: LicenseContainer { } func write(_ license: LicenseDocument) throws { - guard let archive = Archive(url: zip, accessMode: .update) else { + guard let archive = Archive(url: zip.url, accessMode: .update) else { throw LCPError.licenseContainer(.openFailed) } diff --git a/Sources/LCP/License/LCPError+wrap.swift b/Sources/LCP/License/LCPError+wrap.swift index 24645664c..4ae029ade 100644 --- a/Sources/LCP/License/LCPError+wrap.swift +++ b/Sources/LCP/License/LCPError+wrap.swift @@ -5,7 +5,7 @@ // import Foundation -import R2Shared +import ReadiumShared extension LCPError { static func wrap(_ optionalError: Error?) -> LCPError { diff --git a/Sources/LCP/License/License.swift b/Sources/LCP/License/License.swift index 0c05563f2..a82286629 100644 --- a/Sources/LCP/License/License.swift +++ b/Sources/LCP/License/License.swift @@ -5,7 +5,7 @@ // import Foundation -import R2Shared +import ReadiumShared import ZIPFoundation final class License: Loggable { @@ -192,7 +192,7 @@ extension License: LCPLicense { func renewWithWebPage(_ link: Link) throws -> Deferred { guard let statusURL = try? license.url(for: .status, preferredType: .lcpStatusDocument), - let url = link.url + let url = link.url() else { throw LCPError.licenseInteractionNotAvailable } @@ -216,13 +216,13 @@ extension License: LCPLicense { : Deferred.success(nil) } - func makeRenewURL(from endDate: Date?) throws -> URL { + func makeRenewURL(from endDate: Date?) throws -> HTTPURL { var params = device.asQueryParameters if let end = endDate { params["end"] = end.iso8601 } - guard let url = link.url(with: params) else { + guard let url = link.url(parameters: params) else { throw LCPError.licenseInteractionNotAvailable } return url @@ -263,8 +263,13 @@ extension License: LCPLicense { } func returnPublication(completion: @escaping (LCPError?) -> Void) { - guard let status = documents.status, - let url = try? status.url(for: .return, preferredType: .lcpStatusDocument, with: device.asQueryParameters) + guard + let status = documents.status, + let url = try? status.url( + for: .return, + preferredType: .lcpStatusDocument, + parameters: device.asQueryParameters + ) else { completion(LCPError.licenseInteractionNotAvailable) return @@ -299,7 +304,7 @@ public extension LCPRenewDelegate { Deferred { preferredEndDate(maximum: maximum, completion: $0) } } - func presentWebPage(url: URL) -> Deferred { + func presentWebPage(url: HTTPURL) -> Deferred { Deferred { presentWebPage(url: url, completion: $0) } } } diff --git a/Sources/LCP/License/LicenseValidation.swift b/Sources/LCP/License/LicenseValidation.swift index 92d7977c0..fa46913b5 100644 --- a/Sources/LCP/License/LicenseValidation.swift +++ b/Sources/LCP/License/LicenseValidation.swift @@ -5,7 +5,7 @@ // import Foundation -import R2Shared +import ReadiumShared /// To modify depending of the profiles supported by liblcp.a. private let supportedProfiles = [ diff --git a/Sources/LCP/License/Model/Components/Link.swift b/Sources/LCP/License/Model/Components/Link.swift index 93347df29..480746e7d 100644 --- a/Sources/LCP/License/Model/Components/Link.swift +++ b/Sources/LCP/License/Model/Components/Link.swift @@ -5,7 +5,7 @@ // import Foundation -import R2Shared +import ReadiumShared /// A Link to a resource. public struct Link { @@ -50,20 +50,19 @@ public struct Link { /// Gets the valid URL if possible, applying the given template context as query parameters if the link is templated. /// eg. http://url{?id,name} + [id: x, name: y] -> http://url?id=x&name=y - func url(with parameters: [String: LosslessStringConvertible]) -> URL? { + func url(parameters: [String: LosslessStringConvertible] = [:]) -> HTTPURL? { var href = href if templated { href = URITemplate(href).expand(with: parameters.mapValues { String(describing: $0) }) } - return URL(string: href) + return HTTPURL(string: href) } /// Expands the href without any template context. - var url: URL? { - url(with: [:]) - } + @available(*, unavailable, message: "Use url() instead") + var url: URL? { fatalError() } var mediaType: MediaType { type.flatMap { MediaType.of(mediaType: $0) } ?? .binary diff --git a/Sources/LCP/License/Model/Components/Links.swift b/Sources/LCP/License/Model/Components/Links.swift index 755e8fff8..ed896072e 100644 --- a/Sources/LCP/License/Model/Components/Links.swift +++ b/Sources/LCP/License/Model/Components/Links.swift @@ -5,7 +5,7 @@ // import Foundation -import R2Shared +import ReadiumShared public struct Links { private let links: [Link] diff --git a/Sources/LCP/License/Model/LicenseDocument.swift b/Sources/LCP/License/Model/LicenseDocument.swift index d7ba674f9..a1d9a48f0 100644 --- a/Sources/LCP/License/Model/LicenseDocument.swift +++ b/Sources/LCP/License/Model/LicenseDocument.swift @@ -5,7 +5,7 @@ // import Foundation -import R2Shared +import ReadiumShared /// Document that contains references to the various keys, links to related external resources, rights and restrictions that are applied to the Protected Publication, and user information. /// https://github.com/readium/lcp-specs/blob/master/schema/license.schema.json @@ -99,11 +99,11 @@ public struct LicenseDocument { /// are found, the first link with the `rel` and an empty `type` will be returned. /// /// - Throws: `LCPError.invalidLink` if the URL can't be built. - func url(for rel: Rel, preferredType: MediaType? = nil, with parameters: [String: LosslessStringConvertible] = [:]) throws -> URL { + func url(for rel: Rel, preferredType: MediaType? = nil, parameters: [String: LosslessStringConvertible] = [:]) throws -> HTTPURL { let link = link(for: rel, type: preferredType) ?? links.firstWithRelAndNoType(rel.rawValue) - guard let url = link?.url(with: parameters) else { + guard let url = link?.url(parameters: parameters) else { throw ParsingError.url(rel: rel.rawValue) } diff --git a/Sources/LCP/License/Model/StatusDocument.swift b/Sources/LCP/License/Model/StatusDocument.swift index c4d49a224..57668b1bf 100644 --- a/Sources/LCP/License/Model/StatusDocument.swift +++ b/Sources/LCP/License/Model/StatusDocument.swift @@ -5,7 +5,7 @@ // import Foundation -import R2Shared +import ReadiumShared /// Document that contains information about the history of a License Document, along with its current status and available interactions. /// https://github.com/readium/lcp-specs/blob/master/schema/status.schema.json @@ -104,11 +104,11 @@ public struct StatusDocument { /// are found, the first link with the `rel` and an empty `type` will be returned. /// /// - Throws: `LCPError.invalidLink` if the URL can't be built. - func url(for rel: Rel, preferredType: MediaType? = nil, with parameters: [String: LosslessStringConvertible] = [:]) throws -> URL { + func url(for rel: Rel, preferredType: MediaType? = nil, parameters: [String: LosslessStringConvertible] = [:]) throws -> HTTPURL { let link = link(for: rel, type: preferredType) ?? linkWithNoType(for: rel) - guard let url = link?.url(with: parameters) else { + guard let url = link?.url(parameters: parameters) else { throw ParsingError.url(rel: rel.rawValue) } diff --git a/Sources/LCP/Persistence/Transactions.swift b/Sources/LCP/Persistence/Transactions.swift index 82440965f..e83b036ce 100644 --- a/Sources/LCP/Persistence/Transactions.swift +++ b/Sources/LCP/Persistence/Transactions.swift @@ -5,7 +5,7 @@ // import Foundation -import R2Shared +import ReadiumShared import SQLite /// Database's TransactionsTable , in charge of keeping tracks of the previous license checking. diff --git a/Sources/LCP/Services/CRLService.swift b/Sources/LCP/Services/CRLService.swift index 407412190..91d7676eb 100644 --- a/Sources/LCP/Services/CRLService.swift +++ b/Sources/LCP/Services/CRLService.swift @@ -5,7 +5,7 @@ // import Foundation -import R2Shared +import ReadiumShared /// Certificate Revocation List final class CRLService { @@ -44,7 +44,7 @@ final class CRLService { /// Fetches the updated Certificate Revocation List from EDRLab. private func fetch(timeout: TimeInterval? = nil) -> Deferred { - let url = URL(string: "http://crl.edrlab.telesec.de/rl/EDRLab_CA.crl")! + let url = HTTPURL(string: "http://crl.edrlab.telesec.de/rl/EDRLab_CA.crl")! return httpClient.fetch(HTTPRequest(url: url, timeoutInterval: timeout)) .mapError { _ in LCPError.crlFetching } diff --git a/Sources/LCP/Services/DeviceService.swift b/Sources/LCP/Services/DeviceService.swift index 4bdf97e63..7ced5696d 100644 --- a/Sources/LCP/Services/DeviceService.swift +++ b/Sources/LCP/Services/DeviceService.swift @@ -5,7 +5,7 @@ // import Foundation -import R2Shared +import ReadiumShared import UIKit final class DeviceService { @@ -50,7 +50,7 @@ final class DeviceService { guard !registered else { return .success(nil) } - guard let url = link.url(with: self.asQueryParameters) else { + guard let url = link.url(parameters: self.asQueryParameters) else { throw LCPError.licenseInteractionNotAvailable } diff --git a/Sources/LCP/Services/LicensesService.swift b/Sources/LCP/Services/LicensesService.swift index 82f4d833b..873feac60 100644 --- a/Sources/LCP/Services/LicensesService.swift +++ b/Sources/LCP/Services/LicensesService.swift @@ -5,7 +5,7 @@ // import Foundation -import R2Shared +import ReadiumShared final class LicensesService: Loggable { // Mapping between an unprotected format to the matching LCP protected format. @@ -32,7 +32,7 @@ final class LicensesService: Loggable { self.passphrases = passphrases } - func retrieve(from publication: URL, authentication: LCPAuthenticating?, allowUserInteraction: Bool, sender: Any?) -> Deferred { + func retrieve(from publication: FileURL, authentication: LCPAuthenticating?, allowUserInteraction: Bool, sender: Any?) -> Deferred { makeLicenseContainer(for: publication) .flatMap { container in guard let container = container, container.containsLicense() else { @@ -95,7 +95,7 @@ final class LicensesService: Loggable { } } - func acquirePublication(from lcpl: URL, onProgress: @escaping (LCPAcquisition.Progress) -> Void, completion: @escaping (CancellableResult) -> Void) -> LCPAcquisition { + func acquirePublication(from lcpl: FileURL, onProgress: @escaping (LCPAcquisition.Progress) -> Void, completion: @escaping (CancellableResult) -> Void) -> LCPAcquisition { let acquisition = LCPAcquisition(onProgress: onProgress, completion: completion) readLicense(from: lcpl).resolve { result in @@ -118,7 +118,7 @@ final class LicensesService: Loggable { return acquisition } - private func readLicense(from lcpl: URL) -> Deferred { + private func readLicense(from lcpl: FileURL) -> Deferred { makeLicenseContainer(for: lcpl) .tryMap { container in guard let container = container, container.containsLicense() else { @@ -172,7 +172,7 @@ final class LicensesService: Loggable { } /// Injects the given License Document into the `file` acquired using `downloadTask`. - private func injectLicense(_ license: LicenseDocument, in download: HTTPDownload) -> Deferred { + private func injectLicense(_ license: LicenseDocument, in download: HTTPDownload) -> Deferred { var mimetypes: [String] = [ download.mediaType.string, ] @@ -181,7 +181,7 @@ final class LicensesService: Loggable { } return makeLicenseContainer(for: download.location, mimetypes: mimetypes) - .tryMap(on: .global(qos: .background)) { container -> URL in + .tryMap(on: .global(qos: .background)) { container -> FileURL in guard let container = container else { throw LCPError.licenseContainer(.openFailed) } @@ -193,8 +193,8 @@ final class LicensesService: Loggable { } /// Returns the suggested filename to be used when importing a publication. - private func suggestedFilename(for file: URL, license: LicenseDocument) -> String { - let fileExtension: String = { + private func suggestedFilename(for file: FileURL, license: LicenseDocument) -> String { + let fileExtension: String? = { let publicationLink = license.link(for: .publication) if var mediaType = MediaType.of(file, mediaType: publicationLink?.type) { mediaType = mediaTypesMapping[mediaType] ?? mediaType @@ -203,7 +203,8 @@ final class LicensesService: Loggable { return file.pathExtension } }() + let suffix = fileExtension?.addingPrefix(".") ?? "" - return "\(license.id).\(fileExtension)" + return "\(license.id)\(suffix)" } } diff --git a/Sources/LCP/Services/PassphrasesService.swift b/Sources/LCP/Services/PassphrasesService.swift index 237175039..3392aef27 100644 --- a/Sources/LCP/Services/PassphrasesService.swift +++ b/Sources/LCP/Services/PassphrasesService.swift @@ -6,7 +6,7 @@ import CryptoSwift import Foundation -import R2Shared +import ReadiumShared final class PassphrasesService { private let client: LCPClient diff --git a/Sources/LCP/Toolkit/Deferred.swift b/Sources/LCP/Toolkit/Deferred.swift index c5949ffa9..eed09d9de 100644 --- a/Sources/LCP/Toolkit/Deferred.swift +++ b/Sources/LCP/Toolkit/Deferred.swift @@ -5,7 +5,7 @@ // import Foundation -import R2Shared +import ReadiumShared public extension Deferred where Success == Void { /// Resolves a `Deferred` by returning an optional `Failure`, ignoring any success or cancelled diff --git a/Sources/LCP/Toolkit/HTTPClient.swift b/Sources/LCP/Toolkit/HTTPClient.swift index 07eeb84a7..38e1a6c3c 100644 --- a/Sources/LCP/Toolkit/HTTPClient.swift +++ b/Sources/LCP/Toolkit/HTTPClient.swift @@ -5,7 +5,7 @@ // import Foundation -import R2Shared +import ReadiumShared extension HTTPClient { func fetch(_ url: HTTPRequestConvertible) -> Deferred { diff --git a/Sources/LCP/Toolkit/R2LCPLocalizedString.swift b/Sources/LCP/Toolkit/ReadiumLCPLocalizedString.swift similarity index 54% rename from Sources/LCP/Toolkit/R2LCPLocalizedString.swift rename to Sources/LCP/Toolkit/ReadiumLCPLocalizedString.swift index 409dcaf83..dcd9a104c 100644 --- a/Sources/LCP/Toolkit/R2LCPLocalizedString.swift +++ b/Sources/LCP/Toolkit/ReadiumLCPLocalizedString.swift @@ -5,8 +5,8 @@ // import Foundation -import R2Shared +import ReadiumShared -func R2LCPLocalizedString(_ key: String, _ values: CVarArg...) -> String { - R2LocalizedString("ReadiumLCP.\(key)", in: Bundle.module, values) +func ReadiumLCPLocalizedString(_ key: String, _ values: CVarArg...) -> String { + ReadiumLocalizedString("ReadiumLCP.\(key)", in: Bundle.module, values) } diff --git a/Sources/Navigator/Audiobook/AudioNavigator.swift b/Sources/Navigator/Audiobook/AudioNavigator.swift index 1f3aec58b..7bf8d207a 100644 --- a/Sources/Navigator/Audiobook/AudioNavigator.swift +++ b/Sources/Navigator/Audiobook/AudioNavigator.swift @@ -6,7 +6,7 @@ import AVFoundation import Foundation -import R2Shared +import ReadiumShared /// Status of a played media resource. public enum MediaPlaybackState { diff --git a/Sources/Navigator/Audiobook/Preferences/AudioPreferences.swift b/Sources/Navigator/Audiobook/Preferences/AudioPreferences.swift index e42f5254b..71871b569 100644 --- a/Sources/Navigator/Audiobook/Preferences/AudioPreferences.swift +++ b/Sources/Navigator/Audiobook/Preferences/AudioPreferences.swift @@ -5,7 +5,7 @@ // import Foundation -import R2Shared +import ReadiumShared /// Preferences for the `AudioNavigator`. public struct AudioPreferences: ConfigurablePreferences { diff --git a/Sources/Navigator/Audiobook/Preferences/AudioSettings.swift b/Sources/Navigator/Audiobook/Preferences/AudioSettings.swift index 76c182f06..36a3147b8 100644 --- a/Sources/Navigator/Audiobook/Preferences/AudioSettings.swift +++ b/Sources/Navigator/Audiobook/Preferences/AudioSettings.swift @@ -5,7 +5,7 @@ // import Foundation -import R2Shared +import ReadiumShared /// Setting values of the `AudioNavigator`. /// diff --git a/Sources/Navigator/Audiobook/PublicationMediaLoader.swift b/Sources/Navigator/Audiobook/PublicationMediaLoader.swift index b5a3d6649..d778a9e2d 100644 --- a/Sources/Navigator/Audiobook/PublicationMediaLoader.swift +++ b/Sources/Navigator/Audiobook/PublicationMediaLoader.swift @@ -6,7 +6,7 @@ import AVFoundation import Foundation -import R2Shared +import ReadiumShared /// Serves `Publication`'s `Resource`s as an `AVURLAsset`. /// @@ -31,12 +31,14 @@ final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate { self.publication = publication } - private let queue = DispatchQueue(label: "org.readium.r2-navigator-swift.PublicationMediaLoader") + private let queue = DispatchQueue(label: "org.readium.swift-toolkit.navigator.PublicationMediaLoader") /// Creates a new `AVURLAsset` to serve the given `link`. func makeAsset(for link: Link) throws -> AVURLAsset { - let originalURL = link.url(relativeTo: publication.baseURL) ?? URL(fileURLWithPath: link.href) - guard var components = URLComponents(url: originalURL, resolvingAgainstBaseURL: true) else { + guard + let originalURL = try? link.url(relativeTo: publication.baseURL), + var components = URLComponents(url: originalURL.url, resolvingAgainstBaseURL: true) + else { throw AssetError.invalidHREF(link.href) } @@ -82,7 +84,7 @@ final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate { /// Terminates and removes the given loading request, cancelling it if necessary. private func finishRequest(_ request: AVAssetResourceLoadingRequest) { guard - let href = request.href, + let href = request.request.url?.audioHREF, var reqs = requests[href], let index = reqs.firstIndex(where: { req, _ in req == request }) else { @@ -104,7 +106,7 @@ final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate { // MARK: - AVAssetResourceLoaderDelegate func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool { - guard let href = loadingRequest.href else { + guard let href = loadingRequest.request.url?.audioHREF else { return false } @@ -159,24 +161,18 @@ final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate { } } -private let schemePrefix = "r2" +private let schemePrefix = "readium" -private extension AVAssetResourceLoadingRequest { - var href: String? { - guard let url = request.url, url.scheme?.hasPrefix(schemePrefix) == true else { +extension URL { + var audioHREF: String? { + guard let url = absoluteURL, url.scheme.rawValue.hasPrefix(schemePrefix) == true else { return nil } // The URL can be either: - // * r2file://directory/local-file.mp3 - // * r2http(s)://domain.com/external-file.mp3 - switch url.scheme?.lowercased().removingPrefix(schemePrefix) { - case "file": - return url.path - case "http", "https": - return url.absoluteString.removingPrefix(schemePrefix) - default: - return nil - } + // * readium:relative/file.mp3 + // * readiumfile:///directory/local-file.mp3 + // * readiumhttp(s)://domain.com/external-file.mp3 + return url.string.removingPrefix(schemePrefix).removingPrefix(":") } } diff --git a/Sources/Navigator/CBZ/CBZNavigatorViewController.swift b/Sources/Navigator/CBZ/CBZNavigatorViewController.swift index 957edc8b5..1097bd0ae 100644 --- a/Sources/Navigator/CBZ/CBZNavigatorViewController.swift +++ b/Sources/Navigator/CBZ/CBZNavigatorViewController.swift @@ -4,7 +4,7 @@ // available in the top-level LICENSE file of the project. // -import R2Shared +import ReadiumShared import UIKit public protocol CBZNavigatorDelegate: VisualNavigatorDelegate {} @@ -26,7 +26,7 @@ open class CBZNavigatorViewController: UIViewController, VisualNavigator, Loggab private let server: HTTPServer? private let publicationEndpoint: HTTPServerEndpoint? - private var publicationBaseURL: URL! + private var publicationBaseURL: HTTPURL! public convenience init( publication: Publication, @@ -69,25 +69,11 @@ open class CBZNavigatorViewController: UIViewController, VisualNavigator, Loggab } ) } - - publicationBaseURL = URL(string: publicationBaseURL.absoluteString.addingSuffix("/"))! } - @available(*, deprecated, message: "See the 2.5.0 migration guide to migrate the HTTP server") + @available(*, unavailable, message: "See the 2.5.0 migration guide to migrate the HTTP server") public convenience init(publication: Publication, initialLocation: Locator? = nil) { - precondition(!publication.isRestricted, "The provided publication is restricted. Check that any DRM was properly unlocked using a Content Protection.") - guard publication.baseURL != nil else { - preconditionFailure("No base URL provided for the publication. Add it to the HTTP server.") - } - - self.init( - publication: publication, - initialLocation: initialLocation, - httpServer: nil, - publicationEndpoint: nil - ) - - publicationBaseURL = URL(string: publicationBaseURL.absoluteString.addingSuffix("/"))! + fatalError() } private init( @@ -122,7 +108,7 @@ open class CBZNavigatorViewController: UIViewController, VisualNavigator, Loggab deinit { if let endpoint = publicationEndpoint { - server?.remove(at: endpoint) + try? server?.remove(at: endpoint) } } @@ -194,13 +180,14 @@ open class CBZNavigatorViewController: UIViewController, VisualNavigator, Loggab } private func imageViewController(at index: Int) -> ImageViewController? { - guard publication.readingOrder.indices.contains(index), - let url = publication.readingOrder[index].url(relativeTo: publicationBaseURL) + guard + publication.readingOrder.indices.contains(index), + let url = try? publication.readingOrder[index].url(relativeTo: publicationBaseURL) else { return nil } - return ImageViewController(index: index, url: url) + return ImageViewController(index: index, url: url.url) } // MARK: - Navigator @@ -213,8 +200,8 @@ open class CBZNavigatorViewController: UIViewController, VisualNavigator, Loggab ) } - public var readingProgression: R2Shared.ReadingProgression { - R2Shared.ReadingProgression(presentation.readingProgression) + public var readingProgression: ReadiumShared.ReadingProgression { + ReadiumShared.ReadingProgression(presentation.readingProgression) } public var currentLocation: Locator? { diff --git a/Sources/Navigator/CBZ/ImageViewController.swift b/Sources/Navigator/CBZ/ImageViewController.swift index 4e693d97a..3b2d77bbd 100644 --- a/Sources/Navigator/CBZ/ImageViewController.swift +++ b/Sources/Navigator/CBZ/ImageViewController.swift @@ -5,7 +5,7 @@ // import Foundation -import R2Shared +import ReadiumShared import UIKit /// Zoomable image view controller. diff --git a/Sources/Navigator/Decorator/DecorableNavigator.swift b/Sources/Navigator/Decorator/DecorableNavigator.swift index 6743e1b08..9e2ccadbf 100644 --- a/Sources/Navigator/Decorator/DecorableNavigator.swift +++ b/Sources/Navigator/Decorator/DecorableNavigator.swift @@ -5,7 +5,7 @@ // import Foundation -import R2Shared +import ReadiumShared import UIKit /// A navigator able to render arbitrary decorations over a publication. diff --git a/Sources/Navigator/Decorator/DiffableDecoration.swift b/Sources/Navigator/Decorator/DiffableDecoration.swift index 2650aba7a..531a53eb2 100644 --- a/Sources/Navigator/Decorator/DiffableDecoration.swift +++ b/Sources/Navigator/Decorator/DiffableDecoration.swift @@ -6,7 +6,7 @@ import DifferenceKit import Foundation -import R2Shared +import ReadiumShared struct DiffableDecoration: Hashable, Differentiable { let decoration: Decoration diff --git a/Sources/Navigator/EPUB/CSS/CSSLayout.swift b/Sources/Navigator/EPUB/CSS/CSSLayout.swift index fe1c0cf8a..42b6e13a9 100644 --- a/Sources/Navigator/EPUB/CSS/CSSLayout.swift +++ b/Sources/Navigator/EPUB/CSS/CSSLayout.swift @@ -5,7 +5,7 @@ // import Foundation -import R2Shared +import ReadiumShared /// Readium CSS layout variant to use. /// diff --git a/Sources/Navigator/EPUB/CSS/HTMLFontFamilyDeclaration.swift b/Sources/Navigator/EPUB/CSS/HTMLFontFamilyDeclaration.swift index f322c8559..72ab89385 100644 --- a/Sources/Navigator/EPUB/CSS/HTMLFontFamilyDeclaration.swift +++ b/Sources/Navigator/EPUB/CSS/HTMLFontFamilyDeclaration.swift @@ -5,7 +5,7 @@ // import Foundation -import R2Shared +import ReadiumShared public protocol HTMLFontFamilyDeclaration { /// Name of the font family. @@ -21,14 +21,14 @@ public protocol HTMLFontFamilyDeclaration { /// /// Use `servingFile` to convert a file URL into an http one to make a local /// file available to the web views. - func inject(in html: String, servingFile: (URL) throws -> URL) throws -> String + func inject(in html: String, servingFile: (FileURL) throws -> HTTPURL) throws -> String } /// A type-erasing `HTMLFontFamilyDeclaration` object public struct AnyHTMLFontFamilyDeclaration: HTMLFontFamilyDeclaration { private let _fontFamily: () -> FontFamily private let _alternates: () -> [FontFamily] - private let _inject: (String, (URL) throws -> URL) throws -> String + private let _inject: (String, (FileURL) throws -> HTTPURL) throws -> String public var fontFamily: FontFamily { _fontFamily() } public var alternates: [FontFamily] { _alternates() } @@ -39,7 +39,7 @@ public struct AnyHTMLFontFamilyDeclaration: HTMLFontFamilyDeclaration { _inject = { try declaration.inject(in: $0, servingFile: $1) } } - public func inject(in html: String, servingFile: (URL) throws -> URL) throws -> String { + public func inject(in html: String, servingFile: (FileURL) throws -> HTTPURL) throws -> String { try _inject(html, servingFile) } } @@ -65,7 +65,7 @@ public struct CSSFontFamilyDeclaration: HTMLFontFamilyDeclaration { self.fontFaces = fontFaces } - public func inject(in html: String, servingFile: (URL) throws -> URL) throws -> String { + public func inject(in html: String, servingFile: (FileURL) throws -> HTTPURL) throws -> String { var injections = try fontFaces.flatMap { try $0.injections(for: html, servingFile: servingFile) } @@ -89,14 +89,14 @@ public struct CSSFontFace { /// /// `preload` indicates whether this source will be declared for preloading /// in the HTML using ``. - private typealias Source = (file: URL, preload: Bool) + private typealias Source = (file: FileURL, preload: Bool) public var style: CSSFontStyle? public var weight: CSSFontWeight? private var sources: [Source] public init( - file: URL, + file: FileURL, preload: Bool = false, style: CSSFontStyle? = nil, weight: CSSFontWeight? = nil @@ -111,26 +111,26 @@ public struct CSSFontFace { /// /// - Parameter preload: Indicates whether this source will be declared for /// preloading in the HTML using ``. - public func addingSource(file: URL, preload: Bool = false) -> Self { + public func addingSource(file: FileURL, preload: Bool = false) -> Self { var copy = self copy.sources.append((file, preload)) return copy } - func injections(for html: String, servingFile: (URL) throws -> URL) throws -> [HTMLInjection] { + func injections(for html: String, servingFile: (FileURL) throws -> HTTPURL) throws -> [HTMLInjection] { try sources .filter(\.preload) .map { source in let file = try servingFile(source.file) - return .link(href: file.absoluteString, rel: "preload", as: "font", crossOrigin: "") + return .link(href: file.string, rel: "preload", as: "font", crossOrigin: "") } } - func css(for fontFamily: String, servingFile: (URL) throws -> URL) throws -> String { + func css(for fontFamily: String, servingFile: (FileURL) throws -> HTTPURL) throws -> String { let urls = try sources.map { try servingFile($0.file) } var descriptors: [String: String] = [ "font-family": "\"\(fontFamily)\"", - "src": urls.map { "url(\"\($0.absoluteString)\")" }.joined(separator: ", "), + "src": urls.map { "url(\"\($0.string)\")" }.joined(separator: ", "), ] if let style = style { diff --git a/Sources/Navigator/EPUB/CSS/ReadiumCSS.swift b/Sources/Navigator/EPUB/CSS/ReadiumCSS.swift index 5022d0ec6..e9e749e8c 100644 --- a/Sources/Navigator/EPUB/CSS/ReadiumCSS.swift +++ b/Sources/Navigator/EPUB/CSS/ReadiumCSS.swift @@ -5,8 +5,8 @@ // import Foundation -import R2Shared import ReadiumInternal +import ReadiumShared import SwiftSoup struct ReadiumCSS { @@ -15,7 +15,7 @@ struct ReadiumCSS { var userProperties: CSSUserProperties = .init() /// Base URL of the Readium CSS assets. - var baseURL: URL + var baseURL: HTTPURL var fontFamilyDeclarations: [AnyHTMLFontFamilyDeclaration] = [] } @@ -114,17 +114,17 @@ extension ReadiumCSS: HTMLInjectable { let hasStyles = hasStyles(html) var stylesheetsFolder = baseURL if let folder = layout.stylesheets.folder { - stylesheetsFolder.appendPathComponent(folder, isDirectory: true) + stylesheetsFolder = stylesheetsFolder.appendingPath(folder, isDirectory: true) } inj.append(.stylesheetLink( - href: stylesheetsFolder.appendingPathComponent("ReadiumCSS-before.css").absoluteString, + href: stylesheetsFolder.appendingPath("ReadiumCSS-before.css", isDirectory: false).string, prepend: true )) if !hasStyles { - inj.append(.stylesheetLink(href: stylesheetsFolder.appendingPathComponent("ReadiumCSS-default.css").absoluteString)) + inj.append(.stylesheetLink(href: stylesheetsFolder.appendingPath("ReadiumCSS-default.css", isDirectory: false).string)) } - inj.append(.stylesheetLink(href: stylesheetsFolder.appendingPathComponent("ReadiumCSS-after.css").absoluteString)) + inj.append(.stylesheetLink(href: stylesheetsFolder.appendingPath("ReadiumCSS-after.css", isDirectory: false).string)) // Fix Readium CSS issue with the positioning of