From 0da00e8e6adc3c996ae4ac04934553d3fa5eb9af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Wed, 15 Jul 2026 12:06:39 +0200 Subject: [PATCH 01/39] Improve LCP profiles detection (#857) --- CHANGELOG.md | 1 + .../Sources/App/Common/Views/JSONView.swift | 1 + Sources/LCP/LCPClient.swift | 27 ++++++++++++++ Sources/LCP/LCPService.swift | 13 ------- Sources/LCP/License/LicenseValidation.swift | 36 ++++--------------- Sources/LCP/Resources/prod-license.lcpl | 1 - Sources/LCP/Services/LicensesService.swift | 4 --- TestApp/Sources/App/Readium.swift | 4 +++ Tests/LCPTests/LCPTestClient.swift | 4 +++ 9 files changed, 44 insertions(+), 47 deletions(-) delete mode 100644 Sources/LCP/Resources/prod-license.lcpl diff --git a/CHANGELOG.md b/CHANGELOG.md index d3a59267fa..169161d860 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file. Take a look #### LCP * `LCPService` has a new `addPassphrase(_:isHashed:userID:provider:)` method to store a passphrase candidate in the repository without opening a license first. Useful to preload a passphrase ahead of time (e.g. from a catalog). +* `LCPClient` has a new `getSupportedLCPProfileURIs()` requirement, letting the toolkit report `LCPError.licenseProfileNotSupported` based on the profiles the embedded liblcp actually supports. Update your `LCPClient` facade to forward `R2LCPClient.getSupportedLCPProfileURIs()` (a default implementation is provided for backward compatibility). ### Fixed diff --git a/Playground/Sources/App/Common/Views/JSONView.swift b/Playground/Sources/App/Common/Views/JSONView.swift index e188e607bb..e6b2d4435d 100644 --- a/Playground/Sources/App/Common/Views/JSONView.swift +++ b/Playground/Sources/App/Common/Views/JSONView.swift @@ -25,6 +25,7 @@ struct JSONView: View { ScrollView { if let attributedText { Text(attributedText) + .font(.body.monospaced()) .padding() .frame(maxWidth: .infinity, alignment: .leading) } else { diff --git a/Sources/LCP/LCPClient.swift b/Sources/LCP/LCPClient.swift index de7330fa5d..40e4937ca8 100644 --- a/Sources/LCP/LCPClient.swift +++ b/Sources/LCP/LCPClient.swift @@ -28,6 +28,10 @@ import Foundation /// return R2LCPClient.findOneValidPassphrase(jsonLicense: jsonLicense, hashedPassphrases: hashedPassphrases) /// } /// +/// func getSupportedLCPProfileURIs() -> [String] { +/// return R2LCPClient.getSupportedLCPProfileURIs() ?? [] +/// } +/// /// } public protocol LCPClient { /// Create a context for a given license/passphrase tuple. @@ -38,6 +42,29 @@ public protocol LCPClient { /// Given an array of possible password hashes, return a valid password hash for the lcpl licence. func findOneValidPassphrase(jsonLicense: String, hashedPassphrases: [LCPPassphraseHash]) -> LCPPassphraseHash? + + /// Returns the LCP profile URIs supported by the underlying liblcp build. + func getSupportedLCPProfileURIs() -> [String] +} + +public extension LCPClient { + // FIXME: This default implementation preserves source compatibility for facades that predate `getSupportedLCPProfileURIs()`. Remove it in the next breaking release and make the method a hard protocol requirement, so the supported profiles always come from liblcp instead of this stale hardcoded list. + func getSupportedLCPProfileURIs() -> [String] { + [ + "http://readium.org/lcp/basic-profile", + "http://readium.org/lcp/profile-1.0", + "http://readium.org/lcp/profile-2.0", + "http://readium.org/lcp/profile-2.1", + "http://readium.org/lcp/profile-2.2", + "http://readium.org/lcp/profile-2.3", + "http://readium.org/lcp/profile-2.4", + "http://readium.org/lcp/profile-2.5", + "http://readium.org/lcp/profile-2.6", + "http://readium.org/lcp/profile-2.7", + "http://readium.org/lcp/profile-2.8", + "http://readium.org/lcp/profile-2.9", + ] + } } public typealias LCPClientContext = Any diff --git a/Sources/LCP/LCPService.swift b/Sources/LCP/LCPService.swift index a61ac9e616..3244ed7e2c 100644 --- a/Sources/LCP/LCPService.swift +++ b/Sources/LCP/LCPService.swift @@ -46,25 +46,12 @@ public final class LCPService: Loggable { deviceName: String? = nil, deviceId: String? = nil ) { - // Determine whether the embedded liblcp.a is in production mode, by attempting to open a production license. - let isProduction: Bool = { - guard - let prodLicenseURL = Bundle.module.url(forResource: "prod-license", withExtension: "lcpl"), - let prodLicense = try? String(contentsOf: prodLicenseURL, encoding: .utf8) - else { - return false - } - let passphrase = "7B7602FEF5DEDA10F768818FFACBC60B173DB223B7E66D8B2221EBE2C635EFAD" // "One passphrase" - return client.findOneValidPassphrase(jsonLicense: prodLicense, hashedPassphrases: [passphrase]) == passphrase - }() - let passphrases = PassphrasesService( client: client, repository: passphraseRepository ) licenses = LicensesService( - isProduction: isProduction, client: client, licenses: licenseRepository, crl: CRLService(httpClient: httpClient), diff --git a/Sources/LCP/License/LicenseValidation.swift b/Sources/LCP/License/LicenseValidation.swift index cc23150d00..b4cd49b213 100644 --- a/Sources/LCP/License/LicenseValidation.swift +++ b/Sources/LCP/License/LicenseValidation.swift @@ -7,22 +7,6 @@ import Foundation import ReadiumShared -/// To modify depending of the profiles supported by liblcp.a. -private let supportedProfiles = [ - "http://readium.org/lcp/basic-profile", - "http://readium.org/lcp/profile-1.0", - "http://readium.org/lcp/profile-2.0", - "http://readium.org/lcp/profile-2.1", - "http://readium.org/lcp/profile-2.2", - "http://readium.org/lcp/profile-2.3", - "http://readium.org/lcp/profile-2.4", - "http://readium.org/lcp/profile-2.5", - "http://readium.org/lcp/profile-2.6", - "http://readium.org/lcp/profile-2.7", - "http://readium.org/lcp/profile-2.8", - "http://readium.org/lcp/profile-2.9", -] - typealias Context = Result /// Holds the License/Status Documents and the DRM context, once validated. @@ -44,7 +28,6 @@ struct ValidatedDocuments { /// Use `observe` to be notified when any validation is done or if an error occurs. final actor LicenseValidation: Loggable { // Dependencies for the State's handlers - fileprivate let isProduction: Bool fileprivate let client: LCPClient fileprivate let authentication: LCPAuthenticating? fileprivate let allowUserInteraction: Bool @@ -70,7 +53,6 @@ final actor LicenseValidation: Loggable { authentication: LCPAuthenticating?, allowUserInteraction: Bool, sender: Any?, - isProduction: Bool, client: LCPClient, crl: CRLService, device: DeviceService, @@ -81,7 +63,6 @@ final actor LicenseValidation: Loggable { self.authentication = authentication self.allowUserInteraction = allowUserInteraction self.sender = sender - self.isProduction = isProduction self.client = client self.crl = crl self.device = device @@ -279,9 +260,12 @@ extension LicenseValidation { private func validateLicense(data: Data) async throws { let license = try LicenseDocument(data: data) - // In test mode, only the basic profile is authorized. - // This is done here instead of during the integrity check because the passphrase can't be validated. - guard isProduction || license.encryption.profile == "http://readium.org/lcp/basic-profile" else { + // Reject the license early (before passphrase validation) if liblcp doesn't + // support its profile, to report a clear error instead of a confusing + // "incorrect passphrase". An empty list means the profiles are unknown, so we + // defer to `createContext` rather than blocking a possibly-valid license. + let supportedProfiles = client.getSupportedLCPProfileURIs() + guard supportedProfiles.isEmpty || supportedProfiles.contains(license.encryption.profile) else { throw LCPError.licenseProfileNotSupported } @@ -371,13 +355,7 @@ extension LicenseValidation { } private func validateIntegrity(of license: LicenseDocument, with passphrase: String) async throws { - // 1. Checks the profile - let profile = license.encryption.profile - guard supportedProfiles.contains(profile) else { - throw LCPError.licenseProfileNotSupported - } - - // 2. Creates the DRM context + // Creates the DRM context let pemCrl = try await crl.retrieve() let context = try client.createContext(jsonLicense: license.jsonString, hashedPassphrase: passphrase, pemCrl: pemCrl) diff --git a/Sources/LCP/Resources/prod-license.lcpl b/Sources/LCP/Resources/prod-license.lcpl deleted file mode 100644 index 202598f6d9..0000000000 --- a/Sources/LCP/Resources/prod-license.lcpl +++ /dev/null @@ -1 +0,0 @@ -{"provider":"https://www.edrlab.org","id":"52d2fca0-8113-4f4a-a284-2bdeb9710438","issued":"2019-04-17T12:56:16Z","encryption":{"profile":"http://readium.org/lcp/profile-1.0","content_key":{"algorithm":"http://www.w3.org/2001/04/xmlenc#aes256-cbc","encrypted_value":"t0h8Ew5aH/TiW7UIqpEDcwSaALSR30kXGx0eLgLs8d3hJM3NIl9WyhG/Kk3J1ZMKllH2dFd7zaLukFxWG7+8AQ=="},"user_key":{"algorithm":"http://www.w3.org/2001/04/xmlenc#sha256","text_hint":"One passphrase","key_check":"tKYonIBR9/0wl5KbVaovW0OsLpHRyv+StukCjYeddy2YwsY3KA6fUQxaQp7Cy5f4o4fxq8VtzqCZnrpgRctNfw=="}},"links":[{"rel":"status","href":"https://lsd-prod.edrlab.org/licenses/52d2fca0-8113-4f4a-a284-2bdeb9710438/status","type":"application/vnd.readium.license.status.v1.0+json"},{"rel":"publication","href":"https://lcp-prod.edrlab.org/contents/6fbca352-13f7-424c-b93d-0d752df738bc","type":"application/epub+zip","title":"moby-dick","length":1646451,"hash":"33a41df3ae98c40fc4cc16e96052bd41db207c45fbfe7439009795f7e3c0df7c"},{"rel":"hint","href":"https://front-prod.edrlab.org"}],"user":{"id":"7b61b202-33b3-4129-b2b5-16eac65c70bf","email":"WyckqOe4nDiuDZPfDva5o9t3YQVfolV1YC2eksipBh3u0L4wuNugDx6dPryj8TlZ","name":"v++cWselhj4I0SGpV71S+0DcJCTekEJbwN8Fj86uk6Q=","encrypted":["email","name"]},"rights":{"print":2,"copy":50},"signature":{"certificate":"MIIDODCCAiCgAwIBAgIJAO5wGoN7S9PxMA0GCSqGSIb3DQEBCwUAMEIxEzARBgNVBAoTCmVkcmxhYi5vcmcxFzAVBgNVBAsTDmVkcmxhYi5vcmcgTENQMRIwEAYDVQQDEwlFRFJMYWIgQ0EwHhcNMTkwMzI2MTc0NTE1WhcNMjEwMzI1MjM1OTU5WjBcMRMwEQYDVQQKEwplZHJsYWIub3JnMSYwJAYDVQQLEx1SZWFkaXVtIExDUCBMaWNlbnNlIFByb3ZpZGVyczEdMBsGA1UEAxMURURSTGFiIFRlc3QgUHJvdmlkZXIwgZswEAYHKoZIzj0CAQYFK4EEACMDgYYABAHBjgjfztMPkm8Qjq74rkZ48ZPG3uj5+anHtlWHK6IuJtiNbsPMEmXOoaSdqNaoMvK9IrMYptv+RI52wXi8eikuHwFWmKy2BX9JC6mGVG10cizsU/j4pYoDAte76I3NZ8Dkdm9kMo0JpQ6/Fbw69DDRxDnZetExUMPCWCZlo+k2ANXDN6OBnjCBmzAfBgNVHSMEGDAWgBTcXPyT5B+f7rC66lILK8pSXODJhzAdBgNVHQ4EFgQUClSIBtrmOCvKHacKnNKCBFyhLKIwDgYDVR0PAQH/BAQDAgeAMAkGA1UdEwQCMAAwPgYDVR0fBDcwNTAzoDGgL4YtaHR0cDovL2NybC5lZHJsYWIudGVsZXNlYy5kZS9ybC9FRFJMYWJfQ0EuY3JsMA0GCSqGSIb3DQEBCwUAA4IBAQAzVI5SMRg9qHwo2ZPvhi780lEOJ3vebNzCmuSQYDtOI2/gpLkXvsK3On+G7SweTCI9jOzCB0AsCTvbImsk2jG3fkpXwe7jstlAopqjuzwDW4pFdL3Go8pvDM4tAMqKzW8zSjNJPhBAolB60/WQUxM4JkK5caHUJfEbspAl0GHsueFxgDJuJz4tVhPSi79JQQguz9CamBHicMpC+/wTBEzluEtO0HQj/J3YcBlM+PdHJqCHgKdp6N/kKxkdTVKTLsjGH7sqKzUt3MqMnfi5n7BWhfUYWdu4sluZ52l14f43gogbEkITLLynSOMfLkW2Won3+3TSHODkGSpicXS3+Beg","value":"AQjZ00NLV7Bu15ctjyZFcDPFTwt5vCMQoUhMuYKTEAaXgVxnFxwDynlWRHXydFzShoXvxTrXYbL8gudrKmnLo7E7AePScJKbnhs0QY2o8HqbRb72TNQ88sP54MW5FPjAvm1LcisBhxOnNq220NoC6DHe113780t32M3h7d57TIRqAxkb","algorithm":"http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256"}} diff --git a/Sources/LCP/Services/LicensesService.swift b/Sources/LCP/Services/LicensesService.swift index 303447acc0..32f37b19b1 100644 --- a/Sources/LCP/Services/LicensesService.swift +++ b/Sources/LCP/Services/LicensesService.swift @@ -14,7 +14,6 @@ final class LicensesService: Loggable { .pdf: .lcpProtectedPDF, ] - private let isProduction: Bool private let client: LCPClient private let licenses: LCPLicenseRepository private let crl: CRLService @@ -24,7 +23,6 @@ final class LicensesService: Loggable { private let passphrases: PassphrasesService init( - isProduction: Bool, client: LCPClient, licenses: LCPLicenseRepository, crl: CRLService, @@ -33,7 +31,6 @@ final class LicensesService: Loggable { httpClient: HTTPClient, passphrases: PassphrasesService ) { - self.isProduction = isProduction self.client = client self.licenses = licenses self.crl = crl @@ -89,7 +86,6 @@ final class LicensesService: Loggable { authentication: authentication, allowUserInteraction: allowUserInteraction, sender: sender, - isProduction: isProduction, client: client, crl: crl, device: device, diff --git a/TestApp/Sources/App/Readium.swift b/TestApp/Sources/App/Readium.swift index 6221db1a08..6290c7dfba 100644 --- a/TestApp/Sources/App/Readium.swift +++ b/TestApp/Sources/App/Readium.swift @@ -63,6 +63,10 @@ final class Readium { func findOneValidPassphrase(jsonLicense: String, hashedPassphrases: [LCPPassphraseHash]) -> LCPPassphraseHash? { R2LCPClient.findOneValidPassphrase(jsonLicense: jsonLicense, hashedPassphrases: hashedPassphrases) } + + func getSupportedLCPProfileURIs() -> [String] { + R2LCPClient.getSupportedLCPProfileURIs() ?? [] + } } #endif } diff --git a/Tests/LCPTests/LCPTestClient.swift b/Tests/LCPTests/LCPTestClient.swift index 83ddeb53c4..c52a8f9af5 100644 --- a/Tests/LCPTests/LCPTestClient.swift +++ b/Tests/LCPTests/LCPTestClient.swift @@ -20,4 +20,8 @@ class LCPTestClient: LCPClient { func findOneValidPassphrase(jsonLicense: String, hashedPassphrases: [String]) -> String? { R2LCPClient.findOneValidPassphrase(jsonLicense: jsonLicense, hashedPassphrases: hashedPassphrases) } + + func getSupportedLCPProfileURIs() -> [String] { + R2LCPClient.getSupportedLCPProfileURIs() ?? [] + } } From f123eef0acb9f031ca568132ba9dd84b580fd822 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sven=20Majeri=C4=87?= <48984233+svenmeyers89@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:24:06 +0200 Subject: [PATCH 02/39] Allow external Audio Session handling (#856) --- CHANGELOG.md | 4 ++++ .../Navigator/Audiobook/AudioNavigator.swift | 11 +++++---- .../TTS/PublicationSpeechSynthesizer.swift | 16 ++++++++----- .../Shared/Toolkit/Media/AudioSession.swift | 24 ++++++++++++++----- 4 files changed, 39 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 169161d860..26ca77a54f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes to this project will be documented in this file. Take a look ### Added +#### Navigator + +* Added the `AudioSessionManaging` protocol, letting apps provide their own audio session manager instead of the built-in `AudioSession` (contributed by [@svenmeyers89](https://github.com/readium/swift-toolkit/pull/856)). + #### LCP * `LCPService` has a new `addPassphrase(_:isHashed:userID:provider:)` method to store a passphrase candidate in the repository without opening a license first. Useful to preload a passphrase ahead of time (e.g. from a catalog). diff --git a/Sources/Navigator/Audiobook/AudioNavigator.swift b/Sources/Navigator/Audiobook/AudioNavigator.swift index 31a992ae76..1e1edf631d 100644 --- a/Sources/Navigator/Audiobook/AudioNavigator.swift +++ b/Sources/Navigator/Audiobook/AudioNavigator.swift @@ -113,6 +113,7 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo public nonisolated let publication: Publication private let initialLocation: Locator? private let config: Configuration + private let audioSession: AudioSessionManaging public var audioConfiguration: AudioSession.Configuration { config.audioSession @@ -121,11 +122,13 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo public init( publication: Publication, initialLocation: Locator? = nil, - config: Configuration = Configuration() + config: Configuration = Configuration(), + audioSession: AudioSessionManaging = AudioSession.shared ) { self.publication = publication self.initialLocation = initialLocation self.config = config + self.audioSession = audioSession let durations = publication.readingOrder.map { $0.duration ?? 0 } let totalDuration = durations.reduce(0, +) @@ -148,7 +151,7 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo } playTask?.cancel() - AudioSession.shared.end(for: self) + audioSession.end(for: self) } /// Returns whether the resource is currently playing or not. @@ -202,7 +205,7 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo /// Resumes or start the playback. public func play() { playTask = Task { @MainActor in - AudioSession.shared.start(with: self, isPlaying: false) + audioSession.start(with: self, isPlaying: false) if player.currentItem == nil { if let location = initialLocation { @@ -279,7 +282,7 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo return } - let session = AudioSession.shared + let session = self.audioSession switch player.timeControlStatus { case .paused: session.user(self, didChangePlaying: false) diff --git a/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift b/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift index 51c20f59b2..eff418ff89 100644 --- a/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift +++ b/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift @@ -88,7 +88,7 @@ public class PublicationSpeechSynthesizer: Loggable { public private(set) var state: State = .stopped { didSet { if oldValue.isPlaying != state.isPlaying { - AudioSession.shared.user(audioSessionUser, didChangePlaying: state.isPlaying) + audioSession.user(audioSessionUser, didChangePlaying: state.isPlaying) } Task { @@ -105,6 +105,7 @@ public class PublicationSpeechSynthesizer: Loggable { public weak var delegate: PublicationSpeechSynthesizerDelegate? private let publication: Publication + private let audioSession: AudioSessionManaging private let engineFactory: EngineFactory private let tokenizerFactory: TokenizerFactory @@ -117,6 +118,7 @@ public class PublicationSpeechSynthesizer: Loggable { /// - config: Initial TTS configuration. /// - audioSessionConfig: Configuration of the audio session used to play /// the utterances. + /// - audioSession: Audio session manager used to coordinate playback. /// - engineFactory: Factory to create an instance of `TtsEngine`. Defaults to `AVTTSEngine`. /// - tokenizerFactory: Factory to create a `ContentTokenizer` which will be used to /// split each `ContentElement` item into smaller chunks. Splits by sentences by default. @@ -129,6 +131,7 @@ public class PublicationSpeechSynthesizer: Loggable { mode: .spokenAudio, routeSharingPolicy: .longFormAudio ), + audioSession: AudioSessionManaging = AudioSession.shared, engineFactory: @escaping EngineFactory = { AVTTSEngine() }, tokenizerFactory: @escaping TokenizerFactory = defaultTokenizerFactory, delegate: PublicationSpeechSynthesizerDelegate? = nil @@ -139,12 +142,17 @@ public class PublicationSpeechSynthesizer: Loggable { self.publication = publication self.config = config + self.audioSession = audioSession audioSessionUser = AudioSessionUser(config: audioSessionConfig) self.engineFactory = engineFactory self.tokenizerFactory = tokenizerFactory self.delegate = delegate } + deinit { + audioSession.end(for: audioSessionUser) + } + /// The default content tokenizer will split the `Content.Element` items into individual sentences. public static let defaultTokenizerFactory: TokenizerFactory = { defaultLanguage in makeTextContentTokenizer( @@ -181,7 +189,7 @@ public class PublicationSpeechSynthesizer: Loggable { /// (Re)starts the synthesizer from the given locator or the beginning of the publication. public func start(from startLocator: Locator? = nil) { - AudioSession.shared.start(with: audioSessionUser, isPlaying: false) + audioSession.start(with: audioSessionUser, isPlaying: false) currentTask?.cancel() publicationIterator = publication.content(from: startLocator)?.iterator() @@ -421,10 +429,6 @@ public class PublicationSpeechSynthesizer: Loggable { audioConfiguration = config } - deinit { - AudioSession.shared.end(for: self) - } - func play() {} } } diff --git a/Sources/Shared/Toolkit/Media/AudioSession.swift b/Sources/Shared/Toolkit/Media/AudioSession.swift index cd1f33bfd6..39dfd2641c 100644 --- a/Sources/Shared/Toolkit/Media/AudioSession.swift +++ b/Sources/Shared/Toolkit/Media/AudioSession.swift @@ -24,14 +24,26 @@ public extension AudioSessionUser { } } +/// Manages the app's audio session for Readium audio consumers. +public protocol AudioSessionManaging { + /// Starts a new audio session with the given `user`. + func start(with user: AudioSessionUser, isPlaying: Bool) + + /// Ends the current audio session. + func end(for user: AudioSessionUser) + + /// Indicates whether the `user` is playing. + func user(_ user: AudioSessionUser, didChangePlaying isPlaying: Bool) +} + /// Manages an activated `AVAudioSession`. @MainActor -public final class AudioSession: Loggable { +public final class AudioSession: AudioSessionManaging, Loggable { public struct Configuration: Equatable { - let category: AVAudioSession.Category - let mode: AVAudioSession.Mode - let routeSharingPolicy: AVAudioSession.RouteSharingPolicy - let options: AVAudioSession.CategoryOptions + public let category: AVAudioSession.Category + public let mode: AVAudioSession.Mode + public let routeSharingPolicy: AVAudioSession.RouteSharingPolicy + public let options: AVAudioSession.CategoryOptions public init( category: AVAudioSession.Category = .playback, @@ -59,7 +71,7 @@ public final class AudioSession: Loggable { NotificationCenter.default.removeObserver(self) } - struct User { + fileprivate struct User { let id: ObjectIdentifier private(set) weak var user: AudioSessionUser? From afccc37cd32946173e8d287c701805f27a029da0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Wed, 15 Jul 2026 15:51:39 +0200 Subject: [PATCH 03/39] Store the auto-generated LCP device ID in the Keychain (#858) --- CHANGELOG.md | 6 + Sources/LCP/License/License.swift | 7 +- Sources/LCP/Services/DeviceService.swift | 119 ++++++++++++++++-- .../Services/DeviceServiceTests.swift | 80 ++++++++++++ 4 files changed, 197 insertions(+), 15 deletions(-) create mode 100644 Tests/LCPTests/Services/DeviceServiceTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 26ca77a54f..d62c565dee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,12 @@ All notable changes to this project will be documented in this file. Take a look * `LCPService` has a new `addPassphrase(_:isHashed:userID:provider:)` method to store a passphrase candidate in the repository without opening a license first. Useful to preload a passphrase ahead of time (e.g. from a catalog). * `LCPClient` has a new `getSupportedLCPProfileURIs()` requirement, letting the toolkit report `LCPError.licenseProfileNotSupported` based on the profiles the embedded liblcp actually supports. Update your `LCPClient` facade to forward `R2LCPClient.getSupportedLCPProfileURIs()` (a default implementation is provided for backward compatibility). +### Changed + +#### LCP + +* The auto-generated LCP device ID is now stored in the Keychain instead of `UserDefaults`, so it survives an app delete/reinstall and no longer needlessly consumes a license's device-registration slots. Existing IDs are automatically migrated from `UserDefaults`. + ### Fixed #### Shared diff --git a/Sources/LCP/License/License.swift b/Sources/LCP/License/License.swift index 3f02491478..662a4dab08 100644 --- a/Sources/LCP/License/License.swift +++ b/Sources/LCP/License/License.swift @@ -241,7 +241,9 @@ extension License: LCPLicense { } func makeRenewURL(from endDate: Date?) throws -> HTTPURL { - var params = device.asQueryParameters + guard var params = device.asQueryParameters else { + throw LCPError.licenseInteractionNotAvailable + } if let end = endDate { params["end"] = end.iso8601 } @@ -289,10 +291,11 @@ extension License: LCPLicense { func returnPublication() async -> Result { guard let status = documents.status, + let parameters = device.asQueryParameters, let url = try? status.url( for: .return, preferredType: .lcpStatusDocument, - parameters: device.asQueryParameters + parameters: parameters ) else { return .failure(.licenseInteractionNotAvailable) diff --git a/Sources/LCP/Services/DeviceService.swift b/Sources/LCP/Services/DeviceService.swift index f9d10e207a..a31b35e335 100644 --- a/Sources/LCP/Services/DeviceService.swift +++ b/Sources/LCP/Services/DeviceService.swift @@ -7,40 +7,127 @@ import Foundation import ReadiumShared -final class DeviceService { +final class DeviceService: Loggable { private let repository: LCPLicenseRepository private let httpClient: HTTPClient /// Returns the device's name. let name: String - /// Returns the device's ID - let id: String + + /// The device's ID, or `nil` when it could not be resolved. + /// + /// The auto-generated ID is `nil` only when the Keychain is temporarily + /// inaccessible (e.g. before the device's first unlock after a reboot). In + /// that case we deliberately avoid minting a throwaway ID: registering with + /// it would burn one of the license's device slots and the ID would change + /// on the next launch. Callers skip the LSD device interactions until a + /// later launch resolves the persisted ID. + let id: String? + + /// Legacy `UserDefaults` key used to persist the auto-generated device ID + /// before it was moved to the Keychain. + static let legacyDeviceIDDefaultsKey = "lcp_device_id" + + /// Keychain account key under which the auto-generated device ID is stored. + static let deviceIDKeychainAccount = "device-id" init( deviceName: String, deviceId: String?, repository: LCPLicenseRepository, - httpClient: HTTPClient + httpClient: HTTPClient, + keychainServiceName: String = "org.readium.lcp.device" ) { name = deviceName if let providedId = deviceId { + // The app supplied its own device ID: use it verbatim and never + // touch the Keychain. id = providedId - } else if let savedId = UserDefaults.standard.string(forKey: "lcp_device_id") { - id = savedId } else { - let generatedId = UUID().uuidString - UserDefaults.standard.set(generatedId, forKey: "lcp_device_id") - id = generatedId + id = Self.resolveAutoGeneratedID( + keychain: Keychain( + serviceName: keychainServiceName, + synchronizable: false + ) + ) } self.repository = repository self.httpClient = httpClient } - /// Device ID and name as query parameters for HTTP requests. - var asQueryParameters: [String: String] { - [ + /// Resolves the auto-generated device ID, keeping it stable across app + /// reinstalls by persisting it in the Keychain. + /// + /// Resolution order: + /// 1. An ID already stored in the Keychain is reused. + /// 2. Otherwise a legacy ID found in `UserDefaults` is migrated to the + /// Keychain (the `UserDefaults` value is left untouched). + /// 3. Otherwise a new ID is generated and stored in the Keychain. + /// + /// Returns `nil` when the Keychain cannot be read (e.g. it is still locked + /// before the first unlock) or holds an unreadable item: an ID may already + /// exist there, so we neither mint a new one nor overwrite it. See ``id``. + private static func resolveAutoGeneratedID(keychain: Keychain) -> String? { + // 1. Reuse an ID already stored in the Keychain. + do { + if let id = try loadID(from: keychain) { + return id + } + } catch { + // A read failure is not the same as "no ID": an ID may exist but be + // temporarily unreadable. Minting a new one here would register the + // device under a throwaway ID and burn a slot. + log(.error, "Failed to read the LCP device ID from the Keychain: \(error)") + return nil + } + + // 2. Migrate a legacy `UserDefaults` ID, or 3. generate a new one. + let legacyID = UserDefaults.standard.string(forKey: legacyDeviceIDDefaultsKey) + let id = legacyID ?? UUID().uuidString + + do { + try keychain.save(data: Data(id.utf8), forKey: deviceIDKeychainAccount) + return id + } catch .duplicateItem { + // Another instance stored an ID between our read and write, or an + // existing item was unreadable above. Re-read so we return the + // stored ID rather than a conflicting fresh one; if it is still + // unreadable, report "no ID" rather than a throwaway. + return try? loadID(from: keychain) + } catch { + log(.error, "Failed to persist the LCP device ID in the Keychain: \(error)") + // A legacy ID is still persisted in `UserDefaults`, so it stays + // stable across launches even without the Keychain and is safe to + // use. A freshly generated ID exists only in memory: using it would + // register a throwaway that changes next launch and burns a slot, so + // report "no ID" instead. + return legacyID + } + } + + /// Loads the stored device ID from the Keychain, or `nil` if none is stored. + /// + /// Throws when the Keychain is inaccessible or the stored bytes are not + /// valid UTF-8, so an unreadable item is never mistaken for an absent one. + private static func loadID(from keychain: Keychain) throws(KeychainError) -> String? { + guard let data = try keychain.load(forKey: deviceIDKeychainAccount) else { + return nil + } + guard let id = String(data: data, encoding: .utf8) else { + throw .invalidData + } + return id + } + + /// Device ID and name as query parameters for HTTP requests, or `nil` when + /// the device ID could not be resolved (see ``id``). + var asQueryParameters: [String: String]? { + guard let id else { + return nil + } + return [ "id": id, "name": name, ] @@ -54,7 +141,13 @@ final class DeviceService { guard !registered else { return nil } - guard let url = link.url(parameters: asQueryParameters) else { + guard let parameters = asQueryParameters else { + // The device ID is temporarily unavailable (e.g. the Keychain is + // locked before the first unlock). Skip registration; a later + // launch will register once the ID resolves. + return nil + } + guard let url = link.url(parameters: parameters) else { throw LCPError.licenseInteractionNotAvailable } diff --git a/Tests/LCPTests/Services/DeviceServiceTests.swift b/Tests/LCPTests/Services/DeviceServiceTests.swift new file mode 100644 index 0000000000..428fedf942 --- /dev/null +++ b/Tests/LCPTests/Services/DeviceServiceTests.swift @@ -0,0 +1,80 @@ +// +// Copyright 2026 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 +@testable import ReadiumLCP +import ReadiumShared +import Testing + +/// Serialized because the legacy migration path reads a process-global +/// `UserDefaults` key, which would race with the fresh-state test if run in +/// parallel. +@Suite(.serialized) +struct DeviceServiceTests { + /// Unique Keychain service name so each test run is isolated from the real + /// device store and from previous runs. + private let serviceName = "org.readium.lcp.device.test.\(UUID().uuidString)" + + private func keychain() -> Keychain { + Keychain(serviceName: serviceName, synchronizable: false) + } + + private func loadStoredID() throws -> String? { + try keychain() + .load(forKey: DeviceService.deviceIDKeychainAccount) + .flatMap { String(data: $0, encoding: .utf8) } + } + + private func makeService(deviceId: String? = nil) -> DeviceService { + DeviceService( + deviceName: "Test Device", + deviceId: deviceId, + repository: InMemoryLCPLicenseRepository(), + httpClient: DefaultHTTPClient(), + keychainServiceName: serviceName + ) + } + + @Test func generatesAndPersistsAcrossInstances() { + defer { try? keychain().deleteAll() } + + // A fresh state generates a new ID... + let first = makeService() + #expect(first.id != nil) + + // ...which is reused by a second service (persisted in the Keychain). + let second = makeService() + #expect(second.id == first.id) + } + + @Test func migratesLegacyUserDefaultsValue() throws { + defer { + try? keychain().deleteAll() + UserDefaults.standard.removeObject(forKey: DeviceService.legacyDeviceIDDefaultsKey) + } + + // A legacy ID is present in UserDefaults and the Keychain is empty. + let legacyID = "legacy-\(UUID().uuidString)" + UserDefaults.standard.set(legacyID, forKey: DeviceService.legacyDeviceIDDefaultsKey) + + // The legacy value is returned... + let service = makeService() + #expect(service.id == legacyID) + + // ...and migrated to the Keychain. + #expect(try loadStoredID() == legacyID) + } + + @Test func usesProvidedIDWithoutTouchingKeychain() throws { + defer { try? keychain().deleteAll() } + + let service = makeService(deviceId: "app-provided-id") + #expect(service.id == "app-provided-id") + + // The Keychain is untouched. + #expect(try loadStoredID() == nil) + } +} From d82f44f4f05d87add9e22a8b75abbd61dce745dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Fri, 17 Jul 2026 10:23:25 +0200 Subject: [PATCH 04/39] 3.11.0 (#862) --- CHANGELOG.md | 5 ++++- Playground/.xcodegen | 2 +- Playground/Playground.xcodeproj/project.pbxproj | 4 ++-- README.md | 10 +++++----- Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec | 6 +++--- Support/CocoaPods/ReadiumAdapterLCPSQLite.podspec | 8 ++++---- Support/CocoaPods/ReadiumInternal.podspec | 2 +- Support/CocoaPods/ReadiumLCP.podspec | 6 +++--- Support/CocoaPods/ReadiumNavigator.podspec | 6 +++--- Support/CocoaPods/ReadiumOPDS.podspec | 6 +++--- Support/CocoaPods/ReadiumShared.podspec | 4 ++-- Support/CocoaPods/ReadiumStreamer.podspec | 6 +++--- Support/CocoaPods/Specs.swift | 2 +- TestApp/Sources/Info.plist | 4 ++-- 14 files changed, 37 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d62c565dee..8c685630b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,9 @@ All notable changes to this project will be documented in this file. Take a look at [the migration guide](docs/Migration%20Guide.md) to upgrade between two major versions. -## [Unreleased] + + +## [3.11.0] - 2026-07-17 ### Added @@ -1283,3 +1285,4 @@ progression. Now if no reading progression is set, the `effectiveReadingProgress [3.8.0]: https://github.com/readium/swift-toolkit/compare/3.7.0...3.8.0 [3.9.0]: https://github.com/readium/swift-toolkit/compare/3.8.0...3.9.0 [3.10.0]: https://github.com/readium/swift-toolkit/compare/3.9.0...3.10.0 +[3.11.0]: https://github.com/readium/swift-toolkit/compare/3.10.0...3.11.0 diff --git a/Playground/.xcodegen b/Playground/.xcodegen index e42a26ea5b..eee52ea4cd 100644 --- a/Playground/.xcodegen +++ b/Playground/.xcodegen @@ -1,5 +1,5 @@ # XCODEGEN VERSION -2.45.4 +2.46.0 # SPEC { diff --git a/Playground/Playground.xcodeproj/project.pbxproj b/Playground/Playground.xcodeproj/project.pbxproj index 1154fb76d9..a9e8a47796 100644 --- a/Playground/Playground.xcodeproj/project.pbxproj +++ b/Playground/Playground.xcodeproj/project.pbxproj @@ -42,9 +42,9 @@ B77C0B458C816697C5C670E9 /* PlaygroundApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaygroundApp.swift; sourceTree = ""; }; BDA9169E926B14087F3B1BA2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; CCB6D3C4C19C2038573D2B90 /* JSONView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONView.swift; sourceTree = ""; }; - D4ED8674105B331259C428CB /* swift-toolkit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = swift-toolkit; path = ..; sourceTree = SOURCE_ROOT; }; D608867E2F9CC0B751114DE9 /* A02-ReadMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "A02-ReadMetadata.swift"; sourceTree = ""; }; E40DD68F934F5F0D2981ACA1 /* Playground.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Playground.app; sourceTree = BUILT_PRODUCTS_DIR; }; + ED646581982F3FF78FB275C8 /* swift-toolkit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = swift-toolkit; path = ..; sourceTree = SOURCE_ROOT; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -129,7 +129,7 @@ 75054112A41CDCE58ADACF92 /* Packages */ = { isa = PBXGroup; children = ( - D4ED8674105B331259C428CB /* swift-toolkit */, + ED646581982F3FF78FB275C8 /* swift-toolkit */, ); name = Packages; sourceTree = ""; diff --git a/README.md b/README.md index 77c77064d6..cde1cb7c43 100644 --- a/README.md +++ b/README.md @@ -134,11 +134,11 @@ Add the following `pod` statements to your `Podfile` for the Readium libraries y source 'https://github.com/readium/podspecs' source 'https://cdn.cocoapods.org/' -pod 'ReadiumShared', '~> 3.10.0' -pod 'ReadiumStreamer', '~> 3.10.0' -pod 'ReadiumNavigator', '~> 3.10.0' -pod 'ReadiumOPDS', '~> 3.10.0' -pod 'ReadiumLCP', '~> 3.10.0' +pod 'ReadiumShared', '~> 3.11.0' +pod 'ReadiumStreamer', '~> 3.11.0' +pod 'ReadiumNavigator', '~> 3.11.0' +pod 'ReadiumOPDS', '~> 3.11.0' +pod 'ReadiumLCP', '~> 3.11.0' ``` Take a look at [CocoaPods's documentation](https://guides.cocoapods.org/using/using-cocoapods.html) for more information. diff --git a/Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec b/Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec index d1d5805cb8..f7dd1687d2 100644 --- a/Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec +++ b/Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec @@ -4,7 +4,7 @@ Pod::Spec.new do |s| s.name = "ReadiumAdapterGCDWebServer" - s.version = "3.10.0" + s.version = "3.11.0" s.license = "BSD 3-Clause License" s.summary = "Adapter to use GCDWebServer as an HTTP server in Readium" s.homepage = "http://readium.github.io" @@ -18,8 +18,8 @@ Pod::Spec.new do |s| s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-package-name Readium' } - s.dependency 'ReadiumInternal', '~> 3.10.0' - s.dependency 'ReadiumShared', '~> 3.10.0' + s.dependency 'ReadiumInternal', '~> 3.11.0' + s.dependency 'ReadiumShared', '~> 3.11.0' s.dependency 'ReadiumGCDWebServer', '~> 4.0.0' end diff --git a/Support/CocoaPods/ReadiumAdapterLCPSQLite.podspec b/Support/CocoaPods/ReadiumAdapterLCPSQLite.podspec index 57d781222c..0b6615f1ae 100644 --- a/Support/CocoaPods/ReadiumAdapterLCPSQLite.podspec +++ b/Support/CocoaPods/ReadiumAdapterLCPSQLite.podspec @@ -4,7 +4,7 @@ Pod::Spec.new do |s| s.name = "ReadiumAdapterLCPSQLite" - s.version = "3.10.0" + s.version = "3.11.0" s.license = "BSD 3-Clause License" s.summary = "Adapter to use SQLite.swift for the Readium LCP repositories" s.homepage = "http://readium.github.io" @@ -18,9 +18,9 @@ Pod::Spec.new do |s| s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-package-name Readium' } - s.dependency 'ReadiumInternal', '~> 3.10.0' - s.dependency 'ReadiumShared', '~> 3.10.0' - s.dependency 'ReadiumLCP', '~> 3.10.0' + s.dependency 'ReadiumInternal', '~> 3.11.0' + s.dependency 'ReadiumShared', '~> 3.11.0' + s.dependency 'ReadiumLCP', '~> 3.11.0' s.dependency 'SQLite.swift', '~> 0.16.0' end diff --git a/Support/CocoaPods/ReadiumInternal.podspec b/Support/CocoaPods/ReadiumInternal.podspec index acf30f3db7..6299671478 100644 --- a/Support/CocoaPods/ReadiumInternal.podspec +++ b/Support/CocoaPods/ReadiumInternal.podspec @@ -4,7 +4,7 @@ Pod::Spec.new do |s| s.name = "ReadiumInternal" - s.version = "3.10.0" + s.version = "3.11.0" s.license = "BSD 3-Clause License" s.summary = "Private utilities used by the Readium modules" s.homepage = "http://readium.github.io" diff --git a/Support/CocoaPods/ReadiumLCP.podspec b/Support/CocoaPods/ReadiumLCP.podspec index db720eafba..ec870dbd19 100644 --- a/Support/CocoaPods/ReadiumLCP.podspec +++ b/Support/CocoaPods/ReadiumLCP.podspec @@ -4,7 +4,7 @@ Pod::Spec.new do |s| s.name = "ReadiumLCP" - s.version = "3.10.0" + s.version = "3.11.0" s.license = "BSD 3-Clause License" s.summary = "Readium LCP" s.homepage = "http://readium.github.io" @@ -24,8 +24,8 @@ Pod::Spec.new do |s| s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-package-name Readium' } - s.dependency 'ReadiumInternal', '~> 3.10.0' - s.dependency 'ReadiumShared', '~> 3.10.0' + s.dependency 'ReadiumInternal', '~> 3.11.0' + s.dependency 'ReadiumShared', '~> 3.11.0' s.dependency 'ReadiumZIPFoundation', '~> 3.0.1' s.dependency 'CryptoSwift', '~> 1.10.0' diff --git a/Support/CocoaPods/ReadiumNavigator.podspec b/Support/CocoaPods/ReadiumNavigator.podspec index 070c7b3557..82e2395e34 100644 --- a/Support/CocoaPods/ReadiumNavigator.podspec +++ b/Support/CocoaPods/ReadiumNavigator.podspec @@ -4,7 +4,7 @@ Pod::Spec.new do |s| s.name = "ReadiumNavigator" - s.version = "3.10.0" + s.version = "3.11.0" s.license = "BSD 3-Clause License" s.summary = "Readium Navigator" s.homepage = "http://readium.github.io" @@ -23,8 +23,8 @@ Pod::Spec.new do |s| s.ios.deployment_target = "15.0" s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-package-name Readium' } - s.dependency 'ReadiumInternal', '~> 3.10.0' - s.dependency 'ReadiumShared', '~> 3.10.0' + s.dependency 'ReadiumInternal', '~> 3.11.0' + s.dependency 'ReadiumShared', '~> 3.11.0' s.dependency 'DifferenceKit', '~> 1.0' s.dependency 'SwiftSoup', '~> 2.11.0' diff --git a/Support/CocoaPods/ReadiumOPDS.podspec b/Support/CocoaPods/ReadiumOPDS.podspec index 116a1fba36..dc3c94af2f 100644 --- a/Support/CocoaPods/ReadiumOPDS.podspec +++ b/Support/CocoaPods/ReadiumOPDS.podspec @@ -4,7 +4,7 @@ Pod::Spec.new do |s| s.name = "ReadiumOPDS" - s.version = "3.10.0" + s.version = "3.11.0" s.license = "BSD 3-Clause License" s.summary = "Readium OPDS" s.homepage = "http://readium.github.io" @@ -18,8 +18,8 @@ Pod::Spec.new do |s| s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-package-name Readium' } - s.dependency 'ReadiumInternal', '~> 3.10.0' - s.dependency 'ReadiumShared', '~> 3.10.0' + s.dependency 'ReadiumInternal', '~> 3.11.0' + s.dependency 'ReadiumShared', '~> 3.11.0' s.dependency 'ReadiumFuzi', '~> 4.0.0' end diff --git a/Support/CocoaPods/ReadiumShared.podspec b/Support/CocoaPods/ReadiumShared.podspec index ddd81cf42f..2b8009adf5 100644 --- a/Support/CocoaPods/ReadiumShared.podspec +++ b/Support/CocoaPods/ReadiumShared.podspec @@ -4,7 +4,7 @@ Pod::Spec.new do |s| s.name = "ReadiumShared" - s.version = "3.10.0" + s.version = "3.11.0" s.license = "BSD 3-Clause License" s.summary = "Readium Shared" s.homepage = "http://readium.github.io" @@ -23,7 +23,7 @@ Pod::Spec.new do |s| s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-package-name Readium' } - s.dependency 'ReadiumInternal', '~> 3.10.0' + s.dependency 'ReadiumInternal', '~> 3.11.0' s.dependency 'Minizip', '~> 1.0.0' s.dependency 'SwiftSoup', '~> 2.11.0' s.dependency 'ReadiumFuzi', '~> 4.0.0' diff --git a/Support/CocoaPods/ReadiumStreamer.podspec b/Support/CocoaPods/ReadiumStreamer.podspec index ad55aba350..b052caedf6 100644 --- a/Support/CocoaPods/ReadiumStreamer.podspec +++ b/Support/CocoaPods/ReadiumStreamer.podspec @@ -4,7 +4,7 @@ Pod::Spec.new do |s| s.name = "ReadiumStreamer" - s.version = "3.10.0" + s.version = "3.11.0" s.license = "BSD 3-Clause License" s.summary = "Readium Streamer" s.homepage = "http://readium.github.io" @@ -25,8 +25,8 @@ Pod::Spec.new do |s| s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-package-name Readium' } - s.dependency 'ReadiumInternal', '~> 3.10.0' - s.dependency 'ReadiumShared', '~> 3.10.0' + s.dependency 'ReadiumInternal', '~> 3.11.0' + s.dependency 'ReadiumShared', '~> 3.11.0' s.dependency 'ReadiumFuzi', '~> 4.0.0' s.dependency 'CryptoSwift', '~> 1.10.0' diff --git a/Support/CocoaPods/Specs.swift b/Support/CocoaPods/Specs.swift index fb9831fa8c..a12a96eb07 100644 --- a/Support/CocoaPods/Specs.swift +++ b/Support/CocoaPods/Specs.swift @@ -5,7 +5,7 @@ // /// Readium toolkit version — bump this when releasing a new version, then run `make podspecs`. -let version = "3.10.0" +let version = "3.11.0" /// Minimum iOS deployment target shared by all modules. let iosTarget = "15.0" diff --git a/TestApp/Sources/Info.plist b/TestApp/Sources/Info.plist index 33a7d7d1a8..68d3876676 100644 --- a/TestApp/Sources/Info.plist +++ b/TestApp/Sources/Info.plist @@ -252,9 +252,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 3.10.0 + 3.11.0 CFBundleVersion - 3.10.0 + 3.11.0 LSRequiresIPhoneOS LSSupportsOpeningDocumentsInPlace From f75c3445cc5bbb5298bb895a8928aa54e536778b Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Wed, 1 Apr 2026 10:33:50 -0500 Subject: [PATCH 05/39] Refactor public classes, structs, and enums to be Sendable (#761) --- CHANGELOG.md | 10 ++++ .../Adapters/GCDWebServer/GCDHTTPServer.swift | 8 +-- .../SQLiteLCPLicenseRepository.swift | 4 +- .../SQLiteLCPPassphraseRepository.swift | 4 +- Sources/Internal/Extensions/Task.swift | 2 +- Sources/Internal/UTI.swift | 4 +- .../Authentications/LCPAuthenticating.swift | 6 +- Sources/LCP/Authentications/LCPDialog.swift | 4 +- .../LCPDialogAuthentication.swift | 2 +- .../LCPObservableAuthentication.swift | 4 +- .../LCPPassphraseAuthentication.swift | 2 +- Sources/LCP/LCPAcquiredPublication.swift | 2 +- Sources/LCP/LCPClient.swift | 2 +- Sources/LCP/LCPError.swift | 12 ++-- Sources/LCP/LCPLicenseRepository.swift | 2 +- Sources/LCP/LCPProgress.swift | 2 +- Sources/LCP/LCPRenewDelegate.swift | 2 +- Sources/LCP/LCPService.swift | 2 +- .../Model/Components/LCP/ContentKey.swift | 2 +- .../Model/Components/LCP/Encryption.swift | 2 +- .../License/Model/Components/LCP/Rights.swift | 2 +- .../Model/Components/LCP/Signature.swift | 2 +- .../License/Model/Components/LCP/User.swift | 2 +- .../Model/Components/LCP/UserKey.swift | 2 +- .../License/Model/Components/LSD/Event.swift | 4 +- .../Components/LSD/PotentialRights.swift | 2 +- .../LCP/License/Model/Components/Link.swift | 2 +- .../LCP/License/Model/Components/Links.swift | 2 +- .../LCP/License/Model/LicenseDocument.swift | 4 +- .../LCP/License/Model/StatusDocument.swift | 6 +- .../LCPKeychainLicenseRepository.swift | 2 +- .../LCPKeychainPassphraseRepository.swift | 2 +- Sources/LCP/Toolkit/DataCompression.swift | 4 +- .../Navigator/Audiobook/AudioNavigator.swift | 6 +- .../Preferences/AudioPreferences.swift | 2 +- .../Audiobook/Preferences/AudioSettings.swift | 4 +- .../Decorator/DecorableNavigator.swift | 2 +- .../DirectionalNavigationAdapter.swift | 6 +- .../Navigator/EPUB/CSS/CSSProperties.swift | 58 +++++++++---------- .../EPUB/CSS/HTMLFontFamilyDeclaration.swift | 10 ++-- .../EPUB/EPUBNavigatorViewController.swift | 2 +- .../EPUB/HTMLDecorationTemplate.swift | 4 +- .../EPUB/Preferences/EPUBPreferences.swift | 2 +- .../EPUB/Preferences/EPUBSettings.swift | 4 +- Sources/Navigator/EditingAction.swift | 2 +- Sources/Navigator/Input/Key/Key.swift | 2 +- Sources/Navigator/Input/Key/KeyEvent.swift | 4 +- .../Navigator/Input/Key/KeyModifiers.swift | 2 +- .../Input/Pointer/PointerEvent.swift | 6 +- Sources/Navigator/Navigator.swift | 4 +- .../PDF/PDFNavigatorViewController.swift | 2 +- .../PDF/Preferences/PDFPreferences.swift | 2 +- .../PDF/Preferences/PDFSettings.swift | 4 +- .../Navigator/Preferences/Configurable.swift | 2 +- .../Preferences/MappedPreference.swift | 6 +- .../Navigator/Preferences/Preference.swift | 4 +- .../Preferences/ProgressionStrategy.swift | 22 +++---- .../Preferences/ProxyPreference.swift | 4 +- Sources/Navigator/Preferences/Types.swift | 20 +++---- Sources/Navigator/SelectableNavigator.swift | 2 +- Sources/Navigator/TTS/AVTTSEngine.swift | 4 +- .../TTS/PublicationSpeechSynthesizer.swift | 10 ++-- Sources/Navigator/TTS/TTSEngine.swift | 8 +-- Sources/Navigator/TTS/TTSVoice.swift | 6 +- .../Viewport/ViewportObservingNavigator.swift | 4 +- Sources/Navigator/VisualNavigator.swift | 2 +- Sources/OPDS/OPDS1Parser.swift | 36 ++++++------ Sources/OPDS/OPDS2Parser.swift | 34 +++++------ Sources/OPDS/OPDSParser.swift | 4 +- Sources/OPDS/ParseData.swift | 2 +- Sources/Shared/Logger/Loggable.swift | 2 +- Sources/Shared/Logger/LoggerStub.swift | 2 +- Sources/Shared/OPDS/Facet.swift | 2 +- Sources/Shared/OPDS/Feed.swift | 2 +- Sources/Shared/OPDS/Group.swift | 2 +- Sources/Shared/OPDS/OPDSAcquisition.swift | 2 +- Sources/Shared/OPDS/OPDSAvailability.swift | 4 +- Sources/Shared/OPDS/OPDSCopies.swift | 2 +- Sources/Shared/OPDS/OPDSHolds.swift | 2 +- Sources/Shared/OPDS/OPDSPrice.swift | 2 +- Sources/Shared/OPDS/OpdsMetadata.swift | 2 +- .../AccessibilityMetadataDisplayGuide.swift | 16 ++--- .../Extensions/EPUB/EPUBLayout.swift | 2 +- .../Extensions/Encryption/Encryption.swift | 2 +- .../Extensions/HTML/DOMRange.swift | 4 +- Sources/Shared/Publication/Link.swift | 2 +- .../Protection/ContentProtection.swift | 6 +- .../FallbackContentProtection.swift | 2 +- Sources/Shared/Publication/Publication.swift | 2 +- .../Content Protection/UserRights.swift | 4 +- .../Services/Content/Content.swift | 8 +-- .../Services/Content/ContentService.swift | 2 +- .../HTMLResourceContentIterator.swift | 4 +- .../PublicationContentIterator.swift | 4 +- .../Positions/InMemoryPositionsService.swift | 2 +- .../Services/Search/SearchService.swift | 4 +- .../Search/StringSearchAlgorithm.swift | 4 +- .../Services/Search/StringSearchService.swift | 2 +- .../Toolkit/Archive/ArchiveOpener.swift | 4 +- .../Toolkit/Archive/ArchiveProperties.swift | 2 +- .../Archive/DefaultArchiveOpener.swift | 2 +- .../Toolkit/Data/Asset/AssetRetriever.swift | 4 +- .../Toolkit/Data/Container/Container.swift | 4 +- .../Container/SingleResourceContainer.swift | 2 +- Sources/Shared/Toolkit/Data/ReadError.swift | 4 +- .../Data/Resource/FailureResource.swift | 2 +- .../Resource/ResourceContentExtractor.swift | 4 +- .../Data/Resource/ResourceFactory.swift | 2 +- .../Data/Resource/ResourceProperties.swift | 2 +- Sources/Shared/Toolkit/DebugError.swift | 2 +- Sources/Shared/Toolkit/DocumentTypes.swift | 4 +- Sources/Shared/Toolkit/Either.swift | 2 +- .../Toolkit/File/DirectoryContainer.swift | 4 +- .../Shared/Toolkit/File/FileContainer.swift | 2 +- .../Toolkit/File/FileResourceFactory.swift | 2 +- .../Shared/Toolkit/File/FileSystemError.swift | 2 +- Sources/Shared/Toolkit/FileExtension.swift | 2 +- Sources/Shared/Toolkit/Format/Format.swift | 6 +- .../Shared/Toolkit/Format/FormatSniffer.swift | 2 +- .../Format/Sniffers/AudioFormatSniffer.swift | 2 +- .../Sniffers/AudiobookFormatSniffer.swift | 2 +- .../Format/Sniffers/BitmapFormatSniffer.swift | 2 +- .../Format/Sniffers/ComicFormatSniffer.swift | 2 +- .../Format/Sniffers/EPUBFormatSniffer.swift | 2 +- .../Format/Sniffers/HTMLFormatSniffer.swift | 2 +- .../Format/Sniffers/JSONFormatSniffer.swift | 2 +- .../Sniffers/LCPLicenseFormatSniffer.swift | 2 +- .../Sniffers/LanguageFormatSniffer.swift | 2 +- .../Format/Sniffers/OPDSFormatSniffer.swift | 2 +- .../Format/Sniffers/PDFFormatSniffer.swift | 2 +- .../Format/Sniffers/RARFormatSniffer.swift | 2 +- .../Format/Sniffers/RPFFormatSniffer.swift | 2 +- .../Format/Sniffers/RWPMFormatSniffer.swift | 2 +- .../Format/Sniffers/XMLFormatSniffer.swift | 2 +- .../Format/Sniffers/ZIPFormatSniffer.swift | 2 +- .../Toolkit/HTTP/DefaultHTTPClient.swift | 2 +- Sources/Shared/Toolkit/HTTP/HTTPClient.swift | 4 +- .../Toolkit/HTTP/HTTPProblemDetails.swift | 2 +- Sources/Shared/Toolkit/HTTP/HTTPRequest.swift | 6 +- .../Toolkit/HTTP/HTTPResourceFactory.swift | 2 +- Sources/Shared/Toolkit/HTTP/HTTPServer.swift | 2 +- Sources/Shared/Toolkit/JSONValue.swift | 6 +- Sources/Shared/Toolkit/Keychain.swift | 2 +- .../Toolkit/Logging/WarningLogger.swift | 4 +- .../Shared/Toolkit/Media/AudioSession.swift | 4 +- .../Shared/Toolkit/Media/NowPlayingInfo.swift | 2 +- Sources/Shared/Toolkit/PDF/CGPDF.swift | 2 +- Sources/Shared/Toolkit/PDF/PDFDocument.swift | 6 +- Sources/Shared/Toolkit/PDF/PDFKit.swift | 2 +- .../Shared/Toolkit/PDF/PDFOutlineNode.swift | 2 +- .../Toolkit/Tokenizer/TextTokenizer.swift | 4 +- Sources/Shared/Toolkit/URL/AnyURL.swift | 2 +- Sources/Shared/Toolkit/URL/RelativeURL.swift | 2 +- Sources/Shared/Toolkit/URL/URITemplate.swift | 2 +- Sources/Shared/Toolkit/URL/URLQuery.swift | 4 +- Sources/Shared/Toolkit/Weak.swift | 24 +------- Sources/Shared/Toolkit/XML/XML.swift | 6 +- .../ZIP/Minizip/MinizipArchiveOpener.swift | 2 +- .../Shared/Toolkit/ZIP/ZIPArchiveOpener.swift | 2 +- .../ZIPFoundationArchiveOpener.swift | 2 +- .../AudioPublicationManifestAugmentor.swift | 2 +- Sources/Streamer/Parser/EPUB/EPUBParser.swift | 4 +- Sources/Streamer/Parser/EPUB/OPFParser.swift | 2 +- .../EPUBDeobfuscator.swift | 2 +- .../Parser/EPUB/SMIL/SMILParser.swift | 2 +- .../EPUB/Services/EPUBPositionsService.swift | 2 +- Sources/Streamer/Parser/PDF/PDFParser.swift | 2 +- .../Streamer/Parser/PublicationParser.swift | 2 +- .../Parser/Readium/ReadiumWebPubParser.swift | 8 +-- Sources/Streamer/PublicationOpener.swift | 4 +- .../Streamer/Toolkit/DataCompression.swift | 4 +- .../OPDS/OPDSFeeds/OPDSFeedViewModel.swift | 2 +- .../Locator/DefaultLocatorServiceTests.swift | 30 +++++----- .../Services/AudioLocatorServiceTests.swift | 22 +++---- docs/Migration Guide.md | 1 - 175 files changed, 387 insertions(+), 398 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c685630b6..57d0642ded 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to this project will be documented in this file. Take a look at [the migration guide](docs/Migration%20Guide.md) to upgrade between two major versions. + +## [Unreleased: swift6] + +### Changed + +#### Shared + +* OPDS models (`Feed`, `Group`, `Facet`, `OpdsMetadata`) are now structs with value semantics. + + ## [3.11.0] - 2026-07-17 diff --git a/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift b/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift index be236ad1a0..b77b1b41c8 100644 --- a/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift +++ b/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift @@ -5,14 +5,14 @@ // import Foundation -import ReadiumGCDWebServer +@preconcurrency import ReadiumGCDWebServer import ReadiumInternal import ReadiumShared import UIKit @available(*, deprecated, message: "The Readium navigators do not need an HTTP server anymore. This adapter will be removed in a future version of the toolkit.") -public enum GCDHTTPServerError: Error { - case failedToStartServer(cause: Error) +public enum GCDHTTPServerError: Error, Sendable { + case failedToStartServer(cause: any Error) case serverNotStarted case invalidEndpoint(HTTPServerEndpoint) case nullServerURL @@ -20,7 +20,7 @@ public enum GCDHTTPServerError: Error { /// Implementation of `HTTPServer` using ReadiumGCDWebServer under the hood. @available(*, deprecated, message: "The Readium navigators do not need an HTTP server anymore. This adapter will be removed in a future version of the toolkit.") -public class GCDHTTPServer: HTTPServer, Loggable { +public final class GCDHTTPServer: HTTPServer, Loggable { /// The actual underlying HTTP server instance. private let server = ReadiumGCDWebServer() diff --git a/Sources/Adapters/LCPSQLite/SQLiteLCPLicenseRepository.swift b/Sources/Adapters/LCPSQLite/SQLiteLCPLicenseRepository.swift index 9dba0b82cf..ad44e0ccfb 100644 --- a/Sources/Adapters/LCPSQLite/SQLiteLCPLicenseRepository.swift +++ b/Sources/Adapters/LCPSQLite/SQLiteLCPLicenseRepository.swift @@ -7,10 +7,10 @@ import Foundation import ReadiumLCP import ReadiumShared -import SQLite +@preconcurrency import SQLite @available(*, deprecated, message: "Use LCPKeychainLicenseRepository from ReadiumLCP instead") -public class LCPSQLiteLicenseRepository: LCPLicenseRepository, Loggable { +public final class LCPSQLiteLicenseRepository: LCPLicenseRepository, Loggable, Sendable { let licenses = Table("Licenses") let id = SQLite.Expression("id") let printsLeft = SQLite.Expression("printsLeft") diff --git a/Sources/Adapters/LCPSQLite/SQLiteLCPPassphraseRepository.swift b/Sources/Adapters/LCPSQLite/SQLiteLCPPassphraseRepository.swift index 7854cd68b0..2305ae467c 100644 --- a/Sources/Adapters/LCPSQLite/SQLiteLCPPassphraseRepository.swift +++ b/Sources/Adapters/LCPSQLite/SQLiteLCPPassphraseRepository.swift @@ -7,10 +7,10 @@ import Foundation import ReadiumLCP import ReadiumShared -import SQLite +@preconcurrency import SQLite @available(*, deprecated, message: "Use LCPKeychainPassphraseRepository from ReadiumLCP instead") -public class LCPSQLitePassphraseRepository: LCPPassphraseRepository, Loggable { +public final class LCPSQLitePassphraseRepository: LCPPassphraseRepository, Loggable, Sendable { let transactions = Table("Transactions") let licenseId = SQLite.Expression("licenseId") let provider = SQLite.Expression("origin") diff --git a/Sources/Internal/Extensions/Task.swift b/Sources/Internal/Extensions/Task.swift index f6358ec44a..46fa09859c 100644 --- a/Sources/Internal/Extensions/Task.swift +++ b/Sources/Internal/Extensions/Task.swift @@ -7,7 +7,7 @@ import Foundation @MainActor -public final class CancellableTasks { +public final class CancellableTasks: Sendable { private var tasks: Set> = [] public nonisolated init() {} diff --git a/Sources/Internal/UTI.swift b/Sources/Internal/UTI.swift index 63d1e89262..c9ac46bda7 100644 --- a/Sources/Internal/UTI.swift +++ b/Sources/Internal/UTI.swift @@ -8,9 +8,9 @@ import Foundation import UniformTypeIdentifiers /// Uniform Type Identifier. -public struct UTI { +public struct UTI: Sendable { /// Type tag class, eg. UTTagClass.mimeType. - public enum TagClass { + public enum TagClass: Sendable { case mediaType, fileExtension } diff --git a/Sources/LCP/Authentications/LCPAuthenticating.swift b/Sources/LCP/Authentications/LCPAuthenticating.swift index 6fdd1c8da6..2401167b3f 100644 --- a/Sources/LCP/Authentications/LCPAuthenticating.swift +++ b/Sources/LCP/Authentications/LCPAuthenticating.swift @@ -6,7 +6,7 @@ import Foundation -public protocol LCPAuthenticating { +public protocol LCPAuthenticating: Sendable { /// Retrieves the passphrase to decrypt the given license. /// /// If `allowUserInteraction` is true, the reading app can prompt the user to enter the @@ -31,14 +31,14 @@ public protocol LCPAuthenticating { ) async -> String? } -public enum LCPAuthenticationReason { +public enum LCPAuthenticationReason: Sendable { /// No matching passphrase was found. case passphraseNotFound /// The provided passphrase was invalid. case invalidPassphrase } -public struct LCPAuthenticatedLicense { +public struct LCPAuthenticatedLicense: Sendable { /// A hint to be displayed to the User to help them remember the User Passphrase. public var hint: String { document.encryption.userKey.textHint diff --git a/Sources/LCP/Authentications/LCPDialog.swift b/Sources/LCP/Authentications/LCPDialog.swift index dc34755ab5..6922aadd9b 100644 --- a/Sources/LCP/Authentications/LCPDialog.swift +++ b/Sources/LCP/Authentications/LCPDialog.swift @@ -46,8 +46,8 @@ import SwiftUI /// } /// } /// ``` -public struct LCPDialog: View { - public enum ErrorMessage { +public struct LCPDialog: View, Sendable { + public enum ErrorMessage: Sendable { case incorrectPassphrase var string: String { diff --git a/Sources/LCP/Authentications/LCPDialogAuthentication.swift b/Sources/LCP/Authentications/LCPDialogAuthentication.swift index 43a1c2e7ac..fb9e8d01cb 100644 --- a/Sources/LCP/Authentications/LCPDialogAuthentication.swift +++ b/Sources/LCP/Authentications/LCPDialogAuthentication.swift @@ -13,7 +13,7 @@ import UIKit /// For this authentication to trigger, you must provide a `sender` parameter of type /// `UIViewController` to `Streamer.open()` or `LCPService.retrieveLicense()`. It will be used /// as the presenting view controller for the dialog. -public class LCPDialogAuthentication: LCPAuthenticating, Loggable { +public final class LCPDialogAuthentication: LCPAuthenticating, Loggable, Sendable { private let animated: Bool private let modalPresentationStyle: UIModalPresentationStyle private let modalTransitionStyle: UIModalTransitionStyle diff --git a/Sources/LCP/Authentications/LCPObservableAuthentication.swift b/Sources/LCP/Authentications/LCPObservableAuthentication.swift index e088eccf98..7472ba7833 100644 --- a/Sources/LCP/Authentications/LCPObservableAuthentication.swift +++ b/Sources/LCP/Authentications/LCPObservableAuthentication.swift @@ -12,12 +12,12 @@ import SwiftUI /// Pair an ``LCPObservableAuthentication`` with an ``LCPDialog`` to implement /// the LCP authentication in SwiftUI. @MainActor -public final class LCPObservableAuthentication: LCPAuthenticating, ObservableObject { +public final class LCPObservableAuthentication: LCPAuthenticating, ObservableObject, Sendable { /// Represents an on-going LCP authentication request. /// /// You must call the `submit()` or `cancel()` API to conclude the request. @MainActor - public final class Request: Identifiable { + public final class Request: Identifiable, Sendable { /// LCP License requested to be unlocked. public let license: LCPAuthenticatedLicense diff --git a/Sources/LCP/Authentications/LCPPassphraseAuthentication.swift b/Sources/LCP/Authentications/LCPPassphraseAuthentication.swift index 4940739f34..3f678def10 100644 --- a/Sources/LCP/Authentications/LCPPassphraseAuthentication.swift +++ b/Sources/LCP/Authentications/LCPPassphraseAuthentication.swift @@ -10,7 +10,7 @@ import Foundation /// passphrase. /// /// If the provided `passphrase` is incorrect, the given `fallback` authentication is used. -public class LCPPassphraseAuthentication: LCPAuthenticating { +public final class LCPPassphraseAuthentication: LCPAuthenticating, Sendable { private let passphrase: String private let fallback: LCPAuthenticating? diff --git a/Sources/LCP/LCPAcquiredPublication.swift b/Sources/LCP/LCPAcquiredPublication.swift index aa1f9fe249..5438b5daec 100644 --- a/Sources/LCP/LCPAcquiredPublication.swift +++ b/Sources/LCP/LCPAcquiredPublication.swift @@ -9,7 +9,7 @@ import ReadiumShared /// Holds information about an LCP protected publication which was acquired /// from an LCPL. -public struct LCPAcquiredPublication { +public struct LCPAcquiredPublication: Sendable { /// Path to the downloaded publication. /// /// You must move this file to the user library's folder. diff --git a/Sources/LCP/LCPClient.swift b/Sources/LCP/LCPClient.swift index 40e4937ca8..951d1378be 100644 --- a/Sources/LCP/LCPClient.swift +++ b/Sources/LCP/LCPClient.swift @@ -72,7 +72,7 @@ public typealias LCPClientContext = Any /// Copy of the R2LCPClient.LCPClientError enum. /// /// Order is important, because it is used to match the original enum cases. -public enum LCPClientError: Int, Error { +public enum LCPClientError: Int, Error, Sendable { case licenseOutOfDate = 0 case certificateRevoked case certificateSignatureInvalid diff --git a/Sources/LCP/LCPError.swift b/Sources/LCP/LCPError.swift index 8f8b78a609..6ae04ce105 100644 --- a/Sources/LCP/LCPError.swift +++ b/Sources/LCP/LCPError.swift @@ -67,7 +67,7 @@ public enum LCPError: Error { /// from the number of "register" events in the status document. If no event is /// logged in the status document, no such message should appear (certainly not /// "The license was registered by 0 devices"). -public enum StatusError: Error { +public enum StatusError: Error, Sendable { /// This license was cancelled on the given date. case cancelled(Date) /// This license has been returned on the given date. @@ -80,7 +80,7 @@ public enum StatusError: Error { } /// Errors while renewing a loan. -public enum RenewError: Error { +public enum RenewError: Error, Sendable { /// Your publication could not be renewed properly. case renewFailed /// Incorrect renewal period, your publication could not be renewed. @@ -90,7 +90,7 @@ public enum RenewError: Error { } /// Errors while returning a loan. -public enum ReturnError: Error { +public enum ReturnError: Error, Sendable { /// Your publication could not be returned properly. case returnFailed /// Your publication has already been returned before or is expired. @@ -100,7 +100,7 @@ public enum ReturnError: Error { } /// Errors while parsing the License or Status JSON Documents. -public enum ParsingError: Error { +public enum ParsingError: Error, Sendable { /// The JSON is malformed and can't be parsed. case malformedJSON /// The JSON is not representing a valid License Document. @@ -118,9 +118,9 @@ public enum ParsingError: Error { } /// Errors while reading or writing a LCP container (LCPL, EPUB, LCPDF, etc.) -public enum ContainerError: Error { +public enum ContainerError: Error, Sendable { /// Can't access the container, it's format is wrong. - case openFailed(Error?) + case openFailed((any Error)?) /// The file at given relative path is not found in the Container. case fileNotFound(String) /// Can't read the file at given relative path in the Container. diff --git a/Sources/LCP/LCPLicenseRepository.swift b/Sources/LCP/LCPLicenseRepository.swift index f8d99f9093..3d249d7587 100644 --- a/Sources/LCP/LCPLicenseRepository.swift +++ b/Sources/LCP/LCPLicenseRepository.swift @@ -39,7 +39,7 @@ public protocol LCPLicenseRepository { } /// Holds the current state of consumable user rights for a license. -public struct LCPConsumableUserRights { +public struct LCPConsumableUserRights: Sendable { /// Maximum number of pages left to be printed. /// /// If `nil`, there is no limit. diff --git a/Sources/LCP/LCPProgress.swift b/Sources/LCP/LCPProgress.swift index bdf8fa971e..5a061b27ed 100644 --- a/Sources/LCP/LCPProgress.swift +++ b/Sources/LCP/LCPProgress.swift @@ -7,7 +7,7 @@ import Foundation /// Percent-based progress of the acquisition. -public enum LCPProgress { +public enum LCPProgress: Sendable { /// Undetermined progress, a spinner should be shown to the user. case indefinite /// A finite progress from 0.0 to 1.0, a progress bar should be shown to the user. diff --git a/Sources/LCP/LCPRenewDelegate.swift b/Sources/LCP/LCPRenewDelegate.swift index 190f948705..3cad3c06a2 100644 --- a/Sources/LCP/LCPRenewDelegate.swift +++ b/Sources/LCP/LCPRenewDelegate.swift @@ -28,7 +28,7 @@ public protocol LCPRenewDelegate { /// /// No date picker is presented for selecting a preferred end date. If you want to support one, you can subclass or /// decorate `LCPRenewDelegate`. -public class LCPDefaultRenewDelegate: NSObject, LCPRenewDelegate { +public final class LCPDefaultRenewDelegate: NSObject, LCPRenewDelegate { private let presentingViewController: UIViewController private let modalPresentationStyle: UIModalPresentationStyle diff --git a/Sources/LCP/LCPService.swift b/Sources/LCP/LCPService.swift index 3244ed7e2c..5b3ea1030e 100644 --- a/Sources/LCP/LCPService.swift +++ b/Sources/LCP/LCPService.swift @@ -174,7 +174,7 @@ public final class LCPService: Loggable { } /// Source of an LCP License Document (LCPL) file. -public enum LicenseDocumentSource { +public enum LicenseDocumentSource: Sendable { /// Raw bytes of the LCPL. case data(Data) diff --git a/Sources/LCP/License/Model/Components/LCP/ContentKey.swift b/Sources/LCP/License/Model/Components/LCP/ContentKey.swift index 174034decc..d936d441ad 100644 --- a/Sources/LCP/License/Model/Components/LCP/ContentKey.swift +++ b/Sources/LCP/License/Model/Components/LCP/ContentKey.swift @@ -9,7 +9,7 @@ import ReadiumShared /// Used to encrypt the Publication Resources. /// This is encrypted using the User Key. -public struct ContentKey: JSONValueDecodable { +public struct ContentKey: JSONValueDecodable, Sendable { /// Algorithm used to encrypt the Content Key, identified using the URIs defined in [XML-ENC]. This MUST match the Content Key encryption algorithm named in the Encryption Profile identified in `encryption/profile`. public let algorithm: String /// Encrypted Content Key. diff --git a/Sources/LCP/License/Model/Components/LCP/Encryption.swift b/Sources/LCP/License/Model/Components/LCP/Encryption.swift index 9b00cab48b..3422573016 100644 --- a/Sources/LCP/License/Model/Components/LCP/Encryption.swift +++ b/Sources/LCP/License/Model/Components/LCP/Encryption.swift @@ -7,7 +7,7 @@ import Foundation import ReadiumShared -public struct Encryption: JSONValueDecodable { +public struct Encryption: JSONValueDecodable, Sendable { /// Identifies the Encryption Profile used by this LCP-protected Publication. public let profile: String /// Used to encrypt the Publication Resources. diff --git a/Sources/LCP/License/Model/Components/LCP/Rights.swift b/Sources/LCP/License/Model/Components/LCP/Rights.swift index aee383bb50..5ce72af080 100644 --- a/Sources/LCP/License/Model/Components/LCP/Rights.swift +++ b/Sources/LCP/License/Model/Components/LCP/Rights.swift @@ -7,7 +7,7 @@ import Foundation import ReadiumShared -public struct Rights: JSONValueDecodable { +public struct Rights: JSONValueDecodable, Sendable { /// Maximum number of pages that can be printed over the lifetime of the license. public let print: Int? /// Maximum number of characters that can be copied to the clipboard over the lifetime of the license. diff --git a/Sources/LCP/License/Model/Components/LCP/Signature.swift b/Sources/LCP/License/Model/Components/LCP/Signature.swift index a77104220e..a55ad7cf65 100644 --- a/Sources/LCP/License/Model/Components/LCP/Signature.swift +++ b/Sources/LCP/License/Model/Components/LCP/Signature.swift @@ -8,7 +8,7 @@ import Foundation import ReadiumShared /// Signature allowing to certify the License Document integrity. -public struct Signature: JSONValueDecodable { +public struct Signature: JSONValueDecodable, Sendable { /// Algorithm used to calculate the signature, identified using the URIs given in [XML-SIG]. This MUST match the signature algorithm named in the Encryption Profile identified in `encryption/profile`. public let algorithm: String /// The Provider Certificate: an X509 certificate used by the Content Provider. diff --git a/Sources/LCP/License/Model/Components/LCP/User.swift b/Sources/LCP/License/Model/Components/LCP/User.swift index c21be867f8..03dff6d128 100644 --- a/Sources/LCP/License/Model/Components/LCP/User.swift +++ b/Sources/LCP/License/Model/Components/LCP/User.swift @@ -7,7 +7,7 @@ import Foundation import ReadiumShared -public struct User: JSONValueDecodable { +public struct User: JSONValueDecodable, Sendable { public typealias ID = String /// Unique identifier for the User at a specific Provider. diff --git a/Sources/LCP/License/Model/Components/LCP/UserKey.swift b/Sources/LCP/License/Model/Components/LCP/UserKey.swift index 92760e92a2..0b58a7e690 100644 --- a/Sources/LCP/License/Model/Components/LCP/UserKey.swift +++ b/Sources/LCP/License/Model/Components/LCP/UserKey.swift @@ -8,7 +8,7 @@ import Foundation import ReadiumShared /// Used to encrypt the ContentKey. -public struct UserKey: JSONValueDecodable { +public struct UserKey: JSONValueDecodable, Sendable { /// A hint to be displayed to the User to help them remember the User Passphrase. public let textHint: String /// Algorithm used to generate the User Key from the User Passphrase, identified using the URIs defined in [XML-ENC]. This MUST match the User Key hash algorithm named in the Encryption Profile identified in `encryption/profile`. diff --git a/Sources/LCP/License/Model/Components/LSD/Event.swift b/Sources/LCP/License/Model/Components/LSD/Event.swift index 7f8e1fdae5..7d463b4db6 100644 --- a/Sources/LCP/License/Model/Components/LSD/Event.swift +++ b/Sources/LCP/License/Model/Components/LSD/Event.swift @@ -8,8 +8,8 @@ import Foundation import ReadiumShared /// Event related to the change in status of a License Document. -public struct Event: JSONValueDecodable { - public enum EventType: String { +public struct Event: JSONValueDecodable, Sendable { + public enum EventType: String, Sendable { /// Signals a successful registration event by a device. case register /// Signals a successful renew event. diff --git a/Sources/LCP/License/Model/Components/LSD/PotentialRights.swift b/Sources/LCP/License/Model/Components/LSD/PotentialRights.swift index f70e910a2e..5caa03f577 100644 --- a/Sources/LCP/License/Model/Components/LSD/PotentialRights.swift +++ b/Sources/LCP/License/Model/Components/LSD/PotentialRights.swift @@ -7,7 +7,7 @@ import Foundation import ReadiumShared -public struct PotentialRights: JSONValueDecodable { +public struct PotentialRights: JSONValueDecodable, Sendable { /// Time and Date when the license ends. public let end: Date? diff --git a/Sources/LCP/License/Model/Components/Link.swift b/Sources/LCP/License/Model/Components/Link.swift index 0f676245f4..91edb5037d 100644 --- a/Sources/LCP/License/Model/Components/Link.swift +++ b/Sources/LCP/License/Model/Components/Link.swift @@ -8,7 +8,7 @@ import Foundation import ReadiumShared /// A Link to a resource. -public struct Link: JSONValueDecodable { +public struct Link: JSONValueDecodable, Sendable { /// The link destination. public let href: String /// Indicates the relationship between the resource and its containing collection. diff --git a/Sources/LCP/License/Model/Components/Links.swift b/Sources/LCP/License/Model/Components/Links.swift index 3e937dd6f6..47752cd472 100644 --- a/Sources/LCP/License/Model/Components/Links.swift +++ b/Sources/LCP/License/Model/Components/Links.swift @@ -7,7 +7,7 @@ import Foundation import ReadiumShared -public struct Links: JSONValueDecodable { +public struct Links: JSONValueDecodable, Sendable { private let links: [Link] public init?(json: T?, warnings: WarningLogger?) throws { diff --git a/Sources/LCP/License/Model/LicenseDocument.swift b/Sources/LCP/License/Model/LicenseDocument.swift index beeb02ec1c..e5537b5519 100644 --- a/Sources/LCP/License/Model/LicenseDocument.swift +++ b/Sources/LCP/License/Model/LicenseDocument.swift @@ -9,12 +9,12 @@ 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 -public struct LicenseDocument { +public struct LicenseDocument: Sendable { public typealias ID = String public typealias Provider = String /// The possible rel of Links. - public enum Rel: String { + public enum Rel: String, Sendable { /// Location where a Reading System can redirect a User looking for additional information about the User Passphrase. case hint /// Location where the Publication associated with the License Document can be downloaded diff --git a/Sources/LCP/License/Model/StatusDocument.swift b/Sources/LCP/License/Model/StatusDocument.swift index 0857e7d2d2..cfca63dc5a 100644 --- a/Sources/LCP/License/Model/StatusDocument.swift +++ b/Sources/LCP/License/Model/StatusDocument.swift @@ -9,8 +9,8 @@ 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 -public struct StatusDocument { - public enum Status: String { +public struct StatusDocument: Sendable { + public enum Status: String, Sendable { /// The License Document is available, but the user hasn't accessed the License and/or Status Document yet. case ready /// The license is active, and a device has been successfully registered for this license. This is the default value if the License Document does not contain a registration link, or a registration mechanism through the license itself. @@ -25,7 +25,7 @@ public struct StatusDocument { case expired } - public enum Rel: String { + public enum Rel: String, Sendable { case register case license case `return` diff --git a/Sources/LCP/Repositories/Keychain/LCPKeychainLicenseRepository.swift b/Sources/LCP/Repositories/Keychain/LCPKeychainLicenseRepository.swift index a10945ba59..2a6570a8e1 100644 --- a/Sources/LCP/Repositories/Keychain/LCPKeychainLicenseRepository.swift +++ b/Sources/LCP/Repositories/Keychain/LCPKeychainLicenseRepository.swift @@ -8,7 +8,7 @@ import Foundation import ReadiumShared /// Errors occurring in ``LCPKeychainLicenseRepository``. -public enum LCPKeychainLicenseRepositoryError: Error { +public enum LCPKeychainLicenseRepositoryError: Error, Sendable { /// The license with the given `id` was not found in the repository. case licenseNotFound(id: LicenseDocument.ID) diff --git a/Sources/LCP/Repositories/Keychain/LCPKeychainPassphraseRepository.swift b/Sources/LCP/Repositories/Keychain/LCPKeychainPassphraseRepository.swift index 4a57cdc20d..43922abe17 100644 --- a/Sources/LCP/Repositories/Keychain/LCPKeychainPassphraseRepository.swift +++ b/Sources/LCP/Repositories/Keychain/LCPKeychainPassphraseRepository.swift @@ -8,7 +8,7 @@ import Foundation import ReadiumShared /// Errors occurring in ``LCPKeychainPassphraseRepository``. -public enum LCPKeychainPassphraseRepositoryError: Error { +public enum LCPKeychainPassphraseRepositoryError: Error, Sendable { /// An error occurred while accessing the keychain. case keychain(KeychainError) diff --git a/Sources/LCP/Toolkit/DataCompression.swift b/Sources/LCP/Toolkit/DataCompression.swift index e6b93a33ab..de4b1bbf61 100644 --- a/Sources/LCP/Toolkit/DataCompression.swift +++ b/Sources/LCP/Toolkit/DataCompression.swift @@ -260,7 +260,7 @@ public extension Data { } /// Struct based type representing a Crc32 checksum. -public struct Crc32: CustomStringConvertible { +public struct Crc32: CustomStringConvertible, Sendable { private static let zLibCrc32: ZLibCrc32FuncPtr? = loadCrc32fromZLib() public init() {} @@ -349,7 +349,7 @@ public struct Crc32: CustomStringConvertible { } /// Struct based type representing a Adler32 checksum. -public struct Adler32: CustomStringConvertible { +public struct Adler32: CustomStringConvertible, Sendable { private static let zLibAdler32: ZLibAdler32FuncPtr? = loadAdler32fromZLib() public init() {} diff --git a/Sources/Navigator/Audiobook/AudioNavigator.swift b/Sources/Navigator/Audiobook/AudioNavigator.swift index 1e1edf631d..2510490939 100644 --- a/Sources/Navigator/Audiobook/AudioNavigator.swift +++ b/Sources/Navigator/Audiobook/AudioNavigator.swift @@ -9,14 +9,14 @@ import Foundation import ReadiumShared /// Status of a played media resource. -public enum MediaPlaybackState { +public enum MediaPlaybackState: Sendable { case paused case loading case playing } /// Holds metadata about a played media resource. -public struct MediaPlaybackInfo { +public struct MediaPlaybackInfo: Sendable { /// Index of the current resource in the `readingOrder`. public let resourceIndex: Int @@ -80,7 +80,7 @@ public extension AudioNavigatorDelegate { public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Loggable { public weak var delegate: AudioNavigatorDelegate? - public struct Configuration { + public struct Configuration: Sendable { /// Initial set of setting preferences. public var preferences: AudioPreferences diff --git a/Sources/Navigator/Audiobook/Preferences/AudioPreferences.swift b/Sources/Navigator/Audiobook/Preferences/AudioPreferences.swift index 54e4da684e..0554b46985 100644 --- a/Sources/Navigator/Audiobook/Preferences/AudioPreferences.swift +++ b/Sources/Navigator/Audiobook/Preferences/AudioPreferences.swift @@ -8,7 +8,7 @@ import Foundation import ReadiumShared /// Preferences for the `AudioNavigator`. -public struct AudioPreferences: ConfigurablePreferences { +public struct AudioPreferences: ConfigurablePreferences, Sendable { public static let empty: AudioPreferences = .init() /// Volume of playback, from 0.0 to 1.0. diff --git a/Sources/Navigator/Audiobook/Preferences/AudioSettings.swift b/Sources/Navigator/Audiobook/Preferences/AudioSettings.swift index be042b74d7..d3f117bca5 100644 --- a/Sources/Navigator/Audiobook/Preferences/AudioSettings.swift +++ b/Sources/Navigator/Audiobook/Preferences/AudioSettings.swift @@ -10,7 +10,7 @@ import ReadiumShared /// Setting values of the `AudioNavigator`. /// /// See `AudioPreferences` -public struct AudioSettings: ConfigurableSettings { +public struct AudioSettings: ConfigurableSettings, Sendable { public let volume: Double public let speed: Double @@ -30,7 +30,7 @@ public struct AudioSettings: ConfigurableSettings { /// These values will be used when no publication metadata or user preference takes precedence. /// /// See `AudioPreferences`. -public struct AudioDefaults { +public struct AudioDefaults: Sendable { public var volume: Double? public var speed: Double? diff --git a/Sources/Navigator/Decorator/DecorableNavigator.swift b/Sources/Navigator/Decorator/DecorableNavigator.swift index 2f8749b75d..f8b5d7c353 100644 --- a/Sources/Navigator/Decorator/DecorableNavigator.swift +++ b/Sources/Navigator/Decorator/DecorableNavigator.swift @@ -89,7 +89,7 @@ public struct Decoration: Hashable, JSONObjectEncodable { /// instructions which makes sense for the resource type. public struct Style: Hashable { /// Unique ID for a style. - public struct Id: RawRepresentable, ExpressibleByStringLiteral, Hashable, JSONValueEncodable { + public struct Id: RawRepresentable, ExpressibleByStringLiteral, Hashable, JSONValueEncodable, Sendable { public let rawValue: String public init(rawValue: String) { self.rawValue = rawValue diff --git a/Sources/Navigator/DirectionalNavigationAdapter.swift b/Sources/Navigator/DirectionalNavigationAdapter.swift index ce32177ef5..574052bb01 100644 --- a/Sources/Navigator/DirectionalNavigationAdapter.swift +++ b/Sources/Navigator/DirectionalNavigationAdapter.swift @@ -17,7 +17,7 @@ import Foundation public typealias TapEdges = Edges /// Indicates which viewport edges trigger page turns on pointer activation. - public struct Edges: OptionSet { + public struct Edges: OptionSet, Sendable { /// The user can turn pages when tapping on the edges of both the /// horizontal and vertical axes. public static let all: Edges = [.horizontal, .vertical] @@ -35,7 +35,7 @@ import Foundation /// Policy controlling how pointer events (touches, mouse clicks) trigger /// page turns. - public struct PointerPolicy { + public struct PointerPolicy: Sendable { /// The types of pointer that will trigger page turns. public var types: [PointerType] @@ -84,7 +84,7 @@ import Foundation } /// Policy controlling how keyboard events trigger page turns. - public struct KeyboardPolicy { + public struct KeyboardPolicy: Sendable { /// Indicates whether arrow keys should turn pages. public var handleArrowKeys: Bool diff --git a/Sources/Navigator/EPUB/CSS/CSSProperties.swift b/Sources/Navigator/EPUB/CSS/CSSProperties.swift index ec654d1dcd..a4765a5d83 100644 --- a/Sources/Navigator/EPUB/CSS/CSSProperties.swift +++ b/Sources/Navigator/EPUB/CSS/CSSProperties.swift @@ -35,7 +35,7 @@ public extension CSSProperties { /// User settings properties. /// /// See https://readium.org/readium-css/docs/CSS19-api.html#user-settings -public struct CSSUserProperties: CSSProperties { +public struct CSSUserProperties: CSSProperties, Sendable { // View mode /// User view: paged or scrolled. @@ -261,7 +261,7 @@ public struct CSSUserProperties: CSSProperties { /// Reading System properties. /// /// See https://readium.org/readium-css/docs/CSS19-api.html#reading-system-styles -public struct CSSRSProperties: CSSProperties { +public struct CSSRSProperties: CSSProperties, Sendable { // Pagination /// @param colWidth The optimal column’s width. It serves as a floor in our design. @@ -530,7 +530,7 @@ public struct CSSRSProperties: CSSProperties { } } -public enum CSSView: String, CSSConvertible { +public enum CSSView: String, CSSConvertible, Sendable { case paged = "readium-paged-on" case scroll = "readium-scroll-on" @@ -539,7 +539,7 @@ public enum CSSView: String, CSSConvertible { } } -public enum CSSColCount: String, CSSConvertible { +public enum CSSColCount: String, CSSConvertible, Sendable { case auto case one = "1" case two = "2" @@ -549,7 +549,7 @@ public enum CSSColCount: String, CSSConvertible { } } -public enum CSSAppearance: String, CSSConvertible { +public enum CSSAppearance: String, CSSConvertible, Sendable { case night = "readium-night-on" case sepia = "readium-sepia-on" @@ -558,9 +558,9 @@ public enum CSSAppearance: String, CSSConvertible { } } -public protocol CSSColor: CSSConvertible {} +public protocol CSSColor: CSSConvertible, Sendable {} -public struct CSSRGBColor: CSSColor { +public struct CSSRGBColor: CSSColor, Sendable { let red: Int let green: Int let blue: Int @@ -579,7 +579,7 @@ public struct CSSRGBColor: CSSColor { } } -public struct CSSHexColor: CSSColor { +public struct CSSHexColor: CSSColor, Sendable { let color: String public init(_ color: String) { @@ -591,7 +591,7 @@ public struct CSSHexColor: CSSColor { } } -public struct CSSIntColor: CSSColor { +public struct CSSIntColor: CSSColor, Sendable { let color: Int public init(_ color: Int) { @@ -603,12 +603,12 @@ public struct CSSIntColor: CSSColor { } } -public protocol CSSLength: CSSConvertible {} +public protocol CSSLength: CSSConvertible, Sendable {} public protocol CSSAbsoluteLength: CSSLength {} /// Centimeters -public struct CSSCmLength: CSSAbsoluteLength { +public struct CSSCmLength: CSSAbsoluteLength, Sendable { public let value: Double public init(_ value: Double) { @@ -621,7 +621,7 @@ public struct CSSCmLength: CSSAbsoluteLength { } /// Millimeters -public struct CSSMmLength: CSSAbsoluteLength { +public struct CSSMmLength: CSSAbsoluteLength, Sendable { public let value: Double public init(_ value: Double) { @@ -634,7 +634,7 @@ public struct CSSMmLength: CSSAbsoluteLength { } /// Inches -public struct CSSInLength: CSSAbsoluteLength { +public struct CSSInLength: CSSAbsoluteLength, Sendable { public let value: Double public init(_ value: Double) { @@ -647,7 +647,7 @@ public struct CSSInLength: CSSAbsoluteLength { } /// Pixels -public struct CSSPxLength: CSSAbsoluteLength { +public struct CSSPxLength: CSSAbsoluteLength, Sendable { public let value: Double public init(_ value: Double) { @@ -660,7 +660,7 @@ public struct CSSPxLength: CSSAbsoluteLength { } /// Points -public struct CSSPtLength: CSSAbsoluteLength { +public struct CSSPtLength: CSSAbsoluteLength, Sendable { public let value: Double public init(_ value: Double) { @@ -673,7 +673,7 @@ public struct CSSPtLength: CSSAbsoluteLength { } /// Picas -public struct CSSPcLength: CSSAbsoluteLength { +public struct CSSPcLength: CSSAbsoluteLength, Sendable { public let value: Double public init(_ value: Double) { @@ -688,7 +688,7 @@ public struct CSSPcLength: CSSAbsoluteLength { public protocol CSSRelativeLength: CSSLength {} /// Relative to the font-size of the element. -public struct CSSEmLength: CSSRelativeLength { +public struct CSSEmLength: CSSRelativeLength, Sendable { public let value: Double public init(_ value: Double) { @@ -701,7 +701,7 @@ public struct CSSEmLength: CSSRelativeLength { } /// Relative to the width of the "0" (zero). -public struct CSSChLength: CSSRelativeLength { +public struct CSSChLength: CSSRelativeLength, Sendable { public let value: Double public init(_ value: Double) { @@ -714,7 +714,7 @@ public struct CSSChLength: CSSRelativeLength { } /// Relative to font-size of the root element. -public struct CSSRemLength: CSSRelativeLength { +public struct CSSRemLength: CSSRelativeLength, Sendable { public let value: Double public init(_ value: Double) { @@ -727,7 +727,7 @@ public struct CSSRemLength: CSSRelativeLength { } /// Relative to 1% of the width of the viewport. -public struct CSSVwLength: CSSRelativeLength { +public struct CSSVwLength: CSSRelativeLength, Sendable { public let value: Double public init(_ value: Double) { @@ -740,7 +740,7 @@ public struct CSSVwLength: CSSRelativeLength { } /// Relative to 1% of the height of the viewport. -public struct CSSVhLength: CSSRelativeLength { +public struct CSSVhLength: CSSRelativeLength, Sendable { public let value: Double public init(_ value: Double) { @@ -753,7 +753,7 @@ public struct CSSVhLength: CSSRelativeLength { } /// Relative to 1% of viewport's smaller dimension. -public struct CSSVMinLength: CSSRelativeLength { +public struct CSSVMinLength: CSSRelativeLength, Sendable { public let value: Double public init(_ value: Double) { @@ -766,7 +766,7 @@ public struct CSSVMinLength: CSSRelativeLength { } /// Relative to 1% of viewport's larger dimension. -public struct CSSVMaxLength: CSSRelativeLength { +public struct CSSVMaxLength: CSSRelativeLength, Sendable { public let value: Double public init(_ value: Double) { @@ -779,7 +779,7 @@ public struct CSSVMaxLength: CSSRelativeLength { } /// Relative to the parent element. -public struct CSSPercentLength: CSSRelativeLength { +public struct CSSPercentLength: CSSRelativeLength, Sendable { public let value: Double public init(_ value: Double) { @@ -791,7 +791,7 @@ public struct CSSPercentLength: CSSRelativeLength { } } -public enum CSSTextAlign: String, CSSConvertible { +public enum CSSTextAlign: String, CSSConvertible, Sendable { case start case left case right @@ -803,7 +803,7 @@ public enum CSSTextAlign: String, CSSConvertible { } /// Line height supports unitless numbers. -public enum CSSLineHeight: CSSConvertible { +public enum CSSLineHeight: CSSConvertible, Sendable { case length(CSSLength) case unitless(Double) @@ -817,7 +817,7 @@ public enum CSSLineHeight: CSSConvertible { } } -public enum CSSHyphens: String, CSSConvertible { +public enum CSSHyphens: String, CSSConvertible, Sendable { case none case auto @@ -826,7 +826,7 @@ public enum CSSHyphens: String, CSSConvertible { } } -public enum CSSLigatures: String, CSSConvertible { +public enum CSSLigatures: String, CSSConvertible, Sendable { case none case common = "common-ligatures" @@ -835,7 +835,7 @@ public enum CSSLigatures: String, CSSConvertible { } } -public enum CSSBoxSizing: String, CSSConvertible { +public enum CSSBoxSizing: String, CSSConvertible, Sendable { case contentBox = "content-box" case borderBox = "border-box" diff --git a/Sources/Navigator/EPUB/CSS/HTMLFontFamilyDeclaration.swift b/Sources/Navigator/EPUB/CSS/HTMLFontFamilyDeclaration.swift index 186e7985d2..74eead1ccf 100644 --- a/Sources/Navigator/EPUB/CSS/HTMLFontFamilyDeclaration.swift +++ b/Sources/Navigator/EPUB/CSS/HTMLFontFamilyDeclaration.swift @@ -57,7 +57,7 @@ public extension HTMLFontFamilyDeclaration { } /// A font family declaration. -public struct CSSFontFamilyDeclaration: HTMLFontFamilyDeclaration { +public struct CSSFontFamilyDeclaration: HTMLFontFamilyDeclaration, Sendable { public let fontFamily: FontFamily public let alternates: [FontFamily] @@ -89,7 +89,7 @@ public struct CSSFontFamilyDeclaration: HTMLFontFamilyDeclaration { } /// Represents a single `@font-face` CSS rule. -public struct CSSFontFace { +public struct CSSFontFace: Sendable { /// Represents an individual font file. /// /// `preload` indicates whether this source will be declared for preloading @@ -161,13 +161,13 @@ public struct CSSFontFace { } /// Styles that a font can be styled with. -public enum CSSFontStyle: String, Codable { +public enum CSSFontStyle: String, Codable, Sendable { case normal case italic } /// Weight (or boldness) of a font. -public enum CSSFontWeight: Codable { +public enum CSSFontWeight: Codable, Sendable { case standard(CSSStandardFontWeight) case variable(ClosedRange) } @@ -175,7 +175,7 @@ public enum CSSFontWeight: Codable { /// Standard weights (or boldness) of a font. /// /// See https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight#common_weight_name_mapping -public enum CSSStandardFontWeight: Int, Codable { +public enum CSSStandardFontWeight: Int, Codable, Sendable { case thin = 100 case extraLight = 200 case light = 300 diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift index b668179ca9..d63f4c7393 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift @@ -29,7 +29,7 @@ open class EPUBNavigatorViewController: InputObservableViewController, VisualNavigator, ViewportObservingNavigator, SelectableNavigator, DecorableNavigator, Configurable, Loggable { - public enum EPUBError: Error { + public enum EPUBError: Error, Sendable { /// The provided publication is restricted. Check that any DRM was /// properly unlocked using a Content Protection. case publicationRestricted diff --git a/Sources/Navigator/EPUB/HTMLDecorationTemplate.swift b/Sources/Navigator/EPUB/HTMLDecorationTemplate.swift index ef1f945b96..96133d1f92 100644 --- a/Sources/Navigator/EPUB/HTMLDecorationTemplate.swift +++ b/Sources/Navigator/EPUB/HTMLDecorationTemplate.swift @@ -11,7 +11,7 @@ import UIKit /// An `HTMLDecorationTemplate` renders a `Decoration` into a set of HTML elements and associated stylesheet. public struct HTMLDecorationTemplate: JSONObjectEncodable { /// Determines the number of created HTML elements and their position relative to the matching DOM range. - public enum Layout: String { + public enum Layout: String, Sendable { /// A single HTML element covering the smallest region containing all CSS border boxes. case bounds /// One HTML element for each CSS border box (e.g. line of text). @@ -19,7 +19,7 @@ public struct HTMLDecorationTemplate: JSONObjectEncodable { } /// Indicates how the width of each created HTML element expands in the viewport. - public enum Width: String { + public enum Width: String, Sendable { /// Smallest width fitting the CSS border box. case wrap /// Fills the bounds layout. diff --git a/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift b/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift index a0e643e0ac..248620fd53 100644 --- a/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift +++ b/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift @@ -8,7 +8,7 @@ import Foundation import ReadiumShared /// Preferences for the `EPUBNavigatorViewController`. -public struct EPUBPreferences: ConfigurablePreferences { +public struct EPUBPreferences: ConfigurablePreferences, Sendable { public static let empty: EPUBPreferences = .init() /// Default page background color. diff --git a/Sources/Navigator/EPUB/Preferences/EPUBSettings.swift b/Sources/Navigator/EPUB/Preferences/EPUBSettings.swift index 637040cf03..53210b232e 100644 --- a/Sources/Navigator/EPUB/Preferences/EPUBSettings.swift +++ b/Sources/Navigator/EPUB/Preferences/EPUBSettings.swift @@ -10,7 +10,7 @@ import ReadiumShared /// Setting values of the `EPUBNavigatorViewController`. /// /// See `EPUBPreferences` -public struct EPUBSettings: ConfigurableSettings { +public struct EPUBSettings: ConfigurableSettings, Sendable { public var backgroundColor: Color? public var columnCount: ColumnCount public var fit: Fit @@ -205,7 +205,7 @@ public struct EPUBSettings: ConfigurableSettings { /// takes precedence. /// /// See `EPUBPreferences`. -public struct EPUBDefaults { +public struct EPUBDefaults: Sendable { public var columnCount: ColumnCount? public var fit: Fit? public var fontSize: Double? diff --git a/Sources/Navigator/EditingAction.swift b/Sources/Navigator/EditingAction.swift index ef4727904e..449c8b6f13 100644 --- a/Sources/Navigator/EditingAction.swift +++ b/Sources/Navigator/EditingAction.swift @@ -16,7 +16,7 @@ import UIKit /// Then, implement the selector in one of your classes in the responder chain. /// Typically, in the `UIViewController` wrapping the navigator view /// controller. -public struct EditingAction: Hashable { +public struct EditingAction: Hashable, Sendable { /// Default editing actions enabled in the navigator. public static var defaultActions: [EditingAction] { [copy, share, lookup, translate] diff --git a/Sources/Navigator/Input/Key/Key.swift b/Sources/Navigator/Input/Key/Key.swift index 8c5f5de167..8fe9e82d23 100644 --- a/Sources/Navigator/Input/Key/Key.swift +++ b/Sources/Navigator/Input/Key/Key.swift @@ -7,7 +7,7 @@ import Foundation import UIKit -public enum Key: Equatable, CustomStringConvertible { +public enum Key: Equatable, CustomStringConvertible, Sendable { /// Printable character. case character(String) diff --git a/Sources/Navigator/Input/Key/KeyEvent.swift b/Sources/Navigator/Input/Key/KeyEvent.swift index e170a27163..c962f941de 100644 --- a/Sources/Navigator/Input/Key/KeyEvent.swift +++ b/Sources/Navigator/Input/Key/KeyEvent.swift @@ -8,7 +8,7 @@ import Foundation import UIKit /// Represents a keyboard event emitted by a Navigator. -public struct KeyEvent: Equatable, CustomStringConvertible { +public struct KeyEvent: Equatable, CustomStringConvertible, Sendable { /// Phase of this event, e.g. pressed or released. public var phase: Phase @@ -29,7 +29,7 @@ public struct KeyEvent: Equatable, CustomStringConvertible { } /// Phase of a key event, e.g. pressed or released. - public enum Phase: Equatable, CustomStringConvertible { + public enum Phase: Equatable, CustomStringConvertible, Sendable { case down case change case up diff --git a/Sources/Navigator/Input/Key/KeyModifiers.swift b/Sources/Navigator/Input/Key/KeyModifiers.swift index 98542fce85..9aaa8b375a 100644 --- a/Sources/Navigator/Input/Key/KeyModifiers.swift +++ b/Sources/Navigator/Input/Key/KeyModifiers.swift @@ -7,7 +7,7 @@ import UIKit /// Represents a set of modifier keys held together. -public struct KeyModifiers: OptionSet, Equatable, CustomStringConvertible { +public struct KeyModifiers: OptionSet, Equatable, CustomStringConvertible, Sendable { public static let command = KeyModifiers(rawValue: 1 << 0) public static let control = KeyModifiers(rawValue: 1 << 1) public static let option = KeyModifiers(rawValue: 1 << 2) diff --git a/Sources/Navigator/Input/Pointer/PointerEvent.swift b/Sources/Navigator/Input/Pointer/PointerEvent.swift index 9d81c7ff84..5ede5dc642 100644 --- a/Sources/Navigator/Input/Pointer/PointerEvent.swift +++ b/Sources/Navigator/Input/Pointer/PointerEvent.swift @@ -44,7 +44,7 @@ public struct PointerEvent: Equatable { } /// Phase of a pointer event. - public enum Phase: Equatable, CustomStringConvertible { + public enum Phase: Equatable, CustomStringConvertible, Sendable { /// Fired when a pointer becomes active. case down @@ -102,7 +102,7 @@ public enum Pointer: Equatable, CustomStringConvertible { } /// Type of a pointer. -public enum PointerType: Equatable, CaseIterable { +public enum PointerType: Equatable, CaseIterable, Sendable { case touch case mouse } @@ -134,7 +134,7 @@ public struct MousePointer: Identifiable, Equatable { /// Represents a set of mouse buttons. /// /// The values are derived from https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons#value -public struct MouseButtons: OptionSet, Equatable, CustomStringConvertible { +public struct MouseButtons: OptionSet, Equatable, CustomStringConvertible, Sendable { /// Main button, usually the left button. public static let main = MouseButtons(rawValue: 1 << 0) diff --git a/Sources/Navigator/Navigator.swift b/Sources/Navigator/Navigator.swift index 004154c87c..22ab39dca1 100644 --- a/Sources/Navigator/Navigator.swift +++ b/Sources/Navigator/Navigator.swift @@ -49,7 +49,7 @@ public protocol Navigator: AnyObject { func goBackward(options: NavigatorGoOptions) async -> Bool } -public struct NavigatorGoOptions: Hashable { +public struct NavigatorGoOptions: Hashable, Sendable { /// Indicates whether the move should be animated when possible. public var animated: Bool = false @@ -142,7 +142,7 @@ public extension NavigatorDelegate { func navigator(_ navigator: Navigator, didFailToLoadResourceAt href: RelativeURL, withError error: ReadError) {} } -public enum NavigatorError: Error { +public enum NavigatorError: Error, Sendable { /// The user tried to copy the text selection but the DRM License doesn't allow it. case copyForbidden } diff --git a/Sources/Navigator/PDF/PDFNavigatorViewController.swift b/Sources/Navigator/PDF/PDFNavigatorViewController.swift index 16087945ad..970faa0504 100644 --- a/Sources/Navigator/PDF/PDFNavigatorViewController.swift +++ b/Sources/Navigator/PDF/PDFNavigatorViewController.swift @@ -28,7 +28,7 @@ open class PDFNavigatorViewController: VisualNavigator, ViewportObservingNavigator, SelectableNavigator, Configurable, Loggable { - public struct Configuration { + public struct Configuration: Sendable { /// Initial set of setting preferences. public var preferences: PDFPreferences diff --git a/Sources/Navigator/PDF/Preferences/PDFPreferences.swift b/Sources/Navigator/PDF/Preferences/PDFPreferences.swift index 723565f582..fff37b957a 100644 --- a/Sources/Navigator/PDF/Preferences/PDFPreferences.swift +++ b/Sources/Navigator/PDF/Preferences/PDFPreferences.swift @@ -8,7 +8,7 @@ import Foundation import ReadiumShared /// Preferences for the `PDFNavigatorViewController`. -public struct PDFPreferences: ConfigurablePreferences { +public struct PDFPreferences: ConfigurablePreferences, Sendable { public static let empty: PDFPreferences = .init() /// Background color behind the document pages. diff --git a/Sources/Navigator/PDF/Preferences/PDFSettings.swift b/Sources/Navigator/PDF/Preferences/PDFSettings.swift index 936daee8ab..1e328380c9 100644 --- a/Sources/Navigator/PDF/Preferences/PDFSettings.swift +++ b/Sources/Navigator/PDF/Preferences/PDFSettings.swift @@ -11,7 +11,7 @@ import ReadiumShared /// Setting values of the `PDFNavigatorViewController`. /// /// See `PDFPreferences` -public struct PDFSettings: ConfigurableSettings { +public struct PDFSettings: ConfigurableSettings, Sendable { public let backgroundColor: Color? public let fit: Fit public let offsetFirstPage: Bool @@ -67,7 +67,7 @@ public struct PDFSettings: ConfigurableSettings { /// takes precedence. /// /// See `PDFPreferences`. -public struct PDFDefaults { +public struct PDFDefaults: Sendable { public var backgroundColor: Color? public var fit: Fit? public var offsetFirstPage: Bool? diff --git a/Sources/Navigator/Preferences/Configurable.swift b/Sources/Navigator/Preferences/Configurable.swift index 7eee624d9c..afc6cd377c 100644 --- a/Sources/Navigator/Preferences/Configurable.swift +++ b/Sources/Navigator/Preferences/Configurable.swift @@ -50,7 +50,7 @@ public extension Configurable { } /// A type-erasing `Configurable` object. -public class AnyConfigurable< +public final class AnyConfigurable< Settings: ConfigurableSettings, Preferences: ConfigurablePreferences, Editor: PreferencesEditor diff --git a/Sources/Navigator/Preferences/MappedPreference.swift b/Sources/Navigator/Preferences/MappedPreference.swift index c66514db3a..1046b6148c 100644 --- a/Sources/Navigator/Preferences/MappedPreference.swift +++ b/Sources/Navigator/Preferences/MappedPreference.swift @@ -166,7 +166,7 @@ public class MappedPreference: Preference { } } -public class PreferenceWithSupportedValues: MappedPreference, EnumPreference { +public final class PreferenceWithSupportedValues: MappedPreference, EnumPreference { public let supportedValues: [Value] init(original: AnyPreference, supportedValues: [Value]) { @@ -175,7 +175,7 @@ public class PreferenceWithSupportedValues: MappedPreference: +public final class MappedEnumPreference: MappedPreference, EnumPreference { let originalEnum: AnyEnumPreference @@ -203,7 +203,7 @@ public class MappedEnumPreference: } } -public class MappedRangePreference: +public final class MappedRangePreference: MappedPreference, RangePreference { let originalRange: AnyRangePreference diff --git a/Sources/Navigator/Preferences/Preference.swift b/Sources/Navigator/Preferences/Preference.swift index 85f11f3637..bb8ab926d2 100644 --- a/Sources/Navigator/Preferences/Preference.swift +++ b/Sources/Navigator/Preferences/Preference.swift @@ -119,7 +119,7 @@ public extension EnumPreference { } /// A type-erasing `EnumPreference` object. -public class AnyEnumPreference: AnyPreference, EnumPreference { +public final class AnyEnumPreference: AnyPreference, EnumPreference { public var supportedValues: [Value] { _supportedValues() } @@ -140,7 +140,7 @@ public extension RangePreference { } /// A type-erasing `Preference` object. -public class AnyRangePreference: AnyPreference, RangePreference { +public final class AnyRangePreference: AnyPreference, RangePreference { public var supportedRange: ClosedRange { _supportedRange() } diff --git a/Sources/Navigator/Preferences/ProgressionStrategy.swift b/Sources/Navigator/Preferences/ProgressionStrategy.swift index 81b7f860c8..3eee3e6132 100644 --- a/Sources/Navigator/Preferences/ProgressionStrategy.swift +++ b/Sources/Navigator/Preferences/ProgressionStrategy.swift @@ -7,7 +7,7 @@ import Foundation /// A strategy to increment or decrement a setting. -public protocol ProgressionStrategy { +public protocol ProgressionStrategy: Sendable { associatedtype Value func increment(_ value: Value) -> Value @@ -16,7 +16,7 @@ public protocol ProgressionStrategy { /// Progression strategy based on a list of preferred values for the setting. /// Steps MUST be sorted in increasing order. -public class StepsProgressionStrategy: ProgressionStrategy { +public final class StepsProgressionStrategy: ProgressionStrategy, Sendable { private let steps: [Value] public init(steps: [Value]) { @@ -37,7 +37,7 @@ public class StepsProgressionStrategy: ProgressionStrategy { } /// Simple progression strategy which increments or decrements the setting by a fixed number. -public class IncrementProgressionStrategy: ProgressionStrategy { +public final class IncrementProgressionStrategy: ProgressionStrategy, Sendable { private let increment: Value public init(increment: Value) { @@ -53,13 +53,13 @@ public class IncrementProgressionStrategy: ProgressionStrategy { } } -public class AnyProgressionStrategy: ProgressionStrategy { - private let _increment: (Value) -> Value - private let _decrement: (Value) -> Value +public final class AnyProgressionStrategy: ProgressionStrategy, Sendable { + private let _increment: @Sendable (Value) -> Value + private let _decrement: @Sendable (Value) -> Value public init(_ strategy: S) where S.Value == Value { - _increment = strategy.increment - _decrement = strategy.decrement + _increment = { strategy.increment($0) } + _decrement = { strategy.decrement($0) } } public func increment(_ value: Value) -> Value { @@ -71,19 +71,19 @@ public class AnyProgressionStrategy: ProgressionStrategy { } } -public extension ProgressionStrategy { +public extension ProgressionStrategy where Value: Sendable { func eraseToAnyProgressionStrategy() -> AnyProgressionStrategy { AnyProgressionStrategy(self) } } -public extension AnyProgressionStrategy where Value: Numeric { +public extension AnyProgressionStrategy where Value: Numeric & Sendable { static func increment(_ increment: Value) -> AnyProgressionStrategy { IncrementProgressionStrategy(increment: increment).eraseToAnyProgressionStrategy() } } -public extension AnyProgressionStrategy where Value: Comparable { +public extension AnyProgressionStrategy where Value: Comparable & Sendable { static func steps(_ steps: Value...) -> AnyProgressionStrategy { StepsProgressionStrategy(steps: steps).eraseToAnyProgressionStrategy() } diff --git a/Sources/Navigator/Preferences/ProxyPreference.swift b/Sources/Navigator/Preferences/ProxyPreference.swift index 999457a186..cba59436f7 100644 --- a/Sources/Navigator/Preferences/ProxyPreference.swift +++ b/Sources/Navigator/Preferences/ProxyPreference.swift @@ -41,7 +41,7 @@ public class ProxyPreference: Preference { } } -public class ProxyEnumPreference: ProxyPreference, EnumPreference { +public final class ProxyEnumPreference: ProxyPreference, EnumPreference { public let supportedValues: [Value] init( @@ -61,7 +61,7 @@ public class ProxyEnumPreference: ProxyPreference, EnumP } } -public class ProxyRangePreference: ProxyPreference, RangePreference { +public final class ProxyRangePreference: ProxyPreference, RangePreference { public var supportedRange: ClosedRange private let progressionStrategy: AnyProgressionStrategy private let valueFormatter: (Value) -> String diff --git a/Sources/Navigator/Preferences/Types.swift b/Sources/Navigator/Preferences/Types.swift index 7672ad620a..a91d145cdf 100644 --- a/Sources/Navigator/Preferences/Types.swift +++ b/Sources/Navigator/Preferences/Types.swift @@ -9,13 +9,13 @@ import ReadiumShared import UIKit /// Layout axis. -public enum Axis: String, Codable, Hashable { +public enum Axis: String, Codable, Hashable, Sendable { case horizontal case vertical } /// Synthetic spread policy. -public enum Spread: String, Codable, Hashable { +public enum Spread: String, Codable, Hashable, Sendable { /// The publication should be displayed in a spread if the screen is large /// enough. case auto @@ -26,7 +26,7 @@ public enum Spread: String, Codable, Hashable { } /// Direction of the reading progression across resources. -public enum ReadingProgression: String, Codable, Hashable { +public enum ReadingProgression: String, Codable, Hashable, Sendable { case ltr case rtl @@ -49,7 +49,7 @@ extension ReadiumShared.ReadingProgression { } /// Method for fitting the content within the viewport. -public enum Fit: String, Codable, Hashable { +public enum Fit: String, Codable, Hashable, Sendable { /// Use the best fitting strategy depending on the current settings and /// content. case auto @@ -60,7 +60,7 @@ public enum Fit: String, Codable, Hashable { } /// Reader theme for reflowable documents. -public enum Theme: String, Codable, Hashable { +public enum Theme: String, Codable, Hashable, Sendable { case light case dark case sepia @@ -93,20 +93,20 @@ public enum Theme: String, Codable, Hashable { } /// Number of columns displayed in a reflowable document. -public enum ColumnCount: String, Codable, Hashable { +public enum ColumnCount: String, Codable, Hashable, Sendable { case auto case one = "1" case two = "2" } /// Filter used to render images in a reflowable document. -public enum ImageFilter: String, Codable, Hashable { +public enum ImageFilter: String, Codable, Hashable, Sendable { case darken case invert } /// Text alignment in a reflowable document. -public enum TextAlignment: String, Codable, Hashable { +public enum TextAlignment: String, Codable, Hashable, Sendable { /// Align the text in the center of the page. case center /// Stretch lines of text that end with a soft line break to fill the width @@ -123,7 +123,7 @@ public enum TextAlignment: String, Codable, Hashable { } /// Represents a color stored as a packed int. -public struct Color: RawRepresentable, Codable, Hashable { +public struct Color: RawRepresentable, Codable, Hashable, Sendable { /// Packed int representation. public var rawValue: Int @@ -190,7 +190,7 @@ public struct Color: RawRepresentable, Codable, Hashable { /// /// For a list of vetted font families, see /// https://readium.org/readium-css/docs/CSS10-libre_fonts. -public struct FontFamily: RawRepresentable, ExpressibleByStringLiteral, Codable, Hashable { +public struct FontFamily: RawRepresentable, ExpressibleByStringLiteral, Codable, Hashable, Sendable { // Generic font families // See https://www.w3.org/TR/css-fonts-4/#generic-font-families diff --git a/Sources/Navigator/SelectableNavigator.swift b/Sources/Navigator/SelectableNavigator.swift index c53f18396c..e3ee7129c4 100644 --- a/Sources/Navigator/SelectableNavigator.swift +++ b/Sources/Navigator/SelectableNavigator.swift @@ -20,7 +20,7 @@ public protocol SelectableNavigator: Navigator { /// Represents a user content selection in a navigator. /// /// In the case of a text selection, you can get its content using `locator.text.highlight`. -public struct Selection { +public struct Selection: Sendable { /// Location of the user selection in the `Publication`. public let locator: Locator diff --git a/Sources/Navigator/TTS/AVTTSEngine.swift b/Sources/Navigator/TTS/AVTTSEngine.swift index 22cdb90638..ce9e34f0a1 100644 --- a/Sources/Navigator/TTS/AVTTSEngine.swift +++ b/Sources/Navigator/TTS/AVTTSEngine.swift @@ -8,14 +8,14 @@ import AVFoundation import Foundation import ReadiumShared -public protocol AVTTSEngineDelegate: AnyObject { +public protocol AVTTSEngineDelegate: AnyObject, Sendable { /// Called when the engine created a new utterance to be played. /// You can customize additional properties of the utterance. func avTTSEngine(_ engine: AVTTSEngine, didCreateUtterance utterance: AVSpeechUtterance) } /// Implementation of a `TTSEngine` using Apple AVFoundation's `AVSpeechSynthesizer`. -public class AVTTSEngine: NSObject, TTSEngine, AVSpeechSynthesizerDelegate, Loggable { +public final class AVTTSEngine: NSObject, TTSEngine, AVSpeechSynthesizerDelegate, Loggable { /// Range of valid values for an AVUtterance rate. /// /// > The speech rate is a decimal representation within the range of `AVSpeechUtteranceMinimumSpeechRate` and diff --git a/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift b/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift index eff418ff89..b9426e7997 100644 --- a/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift +++ b/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift @@ -20,7 +20,7 @@ public protocol PublicationSpeechSynthesizerDelegate: AnyObject { /// `PublicationSpeechSynthesizer` orchestrates the rendition of a `Publication` by iterating through its content, /// splitting it into individual utterances using a `ContentTokenizer`, then using a `TTSEngine` to read them aloud. -public class PublicationSpeechSynthesizer: Loggable { +public final class PublicationSpeechSynthesizer: Loggable { public typealias EngineFactory = () -> TTSEngine public typealias TokenizerFactory = (_ defaultLanguage: Language?) -> ContentTokenizer @@ -29,13 +29,13 @@ public class PublicationSpeechSynthesizer: Loggable { publication.content() != nil } - public enum Error: Swift.Error { + public enum Error: Swift.Error, Sendable { /// Underlying `TTSEngine` error. case engine(TTSError) } /// User configuration for the text-to-speech engine. - public struct Configuration: Equatable { + public struct Configuration: Equatable, Sendable { /// Language overriding the publication one. public var defaultLanguage: Language? @@ -53,7 +53,7 @@ public class PublicationSpeechSynthesizer: Loggable { /// An utterance is an arbitrary text (e.g. sentence) extracted from the publication, that can be synthesized by /// the TTS engine. - public struct Utterance: Equatable { + public struct Utterance: Equatable, Sendable { /// Text to be spoken. public let text: String /// Locator to the utterance in the publication. @@ -63,7 +63,7 @@ public class PublicationSpeechSynthesizer: Loggable { } /// Represents a state of the `PublicationSpeechSynthesizer`. - public enum State: Equatable { + public enum State: Equatable, Sendable { /// The synthesizer is completely stopped and must be (re)started from a given locator. case stopped diff --git a/Sources/Navigator/TTS/TTSEngine.swift b/Sources/Navigator/TTS/TTSEngine.swift index 66ea8e5eab..857476a19e 100644 --- a/Sources/Navigator/TTS/TTSEngine.swift +++ b/Sources/Navigator/TTS/TTSEngine.swift @@ -33,16 +33,16 @@ public extension TTSEngine { } } -public enum TTSError: Error { +public enum TTSError: Error, Sendable { /// Tried to synthesize an utterance with an unsupported language. - case languageNotSupported(language: Language, cause: Error?) + case languageNotSupported(language: Language, cause: (any Error)?) /// Other engine-specific errors. - case other(Error) + case other(any Error) } /// An utterance is an arbitrary text (e.g. sentence) that can be synthesized by the TTS engine. -public struct TTSUtterance { +public struct TTSUtterance: Sendable { /// Text to be spoken. public let text: String diff --git a/Sources/Navigator/TTS/TTSVoice.swift b/Sources/Navigator/TTS/TTSVoice.swift index d482ef0f4e..a2f36a7034 100644 --- a/Sources/Navigator/TTS/TTSVoice.swift +++ b/Sources/Navigator/TTS/TTSVoice.swift @@ -9,12 +9,12 @@ import Foundation import ReadiumShared /// Represents a voice provided by the TTS engine which can speak an utterance. -public struct TTSVoice: Hashable { - public enum Gender: Hashable { +public struct TTSVoice: Hashable, Sendable { + public enum Gender: Hashable, Sendable { case female, male, unspecified } - public enum Quality: Hashable { + public enum Quality: Hashable, Sendable { case lower, low, medium, high, higher } diff --git a/Sources/Navigator/Viewport/ViewportObservingNavigator.swift b/Sources/Navigator/Viewport/ViewportObservingNavigator.swift index 757c25b2b5..020b8d3fd3 100644 --- a/Sources/Navigator/Viewport/ViewportObservingNavigator.swift +++ b/Sources/Navigator/Viewport/ViewportObservingNavigator.swift @@ -26,7 +26,7 @@ public extension ViewportObservingNavigatorDelegate { } /// Information about the visible portion of a publication. -public struct NavigatorViewport: Equatable { +public struct NavigatorViewport: Equatable, Sendable { /// Visible reading order resources, in reading order. public var resources: [Resource] @@ -51,7 +51,7 @@ public struct NavigatorViewport: Equatable { } /// A visible reading order resource inside the viewport. - public struct Resource: Equatable { + public struct Resource: Equatable, Sendable { /// HREF of the reading order resource. public var href: AnyURL diff --git a/Sources/Navigator/VisualNavigator.swift b/Sources/Navigator/VisualNavigator.swift index c9bf1ccca5..c66b960ad8 100644 --- a/Sources/Navigator/VisualNavigator.swift +++ b/Sources/Navigator/VisualNavigator.swift @@ -65,7 +65,7 @@ public extension VisualNavigator { } } -public struct VisualNavigatorPresentation { +public struct VisualNavigatorPresentation: Sendable { /// Horizontal direction of progression across resources. public let readingProgression: ReadingProgression diff --git a/Sources/OPDS/OPDS1Parser.swift b/Sources/OPDS/OPDS1Parser.swift index 020f54d3c0..d6fc08ce0e 100644 --- a/Sources/OPDS/OPDS1Parser.swift +++ b/Sources/OPDS/OPDS1Parser.swift @@ -94,7 +94,7 @@ public class OPDS1Parser: Loggable { guard let title = root.firstChild(tag: "title")?.stringValue else { throw OPDS1ParserError.missingTitle } - let feed = Feed(title: title) + var feed = Feed(title: title) feed.metadata.identifier = root.firstChild(tag: "id")?.stringValue @@ -145,7 +145,7 @@ public class OPDS1Parser: Loggable { if let publication = parseEntry(entry: entry, feedURL: feedURL) { // Checking if this publication need to go into a group or in publications. if let collectionLink = collectionLink { - addPublicationInGroup(feed, publication, collectionLink) + addPublicationInGroup(&feed, publication, collectionLink) } else { feed.publications.append(publication) } @@ -170,7 +170,7 @@ public class OPDS1Parser: Loggable { // Check collection link if let collectionLink = collectionLink { - addNavigationInGroup(feed, newLink, collectionLink) + addNavigationInGroup(&feed, newLink, collectionLink) } else { feed.navigation.append(newLink) } @@ -210,7 +210,7 @@ public class OPDS1Parser: Loggable { if isFacet { if let facetGroupName = link.attributes["facetGroup"] { - addFacet(feed: feed, to: newLink, named: facetGroupName) + addFacet(feed: &feed, to: newLink, named: facetGroupName) } } else { feed.links.append(newLink) @@ -405,33 +405,31 @@ public class OPDS1Parser: Loggable { ) } - static func addFacet(feed: Feed, to link: Link, named title: String) { - for facet in feed.facets { - if facet.metadata.title == title { - facet.links.append(link) - return - } + static func addFacet(feed: inout Feed, to link: Link, named title: String) { + if let index = feed.facets.firstIndex(where: { $0.metadata.title == title }) { + feed.facets[index].links.append(link) + return } - let newFacet = Facet(title: title) + var newFacet = Facet(title: title) newFacet.links.append(link) feed.facets.append(newFacet) } - static func addPublicationInGroup(_ feed: Feed, + static func addPublicationInGroup(_ feed: inout Feed, _ publication: Publication, _ collectionLink: Link) { - for group in feed.groups { + for (i, group) in feed.groups.enumerated() { for l in group.links { if l.href == collectionLink.href { - group.publications.append(publication) + feed.groups[i].publications.append(publication) return } } } if let title = collectionLink.title { - let newGroup = Group(title: title) + var newGroup = Group(title: title) let selfLink = Link( href: collectionLink.href, title: collectionLink.title, @@ -443,20 +441,20 @@ public class OPDS1Parser: Loggable { } } - static func addNavigationInGroup(_ feed: Feed, + static func addNavigationInGroup(_ feed: inout Feed, _ link: Link, _ collectionLink: Link) { - for group in feed.groups { + for (i, group) in feed.groups.enumerated() { for l in group.links { if l.href == collectionLink.href { - group.navigation.append(link) + feed.groups[i].navigation.append(link) return } } } if let title = collectionLink.title { - let newGroup = Group(title: title) + var newGroup = Group(title: title) let selfLink = Link( href: collectionLink.href, title: collectionLink.title, diff --git a/Sources/OPDS/OPDS2Parser.swift b/Sources/OPDS/OPDS2Parser.swift index bd7189e43f..edd9df8670 100644 --- a/Sources/OPDS/OPDS2Parser.swift +++ b/Sources/OPDS/OPDS2Parser.swift @@ -91,8 +91,8 @@ public class OPDS2Parser: Loggable { throw OPDS2ParserError.missingTitle } - let feed = Feed(title: title) - parseMetadata(opdsMetadata: feed.metadata, metadataDict: metadataDict) + var feed = Feed(title: title) + parseMetadata(opdsMetadata: &feed.metadata, metadataDict: metadataDict) for (k, v) in jsonDict { switch k { @@ -108,27 +108,27 @@ public class OPDS2Parser: Loggable { guard let links = v.array else { throw OPDS2ParserError.invalidLink } - try parseLinks(feed: feed, feedURL: feedURL, links: links) + try parseLinks(feed: &feed, feedURL: feedURL, links: links) case "facets": guard let facets = v.array else { throw OPDS2ParserError.invalidFacet } - try parseFacets(feed: feed, feedURL: feedURL, facets: facets) + try parseFacets(feed: &feed, feedURL: feedURL, facets: facets) case "publications": guard let publications = v.array else { throw OPDS2ParserError.invalidPublication } - try parsePublications(feed: feed, feedURL: feedURL, publications: publications) + try parsePublications(feed: &feed, feedURL: feedURL, publications: publications) case "navigation": guard let navLinks = v.array else { throw OPDS2ParserError.invalidNavigation } - try parseNavigation(feed: feed, feedURL: feedURL, navLinks: navLinks) + try parseNavigation(feed: &feed, feedURL: feedURL, navLinks: navLinks) case "groups": guard let groups = v.array else { throw OPDS2ParserError.invalidGroup } - try parseGroups(feed: feed, feedURL: feedURL, groups: groups) + try parseGroups(feed: &feed, feedURL: feedURL, groups: groups) default: continue } @@ -137,7 +137,7 @@ public class OPDS2Parser: Loggable { return feed } - static func parseMetadata(opdsMetadata: OpdsMetadata, metadataDict: [String: JSONValue]) { + static func parseMetadata(opdsMetadata: inout OpdsMetadata, metadataDict: [String: JSONValue]) { for (k, v) in metadataDict { switch k { case "title": @@ -162,7 +162,7 @@ public class OPDS2Parser: Loggable { } } - static func parseFacets(feed: Feed, feedURL: URL, facets: [JSONValue]) throws { + static func parseFacets(feed: inout Feed, feedURL: URL, facets: [JSONValue]) throws { for facetValue in facets { guard let facetDict = facetValue.object else { continue } guard let metadata = facetDict["metadata"]?.object else { @@ -172,8 +172,8 @@ public class OPDS2Parser: Loggable { throw OPDS2ParserError.invalidFacet } - let facet = Facet(title: title) - parseMetadata(opdsMetadata: facet.metadata, metadataDict: metadata) + var facet = Facet(title: title) + parseMetadata(opdsMetadata: &facet.metadata, metadataDict: metadata) for (k, v) in facetDict { if k == "links" { @@ -192,7 +192,7 @@ public class OPDS2Parser: Loggable { } } - static func parseLinks(feed: Feed, feedURL: URL, links: [JSONValue]) throws { + static func parseLinks(feed: inout Feed, feedURL: URL, links: [JSONValue]) throws { for linkValue in links { if var link = try Link(json: linkValue) { try link.normalizeHREFs(to: feedURL) @@ -201,14 +201,14 @@ public class OPDS2Parser: Loggable { } } - static func parsePublications(feed: Feed, feedURL: URL, publications: [JSONValue]) throws { + static func parsePublications(feed: inout Feed, feedURL: URL, publications: [JSONValue]) throws { for pubValue in publications { let pub = try Publication(json: pubValue) feed.publications.append(pub) } } - static func parseNavigation(feed: Feed, feedURL: URL, navLinks: [JSONValue]) throws { + static func parseNavigation(feed: inout Feed, feedURL: URL, navLinks: [JSONValue]) throws { for navValue in navLinks { if var link = try Link(json: navValue) { try link.normalizeHREFs(to: feedURL) @@ -217,7 +217,7 @@ public class OPDS2Parser: Loggable { } } - static func parseGroups(feed: Feed, feedURL: URL, groups: [JSONValue]) throws { + static func parseGroups(feed: inout Feed, feedURL: URL, groups: [JSONValue]) throws { for groupValue in groups { guard let groupDict = groupValue.object else { continue } guard let metadata = groupDict["metadata"]?.object else { @@ -227,8 +227,8 @@ public class OPDS2Parser: Loggable { throw OPDS2ParserError.invalidGroup } - let group = Group(title: title) - parseMetadata(opdsMetadata: group.metadata, metadataDict: metadata) + var group = Group(title: title) + parseMetadata(opdsMetadata: &group.metadata, metadataDict: metadata) for (k, v) in groupDict { switch k { diff --git a/Sources/OPDS/OPDSParser.swift b/Sources/OPDS/OPDSParser.swift index 80031b9397..eb5142e789 100644 --- a/Sources/OPDS/OPDSParser.swift +++ b/Sources/OPDS/OPDSParser.swift @@ -7,12 +7,12 @@ import Foundation import ReadiumShared -public enum OPDSParserError: Error { +public enum OPDSParserError: Error, Sendable { case documentNotFound case documentNotValid } -public enum OPDSParser { +public enum OPDSParser: Sendable { static var feedURL: URL? /// Parse an OPDS feed or publication. diff --git a/Sources/OPDS/ParseData.swift b/Sources/OPDS/ParseData.swift index 116c57ef0d..bb8cb7223a 100644 --- a/Sources/OPDS/ParseData.swift +++ b/Sources/OPDS/ParseData.swift @@ -8,7 +8,7 @@ import Foundation import ReadiumShared /// List of OPDS versions compliant with the parser. -public enum Version { +public enum Version: Sendable { /// OPDS 1.x must be an XML ressource case OPDS1 /// OPDS 2.x must be a JSON ressource diff --git a/Sources/Shared/Logger/Loggable.swift b/Sources/Shared/Logger/Loggable.swift index b15a9e7e19..1f8c1f16a4 100644 --- a/Sources/Shared/Logger/Loggable.swift +++ b/Sources/Shared/Logger/Loggable.swift @@ -7,7 +7,7 @@ import Foundation /// The different levels of log-severity available for logging. -public enum SeverityLevel: String { +public enum SeverityLevel: String, Sendable { case trace case debug case info diff --git a/Sources/Shared/Logger/LoggerStub.swift b/Sources/Shared/Logger/LoggerStub.swift index c4f55e5554..78798299a2 100644 --- a/Sources/Shared/Logger/LoggerStub.swift +++ b/Sources/Shared/Logger/LoggerStub.swift @@ -8,7 +8,7 @@ import Foundation /// A Logger implementation of the Loggable protocol. /// Used as default -public class LoggerStub: LoggerType { +public final class LoggerStub: LoggerType, Sendable { public init() {} /// Log `message` with a severity of `level`. diff --git a/Sources/Shared/OPDS/Facet.swift b/Sources/Shared/OPDS/Facet.swift index 8eb52e1c28..128a0e1a92 100644 --- a/Sources/Shared/OPDS/Facet.swift +++ b/Sources/Shared/OPDS/Facet.swift @@ -5,7 +5,7 @@ // /// Enables faceted navigation in OPDS. -public class Facet { +public struct Facet: Sendable { public var metadata: OpdsMetadata public var links = [Link]() diff --git a/Sources/Shared/OPDS/Feed.swift b/Sources/Shared/OPDS/Feed.swift index 1f2f0a97cb..8bde94d08a 100644 --- a/Sources/Shared/OPDS/Feed.swift +++ b/Sources/Shared/OPDS/Feed.swift @@ -5,7 +5,7 @@ // /// Main structure of an OPDS catalog. -public class Feed { +public struct Feed { public var metadata: OpdsMetadata public var links = [Link]() public var facets = [Facet]() diff --git a/Sources/Shared/OPDS/Group.swift b/Sources/Shared/OPDS/Group.swift index 992abf7bd1..dbc0ae96a6 100644 --- a/Sources/Shared/OPDS/Group.swift +++ b/Sources/Shared/OPDS/Group.swift @@ -5,7 +5,7 @@ // /// A substructure of a feed. -public class Group { +public struct Group { public var metadata: OpdsMetadata public var links = [Link]() public var publications = [Publication]() diff --git a/Sources/Shared/OPDS/OPDSAcquisition.swift b/Sources/Shared/OPDS/OPDSAcquisition.swift index acfead4730..cc8b074a42 100644 --- a/Sources/Shared/OPDS/OPDSAcquisition.swift +++ b/Sources/Shared/OPDS/OPDSAcquisition.swift @@ -9,7 +9,7 @@ import ReadiumInternal /// OPDS Acquisition Object /// https://specs.opds.io/schema/acquisition-object.schema.json -public struct OPDSAcquisition: Equatable, JSONObjectEncodable, JSONValueDecodable { +public struct OPDSAcquisition: Equatable, JSONObjectEncodable, JSONValueDecodable, Sendable { public var type: String public var children: [OPDSAcquisition] = [] diff --git a/Sources/Shared/OPDS/OPDSAvailability.swift b/Sources/Shared/OPDS/OPDSAvailability.swift index 6136f8dda9..40580d6eaa 100644 --- a/Sources/Shared/OPDS/OPDSAvailability.swift +++ b/Sources/Shared/OPDS/OPDSAvailability.swift @@ -9,7 +9,7 @@ import ReadiumInternal /// Indicated the availability of a given resource. /// https://specs.opds.io/schema/properties.schema.json -public struct OPDSAvailability: Equatable, JSONValueDecodable, JSONObjectEncodable { +public struct OPDSAvailability: Equatable, JSONValueDecodable, JSONObjectEncodable, Sendable { public let state: State /// Timestamp for the previous state change. @@ -50,7 +50,7 @@ public struct OPDSAvailability: Equatable, JSONValueDecodable, JSONObjectEncodab ]) } - public enum State: String { + public enum State: String, Sendable { case available, unavailable, reserved, ready } } diff --git a/Sources/Shared/OPDS/OPDSCopies.swift b/Sources/Shared/OPDS/OPDSCopies.swift index c325a821c5..92b0afb9b2 100644 --- a/Sources/Shared/OPDS/OPDSCopies.swift +++ b/Sources/Shared/OPDS/OPDSCopies.swift @@ -9,7 +9,7 @@ import ReadiumInternal /// Library-specific feature that contains information about the copies that a library has acquired. /// https://specs.opds.io/schema/properties.schema.json -public struct OPDSCopies: Equatable, JSONValueDecodable, JSONObjectEncodable { +public struct OPDSCopies: Equatable, JSONValueDecodable, JSONObjectEncodable, Sendable { public let total: Int? public let available: Int? diff --git a/Sources/Shared/OPDS/OPDSHolds.swift b/Sources/Shared/OPDS/OPDSHolds.swift index 5d1ea32cda..7769ddee4d 100644 --- a/Sources/Shared/OPDS/OPDSHolds.swift +++ b/Sources/Shared/OPDS/OPDSHolds.swift @@ -9,7 +9,7 @@ import ReadiumInternal /// Library-specific features when a specific book is unavailable but provides a hold list. /// https://specs.opds.io/schema/properties.schema.json -public struct OPDSHolds: Equatable, JSONValueDecodable, JSONObjectEncodable { +public struct OPDSHolds: Equatable, JSONValueDecodable, JSONObjectEncodable, Sendable { public let total: Int? public let position: Int? diff --git a/Sources/Shared/OPDS/OPDSPrice.swift b/Sources/Shared/OPDS/OPDSPrice.swift index 38bbcd0f76..3937dbb4c6 100644 --- a/Sources/Shared/OPDS/OPDSPrice.swift +++ b/Sources/Shared/OPDS/OPDSPrice.swift @@ -9,7 +9,7 @@ import ReadiumInternal /// The price of a publication in an OPDS link. /// https://specs.opds.io/schema/properties.schema.json -public struct OPDSPrice: Equatable, JSONValueDecodable, JSONObjectEncodable { +public struct OPDSPrice: Equatable, JSONValueDecodable, JSONObjectEncodable, Sendable { public var currency: String // eg. EUR /// Should only be used for display purposes, because of precision issues inherent with Double and the JSON parsing. diff --git a/Sources/Shared/OPDS/OpdsMetadata.swift b/Sources/Shared/OPDS/OpdsMetadata.swift index 26b9d0aa62..e5f44f5bc1 100644 --- a/Sources/Shared/OPDS/OpdsMetadata.swift +++ b/Sources/Shared/OPDS/OpdsMetadata.swift @@ -7,7 +7,7 @@ import Foundation /// OPDS metadata properties. -public class OpdsMetadata { +public struct OpdsMetadata: Sendable { public var title: String public var identifier: String? public var numberOfItem: Int? diff --git a/Sources/Shared/Publication/Accessibility/AccessibilityMetadataDisplayGuide.swift b/Sources/Shared/Publication/Accessibility/AccessibilityMetadataDisplayGuide.swift index 44708469be..2cde09253a 100644 --- a/Sources/Shared/Publication/Accessibility/AccessibilityMetadataDisplayGuide.swift +++ b/Sources/Shared/Publication/Accessibility/AccessibilityMetadataDisplayGuide.swift @@ -88,7 +88,7 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { /// access. /// /// https://w3c.github.io/publ-a11y/a11y-meta-display-guide/2.0/guidelines/#ways-of-reading - public struct WaysOfReading: AccessibilityDisplayField { + public struct WaysOfReading: AccessibilityDisplayField, Sendable { /// Indicates if users can modify the appearance of the text and the /// page layout according to the possibilities offered by the reading /// system. @@ -266,7 +266,7 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { /// Identifies the navigation features included in the publication. /// /// https://w3c.github.io/publ-a11y/a11y-meta-display-guide/2.0/guidelines/#navigation - public struct Navigation: AccessibilityDisplayField { + public struct Navigation: AccessibilityDisplayField, Sendable { /// Indicates whether no information about navigation features is /// available. public var noMetadata: Bool { @@ -348,7 +348,7 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { /// for prerecorded audio are available. /// /// https://w3c.github.io/publ-a11y/a11y-meta-display-guide/2.0/guidelines/#rich-content - public struct RichContent: AccessibilityDisplayField { + public struct RichContent: AccessibilityDisplayField, Sendable { /// Indicates whether no information about rich content is available. public var noMetadata: Bool { !extendedAltTextDescriptions && !mathFormula && !mathFormulaAsMathML && @@ -469,7 +469,7 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { /// better understand the accessibility characteristics of digital /// publications. These are for metadata that do not fit into the other /// categories or are rarely used in trade publishing. - public struct AdditionalInformation: AccessibilityDisplayField { + public struct AdditionalInformation: AccessibilityDisplayField, Sendable { /// No information is available. public var noMetadata: Bool { !pageBreakMarkers && !aria && !audioDescriptions && !braille && @@ -629,7 +629,7 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { /// when content is potentially dangerous to them. /// /// https://w3c.github.io/publ-a11y/a11y-meta-display-guide/2.0/guidelines/#hazards - public struct Hazards: AccessibilityDisplayField { + public struct Hazards: AccessibilityDisplayField, Sendable { public enum Hazard: Sendable { case yes case no @@ -780,7 +780,7 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { /// internationally recognized conformance standards for accessibility. /// /// https://w3c.github.io/publ-a11y/a11y-meta-display-guide/2.0/guidelines/#conformance-group - public struct Conformance: AccessibilityDisplayField { + public struct Conformance: AccessibilityDisplayField, Sendable { /// Accessibility conformance profiles. public var profiles: [Accessibility.Profile] @@ -838,7 +838,7 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { /// by legal counsel for each jurisdiction. /// /// https://w3c.github.io/publ-a11y/a11y-meta-display-guide/2.0/guidelines/#legal-considerations - public struct Legal: AccessibilityDisplayField { + public struct Legal: AccessibilityDisplayField, Sendable { /// No information is available. public var noMetadata: Bool { !exemption @@ -888,7 +888,7 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { /// duplicate, the other discoverability metadata. /// /// https://w3c.github.io/publ-a11y/a11y-meta-display-guide/2.0/guidelines/#accessibility-summary - public struct AccessibilitySummary: AccessibilityDisplayField { + public struct AccessibilitySummary: AccessibilityDisplayField, Sendable { public var summary: String? public let id: AccessibilityDisplayString = .accessibilitySummaryTitle diff --git a/Sources/Shared/Publication/Extensions/EPUB/EPUBLayout.swift b/Sources/Shared/Publication/Extensions/EPUB/EPUBLayout.swift index 3b9a8b9940..5091719938 100644 --- a/Sources/Shared/Publication/Extensions/EPUB/EPUBLayout.swift +++ b/Sources/Shared/Publication/Extensions/EPUB/EPUBLayout.swift @@ -7,6 +7,6 @@ import Foundation /// Hint about the nature of the layout for the linked resources. -public enum EPUBLayout: String { +public enum EPUBLayout: String, Sendable { case fixed, reflowable } diff --git a/Sources/Shared/Publication/Extensions/Encryption/Encryption.swift b/Sources/Shared/Publication/Extensions/Encryption/Encryption.swift index eb6e1ccec5..10ff4c75f6 100644 --- a/Sources/Shared/Publication/Extensions/Encryption/Encryption.swift +++ b/Sources/Shared/Publication/Extensions/Encryption/Encryption.swift @@ -9,7 +9,7 @@ import ReadiumInternal /// Indicates that a resource is encrypted/obfuscated and provides relevant information for /// decryption. -public struct Encryption: Equatable, JSONValueDecodable, JSONObjectEncodable { +public struct Encryption: Equatable, JSONValueDecodable, JSONObjectEncodable, Sendable { /// Identifies the algorithm used to encrypt the resource. public let algorithm: String // URI diff --git a/Sources/Shared/Publication/Extensions/HTML/DOMRange.swift b/Sources/Shared/Publication/Extensions/HTML/DOMRange.swift index c586b0e46f..14a9492d0b 100644 --- a/Sources/Shared/Publication/Extensions/HTML/DOMRange.swift +++ b/Sources/Shared/Publication/Extensions/HTML/DOMRange.swift @@ -22,7 +22,7 @@ import ReadiumInternal /// represents a "collapsed" range that has identical `start` and `end` boundary points. /// /// https://github.com/readium/architecture/blob/master/models/locators/extensions/html.md#the-domrange-object -public struct DOMRange: Hashable, JSONValueDecodable, JSONObjectEncodable { +public struct DOMRange: Hashable, JSONValueDecodable, JSONObjectEncodable, Sendable { /// A serializable representation of the "start" boundary point of the DOM Range. let start: Point @@ -71,7 +71,7 @@ public struct DOMRange: Hashable, JSONValueDecodable, JSONObjectEncodable { /// node). /// /// https://github.com/readium/architecture/blob/master/models/locators/extensions/html.md#the-start-and-end-object - public struct Point: Hashable, JSONValueDecodable, JSONObjectEncodable { + public struct Point: Hashable, JSONValueDecodable, JSONObjectEncodable, Sendable { let cssSelector: String let textNodeIndex: Int let charOffset: Int? diff --git a/Sources/Shared/Publication/Link.swift b/Sources/Shared/Publication/Link.swift index e2e50bcffa..1277e96b0c 100644 --- a/Sources/Shared/Publication/Link.swift +++ b/Sources/Shared/Publication/Link.swift @@ -7,7 +7,7 @@ import Foundation import ReadiumInternal -public enum LinkError: Error, Equatable { +public enum LinkError: Error, Equatable, Sendable { /// The link's HREF is not a valid URL. case invalidHREF(String) } diff --git a/Sources/Shared/Publication/Protection/ContentProtection.swift b/Sources/Shared/Publication/Protection/ContentProtection.swift index 83ea59659d..53620b1b46 100644 --- a/Sources/Shared/Publication/Protection/ContentProtection.swift +++ b/Sources/Shared/Publication/Protection/ContentProtection.swift @@ -25,9 +25,9 @@ public protocol ContentProtection { ) async -> Result } -public enum ContentProtectionOpenError: Error { +public enum ContentProtectionOpenError: Error, Sendable { /// The asset is not supported by this ``ContentProtection`` - case assetNotSupported(Error?) + case assetNotSupported((any Error)?) /// An error occurred while reading the asset. case reading(ReadError) @@ -49,7 +49,7 @@ public struct ContentProtectionScheme: RawRepresentable, Equatable, Sendable { public static let adept = ContentProtectionScheme(rawValue: HTTPURL(string: "http://ns.adobe.com/adept")!) } -public struct ContentProtectionSchemeNotSupportedError: Error { +public struct ContentProtectionSchemeNotSupportedError: Error, Sendable { public let scheme: ContentProtectionScheme public init(scheme: ContentProtectionScheme) { diff --git a/Sources/Shared/Publication/Protection/FallbackContentProtection.swift b/Sources/Shared/Publication/Protection/FallbackContentProtection.swift index 1909fe6b79..169f9b4dce 100644 --- a/Sources/Shared/Publication/Protection/FallbackContentProtection.swift +++ b/Sources/Shared/Publication/Protection/FallbackContentProtection.swift @@ -8,7 +8,7 @@ import Foundation /// ``ContentProtection`` implementation used as a fallback when detecting /// known DRMs not supported by the app. -public final class _FallbackContentProtection: ContentProtection { +public final class _FallbackContentProtection: ContentProtection, Sendable { public init() {} public func open( diff --git a/Sources/Shared/Publication/Publication.swift b/Sources/Shared/Publication/Publication.swift index febb79d8c9..17c8fbffd5 100644 --- a/Sources/Shared/Publication/Publication.swift +++ b/Sources/Shared/Publication/Publication.swift @@ -9,7 +9,7 @@ import Foundation import ReadiumInternal /// Shared model for a Readium Publication. -public class Publication: Closeable, Loggable { +public final class Publication: Closeable, Loggable { public var manifest: Manifest private let container: Container private let services: [PublicationService] diff --git a/Sources/Shared/Publication/Services/Content Protection/UserRights.swift b/Sources/Shared/Publication/Services/Content Protection/UserRights.swift index 1a247a0536..a3996afd83 100644 --- a/Sources/Shared/Publication/Services/Content Protection/UserRights.swift +++ b/Sources/Shared/Publication/Services/Content Protection/UserRights.swift @@ -35,7 +35,7 @@ public protocol UserRights { } /// A `UserRights` without any restriction. -public class UnrestrictedUserRights: UserRights { +public final class UnrestrictedUserRights: UserRights, Sendable { public init() {} public func canCopy(text: String) async -> Bool { @@ -56,7 +56,7 @@ public class UnrestrictedUserRights: UserRights { } /// A `UserRights` which forbids all rights. -public class AllRestrictedUserRights: UserRights { +public final class AllRestrictedUserRights: UserRights, Sendable { public init() {} public func canCopy(text: String) async -> Bool { diff --git a/Sources/Shared/Publication/Services/Content/Content.swift b/Sources/Shared/Publication/Services/Content/Content.swift index bfb281eb6c..b00ab16122 100644 --- a/Sources/Shared/Publication/Services/Content/Content.swift +++ b/Sources/Shared/Publication/Services/Content/Content.swift @@ -194,7 +194,7 @@ public struct TextContentElement: Hashable, TextualContentElement { } /// Represents a purpose of an element in the broader context of the document. - public enum Role: Hashable { + public enum Role: Hashable, Sendable { /// Title of a section with its level (1 being the highest). case heading(level: Int) @@ -229,7 +229,7 @@ public struct TextContentElement: Hashable, TextualContentElement { /// An attribute key identifies uniquely a type of attribute. /// /// The `V` phantom type is there to perform static type checking when requesting an attribute. -public struct ContentAttributeKey: Hashable { +public struct ContentAttributeKey: Hashable, Sendable { public static var accessibilityLabel: ContentAttributeKey { .init("accessibilityLabel") } @@ -312,7 +312,7 @@ public protocol ContentIterator: AnyObject { } /// Helper class to treat a `Content` as a `Sequence`. -public class ContentSequence: AsyncSequence { +public final class ContentSequence: AsyncSequence { public typealias Element = ContentElement private let content: Content @@ -325,7 +325,7 @@ public class ContentSequence: AsyncSequence { Iterator(iterator: content.iterator()) } - public class Iterator: AsyncIteratorProtocol, Loggable { + public final class Iterator: AsyncIteratorProtocol, Loggable { private let iterator: ContentIterator public init(iterator: ContentIterator) { diff --git a/Sources/Shared/Publication/Services/Content/ContentService.swift b/Sources/Shared/Publication/Services/Content/ContentService.swift index e2b79a9df2..95d39577b2 100644 --- a/Sources/Shared/Publication/Services/Content/ContentService.swift +++ b/Sources/Shared/Publication/Services/Content/ContentService.swift @@ -19,7 +19,7 @@ public protocol ContentService: PublicationService { /// Default implementation of `ContentService`, delegating the content parsing /// to `ResourceContentIteratorFactory`. -public class DefaultContentService: ContentService { +public final class DefaultContentService: ContentService, Sendable { private let publication: Weak private let resourceContentIteratorFactories: [ResourceContentIteratorFactory] diff --git a/Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift b/Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift index c144ffa94f..32c8b6bced 100644 --- a/Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift +++ b/Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift @@ -18,9 +18,9 @@ import SwiftSoup /// /// Locators will contain a `before` context of up to `beforeMaxLength` /// characters. -public class HTMLResourceContentIterator: ContentIterator { +public final class HTMLResourceContentIterator: ContentIterator { /// Factory for an `HTMLResourceContentIterator`. - public class Factory: ResourceContentIteratorFactory { + public final class Factory: ResourceContentIteratorFactory, Sendable { public init() {} public func make( diff --git a/Sources/Shared/Publication/Services/Content/Iterators/PublicationContentIterator.swift b/Sources/Shared/Publication/Services/Content/Iterators/PublicationContentIterator.swift index f2b3c15612..0aed23be0d 100644 --- a/Sources/Shared/Publication/Services/Content/Iterators/PublicationContentIterator.swift +++ b/Sources/Shared/Publication/Services/Content/Iterators/PublicationContentIterator.swift @@ -6,7 +6,7 @@ import Foundation -public protocol ResourceContentIteratorFactory { +public protocol ResourceContentIteratorFactory: Sendable { /// Creates a `ContentIterator` instance for the `resource`, starting from /// the given `locator`. /// @@ -21,7 +21,7 @@ public protocol ResourceContentIteratorFactory { /// A composite [Content.Iterator] which iterates through a whole [publication] and delegates the /// iteration inside a given resource to media type-specific iterators. -public class PublicationContentIterator: ContentIterator, Loggable { +public final class PublicationContentIterator: ContentIterator, Loggable { /// `ContentIterator` for a resource, associated with its index in the reading order. private typealias IndexedIterator = (index: Int, iterator: ContentIterator) diff --git a/Sources/Shared/Publication/Services/Positions/InMemoryPositionsService.swift b/Sources/Shared/Publication/Services/Positions/InMemoryPositionsService.swift index e34c7343d6..6790ea5080 100644 --- a/Sources/Shared/Publication/Services/Positions/InMemoryPositionsService.swift +++ b/Sources/Shared/Publication/Services/Positions/InMemoryPositionsService.swift @@ -7,7 +7,7 @@ import Foundation /// A ``PositionsService`` holding the pre-computed position locators in memory. -public class InMemoryPositionsService: PositionsService { +public final class InMemoryPositionsService: PositionsService, Sendable { private let _positions: [[Locator]] public init(positionsByReadingOrder: [[Locator]]) { diff --git a/Sources/Shared/Publication/Services/Search/SearchService.swift b/Sources/Shared/Publication/Services/Search/SearchService.swift index d16689af34..32a615a7f9 100644 --- a/Sources/Shared/Publication/Services/Search/SearchService.swift +++ b/Sources/Shared/Publication/Services/Search/SearchService.swift @@ -58,7 +58,7 @@ public extension SearchIterator { } /// Holds the available search options and their current values. -public struct SearchOptions: Hashable { +public struct SearchOptions: Hashable, Sendable { /// Whether the search will differentiate between capital and lower-case letters. public var caseSensitive: Bool? @@ -110,7 +110,7 @@ public struct SearchOptions: Hashable { public typealias SearchResult = Result /// Represents an error which might occur during a search activity. -public enum SearchError: Error { +public enum SearchError: Error, Sendable { /// The publication is not searchable. case publicationNotSearchable diff --git a/Sources/Shared/Publication/Services/Search/StringSearchAlgorithm.swift b/Sources/Shared/Publication/Services/Search/StringSearchAlgorithm.swift index 29fc424733..2573c6f227 100644 --- a/Sources/Shared/Publication/Services/Search/StringSearchAlgorithm.swift +++ b/Sources/Shared/Publication/Services/Search/StringSearchAlgorithm.swift @@ -7,7 +7,7 @@ import Foundation /// Implements the actual search algorithm in sanitized text content. -public protocol StringSearchAlgorithm { +public protocol StringSearchAlgorithm: Sendable { /// Default value for the search options available with this algorithm. /// /// If an option does not have a value, it is not supported by the algorithm. @@ -23,7 +23,7 @@ public protocol StringSearchAlgorithm { } /// A basic `StringSearchAlgorithm` using the native `String.range(of:)` APIs. -public class BasicStringSearchAlgorithm: StringSearchAlgorithm { +public final class BasicStringSearchAlgorithm: StringSearchAlgorithm, Sendable { public let options: SearchOptions = .init( caseSensitive: false, diacriticSensitive: false, diff --git a/Sources/Shared/Publication/Services/Search/StringSearchService.swift b/Sources/Shared/Publication/Services/Search/StringSearchService.swift index 293096191c..6bb4fbd61b 100644 --- a/Sources/Shared/Publication/Services/Search/StringSearchService.swift +++ b/Sources/Shared/Publication/Services/Search/StringSearchService.swift @@ -15,7 +15,7 @@ import Foundation /// /// The actual search is implemented by the provided `searchAlgorithm`. @available(*, deprecated, renamed: "ContentSearchService", message: "Use ContentSearchService for new integrations.") -public class StringSearchService: SearchService { +public final class StringSearchService: SearchService, Sendable { public static func makeFactory( snippetLength: Int = 200, searchAlgorithm: StringSearchAlgorithm = BasicStringSearchAlgorithm(), diff --git a/Sources/Shared/Toolkit/Archive/ArchiveOpener.swift b/Sources/Shared/Toolkit/Archive/ArchiveOpener.swift index e22b0c463f..aebab283b9 100644 --- a/Sources/Shared/Toolkit/Archive/ArchiveOpener.swift +++ b/Sources/Shared/Toolkit/Archive/ArchiveOpener.swift @@ -17,7 +17,7 @@ public protocol ArchiveOpener { func sniffOpen(resource: Resource) async -> Result } -public enum ArchiveOpenError: Error { +public enum ArchiveOpenError: Error, Sendable { /// Archive format not supported. case formatNotSupported(Format) @@ -25,7 +25,7 @@ public enum ArchiveOpenError: Error { case reading(ReadError) } -public enum ArchiveSniffOpenError: Error { +public enum ArchiveSniffOpenError: Error, Sendable { /// The format of the resource could not be inferred. case formatNotRecognized diff --git a/Sources/Shared/Toolkit/Archive/ArchiveProperties.swift b/Sources/Shared/Toolkit/Archive/ArchiveProperties.swift index c9460c5eb8..30558079c2 100644 --- a/Sources/Shared/Toolkit/Archive/ArchiveProperties.swift +++ b/Sources/Shared/Toolkit/Archive/ArchiveProperties.swift @@ -8,7 +8,7 @@ import Foundation import ReadiumInternal /// Holds information about how the resource is stored in the archive. -public struct ArchiveProperties: Equatable, JSONValueDecodable, JSONObjectEncodable { +public struct ArchiveProperties: Equatable, JSONValueDecodable, JSONObjectEncodable, Sendable { /// The length of the entry stored in the archive. It might be a compressed /// length if the entry is deflated. public let entryLength: UInt64 diff --git a/Sources/Shared/Toolkit/Archive/DefaultArchiveOpener.swift b/Sources/Shared/Toolkit/Archive/DefaultArchiveOpener.swift index 73e1bdfa56..b60d612f58 100644 --- a/Sources/Shared/Toolkit/Archive/DefaultArchiveOpener.swift +++ b/Sources/Shared/Toolkit/Archive/DefaultArchiveOpener.swift @@ -7,7 +7,7 @@ import Foundation /// Default implementation of ``ArchiveOpener`` supporting ZIP archives. -public class DefaultArchiveOpener: CompositeArchiveOpener { +public final class DefaultArchiveOpener: CompositeArchiveOpener { /// - Parameter additionalArchiveOpeners: Additional archive openers to use. public init(additionalArchiveOpeners: [any ArchiveOpener] = []) { super.init(additionalArchiveOpeners + [ZIPArchiveOpener()]) diff --git a/Sources/Shared/Toolkit/Data/Asset/AssetRetriever.swift b/Sources/Shared/Toolkit/Data/Asset/AssetRetriever.swift index 76137ec203..9979728d98 100644 --- a/Sources/Shared/Toolkit/Data/Asset/AssetRetriever.swift +++ b/Sources/Shared/Toolkit/Data/Asset/AssetRetriever.swift @@ -8,7 +8,7 @@ import Foundation /// Error while trying to retrieve an asset from a ``Resource`` or a /// ``Container``. -public enum AssetRetrieveError: Error { +public enum AssetRetrieveError: Error, Sendable { /// The format of the resource is not recognized. case formatNotSupported @@ -17,7 +17,7 @@ public enum AssetRetrieveError: Error { } /// Error while trying to retrieve an asset from an URL. -public enum AssetRetrieveURLError: Error { +public enum AssetRetrieveURLError: Error, Sendable { /// The scheme (e.g. http, file, content) for the requested URL is not /// supported. case schemeNotSupported(URLScheme) diff --git a/Sources/Shared/Toolkit/Data/Container/Container.swift b/Sources/Shared/Toolkit/Data/Container/Container.swift index e6a18fd906..a3bf564085 100644 --- a/Sources/Shared/Toolkit/Data/Container/Container.swift +++ b/Sources/Shared/Toolkit/Data/Container/Container.swift @@ -28,7 +28,7 @@ public protocol Container: Closeable { } /// A `Container` providing no entries at all. -public struct EmptyContainer: Container { +public struct EmptyContainer: Container, Sendable { public init() {} public let sourceURL: AbsoluteURL? = nil @@ -46,7 +46,7 @@ public struct EmptyContainer: Container { /// sources. /// /// The `containers` will be tested in the given order. -public class CompositeContainer: Container { +public final class CompositeContainer: Container { private let containers: [Container] public convenience init(_ containers: Container...) { diff --git a/Sources/Shared/Toolkit/Data/Container/SingleResourceContainer.swift b/Sources/Shared/Toolkit/Data/Container/SingleResourceContainer.swift index 4b37c828cd..aa8ee6e227 100644 --- a/Sources/Shared/Toolkit/Data/Container/SingleResourceContainer.swift +++ b/Sources/Shared/Toolkit/Data/Container/SingleResourceContainer.swift @@ -7,7 +7,7 @@ import Foundation /// Encapsulates a single ``Resource`` into a ``Container``. -public class SingleResourceContainer: Container { +public final class SingleResourceContainer: Container { public let entry: AnyURL private let resource: Resource diff --git a/Sources/Shared/Toolkit/Data/ReadError.swift b/Sources/Shared/Toolkit/Data/ReadError.swift index 6d38c90731..8e13a7b5c4 100644 --- a/Sources/Shared/Toolkit/Data/ReadError.swift +++ b/Sources/Shared/Toolkit/Data/ReadError.swift @@ -7,7 +7,7 @@ import Foundation /// Errors occurring while reading a resource. -public enum ReadError: Error { +public enum ReadError: Error, Sendable { /// An error occurred while trying to access the content. /// /// At the moment, `AccessError`s constructed by the toolkit can be either @@ -130,7 +130,7 @@ public enum ReadError: Error { } } -public enum AccessError: Error { +public enum AccessError: Error, Sendable { /// An error occurred while accessing content over HTTP. case http(HTTPError) diff --git a/Sources/Shared/Toolkit/Data/Resource/FailureResource.swift b/Sources/Shared/Toolkit/Data/Resource/FailureResource.swift index 7cc0f39341..3a64ea90cd 100644 --- a/Sources/Shared/Toolkit/Data/Resource/FailureResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/FailureResource.swift @@ -7,7 +7,7 @@ import Foundation /// Creates a Resource that will always return the given `error`. -public final class FailureResource: Resource { +public final class FailureResource: Resource, Sendable { private let error: ReadError public let sourceURL: AbsoluteURL? diff --git a/Sources/Shared/Toolkit/Data/Resource/ResourceContentExtractor.swift b/Sources/Shared/Toolkit/Data/Resource/ResourceContentExtractor.swift index d92531ce49..e504e57334 100644 --- a/Sources/Shared/Toolkit/Data/Resource/ResourceContentExtractor.swift +++ b/Sources/Shared/Toolkit/Data/Resource/ResourceContentExtractor.swift @@ -17,7 +17,7 @@ public protocol ResourceContentExtractor { public typealias _ResourceContentExtractor = ResourceContentExtractor /// Creates a `ResourceContentExtractor` for a given resource and media type. -public protocol ResourceContentExtractorFactory { +public protocol ResourceContentExtractorFactory: Sendable { /// Creates a `ResourceContentExtractor` instance for the given `resource`. /// Returns nil if the resource format is not supported. func makeExtractor(for resource: Resource, mediaType: MediaType) -> ResourceContentExtractor? @@ -27,7 +27,7 @@ public protocol ResourceContentExtractorFactory { public typealias _ResourceContentExtractorFactory = ResourceContentExtractorFactory /// Default `ResourceContentExtractorFactory` supporting HTML resources. -public class DefaultResourceContentExtractorFactory: ResourceContentExtractorFactory { +public class DefaultResourceContentExtractorFactory: ResourceContentExtractorFactory, Sendable { public init() {} public func makeExtractor(for resource: Resource, mediaType: MediaType) -> ResourceContentExtractor? { diff --git a/Sources/Shared/Toolkit/Data/Resource/ResourceFactory.swift b/Sources/Shared/Toolkit/Data/Resource/ResourceFactory.swift index 7745466786..f621fec8be 100644 --- a/Sources/Shared/Toolkit/Data/Resource/ResourceFactory.swift +++ b/Sources/Shared/Toolkit/Data/Resource/ResourceFactory.swift @@ -12,7 +12,7 @@ public protocol ResourceFactory { func make(url: AbsoluteURL) async -> Result } -public enum ResourceMakeError: Error { +public enum ResourceMakeError: Error, Sendable { /// URL scheme not supported by the ``ResourceFactory``. case schemeNotSupported(URLScheme) } diff --git a/Sources/Shared/Toolkit/Data/Resource/ResourceProperties.swift b/Sources/Shared/Toolkit/Data/Resource/ResourceProperties.swift index a6256a4d7f..38815d7024 100644 --- a/Sources/Shared/Toolkit/Data/Resource/ResourceProperties.swift +++ b/Sources/Shared/Toolkit/Data/Resource/ResourceProperties.swift @@ -7,7 +7,7 @@ import Foundation /// Properties associated to a resource. -public struct ResourceProperties: Hashable { +public struct ResourceProperties: Hashable, Sendable { public var properties: [String: JSONValue] public init(_ properties: [String: JSONValue] = [:]) { diff --git a/Sources/Shared/Toolkit/DebugError.swift b/Sources/Shared/Toolkit/DebugError.swift index 0268e1dfc3..1b62a5e79b 100644 --- a/Sources/Shared/Toolkit/DebugError.swift +++ b/Sources/Shared/Toolkit/DebugError.swift @@ -6,7 +6,7 @@ import Foundation -public struct DebugError: Error, CustomStringConvertible { +public struct DebugError: Error, CustomStringConvertible, Sendable { public let message: String public let cause: Error? diff --git a/Sources/Shared/Toolkit/DocumentTypes.swift b/Sources/Shared/Toolkit/DocumentTypes.swift index bf98d7de71..914389b93d 100644 --- a/Sources/Shared/Toolkit/DocumentTypes.swift +++ b/Sources/Shared/Toolkit/DocumentTypes.swift @@ -14,7 +14,7 @@ import ReadiumInternal /// Provides a convenient access layer to the Document Types declared in the `Info.plist`, /// under `CFBundleDocumentTypes`. -public struct DocumentTypes { +public struct DocumentTypes: Sendable { /// Default `DocumentTypes` instance extracted from the main bundle's Info.plist. public static let main = DocumentTypes(bundle: .main) @@ -90,7 +90,7 @@ public struct DocumentTypes { } /// Metadata about a Document Type declared in `CFBundleDocumentTypes`. -public struct DocumentType: Equatable, Loggable { +public struct DocumentType: Equatable, Loggable, Sendable { /// Abstract name for the document type, used to refer to the type. public let name: String diff --git a/Sources/Shared/Toolkit/Either.swift b/Sources/Shared/Toolkit/Either.swift index 122339ca17..2ab91639f9 100644 --- a/Sources/Shared/Toolkit/Either.swift +++ b/Sources/Shared/Toolkit/Either.swift @@ -6,7 +6,7 @@ import Foundation -public enum Either { +public enum Either: Sendable { case left(L) case right(R) } diff --git a/Sources/Shared/Toolkit/File/DirectoryContainer.swift b/Sources/Shared/Toolkit/File/DirectoryContainer.swift index 478661bbfe..39c3bca3b9 100644 --- a/Sources/Shared/Toolkit/File/DirectoryContainer.swift +++ b/Sources/Shared/Toolkit/File/DirectoryContainer.swift @@ -7,8 +7,8 @@ import Foundation /// A file system directory as a ``Container``. -public struct DirectoryContainer: Container, Loggable { - public struct NotADirectoryError: Error {} +public struct DirectoryContainer: Container, Loggable, Sendable { + public struct NotADirectoryError: Error, Sendable {} private let directoryURL: FileURL public var sourceURL: AbsoluteURL? { diff --git a/Sources/Shared/Toolkit/File/FileContainer.swift b/Sources/Shared/Toolkit/File/FileContainer.swift index 20990411d7..6730be13a0 100644 --- a/Sources/Shared/Toolkit/File/FileContainer.swift +++ b/Sources/Shared/Toolkit/File/FileContainer.swift @@ -7,7 +7,7 @@ import Foundation /// Provides access to individual file resources on the local file system. -public final class FileContainer: Container, Loggable { +public final class FileContainer: Container, Loggable, Sendable { private let files: [RelativeURL: FileURL] public let sourceURL: AbsoluteURL? = nil diff --git a/Sources/Shared/Toolkit/File/FileResourceFactory.swift b/Sources/Shared/Toolkit/File/FileResourceFactory.swift index b71b282ba7..2324ac9f29 100644 --- a/Sources/Shared/Toolkit/File/FileResourceFactory.swift +++ b/Sources/Shared/Toolkit/File/FileResourceFactory.swift @@ -8,7 +8,7 @@ import Foundation /// Creates ``FileResource`` instances granting access to `file://` URLs stored /// on the file system. -public class FileResourceFactory: ResourceFactory { +public final class FileResourceFactory: ResourceFactory, Sendable { public func make(url: any AbsoluteURL) async -> Result { guard let file = url.fileURL else { return .failure(.schemeNotSupported(url.scheme)) diff --git a/Sources/Shared/Toolkit/File/FileSystemError.swift b/Sources/Shared/Toolkit/File/FileSystemError.swift index 6a4637c349..0a94f96a28 100644 --- a/Sources/Shared/Toolkit/File/FileSystemError.swift +++ b/Sources/Shared/Toolkit/File/FileSystemError.swift @@ -7,7 +7,7 @@ import Foundation /// Error occurring on the file system. -public enum FileSystemError: Error { +public enum FileSystemError: Error, Sendable { /// File was not found. case fileNotFound(Error?) diff --git a/Sources/Shared/Toolkit/FileExtension.swift b/Sources/Shared/Toolkit/FileExtension.swift index 86380c0c3c..8f1f76b55b 100644 --- a/Sources/Shared/Toolkit/FileExtension.swift +++ b/Sources/Shared/Toolkit/FileExtension.swift @@ -7,7 +7,7 @@ import Foundation /// Represents a file extension. -public struct FileExtension: Hashable, RawRepresentable, ExpressibleByStringLiteral { +public struct FileExtension: Hashable, RawRepresentable, ExpressibleByStringLiteral, Sendable { public let rawValue: String public init(rawValue: String) { diff --git a/Sources/Shared/Toolkit/Format/Format.swift b/Sources/Shared/Toolkit/Format/Format.swift index 4f395e7035..a4728bbb50 100644 --- a/Sources/Shared/Toolkit/Format/Format.swift +++ b/Sources/Shared/Toolkit/Format/Format.swift @@ -8,7 +8,7 @@ import Foundation import ReadiumInternal /// Represents and holds information about the document format of an asset. -public struct Format: Hashable { +public struct Format: Hashable, Sendable { public var specifications: FormatSpecifications public var mediaType: MediaType? public var fileExtension: FileExtension? @@ -86,7 +86,7 @@ public struct Format: Hashable { ) } -public struct FormatSpecifications: Hashable { +public struct FormatSpecifications: Hashable, Sendable { public var specifications: Set public init(_ specifications: FormatSpecification...) { @@ -122,7 +122,7 @@ public struct FormatSpecifications: Hashable { } } -public struct FormatSpecification: RawRepresentable, Hashable { +public struct FormatSpecification: RawRepresentable, Hashable, Sendable { public var rawValue: String public init(rawValue: String) { diff --git a/Sources/Shared/Toolkit/Format/FormatSniffer.swift b/Sources/Shared/Toolkit/Format/FormatSniffer.swift index a87674ab5b..30a0cc3df7 100644 --- a/Sources/Shared/Toolkit/Format/FormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/FormatSniffer.swift @@ -53,7 +53,7 @@ public extension FormatSniffer { } /// Bundle of media type and file extension hints for the `FormatHintsSniffer`. -public struct FormatHints { +public struct FormatHints: Sendable { public var mediaTypes: [MediaType] public var fileExtensions: [FileExtension] diff --git a/Sources/Shared/Toolkit/Format/Sniffers/AudioFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/AudioFormatSniffer.swift index 50792893f2..80ac5fae68 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/AudioFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/AudioFormatSniffer.swift @@ -7,7 +7,7 @@ import Foundation /// Sniffs audio formats. -public class AudioFormatSniffer: FormatSniffer { +public final class AudioFormatSniffer: FormatSniffer, Sendable { public init() {} public func sniffHints(_ hints: FormatHints) -> Format? { diff --git a/Sources/Shared/Toolkit/Format/Sniffers/AudiobookFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/AudiobookFormatSniffer.swift index dfd5bdac27..ebaac322a7 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/AudiobookFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/AudiobookFormatSniffer.swift @@ -7,7 +7,7 @@ import Foundation /// Sniffs an Audiobook. -public struct ZABFormatSniffer: FormatSniffer { +public struct ZABFormatSniffer: FormatSniffer, Sendable { /// Required extensions for an archive to be considered an audiobook public static let defaultRequiredExtensions: Set = audioExtensions diff --git a/Sources/Shared/Toolkit/Format/Sniffers/BitmapFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/BitmapFormatSniffer.swift index 165e44a59f..088316d797 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/BitmapFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/BitmapFormatSniffer.swift @@ -7,7 +7,7 @@ import Foundation /// Sniffs bitmap formats. -public class BitmapFormatSniffer: FormatSniffer { +public final class BitmapFormatSniffer: FormatSniffer, Sendable { public init() {} public func sniffHints(_ hints: FormatHints) -> Format? { diff --git a/Sources/Shared/Toolkit/Format/Sniffers/ComicFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/ComicFormatSniffer.swift index 0cbe03383d..61634971a2 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/ComicFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/ComicFormatSniffer.swift @@ -7,7 +7,7 @@ import Foundation /// Sniffs a ComicBook Archive. -public struct ComicFormatSniffer: FormatSniffer { +public struct ComicFormatSniffer: FormatSniffer, Sendable { /// Required extensions for an archive to be considered a ComicBook Archive. /// Reference: https://wiki.mobileread.com/wiki/CBR_and_CBZ public static let defaultRequiredExtensions: Set = bitmapExtensions diff --git a/Sources/Shared/Toolkit/Format/Sniffers/EPUBFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/EPUBFormatSniffer.swift index 5aa775bace..89e6947504 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/EPUBFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/EPUBFormatSniffer.swift @@ -9,7 +9,7 @@ import Foundation /// Sniffs an EPUB publication. /// /// Reference: https://www.w3.org/publishing/epub3/epub-ocf.html#sec-zip-container-mime -public struct EPUBFormatSniffer: FormatSniffer { +public struct EPUBFormatSniffer: FormatSniffer, Sendable { private let xmlDocumentFactory: XMLDocumentFactory public init(xmlDocumentFactory: XMLDocumentFactory) { diff --git a/Sources/Shared/Toolkit/Format/Sniffers/HTMLFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/HTMLFormatSniffer.swift index 8c324b2358..3e43e6e5b7 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/HTMLFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/HTMLFormatSniffer.swift @@ -7,7 +7,7 @@ import Foundation /// Sniffs an HTML or XHTML document. -public struct HTMLFormatSniffer: FormatSniffer { +public struct HTMLFormatSniffer: FormatSniffer, Sendable { public init() {} public func sniffHints(_ hints: FormatHints) -> Format? { diff --git a/Sources/Shared/Toolkit/Format/Sniffers/JSONFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/JSONFormatSniffer.swift index b3f588bd6f..b31d1e3cf6 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/JSONFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/JSONFormatSniffer.swift @@ -7,7 +7,7 @@ import Foundation /// Sniffs a JSON document. -public struct JSONFormatSniffer: FormatSniffer { +public struct JSONFormatSniffer: FormatSniffer, Sendable { public init() {} public func sniffHints(_ hints: FormatHints) -> Format? { diff --git a/Sources/Shared/Toolkit/Format/Sniffers/LCPLicenseFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/LCPLicenseFormatSniffer.swift index f780980f4b..ffb4550d15 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/LCPLicenseFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/LCPLicenseFormatSniffer.swift @@ -7,7 +7,7 @@ import Foundation /// Sniffs an LCP License Document. -public struct LCPLicenseFormatSniffer: FormatSniffer { +public struct LCPLicenseFormatSniffer: FormatSniffer, Sendable { public init() {} public func sniffHints(_ hints: FormatHints) -> Format? { diff --git a/Sources/Shared/Toolkit/Format/Sniffers/LanguageFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/LanguageFormatSniffer.swift index 84f952dd09..68a3261c71 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/LanguageFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/LanguageFormatSniffer.swift @@ -6,7 +6,7 @@ import Foundation -public class LanguageFormatSniffer: FormatSniffer { +public final class LanguageFormatSniffer: FormatSniffer, Sendable { public init() {} public func sniffHints(_ hints: FormatHints) -> Format? { diff --git a/Sources/Shared/Toolkit/Format/Sniffers/OPDSFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/OPDSFormatSniffer.swift index 444ca3ffd9..354d01eecb 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/OPDSFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/OPDSFormatSniffer.swift @@ -7,7 +7,7 @@ import Foundation /// Sniffs OPDS documents. -public class OPDSFormatSniffer: FormatSniffer { +public final class OPDSFormatSniffer: FormatSniffer, Sendable { public init() {} public func sniffHints(_ hints: FormatHints) -> Format? { diff --git a/Sources/Shared/Toolkit/Format/Sniffers/PDFFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/PDFFormatSniffer.swift index cde825e181..bff410cf72 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/PDFFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/PDFFormatSniffer.swift @@ -9,7 +9,7 @@ import Foundation /// Sniffs a PDF document. /// /// Reference: https://www.loc.gov/preservation/digital/formats/fdd/fdd000123.shtml -public struct PDFFormatSniffer: FormatSniffer { +public struct PDFFormatSniffer: FormatSniffer, Sendable { public init() {} public func sniffHints(_ hints: FormatHints) -> Format? { diff --git a/Sources/Shared/Toolkit/Format/Sniffers/RARFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/RARFormatSniffer.swift index 11bbf15d27..eb29bc3c40 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/RARFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/RARFormatSniffer.swift @@ -7,7 +7,7 @@ import Foundation /// Sniffs a RAR file. -public struct RARFormatSniffer: FormatSniffer { +public struct RARFormatSniffer: FormatSniffer, Sendable { public init() {} public func sniffHints(_ hints: FormatHints) -> Format? { diff --git a/Sources/Shared/Toolkit/Format/Sniffers/RPFFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/RPFFormatSniffer.swift index b5f6f8a6be..7a2d440b41 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/RPFFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/RPFFormatSniffer.swift @@ -7,7 +7,7 @@ import Foundation /// Sniffs a Readium Web Publication package. -public struct RPFFormatSniffer: FormatSniffer { +public struct RPFFormatSniffer: FormatSniffer, Sendable { public init() {} public func sniffHints(_ hints: FormatHints) -> Format? { diff --git a/Sources/Shared/Toolkit/Format/Sniffers/RWPMFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/RWPMFormatSniffer.swift index 4373e0a381..5e3f2c5e0d 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/RWPMFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/RWPMFormatSniffer.swift @@ -7,7 +7,7 @@ import Foundation /// Sniffs a Readium Web Publication Manifest. -public struct RWPMFormatSniffer: FormatSniffer { +public struct RWPMFormatSniffer: FormatSniffer, Sendable { public init() {} public func sniffHints(_ hints: FormatHints) -> Format? { diff --git a/Sources/Shared/Toolkit/Format/Sniffers/XMLFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/XMLFormatSniffer.swift index b2f8b00e99..601facad32 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/XMLFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/XMLFormatSniffer.swift @@ -7,7 +7,7 @@ import Foundation /// Sniffs an XML document. -public struct XMLFormatSniffer: FormatSniffer { +public struct XMLFormatSniffer: FormatSniffer, Sendable { public init() {} public func sniffHints(_ hints: FormatHints) -> Format? { diff --git a/Sources/Shared/Toolkit/Format/Sniffers/ZIPFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/ZIPFormatSniffer.swift index 6b0f13ca97..b0ffe64d2d 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/ZIPFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/ZIPFormatSniffer.swift @@ -7,7 +7,7 @@ import Foundation /// Sniffs a ZIP file. -public struct ZIPFormatSniffer: FormatSniffer { +public struct ZIPFormatSniffer: FormatSniffer, Sendable { public init() {} public func sniffHints(_ hints: FormatHints) -> Format? { diff --git a/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift b/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift index 94d420d184..0ba7fb60fc 100644 --- a/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift +++ b/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift @@ -7,7 +7,7 @@ import Foundation import UIKit -public enum URLAuthenticationChallengeResponse { +public enum URLAuthenticationChallengeResponse: Sendable { /// Use the specified credential. case useCredential(URLCredential) /// Use the default handling for the challenge as though this delegate method were not implemented. diff --git a/Sources/Shared/Toolkit/HTTP/HTTPClient.swift b/Sources/Shared/Toolkit/HTTP/HTTPClient.swift index b43b27a3c3..f8e6109b17 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPClient.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPClient.swift @@ -151,7 +151,7 @@ public extension HTTPClient { } /// Status code of an HTTP response. -public struct HTTPStatus: Equatable, RawRepresentable, ExpressibleByIntegerLiteral { +public struct HTTPStatus: Equatable, RawRepresentable, ExpressibleByIntegerLiteral, Sendable { public let rawValue: Int public init(rawValue: RawValue) { @@ -284,7 +284,7 @@ public struct HTTPResponse: Equatable { } /// Holds the information about a successful download. -public struct HTTPDownload { +public struct HTTPDownload: Sendable { /// The location of a temporary file where the server's response is stored. /// You are responsible for moving or deleting the downloaded file.. public let location: FileURL diff --git a/Sources/Shared/Toolkit/HTTP/HTTPProblemDetails.swift b/Sources/Shared/Toolkit/HTTP/HTTPProblemDetails.swift index 2aa39d849c..0f41d92f4c 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPProblemDetails.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPProblemDetails.swift @@ -10,7 +10,7 @@ import Foundation /// /// https://tools.ietf.org/html/rfc7807 public struct HTTPProblemDetails: Decodable, Equatable, Sendable { - public enum Error: Swift.Error { + public enum Error: Swift.Error, Sendable { case malformed(json: String?) } diff --git a/Sources/Shared/Toolkit/HTTP/HTTPRequest.swift b/Sources/Shared/Toolkit/HTTP/HTTPRequest.swift index f6ca759d1e..7edad4384e 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPRequest.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPRequest.swift @@ -15,7 +15,7 @@ public struct HTTPRequest: Equatable { public var method: Method /// Supported HTTP methods. - public enum Method: String, Equatable { + public enum Method: String, Equatable, Sendable { case delete = "DELETE" case get = "GET" case head = "HEAD" @@ -32,7 +32,7 @@ public struct HTTPRequest: Equatable { public var body: Body? /// Supported body values. - public enum Body: Equatable { + public enum Body: Equatable, Sendable { case data(Data) case file(URL) } @@ -130,7 +130,7 @@ public protocol HTTPRequestConvertible { func httpRequest() -> HTTPResult } -public enum HTTPRequestError: Error { +public enum HTTPRequestError: Error, Sendable { case invalidURL(CustomStringConvertible & Sendable) } diff --git a/Sources/Shared/Toolkit/HTTP/HTTPResourceFactory.swift b/Sources/Shared/Toolkit/HTTP/HTTPResourceFactory.swift index 526dc7e85d..36a6d65409 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPResourceFactory.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPResourceFactory.swift @@ -8,7 +8,7 @@ import Foundation /// Creates ``HTTPResource`` instances granting access to `http(s)://` URLs /// using an ``HTTPClient``. -public class HTTPResourceFactory: ResourceFactory { +public final class HTTPResourceFactory: ResourceFactory { private let client: HTTPClient public init(client: HTTPClient) { diff --git a/Sources/Shared/Toolkit/HTTP/HTTPServer.swift b/Sources/Shared/Toolkit/HTTP/HTTPServer.swift index 24098445da..0f9132f7cd 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPServer.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPServer.swift @@ -118,7 +118,7 @@ public extension HTTPServer { public typealias HTTPServerEndpoint = String /// Request made to an `HTTPServer`. -public struct HTTPServerRequest { +public struct HTTPServerRequest: Sendable { /// Absolute URL on the server. public let url: HTTPURL diff --git a/Sources/Shared/Toolkit/JSONValue.swift b/Sources/Shared/Toolkit/JSONValue.swift index ef6acfc6ae..ca9d696008 100644 --- a/Sources/Shared/Toolkit/JSONValue.swift +++ b/Sources/Shared/Toolkit/JSONValue.swift @@ -127,11 +127,11 @@ public enum JSONValue: Sendable, Hashable, Loggable { // MARK: - Errors /// Errors thrown during JSON parsing and serialization. -public enum JSONError: Error { +public enum JSONError: Error, Sendable { /// The JSON data could not be parsed into the expected type. - case parsing(Any.Type, cause: Error? = nil) + case parsing(Any.Type, cause: (any Error)? = nil) /// The value could not be serialized to JSON. - case serializing(Any.Type, cause: Error? = nil) + case serializing(Any.Type, cause: (any Error)? = nil) } // MARK: - Decoding Protocols diff --git a/Sources/Shared/Toolkit/Keychain.swift b/Sources/Shared/Toolkit/Keychain.swift index 6ab3de82c2..29af4250b6 100644 --- a/Sources/Shared/Toolkit/Keychain.swift +++ b/Sources/Shared/Toolkit/Keychain.swift @@ -8,7 +8,7 @@ import Foundation import Security /// Errors occurring in ``Keychain``. -public enum KeychainError: Error { +public enum KeychainError: Error, Sendable { /// The item was not found in the Keychain. case itemNotFound diff --git a/Sources/Shared/Toolkit/Logging/WarningLogger.swift b/Sources/Shared/Toolkit/Logging/WarningLogger.swift index d92fd9cacd..c205ee509d 100644 --- a/Sources/Shared/Toolkit/Logging/WarningLogger.swift +++ b/Sources/Shared/Toolkit/Logging/WarningLogger.swift @@ -30,7 +30,7 @@ public protocol Warning { } /// Indicates how the user experience might be affected by a warning. -public enum WarningSeverityLevel { +public enum WarningSeverityLevel: Sendable { /// The user probably won't notice the issue. case minor /// The user experience might be affected, but it shouldn't prevent the user from enjoying the @@ -41,7 +41,7 @@ public enum WarningSeverityLevel { } /// Warning raised when parsing a model object from its JSON representation fails. -public struct JSONWarning: Warning { +public struct JSONWarning: Warning, Sendable { /// Type of the model object to be parsed. public let modelType: Any.Type /// Details about the failure. diff --git a/Sources/Shared/Toolkit/Media/AudioSession.swift b/Sources/Shared/Toolkit/Media/AudioSession.swift index 39dfd2641c..ca0689f495 100644 --- a/Sources/Shared/Toolkit/Media/AudioSession.swift +++ b/Sources/Shared/Toolkit/Media/AudioSession.swift @@ -38,8 +38,8 @@ public protocol AudioSessionManaging { /// Manages an activated `AVAudioSession`. @MainActor -public final class AudioSession: AudioSessionManaging, Loggable { - public struct Configuration: Equatable { +public final class AudioSession: AudioSessionManaging, Sendable, Loggable { + public struct Configuration: Sendable, Equatable { public let category: AVAudioSession.Category public let mode: AVAudioSession.Mode public let routeSharingPolicy: AVAudioSession.RouteSharingPolicy diff --git a/Sources/Shared/Toolkit/Media/NowPlayingInfo.swift b/Sources/Shared/Toolkit/Media/NowPlayingInfo.swift index 5c2904f7e9..a5d781d6a3 100644 --- a/Sources/Shared/Toolkit/Media/NowPlayingInfo.swift +++ b/Sources/Shared/Toolkit/Media/NowPlayingInfo.swift @@ -36,7 +36,7 @@ public final class NowPlayingInfo { } } - public struct Playback: Equatable { + public struct Playback: Equatable, Sendable { /// The playback duration of the media item, in seconds. public var duration: Double? /// The elapsed time of the now playing item, in seconds. diff --git a/Sources/Shared/Toolkit/PDF/CGPDF.swift b/Sources/Shared/Toolkit/PDF/CGPDF.swift index a876728f5c..60e5e98ce4 100644 --- a/Sources/Shared/Toolkit/PDF/CGPDF.swift +++ b/Sources/Shared/Toolkit/PDF/CGPDF.swift @@ -227,7 +227,7 @@ extension CGPDFDocument: PDFDocument { /// Creates a `PDFDocument` using Core Graphics. @available(*, deprecated, renamed: "PDFKitPDFDocumentFactory", message: "The PDFKitPDFDocumentFactory is more capable") -public class CGPDFDocumentFactory: PDFDocumentFactory, Loggable { +public class CGPDFDocumentFactory: PDFDocumentFactory, Loggable, Sendable { public init() {} public func open(file: FileURL, password: String?) async throws -> PDFDocument { diff --git a/Sources/Shared/Toolkit/PDF/PDFDocument.swift b/Sources/Shared/Toolkit/PDF/PDFDocument.swift index 132b201576..9f4f2e0659 100644 --- a/Sources/Shared/Toolkit/PDF/PDFDocument.swift +++ b/Sources/Shared/Toolkit/PDF/PDFDocument.swift @@ -7,7 +7,7 @@ import Foundation import UIKit -public enum PDFDocumentError: Error { +public enum PDFDocumentError: Error, Sendable { /// The provided password was incorrect. case invalidPassword /// Impossible to open the given PDF. @@ -67,7 +67,7 @@ public protocol PDFDocumentFactory { func open(resource: Resource, at href: HREF, password: String?) async throws -> PDFDocument } -public class DefaultPDFDocumentFactory: PDFDocumentFactory, Loggable { +public final class DefaultPDFDocumentFactory: PDFDocumentFactory, Loggable, Sendable { private let factory = PDFKitPDFDocumentFactory() public init() {} @@ -83,7 +83,7 @@ public class DefaultPDFDocumentFactory: PDFDocumentFactory, Loggable { /// A PDF document factory which will iterate over a list of factories until one works. @available(*, deprecated, message: "Not used anymore") -public class CompositePDFDocumentFactory: PDFDocumentFactory, Loggable { +public final class CompositePDFDocumentFactory: PDFDocumentFactory, Loggable { private let factories: [PDFDocumentFactory] public init(factories: [PDFDocumentFactory]) { diff --git a/Sources/Shared/Toolkit/PDF/PDFKit.swift b/Sources/Shared/Toolkit/PDF/PDFKit.swift index 47c8cb18f2..bc5019de2f 100644 --- a/Sources/Shared/Toolkit/PDF/PDFKit.swift +++ b/Sources/Shared/Toolkit/PDF/PDFKit.swift @@ -70,7 +70,7 @@ extension PDFKit.PDFDocument: PDFDocumentTextProviding { } /// Creates a `PDFDocument` using PDFKit. -public class PDFKitPDFDocumentFactory: PDFDocumentFactory { +public final class PDFKitPDFDocumentFactory: PDFDocumentFactory, Sendable { public init() {} public func open(file: FileURL, password: String?) async throws -> PDFDocument { diff --git a/Sources/Shared/Toolkit/PDF/PDFOutlineNode.swift b/Sources/Shared/Toolkit/PDF/PDFOutlineNode.swift index 72fa1f658c..3bc4a07bcf 100644 --- a/Sources/Shared/Toolkit/PDF/PDFOutlineNode.swift +++ b/Sources/Shared/Toolkit/PDF/PDFOutlineNode.swift @@ -6,7 +6,7 @@ import Foundation -public struct PDFOutlineNode { +public struct PDFOutlineNode: Sendable { /// Title of this outline item. public let title: String? diff --git a/Sources/Shared/Toolkit/Tokenizer/TextTokenizer.swift b/Sources/Shared/Toolkit/Tokenizer/TextTokenizer.swift index efd7cc9645..da9557b719 100644 --- a/Sources/Shared/Toolkit/Tokenizer/TextTokenizer.swift +++ b/Sources/Shared/Toolkit/Tokenizer/TextTokenizer.swift @@ -11,11 +11,11 @@ import NaturalLanguage public typealias TextTokenizer = Tokenizer> /// A text token unit which can be used with a `TextTokenizer`. -public enum TextUnit { +public enum TextUnit: Sendable { case word, sentence, paragraph } -public enum TextTokenizerError: Error { +public enum TextTokenizerError: Error, Sendable { case rangeConversionFailed(range: NSRange, string: String) } diff --git a/Sources/Shared/Toolkit/URL/AnyURL.swift b/Sources/Shared/Toolkit/URL/AnyURL.swift index 3f5ea72c48..8532e4457c 100644 --- a/Sources/Shared/Toolkit/URL/AnyURL.swift +++ b/Sources/Shared/Toolkit/URL/AnyURL.swift @@ -10,7 +10,7 @@ import ReadiumInternal /// Represents either an absolute or relative URL. /// /// See https://url.spec.whatwg.org -public enum AnyURL: URLProtocol { +public enum AnyURL: URLProtocol, Sendable { /// An absolute URL. case absolute(AbsoluteURL) diff --git a/Sources/Shared/Toolkit/URL/RelativeURL.swift b/Sources/Shared/Toolkit/URL/RelativeURL.swift index e306c93c5b..1c047697dd 100644 --- a/Sources/Shared/Toolkit/URL/RelativeURL.swift +++ b/Sources/Shared/Toolkit/URL/RelativeURL.swift @@ -7,7 +7,7 @@ import Foundation /// Represents a relative URL. -public struct RelativeURL: URLProtocol, Hashable { +public struct RelativeURL: URLProtocol, Hashable, Sendable { public let url: URL /// Creates a ``RelativeURL`` from a standard Swift `URL`. diff --git a/Sources/Shared/Toolkit/URL/URITemplate.swift b/Sources/Shared/Toolkit/URL/URITemplate.swift index 2ac1b62e01..d73d9da81a 100644 --- a/Sources/Shared/Toolkit/URL/URITemplate.swift +++ b/Sources/Shared/Toolkit/URL/URITemplate.swift @@ -11,7 +11,7 @@ import ReadiumInternal /// /// Only handles simple cases, fitting Readium's use cases. /// See https://tools.ietf.org/html/rfc6570 -public struct URITemplate: CustomStringConvertible { +public struct URITemplate: CustomStringConvertible, Sendable { public let uri: String public init(_ uri: String) { diff --git a/Sources/Shared/Toolkit/URL/URLQuery.swift b/Sources/Shared/Toolkit/URL/URLQuery.swift index ef02bd5514..f477e197d1 100644 --- a/Sources/Shared/Toolkit/URL/URLQuery.swift +++ b/Sources/Shared/Toolkit/URL/URLQuery.swift @@ -7,9 +7,9 @@ import Foundation /// Represents a list of query parameters in a URL. -public struct URLQuery: Hashable { +public struct URLQuery: Hashable, Sendable { /// Represents a single query parameter and its value in a URL. - public struct Parameter: Hashable { + public struct Parameter: Hashable, Sendable { public let name: String public let value: String? } diff --git a/Sources/Shared/Toolkit/Weak.swift b/Sources/Shared/Toolkit/Weak.swift index 87d5970b9a..5e563f0a18 100644 --- a/Sources/Shared/Toolkit/Weak.swift +++ b/Sources/Shared/Toolkit/Weak.swift @@ -11,9 +11,8 @@ import Foundation /// Get the reference by calling `weakVar()`. /// Conveniently, the reference can be reset by setting the `ref` property. @dynamicCallable -public class Weak { - /// Weakly held reference. - public weak var ref: T? +public class Weak: @unchecked Sendable { + public package(set) weak var ref: T? public init(_ ref: T? = nil) { self.ref = ref @@ -23,22 +22,3 @@ public class Weak { ref } } - -/// Smart pointer passing as a Weak reference but preventing the reference from being lost. -/// Mainly useful for the unit test suite. -public class _Strong: Weak { - private var strongRef: T? - - override public var ref: T? { - get { super.ref } - set { - super.ref = newValue - strongRef = newValue - } - } - - override public init(_ ref: T? = nil) { - strongRef = ref - super.init(ref) - } -} diff --git a/Sources/Shared/Toolkit/XML/XML.swift b/Sources/Shared/Toolkit/XML/XML.swift index 25dd4fe184..2c74f83928 100644 --- a/Sources/Shared/Toolkit/XML/XML.swift +++ b/Sources/Shared/Toolkit/XML/XML.swift @@ -6,7 +6,7 @@ import Foundation -public struct XMLNamespace { +public struct XMLNamespace: Sendable { public let prefix: String public let uri: String @@ -70,7 +70,7 @@ public protocol XMLElement: XMLNode { func attribute(named localName: String, namespace: String?) -> String? } -public protocol XMLDocumentFactory { +public protocol XMLDocumentFactory: Sendable { /// Opens an XML document from a local file path. /// /// - Parameters: @@ -96,7 +96,7 @@ public protocol XMLDocumentFactory { func open(string: String, namespaces: [XMLNamespace]) throws -> XMLDocument } -public class DefaultXMLDocumentFactory: XMLDocumentFactory, Loggable { +public final class DefaultXMLDocumentFactory: XMLDocumentFactory, Loggable, Sendable { public init() {} public func open(file: FileURL, namespaces: [XMLNamespace]) async throws -> XMLDocument { diff --git a/Sources/Shared/Toolkit/ZIP/Minizip/MinizipArchiveOpener.swift b/Sources/Shared/Toolkit/ZIP/Minizip/MinizipArchiveOpener.swift index 4312b1008a..145a1c1412 100644 --- a/Sources/Shared/Toolkit/ZIP/Minizip/MinizipArchiveOpener.swift +++ b/Sources/Shared/Toolkit/ZIP/Minizip/MinizipArchiveOpener.swift @@ -12,7 +12,7 @@ import Foundation /// - Does not support HTTP streaming of ZIP archives. /// - Has better performance when reading an LCP-protected package containing /// large deflated ZIP entries (instead of stored). -public final class MinizipArchiveOpener: ArchiveOpener { +public final class MinizipArchiveOpener: ArchiveOpener, Sendable { public init() {} public func open(resource: any Resource, format: Format) async -> Result { diff --git a/Sources/Shared/Toolkit/ZIP/ZIPArchiveOpener.swift b/Sources/Shared/Toolkit/ZIP/ZIPArchiveOpener.swift index 1b2476637f..97224051a1 100644 --- a/Sources/Shared/Toolkit/ZIP/ZIPArchiveOpener.swift +++ b/Sources/Shared/Toolkit/ZIP/ZIPArchiveOpener.swift @@ -7,7 +7,7 @@ import Foundation /// An ``ArchiveOpener`` for ZIP resources. -public class ZIPArchiveOpener: CompositeArchiveOpener { +public final class ZIPArchiveOpener: CompositeArchiveOpener { public init() { super.init([ MinizipArchiveOpener(), diff --git a/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveOpener.swift b/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveOpener.swift index 1c09e67e55..3d320cdf40 100644 --- a/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveOpener.swift +++ b/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveOpener.swift @@ -7,7 +7,7 @@ import Foundation /// An ``ArchiveOpener`` able to open ZIP archives using ZIPFoundation. -public final class ZIPFoundationArchiveOpener: ArchiveOpener { +public final class ZIPFoundationArchiveOpener: ArchiveOpener, Sendable { public init() {} public func open(resource: any Resource, format: Format) async -> Result { diff --git a/Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift b/Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift index 24c25be8b1..e1e2d8457f 100644 --- a/Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift +++ b/Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift @@ -28,7 +28,7 @@ public struct AudioPublicationAugmentedManifest { /// An `AudioPublicationManifestAugmentor` using AVFoundation to retrieve the audio metadata. /// /// It will only work for local publications (file://). -public final class AVAudioPublicationManifestAugmentor: AudioPublicationManifestAugmentor { +public final class AVAudioPublicationManifestAugmentor: AudioPublicationManifestAugmentor, Sendable { public init() {} public func augment(_ manifest: Manifest, using container: Container) async -> AudioPublicationAugmentedManifest { diff --git a/Sources/Streamer/Parser/EPUB/EPUBParser.swift b/Sources/Streamer/Parser/EPUB/EPUBParser.swift index 4d53e72a71..a8085e68bf 100644 --- a/Sources/Streamer/Parser/EPUB/EPUBParser.swift +++ b/Sources/Streamer/Parser/EPUB/EPUBParser.swift @@ -15,7 +15,7 @@ import ReadiumShared /// - missingFile: A file is missing from the container at `path`. /// - xmlParse: An XML parsing error occurred. /// - missingElement: An XML element is missing. -public enum EPUBParserError: Error { +public enum EPUBParserError: Error, Sendable { /// The mimetype of the EPUB is not valid. case wrongMimeType case missingFile(path: String) @@ -28,7 +28,7 @@ extension EPUBParser: Loggable {} /// An EPUB container parser that extracts the information from the relevant /// files and builds a `Publication` instance out of it. -public final class EPUBParser: PublicationParser { +public final class EPUBParser: PublicationParser, Sendable { private let reflowablePositionsStrategy: EPUBPositionsService.ReflowableStrategy /// - Parameter reflowablePositionsStrategy: Strategy used to calculate the number of positions in a reflowable resource. diff --git a/Sources/Streamer/Parser/EPUB/OPFParser.swift b/Sources/Streamer/Parser/EPUB/OPFParser.swift index 2beb9340ae..62c5406b54 100644 --- a/Sources/Streamer/Parser/EPUB/OPFParser.swift +++ b/Sources/Streamer/Parser/EPUB/OPFParser.swift @@ -10,7 +10,7 @@ import ReadiumShared /// http://www.idpf.org/epub/30/spec/epub30-publications.html#title-type /// the six basic values of the "title-type" property specified by EPUB 3: -public enum EPUBTitleType: String { +public enum EPUBTitleType: String, Sendable { case main case subtitle case short diff --git a/Sources/Streamer/Parser/EPUB/Resource Transformers/EPUBDeobfuscator.swift b/Sources/Streamer/Parser/EPUB/Resource Transformers/EPUBDeobfuscator.swift index 7b9fbfe95d..b8b20469a8 100644 --- a/Sources/Streamer/Parser/EPUB/Resource Transformers/EPUBDeobfuscator.swift +++ b/Sources/Streamer/Parser/EPUB/Resource Transformers/EPUBDeobfuscator.swift @@ -94,7 +94,7 @@ final class EPUBDeobfuscator { } } -private protocol ObfuscationAlgorithm { +private protocol ObfuscationAlgorithm: Sendable { /// URI identifier for this algorithm. var identifier: String { get } diff --git a/Sources/Streamer/Parser/EPUB/SMIL/SMILParser.swift b/Sources/Streamer/Parser/EPUB/SMIL/SMILParser.swift index fabe4dc518..3947532226 100644 --- a/Sources/Streamer/Parser/EPUB/SMIL/SMILParser.swift +++ b/Sources/Streamer/Parser/EPUB/SMIL/SMILParser.swift @@ -311,7 +311,7 @@ private struct SMILGuidedNavigationDocumentParsing { /// Warning raised when parsing a model object from its SMIL representation /// fails. -public struct SMILWarning: Warning { +public struct SMILWarning: Warning, Sendable { /// Type of the model object to be parsed. public let modelType: Any.Type /// Details about the failure. diff --git a/Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift b/Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift index 26a81e59ab..bfbf5d9b2c 100644 --- a/Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift +++ b/Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift @@ -30,7 +30,7 @@ public actor EPUBPositionsService: PositionsService { /// Strategy used to calculate the number of positions in a reflowable resource. /// /// Note that a fixed-layout resource always has a single position. - public enum ReflowableStrategy { + public enum ReflowableStrategy: Sendable { /// Use the archive entry length (whether it is compressed or stored) and split it by the given `pageLength`. case archiveEntryLength(pageLength: Int) diff --git a/Sources/Streamer/Parser/PDF/PDFParser.swift b/Sources/Streamer/Parser/PDF/PDFParser.swift index 1d65c2dd1e..6110db2ee3 100644 --- a/Sources/Streamer/Parser/PDF/PDFParser.swift +++ b/Sources/Streamer/Parser/PDF/PDFParser.swift @@ -9,7 +9,7 @@ import Foundation import ReadiumShared /// Errors thrown during the parsing of the PDF. -public enum PDFParserError: Error { +public enum PDFParserError: Error, Sendable { /// The file at 'path' is missing from the container. case missingFile(path: String) /// Failed to open the PDF diff --git a/Sources/Streamer/Parser/PublicationParser.swift b/Sources/Streamer/Parser/PublicationParser.swift index 3f2685cbc1..cbf6840b6e 100644 --- a/Sources/Streamer/Parser/PublicationParser.swift +++ b/Sources/Streamer/Parser/PublicationParser.swift @@ -20,7 +20,7 @@ public protocol PublicationParser { func parse(asset: Asset, warnings: WarningLogger?) async -> Result } -public enum PublicationParseError: Error { +public enum PublicationParseError: Error, Sendable { /// Asset format not supported. case formatNotSupported diff --git a/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift b/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift index 7f70ca1a54..062a9c918a 100644 --- a/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift +++ b/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift @@ -7,14 +7,14 @@ import Foundation import ReadiumShared -public enum ReadiumWebPubParserError: Error { +public enum ReadiumWebPubParserError: Error, Sendable { case parseFailure(url: URL, Error?) case missingFile(path: String) } /// Parser for a Readium Web Publication (packaged, or as a manifest). -public class ReadiumWebPubParser: PublicationParser, Loggable { - public enum Error: Swift.Error { +public final class ReadiumWebPubParser: PublicationParser, Loggable { + public enum Error: Swift.Error, Sendable { case manifestNotFound case invalidManifest } @@ -193,7 +193,7 @@ private extension ReadResult { } /// Warning raised when parsing a RWPM. -public struct RWPMWarning: Warning { +public struct RWPMWarning: Warning, Sendable { public let message: String public let severity: WarningSeverityLevel diff --git a/Sources/Streamer/PublicationOpener.swift b/Sources/Streamer/PublicationOpener.swift index cf2be56964..b8b293a9ac 100644 --- a/Sources/Streamer/PublicationOpener.swift +++ b/Sources/Streamer/PublicationOpener.swift @@ -15,7 +15,7 @@ import ReadiumShared /// - onCreatePublication: Called on every parsed `Publication.Builder`. It /// can be used to modify the manifest, the root container or the list of /// service factories of a `Publication`. -public class PublicationOpener { +public final class PublicationOpener { private let parser: PublicationParser private let contentProtections: [ContentProtection] private let onCreatePublication: Publication.Builder.Transform @@ -108,7 +108,7 @@ public class PublicationOpener { } } -public enum PublicationOpenError: Error { +public enum PublicationOpenError: Error, Sendable { /// The asset is not supported by the publication parser. case formatNotSupported diff --git a/Sources/Streamer/Toolkit/DataCompression.swift b/Sources/Streamer/Toolkit/DataCompression.swift index e6b93a33ab..de4b1bbf61 100644 --- a/Sources/Streamer/Toolkit/DataCompression.swift +++ b/Sources/Streamer/Toolkit/DataCompression.swift @@ -260,7 +260,7 @@ public extension Data { } /// Struct based type representing a Crc32 checksum. -public struct Crc32: CustomStringConvertible { +public struct Crc32: CustomStringConvertible, Sendable { private static let zLibCrc32: ZLibCrc32FuncPtr? = loadCrc32fromZLib() public init() {} @@ -349,7 +349,7 @@ public struct Crc32: CustomStringConvertible { } /// Struct based type representing a Adler32 checksum. -public struct Adler32: CustomStringConvertible { +public struct Adler32: CustomStringConvertible, Sendable { private static let zLibAdler32: ZLibAdler32FuncPtr? = loadAdler32fromZLib() public init() {} diff --git a/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedViewModel.swift b/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedViewModel.swift index df028f214c..6ffacc67b3 100644 --- a/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedViewModel.swift +++ b/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedViewModel.swift @@ -142,7 +142,7 @@ class OPDSFeedViewModel: ObservableObject { } // Create the group and assign publications - let pubGroup = ReadiumShared.Group(title: title) + var pubGroup = ReadiumShared.Group(title: title) pubGroup.publications = feed.publications return pubGroup } diff --git a/Tests/SharedTests/Publication/Services/Locator/DefaultLocatorServiceTests.swift b/Tests/SharedTests/Publication/Services/Locator/DefaultLocatorServiceTests.swift index f7a7a19883..2c3818988b 100644 --- a/Tests/SharedTests/Publication/Services/Locator/DefaultLocatorServiceTests.swift +++ b/Tests/SharedTests/Publication/Services/Locator/DefaultLocatorServiceTests.swift @@ -10,7 +10,7 @@ import XCTest class DefaultLocatorServiceTests: XCTestCase { /// locate(Locator) checks that the href exists. func testFromLocator() async { - let service = makeService(readingOrder: [ + let (publication, service) = makeService(readingOrder: [ Link(href: "chap1", mediaType: .xml), Link(href: "chap2", mediaType: .xml), Link(href: "chap3", mediaType: .xml), @@ -21,13 +21,13 @@ class DefaultLocatorServiceTests: XCTestCase { } func testFromLocatorEmptyReadingOrder() async { - let service = makeService(readingOrder: []) + let (publication, service) = makeService(readingOrder: []) let result = await service.locate(Locator(href: "href", mediaType: .html)) XCTAssertNil(result) } func testFromLocatorNotFound() async { - let service = makeService(readingOrder: [ + let (publication, service) = makeService(readingOrder: [ Link(href: "chap1", mediaType: .xml), Link(href: "chap3", mediaType: .xml), ]) @@ -37,7 +37,7 @@ class DefaultLocatorServiceTests: XCTestCase { } func testFromProgression() async { - let service = makeService(positions: positionsFixture) + let (publication, service) = makeService(positions: positionsFixture) var result = await service.locate(progression: 0.0) XCTAssertEqual(result, Locator( @@ -111,7 +111,7 @@ class DefaultLocatorServiceTests: XCTestCase { } func testFromIncorrectProgression() async { - let service = makeService(positions: positionsFixture) + let (publication, service) = makeService(positions: positionsFixture) var result = await service.locate(progression: -0.2) XCTAssertNil(result) @@ -121,13 +121,13 @@ class DefaultLocatorServiceTests: XCTestCase { } func testFromProgressionEmptyPositions() async { - let service = makeService(positions: []) + let (publication, service) = makeService(positions: []) let result = await service.locate(progression: 0.5) XCTAssertNil(result) } func testFromMinimalLink() async { - let service = makeService(readingOrder: [ + let (publication, service) = makeService(readingOrder: [ Link(href: "/href", mediaType: .html, title: "Resource"), ]) @@ -139,7 +139,7 @@ class DefaultLocatorServiceTests: XCTestCase { } func testFromLinkInReadingOrderResourcesOrLinks() async { - let service = makeService( + let (publication, service) = makeService( links: [Link(href: "/href3", mediaType: .html)], readingOrder: [Link(href: "/href1", mediaType: .html)], resources: [Link(href: "/href2", mediaType: .html)] @@ -165,7 +165,7 @@ class DefaultLocatorServiceTests: XCTestCase { } func testFromLinkWithFragment() async throws { - let service = makeService(readingOrder: [ + let (publication, service) = makeService(readingOrder: [ Link(href: "/href", mediaType: .html, title: "Resource"), ]) @@ -177,7 +177,7 @@ class DefaultLocatorServiceTests: XCTestCase { } func testTitleFallbackFromLink() async { - let service = makeService(readingOrder: [ + let (publication, service) = makeService(readingOrder: [ Link(href: "/href", mediaType: .html), ]) @@ -189,7 +189,7 @@ class DefaultLocatorServiceTests: XCTestCase { } func testFromLinkNotFound() async { - let service = makeService(readingOrder: [ + let (publication, service) = makeService(readingOrder: [ Link(href: "/href", mediaType: .html), ]) @@ -202,8 +202,8 @@ class DefaultLocatorServiceTests: XCTestCase { readingOrder: [Link] = [], resources: [Link] = [], positions: [[Locator]] = [] - ) -> DefaultLocatorService { - DefaultLocatorService(publication: _Strong(Publication( + ) -> (Publication, DefaultLocatorService) { + let publication = Publication( manifest: Manifest( metadata: Metadata(title: ""), links: links, @@ -213,7 +213,9 @@ class DefaultLocatorServiceTests: XCTestCase { servicesBuilder: PublicationServicesBuilder( positions: InMemoryPositionsService.makeFactory(positionsByReadingOrder: positions) ) - ))) + ) + let service = DefaultLocatorService(publication: Weak(publication)) + return (publication, service) } } diff --git a/Tests/StreamerTests/Parser/Audio/Services/AudioLocatorServiceTests.swift b/Tests/StreamerTests/Parser/Audio/Services/AudioLocatorServiceTests.swift index aba1465b6a..671520b545 100644 --- a/Tests/StreamerTests/Parser/Audio/Services/AudioLocatorServiceTests.swift +++ b/Tests/StreamerTests/Parser/Audio/Services/AudioLocatorServiceTests.swift @@ -11,7 +11,7 @@ import XCTest class AudioLocatorServiceTests: XCTestCase { func testLocateLocatorMatchingReadingOrderHREF() async { - let service = makeService(readingOrder: [ + let (publication, service) = makeService(readingOrder: [ Link(href: "l1"), Link(href: "l2"), ]) @@ -22,7 +22,7 @@ class AudioLocatorServiceTests: XCTestCase { } func testLocateLocatorReturnsNilIfNoMatch() async { - let service = makeService(readingOrder: [ + let (publication, service) = makeService(readingOrder: [ Link(href: "l1"), Link(href: "l2"), ]) @@ -33,7 +33,7 @@ class AudioLocatorServiceTests: XCTestCase { } func testLocateLocatorUsesTotalProgression() async { - let service = makeService(readingOrder: [ + let (publication, service) = makeService(readingOrder: [ Link(href: "l1", mediaType: .mp3, duration: 100), Link(href: "l2", mediaType: .mp3, duration: 100), ]) @@ -70,7 +70,7 @@ class AudioLocatorServiceTests: XCTestCase { } func testLocateLocatorUsingTotalProgressionKeepsTitleAndText() async throws { - let service = makeService(readingOrder: [ + let (publication, service) = makeService(readingOrder: [ Link(href: "l1", mediaType: .mp3, duration: 100), Link(href: "l2", mediaType: .mp3, duration: 100), ]) @@ -108,7 +108,7 @@ class AudioLocatorServiceTests: XCTestCase { } func testLocateProgression() async { - let service = makeService(readingOrder: [ + let (publication, service) = makeService(readingOrder: [ Link(href: "l1", mediaType: .mp3, duration: 100), Link(href: "l2", mediaType: .mp3, duration: 100), ]) @@ -165,7 +165,7 @@ class AudioLocatorServiceTests: XCTestCase { } func testLocateInvalidProgression() async { - let service = makeService(readingOrder: [ + let (publication, service) = makeService(readingOrder: [ Link(href: "l1", mediaType: .mp3, duration: 100), Link(href: "l2", mediaType: .mp3, duration: 100), ]) @@ -177,11 +177,11 @@ class AudioLocatorServiceTests: XCTestCase { XCTAssertNil(result) } - private func makeService(readingOrder: [Link]) -> AudioLocatorService { - AudioLocatorService( - publication: _Strong(Publication( - manifest: Manifest(metadata: Metadata(title: ""), readingOrder: readingOrder) - )) + private func makeService(readingOrder: [Link]) -> (Publication, AudioLocatorService) { + let publication = Publication( + manifest: Manifest(metadata: Metadata(title: ""), readingOrder: readingOrder) ) + let service = AudioLocatorService(publication: Weak(publication)) + return (publication, service) } } diff --git a/docs/Migration Guide.md b/docs/Migration Guide.md index 01c6017f27..9c2466ea28 100644 --- a/docs/Migration Guide.md +++ b/docs/Migration Guide.md @@ -75,7 +75,6 @@ The free functions `serializeJSONString` and `serializeJSONData` have been repla +let data = locator.jsonData() ``` - ## 3.8.0 ### Removing the HTTP Server from the EPUB Navigator From d93d29b713feba478fb533b8c82acc07a42fee31 Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Thu, 21 May 2026 09:42:31 -0500 Subject: [PATCH 06/39] Refactor HTTP client (#764) --- .../Adapters/GCDWebServer/GCDHTTPServer.swift | 14 +- Sources/LCP/License/License.swift | 6 +- Sources/LCP/License/LicenseValidation.swift | 4 +- Sources/LCP/Services/CRLService.swift | 4 +- .../PDFResourceContentIterator.swift | 2 +- .../Resource/ResourceContentExtractor.swift | 2 +- .../Toolkit/HTTP/DefaultHTTPClient.swift | 722 +++++----- Sources/Shared/Toolkit/HTTP/HTTPClient.swift | 229 +-- Sources/Shared/Toolkit/HTTP/HTTPError.swift | 50 +- Sources/Shared/Toolkit/HTTP/HTTPRequest.swift | 32 +- .../Shared/Toolkit/HTTP/HTTPResource.swift | 35 +- Sources/Shared/Toolkit/HTTP/HTTPServer.swift | 9 +- Sources/Shared/Toolkit/Mutex.swift | 71 + Sources/Shared/Toolkit/PDF/CGPDF.swift | 2 +- .../Toolkit/URL/Absolute URL/FileURL.swift | 2 +- .../Toolkit/URL/Absolute URL/HTTPURL.swift | 2 +- .../URL/Absolute URL/UnknownAbsoluteURL.swift | 2 +- Sources/Shared/Toolkit/URL/AnyURL.swift | 2 +- Sources/Shared/Toolkit/URL/RelativeURL.swift | 2 +- TestApp/Sources/OPDS/OPDSModule.swift | 2 +- Tests/SharedTests/Capture.swift | 19 + .../Locator/DefaultLocatorServiceTests.swift | 11 + .../Toolkit/HTTP/DefaultHTTPClientTests.swift | 1266 +++++++++++++++++ .../HTTP/HTTPProblemDetailsTests.swift | 58 +- .../Toolkit/HTTP/HTTPRequestTests.swift | 54 + .../Toolkit/HTTP/HTTPResourceTests.swift | 105 ++ .../Toolkit/HTTP/HTTPResponseTests.swift | 67 + .../Toolkit/HTTP/MockURLProtocol.swift | 261 ++++ .../Services/AudioLocatorServiceTests.swift | 6 + 29 files changed, 2520 insertions(+), 521 deletions(-) create mode 100644 Sources/Shared/Toolkit/Mutex.swift create mode 100644 Tests/SharedTests/Capture.swift create mode 100644 Tests/SharedTests/Toolkit/HTTP/DefaultHTTPClientTests.swift create mode 100644 Tests/SharedTests/Toolkit/HTTP/HTTPRequestTests.swift create mode 100644 Tests/SharedTests/Toolkit/HTTP/HTTPResourceTests.swift create mode 100644 Tests/SharedTests/Toolkit/HTTP/HTTPResponseTests.swift create mode 100644 Tests/SharedTests/Toolkit/HTTP/MockURLProtocol.swift diff --git a/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift b/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift index b77b1b41c8..09caafd2c9 100644 --- a/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift +++ b/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift @@ -129,7 +129,7 @@ public final class GCDHTTPServer: HTTPServer, Loggable { for request: ReadiumGCDWebServerRequest, completion: @escaping (HTTPServerRequest, HTTPServerResponse, HTTPRequestHandler.OnFailure?) -> Void ) { - let completion = { request, resource, failureHandler in + let dispatchCompletion = { (request: HTTPServerRequest, resource: HTTPServerResponse, failureHandler: HTTPRequestHandler.OnFailure?) in // Escape the queue to avoid deadlocks if something is using the // server in the handler. DispatchQueue.global().async { @@ -168,20 +168,16 @@ public final class GCDHTTPServer: HTTPServer, Loggable { var response = handler.onRequest(request) response.resource = transform(resource: response.resource, request: request, at: endpoint) - completion(request, response, handler.onFailure) + dispatchCompletion(request, response, handler.onFailure) return } log(.warning, "Resource not found for request \(request)") - completion( + dispatchCompletion( HTTPServerRequest(url: url, href: nil), - HTTPServerResponse(error: .errorResponse(HTTPResponse( - request: HTTPRequest(url: url), - url: url, + HTTPServerResponse(error: .errorResponse(HTTPErrorResponse( status: .notFound, - headers: [:], - mediaType: nil, - body: nil + body: Data() ))), nil ) diff --git a/Sources/LCP/License/License.swift b/Sources/LCP/License/License.swift index 662a4dab08..0cd02a8848 100644 --- a/Sources/LCP/License/License.swift +++ b/Sources/LCP/License/License.swift @@ -227,7 +227,7 @@ extension License: LCPLicense { // done, in case it changed the License. return try await httpClient .fetch(HTTPRequest(url: statusURL, headers: ["Accept": MediaType.lcpStatusDocument.string])) - .map { $0.body ?? Data() } + .map(\.body) .get() } @@ -257,7 +257,7 @@ extension License: LCPLicense { let url = try await makeRenewURL(from: preferredEndDate()) return try await httpClient.fetch(HTTPRequest(url: url, method: .put)) - .map { $0.body ?? Data() } + .map(\.body) .mapError { error -> RenewError in switch error { case let .errorResponse(response): @@ -318,7 +318,7 @@ extension License: LCPLicense { return .unexpectedServerError(error) } } - .map { $0.body ?? Data() } + .map(\.body) .get() try await validateStatusDocument(data: data) diff --git a/Sources/LCP/License/LicenseValidation.swift b/Sources/LCP/License/LicenseValidation.swift index b4cd49b213..a1079a563d 100644 --- a/Sources/LCP/License/LicenseValidation.swift +++ b/Sources/LCP/License/LicenseValidation.swift @@ -283,7 +283,7 @@ extension LicenseValidation { // Short timeout to avoid blocking the License, since the LSD is optional. timeoutInterval: 5 )) - .map { $0.body ?? Data() } + .map(\.body) .get() try await raise(.retrievedStatusData(data)) @@ -300,7 +300,7 @@ extension LicenseValidation { let data = try await httpClient // Short timeout to avoid blocking the License, since it can be updated next time. .fetch(HTTPRequest(url: url, timeoutInterval: 5)) - .map { $0.body ?? Data() } + .map(\.body) .get() try await raise(.retrievedLicenseData(data)) diff --git a/Sources/LCP/Services/CRLService.swift b/Sources/LCP/Services/CRLService.swift index 043a9413ed..aa1766cf40 100644 --- a/Sources/LCP/Services/CRLService.swift +++ b/Sources/LCP/Services/CRLService.swift @@ -53,9 +53,11 @@ final class CRLService { .mapError { _ in LCPError.crlFetching } .get() - guard let body = response.body?.base64EncodedString() else { + guard !response.body.isEmpty else { throw LCPError.crlFetching } + + let body = response.body.base64EncodedString() return "-----BEGIN X509 CRL-----\(body)-----END X509 CRL-----" } diff --git a/Sources/Shared/Publication/Services/Content/Iterators/PDFResourceContentIterator.swift b/Sources/Shared/Publication/Services/Content/Iterators/PDFResourceContentIterator.swift index ce922bb99b..64da7bb16d 100644 --- a/Sources/Shared/Publication/Services/Content/Iterators/PDFResourceContentIterator.swift +++ b/Sources/Shared/Publication/Services/Content/Iterators/PDFResourceContentIterator.swift @@ -26,7 +26,7 @@ public enum PDFResourceContentIteratorError: Error { /// ``PDFDocumentService``. public class PDFResourceContentIterator: ContentIterator, Loggable { /// Factory for a `PDFResourceContentIterator`. - public class Factory: ResourceContentIteratorFactory { + public final class Factory: ResourceContentIteratorFactory { public init() {} public func make( diff --git a/Sources/Shared/Toolkit/Data/Resource/ResourceContentExtractor.swift b/Sources/Shared/Toolkit/Data/Resource/ResourceContentExtractor.swift index e504e57334..6e7abd1fa1 100644 --- a/Sources/Shared/Toolkit/Data/Resource/ResourceContentExtractor.swift +++ b/Sources/Shared/Toolkit/Data/Resource/ResourceContentExtractor.swift @@ -27,7 +27,7 @@ public protocol ResourceContentExtractorFactory: Sendable { public typealias _ResourceContentExtractorFactory = ResourceContentExtractorFactory /// Default `ResourceContentExtractorFactory` supporting HTML resources. -public class DefaultResourceContentExtractorFactory: ResourceContentExtractorFactory, Sendable { +public final class DefaultResourceContentExtractorFactory: ResourceContentExtractorFactory, Sendable { public init() {} public func makeExtractor(for resource: Resource, mediaType: MediaType) -> ResourceContentExtractor? { diff --git a/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift b/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift index 0ba7fb60fc..e3b10b1aa9 100644 --- a/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift +++ b/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift @@ -5,57 +5,79 @@ // import Foundation -import UIKit public enum URLAuthenticationChallengeResponse: Sendable { /// Use the specified credential. case useCredential(URLCredential) - /// Use the default handling for the challenge as though this delegate method were not implemented. + /// Use the default handling for the challenge as though this delegate + /// method were not implemented. case performDefaultHandling /// Cancel the entire request. case cancelAuthenticationChallenge - /// Reject this challenge, and call the authentication delegate method again with the next - /// authentication protection space. + /// Reject this challenge, and call the authentication delegate method again + /// with the next authentication protection space. case rejectProtectionSpace } /// Delegate protocol for `DefaultHTTPClient`. -public protocol DefaultHTTPClientDelegate: AnyObject { +public protocol DefaultHTTPClientDelegate: AnyObject, Sendable { /// Tells the delegate that the HTTP client will start a new `request`. /// - /// Warning: You MUST call the `completion` handler with the request to start, otherwise the client will hang. + /// You can modify the `request`, for example by adding additional HTTP + /// headers or redirecting to a different URL, before returning the new + /// request. /// - /// You can modify the `request`, for example by adding additional HTTP headers or redirecting to a different URL, - /// before calling the `completion` handler with the new request. - func httpClient(_ httpClient: DefaultHTTPClient, willStartRequest request: HTTPRequest) async -> HTTPResult + /// - Note: If this method returns a failure, the request is aborted + /// immediately and `httpClient(_:request:didFailWithError:)` is NOT called. + func httpClient( + _ httpClient: DefaultHTTPClient, + willStartRequest request: HTTPRequest + ) async -> HTTPResult - /// Asks the delegate to recover from an `error` received for the given `request`. + /// Asks the delegate to recover from an `error` received for the given + /// `request`. /// /// This can be used to implement custom authentication flows, for example. /// - /// You can call the `completion` handler with either: - /// * a new request to start - /// * the `error` argument, if you cannot recover from it - /// * a new `HTTPError` to provide additional information - func httpClient(_ httpClient: DefaultHTTPClient, recoverRequest request: HTTPRequest, fromError error: HTTPError) async -> HTTPResult + /// You can return either: + /// - a new request to start + /// - the `error` argument, if you cannot recover from it + /// - a new `HTTPError` to provide additional information + func httpClient( + _ httpClient: DefaultHTTPClient, + recoverRequest request: HTTPRequest, + fromError error: HTTPError + ) async -> HTTPResult - /// Tells the delegate that we received an HTTP response for the given `request`. + /// Tells the delegate that we received an HTTP response for the given + /// `request`. /// - /// You do not need to do anything with this `response`, which the HTTP client will handle. This is merely for - /// informational purposes. For example, you could implement this to confirm that request credentials were - /// successful. - func httpClient(_ httpClient: DefaultHTTPClient, request: HTTPRequest, didReceiveResponse response: HTTPResponse) + /// You do not need to do anything with this `response`, which the HTTP + /// client will handle. This is merely for informational purposes. For + /// example, you could implement this to confirm that request credentials + /// were successful. + func httpClient( + _ httpClient: DefaultHTTPClient, + request: HTTPRequest, + didReceiveResponse response: HTTPResponse + ) /// Tells the delegate that a `request` failed with the given `error`. /// - /// You do not need to do anything with this `response`, which the HTTP client will handle. This is merely for - /// informational purposes. + /// You do not need to do anything with this `response`, which the HTTP + /// client will handle. This is merely for informational purposes. /// - /// This will be called only if `httpClient(_:recoverRequest:fromError:completion:)` is not implemented, or returns - /// an error. - func httpClient(_ httpClient: DefaultHTTPClient, request: HTTPRequest, didFailWithError error: HTTPError) + /// This will be called only if `httpClient(_:recoverRequest:fromError:)` + /// is not implemented, or returns an error. It is also NOT called if + /// `httpClient(_:willStartRequest:)` fails and aborts the request. + func httpClient( + _ httpClient: DefaultHTTPClient, + request: HTTPRequest, + didFailWithError error: HTTPError + ) - /// Requests credentials from the delegate in response to an authentication request from the remote server. + /// Requests credentials from the delegate in response to an authentication + /// request from the remote server. func httpClient( _ httpClient: DefaultHTTPClient, request: HTTPRequest, @@ -84,34 +106,35 @@ public extension DefaultHTTPClientDelegate { } } -/// An implementation of `HTTPClient` using native APIs. -public final class DefaultHTTPClient: HTTPClient, Loggable { +/// An implementation of `HTTPClient` using Apple's `URLSession`. +public final class DefaultHTTPClient: HTTPClient, Loggable, Sendable { /// Returns the default user agent used when issuing requests. /// - /// For example, TestApp/1.3 x86_64 iOS/15.0 CFNetwork/1312 Darwin/20.6.0 - public static var defaultUserAgent: String = { - var sysinfo = utsname() - uname(&sysinfo) - - let darwinVersion = String(bytes: Data(bytes: &sysinfo.release, count: Int(_SYS_NAMELEN)), encoding: .ascii)? - .trimmingCharacters(in: .controlCharacters) - ?? "0" + /// For example, TestApp/1.3 + public static let defaultUserAgent: String? = { + let appInfo = Bundle.main.infoDictionary + guard var userAgent = appInfo?["CFBundleName"] as? String else { + return nil + } + if let appVersion = appInfo?["CFBundleShortVersionString"] as? String { + userAgent.append("/\(appVersion)") + } + return userAgent + }() - let deviceName = String(bytes: Data(bytes: &sysinfo.machine, count: Int(_SYS_NAMELEN)), encoding: .ascii)? - .trimmingCharacters(in: .controlCharacters) - ?? "0" + private struct WeakDelegate: Sendable { + weak var value: (any DefaultHTTPClientDelegate)? + } - let cfNetworkVersion = Bundle(identifier: "com.apple.CFNetwork")? - .infoDictionary?["CFBundleShortVersionString"] as? String - ?? "0" + private let _delegate: Mutex - let appInfo = Bundle.main.infoDictionary - let appName = appInfo?["CFBundleName"] as? String ?? "Unknown App" - let appVersion = appInfo?["CFBundleShortVersionString"] as? String ?? "0" - let device = UIDevice.current + public var delegate: (any DefaultHTTPClientDelegate)? { + get { _delegate.withLock { $0.value } } + set { _delegate.withLock { $0.value = newValue } } + } - return "\(appName)/\(appVersion) \(deviceName) \(device.systemName)/\(device.systemVersion) CFNetwork/\(cfNetworkVersion) Darwin/\(darwinVersion)" - }() + private let session: URLSession + private let userAgent: String? /// Creates a `DefaultHTTPClient` with common configuration settings. /// @@ -119,7 +142,7 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { /// - userAgent: Default user agent issued with requests. /// - cachePolicy: Determines the request caching policy used by HTTP tasks. /// - ephemeral: When true, uses no persistent storage for caches, cookies, or credentials. - /// - additionalHeaders: A dictionary of additional headers to send with requests. For example, `User-Agent`. + /// - additionalHeaders: A dictionary of additional headers to send with requests. /// - requestTimeout: The timeout interval to use when waiting for additional data. /// - resourceTimeout: The maximum amount of time that a resource request should be allowed to take. /// - delegate: An optional delegate to handle common HTTP events. @@ -131,7 +154,7 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { additionalHeaders: [String: String]? = nil, requestTimeout: TimeInterval? = nil, resourceTimeout: TimeInterval? = nil, - delegate: DefaultHTTPClientDelegate? = nil, + delegate: (any DefaultHTTPClientDelegate)? = nil, configure: ((URLSessionConfiguration) -> Void)? = nil ) { let config: URLSessionConfiguration = ephemeral ? .ephemeral : .default @@ -152,31 +175,20 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { self.init(configuration: config, userAgent: userAgent, delegate: delegate) } - public weak var delegate: DefaultHTTPClientDelegate? - - private let tasks: HTTPTaskManager - private let session: URLSession - private let userAgent: String - /// Creates a `DefaultHTTPClient` with a custom configuration. /// /// - Parameters: - /// - configuration: The `URLSessionConfiguration` to use for all requests. + /// - configuration: The `URLSessionConfiguration` used for all requests. /// - userAgent: Default user agent issued with requests. /// - delegate: An optional delegate to handle common HTTP events. public init( configuration: URLSessionConfiguration, userAgent: String? = nil, - delegate: DefaultHTTPClientDelegate? = nil + delegate: (any DefaultHTTPClientDelegate)? = nil ) { - let tasks = HTTPTaskManager() - self.userAgent = userAgent ?? DefaultHTTPClient.defaultUserAgent - self.delegate = delegate - self.tasks = tasks - // Note that URLSession keeps a strong reference to its delegate, so we - // don't use the DefaultHTTPClient itself as its delegate. - session = URLSession(configuration: configuration, delegate: tasks, delegateQueue: nil) + _delegate = Mutex(WeakDelegate(value: delegate)) + session = URLSession(configuration: configuration, delegate: nil, delegateQueue: nil) } deinit { @@ -184,380 +196,378 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { } public func stream( - request: any HTTPRequestConvertible, - consume: @escaping (Data, Double?) -> HTTPResult + _ request: any HTTPRequestConvertible, + onReceiveResponse: (@Sendable (HTTPResponse) async -> HTTPResult)? = nil, + consume: @Sendable (Data, Double?) -> HTTPResult ) async -> HTTPResult { await request.httpRequest() .asyncFlatMap(willStartRequest) .asyncFlatMap { request in - await startTask(for: request, consume: consume) + let result = await startTask(for: request, onReceiveResponse: onReceiveResponse, consume: consume) .asyncRecover { error in await recover(request, from: error) .asyncFlatMap { newRequest in - await stream(request: newRequest, consume: consume) + await streamOnce(request: newRequest, onReceiveResponse: onReceiveResponse, consume: consume) } } + + if case let .failure(error) = result { + if case .cancelled = error { + // no-op + } else { + log(.error, "\(request.method) \(request.url) failed with:\n\(error)") + delegate?.httpClient(self, request: request, didFailWithError: error) + } + } + + return result + } + } + + private func streamOnce( + request: any HTTPRequestConvertible, + onReceiveResponse: (@Sendable (HTTPResponse) async -> HTTPResult)?, + consume: @Sendable (Data, Double?) -> HTTPResult + ) async -> HTTPResult { + await request.httpRequest() + .asyncFlatMap { request in + await startTask(for: request, onReceiveResponse: onReceiveResponse, consume: consume) } } - /// Creates and starts a new task for the `request`, whose cancellable will be exposed through `mediator`. - private func startTask(for request: HTTPRequest, consume: @escaping HTTPTask.Consume) async -> HTTPResult { + /// Creates and starts an async byte stream for the `request`. + private func startTask( + for request: HTTPRequest, + onReceiveResponse: (@Sendable (HTTPResponse) async -> HTTPResult)?, + consume: @Sendable (Data, Double?) -> HTTPResult + ) async -> HTTPResult { var request = request if request.userAgent == nil { request.userAgent = userAgent } - let result = await tasks.start( + log(.info, request) + + let taskDelegate = TaskDelegate( request: request, - task: session.dataTask(with: request.urlRequest), - receiveResponse: { [weak self] response in - if let self = self { - self.delegate?.httpClient(self, request: request, didReceiveResponse: response) + delegate: delegate, + client: self + ) + + do { + let task = session.dataTask(with: makeURLRequest(request)) + task.delegate = taskDelegate + + return try await withTaskCancellationHandler { + let (stream, response) = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<(AsyncThrowingStream, URLResponse), Error>) in + taskDelegate.setResponseContinuation(continuation) + task.resume() } - }, - receiveChallenge: { [weak self] challenge in - if let self = self, let delegate = self.delegate { - return await delegate.httpClient(self, request: request, didReceive: challenge) - } else { - return .performDefaultHandling + + guard let httpURLResponse = response as? HTTPURLResponse, let url = httpURLResponse.url?.httpURL else { + return .failure(.malformedResponse(nil)) } - }, - consume: consume - ) - if let delegate = delegate, case let .failure(error) = result { - delegate.httpClient(self, request: request, didFailWithError: error) - } + let httpResponse = makeHTTPResponse(request: request, response: httpURLResponse, url: url) + delegate?.httpClient(self, request: request, didReceiveResponse: httpResponse) - return result - } + if !httpResponse.status.isSuccess { + let body = try await collectErrorBody(from: stream, task: task, response: httpResponse) + return .failure(.errorResponse(makeErrorResponse(httpResponse: httpResponse, body: body))) + } - /// Lets the `delegate` customize the `request` if needed, before actually starting it. - private func willStartRequest(_ request: HTTPRequest) async -> HTTPResult { - guard let delegate = delegate else { - return .success(request) - } - return await delegate.httpClient(self, willStartRequest: request) - .flatMap { $0.httpRequest() } - } + if request.hasHeader("Range"), !httpResponse.acceptsByteRanges { + log(.error, "Streaming ranges requires the remote HTTP server to support byte range requests: \(url)") + task.cancel() + return .failure(.rangeNotSupported) + } - /// Attempts to recover from a `error` by asking the `delegate` for a new request. - private func recover(_ request: HTTPRequest, from error: HTTPError) async -> HTTPResult { - if let delegate = delegate { - return await delegate.httpClient(self, recoverRequest: request, fromError: error) - } else { - return .failure(error) - } - } + if let onReceive = onReceiveResponse { + let result = await onReceive(httpResponse) + if case let .failure(error) = result { + task.cancel() + return .failure(error) + } + } - private class HTTPTaskManager: NSObject, URLSessionDataDelegate { - /// On-going tasks. - @Atomic private var tasks: [HTTPTask] = [] + let expectedBytes = httpResponse.fullContentLength + var readBytes: Int64 = httpResponse.contentRangeOffset - func start( - request: HTTPRequest, - task sessionTask: URLSessionDataTask, - receiveResponse: @escaping HTTPTask.ReceiveResponse, - receiveChallenge: @escaping HTTPTask.ReceiveChallenge, - consume: @escaping HTTPTask.Consume - ) async -> HTTPResult { - let task = HTTPTask( - request: request, - task: sessionTask, - receiveResponse: receiveResponse, - receiveChallenge: receiveChallenge, - consume: consume - ) - $tasks.write { $0.append(task) } - - let result = await withTaskCancellationHandler { - await withCheckedContinuation { continuation in - task.start(with: continuation) + for try await chunk in stream { + try Task.checkCancellation() + readBytes += Int64(chunk.count) + let progress = expectedBytes.map { $0 > 0 ? Double(min(readBytes, $0)) / Double($0) : 1.0 } + if case let .failure(error) = consume(chunk, progress) { + task.cancel() + return .failure(error) + } } + + try Task.checkCancellation() + return .success(httpResponse) } onCancel: { task.cancel() } - $tasks.write { $0.removeAll { $0.task == sessionTask } } + } catch { + if (error is CancellationError) || ((error as? URLError)?.code == .cancelled) { + return .failure(.cancelled) + } + return .failure(.wrap(error) ?? .other(error)) + } + } - return result + private let maxErrorBodySize = 1024 * 1024 + private let defaultErrorBodySize = 1024 + + private func collectErrorBody( + from stream: AsyncThrowingStream, + task: URLSessionDataTask, + response: HTTPResponse + ) async throws -> Data { + let capacity = min(maxErrorBodySize, Int(response.fullContentLength ?? Int64(defaultErrorBodySize))) + var data = Data() + for try await chunk in stream { + if data.count < capacity { + data.append(chunk) + } else { + task.cancel() + break + } } + return data.prefix(capacity) + } + + private func makeURLRequest(_ request: HTTPRequest) -> URLRequest { + var urlRequest = URLRequest(url: request.url.url) + urlRequest.httpMethod = request.method.rawValue + urlRequest.allHTTPHeaderFields = request.headers + urlRequest.timeoutInterval = request.timeoutInterval ?? session.configuration.timeoutIntervalForRequest - private func findTask(for urlTask: URLSessionTask) -> HTTPTask? { - let task = tasks.first { $0.task == urlTask } - if task == nil { - log(.error, "Cannot find on-going HTTP task for \(urlTask)") + if let body = request.body { + switch body { + case let .data(data): + urlRequest.httpBody = data + case let .file(url): + urlRequest.httpBodyStream = InputStream(url: url) } - return task } - // MARK: - URLSessionDataDelegate + return urlRequest + } - func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { - guard let task = findTask(for: dataTask) else { - completionHandler(.cancel) - return + private func makeHTTPResponse(request: HTTPRequest, response: HTTPURLResponse, url: HTTPURL) -> HTTPResponse { + var headers: [String: String] = [:] + for (k, v) in response.allHeaderFields { + if let ks = k as? String, let vs = v as? String { + headers[ks] = vs } - task.urlSession(session, didReceive: response, completionHandler: completionHandler) } + return HTTPResponse( + request: request, + url: url, + status: HTTPStatus(rawValue: response.statusCode), + headers: headers, + mediaType: response.mimeType.flatMap { MediaType($0) } + ) + } - func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { - findTask(for: dataTask)?.urlSession(session, didReceive: data) + private func makeErrorResponse(httpResponse: HTTPResponse, body: Data) -> HTTPErrorResponse { + HTTPErrorResponse( + status: httpResponse.status, + body: body, + mediaType: httpResponse.mediaType, + headers: httpResponse.headers + ) + } + + /// Lets the `delegate` customize the `request` if needed, before actually starting it. + private func willStartRequest(_ request: HTTPRequest) async -> HTTPResult { + guard let delegate else { + return .success(request) } + return await delegate.httpClient(self, willStartRequest: request) + .flatMap { $0.httpRequest() } + } - func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - findTask(for: task)?.urlSession(session, didCompleteWithError: error) + /// Attempts to recover from an `error` by asking the `delegate` for a new request. + private func recover(_ request: HTTPRequest, from error: HTTPError) async -> HTTPResult { + if let delegate { + return await delegate.httpClient(self, recoverRequest: request, fromError: error) + } else { + return .failure(error) } + } - func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { - guard let task = findTask(for: task) else { - completionHandler(.performDefaultHandling, nil) - return - } + /// Minimal `URLSessionDataDelegate` that handles auth challenges and + /// bridges data callbacks into `AsyncThrowingStream`. + private final class TaskDelegate: NSObject, URLSessionDataDelegate, Sendable { + let request: HTTPRequest - task.urlSession(session, didReceive: challenge, completion: completionHandler) + /// Strong reference — the delegate must remain alive for the full + /// lifetime of the request (auth challenges, response notifications). + let delegate: (any DefaultHTTPClientDelegate)? + + private struct WeakClient: Sendable { + weak var value: DefaultHTTPClient? } - } - /// Represents an on-going HTTP task. - private class HTTPTask: Cancellable, Loggable { - typealias Continuation = CheckedContinuation, Never> - typealias ReceiveResponse = (HTTPResponse) -> Void - typealias ReceiveChallenge = (URLAuthenticationChallenge) async -> URLAuthenticationChallengeResponse - typealias Consume = (Data, Double?) -> HTTPResult - - private let request: HTTPRequest - fileprivate let task: URLSessionTask - private let receiveResponse: ReceiveResponse - private let receiveChallenge: ReceiveChallenge - private let consume: Consume - - /// States the HTTP task can be in. - private var state: State = .initializing - - private enum State { - /// Waiting to start the task. - case initializing - - /// Waiting for the HTTP response. - case start(continuation: Continuation) - - /// We received a success response, the data will be sent to - /// `consume` progressively. - case stream(continuation: Continuation, response: HTTPResponse, readBytes: Int64) - - /// We received an error response, the data will be accumulated in - /// `response.body` if the error is an `HTTPError.errorResponse`, as - /// it could be needed for example when the response is an OPDS - /// Authentication Document. - case failure(continuation: Continuation, error: HTTPError) - - /// The request is terminated. - case finished - - var continuation: Continuation? { - switch self { - case .initializing, .finished: - return nil - case let .start(continuation): - return continuation - case let .stream(continuation, _, _): - return continuation - case let .failure(continuation, _): - return continuation - } - } + private let _client: Mutex + var client: DefaultHTTPClient? { + _client.withLock { $0.value } } + private struct State: Sendable { + var authTask: Task? + var streamContinuation: AsyncThrowingStream.Continuation? + var responseContinuation: CheckedContinuation<(AsyncThrowingStream, URLResponse), Error>? + } + + private let state = Mutex(State()) + init( request: HTTPRequest, - task: URLSessionDataTask, - receiveResponse: @escaping ReceiveResponse, - receiveChallenge: @escaping ReceiveChallenge, - consume: @escaping Consume + delegate: (any DefaultHTTPClientDelegate)?, + client: DefaultHTTPClient ) { self.request = request - self.task = task - self.receiveResponse = receiveResponse - self.receiveChallenge = receiveChallenge - self.consume = consume - } - - deinit { - finish() - } - - func start(with continuation: Continuation) { - log(.info, request) - state = .start(continuation: continuation) - task.resume() + self.delegate = delegate + _client = Mutex(WeakClient(value: client)) } - func cancel() { - task.cancel() + func setResponseContinuation(_ continuation: CheckedContinuation<(AsyncThrowingStream, URLResponse), Error>) { + state.withLock { $0.responseContinuation = continuation } } - private func finish() { - switch state { - case let .start(continuation): - continuation.resume(returning: .failure(.cancelled)) + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + let (authTask, responseCont, streamCont) = state.withLock { s in + let auth = s.authTask + s.authTask = nil + let resp = s.responseContinuation + s.responseContinuation = nil + let stream = s.streamContinuation + return (auth, resp, stream) + } - case let .stream(continuation, response, _): - continuation.resume(returning: .success(response)) + authTask?.cancel() - case let .failure(continuation, error): - if case .cancelled = error { - // no-op + if let responseCont { + if let error { + responseCont.resume(throwing: error) } else { - var errorDescription = "" - dump(error, to: &errorDescription) - log(.error, "\(request.method) \(request.url) failed with:\n\(errorDescription)") + responseCont.resume(throwing: URLError(.badServerResponse)) + } + } else { + if let error { + streamCont?.finish(throwing: error) + } else { + streamCont?.finish() } - - continuation.resume(returning: .failure(error)) - - case .initializing, .finished: - break } - - state = .finished } - func urlSession(_ session: URLSession, didReceive urlResponse: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { - if case .finished = state { - completionHandler(.cancel) - return - } - guard - let continuation = state.continuation, - let urlResponse = urlResponse as? HTTPURLResponse, - let url = urlResponse.url?.httpURL - else { - completionHandler(.cancel) - return + func urlSession( + _ session: URLSession, + dataTask: URLSessionDataTask, + didReceive response: URLResponse + ) async -> URLSession.ResponseDisposition { + let responseCont = state.withLock { s in + let cont = s.responseContinuation + s.responseContinuation = nil + return cont } - let response = HTTPResponse(request: request, response: urlResponse, url: url) - - guard response.status.isSuccess else { - state = .failure(continuation: continuation, error: .errorResponse(response)) - completionHandler(.allow) - return - } - - guard !request.hasHeader("Range") || response.acceptsByteRanges else { - log(.error, "Streaming ranges requires the remote HTTP server to support byte range requests: \(url)") - state = .failure(continuation: continuation, error: .rangeNotSupported) - completionHandler(.cancel) - return + if let responseCont { + var streamContinuation: AsyncThrowingStream.Continuation! + let stream = AsyncThrowingStream { cont in + streamContinuation = cont + } + state.withLock { $0.streamContinuation = streamContinuation } + responseCont.resume(returning: (stream, response)) + return .allow + } else { + return .cancel } - - state = .stream(continuation: continuation, response: response, readBytes: 0) - receiveResponse(response) - - completionHandler(.allow) } - func urlSession(_ session: URLSession, didReceive data: Data) { - switch state { - case .initializing, .start, .finished: - break - - case .stream(let continuation, let response, var readBytes): - readBytes += Int64(data.count) - var progress: Double? = nil - if let expectedBytes = response.contentLength { - progress = Double(min(readBytes, expectedBytes)) / Double(expectedBytes) - } - - switch consume(data, progress) { - case .success: - state = .stream(continuation: continuation, response: response, readBytes: readBytes) - case let .failure(error): - state = .failure(continuation: continuation, error: error) - } - - case .failure(let continuation, var error): - if case var .errorResponse(response) = error { - var body = response.body ?? Data() - body.append(data) - response.body = body - error = .errorResponse(response) - } - - state = .failure(continuation: continuation, error: error) - } + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + let cont = state.withLock { $0.streamContinuation } + cont?.yield(data) } - func urlSession(_ session: URLSession, didCompleteWithError error: Error?) { - if let error = error { - if case .failure = state { - // No-op, we don't want to overwrite the failure state in this case. - } else if let continuation = state.continuation { - state = .failure(continuation: continuation, error: .wrap(error) ?? .other(error)) - } else { - state = .finished - } + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + guard let client = client else { + completionHandler(.performDefaultHandling, nil) + return } - finish() - } - func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { - Task { - let response = await receiveChallenge(challenge) - switch response { - case let .useCredential(credential): - completion(.useCredential, credential) - case .performDefaultHandling: - completion(.performDefaultHandling, nil) - case .cancelAuthenticationChallenge: - completion(.cancelAuthenticationChallenge, nil) - case .rejectProtectionSpace: - completion(.rejectProtectionSpace, nil) + state.withLock { s in + s.authTask?.cancel() + s.authTask = Task { + if Task.isCancelled { + completionHandler(.cancelAuthenticationChallenge, nil) + return + } + + if let delegate { + let response = await delegate.httpClient(client, request: request, didReceive: challenge) + + if Task.isCancelled { + completionHandler(.cancelAuthenticationChallenge, nil) + return + } + + switch response { + case let .useCredential(credential): + completionHandler(.useCredential, credential) + case .performDefaultHandling: + completionHandler(.performDefaultHandling, nil) + case .cancelAuthenticationChallenge: + completionHandler(.cancelAuthenticationChallenge, nil) + case .rejectProtectionSpace: + completionHandler(.rejectProtectionSpace, nil) + } + } else { + completionHandler(.performDefaultHandling, nil) + } } } } } } -private extension HTTPRequest { - var urlRequest: URLRequest { - var request = URLRequest(url: url.url) - request.httpMethod = method.rawValue - request.allHTTPHeaderFields = headers - - if let timeoutInterval = timeoutInterval { - request.timeoutInterval = timeoutInterval - } - - if let body = body { - switch body { - case let .data(data): - request.httpBody = data - case let .file(url): - request.httpBodyStream = InputStream(url: url) - } +private extension HTTPResponse { + /// The full expected content length for this resource, when known. + /// + /// This will be the total length of the resource, even for byte range requests. + /// Handles headers like `bytes 0-100/1000` and `bytes */1000`. + var fullContentLength: Int64? { + guard + let contentRange = valueForHeader("Content-Range"), + let totalLengthString = contentRange.split(separator: "/").last?.trimmingCharacters(in: .whitespaces), + let totalLength = Int64(totalLengthString) + else { + return contentLength } - - return request + return totalLength } -} -private extension HTTPResponse { - init(request: HTTPRequest, response: HTTPURLResponse, url: HTTPURL, body: Data? = nil) { - var headers: [String: String] = [:] - for (k, v) in response.allHeaderFields { - if let ks = k as? String, let vs = v as? String { - headers[ks] = vs - } + /// Offset of the current response in the full resource. + /// Handles headers like `bytes 0-100/1000`. Returns 0 if the range is unknown (e.g., `bytes */1000`). + var contentRangeOffset: Int64 { + guard + let contentRange = valueForHeader("Content-Range"), + let rangeString = contentRange.split(separator: " ", maxSplits: 1).last, + let rangeStartString = rangeString.split(separator: "-").first?.trimmingCharacters(in: .whitespaces), + let rangeStart = Int64(rangeStartString) + else { + return 0 } - self.init( - request: request, - url: url, - status: HTTPStatus(rawValue: response.statusCode), - headers: headers, - mediaType: response.mimeType.flatMap { MediaType($0) }, - body: body - ) + return rangeStart } } diff --git a/Sources/Shared/Toolkit/HTTP/HTTPClient.swift b/Sources/Shared/Toolkit/HTTP/HTTPClient.swift index f8e6109b17..e11962da3f 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPClient.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPClient.swift @@ -5,44 +5,71 @@ // import Foundation -import UIKit +#if canImport(UIKit) + import UIKit +#endif /// An HTTP client performs HTTP requests. /// -/// You may provide a custom implementation, or use the `DefaultHTTPClient` one which relies on native APIs. +/// You may provide a custom implementation, or use the `DefaultHTTPClient` one +/// which relies on native APIs. public protocol HTTPClient: Loggable { /// Streams a resource from the given `request`. /// /// - Parameters: /// - request: Request to the streamed resource. - /// also access it in the completion block after consuming the data. + /// - onReceiveResponse: Optional callback allowing you to intercept the + /// response headers and cancel early with `HTTPError.cancelled`. /// - consume: Callback called for each chunk of data received. Callers /// are responsible to accumulate the data if needed. Return an error - /// to abort the request. + /// to abort the request. The `progress` parameter represents the + /// overall resource progress (including any `contentRangeOffset` for + /// range requests), not just the progress of the current chunk. + /// Important: `consume` is always called serially. Implementations must + /// never invoke it concurrently. func stream( - request: HTTPRequestConvertible, - consume: @escaping (_ chunk: Data, _ progress: Double?) -> HTTPResult + _ request: HTTPRequestConvertible, + onReceiveResponse: (@Sendable (HTTPResponse) async -> HTTPResult)?, + consume: @Sendable (_ chunk: Data, _ progress: Double?) -> HTTPResult ) async -> HTTPResult } public extension HTTPClient { - /// Fetches the resource from the given `request`. - func fetch(_ request: HTTPRequestConvertible) async -> HTTPResult { - var data = Data() - let response = await stream( - request: request, + /// Streams a resource from the given `request`. + /// + /// - Parameters: + /// - request: Request to the streamed resource. + /// - consume: Callback called for each chunk of data received. Callers + /// are responsible to accumulate the data if needed. Return an error + /// to abort the request. The `progress` parameter represents the + /// overall resource progress (including any `contentRangeOffset` for + /// range requests), not just the progress of the current chunk. + /// Important: `consume` is always called serially. Implementations must + /// never invoke it concurrently. + func stream( + _ request: HTTPRequestConvertible, + consume: @Sendable (_ chunk: Data, _ progress: Double?) -> HTTPResult + ) async -> HTTPResult { + await stream(request, onReceiveResponse: nil, consume: consume) + } + + /// Fetches the resource from the given `request` and returns the + /// accumulated data. + func fetch( + _ request: HTTPRequestConvertible, + onReceiveResponse: (@Sendable (HTTPResponse) async -> HTTPResult)? = nil + ) async -> HTTPResult { + let accumulator = Mutex(Data()) + let responseResult = await stream( + request, + onReceiveResponse: onReceiveResponse, consume: { chunk, _ in - data.append(chunk) + accumulator.withLock { $0.append(chunk) } return .success(()) } ) - return response - .map { - var response = $0 - response.body = data - return response - } + return responseResult.map { HTTPBody(body: accumulator.withLock { $0 }, mediaType: $0.mediaType) } } /// Fetches the resource and attempts to decode it with the given `decoder`. @@ -50,15 +77,13 @@ public extension HTTPClient { /// If the decoder fails, a `malformedResponse` HTTP error is returned. func fetch( _ request: HTTPRequestConvertible, - decoder: @escaping (HTTPResponse, Data) throws -> T? + onReceiveResponse: (@Sendable (HTTPResponse) async -> HTTPResult)? = nil, + decoder: @escaping (HTTPBody) throws -> T? ) async -> HTTPResult { - await fetch(request) + await fetch(request, onReceiveResponse: onReceiveResponse) .flatMap { response in do { - guard - let body = response.body, - let result = try decoder(response, body) - else { + guard let result = try decoder(response) else { return .failure(.malformedResponse(nil)) } return .success(result) @@ -72,31 +97,34 @@ public extension HTTPClient { /// Fetches the resource as a JSON object. func fetchJSON(_ request: HTTPRequestConvertible) async -> HTTPResult<[String: Any]> { await fetch(request) { - try JSONSerialization.jsonObject(with: $1) as? [String: Any] + try JSONSerialization.jsonObject(with: $0.body) as? [String: Any] } } /// Fetches the resource as a `String`. func fetchString(_ request: HTTPRequestConvertible) async -> HTTPResult { - await fetch(request) { response, body in - let encoding = response.mediaType?.encoding ?? .utf8 - return String(data: body, encoding: encoding) + await fetch(request) { + let encoding = $0.mediaType?.encoding ?? .utf8 + return String(data: $0.body, encoding: encoding) } } - /// Fetches the resource as an `UIImage`. - func fetchImage(_ request: HTTPRequestConvertible) async -> HTTPResult { - await fetch(request) { - UIImage(data: $1) + #if canImport(UIKit) + /// Fetches the resource as an `UIImage`. + func fetchImage(_ request: HTTPRequestConvertible) async -> HTTPResult { + await fetch(request) { + UIImage(data: $0.body) + } } - } + #endif /// Downloads the resource at a temporary location. /// - /// You are responsible for moving or deleting the downloaded file in the `completion` block. + /// You are responsible for moving or deleting the downloaded file. func download( _ request: HTTPRequestConvertible, - onProgress: @escaping (Double) -> Void + onReceiveResponse: (@Sendable (HTTPResponse) async -> HTTPResult)? = nil, + onProgress: @Sendable @escaping (Double) -> Void ) async -> HTTPResult { let location = await FileURL( url: URL( @@ -107,17 +135,18 @@ public extension HTTPClient { let fileHandle: FileHandle do { - try "".write(to: location.url, atomically: true, encoding: .utf8) + try Data().write(to: location.url) fileHandle = try FileHandle(forWritingTo: location.url) } catch { return .failure(.fileSystem(.io(error))) } + defer { try? fileHandle.close() } let result = await stream( - request: request, + request, + onReceiveResponse: onReceiveResponse, consume: { data, progression in do { - try fileHandle.seekToEnd() try fileHandle.write(contentsOf: data) } catch { return .failure(.fileSystem(.io(error))) @@ -151,7 +180,7 @@ public extension HTTPClient { } /// Status code of an HTTP response. -public struct HTTPStatus: Equatable, RawRepresentable, ExpressibleByIntegerLiteral, Sendable { +public struct HTTPStatus: Equatable, Sendable, RawRepresentable, ExpressibleByIntegerLiteral { public let rawValue: Int public init(rawValue: RawValue) { @@ -164,7 +193,7 @@ public struct HTTPStatus: Equatable, RawRepresentable, ExpressibleByIntegerLiter /// Returns whether this represents a successful HTTP status. public var isSuccess: Bool { - (200 ..< 400).contains(rawValue) + (200 ..< 300).contains(rawValue) } /// (200) OK. @@ -197,7 +226,7 @@ public struct HTTPStatus: Equatable, RawRepresentable, ExpressibleByIntegerLiter } /// Represents a successful HTTP response received from a server. -public struct HTTPResponse: Equatable { +public struct HTTPResponse: Equatable, Sendable, HTTPHeadersProviding { /// Request associated with the response. public let request: HTTPRequest @@ -213,29 +242,69 @@ public struct HTTPResponse: Equatable { /// Media type provided in the `Content-Type` header. public let mediaType: MediaType? - /// Response body content, when available. - public var body: Data? - public init( request: HTTPRequest, url: HTTPURL, status: HTTPStatus, headers: [String: String], - mediaType: MediaType?, - body: Data? + mediaType: MediaType? ) { self.request = request self.url = url self.status = status self.headers = headers self.mediaType = mediaType + } +} + +/// Holds the information about a successful fetch. +public struct HTTPBody: Equatable, Sendable { + /// The raw data received in the response body. + public let body: Data + + /// Media type provided in the `Content-Type` header. + public let mediaType: MediaType? + + public init(body: Data, mediaType: MediaType?) { self.body = body + self.mediaType = mediaType } +} +/// Holds the information about a successful download. +public struct HTTPDownload: Equatable, Sendable { + /// The location of a temporary file where the server's response is stored. + /// You are responsible for moving or deleting the downloaded file. + public let location: FileURL + + /// A suggested filename for the response data, taken from the + /// `Content-Disposition` header. + public let suggestedFilename: String? + + /// Media type provided in the `Content-Type` header. + public let mediaType: MediaType? + + public init(location: FileURL, suggestedFilename: String? = nil, mediaType: MediaType?) { + self.location = location + self.suggestedFilename = suggestedFilename + self.mediaType = mediaType + } +} + +/// A protocol that provides access to HTTP headers. +/// +/// Conforming types must provide a dictionary of HTTP headers. The protocol +/// extension provides convenient typed accessors for common HTTP headers. +public protocol HTTPHeadersProviding { + /// HTTP response headers, indexed by their name. + var headers: [String: String] { get } +} + +public extension HTTPHeadersProviding { /// Finds the value of the first header matching the given name. /// /// In keeping with the HTTP RFC, HTTP header field names are case-insensitive. - public func valueForHeader(_ name: String) -> String? { + func valueForHeader(_ name: String) -> String? { let name = name.lowercased() for (n, v) in headers { if n.lowercased() == name { @@ -246,7 +315,7 @@ public struct HTTPResponse: Equatable { } /// Indicates whether this server supports byte range requests. - public var acceptsByteRanges: Bool { + var acceptsByteRanges: Bool { valueForHeader("Accept-Ranges")?.lowercased() == "bytes" || valueForHeader("Content-Range")?.lowercased().hasPrefix("bytes") == true } @@ -255,49 +324,47 @@ public struct HTTPResponse: Equatable { /// /// Warning: For byte range requests, this will be the length of the current chunk, /// not the whole resource. - public var contentLength: Int64? { + var contentLength: Int64? { valueForHeader("Content-Length") .flatMap { Int64($0) } .takeIf { $0 >= 0 } } /// The resource filename as provided by the server in the `Content-Disposition` header. - public var filename: String? { - if let disposition = headers["Content-Disposition"] { - let array = disposition.split(separator: ";") - var filenameString: String? - switch array.count { - case 1: - filenameString = String(array[0]).trimmingCharacters(in: .whitespaces) - case 2: - filenameString = String(array[1]).trimmingCharacters(in: .whitespaces) - default: - break - } + var filename: String? { + guard let disposition = valueForHeader("Content-Disposition") else { + return nil + } - if let filenameString = filenameString, filenameString.starts(with: "filename=") { - return filenameString.replacingOccurrences(of: "filename=", with: "") + let parts = disposition.split(separator: ";") + .map { $0.trimmingCharacters(in: .whitespaces) } + + // Look for filename* first as it takes precedence + for part in parts { + if part.hasPrefix("filename*=") { + let value = part.replacingOccurrences(of: "filename*=", with: "") + let encodingParts = value.split(separator: "'", omittingEmptySubsequences: false) + if encodingParts.count == 3 { + let encoding = String(encodingParts[0]).lowercased() + let encodedFilename = String(encodingParts[2]) + if encoding == "utf-8", let decoded = encodedFilename.removingPercentEncoding { + return decoded + } + } } } - return nil - } -} -/// Holds the information about a successful download. -public struct HTTPDownload: Sendable { - /// The location of a temporary file where the server's response is stored. - /// You are responsible for moving or deleting the downloaded file.. - public let location: FileURL - - /// A suggested filename for the response data, taken from the `Content-Disposition` header. - public let suggestedFilename: String? - - /// Media type sniffed from the `Content-Type` header and response body. - public let mediaType: MediaType? + // Fallback to filename + for part in parts { + if part.hasPrefix("filename=") { + var value = part.replacingOccurrences(of: "filename=", with: "") + if value.hasPrefix("\""), value.hasSuffix("\"") { + value = String(value.dropFirst().dropLast()) + } + return value + } + } - public init(location: FileURL, suggestedFilename: String? = nil, mediaType: MediaType?) { - self.location = location - self.suggestedFilename = suggestedFilename - self.mediaType = mediaType + return nil } } diff --git a/Sources/Shared/Toolkit/HTTP/HTTPError.swift b/Sources/Shared/Toolkit/HTTP/HTTPError.swift index dbbe8cd843..8a90f17074 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPError.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPError.swift @@ -17,7 +17,7 @@ public enum HTTPError: Error, Loggable { case malformedResponse(Error?) /// The server returned a response with an HTTP status error. - case errorResponse(HTTPResponse) + case errorResponse(HTTPErrorResponse) /// The client, server or gateways timed out. case timeout(Error?) @@ -50,15 +50,10 @@ public enum HTTPError: Error, Loggable { /// Response body parsed as a JSON problem details. public func problemDetails() throws -> HTTPProblemDetails? { - guard - case let .errorResponse(response) = self, - response.mediaType?.matches(.problemDetails) == true, - let body = response.body - else { + guard case let .errorResponse(response) = self else { return nil } - - return try HTTPProblemDetails(data: body) + return try response.problemDetails() } /// Wraps a native error into an `HTTPError`, if possible. @@ -88,3 +83,42 @@ public enum HTTPError: Error, Loggable { } } } + +/// Response returned by the server with an HTTP status error. +public struct HTTPErrorResponse: Equatable, Sendable, HTTPHeadersProviding { + /// HTTP status code returned by the server. + public let status: HTTPStatus + + /// The raw data received in the response body. + public let body: Data + + /// Media type provided in the `Content-Type` header. + public let mediaType: MediaType? + + /// HTTP response headers, indexed by their name. + public let headers: [String: String] + + public init( + status: HTTPStatus, + body: Data = Data(), + mediaType: MediaType? = nil, + headers: [String: String] = [:] + ) { + self.status = status + self.body = body + self.mediaType = mediaType + self.headers = headers + } + + /// Response body parsed as a JSON problem details. + public func problemDetails() throws -> HTTPProblemDetails? { + guard + mediaType?.matches(.problemDetails) == true, + !body.isEmpty + else { + return nil + } + + return try HTTPProblemDetails(data: body) + } +} diff --git a/Sources/Shared/Toolkit/HTTP/HTTPRequest.swift b/Sources/Shared/Toolkit/HTTP/HTTPRequest.swift index 7edad4384e..4e43748de2 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPRequest.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPRequest.swift @@ -7,7 +7,7 @@ import Foundation /// Holds the information about an HTTP request performed by an `HTTPClient`. -public struct HTTPRequest: Equatable { +public struct HTTPRequest: Equatable, Sendable { /// Address of the remote resource to request. public var url: HTTPURL @@ -44,7 +44,10 @@ public struct HTTPRequest: Equatable { public var allowUserInteraction: Bool /// Additional context data specific to a given implementation of `HTTPClient`. - public var userInfo: [AnyHashable: AnyHashable] + @available(*, unavailable, message: "This was not used in the toolkit. Open a bug report issue if you used it.") + public var userInfo: [AnyHashable: AnyHashable] { + [:] + } public init( url: HTTPURL, @@ -52,8 +55,7 @@ public struct HTTPRequest: Equatable { headers: [String: String] = [:], body: Body? = nil, timeoutInterval: TimeInterval? = nil, - allowUserInteraction: Bool = false, - userInfo: [AnyHashable: AnyHashable] = [:] + allowUserInteraction: Bool = false ) { self.url = url self.method = method @@ -61,7 +63,6 @@ public struct HTTPRequest: Equatable { self.body = body self.timeoutInterval = timeoutInterval self.allowUserInteraction = allowUserInteraction - self.userInfo = userInfo } /// User agent that will be issued with this request. @@ -78,15 +79,14 @@ public struct HTTPRequest: Equatable { } } - /// Issue a byte range request. Use -1 to download until the end. + /// Issue a byte range request. public mutating func setRange(_ range: Range) { - let start = max(0, range.lowerBound) - let end = range.upperBound - 1 - var value = "\(start)-" - if end >= start { - value += "\(end)" - } - headers["Range"] = "bytes=\(value)" + headers["Range"] = "bytes=\(range.lowerBound)-\(range.upperBound - 1)" + } + + /// Issue a byte range request from the given offset until the end of the resource. + public mutating func setRange(_ range: PartialRangeFrom) { + headers["Range"] = "bytes=\(range.lowerBound)-" } /// Returns whether this request has the HTTP header with the given `key`, without taking into account the case. @@ -126,14 +126,10 @@ extension HTTPRequest: CustomStringConvertible { } /// Convenience protocol to pass an URL or similar objects to an `HTTPClient`. -public protocol HTTPRequestConvertible { +public protocol HTTPRequestConvertible: Sendable { func httpRequest() -> HTTPResult } -public enum HTTPRequestError: Error, Sendable { - case invalidURL(CustomStringConvertible & Sendable) -} - extension HTTPRequest: HTTPRequestConvertible { public func httpRequest() -> HTTPResult { .success(self) diff --git a/Sources/Shared/Toolkit/HTTP/HTTPResource.swift b/Sources/Shared/Toolkit/HTTP/HTTPResource.swift index 1422579289..4582686c87 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPResource.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPResource.swift @@ -44,21 +44,36 @@ public actor HTTPResource: Resource { } private var _headResponse: ReadResult? + private func setHeadResponse(_ result: ReadResult) { + _headResponse = result + } /// Cached HEAD response to get the expected content length and other /// metadata. + /// + /// For compatibility reason, we start a byte range request of 2 bytes and + /// interrupt it right away. private func headResponse() async -> ReadResult { if _headResponse == nil { - _headResponse = await client.fetch(HTTPRequest(url: url, method: .head)) - .map { $0 as HTTPResponse? } - .flatMapError { error in - switch error { - case let .errorResponse(response) where response.status == .methodNotAllowed: - return .success(nil) - default: - return .failure(.access(.http(error))) - } + var request = HTTPRequest(url: url) + request.setRange(0 ..< 2) + + let result = await client.stream( + request, + onReceiveResponse: { response in + await self.setHeadResponse(.success(response)) + return .failure(.cancelled) + }, + consume: { _, _ in .failure(.cancelled) } + ) + + if _headResponse == nil, case let .failure(error) = result { + if let error: ReadError = .wrap(error) { + _headResponse = .failure(error) + } else { + _headResponse = .success(nil) } + } } return _headResponse! } @@ -73,7 +88,7 @@ public actor HTTPResource: Resource { }() return await client.stream( - request: request, + request, consume: { data, _ in consume(data) return .success(()) diff --git a/Sources/Shared/Toolkit/HTTP/HTTPServer.swift b/Sources/Shared/Toolkit/HTTP/HTTPServer.swift index 0f9132f7cd..f2ae372a24 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPServer.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPServer.swift @@ -79,14 +79,7 @@ public extension HTTPServer { onFailure: HTTPRequestHandler.OnFailure? = nil ) throws -> HTTPURL { func onRequest(request: HTTPServerRequest) -> HTTPServerResponse { - lazy var notFound = HTTPError.errorResponse(HTTPResponse( - request: HTTPRequest(url: request.url), - url: request.url, - status: .notFound, - headers: [:], - mediaType: nil, - body: nil - )) + lazy var notFound: HTTPError = .errorResponse(HTTPErrorResponse(status: .notFound)) guard let href = request.href, diff --git a/Sources/Shared/Toolkit/Mutex.swift b/Sources/Shared/Toolkit/Mutex.swift new file mode 100644 index 0000000000..6d6a1e2cf8 --- /dev/null +++ b/Sources/Shared/Toolkit/Mutex.swift @@ -0,0 +1,71 @@ +// +// Copyright 2026 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 os + +// FIXME: iOS 16, use OSAllocatedUnfairLock as in https://gist.github.com/swhitty/571deb25d84c1954a7a01aafa661496e + +/// A synchronization primitive that protects shared mutable state via mutual +/// exclusion. +/// +/// Drop-in replacement for `Synchronization.Mutex` (iOS 18+) that works on iOS +/// 15+ with Swift 6 strict concurrency. +/// +/// ```swift +/// class Manager { +/// let cache = Mutex<[String: Int]>([:]) +/// +/// func save(_ value: Int, for key: String) { +/// cache.withLock { $0[key] = value } +/// } +/// } +/// ``` +@available(iOS, introduced: 15, deprecated: 18, message: "Use Mutex from the Synchronization module instead") +@frozen +public struct Mutex: ~Copyable, @unchecked Sendable { + /// Single heap allocation holds both the lock and the value together. + /// os_unfair_lock must never move after first use — the class guarantees a + /// stable address for the lifetime of the Mutex. + @usableFromInline + final class Storage: @unchecked Sendable { + nonisolated(unsafe) var lock = os_unfair_lock() + nonisolated(unsafe) var value: Value + + init(_ value: consuming Value) { + self.value = value + } + } + + @usableFromInline + let storage: Storage + + /// Initializes the mutex with the given initial value. + public init(_ initialValue: consuming sending Value) { + storage = Storage(initialValue) + } + + /// Acquires the lock, calls `body` with an `inout` reference to the + /// protected value, then releases the lock. + @discardableResult + public nonisolated borrowing func withLock( + _ body: (inout sending Value) throws(Failure) -> sending Result + ) throws(Failure) -> sending Result { + os_unfair_lock_lock(&storage.lock) + defer { os_unfair_lock_unlock(&storage.lock) } + return try body(&storage.value) + } + + /// Tries to acquire the lock without blocking. If successful, calls `body` + /// and returns its result; otherwise returns `nil` immediately. + @discardableResult + public nonisolated borrowing func withLockIfAvailable( + _ body: (inout sending Value) throws(Failure) -> sending Result + ) throws(Failure) -> sending Result? { + guard os_unfair_lock_trylock(&storage.lock) else { return nil } + defer { os_unfair_lock_unlock(&storage.lock) } + return try body(&storage.value) + } +} diff --git a/Sources/Shared/Toolkit/PDF/CGPDF.swift b/Sources/Shared/Toolkit/PDF/CGPDF.swift index 60e5e98ce4..826249885d 100644 --- a/Sources/Shared/Toolkit/PDF/CGPDF.swift +++ b/Sources/Shared/Toolkit/PDF/CGPDF.swift @@ -227,7 +227,7 @@ extension CGPDFDocument: PDFDocument { /// Creates a `PDFDocument` using Core Graphics. @available(*, deprecated, renamed: "PDFKitPDFDocumentFactory", message: "The PDFKitPDFDocumentFactory is more capable") -public class CGPDFDocumentFactory: PDFDocumentFactory, Loggable, Sendable { +public final class CGPDFDocumentFactory: PDFDocumentFactory, Loggable, Sendable { public init() {} public func open(file: FileURL, password: String?) async throws -> PDFDocument { diff --git a/Sources/Shared/Toolkit/URL/Absolute URL/FileURL.swift b/Sources/Shared/Toolkit/URL/Absolute URL/FileURL.swift index eb3f01b8d1..da8666f1ef 100644 --- a/Sources/Shared/Toolkit/URL/Absolute URL/FileURL.swift +++ b/Sources/Shared/Toolkit/URL/Absolute URL/FileURL.swift @@ -70,7 +70,7 @@ public struct FileURL: AbsoluteURL, Hashable, Sendable { /// /// To ignore this warning, compare `FileURL.string` instead of /// `FileURL` itself. - @available(*, deprecated, message: "Strict URL comparisons can be a source of bug. Use isEquivalent() instead.") + @available(*, deprecated, message: "Strict URL comparisons can be a source of bug. Use isEquivalentTo() instead.") public static func == (lhs: FileURL, rhs: FileURL) -> Bool { lhs.string == rhs.string } diff --git a/Sources/Shared/Toolkit/URL/Absolute URL/HTTPURL.swift b/Sources/Shared/Toolkit/URL/Absolute URL/HTTPURL.swift index 48aba28980..fa7f3b0577 100644 --- a/Sources/Shared/Toolkit/URL/Absolute URL/HTTPURL.swift +++ b/Sources/Shared/Toolkit/URL/Absolute URL/HTTPURL.swift @@ -42,7 +42,7 @@ public struct HTTPURL: AbsoluteURL, Hashable, Sendable { /// /// To ignore this warning, compare `HTTPURL.string` instead of /// `HTTPURL` itself. - @available(*, deprecated, message: "Strict URL comparisons can be a source of bug. Use isEquivalent() instead.") + @available(*, deprecated, message: "Strict URL comparisons can be a source of bug. Use isEquivalentTo() instead.") public static func == (lhs: HTTPURL, rhs: HTTPURL) -> Bool { lhs.string == rhs.string } diff --git a/Sources/Shared/Toolkit/URL/Absolute URL/UnknownAbsoluteURL.swift b/Sources/Shared/Toolkit/URL/Absolute URL/UnknownAbsoluteURL.swift index 5ca29a03e0..b0add18b9d 100644 --- a/Sources/Shared/Toolkit/URL/Absolute URL/UnknownAbsoluteURL.swift +++ b/Sources/Shared/Toolkit/URL/Absolute URL/UnknownAbsoluteURL.swift @@ -30,7 +30,7 @@ struct UnknownAbsoluteURL: AbsoluteURL, Hashable { /// /// To ignore this warning, compare `UnknownAbsoluteURL.string` instead of /// `UnknownAbsoluteURL` itself. - @available(*, deprecated, message: "Strict URL comparisons can be a source of bug. Use isEquivalent() instead.") + @available(*, deprecated, message: "Strict URL comparisons can be a source of bug. Use isEquivalentTo() instead.") static func == (lhs: UnknownAbsoluteURL, rhs: UnknownAbsoluteURL) -> Bool { lhs.string == rhs.string } diff --git a/Sources/Shared/Toolkit/URL/AnyURL.swift b/Sources/Shared/Toolkit/URL/AnyURL.swift index 8532e4457c..0c6946b94f 100644 --- a/Sources/Shared/Toolkit/URL/AnyURL.swift +++ b/Sources/Shared/Toolkit/URL/AnyURL.swift @@ -134,7 +134,7 @@ extension AnyURL: Hashable { /// /// To ignore this warning, compare `AnyURL.string` instead of /// `AnyURL` itself. - @available(*, deprecated, message: "Strict URL comparisons can be a source of bug. Use isEquivalent() instead.") + @available(*, deprecated, message: "Strict URL comparisons can be a source of bug. Use isEquivalentTo() instead.") public static func == (lhs: AnyURL, rhs: AnyURL) -> Bool { lhs.string == rhs.string } diff --git a/Sources/Shared/Toolkit/URL/RelativeURL.swift b/Sources/Shared/Toolkit/URL/RelativeURL.swift index 1c047697dd..f5f0f32530 100644 --- a/Sources/Shared/Toolkit/URL/RelativeURL.swift +++ b/Sources/Shared/Toolkit/URL/RelativeURL.swift @@ -116,7 +116,7 @@ public struct RelativeURL: URLProtocol, Hashable, Sendable { /// /// To ignore this warning, compare `RelativeURL.string` instead of /// `RelativeURL` itself. - @available(*, deprecated, message: "Strict URL comparisons can be a source of bug. Use isEquivalent() instead.") + @available(*, deprecated, message: "Strict URL comparisons can be a source of bug. Use isEquivalentTo() instead.") public static func == (lhs: RelativeURL, rhs: RelativeURL) -> Bool { lhs.string == rhs.string } diff --git a/TestApp/Sources/OPDS/OPDSModule.swift b/TestApp/Sources/OPDS/OPDSModule.swift index 7468714f76..943b6678c3 100644 --- a/TestApp/Sources/OPDS/OPDSModule.swift +++ b/TestApp/Sources/OPDS/OPDSModule.swift @@ -29,7 +29,7 @@ protocol OPDSModuleDelegate: ModuleDelegate { _ publication: Publication?, at link: ReadiumShared.Link, sender: UIViewController, - progress: @escaping (Double) -> Void + progress: @escaping @Sendable (Double) -> Void ) async throws -> Book } diff --git a/Tests/SharedTests/Capture.swift b/Tests/SharedTests/Capture.swift new file mode 100644 index 0000000000..773f286b25 --- /dev/null +++ b/Tests/SharedTests/Capture.swift @@ -0,0 +1,19 @@ +// +// Copyright 2026 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 + +/// A reference-type wrapper that allows a value to be captured and mutated +/// inside a `@Sendable` closure. +/// +/// Warning: Not thread-safe - only for sequential test code. +final class Capture: @unchecked Sendable { + var value: T + + init(_ value: T) { + self.value = value + } +} diff --git a/Tests/SharedTests/Publication/Services/Locator/DefaultLocatorServiceTests.swift b/Tests/SharedTests/Publication/Services/Locator/DefaultLocatorServiceTests.swift index 2c3818988b..aec4106d7a 100644 --- a/Tests/SharedTests/Publication/Services/Locator/DefaultLocatorServiceTests.swift +++ b/Tests/SharedTests/Publication/Services/Locator/DefaultLocatorServiceTests.swift @@ -15,6 +15,7 @@ class DefaultLocatorServiceTests: XCTestCase { Link(href: "chap2", mediaType: .xml), Link(href: "chap3", mediaType: .xml), ]) + _ = publication // Silence warning let locator = Locator(href: "chap2", mediaType: .html, text: .init(highlight: "Highlight")) let result = await service.locate(locator) XCTAssertEqual(result, locator) @@ -22,6 +23,7 @@ class DefaultLocatorServiceTests: XCTestCase { func testFromLocatorEmptyReadingOrder() async { let (publication, service) = makeService(readingOrder: []) + _ = publication // Silence warning let result = await service.locate(Locator(href: "href", mediaType: .html)) XCTAssertNil(result) } @@ -31,6 +33,7 @@ class DefaultLocatorServiceTests: XCTestCase { Link(href: "chap1", mediaType: .xml), Link(href: "chap3", mediaType: .xml), ]) + _ = publication // Silence warning let locator = Locator(href: "chap2", mediaType: .html, text: .init(highlight: "Highlight")) let result = await service.locate(locator) XCTAssertNil(result) @@ -38,6 +41,7 @@ class DefaultLocatorServiceTests: XCTestCase { func testFromProgression() async { let (publication, service) = makeService(positions: positionsFixture) + _ = publication // Silence warning var result = await service.locate(progression: 0.0) XCTAssertEqual(result, Locator( @@ -112,6 +116,7 @@ class DefaultLocatorServiceTests: XCTestCase { func testFromIncorrectProgression() async { let (publication, service) = makeService(positions: positionsFixture) + _ = publication // Silence warning var result = await service.locate(progression: -0.2) XCTAssertNil(result) @@ -122,6 +127,7 @@ class DefaultLocatorServiceTests: XCTestCase { func testFromProgressionEmptyPositions() async { let (publication, service) = makeService(positions: []) + _ = publication // Silence warning let result = await service.locate(progression: 0.5) XCTAssertNil(result) } @@ -130,6 +136,7 @@ class DefaultLocatorServiceTests: XCTestCase { let (publication, service) = makeService(readingOrder: [ Link(href: "/href", mediaType: .html, title: "Resource"), ]) + _ = publication // Silence warning let result = await service.locate(Link(href: "/href")) XCTAssertEqual( @@ -144,6 +151,7 @@ class DefaultLocatorServiceTests: XCTestCase { readingOrder: [Link(href: "/href1", mediaType: .html)], resources: [Link(href: "/href2", mediaType: .html)] ) + _ = publication // Silence warning var result = await service.locate(Link(href: "/href1")) XCTAssertEqual( @@ -168,6 +176,7 @@ class DefaultLocatorServiceTests: XCTestCase { let (publication, service) = makeService(readingOrder: [ Link(href: "/href", mediaType: .html, title: "Resource"), ]) + _ = publication // Silence warning let result = try await service.locate(Link(href: "/href#page=42", mediaType: XCTUnwrap(MediaType("text/xml")), title: "My link")) XCTAssertEqual( @@ -180,6 +189,7 @@ class DefaultLocatorServiceTests: XCTestCase { let (publication, service) = makeService(readingOrder: [ Link(href: "/href", mediaType: .html), ]) + _ = publication // Silence warning let result = await service.locate(Link(href: "/href", title: "My link")) XCTAssertEqual( @@ -192,6 +202,7 @@ class DefaultLocatorServiceTests: XCTestCase { let (publication, service) = makeService(readingOrder: [ Link(href: "/href", mediaType: .html), ]) + _ = publication // Silence warning let result = await service.locate(Link(href: "notfound")) XCTAssertNil(result) diff --git a/Tests/SharedTests/Toolkit/HTTP/DefaultHTTPClientTests.swift b/Tests/SharedTests/Toolkit/HTTP/DefaultHTTPClientTests.swift new file mode 100644 index 0000000000..716cee0f85 --- /dev/null +++ b/Tests/SharedTests/Toolkit/HTTP/DefaultHTTPClientTests.swift @@ -0,0 +1,1266 @@ +// +// Copyright 2026 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 +@testable import ReadiumShared +import Testing + +@Suite(.serialized) +struct DefaultHTTPClientTests { + @Suite(.serialized) + struct UserAgent { + @Test("Default user agent is set when none provided on request") + @MainActor func defaultUserAgentIsSet() async { + let receivedUserAgent = Capture(nil) + + let client = makeClient { request in + receivedUserAgent.value = request.value(forHTTPHeaderField: "User-Agent") + return .success() + } + + _ = await client.fetch(makeURL()) + + #expect(receivedUserAgent.value == DefaultHTTPClient.defaultUserAgent) + } + + @Test("Custom user agent overrides default") + func customUserAgent() async { + let receivedUserAgent = Capture(nil) + let customUA = "MyApp/1.0" + + let client = makeClient(userAgent: customUA) { request in + receivedUserAgent.value = request.value(forHTTPHeaderField: "User-Agent") + return .success() + } + + _ = await client.fetch(makeURL()) + + #expect(receivedUserAgent.value == customUA) + } + + @Test("Per-request user agent takes precedence over client default") + func perRequestUserAgent() async { + let receivedUserAgent = Capture(nil) + let requestUA = "RequestSpecific/2.0" + + let client = makeClient(userAgent: "ClientDefault/1.0") { request in + receivedUserAgent.value = request.value(forHTTPHeaderField: "User-Agent") + return .success() + } + + var request = HTTPRequest(url: makeURL()) + request.userAgent = requestUA + _ = await client.fetch(request) + + #expect(receivedUserAgent.value == requestUA) + } + } + + @Suite(.serialized) + struct Headers { + @Test("Additional headers from configuration are sent") + func additionalHeaders() async { + let receivedHeader = Capture(nil) + + let client = makeClient(additionalHeaders: ["X-Custom": "hello"]) { request in + receivedHeader.value = request.value(forHTTPHeaderField: "X-Custom") + return .success() + } + + _ = await client.fetch(makeURL()) + + #expect(receivedHeader.value == "hello") + } + + @Test("Per-request headers are sent") + func perRequestHeaders() async { + let receivedHeader = Capture(nil) + + let client = makeClient { request in + receivedHeader.value = request.value(forHTTPHeaderField: "X-Request") + return .success() + } + + let request = HTTPRequest(url: makeURL(), headers: ["X-Request": "value"]) + _ = await client.fetch(request) + + #expect(receivedHeader.value == "value") + } + } + + @Suite(.serialized) + struct Streaming { + @Test("Stream delivers data in chunks") + func streamDeliversChunks() async throws { + let chunk1 = Data("hello ".utf8) + let chunk2 = Data("world".utf8) + + let client = makeClient { _ in + .success(chunks: [chunk1, chunk2]) + } + + let receivedChunks = Capture<[Data]>([]) + + let result = await client.stream(makeURL()) { data, _ in + receivedChunks.value.append(data) + return .success(()) + } + + let response = try result.get() + #expect(response.status == .ok) + // URLSession coalesces chunks, so verify total data. + let totalData = receivedChunks.value.reduce(Data(), +) + #expect(totalData == chunk1 + chunk2) + } + + @Test("Stream reports progress when Content-Length is known") + func streamReportsProgress() async { + let body = Data("hello world".utf8) + + let lastProgress = Capture(nil) + + let client = makeClient { _ in + .success( + headers: ["Content-Length": "\(body.count)"], + body: body + ) + } + + _ = await client.stream(makeURL()) { _, progress in + if let progress = progress { + lastProgress.value = progress + } + return .success(()) + } + + // Final progress should be 1.0 (all data received) + #expect(lastProgress.value == 1.0) + } + + @Test("Stream reports nil progress when Content-Length is unknown") + func streamReportsNilProgressWhenContentLengthUnknown() async throws { + let progress = Capture(nil) + + let client = makeClient { _ in + .success(body: Data("data".utf8)) + } + + let result = await client.stream(makeURL()) { _, p in + if let p { + progress.value = p + } + return .success(()) + } + + _ = try result.get() + #expect(progress.value == nil) + } + + @Test("onReceiveResponse receives correct response metadata") + func onReceiveResponseReceivesCorrectMetadata() async { + let receivedResponse = Capture(nil) + + let client = makeClient { _ in + .success( + headers: ["X-Custom": "test-value", "Content-Type": "text/plain"], + body: Data("hello".utf8) + ) + } + + _ = await client.stream( + makeURL(), + onReceiveResponse: captureResponse(in: receivedResponse) + ) { _, _ in .success(()) } + + let response = receivedResponse.value! + #expect(response.status == .ok) + #expect(response.valueForHeader("X-Custom") == "test-value") + } + + @Test("onReceiveResponse success allows data to flow through consume") + func onReceiveResponseSuccessAllowsDataToFlow() async throws { + let body = Data("hello world".utf8) + let receivedData = Capture(Data()) + + let client = makeClient { _ in + .success(body: body) + } + + let result = await client.stream( + makeURL(), + onReceiveResponse: { _ in .success(()) } + ) { data, _ in + receivedData.value.append(data) + return .success(()) + } + + _ = try result.get() + #expect(receivedData.value == body) + } + + @Test("onReceiveResponse is called before consume receives data") + func onReceiveResponseIsCalledBeforeConsume() async { + let callOrder = Mutex<[String]>([]) + + let client = makeClient { _ in + .success(body: Data("data".utf8)) + } + + _ = await client.stream( + makeURL(), + onReceiveResponse: { _ in + callOrder.withLock { $0.append("onReceiveResponse") } + return .success(()) + } + ) { _, _ in + callOrder.withLock { $0.append("consume") } + return .success(()) + } + + let order = callOrder.withLock { $0 } + #expect(order.first == "onReceiveResponse") + #expect(order.contains("consume")) + } + + @Test("onReceiveResponse is not called for HTTP error responses") + func onReceiveResponseNotCalledOnHTTPError() async { + let called = Capture(false) + + let client = makeClient { _ in + .success(statusCode: 401) + } + + _ = await client.stream( + makeURL(), + onReceiveResponse: { _ in + called.value = true + return .success(()) + } + ) { _, _ in .success(()) } + + #expect(!called.value) + } + + @Test("Returning failure from onReceiveResponse aborts the stream") + func onReceiveResponseFailureAbortsStream() async { + let client = makeClient { _ in .success() } + + let result = await client.stream( + makeURL(), + onReceiveResponse: { _ in .failure(.offline(nil)) } + ) { _, _ in .success(()) } + + guard case .failure(.offline(nil)) = result else { + Issue.record("Expected .offline failure but got \(result)") + return + } + } + + @Test("Returning failure from consume aborts the stream") + func consumeFailureAbortsStream() async { + let largeBody = Data(repeating: 0x42, count: 1024) + + let client = makeClient { _ in + .success( + headers: ["Content-Length": "\(largeBody.count)"], + chunks: [ + Data(largeBody[0 ..< 512]), + Data(largeBody[512...]), + ] + ) + } + + let result = await client.stream(makeURL()) { _, _ in + .failure(.offline(nil)) + } + + guard case .failure(.offline(nil)) = result else { + Issue.record("Expected .offline failure but got \(result)") + return + } + } + } + + @Suite(.serialized) + struct Fetch { + @Test("Fetch accumulates streamed data into response body") + func fetchAccumulatesData() async throws { + let chunk1 = Data("hello ".utf8) + let chunk2 = Data("world".utf8) + + let client = makeClient { _ in + .success(chunks: [chunk1, chunk2]) + } + + let response = try await client.fetch(makeURL()).get() + + #expect(response.body == chunk1 + chunk2) + } + + @Test("Fetch returns correct media type") + func fetchReturnsMetadata() async throws { + let client = makeClient { _ in + .success( + headers: [ + "Content-Type": "text/plain", + ] + ) + } + + let response = try await client.fetch(makeURL()).get() + + #expect(response.mediaType?.string == "text/plain") + } + + @Test("fetchString returns decoded string") + func fetchString() async throws { + let text = "Hello, Readium!" + + let client = makeClient { _ in + .success( + headers: ["Content-Type": "text/plain; charset=utf-8"], + body: Data(text.utf8) + ) + } + + let result = try await client.fetchString(makeURL()).get() + #expect(result == text) + } + + @Test("fetchJSON parses a JSON object") + func fetchJSON() async throws { + let client = makeClient { _ in + .success( + headers: ["Content-Type": "application/json"], + body: Data(#"{"key": "value"}"#.utf8) + ) + } + + let json = try await client.fetchJSON(makeURL()).get() + #expect(json["key"] as? String == "value") + } + + @Test("fetch with decoder returns malformedResponse when decoder returns nil") + func fetchDecoderReturnsNil() async { + let client = makeClient { _ in + .success(body: Data("not-json".utf8)) + } + + let result = await client.fetch(makeURL()) { _ in nil as String? } + + guard case .failure(.malformedResponse) = result else { + Issue.record("Expected .malformedResponse, got \(result)") + return + } + } + + @Test("fetch with decoder returns malformedResponse when decoder throws") + func fetchDecoderThrows() async { + struct DecoderError: Error {} + + let client = makeClient { _ in + .success(body: Data("not-json".utf8)) + } + + let result = await client.fetch(makeURL()) { _ -> String? in + throw DecoderError() + } + + guard case .failure(.malformedResponse) = result else { + Issue.record("Expected .malformedResponse, got \(result)") + return + } + } + } + + @Suite(.serialized) + struct Download { + @Test("Download writes data to a temporary file") + func downloadWritesToFile() async throws { + let content = Data("file content".utf8) + + let client = makeClient { _ in + .success( + headers: [ + "Content-Length": "\(content.count)", + "Content-Type": "application/octet-stream", + ], + body: content + ) + } + + let download = try await client + .download(makeURL()) { _ in } + .get() + + #expect(download.suggestedFilename == nil) + + let downloadedData = try Data(contentsOf: download.location.url) + #expect(downloadedData == content) + + try FileManager.default.removeItem(at: download.location.url) + } + + @Test("Download reports progress") + func downloadReportsProgress() async throws { + let content = Data(repeating: 0x42, count: 1024) + + let client = makeClient { _ in + .success( + headers: ["Content-Length": "\(content.count)"], + body: content + ) + } + + let lastProgress = Mutex(nil) + + let download = try await client + .download(makeURL()) { progress in + lastProgress.withLock { $0 = progress } + } + .get() + + #expect(lastProgress.withLock { $0 } == 1.0) + + try FileManager.default.removeItem(at: download.location.url) + } + + @Test("Download cleans up temporary file on failure") + func downloadCleansUpOnFailure() async { + let client = makeClient { _ in + .success(statusCode: 500, body: Data("error".utf8)) + } + + let tempDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + let countBefore = (try? FileManager.default.contentsOfDirectory(atPath: tempDir.path))?.count ?? 0 + + let result = await client + .download(makeURL()) { _ in } + + let countAfter = (try? FileManager.default.contentsOfDirectory(atPath: tempDir.path))?.count ?? 0 + + guard case .failure = result else { + Issue.record("Expected failure") + return + } + #expect(countAfter == countBefore, "Temporary file should be deleted on failure") + } + + @Test("Download preserves suggested filename from Content-Disposition") + func downloadSuggestedFilename() async throws { + let client = makeClient { _ in + .success( + headers: [ + "Content-Disposition": "attachment; filename=book.epub", + "Content-Type": "application/epub+zip", + ], + body: Data("epub".utf8) + ) + } + + let download = try await client + .download(makeURL()) { _ in } + .get() + + #expect(download.suggestedFilename == "book.epub") + + try FileManager.default.removeItem(at: download.location.url) + } + + @Test("Download preserves RFC 5987 encoded filename from Content-Disposition") + func downloadRFC5987Filename() async throws { + let client = makeClient { _ in + .success( + headers: [ + "Content-Disposition": "attachment; filename*=UTF-8''bel%C3%A9tr%C3%A9s.epub", + "Content-Type": "application/epub+zip", + ], + body: Data("epub".utf8) + ) + } + + let download = try await client + .download(makeURL()) { _ in } + .get() + + #expect(download.suggestedFilename == "belétrés.epub") + + try FileManager.default.removeItem(at: download.location.url) + } + + @Test("Download returns the media type from Content-Type") + func downloadMediaType() async throws { + let client = makeClient { _ in + .success( + headers: ["Content-Type": "application/epub+zip"], + body: Data("epub".utf8) + ) + } + + let download = try await client + .download(makeURL()) { _ in } + .get() + + #expect(download.mediaType == MediaType.epub) + + try FileManager.default.removeItem(at: download.location.url) + } + } + + @Suite(.serialized) + struct HTTPErrors { + @Test( + "HTTP error status codes return .errorResponse", + arguments: [400, 401, 403, 404, 405, 500] + ) + func httpErrorStatusCodes(statusCode: HTTPStatus) async { + let client = makeClient { _ in + .success(statusCode: statusCode, body: Data()) + } + + let result = await client.fetch(makeURL()) + + guard case let .failure(.errorResponse(response)) = result else { + Issue.record("Expected .errorResponse for status \(statusCode)") + return + } + #expect(response.status == statusCode) + } + + @Test("Error response body is accumulated") + func errorResponseIncludesBody() async { + let errorBody = Data(""" + {"type": "https://example.com/auth", "title": "Authentication Required"} + """.utf8) + + let client = makeClient { _ in + .success( + statusCode: 401, + headers: ["Content-Type": "application/problem+json"], + body: errorBody + ) + } + + let result = await client.fetch(makeURL()) + + guard case let .failure(.errorResponse(response)) = result else { + Issue.record("Expected .errorResponse") + return + } + #expect(response.body == errorBody) + } + + @Test( + "2xx status codes are treated as success", + arguments: [200, 201, 204] + ) + func successStatusCodes(statusCode: HTTPStatus) async { + let client = makeClient { _ in + .success(statusCode: statusCode, body: Data("ok".utf8)) + } + + let result = await client.fetch(makeURL()) + + guard case .success = result else { + Issue.record("Status \(statusCode) should be success but got \(result)") + return + } + } + } + + @Suite(.serialized) + struct Redirects { + @Test("HTTP redirects are followed automatically") + func redirectsAreFollowedAutomatically() async throws { + let client = makeClient { request in + switch request.url?.path { + case "/final": + return .success(body: Data("final response".utf8)) + default: + return .redirect(to: makeURL("/final").url) + } + } + + let httpResponse = Capture(nil) + + let fetchResponse = try await client.fetch( + makeURL(), + onReceiveResponse: captureResponse(in: httpResponse) + ).get() + + #expect(fetchResponse.body == Data("final response".utf8)) + #expect(httpResponse.value?.url.isEquivalentTo(makeURL("/final")) == true) + } + } + + @Suite(.serialized) + struct NetworkErrors { + @Test("URLError propagates to HTTPError") + func urlErrorPropagation() async { + let client = makeClient { _ in + .error(URLError(.cannotConnectToHost)) + } + + let result = await client.fetch(makeURL()) + + guard case .failure(.unreachable) = result else { + Issue.record("Expected .unreachable error, got \(result)") + return + } + } + + @Test("Timeout returns .timeout error") + func timeoutError() async { + let client = makeClient(requestTimeout: 1) { _ in + .delayed(seconds: 60, then: .success()) + } + + let result = await client.fetch(makeURL()) + + guard case .failure(.timeout) = result else { + Issue.record("Expected .timeout error, got \(result)") + return + } + } + } + + @Suite(.serialized) + struct Cancellation { + @Test("Cancelling the Swift task cancels the HTTP request") + func cancelledTaskReturnsCancelledError() async { + let client = makeClient { _ in + .delayed(seconds: 2, then: .success(body: Data("late".utf8))) + } + + let task = Task { + await client.fetch(makeURL()) + } + + // Give the request time to start, then cancel. + try? await Task.sleep(nanoseconds: 100_000_000) // 100ms + task.cancel() + + let result = await task.value + + guard case .failure(.cancelled) = result else { + Issue.record("Expected failure after cancellation, got \(result)") + return + } + } + + @Test("Cancelling the Swift task during an active stream returns .cancelled") + func cancelledTaskDuringStreamReturnsCancelledError() async { + // Use a real HTTP request, the MockURLProtocol cannot be used + // reliably for this test. + let client = DefaultHTTPClient() + + let task = Task { + await client.stream(HTTPURL(string: "https://httpbin.org/drip?duration=10&numbytes=102400&chunk_size=1024")!) { _, _ in .success(()) } + } + + // Give the request time to start, then cancel before the end. + try? await Task.sleep(nanoseconds: 1_000_000_000) // 1s + task.cancel() + + let result = await task.value + + guard case .failure(.cancelled) = result else { + Issue.record("Expected .cancelled failure during stream, got \(result)") + return + } + } + + @Test("Cancelled request returns .cancelled error") + func cancelledError() async { + let client = makeClient { _ in + .error(URLError(.cancelled)) + } + + let result = await client.fetch(makeURL()) + + guard case .failure(.cancelled) = result else { + Issue.record("Expected .cancelled error, got \(result)") + return + } + } + } + + @Suite(.serialized) + struct RangeRequests { + @Test("Range request succeeds when server signals Accept-Ranges") + func rangeRequestSuccessViaAcceptRanges() async throws { + let partialContent = Data("partial".utf8) + + let client = makeClient { request in + let rangeHeader = request.value(forHTTPHeaderField: "Range") + #expect(rangeHeader != nil) + + return .success( + statusCode: 206, + headers: [ + "Accept-Ranges": "bytes", + "Content-Length": "\(partialContent.count)", + ], + body: partialContent + ) + } + + var httpRequest = HTTPRequest(url: makeURL()) + httpRequest.setRange(0 ..< 7) + + let response = Capture(nil) + let result = try await client.fetch( + httpRequest, + onReceiveResponse: captureResponse(in: response) + ).get() + + #expect(response.value?.status == .partialContent) + #expect(result.body == partialContent) + } + + @Test("Range request succeeds when server signals Content-Range without Accept-Ranges") + func rangeRequestSuccessViaContentRange() async throws { + let partialContent = Data("partial".utf8) + + let client = makeClient { _ in + .success( + statusCode: 206, + headers: [ + "Content-Range": "bytes 0-6/100", + "Content-Length": "\(partialContent.count)", + ], + body: partialContent + ) + } + + var httpRequest = HTTPRequest(url: makeURL()) + httpRequest.setRange(0 ..< 7) + + let response = Capture(nil) + let result = try await client.fetch( + httpRequest, + onReceiveResponse: captureResponse(in: response) + ).get() + + #expect(response.value?.status == .partialContent) + #expect(result.body == partialContent) + } + + @Test("Range request fails when server does not support byte ranges") + func rangeRequestFailsWithoutServerSupport() async { + let client = makeClient { _ in + .success( + statusCode: 200, + headers: [:], + body: Data("full content".utf8) + ) + } + + var httpRequest = HTTPRequest(url: makeURL()) + httpRequest.setRange(0 ..< 7) + + let result = await client.fetch(httpRequest) + + guard case .failure(.rangeNotSupported) = result else { + Issue.record("Expected .rangeNotSupported, got \(result)") + return + } + } + + @Test("Open-ended setRange omits upper bound in Range header") + func openEndedRangeRequest() async { + let receivedRange = Capture(nil) + + let client = makeClient { request in + receivedRange.value = request.value(forHTTPHeaderField: "Range") + return .success( + statusCode: 206, + headers: ["Accept-Ranges": "bytes"], + body: Data("tail".utf8) + ) + } + + var httpRequest = HTTPRequest(url: makeURL()) + httpRequest.setRange(5...) + _ = await client.fetch(httpRequest) + + #expect(receivedRange.value == "bytes=5-") + } + } + + @Suite(.serialized) + struct DelegateCallbacks { + @Test("willStartRequest is called before the request") + func willStartRequestIsCalled() async { + let delegate = SpyDelegate() + let client = makeClient(delegate: delegate) { _ in + .success() + } + + _ = await client.fetch(makeURL()) + + #expect(delegate.willStartRequestCalled) + } + + @Test("willStartRequest can modify the request") + func willStartRequestModifiesRequest() async { + let receivedHeader = Capture(nil) + + let delegate = SpyDelegate() + delegate.onWillStartRequest = { request in + var modified = request + modified.headers["X-Injected"] = "by-delegate" + return .success(modified) + } + + let client = makeClient(delegate: delegate) { request in + receivedHeader.value = request.value(forHTTPHeaderField: "X-Injected") + return .success() + } + + _ = await client.fetch(makeURL()) + + #expect(receivedHeader.value == "by-delegate") + } + + @Test("willStartRequest returning failure aborts the request without sending it") + func willStartRequestFailureAbortsRequest() async { + let delegate = SpyDelegate() + delegate.onWillStartRequest = { _ in + .failure(.cancelled) + } + + let client = makeClient(delegate: delegate) { _ in + Issue.record("Request should not have been sent") + return .success(body: Data()) + } + + let result = await client.fetch(makeURL()) + + guard case .failure(.cancelled) = result else { + Issue.record("Expected .cancelled from willStartRequest failure, got \(result)") + return + } + #expect(!delegate.didFailWithErrorCalled) + } + + @Test("didReceiveResponse is called on success") + func didReceiveResponseIsCalled() async { + let delegate = SpyDelegate() + let client = makeClient(delegate: delegate) { _ in + .success() + } + + _ = await client.fetch(makeURL()) + + #expect(delegate.didReceiveResponseCalled) + } + + @Test("didReceiveResponse is called for error HTTP responses") + func didReceiveResponseIsCalledForErrors() async { + let delegate = SpyDelegate() + let client = makeClient(delegate: delegate) { _ in + .success(statusCode: 401, body: Data("unauthorized".utf8)) + } + + _ = await client.fetch(makeURL()) + + #expect(delegate.didReceiveResponseCalled) + #expect(delegate.lastResponse?.status == .unauthorized) + } + + @Test("didFailWithError is called on failure") + func didFailWithErrorIsCalled() async { + let delegate = SpyDelegate() + let client = makeClient(delegate: delegate) { _ in + .error(URLError(.timedOut)) + } + + _ = await client.fetch(makeURL()) + + #expect(delegate.didFailWithErrorCalled) + } + + @Test("recoverRequest can retry with a new request") + func recoverRequestRetries() async throws { + let requestCount = Capture(0) + + let delegate = SpyDelegate() + delegate.onRecoverRequest = { request, _ in + // Retry the same request + .success(request) + } + + let client = makeClient(delegate: delegate) { _ in + requestCount.value += 1 + if requestCount.value == 1 { + return .error(URLError(.timedOut)) + } + return .success(body: Data("recovered".utf8)) + } + + let result = await client.fetch(makeURL()) + + let response = try result.get() + #expect(response.body == Data("recovered".utf8)) + #expect(requestCount.value == 2) + } + + @Test("recoverRequest propagates error when unrecoverable") + func recoverRequestPropagatesError() async { + let delegate = SpyDelegate() + delegate.onRecoverRequest = { _, error in + .failure(error) + } + + let client = makeClient(delegate: delegate) { _ in + .error(URLError(.timedOut)) + } + + let result = await client.fetch(makeURL()) + + guard case .failure(.timeout) = result else { + Issue.record("Expected .timeout, got \(result)") + return + } + #expect(delegate.didFailWithErrorCalled) + } + + @Test("willStartRequest can redirect to a different URL") + func willStartRequestRedirects() async throws { + let delegate = SpyDelegate() + delegate.onWillStartRequest = { _ in + let redirectURL = HTTPURL(string: "https://example.com/redirected")! + return .success(HTTPRequest(url: redirectURL)) + } + + let client = makeClient(delegate: delegate) { request in + switch request.url?.path { + case "/redirected": + return .success(body: Data("redirected response".utf8)) + default: + return .success(statusCode: 404, body: Data()) + } + } + + let response = try await client.fetch(makeURL()).get() + #expect(response.body == Data("redirected response".utf8)) + } + } + + @Suite(.serialized) + struct AuthenticationChallenges { + @Test("Delegate receives authentication challenge") + func delegateReceivesChallenge() async { + let delegate = SpyDelegate() + delegate.onDidReceiveChallenge = { _ in + .performDefaultHandling + } + + let client = makeClient(delegate: delegate) { _ in + .authenticationChallenge( + host: "example.com", + method: NSURLAuthenticationMethodHTTPBasic, + then: .success(body: Data("authenticated".utf8)) + ) + } + + _ = await client.fetch(makeURL()) + + #expect(delegate.didReceiveChallengeCalled) + } + + @Test("Using credentials succeeds after authentication challenge") + func useCredentialSucceeds() async throws { + let delegate = SpyDelegate() + delegate.onDidReceiveChallenge = { _ in + let credential = URLCredential(user: "user", password: "pass", persistence: .none) + return .useCredential(credential) + } + + let client = makeClient(delegate: delegate) { _ in + .authenticationChallenge( + host: "example.com", + method: NSURLAuthenticationMethodHTTPBasic, + then: .success(body: Data("authenticated".utf8)) + ) + } + + let result = try await client.fetch(makeURL()).get() + #expect(result.body == Data("authenticated".utf8)) + } + + @Test("Cancelling authentication fails with HTTPError.cancelled") + func cancellingAuthenticationChallengePropagates() async { + let delegate = SpyDelegate() + delegate.onDidReceiveChallenge = { _ in + .cancelAuthenticationChallenge + } + + let client = makeClient(delegate: delegate) { _ in + .authenticationChallenge( + host: "example.com", + method: NSURLAuthenticationMethodHTTPBasic, + then: .success(body: Data("authenticated".utf8)) + ) + } + + let result = await client.fetch(makeURL()) + + guard case .failure(.cancelled) = result else { + Issue.record("Expected HTTPError.cancelled when cancelling an authentication challenge") + return + } + } + } + + @Suite(.serialized) + struct Configuration { + @Test("Request timeout is passed to URLSessionConfiguration") + func requestTimeoutIsApplied() async { + let receivedTimeout = Capture(nil) + + let client = makeClient(requestTimeout: 42) { request in + receivedTimeout.value = request.timeoutInterval + return .success() + } + + _ = await client.fetch(makeURL()) + #expect(receivedTimeout.value == 42) + } + + @Test("Per-request timeout overrides session timeout") + func perRequestTimeoutOverridesSession() async { + let receivedTimeout = Capture(nil) + + let client = makeClient(requestTimeout: 60.0) { request in + receivedTimeout.value = request.timeoutInterval + return .success() + } + + var request = HTTPRequest(url: makeURL()) + request.timeoutInterval = 5.0 + _ = await client.fetch(request) + + #expect(receivedTimeout.value == 5.0) + } + + @Test("HTTP method is correctly transmitted") + func httpMethodIsTransmitted() async { + let receivedMethod = Capture(nil) + + let client = makeClient { request in + receivedMethod.value = request.httpMethod + return .success() + } + + let request = HTTPRequest(url: makeURL(), method: .post) + _ = await client.fetch(request) + + #expect(receivedMethod.value == "POST") + } + + @Test("Request body is transmitted for POST requests") + func requestBodyIsTransmitted() async { + let receivedBody = Capture(nil) + + let client = makeClient { request in + if let stream = request.httpBodyStream { + stream.open() + var data = Data() + let buffer = UnsafeMutablePointer.allocate(capacity: 1024) + defer { buffer.deallocate() } + while stream.hasBytesAvailable { + let bytesRead = stream.read(buffer, maxLength: 1024) + if bytesRead > 0 { + data.append(buffer, count: bytesRead) + } + } + stream.close() + receivedBody.value = data + } else { + receivedBody.value = request.httpBody + } + return .success() + } + + let bodyData = Data("request body".utf8) + let request = HTTPRequest(url: makeURL(), method: .post, body: .data(bodyData)) + _ = await client.fetch(request) + + #expect(receivedBody.value == bodyData) + } + + @Test("File body is transmitted for POST requests") + func fileBodyIsTransmitted() async throws { + let receivedBody = Capture(nil) + + let client = makeClient { request in + receivedBody.value = request.body() + return .success() + } + + let bodyData = Data("file body content".utf8) + let fileURL = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(UUID().uuidString) + try bodyData.write(to: fileURL) + defer { try? FileManager.default.removeItem(at: fileURL) } + + let request = HTTPRequest(url: makeURL(), method: .post, body: .file(fileURL)) + _ = await client.fetch(request) + + #expect(receivedBody.value == bodyData) + } + + @Test("setPOSTForm sets POST method and URL-encodes form fields") + func setPOSTFormEncodesData() async { + let receivedMethod = Capture(nil) + let receivedContentType = Capture(nil) + let receivedBody = Capture(nil) + + let client = makeClient { request in + receivedMethod.value = request.httpMethod + receivedContentType.value = request.value(forHTTPHeaderField: "Content-Type") + receivedBody.value = request.stringBody() + return .success() + } + + var request = HTTPRequest(url: makeURL()) + request.setPOSTForm(["name": "Alice", "age": "30"]) + _ = await client.fetch(request) + + #expect(receivedMethod.value == "POST") + #expect(receivedContentType.value == "application/x-www-form-urlencoded") + let body = receivedBody.value ?? "" + #expect(body.contains("name=Alice")) + #expect(body.contains("age=30")) + } + } +} + +private extension URLRequest { + func stringBody(encoding: String.Encoding = .utf8) -> String? { + body().flatMap { String(data: $0, encoding: encoding) } + } + + func body() -> Data? { + if let httpBody { + return httpBody + } else if let stream = httpBodyStream { + stream.open() + var data = Data() + let buffer = UnsafeMutablePointer.allocate(capacity: 1024) + defer { buffer.deallocate() } + while stream.hasBytesAvailable { + let bytesRead = stream.read(buffer, maxLength: 1024) + if bytesRead > 0 { data.append(buffer, count: bytesRead) } + } + stream.close() + return data + } else { + return nil + } + } +} + +/// Creates a `DefaultHTTPClient` configured with `MockURLProtocol` +/// for intercepting all requests. +private func makeClient( + userAgent: String? = nil, + additionalHeaders: [String: String]? = nil, + requestTimeout: TimeInterval? = nil, + resourceTimeout: TimeInterval? = nil, + delegate: DefaultHTTPClientDelegate? = nil, + handler: @escaping @Sendable (URLRequest) -> MockURLResponse +) -> DefaultHTTPClient { + MockURLProtocol.handler = handler + + return DefaultHTTPClient( + userAgent: userAgent, + ephemeral: true, + additionalHeaders: additionalHeaders, + requestTimeout: requestTimeout, + resourceTimeout: resourceTimeout, + delegate: delegate, + configure: { config in + config.protocolClasses = [MockURLProtocol.self] + } + ) +} + +private func captureResponse(in response: Capture) -> @Sendable (HTTPResponse) -> HTTPResult { + { resp in + response.value = resp + return .success(()) + } +} + +private func makeURL(_ path: String = "/test") -> HTTPURL { + HTTPURL(string: "https://example.com\(path)")! +} + +/// A test spy implementing `DefaultHTTPClientDelegate` that records calls +/// and allows customizing behavior via closures. +private class SpyDelegate: DefaultHTTPClientDelegate, @unchecked Sendable { + var willStartRequestCalled = false + var didReceiveResponseCalled = false + var didFailWithErrorCalled = false + var didReceiveChallengeCalled = false + + var lastRequest: HTTPRequest? + var lastResponse: HTTPResponse? + var lastError: HTTPError? + var lastChallenge: URLAuthenticationChallenge? + + var onWillStartRequest: ((HTTPRequest) -> HTTPResult)? + var onRecoverRequest: ((HTTPRequest, HTTPError) -> HTTPResult)? + var onDidReceiveChallenge: ((URLAuthenticationChallenge) -> URLAuthenticationChallengeResponse)? + + func httpClient( + _ httpClient: DefaultHTTPClient, + willStartRequest request: HTTPRequest + ) async -> HTTPResult { + willStartRequestCalled = true + lastRequest = request + return onWillStartRequest?(request) ?? .success(request) + } + + func httpClient( + _ httpClient: DefaultHTTPClient, + recoverRequest request: HTTPRequest, + fromError error: HTTPError + ) async -> HTTPResult { + onRecoverRequest?(request, error) ?? .failure(error) + } + + func httpClient( + _ httpClient: DefaultHTTPClient, + request: HTTPRequest, + didReceiveResponse response: HTTPResponse + ) { + didReceiveResponseCalled = true + lastResponse = response + } + + func httpClient( + _ httpClient: DefaultHTTPClient, + request: HTTPRequest, + didFailWithError error: HTTPError + ) { + didFailWithErrorCalled = true + lastError = error + } + + func httpClient( + _ httpClient: DefaultHTTPClient, + request: HTTPRequest, + didReceive challenge: URLAuthenticationChallenge + ) async -> URLAuthenticationChallengeResponse { + didReceiveChallengeCalled = true + lastChallenge = challenge + return onDidReceiveChallenge?(challenge) ?? .performDefaultHandling + } +} diff --git a/Tests/SharedTests/Toolkit/HTTP/HTTPProblemDetailsTests.swift b/Tests/SharedTests/Toolkit/HTTP/HTTPProblemDetailsTests.swift index a2a639a3b9..7a78bd57f3 100644 --- a/Tests/SharedTests/Toolkit/HTTP/HTTPProblemDetailsTests.swift +++ b/Tests/SharedTests/Toolkit/HTTP/HTTPProblemDetailsTests.swift @@ -4,46 +4,72 @@ // available in the top-level LICENSE file of the project. // +import Foundation @testable import ReadiumShared -import XCTest +import Testing -class HTTPProblemDetailsTests: XCTestCase { +struct HTTPProblemDetailsTests { /// Parses a minimal Problem Details JSON. - func testParseMinimalJSON() throws { + @Test func parseMinimalJSON() throws { let json = """ {"title": "You do not have enough credit."} """.data(using: .utf8)! - XCTAssertEqual(try (HTTPProblemDetails(data: json)).title, "You do not have enough credit.") + let details = try HTTPProblemDetails(data: json) + #expect(details.title == "You do not have enough credit.") } /// Parses a full Problem Details JSON. - func testParseFullJSON() throws { + @Test func parseFullJSON() throws { let json = """ { "type": "https://example.net/validation-error", "title": "Your request parameters didn't validate.", "status": 400, + "detail": "Age must be a positive integer.", + "instance": "https://example.net/validation-error/123", "invalid-params": [ { "name": "age", "reason": "must be a positive integer" - }, - { - "name": "color", - "reason": "must be 'green', 'red' or 'blue'" } ] } """.data(using: .utf8)! - XCTAssertEqual( - try HTTPProblemDetails(data: json), - HTTPProblemDetails( - title: "Your request parameters didn't validate.", - type: "https://example.net/validation-error", - status: 400 - ) + let details = try HTTPProblemDetails(data: json) + #expect(details.title == "Your request parameters didn't validate.") + #expect(details.type == "https://example.net/validation-error") + #expect(details.status == 400) + #expect(details.detail == "Age must be a positive integer.") + #expect(details.instance == "https://example.net/validation-error/123") + } + + @Test func parseInvalidJSON() { + let json = """ + {"not-a-title": "Missing title"} + """.data(using: .utf8)! + + #expect(throws: HTTPProblemDetails.Error.self) { + try HTTPProblemDetails(data: json) + } + } + + @Test func extractFromHTTPError() throws { + let json = """ + {"title": "Forbidden action"} + """.data(using: .utf8)! + + let response = HTTPErrorResponse( + status: .forbidden, + body: json, + mediaType: .problemDetails, + headers: ["Content-Type": "application/problem+json"] ) + + let error = HTTPError.errorResponse(response) + let details = try error.problemDetails() + + #expect(details?.title == "Forbidden action") } } diff --git a/Tests/SharedTests/Toolkit/HTTP/HTTPRequestTests.swift b/Tests/SharedTests/Toolkit/HTTP/HTTPRequestTests.swift new file mode 100644 index 0000000000..db3d2afd5f --- /dev/null +++ b/Tests/SharedTests/Toolkit/HTTP/HTTPRequestTests.swift @@ -0,0 +1,54 @@ +// +// Copyright 2026 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 +@testable import ReadiumShared +import Testing + +struct HTTPRequestTests { + private let url = HTTPURL(string: "http://example.com")! + + @Test func setRange() { + var request = HTTPRequest(url: url) + + request.setRange(0 ..< 100) + #expect(request.headers["Range"] == "bytes=0-99") + + request.setRange(100 ..< 200) + #expect(request.headers["Range"] == "bytes=100-199") + } + + @Test func setRangeUntilEnd() { + var request = HTTPRequest(url: url) + + request.setRange(100...) + #expect(request.headers["Range"] == "bytes=100-") + } + + @Test func setPOSTForm() { + var request = HTTPRequest(url: url) + request.setPOSTForm([ + "field1": "value1", + "field2": "value with spaces", + "field3": "special&*characters", + "field4": nil, + ]) + + #expect(request.method == .post) + #expect(request.headers["Content-Type"] == "application/x-www-form-urlencoded") + + if case let .data(data) = request.body, let bodyString = String(data: data, encoding: .utf8) { + let parts = bodyString.split(separator: "&") + #expect(parts.contains("field1=value1")) + #expect(parts.contains("field2=value+with+spaces")) + #expect(parts.contains("field3=special%26*characters")) + #expect(parts.contains("field4=")) + #expect(parts.count == 4) + } else { + Issue.record("Expected data body") + } + } +} diff --git a/Tests/SharedTests/Toolkit/HTTP/HTTPResourceTests.swift b/Tests/SharedTests/Toolkit/HTTP/HTTPResourceTests.swift new file mode 100644 index 0000000000..e45ff87225 --- /dev/null +++ b/Tests/SharedTests/Toolkit/HTTP/HTTPResourceTests.swift @@ -0,0 +1,105 @@ +// +// Copyright 2026 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 +@testable import ReadiumShared +import Testing + +struct HTTPResourceTests { + private let url = HTTPURL(string: "http://example.com/book.epub")! + + class MockHTTPClient: HTTPClient { + struct Response { + let response: HTTPResponse + let body: Data + } + + var fetchResults: [String: HTTPResult] = [:] + var fetchCount = 0 + + func stream( + _ request: HTTPRequestConvertible, + onReceiveResponse: ((HTTPResponse) async -> HTTPResult)?, + consume: (Data, Double?) -> HTTPResult + ) async -> HTTPResult { + let req = try! request.httpRequest().get() + let key = "\(req.method.rawValue) \(req.url.string)" + fetchCount += 1 + + if let result = fetchResults[key] { + switch result { + case let .success(response): + if let onReceiveResponse = onReceiveResponse { + let _ = await onReceiveResponse(response.response) + } + _ = consume(response.body, 1.0) + return .success(response.response) + case let .failure(error): + return .failure(error) + } + } + return .failure(.cancelled) + } + } + + @Test func headResponseIsCached() async throws { + let client = MockHTTPClient() + let resource = HTTPResource(url: url, client: client) + + client.fetchResults["GET \(url.string)"] = .success(.init( + response: HTTPResponse( + request: HTTPRequest(url: url), + url: url, + status: .ok, + headers: ["Content-Length": "1024"], + mediaType: .epub + ), + body: Data() + )) + + let length1 = await resource.estimatedLength() + try #expect(length1.get() == 1024) + #expect(client.fetchCount == 1) + + let length2 = await resource.estimatedLength() + try #expect(length2.get() == 1024) + #expect(client.fetchCount == 1) // Should be cached + } + + @Test func headResponseFallbackOnMethodNotAllowed() async throws { + let client = MockHTTPClient() + let resource = HTTPResource(url: url, client: client) + + let response = HTTPErrorResponse(status: .methodNotAllowed) + client.fetchResults["GET \(url.string)"] = .failure(.errorResponse(response)) + + let length = await resource.estimatedLength() + try #expect(length.get() == nil) + #expect(client.fetchCount == 1) + } + + @Test func streamWithRange() async throws { + let client = MockHTTPClient() + let resource = HTTPResource(url: url, client: client) + + client.fetchResults["GET \(url.string)"] = try .success(.init( + response: HTTPResponse( + request: HTTPRequest(url: url), + url: url, + status: .partialContent, + headers: ["Content-Range": "bytes 0-9/100"], + mediaType: .epub + ), + body: #require("0123456789".data(using: .utf8)) + )) + + var streamedData = Data() + let result = await resource.stream(range: 0 ..< 10, consume: { streamedData.append($0) }) + + try result.get() + #expect(streamedData == "0123456789".data(using: .utf8)) + } +} diff --git a/Tests/SharedTests/Toolkit/HTTP/HTTPResponseTests.swift b/Tests/SharedTests/Toolkit/HTTP/HTTPResponseTests.swift new file mode 100644 index 0000000000..aff97b0153 --- /dev/null +++ b/Tests/SharedTests/Toolkit/HTTP/HTTPResponseTests.swift @@ -0,0 +1,67 @@ +// +// Copyright 2026 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 +@testable import ReadiumShared +import Testing + +@Suite("HTTPResponse") +struct HTTPResponseTests { + private let request = HTTPRequest(url: HTTPURL(string: "http://example.com")!) + private let url = HTTPURL(string: "http://example.com")! + + @Test func valueForHeader() { + let response = HTTPResponse( + request: request, + url: url, + status: .ok, + headers: ["Content-Type": "application/pdf", "X-Custom": "Value"], + mediaType: .pdf + ) + + #expect(response.valueForHeader("Content-Type") == "application/pdf") + #expect(response.valueForHeader("content-type") == "application/pdf") + #expect(response.valueForHeader("X-Custom") == "Value") + #expect(response.valueForHeader("Unknown") == nil) + } + + @Test func acceptsByteRanges() { + var response = HTTPResponse(request: request, url: url, status: .ok, headers: ["Accept-Ranges": "bytes"], mediaType: nil) + #expect(response.acceptsByteRanges) + + response = HTTPResponse(request: request, url: url, status: .ok, headers: ["Content-Range": "bytes 0-100/1000"], mediaType: nil) + #expect(response.acceptsByteRanges) + + response = HTTPResponse(request: request, url: url, status: .ok, headers: [:], mediaType: nil) + #expect(!response.acceptsByteRanges) + } + + @Test func contentLength() { + let response = HTTPResponse(request: request, url: url, status: .ok, headers: ["Content-Length": "1024"], mediaType: nil) + #expect(response.contentLength == 1024) + + let responseInvalid = HTTPResponse(request: request, url: url, status: .ok, headers: ["Content-Length": "invalid"], mediaType: nil) + #expect(responseInvalid.contentLength == nil) + } + + @Test func filename() { + var response = HTTPResponse(request: request, url: url, status: .ok, headers: ["Content-Disposition": "attachment; filename=book.epub"], mediaType: nil) + #expect(response.filename == "book.epub") + + response = HTTPResponse(request: request, url: url, status: .ok, headers: ["Content-Disposition": "filename=image.png"], mediaType: nil) + #expect(response.filename == "image.png") + + response = HTTPResponse(request: request, url: url, status: .ok, headers: ["Content-Disposition": "inline"], mediaType: nil) + #expect(response.filename == nil) + + response = HTTPResponse(request: request, url: url, status: .ok, headers: ["Content-Disposition": "attachment; filename*=UTF-8''%e2%82%ac%20rates; filename=fallback.txt"], mediaType: nil) + #expect(response.filename == "€ rates") + + // Malformed UTF-8 in filename* should fall back to filename + response = HTTPResponse(request: request, url: url, status: .ok, headers: ["Content-Disposition": "attachment; filename*=UTF-8''%FF%FF; filename=fallback.txt"], mediaType: nil) + #expect(response.filename == "fallback.txt") + } +} diff --git a/Tests/SharedTests/Toolkit/HTTP/MockURLProtocol.swift b/Tests/SharedTests/Toolkit/HTTP/MockURLProtocol.swift new file mode 100644 index 0000000000..6184e4ed78 --- /dev/null +++ b/Tests/SharedTests/Toolkit/HTTP/MockURLProtocol.swift @@ -0,0 +1,261 @@ +// +// Copyright 2026 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. +// + +@preconcurrency import Foundation +@testable import ReadiumShared + +/// A `URLProtocol` subclass that intercepts HTTP requests for testing +/// `DefaultHTTPClient` without hitting the network. +/// +/// Configure the static `handler` before each test to control +/// the response returned for intercepted requests. +final class MockURLProtocol: Foundation.URLProtocol { + /// Handler called for each intercepted request. Returns the response + /// configuration to simulate. + /// + /// Must be set before starting a request. + static var handler: (@Sendable (URLRequest) -> MockURLResponse)? { + get { _handler.withLock { $0 } } + set { _handler.withLock { $0 = newValue } } + } + + private static let _handler = Mutex<(@Sendable (URLRequest) -> MockURLResponse)?>(nil) + + private let pendingTask = Mutex?>(nil) + + func setPendingTask(_ task: Task?) { + pendingTask.withLock { + $0?.cancel() + $0 = task + } + } + + // MARK: - URLProtocol + + override class func canInit(with request: URLRequest) -> Bool { + guard let scheme = request.url?.scheme?.lowercased() else { return false } + return scheme == "http" || scheme == "https" + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler else { + fatalError("MockURLProtocol.handler is not set.") + } + guard let client else { return } + deliver(handler(request), proto: self, to: client, for: request) + } + + override func stopLoading() { + setPendingTask(nil) + } +} + +/// Describes a mock response to return for an intercepted request. +indirect enum MockURLResponse: Sendable { + /// A successful response delivered as a sequence of data chunks. + case success( + statusCode: HTTPStatus = .ok, + headers: [String: String] = [:], + chunks: [Data] + ) + + /// A simulated network error. + case error(URLError) + + /// A response that is delivered after a delay, useful for timeout + /// testing. Delivery is cancelled early if `stopLoading()` is called. + case delayed( + seconds: TimeInterval, + then: MockURLResponse + ) + + /// An authentication challenge, followed by a response if the + /// challenge is resolved successfully. + case authenticationChallenge( + host: String = "example.com", + method: String = NSURLAuthenticationMethodHTTPBasic, + then: MockURLResponse + ) + + /// A redirect response. The URL loading system handles the redirect, + /// which triggers a new request cycle (calling `handler` again + /// with the new URL). + case redirect( + to: URL, + statusCode: HTTPStatus = 302 + ) + + /// Convenience for a simple success response with a single body. + static func success( + statusCode: HTTPStatus = .ok, + headers: [String: String] = [:], + body: Data = Data("ok".utf8) + ) -> MockURLResponse { + .success(statusCode: statusCode, headers: headers, chunks: [body]) + } +} + +/// Encapsulates all context needed for delivery. +private struct DeliveryContext: @unchecked Sendable { + let proto: MockURLProtocol + let client: URLProtocolClient + let request: URLRequest +} + +private func deliver( + _ response: MockURLResponse, + proto: MockURLProtocol, + to client: URLProtocolClient, + for request: URLRequest +) { + deliver(response, ctx: DeliveryContext(proto: proto, client: client, request: request)) +} + +private func deliver(_ response: MockURLResponse, ctx: DeliveryContext) { + switch response { + case let .success(statusCode, headers, chunks): + deliverSuccess( + statusCode: statusCode, + headers: headers, + chunks: chunks, + proto: ctx.proto, + to: ctx.client, + for: ctx.request + ) + + case let .error(urlError): + ctx.client.urlProtocol(ctx.proto, didFailWithError: urlError) + + case let .delayed(seconds, then): + ctx.proto.setPendingTask(Task { + do { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + guard !Task.isCancelled else { return } + deliver(then, ctx: ctx) + } catch { + // Cancelled by stopLoading() + } + }) + + case let .authenticationChallenge(host, method, then): + let challengeSender = MockAuthChallengeSender { disposition in + switch disposition { + case .useCredential, .performDefaultHandling: + deliver(then, ctx: ctx) + case .cancelAuthenticationChallenge: + ctx.client.urlProtocol(ctx.proto, didFailWithError: URLError(.userCancelledAuthentication)) + case .rejectProtectionSpace: + ctx.client.urlProtocol(ctx.proto, didFailWithError: URLError(.userAuthenticationRequired)) + @unknown default: + deliver(then, ctx: ctx) + } + } + + let protectionSpace = URLProtectionSpace( + host: host, + port: 443, + protocol: "https", + realm: "Test", + authenticationMethod: method + ) + let challenge = URLAuthenticationChallenge( + protectionSpace: protectionSpace, + proposedCredential: nil, + previousFailureCount: 0, + failureResponse: nil, + error: nil, + sender: challengeSender + ) + ctx.client.urlProtocol(ctx.proto, didReceive: challenge) + + case let .redirect(location, statusCode): + guard let originalURL = ctx.request.url, + let response = HTTPURLResponse( + url: originalURL, + statusCode: statusCode.rawValue, + httpVersion: "HTTP/1.1", + headerFields: ["Location": location.absoluteString] + ) + else { + ctx.client.urlProtocol(ctx.proto, didFailWithError: URLError(.badServerResponse)) + return + } + + var redirectedRequest = ctx.request + redirectedRequest.url = location + + ctx.client.urlProtocol( + ctx.proto, + wasRedirectedTo: redirectedRequest, + redirectResponse: response + ) + } +} + +private func deliverSuccess( + statusCode: HTTPStatus, + headers: [String: String], + chunks: [Data], + proto: Foundation.URLProtocol, + to client: URLProtocolClient, + for request: URLRequest +) { + guard + let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: statusCode.rawValue, + httpVersion: "HTTP/1.1", + headerFields: headers + ) + else { + client.urlProtocol(proto, didFailWithError: URLError(.badServerResponse)) + return + } + + client.urlProtocol(proto, didReceive: response, cacheStoragePolicy: .notAllowed) + + for chunk in chunks { + client.urlProtocol(proto, didLoad: chunk) + } + + client.urlProtocolDidFinishLoading(proto) +} + +// MARK: - MockAuthChallengeSender + +private final class MockAuthChallengeSender: NSObject, URLAuthenticationChallengeSender, @unchecked Sendable { + private let onDisposition: @Sendable (URLSession.AuthChallengeDisposition) -> Void + + init(onDisposition: @escaping @Sendable (URLSession.AuthChallengeDisposition) -> Void) { + self.onDisposition = onDisposition + super.init() + } + + func use(_ credential: URLCredential, for challenge: URLAuthenticationChallenge) { + onDisposition(.useCredential) + } + + func continueWithoutCredential(for challenge: URLAuthenticationChallenge) { + onDisposition(.performDefaultHandling) + } + + func cancel(_ challenge: URLAuthenticationChallenge) { + onDisposition(.cancelAuthenticationChallenge) + } + + func performDefaultHandling(for challenge: URLAuthenticationChallenge) { + onDisposition(.performDefaultHandling) + } + + func rejectProtectionSpaceAndContinue(with challenge: URLAuthenticationChallenge) { + onDisposition(.rejectProtectionSpace) + } +} diff --git a/Tests/StreamerTests/Parser/Audio/Services/AudioLocatorServiceTests.swift b/Tests/StreamerTests/Parser/Audio/Services/AudioLocatorServiceTests.swift index 671520b545..6adfb96255 100644 --- a/Tests/StreamerTests/Parser/Audio/Services/AudioLocatorServiceTests.swift +++ b/Tests/StreamerTests/Parser/Audio/Services/AudioLocatorServiceTests.swift @@ -15,6 +15,7 @@ class AudioLocatorServiceTests: XCTestCase { Link(href: "l1"), Link(href: "l2"), ]) + _ = publication // Silence warning let locator = Locator(href: "l1", mediaType: .mp3, locations: .init(totalProgression: 0.53)) let result = await service.locate(locator) @@ -26,6 +27,7 @@ class AudioLocatorServiceTests: XCTestCase { Link(href: "l1"), Link(href: "l2"), ]) + _ = publication // Silence warning let locator = Locator(href: "l3", mediaType: .mp3, locations: .init(totalProgression: 0.53)) let result = await service.locate(locator) @@ -37,6 +39,7 @@ class AudioLocatorServiceTests: XCTestCase { Link(href: "l1", mediaType: .mp3, duration: 100), Link(href: "l2", mediaType: .mp3, duration: 100), ]) + _ = publication // Silence warning var result = await service.locate(Locator(href: "wrong", mediaType: .mp3, locations: .init(totalProgression: 0.49))) XCTAssertEqual( @@ -74,6 +77,7 @@ class AudioLocatorServiceTests: XCTestCase { Link(href: "l1", mediaType: .mp3, duration: 100), Link(href: "l2", mediaType: .mp3, duration: 100), ]) + _ = publication // Silence warning let result = try await service.locate( Locator( @@ -112,6 +116,7 @@ class AudioLocatorServiceTests: XCTestCase { Link(href: "l1", mediaType: .mp3, duration: 100), Link(href: "l2", mediaType: .mp3, duration: 100), ]) + _ = publication // Silence warning var result = await service.locate(progression: 0) XCTAssertEqual( @@ -169,6 +174,7 @@ class AudioLocatorServiceTests: XCTestCase { Link(href: "l1", mediaType: .mp3, duration: 100), Link(href: "l2", mediaType: .mp3, duration: 100), ]) + _ = publication // Silence warning var result = await service.locate(progression: -0.5) XCTAssertNil(result) From b7b11e5107145a9ed2e4e030c561c1f3a6babd3b Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Sat, 23 May 2026 05:04:02 -0500 Subject: [PATCH 07/39] Fix `FormatSniffer` for strict concurrency (#768) --- .../Toolkit/Format/FormatSnifferBlob.swift | 19 ++++- .../Format/Sniffers/HTMLFormatSniffer.swift | 21 ++--- .../Format/Sniffers/OPDSFormatSniffer.swift | 25 +++--- .../Format/Sniffers/XMLFormatSniffer.swift | 11 ++- .../Locator/DefaultLocatorServiceTests.swift | 80 +++++++++---------- .../Services/AudioLocatorServiceTests.swift | 12 +++ 6 files changed, 93 insertions(+), 75 deletions(-) diff --git a/Sources/Shared/Toolkit/Format/FormatSnifferBlob.swift b/Sources/Shared/Toolkit/Format/FormatSnifferBlob.swift index 80342ed91c..b1c7393132 100644 --- a/Sources/Shared/Toolkit/Format/FormatSnifferBlob.swift +++ b/Sources/Shared/Toolkit/Format/FormatSnifferBlob.swift @@ -10,11 +10,14 @@ public actor FormatSnifferBlob { private let source: Streamable private let xmlDocumentFactory: XMLDocumentFactory - // Caches private var length: ReadResult? private var bytes: ReadResult? private var string: ReadResult? private var json: ReadResult? + + /// Caches. Warning: The `xml` cache holds an `XMLDocument`, which is a complex, + /// mutable reference type (non-Sendable). It is safe here because it never leaves + /// the actor's isolation domain. private var xml: ReadResult? public init(source: Streamable) { @@ -71,8 +74,10 @@ public actor FormatSnifferBlob { return json! } - /// Reads the whole content as an XML document. - func readAsXML() async -> ReadResult { + /// Reads the whole content as an XML document and applies the given closure on it. + /// + /// - Parameter closure: A closure that evaluates the XML document and returns a Sendable result. + func sniffXML(_ closure: @Sendable (XMLDocument?) throws -> T) async -> ReadResult { if xml == nil { xml = await read().asyncMap { await $0.asyncFlatMap { @@ -80,7 +85,13 @@ public actor FormatSnifferBlob { } } } - return xml! + return xml!.flatMap { document in + do { + return try .success(closure(document)) + } catch { + return .failure(.decoding(error)) + } + } } private func length() async -> ReadResult { diff --git a/Sources/Shared/Toolkit/Format/Sniffers/HTMLFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/HTMLFormatSniffer.swift index 3e43e6e5b7..bb53a64117 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/HTMLFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/HTMLFormatSniffer.swift @@ -33,16 +33,19 @@ public struct HTMLFormatSniffer: FormatSniffer, Sendable { return .success(nil) } - return await blob.readAsXML() - .asyncMap { document in - if let format = sniffDocument(document) { - return format - } else if let format = await sniffString(blob) { - return format - } else { - return nil - } + let documentFormat = await blob.sniffXML { document in + sniffDocument(document) + } + + return await documentFormat.asyncMap { format in + if let format = format { + return format + } else if let format = await sniffString(blob) { + return format + } else { + return nil } + } } private func sniffDocument(_ document: XMLDocument?) -> Format? { diff --git a/Sources/Shared/Toolkit/Format/Sniffers/OPDSFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/OPDSFormatSniffer.swift index 354d01eecb..41f30ec945 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/OPDSFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/OPDSFormatSniffer.swift @@ -33,20 +33,19 @@ public final class OPDSFormatSniffer: FormatSniffer, Sendable { public func sniffBlob(_ blob: FormatSnifferBlob, refining format: Format) async -> ReadResult { if format.conformsTo(.xml) { - return await blob.readAsXML() - .map { - guard let document = $0 else { - return nil - } - let namespaces = [XMLNamespace.atom] - if document.first("/atom:feed", with: namespaces) != nil { - return opds1Catalog - } else if document.first("/atom:entry", with: namespaces) != nil { - return opds1Entry - } else { - return nil - } + return await blob.sniffXML { + guard let document = $0 else { + return nil + } + let namespaces = [XMLNamespace.atom] + if document.first("/atom:feed", with: namespaces) != nil { + return opds1Catalog + } else if document.first("/atom:entry", with: namespaces) != nil { + return opds1Entry + } else { + return nil } + } } else if format.conformsTo(.json) { return await blob.read() diff --git a/Sources/Shared/Toolkit/Format/Sniffers/XMLFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/XMLFormatSniffer.swift index 601facad32..a6a493380a 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/XMLFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/XMLFormatSniffer.swift @@ -26,13 +26,12 @@ public struct XMLFormatSniffer: FormatSniffer, Sendable { return .success(nil) } - return await blob.readAsXML() - .map { - guard $0 != nil else { - return nil - } - return xml + return await blob.sniffXML { + guard $0 != nil else { + return nil } + return xml + } } private let xml = Format( diff --git a/Tests/SharedTests/Publication/Services/Locator/DefaultLocatorServiceTests.swift b/Tests/SharedTests/Publication/Services/Locator/DefaultLocatorServiceTests.swift index aec4106d7a..d12e0a70ba 100644 --- a/Tests/SharedTests/Publication/Services/Locator/DefaultLocatorServiceTests.swift +++ b/Tests/SharedTests/Publication/Services/Locator/DefaultLocatorServiceTests.swift @@ -10,40 +10,36 @@ import XCTest class DefaultLocatorServiceTests: XCTestCase { /// locate(Locator) checks that the href exists. func testFromLocator() async { - let (publication, service) = makeService(readingOrder: [ + let sut = makeService(readingOrder: [ Link(href: "chap1", mediaType: .xml), Link(href: "chap2", mediaType: .xml), Link(href: "chap3", mediaType: .xml), ]) - _ = publication // Silence warning let locator = Locator(href: "chap2", mediaType: .html, text: .init(highlight: "Highlight")) - let result = await service.locate(locator) + let result = await sut.service.locate(locator) XCTAssertEqual(result, locator) } func testFromLocatorEmptyReadingOrder() async { - let (publication, service) = makeService(readingOrder: []) - _ = publication // Silence warning - let result = await service.locate(Locator(href: "href", mediaType: .html)) + let sut = makeService(readingOrder: []) + let result = await sut.service.locate(Locator(href: "href", mediaType: .html)) XCTAssertNil(result) } func testFromLocatorNotFound() async { - let (publication, service) = makeService(readingOrder: [ + let sut = makeService(readingOrder: [ Link(href: "chap1", mediaType: .xml), Link(href: "chap3", mediaType: .xml), ]) - _ = publication // Silence warning let locator = Locator(href: "chap2", mediaType: .html, text: .init(highlight: "Highlight")) - let result = await service.locate(locator) + let result = await sut.service.locate(locator) XCTAssertNil(result) } func testFromProgression() async { - let (publication, service) = makeService(positions: positionsFixture) - _ = publication // Silence warning + let sut = makeService(positions: positionsFixture) - var result = await service.locate(progression: 0.0) + var result = await sut.service.locate(progression: 0.0) XCTAssertEqual(result, Locator( href: "chap1", mediaType: .html, @@ -54,7 +50,7 @@ class DefaultLocatorServiceTests: XCTestCase { ) )) - result = await service.locate(progression: 0.25) + result = await sut.service.locate(progression: 0.25) XCTAssertEqual(result, Locator( href: "chap3", mediaType: .html, @@ -69,7 +65,7 @@ class DefaultLocatorServiceTests: XCTestCase { let chap5FirstTotalProg = 5.0 / 8.0 let chap4FirstTotalProg = 3.0 / 8.0 - result = await service.locate(progression: 0.4) + result = await sut.service.locate(progression: 0.4) XCTAssertEqual(result, Locator( href: "chap4", mediaType: .html, @@ -80,7 +76,7 @@ class DefaultLocatorServiceTests: XCTestCase { ) )) - result = await service.locate(progression: 0.55) + result = await sut.service.locate(progression: 0.55) XCTAssertEqual(result, Locator( href: "chap4", mediaType: .html, @@ -91,7 +87,7 @@ class DefaultLocatorServiceTests: XCTestCase { ) )) - result = await service.locate(progression: 0.9) + result = await sut.service.locate(progression: 0.9) XCTAssertEqual(result, Locator( href: "chap5", mediaType: .html, @@ -102,7 +98,7 @@ class DefaultLocatorServiceTests: XCTestCase { ) )) - result = await service.locate(progression: 1.0) + result = await sut.service.locate(progression: 1.0) XCTAssertEqual(result, Locator( href: "chap5", mediaType: .html, @@ -115,30 +111,27 @@ class DefaultLocatorServiceTests: XCTestCase { } func testFromIncorrectProgression() async { - let (publication, service) = makeService(positions: positionsFixture) - _ = publication // Silence warning + let sut = makeService(positions: positionsFixture) - var result = await service.locate(progression: -0.2) + var result = await sut.service.locate(progression: -0.2) XCTAssertNil(result) - result = await service.locate(progression: 1.2) + result = await sut.service.locate(progression: 1.2) XCTAssertNil(result) } func testFromProgressionEmptyPositions() async { - let (publication, service) = makeService(positions: []) - _ = publication // Silence warning - let result = await service.locate(progression: 0.5) + let sut = makeService(positions: []) + let result = await sut.service.locate(progression: 0.5) XCTAssertNil(result) } func testFromMinimalLink() async { - let (publication, service) = makeService(readingOrder: [ + let sut = makeService(readingOrder: [ Link(href: "/href", mediaType: .html, title: "Resource"), ]) - _ = publication // Silence warning - let result = await service.locate(Link(href: "/href")) + let result = await sut.service.locate(Link(href: "/href")) XCTAssertEqual( result, Locator(href: "/href", mediaType: .html, title: "Resource", locations: Locator.Locations(progression: 0.0)) @@ -146,26 +139,25 @@ class DefaultLocatorServiceTests: XCTestCase { } func testFromLinkInReadingOrderResourcesOrLinks() async { - let (publication, service) = makeService( + let sut = makeService( links: [Link(href: "/href3", mediaType: .html)], readingOrder: [Link(href: "/href1", mediaType: .html)], resources: [Link(href: "/href2", mediaType: .html)] ) - _ = publication // Silence warning - var result = await service.locate(Link(href: "/href1")) + var result = await sut.service.locate(Link(href: "/href1")) XCTAssertEqual( result, Locator(href: "/href1", mediaType: .html, locations: Locator.Locations(progression: 0.0)) ) - result = await service.locate(Link(href: "/href2")) + result = await sut.service.locate(Link(href: "/href2")) XCTAssertEqual( result, Locator(href: "/href2", mediaType: .html, locations: Locator.Locations(progression: 0.0)) ) - result = await service.locate(Link(href: "/href3")) + result = await sut.service.locate(Link(href: "/href3")) XCTAssertEqual( result, Locator(href: "/href3", mediaType: .html, locations: Locator.Locations(progression: 0.0)) @@ -173,12 +165,11 @@ class DefaultLocatorServiceTests: XCTestCase { } func testFromLinkWithFragment() async throws { - let (publication, service) = makeService(readingOrder: [ + let sut = makeService(readingOrder: [ Link(href: "/href", mediaType: .html, title: "Resource"), ]) - _ = publication // Silence warning - let result = try await service.locate(Link(href: "/href#page=42", mediaType: XCTUnwrap(MediaType("text/xml")), title: "My link")) + let result = try await sut.service.locate(Link(href: "/href#page=42", mediaType: XCTUnwrap(MediaType("text/xml")), title: "My link")) XCTAssertEqual( result, Locator(href: "/href", mediaType: .html, title: "Resource", locations: Locator.Locations(fragments: ["page=42"])) @@ -186,12 +177,11 @@ class DefaultLocatorServiceTests: XCTestCase { } func testTitleFallbackFromLink() async { - let (publication, service) = makeService(readingOrder: [ + let sut = makeService(readingOrder: [ Link(href: "/href", mediaType: .html), ]) - _ = publication // Silence warning - let result = await service.locate(Link(href: "/href", title: "My link")) + let result = await sut.service.locate(Link(href: "/href", title: "My link")) XCTAssertEqual( result, Locator(href: "/href", mediaType: .html, title: "My link", locations: Locator.Locations(progression: 0.0)) @@ -199,21 +189,25 @@ class DefaultLocatorServiceTests: XCTestCase { } func testFromLinkNotFound() async { - let (publication, service) = makeService(readingOrder: [ + let sut = makeService(readingOrder: [ Link(href: "/href", mediaType: .html), ]) - _ = publication // Silence warning - let result = await service.locate(Link(href: "notfound")) + let result = await sut.service.locate(Link(href: "notfound")) XCTAssertNil(result) } + struct Context { + var publication: Publication + var service: DefaultLocatorService + } + func makeService( links: [Link] = [], readingOrder: [Link] = [], resources: [Link] = [], positions: [[Locator]] = [] - ) -> (Publication, DefaultLocatorService) { + ) -> Context { let publication = Publication( manifest: Manifest( metadata: Metadata(title: ""), @@ -226,7 +220,7 @@ class DefaultLocatorServiceTests: XCTestCase { ) ) let service = DefaultLocatorService(publication: Weak(publication)) - return (publication, service) + return Context(publication: publication, service: service) } } diff --git a/Tests/StreamerTests/Parser/Audio/Services/AudioLocatorServiceTests.swift b/Tests/StreamerTests/Parser/Audio/Services/AudioLocatorServiceTests.swift index 6adfb96255..54c477afba 100644 --- a/Tests/StreamerTests/Parser/Audio/Services/AudioLocatorServiceTests.swift +++ b/Tests/StreamerTests/Parser/Audio/Services/AudioLocatorServiceTests.swift @@ -20,6 +20,8 @@ class AudioLocatorServiceTests: XCTestCase { let locator = Locator(href: "l1", mediaType: .mp3, locations: .init(totalProgression: 0.53)) let result = await service.locate(locator) XCTAssertEqual(result, locator) + + withExtendedLifetime(publication) {} } func testLocateLocatorReturnsNilIfNoMatch() async { @@ -32,6 +34,8 @@ class AudioLocatorServiceTests: XCTestCase { let locator = Locator(href: "l3", mediaType: .mp3, locations: .init(totalProgression: 0.53)) let result = await service.locate(locator) XCTAssertNil(result) + + withExtendedLifetime(publication) {} } func testLocateLocatorUsesTotalProgression() async { @@ -70,6 +74,8 @@ class AudioLocatorServiceTests: XCTestCase { totalProgression: 0.51 )) ) + + withExtendedLifetime(publication) {} } func testLocateLocatorUsingTotalProgressionKeepsTitleAndText() async throws { @@ -109,6 +115,8 @@ class AudioLocatorServiceTests: XCTestCase { text: .init(after: "after", before: "before", highlight: "highlight") ) ) + + withExtendedLifetime(publication) {} } func testLocateProgression() async { @@ -167,6 +175,8 @@ class AudioLocatorServiceTests: XCTestCase { totalProgression: 1 )) ) + + withExtendedLifetime(publication) {} } func testLocateInvalidProgression() async { @@ -181,6 +191,8 @@ class AudioLocatorServiceTests: XCTestCase { result = await service.locate(progression: 1.5) XCTAssertNil(result) + + withExtendedLifetime(publication) {} } private func makeService(readingOrder: [Link]) -> (Publication, AudioLocatorService) { From e3454a80fc6aa64dc34ea2edec36532214e50203 Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Fri, 29 May 2026 12:37:58 -0500 Subject: [PATCH 08/39] Replace `AnyHashable` with `AnySendableHashable` (#772) --- Sources/LCP/LCPError.swift | 2 +- .../Decorator/DecorableNavigator.swift | 33 ++++++--- Sources/Navigator/EPUB/EPUBSpreadView.swift | 4 +- .../EPUB/HTMLDecorationTemplate.swift | 6 +- Sources/Navigator/Input/InputObservable.swift | 6 +- .../Input/InputObservableViewController.swift | 2 +- ...putObservingGestureRecognizerAdapter.swift | 2 +- .../Pointer/ActivatePointerObserver.swift | 4 +- .../Input/Pointer/DragPointerObserver.swift | 6 +- .../Input/Pointer/PointerEvent.swift | 36 ++++++--- .../PDF/PDFNavigatorViewController.swift | 4 +- Sources/OPDS/OPDS1Parser.swift | 4 +- Sources/OPDS/OPDS2Parser.swift | 2 +- .../Services/Content/Content.swift | 38 +++++----- .../PDFResourceContentIterator.swift | 2 +- .../Shared/Toolkit/AnySendableHashable.swift | 43 +++++++++++ Sources/Shared/Toolkit/HTTP/HTTPError.swift | 2 +- .../Shared/Toolkit/Media/NowPlayingInfo.swift | 2 +- .../AudioPublicationManifestAugmentor.swift | 2 +- TestApp/Sources/App/AppModule.swift | 2 +- .../Common/VisualReaderViewController.swift | 2 +- .../Toolkit/AnySendableHashableTests.swift | 74 +++++++++++++++++++ docs/Guides/Navigator/Decorations.md | 4 +- 23 files changed, 215 insertions(+), 67 deletions(-) create mode 100644 Sources/Shared/Toolkit/AnySendableHashable.swift create mode 100644 Tests/SharedTests/Toolkit/AnySendableHashableTests.swift diff --git a/Sources/LCP/LCPError.swift b/Sources/LCP/LCPError.swift index 6ae04ce105..848ac64824 100644 --- a/Sources/LCP/LCPError.swift +++ b/Sources/LCP/LCPError.swift @@ -7,7 +7,7 @@ import Foundation import ReadiumShared -public enum LCPError: Error { +public enum LCPError: Error, Sendable { /// The license could not be retrieved because the passphrase is unknown. case missingPassphrase diff --git a/Sources/Navigator/Decorator/DecorableNavigator.swift b/Sources/Navigator/Decorator/DecorableNavigator.swift index f8b5d7c353..fb7673a121 100644 --- a/Sources/Navigator/Decorator/DecorableNavigator.swift +++ b/Sources/Navigator/Decorator/DecorableNavigator.swift @@ -43,7 +43,7 @@ public protocol DecorableNavigator { public typealias DecorationGroup = String /// Holds the metadata about a decoration activation interaction. -public struct OnDecorationActivatedEvent { +public struct OnDecorationActivatedEvent: Sendable { /// Activated decoration. public let decoration: Decoration /// Name of the group the decoration belongs to. @@ -60,7 +60,7 @@ public struct OnDecorationActivatedEvent { /// a discrete `locator` in the publication. /// /// For example, decorations can be used to draw highlights, images or buttons. -public struct Decoration: Hashable, JSONObjectEncodable { +public struct Decoration: Hashable, JSONObjectEncodable, Sendable { /// An identifier for this decoration. It must be unique in the group the decoration is applied to. public var id: Id @@ -70,14 +70,16 @@ public struct Decoration: Hashable, JSONObjectEncodable { /// Declares the look and feel of the decoration. public var style: Style - /// Additional context data specific to a reading app. Readium does not use it. - public var userInfo: [AnyHashable: AnyHashable] + private let _userInfo: [String: AnySendableHashable] + public var userInfo: [String: AnyHashable] { + _userInfo.mapValues(\.asAnyHashable) + } - public init(id: Id, locator: Locator, style: Style, userInfo: [AnyHashable: AnyHashable] = [:]) { + public init(id: Id, locator: Locator, style: Style, userInfo: [String: any Sendable & Hashable] = [:]) { self.id = id self.style = style self.locator = locator - self.userInfo = userInfo + _userInfo = userInfo.mapValues { AnySendableHashable($0) } } /// Unique identifier for a decoration. @@ -87,7 +89,7 @@ public struct Decoration: Hashable, JSONObjectEncodable { /// /// It is media type agnostic, meaning that each Navigator will translate the style into a set of rendering /// instructions which makes sense for the resource type. - public struct Style: Hashable { + public struct Style: Hashable, Sendable { /// Unique ID for a style. public struct Id: RawRepresentable, ExpressibleByStringLiteral, Hashable, JSONValueEncodable, Sendable { public let rawValue: String @@ -117,7 +119,7 @@ public struct Decoration: Hashable, JSONObjectEncodable { .init(id: .underline, config: HighlightConfig(tint: tint, isActive: isActive)) } - public struct HighlightConfig: Hashable { + public struct HighlightConfig: Hashable, Sendable { public var tint: UIColor? public var isActive: Bool public init(tint: UIColor? = nil, isActive: Bool = false) { @@ -127,11 +129,20 @@ public struct Decoration: Hashable, JSONObjectEncodable { } public let id: Id - public let config: AnyHashable? - public init(id: Id, config: AnyHashable? = nil) { + private let _config: AnySendableHashable? + public var config: AnyHashable? { + _config.map { AnyHashable($0.base) } + } + + public init(id: Id) { + self.id = id + _config = nil + } + + public init(id: Id, config: T) { self.id = id - self.config = config + _config = AnySendableHashable(config) } } diff --git a/Sources/Navigator/EPUB/EPUBSpreadView.swift b/Sources/Navigator/EPUB/EPUBSpreadView.swift index fa809378e4..3853242299 100644 --- a/Sources/Navigator/EPUB/EPUBSpreadView.swift +++ b/Sources/Navigator/EPUB/EPUBSpreadView.swift @@ -787,9 +787,9 @@ private extension PointerEvent { let optionalPointer: Pointer? = switch pointerType { case "mouse": - .mouse(MousePointer(id: pointerId, buttons: MouseButtons(json: json))) + .mouse(MousePointer(id: .int(pointerId), buttons: MouseButtons(json: json))) case "touch": - .touch(TouchPointer(id: pointerId)) + .touch(TouchPointer(id: .int(pointerId))) default: nil } diff --git a/Sources/Navigator/EPUB/HTMLDecorationTemplate.swift b/Sources/Navigator/EPUB/HTMLDecorationTemplate.swift index 96133d1f92..b3b39cb97b 100644 --- a/Sources/Navigator/EPUB/HTMLDecorationTemplate.swift +++ b/Sources/Navigator/EPUB/HTMLDecorationTemplate.swift @@ -95,9 +95,9 @@ public struct HTMLDecorationTemplate: JSONObjectEncodable { return HTMLDecorationTemplate( layout: .boxes, element: { decoration in - let config = decoration.style.config as! Decoration.Style.HighlightConfig - let tint = config.tint ?? defaultTint - let isActive = config.isActive + let config = decoration.style.config as? Decoration.Style.HighlightConfig + let tint = config?.tint ?? defaultTint + let isActive = config?.isActive ?? false var css = "" if asHighlight || isActive { css += "background-color: \(tint.cssValue(alpha: alpha)) !important;" diff --git a/Sources/Navigator/Input/InputObservable.swift b/Sources/Navigator/Input/InputObservable.swift index 363c0ab49c..9d499a0d6f 100644 --- a/Sources/Navigator/Input/InputObservable.swift +++ b/Sources/Navigator/Input/InputObservable.swift @@ -23,10 +23,10 @@ import Foundation /// A token which can be used to remove an `InputObserver` from an /// ``InputObservable``. -public struct InputObservableToken: Hashable, Identifiable { - public let id: AnyHashable +public struct InputObservableToken: Hashable, Identifiable, Sendable { + public let id: UUID - public init(id: AnyHashable = UUID()) { + public init(id: UUID = UUID()) { self.id = id } diff --git a/Sources/Navigator/Input/InputObservableViewController.swift b/Sources/Navigator/Input/InputObservableViewController.swift index fa6ba1744d..9287638153 100644 --- a/Sources/Navigator/Input/InputObservableViewController.swift +++ b/Sources/Navigator/Input/InputObservableViewController.swift @@ -130,7 +130,7 @@ open class InputObservableViewController: UIViewController, InputObservable { extension Pointer { init(touch: UITouch, event: UIEvent?) { - let id = AnyHashable(ObjectIdentifier(touch)) + let id = PointerId.object(ObjectIdentifier(touch)) self = switch touch.type { case .direct, .indirect: diff --git a/Sources/Navigator/Input/InputObservingGestureRecognizerAdapter.swift b/Sources/Navigator/Input/InputObservingGestureRecognizerAdapter.swift index 7a51105365..b55345b2ca 100644 --- a/Sources/Navigator/Input/InputObservingGestureRecognizerAdapter.swift +++ b/Sources/Navigator/Input/InputObservingGestureRecognizerAdapter.swift @@ -21,7 +21,7 @@ final class InputObservingGestureRecognizerAdapter: UIGestureRecognizer { /// Stores the ``PointerEvent`` that were notified to the `observer`, to /// cancel them if the gesture recognizer is resetted before the touches /// are cancelled or ended. - private var pendingPointers: [AnyHashable: PointerEvent] = [:] + private var pendingPointers: [PointerId: PointerEvent] = [:] override func touchesBegan(_ touches: Set, with event: UIEvent) { super.touchesBegan(touches, with: event) diff --git a/Sources/Navigator/Input/Pointer/ActivatePointerObserver.swift b/Sources/Navigator/Input/Pointer/ActivatePointerObserver.swift index 5765e05bf9..d631a8b184 100644 --- a/Sources/Navigator/Input/Pointer/ActivatePointerObserver.swift +++ b/Sources/Navigator/Input/Pointer/ActivatePointerObserver.swift @@ -85,9 +85,9 @@ public extension InputObserving where Self == ActivatePointerObserver { private enum State { case idle - case recognizing(id: AnyHashable, lastLocation: CGPoint) + case recognizing(id: PointerId, lastLocation: CGPoint) case recognized - case failed(activePointers: Set) + case failed(activePointers: Set) } private var state: State = .idle { diff --git a/Sources/Navigator/Input/Pointer/DragPointerObserver.swift b/Sources/Navigator/Input/Pointer/DragPointerObserver.swift index 2b3728ba23..44d156b7d5 100644 --- a/Sources/Navigator/Input/Pointer/DragPointerObserver.swift +++ b/Sources/Navigator/Input/Pointer/DragPointerObserver.swift @@ -45,9 +45,9 @@ public extension InputObserving where Self == DragPointerObserver { private enum State { case idle - case pending(id: AnyHashable, startLocation: CGPoint) - case dragging(id: AnyHashable, lastEvent: PointerEvent) - case failed(activePointers: Set) + case pending(id: PointerId, startLocation: CGPoint) + case dragging(id: PointerId, lastEvent: PointerEvent) + case failed(activePointers: Set) } private enum Action { diff --git a/Sources/Navigator/Input/Pointer/PointerEvent.swift b/Sources/Navigator/Input/Pointer/PointerEvent.swift index 5ede5dc642..3543d6f703 100644 --- a/Sources/Navigator/Input/Pointer/PointerEvent.swift +++ b/Sources/Navigator/Input/Pointer/PointerEvent.swift @@ -8,7 +8,7 @@ import Foundation import ReadiumShared /// Represents a pointer event (e.g. touch, mouse) emitted by a navigator. -public struct PointerEvent: Equatable { +public struct PointerEvent: Equatable, Sendable { /// Pointer causing this event. public var pointer: Pointer @@ -26,7 +26,7 @@ public struct PointerEvent: Equatable { /// A content element targeted by a pointer event, paired with its /// on-screen frame. - @_spi(ExperimentalTargetElement) public struct TargetElement: Equatable { + @_spi(ExperimentalTargetElement) public struct TargetElement: Equatable, Sendable { /// Frame of the element relative to the navigator's view. public var frame: CGRect @@ -70,13 +70,29 @@ public struct PointerEvent: Equatable { } } +public enum PointerId: Hashable, Sendable, CustomStringConvertible { + case object(ObjectIdentifier) + case string(String) + case int(Int) + case uuid(UUID) + + public var description: String { + switch self { + case let .object(id): return String(describing: id) + case let .string(id): return id + case let .int(id): return String(id) + case let .uuid(id): return id.uuidString + } + } +} + /// Represents a pointer device, such as a mouse or a physical touch. -public enum Pointer: Equatable, CustomStringConvertible { +public enum Pointer: Equatable, CustomStringConvertible, Sendable { case touch(TouchPointer) case mouse(MousePointer) /// Unique identifier for this pointer. - public var id: AnyHashable { + public var id: PointerId { switch self { case let .touch(pointer): pointer.id case let .mouse(pointer): pointer.id @@ -108,24 +124,24 @@ public enum PointerType: Equatable, CaseIterable, Sendable { } /// Represents a physical touch pointer. -public struct TouchPointer: Identifiable, Equatable { +public struct TouchPointer: Identifiable, Equatable, Sendable { /// Unique identifier for this pointer. - public let id: AnyHashable + public let id: PointerId - public init(id: AnyHashable) { + public init(id: PointerId) { self.id = id } } /// Represents a mouse pointer. -public struct MousePointer: Identifiable, Equatable { +public struct MousePointer: Identifiable, Equatable, Sendable { /// Unique identifier for this pointer. - public let id: AnyHashable + public let id: PointerId /// Indicates which buttons are pressed on the mouse. public let buttons: MouseButtons - public init(id: AnyHashable, buttons: MouseButtons) { + public init(id: PointerId, buttons: MouseButtons) { self.id = id self.buttons = buttons } diff --git a/Sources/Navigator/PDF/PDFNavigatorViewController.swift b/Sources/Navigator/PDF/PDFNavigatorViewController.swift index 970faa0504..e5692acdf3 100644 --- a/Sources/Navigator/PDF/PDFNavigatorViewController.swift +++ b/Sources/Navigator/PDF/PDFNavigatorViewController.swift @@ -319,7 +319,7 @@ open class PDFNavigatorViewController: @objc private func didTap(_ gesture: UITapGestureRecognizer) { let location = gesture.location(in: view) - let pointer = Pointer.touch(TouchPointer(id: ObjectIdentifier(gesture))) + let pointer = Pointer.touch(TouchPointer(id: .object(ObjectIdentifier(gesture)))) let modifiers = KeyModifiers(flags: gesture.modifierFlags) Task { _ = await inputObservers.didReceive(PointerEvent(pointer: pointer, phase: .down, location: location, modifiers: modifiers)) @@ -331,7 +331,7 @@ open class PDFNavigatorViewController: @objc private func didClick(_ gesture: UITapGestureRecognizer) { let location = gesture.location(in: view) - let pointer = Pointer.mouse(MousePointer(id: ObjectIdentifier(gesture), buttons: .main)) + let pointer = Pointer.mouse(MousePointer(id: .object(ObjectIdentifier(gesture)), buttons: .main)) let modifiers = KeyModifiers(flags: gesture.modifierFlags) Task { _ = await inputObservers.didReceive(PointerEvent(pointer: pointer, phase: .down, location: location, modifiers: modifiers)) diff --git a/Sources/OPDS/OPDS1Parser.swift b/Sources/OPDS/OPDS1Parser.swift index d6fc08ce0e..932ea41c42 100644 --- a/Sources/OPDS/OPDS1Parser.swift +++ b/Sources/OPDS/OPDS1Parser.swift @@ -8,14 +8,14 @@ import Foundation import ReadiumFuzi import ReadiumShared -public enum OPDS1ParserError: Error { +public enum OPDS1ParserError: Error, Sendable { /// The title is missing from the feed. case missingTitle /// Root is not found case rootNotFound } -public enum OPDSParserOpenSearchHelperError: Error { +public enum OPDSParserOpenSearchHelperError: Error, Sendable { /// Search link not found in feed case searchLinkNotFound /// OpenSearch document is invalid diff --git a/Sources/OPDS/OPDS2Parser.swift b/Sources/OPDS/OPDS2Parser.swift index edd9df8670..6b3925ce6d 100644 --- a/Sources/OPDS/OPDS2Parser.swift +++ b/Sources/OPDS/OPDS2Parser.swift @@ -7,7 +7,7 @@ import Foundation import ReadiumShared -public enum OPDS2ParserError: Error { +public enum OPDS2ParserError: Error, Sendable { case invalidJSON case metadataNotFound case invalidLink diff --git a/Sources/Shared/Publication/Services/Content/Content.swift b/Sources/Shared/Publication/Services/Content/Content.swift index b00ab16122..9aa7e95fc5 100644 --- a/Sources/Shared/Publication/Services/Content/Content.swift +++ b/Sources/Shared/Publication/Services/Content/Content.swift @@ -40,7 +40,7 @@ public extension Content { } /// Represents a single semantic content element part of a publication. -public protocol ContentElement: ContentAttributesHolder { +public protocol ContentElement: ContentAttributesHolder, Sendable { /// Locator targeting this element in the Publication. var locator: Locator { get } @@ -56,7 +56,7 @@ public extension ContentElement where Self: Equatable { } /// A type-erasing `ContentElement` object which implements `Equatable`. -public struct AnyEquatableContentElement: Equatable, ContentElement { +public struct AnyEquatableContentElement: Equatable, ContentElement, Sendable { private let element: ContentElement public init(_ element: E) { @@ -101,7 +101,7 @@ public protocol EmbeddedContentElement: ContentElement { } /// An audio clip. -public struct AudioContentElement: Hashable, EmbeddedContentElement, TextualContentElement { +public struct AudioContentElement: Hashable, EmbeddedContentElement, TextualContentElement, Sendable { public var locator: Locator public var embeddedLink: Link public var attributes: [ContentAttribute] @@ -114,7 +114,7 @@ public struct AudioContentElement: Hashable, EmbeddedContentElement, TextualCont } /// A video clip. -public struct VideoContentElement: Hashable, EmbeddedContentElement, TextualContentElement { +public struct VideoContentElement: Hashable, EmbeddedContentElement, TextualContentElement, Sendable { public var locator: Locator public var embeddedLink: Link public var attributes: [ContentAttribute] @@ -127,7 +127,7 @@ public struct VideoContentElement: Hashable, EmbeddedContentElement, TextualCont } /// An embedded image (bitmap or SVG). -public struct ImageContentElement: Hashable, EmbeddedContentElement, TextualContentElement { +public struct ImageContentElement: Hashable, EmbeddedContentElement, TextualContentElement, Sendable { public var locator: Locator public var embeddedLink: Link public var attributes: [ContentAttribute] @@ -149,7 +149,7 @@ public struct ImageContentElement: Hashable, EmbeddedContentElement, TextualCont } /// An inline SVG image. -public struct SVGContentElement: Hashable, TextualContentElement { +public struct SVGContentElement: Hashable, TextualContentElement, Sendable { public var locator: Locator public var attributes: [ContentAttribute] @@ -176,7 +176,7 @@ public struct SVGContentElement: Hashable, TextualContentElement { /// /// @param role Purpose of this element in the broader context of the document. /// @param segments Ranged portions of text with associated attributes. -public struct TextContentElement: Hashable, TextualContentElement { +public struct TextContentElement: Hashable, TextualContentElement, Sendable { public var locator: Locator public var role: Role public var segments: [Segment] @@ -213,7 +213,7 @@ public struct TextContentElement: Hashable, TextualContentElement { /// @param locator Locator to the segment of text. /// @param text Text in the segment. /// @param attributes Attributes associated with this segment, e.g. language. - public struct Segment: Hashable, ContentAttributesHolder { + public struct Segment: Hashable, ContentAttributesHolder, Sendable { public var locator: Locator public var text: String public var attributes: [ContentAttribute] @@ -244,18 +244,22 @@ public struct ContentAttributeKey: Hashable, Sendable { } } -public struct ContentAttribute: Hashable { +public struct ContentAttribute: Hashable, Sendable { public let key: String - public let value: AnyHashable - public init(key: ContentAttributeKey, value: T) { + private let _value: AnySendableHashable + public var value: AnyHashable { + _value.asAnyHashable + } + + public init(key: ContentAttributeKey, value: T) { self.key = key.key - self.value = value + _value = AnySendableHashable(value) } - public init(key: String, value: AnyHashable) { + public init(key: String, value: any Sendable & Hashable) { self.key = key - self.value = value + _value = AnySendableHashable(value) } } @@ -275,12 +279,12 @@ public extension ContentAttributesHolder { } /// Gets the first attribute with the given `key`. - subscript(_ key: ContentAttributeKey) -> T? { + subscript(_ key: ContentAttributeKey) -> T? { attribute(key) } /// Gets the first attribute with the given `key`. - func attribute(_ key: ContentAttributeKey) -> T? { + func attribute(_ key: ContentAttributeKey) -> T? { attributes.first { attr in if attr.key == key.key, let value = attr.value as? T { return value @@ -291,7 +295,7 @@ public extension ContentAttributesHolder { } /// Gets all the attributes with the given `key`. - func attributes(_ key: ContentAttributeKey) -> [T] { + func attributes(_ key: ContentAttributeKey) -> [T] { attributes.compactMap { attr in if attr.key == key.key, let value = attr.value as? T { return value diff --git a/Sources/Shared/Publication/Services/Content/Iterators/PDFResourceContentIterator.swift b/Sources/Shared/Publication/Services/Content/Iterators/PDFResourceContentIterator.swift index 64da7bb16d..7003617a53 100644 --- a/Sources/Shared/Publication/Services/Content/Iterators/PDFResourceContentIterator.swift +++ b/Sources/Shared/Publication/Services/Content/Iterators/PDFResourceContentIterator.swift @@ -6,7 +6,7 @@ import Foundation -public enum PDFResourceContentIteratorError: Error { +public enum PDFResourceContentIteratorError: Error, Sendable { /// The publication must have a ``PDFDocumentService`` to open the document. case missingPDFDocumentService } diff --git a/Sources/Shared/Toolkit/AnySendableHashable.swift b/Sources/Shared/Toolkit/AnySendableHashable.swift new file mode 100644 index 0000000000..19a66524dc --- /dev/null +++ b/Sources/Shared/Toolkit/AnySendableHashable.swift @@ -0,0 +1,43 @@ +// +// Copyright 2026 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 + +/// A type-erased wrapper for a value that is both `Hashable` and `Sendable`. +package struct AnySendableHashable: Hashable, Sendable { + package let base: any Hashable & Sendable + private let equals: @Sendable (any Hashable & Sendable) -> Bool + private let hasher: @Sendable (inout Hasher) -> Void + + package var asAnyHashable: AnyHashable { + AnyHashable(base) + } + + package init(_ base: T) { + if let nested = base as? AnySendableHashable { + self.base = nested.base + equals = nested.equals + hasher = nested.hasher + } else { + self.base = base + equals = { ($0 as? T) == base } + hasher = { base.hash(into: &$0) } + } + } + + package static func == (lhs: Self, rhs: Self) -> Bool { + lhs.equals(rhs.base) + } + + package func hash(into hasher: inout Hasher) { + self.hasher(&hasher) + } + + /// Safely unwraps the underlying value to the expected type. + package func unwrap(as type: T.Type = T.self) -> T? { + base as? T + } +} diff --git a/Sources/Shared/Toolkit/HTTP/HTTPError.swift b/Sources/Shared/Toolkit/HTTP/HTTPError.swift index 8a90f17074..86c110935e 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPError.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPError.swift @@ -9,7 +9,7 @@ import Foundation public typealias HTTPResult = Result /// Represents an error occurring during an `HTTPClient` activity. -public enum HTTPError: Error, Loggable { +public enum HTTPError: Error, Loggable, Sendable { /// The provided request was not valid. case malformedRequest(url: String?) diff --git a/Sources/Shared/Toolkit/Media/NowPlayingInfo.swift b/Sources/Shared/Toolkit/Media/NowPlayingInfo.swift index a5d781d6a3..98f8dc341f 100644 --- a/Sources/Shared/Toolkit/Media/NowPlayingInfo.swift +++ b/Sources/Shared/Toolkit/Media/NowPlayingInfo.swift @@ -15,7 +15,7 @@ import UIKit public final class NowPlayingInfo { public static let shared = NowPlayingInfo() - public struct Media: Equatable { + public struct Media: Equatable, Sendable { /// The title (or name) of the media item. public var title: String /// The performing artist(s) for a media item. diff --git a/Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift b/Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift index e1e2d8457f..a0ae14a89c 100644 --- a/Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift +++ b/Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift @@ -15,7 +15,7 @@ public protocol AudioPublicationManifestAugmentor { func augment(_ baseManifest: Manifest, using container: Container) async -> AudioPublicationAugmentedManifest } -public struct AudioPublicationAugmentedManifest { +public struct AudioPublicationAugmentedManifest: Sendable { public var manifest: Manifest public var cover: UIImage? diff --git a/TestApp/Sources/App/AppModule.swift b/TestApp/Sources/App/AppModule.swift index 295abff889..071777f357 100644 --- a/TestApp/Sources/App/AppModule.swift +++ b/TestApp/Sources/App/AppModule.swift @@ -101,7 +101,7 @@ extension AppModule: OPDSModuleDelegate { _ publication: Publication?, at link: ReadiumShared.Link, sender: UIViewController, - progress: @escaping (Double) -> Void + progress: @escaping @Sendable (Double) -> Void ) async throws -> Book { guard let url = link.url(relativeTo: publication?.baseURL).httpURL else { throw OPDSError.invalidURL(link.href) diff --git a/TestApp/Sources/Reader/Common/VisualReaderViewController.swift b/TestApp/Sources/Reader/Common/VisualReaderViewController.swift index a2166f6dc3..fb15a6d0ed 100644 --- a/TestApp/Sources/Reader/Common/VisualReaderViewController.swift +++ b/TestApp/Sources/Reader/Common/VisualReaderViewController.swift @@ -407,7 +407,7 @@ extension Decoration.Style.Id { static let pageList: Decoration.Style.Id = "page_list" } -struct PageListConfig: Hashable { +struct PageListConfig: Hashable, Sendable { /// Page number label, taken from `publication.pageList[].title`. var label: String } diff --git a/Tests/SharedTests/Toolkit/AnySendableHashableTests.swift b/Tests/SharedTests/Toolkit/AnySendableHashableTests.swift new file mode 100644 index 0000000000..0d479bc5a3 --- /dev/null +++ b/Tests/SharedTests/Toolkit/AnySendableHashableTests.swift @@ -0,0 +1,74 @@ +// +// Copyright 2026 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. +// + +@testable import ReadiumShared +import Testing + +@Suite("AnySendableHashable") +struct AnySendableHashableTests { + @Test("Equality") + func equality() { + #expect(AnySendableHashable(1) == AnySendableHashable(1)) + #expect(AnySendableHashable(1) != AnySendableHashable(2)) + #expect(AnySendableHashable(1) != AnySendableHashable("1")) + } + + @Test("Hashing") + func hashing() { + #expect(AnySendableHashable(1).hashValue == AnySendableHashable(1).hashValue) + } + + @Test("Unwrapping") + func unwrapping() { + let h = AnySendableHashable(42) + #expect(h.unwrap(as: Int.self) == 42) + #expect(h.unwrap(as: String.self) == nil) + + let s = AnySendableHashable("hello") + #expect(s.unwrap(as: String.self) == "hello") + #expect(s.unwrap(as: Int.self) == nil) + } + + @Test("Nested wrapping is flattened") + func nestedWrapping() { + let h1 = AnySendableHashable(1) + let h2 = AnySendableHashable(h1) + let h3 = AnySendableHashable(h2) + + #expect(h1 == h2) + #expect(h1 == h3) + #expect(h2 == h3) + + #expect(h3.unwrap(as: Int.self) == 1) + #expect(!(h3.base is AnySendableHashable)) + } + + @Test("As dictionary key") + func asDictionaryKey() { + let dict: [AnySendableHashable: String] = [ + AnySendableHashable(1): "one", + AnySendableHashable("1"): "string one", + ] + + #expect(dict[AnySendableHashable(1)] == "one") + #expect(dict[AnySendableHashable("1")] == "string one") + } + + struct ComplexType: Hashable, Sendable { + let id: Int + let name: String + } + + @Test("Complex type") + func complexType() { + let val = ComplexType(id: 1, name: "test") + let h = AnySendableHashable(val) + + #expect(h.unwrap(as: ComplexType.self) == val) + #expect(h == AnySendableHashable(ComplexType(id: 1, name: "test"))) + #expect(h != AnySendableHashable(ComplexType(id: 2, name: "test"))) + } +} diff --git a/docs/Guides/Navigator/Decorations.md b/docs/Guides/Navigator/Decorations.md index 57a24afaa3..dd7fe36b5f 100644 --- a/docs/Guides/Navigator/Decorations.md +++ b/docs/Guides/Navigator/Decorations.md @@ -105,10 +105,10 @@ extension Decoration.Style.Id { #### 2. Define a config struct -The config carries the data your template needs. It must be `Hashable` so the diffing engine can detect changes. +The config carries the data your template needs. ```swift -struct PageListConfig: Hashable { +struct PageListConfig: Hashable, Sendable { /// Page number label from publication.pageList[].title var label: String } From 9a12961952ea4f889e648192b6888f47a7cdd254 Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Mon, 1 Jun 2026 07:18:06 -0500 Subject: [PATCH 09/39] Update `Logger` to be Swift 6 compliant (#798) --- Sources/Shared/Logger/Loggable.swift | 12 ++++-- Sources/Shared/Logger/Logger.swift | 54 +++++++++++++++----------- Sources/Shared/Logger/LoggerStub.swift | 4 +- 3 files changed, 41 insertions(+), 29 deletions(-) diff --git a/Sources/Shared/Logger/Loggable.swift b/Sources/Shared/Logger/Loggable.swift index 1f8c1f16a4..fe3d084637 100644 --- a/Sources/Shared/Logger/Loggable.swift +++ b/Sources/Shared/Logger/Loggable.swift @@ -63,11 +63,13 @@ public protocol Loggable { /// Default implementation public extension Loggable { func log(_ level: SeverityLevel, _ value: Any?, file: String, line: Int) { - Logger.sharedInstance.log(value, at: level, file: file, line: line) + let message = value.map { String(describing: $0) } + Logger.sharedInstance.log(message, at: level, file: file, line: line) } func log(_ level: SeverityLevel, _ value: Any?, defaultFile: String = #file, defaultLine: Int = #line) { - Logger.sharedInstance.log(value, at: level, file: defaultFile, line: defaultLine) + let message = value.map { String(describing: $0) } + Logger.sharedInstance.log(message, at: level, file: defaultFile, line: defaultLine) } @discardableResult func logAndRethrow(_ block: () throws -> T) rethrows -> T { @@ -80,11 +82,13 @@ public extension Loggable { } static func log(_ level: SeverityLevel, _ value: Any?, file: String, line: Int) { - Logger.sharedInstance.log(value, at: level, file: file, line: line) + let message = value.map { String(describing: $0) } + Logger.sharedInstance.log(message, at: level, file: file, line: line) } static func log(_ level: SeverityLevel, _ value: Any?, defaultFile: String = #file, defaultLine: Int = #line) { - Logger.sharedInstance.log(value, at: level, file: defaultFile, line: defaultLine) + let message = value.map { String(describing: $0) } + Logger.sharedInstance.log(message, at: level, file: defaultFile, line: defaultLine) } func warnIfMainThread(_ path: String = #file, _ function: String = #function, _ className: String = String(describing: Self.self), _ line: Int = #line) { diff --git a/Sources/Shared/Logger/Logger.swift b/Sources/Shared/Logger/Logger.swift index c92a9bc24f..99f887a5e9 100644 --- a/Sources/Shared/Logger/Logger.swift +++ b/Sources/Shared/Logger/Logger.swift @@ -14,29 +14,34 @@ import Foundation /// - customLogger: The Logger that will be used for printing logs. /// Defaults to a `LoggerStub` which may perform no-op logging. public func ReadiumEnableLog(withMinimumSeverityLevel level: SeverityLevel, customLogger: LoggerType = LoggerStub()) { - Logger.sharedInstance.setupLogger(logger: customLogger) - Logger.sharedInstance.setMinimumSeverityLevel(at: level) + Logger.sharedInstance.setupLogger(logger: customLogger, withMinimumSeverityLevel: level) print("\(SeverityLevel.info.symbol) Readium 2 Log enabled with minimum severity level of [\(level)].") } /// The Logger protocol. -public protocol LoggerType { - func log(level: SeverityLevel, value: Any?, file: String, line: Int) +public protocol LoggerType: Sendable { + func log(level: SeverityLevel, value: String?, file: String, line: Int) } /// Logger singleton. -public final class Logger { - /// The active logger is responssible for displaying the log message - /// throughout the framework. There is a default implementation `StubLogger` - /// available. You can define your own implementation by applying the - /// `Loggable` protocol to your xLogger class. - var activeLogger: LoggerType? +public final class Logger: Sendable { + struct State { + /// The active logger responsible for displaying the log messages + /// throughout the framework. There is a default implementation `LoggerStub` + /// available. You can define your own implementation by conforming your + /// custom logger class to the `LoggerType` protocol. + var activeLogger: LoggerType? - /// The minimum severity level for logs to be displayed. - var minimumSeverityLevel: SeverityLevel? + /// The minimum severity level for logs to be displayed. + var minimumSeverityLevel: SeverityLevel? + } + + private let state = Mutex(State()) - private(set) static var sharedInstance = Logger() + static let sharedInstance = Logger() + + private init() {} // MARK: - Public methods. @@ -49,8 +54,10 @@ public final class Logger { public func setupLogger(logger: LoggerType, withMinimumSeverityLevel severityLevel: SeverityLevel? = .warning) { - activeLogger = logger - minimumSeverityLevel = severityLevel + state.withLock { currentState in + currentState.activeLogger = logger + currentState.minimumSeverityLevel = severityLevel + } } /// Allow the framework user to set the minimum severity level for the logs @@ -58,20 +65,21 @@ public final class Logger { /// /// - Parameter severityLevel: The value from the `SeverityLevel` enum. public func setMinimumSeverityLevel(at severityLevel: SeverityLevel?) { - guard let severityLevel = severityLevel else { - return + guard let severityLevel else { return } + state.withLock { currentState in + currentState.minimumSeverityLevel = severityLevel } - minimumSeverityLevel = severityLevel } // MARK: - Internal methods. - func log(_ value: Any?, at level: SeverityLevel, file: String, line: Int) { - if let minimumSeverityLevel = minimumSeverityLevel { - guard level.numericValue >= minimumSeverityLevel.numericValue else { - return + func log(_ value: String?, at level: SeverityLevel, file: String, line: Int) { + let logger: LoggerType? = state.withLock { currentState in + if let minimumSeverityLevel = currentState.minimumSeverityLevel { + guard level.numericValue >= minimumSeverityLevel.numericValue else { return nil } } + return currentState.activeLogger } - activeLogger?.log(level: level, value: value, file: file, line: line) + logger?.log(level: level, value: value, file: file, line: line) } } diff --git a/Sources/Shared/Logger/LoggerStub.swift b/Sources/Shared/Logger/LoggerStub.swift index 78798299a2..0bb1286eeb 100644 --- a/Sources/Shared/Logger/LoggerStub.swift +++ b/Sources/Shared/Logger/LoggerStub.swift @@ -12,11 +12,11 @@ public final class LoggerStub: LoggerType, Sendable { public init() {} /// Log `message` with a severity of `level`. - public func log(level: SeverityLevel, value: Any?, file: String, line: Int) { + public func log(level: SeverityLevel, value: String?, file: String, line: Int) { guard let value = value else { return } let fileName = URL(fileURLWithPath: file).lastPathComponent - print("\(level.symbol) \(fileName):\(line): \(String(describing: value))") + print("\(level.symbol) \(fileName):\(line): \(value)") } } From 24d460ca8d408224e5787f9d0f4a9bd9d5504c88 Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Mon, 1 Jun 2026 09:27:13 -0500 Subject: [PATCH 10/39] Update Publication Services to be Swift 6 compliant (#769) --- .../Cover/GeneratedCoverService.swift | 24 +++---- .../Positions/InMemoryPositionsService.swift | 2 +- .../PerResourcePositionsService.swift | 45 ++++++------ Sources/Shared/Toolkit/AsyncMemoizer.swift | 2 +- .../Audio/Services/AudioLocatorService.swift | 70 +++++++++++-------- .../PDF/Services/LCPDFPositionsService.swift | 37 +++++----- .../LCPDFTableOfContentsService.swift | 51 ++++++-------- .../PDF/Services/PDFPositionsService.swift | 4 +- .../Toolkit/HTTP/DefaultHTTPClientTests.swift | 4 +- .../Services/AudioLocatorServiceTests.swift | 2 +- 10 files changed, 118 insertions(+), 123 deletions(-) diff --git a/Sources/Shared/Publication/Services/Cover/GeneratedCoverService.swift b/Sources/Shared/Publication/Services/Cover/GeneratedCoverService.swift index cca40733a3..f2a1c0f546 100644 --- a/Sources/Shared/Publication/Services/Cover/GeneratedCoverService.swift +++ b/Sources/Shared/Publication/Services/Cover/GeneratedCoverService.swift @@ -8,20 +8,19 @@ import Foundation import UIKit /// A `CoverService` which holds a lazily generated cover bitmap in memory. -public final class GeneratedCoverService: CoverService { +public final class GeneratedCoverService: CoverService, Sendable { enum Error: Swift.Error { case generationFailed } - private var _cover: ReadResult? - private let makeCover: () async -> ReadResult + private let cachedCover: AsyncMemoizer> - public init(makeCover: @escaping () async -> ReadResult) { - self.makeCover = makeCover + public init(makeCover: @escaping @Sendable () async -> ReadResult) { + cachedCover = AsyncMemoizer(makeCover) } public convenience init(cover: UIImage) { - self.init(makeCover: { .success(cover) }) + self.init(makeCover: { [cover] in .success(cover) }) } private let coverLink = Link( @@ -30,13 +29,6 @@ public final class GeneratedCoverService: CoverService { rel: .cover ) - private func cachedCover() async -> ReadResult { - if _cover == nil { - _cover = await makeCover() - } - return _cover! - } - public func cover() async -> ReadResult { await cachedCover().map { $0 as UIImage? } } @@ -50,14 +42,14 @@ public final class GeneratedCoverService: CoverService { return nil } - return CoverResource(cover: cachedCover) + return CoverResource { await self.cachedCover() } } - public static func makeFactory(makeCover: @escaping () async -> ReadResult) -> (PublicationServiceContext) -> GeneratedCoverService? { + public static func makeFactory(makeCover: @escaping @Sendable () async -> ReadResult) -> @Sendable (PublicationServiceContext) -> GeneratedCoverService? { { _ in GeneratedCoverService(makeCover: makeCover) } } - public static func makeFactory(cover: UIImage) -> (PublicationServiceContext) -> GeneratedCoverService? { + public static func makeFactory(cover: UIImage) -> @Sendable (PublicationServiceContext) -> GeneratedCoverService? { { _ in GeneratedCoverService(cover: cover) } } diff --git a/Sources/Shared/Publication/Services/Positions/InMemoryPositionsService.swift b/Sources/Shared/Publication/Services/Positions/InMemoryPositionsService.swift index 6790ea5080..54e8a2487c 100644 --- a/Sources/Shared/Publication/Services/Positions/InMemoryPositionsService.swift +++ b/Sources/Shared/Publication/Services/Positions/InMemoryPositionsService.swift @@ -18,7 +18,7 @@ public final class InMemoryPositionsService: PositionsService, Sendable { .success(_positions) } - public static func makeFactory(positionsByReadingOrder: [[Locator]]) -> (PublicationServiceContext) -> InMemoryPositionsService { + public static func makeFactory(positionsByReadingOrder: [[Locator]]) -> @Sendable (PublicationServiceContext) -> InMemoryPositionsService { { _ in InMemoryPositionsService(positionsByReadingOrder: positionsByReadingOrder) } diff --git a/Sources/Shared/Publication/Services/Positions/PerResourcePositionsService.swift b/Sources/Shared/Publication/Services/Positions/PerResourcePositionsService.swift index 1bdc239308..49e223bc9d 100644 --- a/Sources/Shared/Publication/Services/Positions/PerResourcePositionsService.swift +++ b/Sources/Shared/Publication/Services/Positions/PerResourcePositionsService.swift @@ -8,38 +8,35 @@ import Foundation /// Simple `PositionsService` for a `Publication` which generates one position per `readingOrder` /// resource. -public final class PerResourcePositionsService: PositionsService { - private let readingOrder: [Link] - - /// Media type that will be used as a fallback if the `Link` doesn't specify any. - private let fallbackMediaType: MediaType +public final class PerResourcePositionsService: PositionsService, Sendable { + private let positions: [[Locator]] init(readingOrder: [Link], fallbackMediaType: MediaType) { - self.readingOrder = readingOrder - self.fallbackMediaType = fallbackMediaType + guard !readingOrder.isEmpty else { + positions = [] + return + } + + positions = readingOrder.enumerated().map { index, link in + [ + Locator( + href: link.url(), + mediaType: link.mediaType ?? fallbackMediaType, + title: link.title, + locations: Locator.Locations( + totalProgression: Double(index) / Double(readingOrder.count), + position: index + 1 + ) + ), + ] + } } public func positionsByReadingOrder() async -> ReadResult<[[Locator]]> { .success(positions) } - private lazy var pageCount: Int = readingOrder.count - - private lazy var positions: [[Locator]] = readingOrder.enumerated().map { index, link in - [ - Locator( - href: link.url(), - mediaType: link.mediaType ?? fallbackMediaType, - title: link.title, - locations: Locator.Locations( - totalProgression: Double(index) / Double(pageCount), - position: index + 1 - ) - ), - ] - } - - public static func makeFactory(fallbackMediaType: MediaType) -> (PublicationServiceContext) -> PerResourcePositionsService { + public static func makeFactory(fallbackMediaType: MediaType) -> @Sendable (PublicationServiceContext) -> PerResourcePositionsService { { context in PerResourcePositionsService(readingOrder: context.manifest.readingOrder, fallbackMediaType: fallbackMediaType) } diff --git a/Sources/Shared/Toolkit/AsyncMemoizer.swift b/Sources/Shared/Toolkit/AsyncMemoizer.swift index e31bf74747..6d14cbfe0c 100644 --- a/Sources/Shared/Toolkit/AsyncMemoizer.swift +++ b/Sources/Shared/Toolkit/AsyncMemoizer.swift @@ -15,7 +15,7 @@ /// /// let result = await memoizer() /// ``` -package actor AsyncMemoizer { +package actor AsyncMemoizer { private let compute: @Sendable () async -> T private var task: Task? diff --git a/Sources/Streamer/Parser/Audio/Services/AudioLocatorService.swift b/Sources/Streamer/Parser/Audio/Services/AudioLocatorService.swift index 5fea158fe0..88795d7930 100644 --- a/Sources/Streamer/Parser/Audio/Services/AudioLocatorService.swift +++ b/Sources/Streamer/Parser/Audio/Services/AudioLocatorService.swift @@ -9,22 +9,52 @@ import ReadiumShared /// Locator service for audio publications. final class AudioLocatorService: DefaultLocatorService { - static func makeFactory() -> (PublicationServiceContext) -> AudioLocatorService { - { context in AudioLocatorService(publication: context.publication) } + static func makeFactory() -> @Sendable (PublicationServiceContext) -> AudioLocatorService { + { context in + AudioLocatorService( + readingOrder: context.manifest.readingOrder, + publication: context.publication + ) + } } - private lazy var readingOrder: [Link] = - publication()?.readingOrder ?? [] + private let readingOrder: [Link] /// Duration per reading order index. - private lazy var durations: [Double] = - readingOrder.map { $0.duration ?? 0 } + private let durations: [Double] /// Total duration of the publication. - private lazy var totalDuration: Double? = { - let totalDuration = durations.reduce(0, +) - return (totalDuration > 0) ? totalDuration : nil - }() + private let totalDuration: Double? + + init(readingOrder: [Link], publication: Weak) { + self.readingOrder = readingOrder + let durations = readingOrder.map { $0.duration ?? 0 } + self.durations = durations + let total = durations.reduce(0, +) + totalDuration = (total > 0) ? total : nil + + super.init(publication: publication) + } + + /// Finds the reading order item containing the time `position` (in seconds), as well as its + /// start time. + private func readingOrderItemAtPosition(_ position: Double) -> (link: Link, startPosition: Double)? { + var current: Double = 0 + for (i, duration) in durations.enumerated() { + let link = readingOrder[i] + if current ..< current + duration ~= position { + return (link, startPosition: current) + } + + current += duration + } + + if position == totalDuration, let link = readingOrder.last { + return (link, startPosition: current - (link.duration ?? 0)) + } + + return nil + } override func locate(progression: Double) async -> Locator? { guard let totalDuration = totalDuration else { @@ -54,24 +84,4 @@ final class AudioLocatorService: DefaultLocatorService { ) ) } - - /// Finds the reading order item containing the time `position` (in seconds), as well as its - /// start time. - private func readingOrderItemAtPosition(_ position: Double) -> (link: Link, startPosition: Double)? { - var current: Double = 0 - for (i, duration) in durations.enumerated() { - let link = readingOrder[i] - if current ..< current + duration ~= position { - return (link, startPosition: current) - } - - current += duration - } - - if position == totalDuration, let link = readingOrder.last { - return (link, startPosition: current - (link.duration ?? 0)) - } - - return nil - } } diff --git a/Sources/Streamer/Parser/PDF/Services/LCPDFPositionsService.swift b/Sources/Streamer/Parser/PDF/Services/LCPDFPositionsService.swift index c840070d3a..ee7afc75d8 100644 --- a/Sources/Streamer/Parser/PDF/Services/LCPDFPositionsService.swift +++ b/Sources/Streamer/Parser/PDF/Services/LCPDFPositionsService.swift @@ -12,32 +12,35 @@ import ReadiumShared /// to get its page count. /// /// Requires the publication to have a ``PDFDocumentService``. -final class LCPDFPositionsService: PositionsService, Loggable { - private let readingOrder: [Link] - private let publication: Weak +final class LCPDFPositionsService: PositionsService, Loggable, Sendable { + private let cache: AsyncMemoizer> - init(readingOrder: [Link], publication: Weak) { - self.readingOrder = readingOrder - self.publication = publication + init(publication: Weak) { + cache = AsyncMemoizer { [publication] in + guard let publication = publication() else { + return .failure(.cancelled) + } + + return await Self.makePositionList(of: publication) + } } func positionsByReadingOrder() async -> ReadResult<[[Locator]]> { - await positionsByReadingOrderTask.value + await cache() } - private lazy var positionsByReadingOrderTask: Task, Never> = Task { - guard let pdfDocumentService = self.publication.ref?.pdfDocumentService else { + private static func makePositionList(of publication: Publication) async -> ReadResult<[[Locator]]> { + guard let pdfDocumentService = publication.pdfDocumentService else { return .failure(.unsupportedOperation(DebugError("PDFDocumentService is required to use the LCPDFPositionsService"))) } - // Calculates the page count of each resource from the reading order. - let resources = await readingOrder.asyncMap { link -> (Int, Link) in + let resources = await publication.readingOrder.asyncMap { link -> (Int, Link) in let href = link.url() guard let document = try? await pdfDocumentService.openDocument(at: href), let pageCount = try? await document.pageCount() else { - log(.warning, "Can't get the number of pages from PDF document at \(link)") + LCPDFPositionsService.log(.warning, "Can't get the number of pages from PDF document at \(link)") return (0, link) } return (pageCount, link) @@ -46,6 +49,7 @@ final class LCPDFPositionsService: PositionsService, Loggable { let totalPageCount = resources.reduce(0) { count, current in count + current.0 } var lastPositionOfPreviousResource = 0 + return .success(resources.map { pageCount, link -> [Locator] in guard pageCount > 0 else { return [] @@ -56,7 +60,7 @@ final class LCPDFPositionsService: PositionsService, Loggable { }) } - private func makePositionList(of link: Link, pageCount: Int, totalPageCount: Int, startPosition: Int = 0) -> [Locator] { + private static func makePositionList(of link: Link, pageCount: Int, totalPageCount: Int, startPosition: Int = 0) -> [Locator] { assert(pageCount > 0, "Invalid PDF page count") assert(totalPageCount > 0, "Invalid PDF total page count") @@ -76,12 +80,9 @@ final class LCPDFPositionsService: PositionsService, Loggable { } } - static func makeFactory() -> (PublicationServiceContext) -> LCPDFPositionsService? { + static func makeFactory() -> @Sendable (PublicationServiceContext) -> LCPDFPositionsService? { { context in - LCPDFPositionsService( - readingOrder: context.manifest.readingOrder, - publication: context.publication - ) + LCPDFPositionsService(publication: context.publication) } } } diff --git a/Sources/Streamer/Parser/PDF/Services/LCPDFTableOfContentsService.swift b/Sources/Streamer/Parser/PDF/Services/LCPDFTableOfContentsService.swift index c007f9628a..dee060dae4 100644 --- a/Sources/Streamer/Parser/PDF/Services/LCPDFTableOfContentsService.swift +++ b/Sources/Streamer/Parser/PDF/Services/LCPDFTableOfContentsService.swift @@ -12,44 +12,39 @@ import ReadiumShared /// when the table of contents is missing from the `manifest.json` file. /// /// Requires the publication to have a ``PDFDocumentService``. -final class LCPDFTableOfContentsService: TableOfContentsService, Loggable { - private let manifest: Manifest - private let publication: Weak +final class LCPDFTableOfContentsService: TableOfContentsService, Loggable, Sendable { + private let cache: AsyncMemoizer> init( manifest: Manifest, publication: Weak ) { - self.manifest = manifest - self.publication = publication + cache = AsyncMemoizer { [manifest, publication] in + guard + manifest.tableOfContents.isEmpty, + manifest.readingOrder.count == 1, + let url = manifest.readingOrder.first?.url() + else { + return .success(manifest.tableOfContents) + } + guard let pdfDocumentService = publication.ref?.pdfDocumentService else { + return .failure(.unsupportedOperation(DebugError("PDFDocumentService is required to use the LCPDFTableOfContentsService"))) + } + + do { + let toc = try await pdfDocumentService.openDocument(at: url).tableOfContents() + return .success(toc.linksWithDocumentHREF(url)) + } catch { + return .failure(.wrap(error) ?? .decoding(error)) + } + } } func tableOfContents() async -> ReadResult<[Link]> { - await tableOfContentsTask.value - } - - private lazy var tableOfContentsTask: Task, Never> = Task { - guard - manifest.tableOfContents.isEmpty, - manifest.readingOrder.count == 1, - let url = manifest.readingOrder.first?.url() - else { - return .success(manifest.tableOfContents) - } - - guard let pdfDocumentService = publication.ref?.pdfDocumentService else { - return .failure(.unsupportedOperation(DebugError("PDFDocumentService is required to use the LCPDFTableOfContentsService"))) - } - - do { - let toc = try await pdfDocumentService.openDocument(at: url).tableOfContents() - return .success(toc.linksWithDocumentHREF(url)) - } catch { - return .failure(.wrap(error) ?? .decoding(error)) - } + await cache() } - static func makeFactory() -> (PublicationServiceContext) -> LCPDFTableOfContentsService? { + static func makeFactory() -> @Sendable (PublicationServiceContext) -> LCPDFTableOfContentsService? { { context in LCPDFTableOfContentsService( manifest: context.manifest, diff --git a/Sources/Streamer/Parser/PDF/Services/PDFPositionsService.swift b/Sources/Streamer/Parser/PDF/Services/PDFPositionsService.swift index f4a3fc3b03..78895f484c 100644 --- a/Sources/Streamer/Parser/PDF/Services/PDFPositionsService.swift +++ b/Sources/Streamer/Parser/PDF/Services/PDFPositionsService.swift @@ -7,7 +7,7 @@ import Foundation import ReadiumShared -final class PDFPositionsService: PositionsService { +final class PDFPositionsService: PositionsService, Sendable { init(link: Link, pageCount: Int, tableOfContents: [Link]) { assert(pageCount > 0, "Invalid PDF page count") // FIXME: Use the `tableOfContents` to generate the titles @@ -35,7 +35,7 @@ final class PDFPositionsService: PositionsService { .success(_positionsByReadingOrder) } - static func makeFactory() -> (PublicationServiceContext) -> PDFPositionsService? { + static func makeFactory() -> @Sendable (PublicationServiceContext) -> PDFPositionsService? { { context in guard let link = context.manifest.readingOrder.first, diff --git a/Tests/SharedTests/Toolkit/HTTP/DefaultHTTPClientTests.swift b/Tests/SharedTests/Toolkit/HTTP/DefaultHTTPClientTests.swift index 716cee0f85..b530b26586 100644 --- a/Tests/SharedTests/Toolkit/HTTP/DefaultHTTPClientTests.swift +++ b/Tests/SharedTests/Toolkit/HTTP/DefaultHTTPClientTests.swift @@ -658,11 +658,11 @@ struct DefaultHTTPClientTests { let client = DefaultHTTPClient() let task = Task { - await client.stream(HTTPURL(string: "https://httpbin.org/drip?duration=10&numbytes=102400&chunk_size=1024")!) { _, _ in .success(()) } + await client.stream(HTTPURL(string: "https://github.com/readium/swift-toolkit/archive/refs/heads/develop.zip")!) { _, _ in .success(()) } } // Give the request time to start, then cancel before the end. - try? await Task.sleep(nanoseconds: 1_000_000_000) // 1s + try? await Task.sleep(seconds: 0.1) task.cancel() let result = await task.value diff --git a/Tests/StreamerTests/Parser/Audio/Services/AudioLocatorServiceTests.swift b/Tests/StreamerTests/Parser/Audio/Services/AudioLocatorServiceTests.swift index 54c477afba..ead9f94593 100644 --- a/Tests/StreamerTests/Parser/Audio/Services/AudioLocatorServiceTests.swift +++ b/Tests/StreamerTests/Parser/Audio/Services/AudioLocatorServiceTests.swift @@ -199,7 +199,7 @@ class AudioLocatorServiceTests: XCTestCase { let publication = Publication( manifest: Manifest(metadata: Metadata(title: ""), readingOrder: readingOrder) ) - let service = AudioLocatorService(publication: Weak(publication)) + let service = AudioLocatorService(readingOrder: readingOrder, publication: Weak(publication)) return (publication, service) } } From 49cb4b40a0d60ebd5e2222a40bca5cb28db8fe8c Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:38:37 -0500 Subject: [PATCH 11/39] Make `Streamable` and `Resource` protocols `Sendable` (#801) --- .../LCP/Content Protection/LCPDecryptor.swift | 146 +++++++++++------- .../EPUB/CSS/HTMLFontFamilyDeclaration.swift | 94 ++++++++--- .../EPUB/EPUBNavigatorViewModel.swift | 28 ++-- .../Cover/GeneratedCoverService.swift | 8 +- .../PerResourcePositionsService.swift | 32 ++-- .../Services/Positions/PositionsService.swift | 13 +- .../Data/Resource/BorrowedResource.swift | 4 +- .../Data/Resource/BufferingResource.swift | 14 +- .../Data/Resource/CachingResource.swift | 2 +- .../Toolkit/Data/Resource/DataResource.swift | 8 +- .../Data/Resource/FailureResource.swift | 4 +- .../Data/Resource/TailCachingResource.swift | 12 +- .../Data/Resource/TransformingResource.swift | 40 ++--- Sources/Shared/Toolkit/Data/Streamable.swift | 51 +++--- .../Shared/Toolkit/File/FileResource.swift | 2 +- .../Shared/Toolkit/HTTP/HTTPResource.swift | 2 +- .../ZIP/Minizip/MinizipContainer.swift | 2 +- .../ZIPFoundationContainer.swift | 2 +- .../EPUBDeobfuscator.swift | 2 +- .../PDF/Services/LCPDFPositionsService.swift | 2 +- .../Positions/PositionsServiceTests.swift | 2 +- .../Toolkit/Data/Resource/FakeResource.swift | 2 +- .../Resource/TransformingResourceTests.swift | 20 +-- .../Toolkit/HTTP/HTTPResourceTests.swift | 10 +- .../Services/EPUBPositionsServiceTests.swift | 2 +- 25 files changed, 291 insertions(+), 213 deletions(-) diff --git a/Sources/LCP/Content Protection/LCPDecryptor.swift b/Sources/LCP/Content Protection/LCPDecryptor.swift index 7baa2e29ac..25a2388d15 100644 --- a/Sources/LCP/Content Protection/LCPDecryptor.swift +++ b/Sources/LCP/Content Protection/LCPDecryptor.swift @@ -58,36 +58,46 @@ final class LCPDecryptor { } } - /// A LCP resource that is read, decrypted and cached fully before reading requested ranges. + /// An LCP resource that is read, decrypted and cached fully before reading + /// requested ranges. /// - /// Can be used when it's impossible to map a read range (byte range request) to the encrypted - /// resource, for example when the resource is deflated before encryption. - private class FullLCPResource: TransformingResource { - private let license: LCPLicense - private let encryption: ReadiumShared.Encryption + /// Can be used when it's impossible to map a read range (byte range + /// request) to the encrypted resource, for example when the resource is + /// deflated before encryption. + private final class FullLCPResource: Resource, Sendable { + private let resource: TransformingResource + private let originalLength: UInt64? init(_ resource: Resource, license: LCPLicense, encryption: ReadiumShared.Encryption) { - self.license = license - self.encryption = encryption - super.init(resource) + originalLength = encryption.originalLength.map { UInt64($0) } + self.resource = TransformingResource(resource, transform: { data in + await license.decryptFully(data: data, isDeflated: encryption.isDeflated) + }) + } + + let sourceURL: AbsoluteURL? = nil + + func properties() async -> ReadResult { + await resource.properties() } - override func transform(data: ReadResult) async -> ReadResult { - await license.decryptFully(data: data, isDeflated: encryption.isDeflated) + func estimatedLength() async -> ReadResult { + .success(originalLength) } - override func estimatedLength() async -> ReadResult { - .success(encryption.originalLength.map { UInt64($0) }) + func stream(range: Range?, consume: @escaping @Sendable (Data) -> Void) async -> ReadResult { + await resource.stream(range: range, consume: consume) } } /// A LCP resource used to read content encrypted with the CBC algorithm. /// /// Supports random access for byte range requests, but the resource MUST NOT be deflated. - private class CBCLCPResource: Resource { + private final class CBCLCPResource: Resource, Sendable { private let resource: Resource private let license: LCPLicense private let encryption: ReadiumShared.Encryption + private let plainTextSize: AsyncMemoizer> init(_ resource: Resource, license: LCPLicense, encryption: ReadiumShared.Encryption) { assert(!encryption.isDeflated) @@ -95,6 +105,10 @@ final class LCPDecryptor { self.resource = resource self.license = license self.encryption = encryption + + plainTextSize = AsyncMemoizer { [resource, license] in + await license.plainTextSizeOfCBCResource(resource) + } } let sourceURL: AbsoluteURL? = nil @@ -104,44 +118,10 @@ final class LCPDecryptor { } func estimatedLength() async -> ReadResult { - await plainTextSize + await plainTextSize() } - private var plainTextSize: ReadResult { - get async { await plainTextSizeTask.value } - } - - private lazy var plainTextSizeTask = Task, Never> { - await resource.estimatedLength().asyncFlatMap { length in - guard let length = length else { - return failure(.requiredEstimatedLength) - } - guard length.isValidAESChunk else { - return failure(.invalidCBCData) - } - - let readPosition = length - 2 * AESBlockSize - return await resource.read(range: readPosition ..< length) - .flatMap { encryptedData in - do { - guard let data = try license.decipher(encryptedData) else { - return failure(.emptyDecryptedData) - } - - let paddingSize = UInt64(data.last ?? 0) - - let result = length - - AESBlockSize // Minus IV or previous block - - paddingSize // Minus padding part - return .success(result) - } catch { - return .failure(.decoding(error)) - } - } - } - } - - func stream(range: Range?, consume: @escaping (Data) -> Void) async -> ReadResult { + func stream(range: Range?, consume: @escaping @Sendable (Data) -> Void) async -> ReadResult { guard let range = range else { return await license.decryptFully(data: resource.read(), isDeflated: encryption.isDeflated) .map { @@ -152,10 +132,10 @@ final class LCPDecryptor { return await resource.estimatedLength().asyncFlatMap { encryptedLength in guard let encryptedLength = encryptedLength else { - return failure(.requiredEstimatedLength) + return .failure(.decoding(LCPDecryptor.Error.requiredEstimatedLength)) } guard let rangeFirst = range.first, let rangeLast = range.last else { - return failure(.invalidRange(range)) + return .failure(.decoding(LCPDecryptor.Error.invalidRange(range))) } // Encrypted data is shifted by AESBlockSize, because of IV and because the @@ -167,14 +147,14 @@ final class LCPDecryptor { ) return await resource.read(range: encryptedStart ..< encryptedEndExclusive) - .combine(plainTextSize) + .combine(plainTextSize()) .flatMap { encryptedData, plainTextSize in do { guard let plainTextSize = plainTextSize else { - return failure(.noPlainTextSize) + return .failure(.decoding(LCPDecryptor.Error.noPlainTextSize)) } guard let bytes = try license.decipher(encryptedData) else { - return failure(.emptyDecryptedData) + return .failure(.decoding(LCPDecryptor.Error.emptyDecryptedData)) } // Exclude the bytes added to match a multiple of AESBlockSize. @@ -199,14 +179,62 @@ final class LCPDecryptor { } } } - - private func failure(_ error: LCPDecryptor.Error) -> ReadResult { - .failure(.decoding(error)) - } } } private extension LCPLicense { + /// Computes the plain text size of a CBC-encrypted, non-deflated LCP + /// resource. + /// + /// The size of an LCP-encrypted resource doesn't match the size of its + /// decrypted content, because of: + /// - the 16-byte IV prepended to the ciphertext, and + /// - the PKCS#7 padding (1...16 bytes) appended to align the plaintext + /// on a multiple of `AESBlockSize`. + /// + /// To recover the exact plain text size without decrypting the whole + /// resource, we read and decrypt only the last two AES blocks: the second- + /// to-last block serves as the IV for the last one, whose final byte + /// encodes the padding length per PKCS#7. + /// + /// - Important: This must only be called on a CBC-encrypted resource that + /// is **not** deflated. On a deflated resource, the returned value would + /// be the *compressed* size, not the actual plain text size. + /// + /// - Returns: The decrypted content length in bytes, or a failure if + /// the resource is not a valid CBC chunk or cannot be deciphered. + func plainTextSizeOfCBCResource(_ resource: Resource) async -> ReadResult { + await resource.estimatedLength().asyncFlatMap { length in + guard let length = length else { + return .failure(.decoding(LCPDecryptor.Error.requiredEstimatedLength)) + } + guard length.isValidAESChunk else { + return .failure(.decoding(LCPDecryptor.Error.invalidCBCData)) + } + + // Read the last two AES blocks: the penultimate one is needed as + // the IV to decrypt the last one, which carries the PKCS#7 padding. + let readPosition = length - 2 * AESBlockSize + return await resource.read(range: readPosition ..< length) + .flatMap { encryptedData in + do { + guard let data = try self.decipher(encryptedData) else { + return .failure(.decoding(LCPDecryptor.Error.emptyDecryptedData)) + } + + let paddingSize = UInt64(data.last ?? 0) + return .success( + length + - AESBlockSize // IV + - paddingSize // PKCS#7 padding + ) + } catch { + return .failure(.decoding(error)) + } + } + } + } + func decryptFully(data: ReadResult, isDeflated: Bool) async -> ReadResult { data.flatMap { guard UInt64($0.count).isValidAESChunk else { diff --git a/Sources/Navigator/EPUB/CSS/HTMLFontFamilyDeclaration.swift b/Sources/Navigator/EPUB/CSS/HTMLFontFamilyDeclaration.swift index 74eead1ccf..169c28bbf7 100644 --- a/Sources/Navigator/EPUB/CSS/HTMLFontFamilyDeclaration.swift +++ b/Sources/Navigator/EPUB/CSS/HTMLFontFamilyDeclaration.swift @@ -7,7 +7,11 @@ import Foundation import ReadiumShared -public protocol HTMLFontFamilyDeclaration { +public enum HTMLFontFamilyError: Error { + case fontNotServed(FileURL) +} + +public protocol HTMLFontFamilyDeclaration: Sendable { /// Name of the font family. /// /// This will be the value of the `fontFamily` EPUB preference. @@ -17,18 +21,32 @@ public protocol HTMLFontFamilyDeclaration { /// symbols are missing from `fontFamily`. var alternates: [FontFamily] { get } + /// List of local font files that must be served and made accessible to web + /// content before calling `inject(in:servedFiles:)`. + /// + /// This is optional and only needed when the implementation injects local + /// font files. Return an empty array if no local files need to be served. + var fontFiles: [FileURL] { get } + /// Injects this font family declaration in the given `html` document. /// - /// Use `servingFile` to convert a file URL into a URL accessible from the - /// web views. - func inject(in html: String, servingFile: (FileURL) throws -> any AbsoluteURL) throws -> String + /// Use `servedFiles` to look up the web-accessible URL for a given + /// `fontFiles` URL. + func inject(in html: String, servedFiles: [FileURL: any AbsoluteURL]) throws -> String +} + +public extension HTMLFontFamilyDeclaration { + var fontFiles: [FileURL] { + [] + } } /// A type-erasing `HTMLFontFamilyDeclaration` object -public struct AnyHTMLFontFamilyDeclaration: HTMLFontFamilyDeclaration { - private let _fontFamily: () -> FontFamily - private let _alternates: () -> [FontFamily] - private let _inject: (String, (FileURL) throws -> any AbsoluteURL) throws -> String +public struct AnyHTMLFontFamilyDeclaration: HTMLFontFamilyDeclaration, Sendable { + private let _fontFamily: @Sendable () -> FontFamily + private let _alternates: @Sendable () -> [FontFamily] + private let _fontFiles: @Sendable () -> [FileURL] + private let _inject: @Sendable (String, [FileURL: any AbsoluteURL]) throws -> String public var fontFamily: FontFamily { _fontFamily() @@ -38,14 +56,19 @@ public struct AnyHTMLFontFamilyDeclaration: HTMLFontFamilyDeclaration { _alternates() } + public var fontFiles: [FileURL] { + _fontFiles() + } + public init(_ declaration: T) { _fontFamily = { declaration.fontFamily } _alternates = { declaration.alternates } - _inject = { try declaration.inject(in: $0, servingFile: $1) } + _fontFiles = { declaration.fontFiles } + _inject = { try declaration.inject(in: $0, servedFiles: $1) } } - public func inject(in html: String, servingFile: (FileURL) throws -> any AbsoluteURL) throws -> String { - try _inject(html, servingFile) + public func inject(in html: String, servedFiles: [FileURL: any AbsoluteURL]) throws -> String { + try _inject(html, servedFiles) } } @@ -64,19 +87,23 @@ public struct CSSFontFamilyDeclaration: HTMLFontFamilyDeclaration, Sendable { /// Declarations for the individual font files for this font family. public var fontFaces: [CSSFontFace] + public var fontFiles: [FileURL] { + fontFaces.flatMap(\.fontFiles) + } + public init(fontFamily: FontFamily, alternates: [FontFamily] = [], fontFaces: [CSSFontFace] = []) { self.fontFamily = fontFamily self.alternates = alternates self.fontFaces = fontFaces } - public func inject(in html: String, servingFile: (FileURL) throws -> any AbsoluteURL) throws -> String { + public func inject(in html: String, servedFiles: [FileURL: any AbsoluteURL]) throws -> String { var injections = try fontFaces.flatMap { - try $0.injections(for: html, servingFile: servingFile) + try $0.injections(for: html, servedFiles: servedFiles) } let css = try fontFaces - .map { try $0.css(for: fontFamily.rawValue, servingFile: servingFile) } + .map { try $0.css(for: fontFamily.rawValue, servedFiles: servedFiles) } .joined(separator: "\n") injections.append(.style(css)) @@ -100,6 +127,10 @@ public struct CSSFontFace: Sendable { public var weight: CSSFontWeight? private var sources: [Source] + public var fontFiles: [FileURL] { + sources.map(\.file) + } + public init( file: FileURL, preload: Bool = false, @@ -124,17 +155,24 @@ public struct CSSFontFace: Sendable { return copy } - func injections(for html: String, servingFile: (FileURL) throws -> any AbsoluteURL) throws -> [HTMLInjection] { + func injections(for html: String, servedFiles: [FileURL: any AbsoluteURL]) throws -> [HTMLInjection] { try sources .filter(\.preload) .map { source in - let file = try servingFile(source.file) + guard let file = servedFiles[source.file] else { + throw HTMLFontFamilyError.fontNotServed(source.file) + } return .link(href: file.string, rel: "preload", as: "font", crossOrigin: "") } } - func css(for fontFamily: String, servingFile: (FileURL) throws -> any AbsoluteURL) throws -> String { - let urls = try sources.map { try servingFile($0.file) } + func css(for fontFamily: String, servedFiles: [FileURL: any AbsoluteURL]) throws -> String { + let urls = try sources.map { source in + guard let url = servedFiles[source.file] else { + throw HTMLFontFamilyError.fontNotServed(source.file) + } + return url + } var descriptors: [String: String] = [ "font-family": "\"\(fontFamily)\"", "src": urls.map { "url(\"\($0.string)\")" }.joined(separator: ", "), @@ -186,3 +224,23 @@ public enum CSSStandardFontWeight: Int, Codable, Sendable { case extraBold = 800 case black = 900 } + +extension WebViewServer { + /// Serves the font files for the given font family declarations and returns + /// a mapping from each font file URL to its web-accessible URL. + func serve( + _ fontFamilyDeclarations: [AnyHTMLFontFamilyDeclaration] + ) -> [FileURL: any AbsoluteURL] { + var servedFonts: [FileURL: AbsoluteURL] = [:] + for ff in fontFamilyDeclarations { + for file in ff.fontFiles { + if servedFonts[file] == nil { + let name = file.lastPathSegment ?? UUID().uuidString + servedFonts[file] = serve(file: file, at: "assets/fonts/\(name)") + } + } + } + + return servedFonts + } +} diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift index bce0ae7e7e..06f5cb0b1a 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift @@ -125,6 +125,7 @@ enum EPUBScriptScope { self.server = server self.assetsBaseURL = assetsBaseURL self.formatSniffer = formatSniffer + servedFonts = server.serve(config.fontFamilyDeclarations) preferences = config.preferences settings = EPUBSettings(publication: publication, config: config) @@ -305,7 +306,7 @@ enum EPUBScriptScope { // MARK: - Readium CSS private var css: ReadiumCSS - private var servedFonts: [FileURL: AbsoluteURL] = [:] + private let servedFonts: [FileURL: AbsoluteURL] func injectReadiumCSS(in resource: Resource, at href: HREF) -> Resource { guard @@ -316,30 +317,19 @@ enum EPUBScriptScope { return resource } - return resource.mapAsString { [weak self] content in - guard let self = self else { - return content - } + let css = css + let fontFamilyDeclarations = config.fontFamilyDeclarations + let servedFonts = servedFonts + return resource.mapAsString { content in do { var content = try css.inject(in: content) - for ff in config.fontFamilyDeclarations { - content = try ff.inject( - in: content, - servingFile: { [server] file in - if let url = self.servedFonts[file] { - return url - } - let name = file.lastPathSegment ?? UUID().uuidString - let url = server.serve(file: file, at: "assets/fonts/\(name)") - self.servedFonts[file] = url - return url - } - ) + for ff in fontFamilyDeclarations { + content = try ff.inject(in: content, servedFiles: servedFonts) } return content } catch { - log(.error, error) + EPUBNavigatorViewModel.log(.error, error) return content } } diff --git a/Sources/Shared/Publication/Services/Cover/GeneratedCoverService.swift b/Sources/Shared/Publication/Services/Cover/GeneratedCoverService.swift index f2a1c0f546..1a32070936 100644 --- a/Sources/Shared/Publication/Services/Cover/GeneratedCoverService.swift +++ b/Sources/Shared/Publication/Services/Cover/GeneratedCoverService.swift @@ -53,10 +53,10 @@ public final class GeneratedCoverService: CoverService, Sendable { { _ in GeneratedCoverService(cover: cover) } } - private class CoverResource: Resource { - private let cover: () async -> ReadResult + private struct CoverResource: Resource { + private let cover: @Sendable () async -> ReadResult - init(cover: @escaping () async -> ReadResult) { + init(cover: @escaping @Sendable () async -> ReadResult) { self.cover = cover } @@ -70,7 +70,7 @@ public final class GeneratedCoverService: CoverService, Sendable { .success(ResourceProperties()) } - func stream(range: Range?, consume: @escaping (Data) -> Void) async -> ReadResult { + func stream(range: Range?, consume: @escaping @Sendable (Data) -> Void) async -> ReadResult { await cover().flatMap { guard let data = $0.pngData() else { return .failure(.decoding("Failed to convert the cover bitmap to PNG data")) diff --git a/Sources/Shared/Publication/Services/Positions/PerResourcePositionsService.swift b/Sources/Shared/Publication/Services/Positions/PerResourcePositionsService.swift index 49e223bc9d..a60f433a5c 100644 --- a/Sources/Shared/Publication/Services/Positions/PerResourcePositionsService.swift +++ b/Sources/Shared/Publication/Services/Positions/PerResourcePositionsService.swift @@ -12,24 +12,20 @@ public final class PerResourcePositionsService: PositionsService, Sendable { private let positions: [[Locator]] init(readingOrder: [Link], fallbackMediaType: MediaType) { - guard !readingOrder.isEmpty else { - positions = [] - return - } - - positions = readingOrder.enumerated().map { index, link in - [ - Locator( - href: link.url(), - mediaType: link.mediaType ?? fallbackMediaType, - title: link.title, - locations: Locator.Locations( - totalProgression: Double(index) / Double(readingOrder.count), - position: index + 1 - ) - ), - ] - } + positions = readingOrder.enumerated() + .map { index, link in + [ + Locator( + href: link.url(), + mediaType: link.mediaType ?? fallbackMediaType, + title: link.title, + locations: Locator.Locations( + totalProgression: Double(index) / Double(readingOrder.count), + position: index + 1 + ) + ), + ] + } } public func positionsByReadingOrder() async -> ReadResult<[[Locator]]> { diff --git a/Sources/Shared/Publication/Services/Positions/PositionsService.swift b/Sources/Shared/Publication/Services/Positions/PositionsService.swift index 47f4bdbb1a..b72b63864f 100644 --- a/Sources/Shared/Publication/Services/Positions/PositionsService.swift +++ b/Sources/Shared/Publication/Services/Positions/PositionsService.swift @@ -39,14 +39,17 @@ public extension PositionsService { guard href.anyURL.isEquivalentTo(positionsLink.url()) else { return nil } - return PositionsResource(positions: positions) + let service = self + return PositionsResource(positions: { + await service.positions() + }) } } -private class PositionsResource: Resource { - private let positions: () async -> ReadResult<[Locator]> +private struct PositionsResource: Resource { + private let positions: @Sendable () async -> ReadResult<[Locator]> - init(positions: @escaping () async -> ReadResult<[Locator]>) { + init(positions: @escaping @Sendable () async -> ReadResult<[Locator]>) { self.positions = positions } @@ -60,7 +63,7 @@ private class PositionsResource: Resource { .success(ResourceProperties()) } - func stream(range: Range?, consume: @escaping (Data) -> Void) async -> ReadResult { + func stream(range: Range?, consume: @escaping @Sendable (Data) -> Void) async -> ReadResult { await positions().flatMap { positions in let response: [String: JSONValue] = .init([ "total": positions.count, diff --git a/Sources/Shared/Toolkit/Data/Resource/BorrowedResource.swift b/Sources/Shared/Toolkit/Data/Resource/BorrowedResource.swift index 0fb74c5daf..5e0589a62a 100644 --- a/Sources/Shared/Toolkit/Data/Resource/BorrowedResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/BorrowedResource.swift @@ -18,7 +18,7 @@ public extension Resource { } @available(*, deprecated, message: "Resources are closed on deallocation now.") -private class BorrowedResource: Resource { +private struct BorrowedResource: Resource { private let resource: Resource init(resource: Resource) { @@ -37,7 +37,7 @@ private class BorrowedResource: Resource { await resource.properties() } - func stream(range: Range?, consume: @escaping (Data) -> Void) async -> ReadResult { + func stream(range: Range?, consume: @escaping @Sendable (Data) -> Void) async -> ReadResult { await resource.stream(range: range, consume: consume) } } diff --git a/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift b/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift index fa8fe234f7..b615eea901 100644 --- a/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift @@ -63,7 +63,7 @@ public actor BufferingResource: Resource, Loggable { public func stream( range: Range?, - consume: @escaping (Data) -> Void + consume: @escaping @Sendable (Data) -> Void ) async -> ReadResult { // Reading the whole resource bypasses buffering to keep things simple. guard let requestedRange = range, !requestedRange.isEmpty else { @@ -99,20 +99,22 @@ public actor BufferingResource: Resource, Loggable { // Read from the original resource using stream to avoid materializing // more than needed. - var data = prefixData + let data = Mutex(prefixData) + let result = await resource.stream(range: fetchRange) { chunk in - data.append(chunk) + data.withLock { $0.append(chunk) } } guard case .success = result else { return result } - buffer.set(data, at: readRange.lowerBound) + let finalData = data.withLock { $0 } + buffer.set(finalData, at: readRange.lowerBound) - let end = min(Int(requestedRange.count), data.count) + let end = min(Int(requestedRange.count), finalData.count) if end > 0 { - consume(data[0 ..< end]) + consume(finalData[0 ..< end]) } return .success(()) } diff --git a/Sources/Shared/Toolkit/Data/Resource/CachingResource.swift b/Sources/Shared/Toolkit/Data/Resource/CachingResource.swift index f310bb078b..5408a7ea57 100644 --- a/Sources/Shared/Toolkit/Data/Resource/CachingResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/CachingResource.swift @@ -42,7 +42,7 @@ public actor CachingResource: Resource { public func stream( range: Range?, - consume: @escaping (Data) -> Void + consume: @escaping @Sendable (Data) -> Void ) async -> ReadResult { await data().map { data in let length = UInt64(data.count) diff --git a/Sources/Shared/Toolkit/Data/Resource/DataResource.swift b/Sources/Shared/Toolkit/Data/Resource/DataResource.swift index d86afe802a..c3943c2585 100644 --- a/Sources/Shared/Toolkit/Data/Resource/DataResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/DataResource.swift @@ -10,11 +10,11 @@ import Foundation public actor DataResource: Resource { public let sourceURL: AbsoluteURL? - private let makeData: () async -> ReadResult + private let makeData: @Sendable () async -> ReadResult /// Creates a `Resource` serving an array of bytes. public init( - data: @autoclosure @escaping () -> Data, + data: @autoclosure @escaping @Sendable () -> Data, sourceURL: AbsoluteURL? = nil ) { self.init(sourceURL: sourceURL) { @@ -34,7 +34,7 @@ public actor DataResource: Resource { /// Creates a `Resource` serving an array of bytes. public init( sourceURL: AbsoluteURL? = nil, - makeData: @escaping () async -> ReadResult + makeData: @escaping @Sendable () async -> ReadResult ) { self.makeData = makeData self.sourceURL = sourceURL @@ -59,7 +59,7 @@ public actor DataResource: Resource { public func stream( range: Range?, - consume: @escaping (Data) -> Void + consume: @escaping @Sendable (Data) -> Void ) async -> ReadResult { await data().map { data in let length = UInt64(data.count) diff --git a/Sources/Shared/Toolkit/Data/Resource/FailureResource.swift b/Sources/Shared/Toolkit/Data/Resource/FailureResource.swift index 3a64ea90cd..6597ab1eca 100644 --- a/Sources/Shared/Toolkit/Data/Resource/FailureResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/FailureResource.swift @@ -25,13 +25,13 @@ public final class FailureResource: Resource, Sendable { .failure(error) } - public func stream(range: Range?, consume: @escaping (Data) -> Void) async -> ReadResult { + public func stream(range: Range?, consume: @escaping @Sendable (Data) -> Void) async -> ReadResult { .failure(error) } } public extension Resource where Self == FailureResource { static func failure(_ error: ReadError, sourceURL: AbsoluteURL? = nil) -> FailureResource { - FailureResource(error: error) + FailureResource(error: error, sourceURL: sourceURL) } } diff --git a/Sources/Shared/Toolkit/Data/Resource/TailCachingResource.swift b/Sources/Shared/Toolkit/Data/Resource/TailCachingResource.swift index fb64c17420..d98bd4e279 100644 --- a/Sources/Shared/Toolkit/Data/Resource/TailCachingResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/TailCachingResource.swift @@ -35,7 +35,7 @@ actor TailCachingResource: Resource, Loggable { func stream( range: Range?, - consume: @escaping (Data) -> Void + consume: @escaping @Sendable (Data) -> Void ) async -> ReadResult { guard cacheFromOffset <= range?.lowerBound ?? 0 else { return await resource.stream(range: range, consume: consume) @@ -78,10 +78,12 @@ actor TailCachingResource: Resource, Loggable { return cache! } - var data = Data() - cache = await resource.stream(range: cacheFromOffset ..< length) { chunk in - data.append(chunk) - }.map { data } + let data = Mutex(Data()) + let streamResult = await resource.stream(range: cacheFromOffset ..< length) { chunk in + data.withLock { $0.append(chunk) } + } + + cache = streamResult.map { data.withLock { $0 } } return cache! } diff --git a/Sources/Shared/Toolkit/Data/Resource/TransformingResource.swift b/Sources/Shared/Toolkit/Data/Resource/TransformingResource.swift index f425dd1388..6ad07ac7a2 100644 --- a/Sources/Shared/Toolkit/Data/Resource/TransformingResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/TransformingResource.swift @@ -14,44 +14,36 @@ import Foundation /// good idea to cache the result of the transformation in case multiple ranges /// will be read. /// -/// You can either provide a `transform` closure during construction, or extend -/// `TransformingResource` and override `transform()`. -open class TransformingResource: Resource { +/// Customize the transformation by providing a `transform` closure during construction. +public final class TransformingResource: Resource, Sendable { private let resource: Resource - private let _transform: ((ReadResult) async -> ReadResult)? - private var data: AsyncMemoizer>! + private let data: AsyncMemoizer> - public init(_ resource: Resource, transform: ((ReadResult) async -> ReadResult)? = nil) { + public init( + _ resource: Resource, + transform: @escaping @Sendable (ReadResult) async -> ReadResult = { $0 } + ) { self.resource = resource - _transform = transform - - data = AsyncMemoizer { [weak self] in - guard let self else { - return .failure(.decoding(DebugError("TransformingResource is deallocated"))) - } - return await self.transform(data: resource.read()) + data = AsyncMemoizer { + await transform(resource.read()) } } - open func transform(data: ReadResult) async -> ReadResult { - await _transform!(data) - } - /// As the resource is transformed, we can't use the original source URL /// as reference. public let sourceURL: AbsoluteURL? = nil - open func estimatedLength() async -> ReadResult { + public func estimatedLength() async -> ReadResult { // As the content will be transformed, we can't rely on the estimated // length from the upstream resource. .success(nil) } - open func properties() async -> ReadResult { + public func properties() async -> ReadResult { await resource.properties() } - public func stream(range: Range?, consume: @escaping (Data) -> Void) async -> ReadResult { + public func stream(range: Range?, consume: @escaping @Sendable (Data) -> Void) async -> ReadResult { await data().map { data in if let range = range?.clamped(to: 0 ..< UInt64(data.count)) { consume(data[range]) @@ -65,16 +57,16 @@ open class TransformingResource: Resource { /// Convenient shortcuts to create a `TransformingResource`. public extension Resource { - func map(transform: @escaping (Data) async -> Data) -> Resource { + func map(transform: @escaping @Sendable (Data) async -> Data) -> Resource { TransformingResource(self, transform: { await $0.asyncMap(transform) }) } - func mapAsString(encoding: String.Encoding = .utf8, transform: @escaping (String) async -> String) -> Resource { - TransformingResource(self) { + func mapAsString(encoding: String.Encoding = .utf8, transform: @escaping @Sendable (String) async -> String) -> Resource { + TransformingResource(self, transform: { await $0.asyncMap { data in let string = String(data: data, encoding: encoding) ?? "" return await transform(string).data(using: .utf8) ?? Data() } - } + }) } } diff --git a/Sources/Shared/Toolkit/Data/Streamable.swift b/Sources/Shared/Toolkit/Data/Streamable.swift index 0331fda475..6036b37077 100644 --- a/Sources/Shared/Toolkit/Data/Streamable.swift +++ b/Sources/Shared/Toolkit/Data/Streamable.swift @@ -7,7 +7,7 @@ import Foundation /// Acts as a proxy to an actual data source by handling read access. -public protocol Streamable: Closeable { +public protocol Streamable: Closeable, Sendable { /// Returns data length from metadata if available. /// /// This value must be treated as a hint, as it might not reflect the @@ -24,7 +24,7 @@ public protocol Streamable: Closeable { /// are responsible to accumulate the data if needed. func stream( range: Range?, - consume: @escaping (Data) -> Void + consume: @escaping @Sendable (Data) -> Void ) async -> ReadResult } @@ -35,7 +35,7 @@ public extension Streamable { /// - consume: Callback called for each chunk of data received. Callers /// are responsible to accumulate the data if needed. // FIXME: Task cancellation - func stream(consume: @escaping (Data) -> Void) async -> ReadResult { + func stream(consume: @escaping @Sendable (Data) -> Void) async -> ReadResult { await stream(range: nil, consume: consume) } @@ -49,11 +49,11 @@ public extension Streamable { /// When `range` is null, the whole content is returned. Out-of-range /// indexes are clamped to the available length automatically. func read(range: Range?) async -> ReadResult { - var data = Data() - let result = await stream(range: range) { - data += $0 + let data = Mutex(Data()) + let result = await stream(range: range) { chunk in + data.withLock { $0 += chunk } } - return result.map { data } + return result.map { data.withLock { $0 } } } /// Reads the whole content as a `String`. @@ -105,43 +105,50 @@ package extension Streamable { throw .outOfMemory(nil) } - var data = Data() + let data = Mutex(Data()) + if let estimated, estimated <= UInt64(Int.max) { - data.reserveCapacity(Int(estimated)) + data.withLock { $0.reserveCapacity(Int(estimated)) } } - var error: ReadError? = nil { - didSet { - data = Data() - } - } + let error = Mutex(nil) let streamResult = await stream { chunk in - guard error == nil else { + let err = error.withLock { $0 } + guard err == nil else { return } guard !Task.isCancelled else { - error = .cancelled + error.withLock { $0 = .cancelled } + data.withLock { $0 = Data() } return } let availableMemory = os_proc_available_memory() - guard availableMemory == 0 || data.count + chunk.count <= availableMemory else { - error = .outOfMemory(nil) - return + let success = data.withLock { buffer in + if availableMemory == 0 || buffer.count + chunk.count <= availableMemory { + buffer.append(chunk) + return true + } else { + buffer = Data() + return false + } } - data.append(chunk) + guard success else { + error.withLock { $0 = .outOfMemory(nil) } + return + } } - if let error { + if let error = error.withLock({ $0 }) { throw error } switch streamResult { case .success: - return data + return data.withLock { $0 } case let .failure(error): throw error } diff --git a/Sources/Shared/Toolkit/File/FileResource.swift b/Sources/Shared/Toolkit/File/FileResource.swift index 6ef63b7d5c..524883a167 100644 --- a/Sources/Shared/Toolkit/File/FileResource.swift +++ b/Sources/Shared/Toolkit/File/FileResource.swift @@ -42,7 +42,7 @@ public actor FileResource: Resource, Loggable { }) } - public func stream(range: Range?, consume: @escaping (Data) -> Void) async -> ReadResult { + public func stream(range: Range?, consume: @escaping @Sendable (Data) -> Void) async -> ReadResult { await handle().flatMap { handle in do { if var range = range { diff --git a/Sources/Shared/Toolkit/HTTP/HTTPResource.swift b/Sources/Shared/Toolkit/HTTP/HTTPResource.swift index 4582686c87..a6dab664de 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPResource.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPResource.swift @@ -78,7 +78,7 @@ public actor HTTPResource: Resource { return _headResponse! } - public func stream(range: Range?, consume: @escaping (Data) -> Void) async -> ReadResult { + public func stream(range: Range?, consume: @escaping @Sendable (Data) -> Void) async -> ReadResult { let request = { var request = HTTPRequest(url: url) if let range = range { diff --git a/Sources/Shared/Toolkit/ZIP/Minizip/MinizipContainer.swift b/Sources/Shared/Toolkit/ZIP/Minizip/MinizipContainer.swift index d49ea41f32..d067974b66 100644 --- a/Sources/Shared/Toolkit/ZIP/Minizip/MinizipContainer.swift +++ b/Sources/Shared/Toolkit/ZIP/Minizip/MinizipContainer.swift @@ -126,7 +126,7 @@ private actor MinizipResource: Resource, Loggable { }) } - func stream(range: Range?, consume: @escaping (Data) -> Void) async -> ReadResult { + func stream(range: Range?, consume: @escaping @Sendable (Data) -> Void) async -> ReadResult { let range = range ?? 0 ..< metadata.length return await zipFile().flatMap { zipFile in diff --git a/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift b/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift index e91f7346de..0a207212be 100644 --- a/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift +++ b/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift @@ -104,7 +104,7 @@ private actor ZIPFoundationResource: Resource, Loggable { }) } - func stream(range: Range?, consume: @escaping (Data) -> Void) async -> ReadResult { + func stream(range: Range?, consume: @escaping @Sendable (Data) -> Void) async -> ReadResult { if range != nil {} return await archive().asyncFlatMap { archive in diff --git a/Sources/Streamer/Parser/EPUB/Resource Transformers/EPUBDeobfuscator.swift b/Sources/Streamer/Parser/EPUB/Resource Transformers/EPUBDeobfuscator.swift index b8b20469a8..9d67f68e94 100644 --- a/Sources/Streamer/Parser/EPUB/Resource Transformers/EPUBDeobfuscator.swift +++ b/Sources/Streamer/Parser/EPUB/Resource Transformers/EPUBDeobfuscator.swift @@ -67,7 +67,7 @@ final class EPUBDeobfuscator { await resource.properties() } - func stream(range: Range?, consume: @escaping (Data) -> Void) async -> ReadResult { + func stream(range: Range?, consume: @escaping @Sendable (Data) -> Void) async -> ReadResult { var readPosition = range?.lowerBound ?? 0 let obfuscatedLength = algorithm.obfuscatedLength diff --git a/Sources/Streamer/Parser/PDF/Services/LCPDFPositionsService.swift b/Sources/Streamer/Parser/PDF/Services/LCPDFPositionsService.swift index ee7afc75d8..67f36c84b3 100644 --- a/Sources/Streamer/Parser/PDF/Services/LCPDFPositionsService.swift +++ b/Sources/Streamer/Parser/PDF/Services/LCPDFPositionsService.swift @@ -18,7 +18,7 @@ final class LCPDFPositionsService: PositionsService, Loggable, Sendable { init(publication: Weak) { cache = AsyncMemoizer { [publication] in guard let publication = publication() else { - return .failure(.cancelled) + return .failure(.unsupportedOperation(DebugError("The publication is deallocated"))) } return await Self.makePositionList(of: publication) diff --git a/Tests/SharedTests/Publication/Services/Positions/PositionsServiceTests.swift b/Tests/SharedTests/Publication/Services/Positions/PositionsServiceTests.swift index 66e4c27525..56e07c9e1d 100644 --- a/Tests/SharedTests/Publication/Services/Positions/PositionsServiceTests.swift +++ b/Tests/SharedTests/Publication/Services/Positions/PositionsServiceTests.swift @@ -7,7 +7,7 @@ @testable import ReadiumShared import XCTest -struct TestPositionsService: PositionsService { +final class TestPositionsService: PositionsService { let positions: [[Locator]] init(_ positions: [[Locator]]) { diff --git a/Tests/SharedTests/Toolkit/Data/Resource/FakeResource.swift b/Tests/SharedTests/Toolkit/Data/Resource/FakeResource.swift index c5bf2ca5bb..d75a55ef58 100644 --- a/Tests/SharedTests/Toolkit/Data/Resource/FakeResource.swift +++ b/Tests/SharedTests/Toolkit/Data/Resource/FakeResource.swift @@ -32,7 +32,7 @@ actor FakeResource: Resource { _properties } - func stream(range: Range?, consume: @escaping (Data) -> Void) async -> ReadResult { + func stream(range: Range?, consume: @escaping @Sendable (Data) -> Void) async -> ReadResult { consume(Data()) return .success(()) } diff --git a/Tests/SharedTests/Toolkit/Data/Resource/TransformingResourceTests.swift b/Tests/SharedTests/Toolkit/Data/Resource/TransformingResourceTests.swift index 9bf3cb4597..c3182c84cf 100644 --- a/Tests/SharedTests/Toolkit/Data/Resource/TransformingResourceTests.swift +++ b/Tests/SharedTests/Toolkit/Data/Resource/TransformingResourceTests.swift @@ -11,13 +11,13 @@ import Testing struct TransformingResourceTests { @Test func sourceURLIsNil() { let resource = DataResource(string: "hello") - let sut = TransformingResource(resource) { $0 } + let sut = TransformingResource(resource, transform: { $0 }) #expect(sut.sourceURL == nil) } @Test func estimatedLengthIsNil() async throws { let resource = DataResource(string: "hello") - let sut = TransformingResource(resource) { $0 } + let sut = TransformingResource(resource, transform: { $0 }) let result = try await sut.estimatedLength().get() #expect(result == nil) } @@ -27,27 +27,27 @@ struct TransformingResourceTests { $0.filename = "chapter.html" } let resource = FakeResource(properties: .success(expected)) - let sut = TransformingResource(resource) { $0 } + let sut = TransformingResource(resource, transform: { $0 }) let actual = try await sut.properties().get() #expect(actual == expected) } @Test func transformApplied() async throws { let resource = DataResource(string: "hello") - let sut = TransformingResource(resource) { data in + let sut = TransformingResource(resource, transform: { data in data.map { String(data: $0, encoding: .utf8)! .uppercased() .data(using: .utf8)! } - } + }) let data = try await sut.read().get() #expect(data == "HELLO".data(using: .utf8)!) } @Test func rangeRead() async throws { let resource = DataResource(data: Data([0, 1, 2, 3, 4, 5, 6, 7])) - let sut = TransformingResource(resource) { $0 } + let sut = TransformingResource(resource, transform: { $0 }) let data = try await sut.read(range: 2 ..< 5).get() #expect(data == Data([2, 3, 4])) } @@ -83,13 +83,13 @@ struct TransformingResourceTests { @Test func transformCalledOnce() async { let counter = Counter() let resource = DataResource(string: "hello") - let sut = TransformingResource(resource) { data in + let sut = TransformingResource(resource, transform: { data in // Sleep to widen the race window so concurrent callers all // enter this branch before any of them finishes writing to `_data`. try? await Task.sleep(seconds: 0.5) await counter.increment() return data - } + }) await withTaskGroup(of: Void.self) { group in for _ in 0 ..< 50 { @@ -106,10 +106,10 @@ struct TransformingResourceTests { @Test func concurrentReadsReturnSameData() async throws { let expected = "hello".data(using: .utf8)! let resource = DataResource(string: "hello") - let sut = TransformingResource(resource) { data in + let sut = TransformingResource(resource, transform: { data in try? await Task.sleep(seconds: 0.5) return data - } + }) var results: [ReadResult] = [] await withTaskGroup(of: ReadResult.self) { group in diff --git a/Tests/SharedTests/Toolkit/HTTP/HTTPResourceTests.swift b/Tests/SharedTests/Toolkit/HTTP/HTTPResourceTests.swift index e45ff87225..c31f0433c0 100644 --- a/Tests/SharedTests/Toolkit/HTTP/HTTPResourceTests.swift +++ b/Tests/SharedTests/Toolkit/HTTP/HTTPResourceTests.swift @@ -22,8 +22,8 @@ struct HTTPResourceTests { func stream( _ request: HTTPRequestConvertible, - onReceiveResponse: ((HTTPResponse) async -> HTTPResult)?, - consume: (Data, Double?) -> HTTPResult + onReceiveResponse: (@Sendable (HTTPResponse) async -> HTTPResult)?, + consume: @Sendable (Data, Double?) -> HTTPResult ) async -> HTTPResult { let req = try! request.httpRequest().get() let key = "\(req.method.rawValue) \(req.url.string)" @@ -96,10 +96,10 @@ struct HTTPResourceTests { body: #require("0123456789".data(using: .utf8)) )) - var streamedData = Data() - let result = await resource.stream(range: 0 ..< 10, consume: { streamedData.append($0) }) + let streamedData = Capture(Data()) + let result = await resource.stream(range: 0 ..< 10, consume: { chunk in streamedData.value.append(chunk) }) try result.get() - #expect(streamedData == "0123456789".data(using: .utf8)) + #expect(streamedData.value == "0123456789".data(using: .utf8)) } } diff --git a/Tests/StreamerTests/Parser/EPUB/Services/EPUBPositionsServiceTests.swift b/Tests/StreamerTests/Parser/EPUB/Services/EPUBPositionsServiceTests.swift index 7d6a1e1d93..58d8a38614 100644 --- a/Tests/StreamerTests/Parser/EPUB/Services/EPUBPositionsServiceTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/Services/EPUBPositionsServiceTests.swift @@ -389,7 +389,7 @@ private class MockContainer: Container { .success(_properties) } - func stream(range: Range?, consume: @escaping (Data) -> Void) async -> ReadResult { + func stream(range: Range?, consume: @escaping @Sendable (Data) -> Void) async -> ReadResult { consume(Data()) return .success(()) } From 305176a929ad134ffdff5f832ff8430108b4d0ac Mon Sep 17 00:00:00 2001 From: Grigor Hakobyan Date: Fri, 12 Jun 2026 19:50:32 +0400 Subject: [PATCH 12/39] Cleanup unused files and properties (#779) --- .../Adapters/GCDWebServer/GCDHTTPServer.swift | 4 -- .../GCDWebServer/ResourceResponse.swift | 9 ---- Sources/Internal/Extensions/Array.swift | 16 ------- .../Internal/Extensions/Date+ISO8601.swift | 8 ---- Sources/LCP/License/License.swift | 1 - .../EPUB/EPUBNavigatorViewController.swift | 4 -- Sources/Navigator/EPUB/EPUBSpreadView.swift | 3 -- .../Navigator/Toolkit/PaginationView.swift | 13 ------ Sources/OPDS/OPDS2Parser.swift | 4 -- Sources/OPDS/OPDSParser.swift | 4 -- .../Shared/Toolkit/Extensions/Optional.swift | 7 --- Sources/Streamer/Parser/PDF/PDFParser.swift | 16 ------- .../Streamer/Toolkit/StringExtension.swift | 44 ------------------- 13 files changed, 133 deletions(-) delete mode 100644 Sources/Streamer/Toolkit/StringExtension.swift diff --git a/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift b/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift index 09caafd2c9..d49899a8c6 100644 --- a/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift +++ b/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift @@ -294,10 +294,6 @@ public final class GCDHTTPServer: HTTPServer, Loggable { private func isPortFree(_ port: UInt) -> Bool { let port = in_port_t(port) - func getErrnoMessage() -> String { - String(cString: UnsafePointer(strerror(errno))) - } - let socketDescriptor = socket(AF_INET, SOCK_STREAM, 0) if socketDescriptor == -1 { // Just in case, returns true to attempt restarting the server. diff --git a/Sources/Adapters/GCDWebServer/ResourceResponse.swift b/Sources/Adapters/GCDWebServer/ResourceResponse.swift index 1624c38efd..9bdac0d14e 100644 --- a/Sources/Adapters/GCDWebServer/ResourceResponse.swift +++ b/Sources/Adapters/GCDWebServer/ResourceResponse.swift @@ -8,15 +8,6 @@ import Foundation import ReadiumGCDWebServer import ReadiumShared -/// Errors thrown by the `WebServerResourceResponse` -/// -/// - streamOpenFailed: The stream is not open, stream.open() failed. -/// - invalidRange: The range queried is invalid. -enum WebServerResponseError: Error { - case streamOpenFailed - case invalidRange -} - /// The object containing the response's ressource data. /// If the ressource to be served is too big, multiple responses will be created. class ResourceResponse: ReadiumGCDWebServerResponse, Loggable { diff --git a/Sources/Internal/Extensions/Array.swift b/Sources/Internal/Extensions/Array.swift index 6c1bf08da8..2cc4257601 100644 --- a/Sources/Internal/Extensions/Array.swift +++ b/Sources/Internal/Extensions/Array.swift @@ -61,20 +61,4 @@ public extension Array where Element: Hashable { } return result } - - @inlinable func removing(_ element: Element) -> Self { - var array = self - 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 { - func firstMemberFrom(_ candidates: Element?...) -> Element? { - candidates.compactMap { $0 }.first { contains($0) } - } } diff --git a/Sources/Internal/Extensions/Date+ISO8601.swift b/Sources/Internal/Extensions/Date+ISO8601.swift index 6f3cc79ec9..d115a3d576 100644 --- a/Sources/Internal/Extensions/Date+ISO8601.swift +++ b/Sources/Internal/Extensions/Date+ISO8601.swift @@ -23,14 +23,6 @@ public extension DateFormatter { }() static func iso8601Formatter(for string: String) -> DateFormatter { - // On iOS 10 and later, this API should be treated withFullTime or withTimeZone for different cases. - // Otherwise it will accept bad format, for exmaple 2018-04-24XXXXXXXXX - // Because it will only test the part you asssigned, date, time, timezone. - // But we should also cover the optional cases. So there is not too much benefit. -// let formatter = ISO8601DateFormatter() -// formatter.formatOptions = [.withFullDate] -// return formatter - // https://developer.apple.com/documentation/foundation/dateformatter // Doesn't support millisecond or uncompleted part for date, time, timezone offset. let formats = [ diff --git a/Sources/LCP/License/License.swift b/Sources/LCP/License/License.swift index 0cd02a8848..0aa5c55258 100644 --- a/Sources/LCP/License/License.swift +++ b/Sources/LCP/License/License.swift @@ -6,7 +6,6 @@ import Foundation import ReadiumShared -import ReadiumZIPFoundation final class License: Loggable { /// Last Documents which passed the integrity checks. diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift index d63f4c7393..c88802dbd3 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift @@ -1226,10 +1226,6 @@ extension EPUBNavigatorViewController: EPUBSpreadViewDelegate { } } - func spreadView(_ spreadView: EPUBSpreadView, present viewController: UIViewController) { - present(viewController, animated: true) - } - func spreadViewDidTerminate() { reloadSpreads() } diff --git a/Sources/Navigator/EPUB/EPUBSpreadView.swift b/Sources/Navigator/EPUB/EPUBSpreadView.swift index 3853242299..5bd7570feb 100644 --- a/Sources/Navigator/EPUB/EPUBSpreadView.swift +++ b/Sources/Navigator/EPUB/EPUBSpreadView.swift @@ -29,9 +29,6 @@ protocol EPUBSpreadViewDelegate: AnyObject { /// Called when the pages visible in the spread changed. func spreadViewPagesDidChange(_ spreadView: EPUBSpreadView) - /// Called when the spread view needs to present a view controller. - func spreadView(_ spreadView: EPUBSpreadView, present viewController: UIViewController) - /// Called when the user triggered an input pointer event. func spreadView(_ spreadView: EPUBSpreadView, didReceive event: PointerEvent) diff --git a/Sources/Navigator/Toolkit/PaginationView.swift b/Sources/Navigator/Toolkit/PaginationView.swift index b72488c731..dc3afa97ce 100644 --- a/Sources/Navigator/Toolkit/PaginationView.swift +++ b/Sources/Navigator/Toolkit/PaginationView.swift @@ -78,19 +78,6 @@ final class PaginationView: UIView, Loggable { loadedViews[currentIndex] } - /// Loaded page views in reading order. - private var orderedViews: [UIView & PageView] { - var orderedViews = loadedViews - .sorted { $0.key < $1.key } - .map(\.value) - - if readingProgression == .rtl { - orderedViews.reverse() - } - - return orderedViews - } - private let scrollView = UIScrollView() /// Set while a transition animation is in progress to prevent diff --git a/Sources/OPDS/OPDS2Parser.swift b/Sources/OPDS/OPDS2Parser.swift index 6b3925ce6d..07bcfe3e5e 100644 --- a/Sources/OPDS/OPDS2Parser.swift +++ b/Sources/OPDS/OPDS2Parser.swift @@ -271,7 +271,3 @@ public class OPDS2Parser: Loggable { } } } - -private func hrefNormalizer(_ baseURL: URL?) -> (String) -> (String) { - { href in URLHelper.getAbsolute(href: href, base: baseURL) ?? href } -} diff --git a/Sources/OPDS/OPDSParser.swift b/Sources/OPDS/OPDSParser.swift index eb5142e789..904eccc73d 100644 --- a/Sources/OPDS/OPDSParser.swift +++ b/Sources/OPDS/OPDSParser.swift @@ -13,8 +13,6 @@ public enum OPDSParserError: Error, Sendable { } public enum OPDSParser: Sendable { - static var feedURL: URL? - /// Parse an OPDS feed or publication. /// Feed can be v1 (XML) or v2 (JSON). /// - Parameters: @@ -22,8 +20,6 @@ public enum OPDSParser: Sendable { /// - completion: A closure called when the parsing is complete, returning the /// parsed `ParseData` on success, or an `Error` if the operation failed. public static func parseURL(url: URL, completion: @escaping (ParseData?, Error?) -> Void) { - feedURL = url - URLSession.shared.dataTask(with: url) { data, response, error in guard let data = data, let response = response else { completion(nil, error ?? OPDSParserError.documentNotFound) diff --git a/Sources/Shared/Toolkit/Extensions/Optional.swift b/Sources/Shared/Toolkit/Extensions/Optional.swift index 20d12083e0..41fd1b947b 100644 --- a/Sources/Shared/Toolkit/Extensions/Optional.swift +++ b/Sources/Shared/Toolkit/Extensions/Optional.swift @@ -27,11 +27,4 @@ public extension Optional { } return value } - - /// Returns the wrapped value and modify the variable to be nil. - internal mutating func pop() -> Wrapped? { - let res = self - self = nil - return res - } } diff --git a/Sources/Streamer/Parser/PDF/PDFParser.swift b/Sources/Streamer/Parser/PDF/PDFParser.swift index 6110db2ee3..0bd05c628d 100644 --- a/Sources/Streamer/Parser/PDF/PDFParser.swift +++ b/Sources/Streamer/Parser/PDF/PDFParser.swift @@ -8,23 +8,7 @@ import CoreGraphics import Foundation import ReadiumShared -/// Errors thrown during the parsing of the PDF. -public enum PDFParserError: Error, Sendable { - /// The file at 'path' is missing from the container. - case missingFile(path: String) - /// Failed to open the PDF - case openFailed - /// The PDF is encrypted with a password. This is not supported right now. - case fileEncryptedWithPassword - /// The LCP for PDF Package is malformed. - case invalidLCPDF -} - public final class PDFParser: PublicationParser, Loggable { - enum Error: Swift.Error { - case fileNotReadable - } - private let pdfFactory: PDFDocumentFactory public init(pdfFactory: PDFDocumentFactory) { diff --git a/Sources/Streamer/Toolkit/StringExtension.swift b/Sources/Streamer/Toolkit/StringExtension.swift deleted file mode 100644 index 85395ba131..0000000000 --- a/Sources/Streamer/Toolkit/StringExtension.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// Copyright 2026 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 - -extension String { - var ns: NSString { - self as NSString - } - - func appending(pathComponent: String) -> String { - (self as NSString).appendingPathComponent(pathComponent) - } - - var deletingLastPathComponent: String { - ns.deletingLastPathComponent - } - - var lastPathComponent: String { - ns.lastPathComponent - } - - var pathExtension: String { - ns.pathExtension - } - - func endIndex(of string: String, options: CompareOptions = .literal) -> Index? { - range(of: string, options: options)?.upperBound - } - - func startIndex(of string: String, options: CompareOptions = .literal) -> Index? { - range(of: string, options: options)?.lowerBound - } - - func insert(string: String, at index: String.Index) -> String { - let prefix = self[.. Date: Fri, 12 Jun 2026 19:52:03 +0400 Subject: [PATCH 13/39] Remove `Atomic` in favor of `Mutex` (#807) --- Sources/Shared/Toolkit/Atomic.swift | 76 ----------------------------- 1 file changed, 76 deletions(-) delete mode 100644 Sources/Shared/Toolkit/Atomic.swift diff --git a/Sources/Shared/Toolkit/Atomic.swift b/Sources/Shared/Toolkit/Atomic.swift deleted file mode 100644 index 0a5bc21ba8..0000000000 --- a/Sources/Shared/Toolkit/Atomic.swift +++ /dev/null @@ -1,76 +0,0 @@ -// -// Copyright 2026 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 - -/// Smart pointer protecting concurrent access to its memory to avoid data races. -/// -/// This is also a property wrapper, which makes it easy to use as: -/// ``` -/// @Atomic var data: Int -/// ``` -/// -/// The property becomes read-only, to prevent a common error when modifying the property using its -/// previous value. For example: -/// ``` -/// data += 1 -/// ``` -/// This is not safe, because it's actually two operations: a read and a write. The value might have changed -/// between the moment you read it and when you write the result of incrementing the value. -/// -/// Instead, you must use `write()` to mutate the property: -/// ``` -/// $data.write { value in -/// value += 1 -/// } -/// ``` -@propertyWrapper -public final class Atomic { - private var value: Value - - /// Queue used to protect accesses to `value`. - /// - /// We could use a serial queue but that would impact performances as concurrent reads would not be - /// possible. To make sure we don't get data races, writes are done using a `.barrier` flag. - private let queue = DispatchQueue(label: "org.readium.swift-toolkit.Atomic", attributes: .concurrent) - - public init(wrappedValue value: Value) { - self.value = value - } - - public var wrappedValue: Value { - get { read() } - set { fatalError("Use $property.write { $0 = ... } to mutate this property") } - } - - public var projectedValue: Atomic { - self - } - - /// Reads the current value synchronously. - public func read() -> Value { - queue.sync { - value - } - } - - /// Reads the current value asynchronously. - public func read(completion: @escaping (Value) -> Void) { - queue.async { - completion(self.value) - } - } - - /// Writes the value synchronously in a safe way. - public func write(_ changes: (inout Value) -> Void) { - // The `barrier` flag here guarantees that we will never have a - // concurrent read on `value` while we are modifying it. This prevents - // a data race. - queue.sync(flags: .barrier) { - changes(&value) - } - } -} From 9fab0a4432268ec5b285944337343cf4929a2cc9 Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Wed, 17 Jun 2026 04:41:35 -0500 Subject: [PATCH 14/39] Adopt `Sendable` across PDF, LCP and `Container` APIs (#803) --- Makefile | 6 + Sources/LCP/LCPClient.swift | 4 +- Sources/LCP/LCPLicenseRepository.swift | 2 +- Sources/LCP/LCPRenewDelegate.swift | 5 +- Sources/LCP/License/License.swift | 30 ++--- Sources/LCP/Services/DeviceService.swift | 10 +- .../PDF/PDFNavigatorViewController.swift | 9 +- .../Content Protection/UserRights.swift | 6 +- .../Toolkit/Data/Container/Container.swift | 10 +- .../Container/TransformingContainer.swift | 6 +- Sources/Shared/Toolkit/Data/Streamable.swift | 6 +- .../Toolkit/File/DirectoryContainer.swift | 2 +- .../Shared/Toolkit/File/FileContainer.swift | 2 +- .../Toolkit/HTTP/DefaultHTTPClient.swift | 2 +- Sources/Shared/Toolkit/HTTP/HTTPClient.swift | 2 +- Sources/Shared/Toolkit/PDF/CGPDF.swift | 18 ++- Sources/Shared/Toolkit/PDF/PDFDocument.swift | 12 +- .../Toolkit/PDF/PDFDocumentService.swift | 14 +- Sources/Shared/Toolkit/PDF/PDFKit.swift | 120 ++++++++++-------- .../ZIPFoundationArchiveFactory.swift | 2 +- .../EPUBDeobfuscator.swift | 28 ++-- TestApp/Sources/App/Readium.swift | 2 +- Tests/SharedTests/ProxyContainer.swift | 4 +- .../ContentProtectionServiceTests.swift | 36 ++++-- .../PDFResourceContentIteratorTests.swift | 13 +- .../Toolkit/HTTP/HTTPResourceTests.swift | 22 +++- .../EPUBDeobfuscatorTests.swift | 47 ++++++- .../Services/EPUBPositionsServiceTests.swift | 2 +- 28 files changed, 254 insertions(+), 168 deletions(-) diff --git a/Makefile b/Makefile index ae0d3872be..cad4d73735 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,12 @@ help: update-locales\tUpdate the localization files\n\ " +.PHONY: test +test: + xcodebuild test -project "TestApp/TestApp.xcodeproj" -scheme TestApp -destination "platform=iOS Simulator,name=iPhone Air" 2> /dev/null \ + | xcbeautify --quieter --disable-logging \ + | grep -Ev "^Executed |Test Suite 'All tests'|Test run started\.|Test session results:"; true + .SILENT: .PHONY: playground playground: diff --git a/Sources/LCP/LCPClient.swift b/Sources/LCP/LCPClient.swift index 951d1378be..5080de8bb5 100644 --- a/Sources/LCP/LCPClient.swift +++ b/Sources/LCP/LCPClient.swift @@ -33,7 +33,7 @@ import Foundation /// } /// /// } -public protocol LCPClient { +public protocol LCPClient: Sendable { /// Create a context for a given license/passphrase tuple. func createContext(jsonLicense: String, hashedPassphrase: LCPPassphraseHash, pemCrl: String) throws -> LCPClientContext @@ -67,7 +67,7 @@ public extension LCPClient { } } -public typealias LCPClientContext = Any +public typealias LCPClientContext = Any & Sendable /// Copy of the R2LCPClient.LCPClientError enum. /// diff --git a/Sources/LCP/LCPLicenseRepository.swift b/Sources/LCP/LCPLicenseRepository.swift index 3d249d7587..685ab7fc7f 100644 --- a/Sources/LCP/LCPLicenseRepository.swift +++ b/Sources/LCP/LCPLicenseRepository.swift @@ -7,7 +7,7 @@ import Foundation /// The license repository stores registered licenses with their consumed rights (e.g. copy). -public protocol LCPLicenseRepository { +public protocol LCPLicenseRepository: Sendable { /// Adds a new `licenseDocument` to the repository, using `licenseDocument.id` as the /// primary key. /// diff --git a/Sources/LCP/LCPRenewDelegate.swift b/Sources/LCP/LCPRenewDelegate.swift index 3cad3c06a2..84f82f51c7 100644 --- a/Sources/LCP/LCPRenewDelegate.swift +++ b/Sources/LCP/LCPRenewDelegate.swift @@ -10,7 +10,7 @@ import SafariServices import UIKit /// UX delegate for the loan renew LSD interaction. -public protocol LCPRenewDelegate { +public protocol LCPRenewDelegate: Sendable { /// Called when the renew interaction allows to customize the end date programmatically. /// /// You can prompt the user for the number of days to renew, for example. @@ -28,6 +28,7 @@ public protocol LCPRenewDelegate { /// /// No date picker is presented for selecting a preferred end date. If you want to support one, you can subclass or /// decorate `LCPRenewDelegate`. +@MainActor public final class LCPDefaultRenewDelegate: NSObject, LCPRenewDelegate { private let presentingViewController: UIViewController private let modalPresentationStyle: UIModalPresentationStyle @@ -64,7 +65,7 @@ extension LCPDefaultRenewDelegate: UIAdaptivePresentationControllerDelegate { } } -extension LCPDefaultRenewDelegate: SFSafariViewControllerDelegate { +extension LCPDefaultRenewDelegate: @preconcurrency SFSafariViewControllerDelegate { public func safariViewControllerDidFinish(_ controller: SFSafariViewController) { webPageContinuation?.resume(returning: ()) webPageContinuation = nil diff --git a/Sources/LCP/License/License.swift b/Sources/LCP/License/License.swift index 0aa5c55258..29f24f8ff9 100644 --- a/Sources/LCP/License/License.swift +++ b/Sources/LCP/License/License.swift @@ -7,19 +7,19 @@ import Foundation import ReadiumShared -final class License: Loggable { +final class License: Loggable, Sendable { /// Last Documents which passed the integrity checks. - private var documents: ValidatedDocuments + private let documents: Mutex // Dependencies - private let client: LCPClient + private let client: any LCPClient private let validation: LicenseValidation - private let licenses: LCPLicenseRepository + private let licenses: any LCPLicenseRepository private let device: DeviceService - private let httpClient: HTTPClient + private let httpClient: any HTTPClient - init(documents: ValidatedDocuments, client: LCPClient, validation: LicenseValidation, licenses: LCPLicenseRepository, device: DeviceService, httpClient: HTTPClient) { - self.documents = documents + init(documents: ValidatedDocuments, client: any LCPClient, validation: LicenseValidation, licenses: any LCPLicenseRepository, device: DeviceService, httpClient: any HTTPClient) { + self.documents = Mutex(documents) self.client = client self.validation = validation self.licenses = licenses @@ -28,7 +28,7 @@ final class License: Loggable { validation.observe { [weak self] result in if case let .success(documents) = result { - self?.documents = documents + self?.documents.withLock { $0 = documents } } } } @@ -37,19 +37,19 @@ final class License: Loggable { /// Public API extension License: LCPLicense { var license: LicenseDocument { - documents.license + documents.withLock { $0.license } } var status: StatusDocument? { - documents.status + documents.withLock { $0.status } } var isRestricted: Bool { - documents.context.getOrNil() == nil + documents.withLock { $0.context.getOrNil() == nil } } var error: LCPError? { - switch documents.context { + switch documents.withLock({ $0.context }) { case .success: return nil case let .failure(error): @@ -69,7 +69,7 @@ extension License: LCPLicense { } func decipher(_ data: Data) throws -> Data? { - let context = try documents.context.get() + let context = try documents.withLock { $0.context }.get() return client.decrypt(data: data, using: context) } @@ -190,7 +190,7 @@ extension License: LCPLicense { /// Finds the renew link according to `prefersWebPage`. func findRenewLink() -> Link? { - guard let status = documents.status else { + guard let status = documents.withLock({ $0.status }) else { return nil } @@ -289,7 +289,7 @@ extension License: LCPLicense { func returnPublication() async -> Result { guard - let status = documents.status, + let status = documents.withLock({ $0.status }), let parameters = device.asQueryParameters, let url = try? status.url( for: .return, diff --git a/Sources/LCP/Services/DeviceService.swift b/Sources/LCP/Services/DeviceService.swift index a31b35e335..b5d75a10d4 100644 --- a/Sources/LCP/Services/DeviceService.swift +++ b/Sources/LCP/Services/DeviceService.swift @@ -7,9 +7,9 @@ import Foundation import ReadiumShared -final class DeviceService: Loggable { - private let repository: LCPLicenseRepository - private let httpClient: HTTPClient +final class DeviceService: Sendable, Loggable { + private let repository: any LCPLicenseRepository + private let httpClient: any HTTPClient /// Returns the device's name. let name: String @@ -34,8 +34,8 @@ final class DeviceService: Loggable { init( deviceName: String, deviceId: String?, - repository: LCPLicenseRepository, - httpClient: HTTPClient, + repository: any LCPLicenseRepository, + httpClient: any HTTPClient, keychainServiceName: String = "org.readium.lcp.device" ) { name = deviceName diff --git a/Sources/Navigator/PDF/PDFNavigatorViewController.swift b/Sources/Navigator/PDF/PDFNavigatorViewController.swift index e5692acdf3..ec1f47471c 100644 --- a/Sources/Navigator/PDF/PDFNavigatorViewController.swift +++ b/Sources/Navigator/PDF/PDFNavigatorViewController.swift @@ -450,7 +450,7 @@ open class PDFNavigatorViewController: return true } - private func openDocument(at href: HREF) async -> PDFKit.PDFDocument? { + private func openDocument(at href: HREF) async -> PDFKit.PDFDocument? { let service = publication.pdfDocumentService if let cached = await service?.cachedDocument(at: href) as? PDFKitDocumentProviding { @@ -460,14 +460,15 @@ open class PDFNavigatorViewController: let factory = PDFKitPDFDocumentFactory() guard let resource = publication.get(href), - let opened = try? await factory.open(resource: resource, at: href, password: nil) as? PDFKit.PDFDocument + let document = try? await factory.open(resource: resource, at: href, password: nil), + let pdfKitDocument = (document as? PDFKitDocumentProviding)?.pdfKitDocument else { return nil } - await service?.setCachedDocument(opened, at: href) + await service?.setCachedDocument(document, at: href) - return opened + return pdfKitDocument } /// Updates the scale factors to match the currently visible pages. diff --git a/Sources/Shared/Publication/Services/Content Protection/UserRights.swift b/Sources/Shared/Publication/Services/Content Protection/UserRights.swift index a3996afd83..5b0a03f644 100644 --- a/Sources/Shared/Publication/Services/Content Protection/UserRights.swift +++ b/Sources/Shared/Publication/Services/Content Protection/UserRights.swift @@ -7,7 +7,7 @@ import Foundation /// Manages consumption of user rights and permissions. -public protocol UserRights { +public protocol UserRights: Sendable { /// Returns whether the user is allowed to copy the given text to the pasteboard. /// /// It may return `false` if the given text exceeds the allowed amount of characters to copy. @@ -35,7 +35,7 @@ public protocol UserRights { } /// A `UserRights` without any restriction. -public final class UnrestrictedUserRights: UserRights, Sendable { +public final class UnrestrictedUserRights: UserRights { public init() {} public func canCopy(text: String) async -> Bool { @@ -56,7 +56,7 @@ public final class UnrestrictedUserRights: UserRights, Sendable { } /// A `UserRights` which forbids all rights. -public final class AllRestrictedUserRights: UserRights, Sendable { +public final class AllRestrictedUserRights: UserRights { public init() {} public func canCopy(text: String) async -> Bool { diff --git a/Sources/Shared/Toolkit/Data/Container/Container.swift b/Sources/Shared/Toolkit/Data/Container/Container.swift index a3bf564085..6088b33831 100644 --- a/Sources/Shared/Toolkit/Data/Container/Container.swift +++ b/Sources/Shared/Toolkit/Data/Container/Container.swift @@ -7,7 +7,7 @@ import Foundation /// A container provides access to a list of `Resource` entries. -public protocol Container: Closeable { +public protocol Container: Closeable, Sendable { /// URL locating this container, when available. /// /// This can be used to optimize access to a container's content for the @@ -28,7 +28,7 @@ public protocol Container: Closeable { } /// A `Container` providing no entries at all. -public struct EmptyContainer: Container, Sendable { +public struct EmptyContainer: Container { public init() {} public let sourceURL: AbsoluteURL? = nil @@ -47,13 +47,13 @@ public struct EmptyContainer: Container, Sendable { /// /// The `containers` will be tested in the given order. public final class CompositeContainer: Container { - private let containers: [Container] + private let containers: [any Container] - public convenience init(_ containers: Container...) { + public convenience init(_ containers: any Container...) { self.init(containers) } - public init(_ containers: [Container]) { + public init(_ containers: [any Container]) { self.containers = containers } diff --git a/Sources/Shared/Toolkit/Data/Container/TransformingContainer.swift b/Sources/Shared/Toolkit/Data/Container/TransformingContainer.swift index 44c0e1c211..aead256635 100644 --- a/Sources/Shared/Toolkit/Data/Container/TransformingContainer.swift +++ b/Sources/Shared/Toolkit/Data/Container/TransformingContainer.swift @@ -11,7 +11,7 @@ import Foundation /// an HTML document, pre-process – e.g. before indexing a publication's content, etc. /// /// If the transformation doesn't apply, simply return resource unchanged. -public typealias ResourceTransformer = (_ href: AnyURL, _ resource: Resource) -> Resource +public typealias ResourceTransformer = @Sendable (_ href: AnyURL, _ resource: Resource) -> Resource /// Transforms the resources' content of a child fetcher using a list of `ResourceTransformer` /// functions. @@ -46,7 +46,7 @@ public final class TransformingContainer: Container { /// Convenient shortcuts to create a `TransformingContainer`. public extension Container { - func map(transform: @escaping (_ href: AnyURL, _ resource: Resource) -> Resource) -> Container { - TransformingContainer(container: self, transformer: { transform($0, $1) }) + func map(transform: @escaping @Sendable (_ href: AnyURL, _ resource: Resource) -> Resource) -> Container { + TransformingContainer(container: self, transformer: transform) } } diff --git a/Sources/Shared/Toolkit/Data/Streamable.swift b/Sources/Shared/Toolkit/Data/Streamable.swift index 6036b37077..008e1df35d 100644 --- a/Sources/Shared/Toolkit/Data/Streamable.swift +++ b/Sources/Shared/Toolkit/Data/Streamable.swift @@ -21,7 +21,11 @@ public protocol Streamable: Closeable, Sendable { /// - range: When null, the whole content is returned. Out-of-range /// indexes are clamped to the available length automatically. /// - consume: Callback called for each chunk of data received. Callers - /// are responsible to accumulate the data if needed. + /// are responsible to accumulate the data if needed. A chunk may be a + /// `Data` slice whose indices do not start at zero (e.g. when streaming + /// a sub-range). Do not assume zero-based indexing: index relative to + /// `chunk.startIndex`, or rebase with `Data(chunk)` before accessing + /// bytes by position. func stream( range: Range?, consume: @escaping @Sendable (Data) -> Void diff --git a/Sources/Shared/Toolkit/File/DirectoryContainer.swift b/Sources/Shared/Toolkit/File/DirectoryContainer.swift index 39c3bca3b9..fac2363933 100644 --- a/Sources/Shared/Toolkit/File/DirectoryContainer.swift +++ b/Sources/Shared/Toolkit/File/DirectoryContainer.swift @@ -7,7 +7,7 @@ import Foundation /// A file system directory as a ``Container``. -public struct DirectoryContainer: Container, Loggable, Sendable { +public struct DirectoryContainer: Container, Loggable { public struct NotADirectoryError: Error, Sendable {} private let directoryURL: FileURL diff --git a/Sources/Shared/Toolkit/File/FileContainer.swift b/Sources/Shared/Toolkit/File/FileContainer.swift index 6730be13a0..20990411d7 100644 --- a/Sources/Shared/Toolkit/File/FileContainer.swift +++ b/Sources/Shared/Toolkit/File/FileContainer.swift @@ -7,7 +7,7 @@ import Foundation /// Provides access to individual file resources on the local file system. -public final class FileContainer: Container, Loggable, Sendable { +public final class FileContainer: Container, Loggable { private let files: [RelativeURL: FileURL] public let sourceURL: AbsoluteURL? = nil diff --git a/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift b/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift index e3b10b1aa9..d26d68a4bb 100644 --- a/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift +++ b/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift @@ -107,7 +107,7 @@ public extension DefaultHTTPClientDelegate { } /// An implementation of `HTTPClient` using Apple's `URLSession`. -public final class DefaultHTTPClient: HTTPClient, Loggable, Sendable { +public final class DefaultHTTPClient: HTTPClient, Loggable { /// Returns the default user agent used when issuing requests. /// /// For example, TestApp/1.3 diff --git a/Sources/Shared/Toolkit/HTTP/HTTPClient.swift b/Sources/Shared/Toolkit/HTTP/HTTPClient.swift index e11962da3f..a88d527a22 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPClient.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPClient.swift @@ -13,7 +13,7 @@ import Foundation /// /// You may provide a custom implementation, or use the `DefaultHTTPClient` one /// which relies on native APIs. -public protocol HTTPClient: Loggable { +public protocol HTTPClient: Loggable, Sendable { /// Streams a resource from the given `request`. /// /// - Parameters: diff --git a/Sources/Shared/Toolkit/PDF/CGPDF.swift b/Sources/Shared/Toolkit/PDF/CGPDF.swift index 826249885d..41a08a905c 100644 --- a/Sources/Shared/Toolkit/PDF/CGPDF.swift +++ b/Sources/Shared/Toolkit/PDF/CGPDF.swift @@ -227,7 +227,7 @@ extension CGPDFDocument: PDFDocument { /// Creates a `PDFDocument` using Core Graphics. @available(*, deprecated, renamed: "PDFKitPDFDocumentFactory", message: "The PDFKitPDFDocumentFactory is more capable") -public final class CGPDFDocumentFactory: PDFDocumentFactory, Loggable, Sendable { +public final class CGPDFDocumentFactory: PDFDocumentFactory, Loggable { public init() {} public func open(file: FileURL, password: String?) async throws -> PDFDocument { @@ -238,11 +238,7 @@ public final class CGPDFDocumentFactory: PDFDocumentFactory, Loggable, Sendable return try open(document: document, password: password) } - private class DataHolder { - var data: Data = .init() - } - - public func open(resource: Resource, at href: HREF, password: String?) async throws -> PDFDocument { + public func open(resource: Resource, at href: HREF, password: String?) async throws -> PDFDocument { if let file = resource.sourceURL?.fileURL { return try await open(file: file, password: password) } @@ -260,12 +256,14 @@ public final class CGPDFDocumentFactory: PDFDocumentFactory, Loggable, Sendable return 0 } + let resource = context.resource + let offset = context.offset + let resultData = Mutex(Data()) let semaphore = DispatchSemaphore(value: 0) - let holder = DataHolder() Task { - switch await context.resource.read(range: context.offset ..< end) { + switch await resource.read(range: offset ..< end) { case let .success(result): - holder.data = result + resultData.withLock { $0 = result } case let .failure(error): CGPDFDocumentFactory.log(.error, error) } @@ -274,7 +272,7 @@ public final class CGPDFDocumentFactory: PDFDocumentFactory, Loggable, Sendable _ = semaphore.wait(timeout: .distantFuture) - let data = holder.data + let data = resultData.withLock { $0 } if !data.isEmpty { data.copyBytes(to: buffer.assumingMemoryBound(to: UInt8.self), count: data.count) context.offset += UInt64(data.count) diff --git a/Sources/Shared/Toolkit/PDF/PDFDocument.swift b/Sources/Shared/Toolkit/PDF/PDFDocument.swift index 9f4f2e0659..7cab9ed419 100644 --- a/Sources/Shared/Toolkit/PDF/PDFDocument.swift +++ b/Sources/Shared/Toolkit/PDF/PDFDocument.swift @@ -19,7 +19,7 @@ public enum PDFDocumentError: Error, Sendable { /// Represents a PDF document. /// /// This is not used to render a PDF document, only to access its metadata. -public protocol PDFDocument { +public protocol PDFDocument: Sendable { /// Permanent identifier based on the contents of the file at the time it was originally /// created. func identifier() async throws -> String? @@ -59,15 +59,15 @@ public protocol PDFDocumentTextProviding: PDFDocument { func pageText(at pageIndex: Int) async throws -> String? } -public protocol PDFDocumentFactory { +public protocol PDFDocumentFactory: Sendable { /// Opens a PDF from a local file path. func open(file: FileURL, password: String?) async throws -> PDFDocument /// Opens a PDF from a `Resource` located at the given `href`. - func open(resource: Resource, at href: HREF, password: String?) async throws -> PDFDocument + func open(resource: Resource, at href: HREF, password: String?) async throws -> PDFDocument } -public final class DefaultPDFDocumentFactory: PDFDocumentFactory, Loggable, Sendable { +public final class DefaultPDFDocumentFactory: PDFDocumentFactory, Loggable { private let factory = PDFKitPDFDocumentFactory() public init() {} @@ -76,7 +76,7 @@ public final class DefaultPDFDocumentFactory: PDFDocumentFactory, Loggable, Send try await factory.open(file: file, password: password) } - public func open(resource: Resource, at href: HREF, password: String?) async throws -> PDFDocument { + public func open(resource: Resource, at href: HREF, password: String?) async throws -> PDFDocument { try await factory.open(resource: resource, at: href, password: password) } } @@ -94,7 +94,7 @@ public final class CompositePDFDocumentFactory: PDFDocumentFactory, Loggable { try await eachFactory { try await $0.open(file: file, password: password) } } - public func open(resource: Resource, at href: HREF, password: String?) async throws -> PDFDocument { + public func open(resource: Resource, at href: HREF, password: String?) async throws -> PDFDocument { try await eachFactory { try await $0.open(resource: resource, at: href, password: password) } } diff --git a/Sources/Shared/Toolkit/PDF/PDFDocumentService.swift b/Sources/Shared/Toolkit/PDF/PDFDocumentService.swift index 9ba24ef93d..404f056f8d 100644 --- a/Sources/Shared/Toolkit/PDF/PDFDocumentService.swift +++ b/Sources/Shared/Toolkit/PDF/PDFDocumentService.swift @@ -10,17 +10,17 @@ import Foundation /// /// Replaces `PDFPublicationService` and `PDFDocumentHolder`. Opens the PDF once per HREF /// and shares the result across the parser, navigator, and publication services. -package protocol PDFDocumentService: PublicationService { +package protocol PDFDocumentService: PublicationService & Sendable { /// Returns the cached document if `href` matches, otherwise opens through the underlying factory, /// caches the result, and returns it. - func openDocument(at href: HREF) async throws -> PDFDocument + func openDocument(at href: HREF) async throws -> PDFDocument /// Returns the cached document if `href` matches, or `nil` otherwise. - func cachedDocument(at href: HREF) async -> PDFDocument? + func cachedDocument(at href: HREF) async -> PDFDocument? /// Replaces the cached document. Use this to seed the cache (parser) or to override it with /// a different concrete type (navigator forcing PDFKit). - func setCachedDocument(_ document: PDFDocument?, at href: HREF) async + func setCachedDocument(_ document: PDFDocument?, at href: HREF) async /// Clears all cached documents. func removeCachedDocuments() async @@ -46,7 +46,7 @@ package actor DefaultPDFDocumentService: PDFDocumentService { } } - package func openDocument(at href: HREF) async throws -> PDFDocument { + package func openDocument(at href: HREF) async throws -> PDFDocument { if let cached, let cachedHREF, cachedHREF.isEquivalentTo(href) { return cached } @@ -61,14 +61,14 @@ package actor DefaultPDFDocumentService: PDFDocumentService { return document } - package func cachedDocument(at href: HREF) -> PDFDocument? { + package func cachedDocument(at href: HREF) -> PDFDocument? { guard let cachedHREF, cachedHREF.isEquivalentTo(href) else { return nil } return cached } - package func setCachedDocument(_ document: PDFDocument?, at href: HREF) { + package func setCachedDocument(_ document: PDFDocument?, at href: HREF) { cachedHREF = document != nil ? href.anyURL : nil cached = document } diff --git a/Sources/Shared/Toolkit/PDF/PDFKit.swift b/Sources/Shared/Toolkit/PDF/PDFKit.swift index bc5019de2f..9b913bdb6c 100644 --- a/Sources/Shared/Toolkit/PDF/PDFKit.swift +++ b/Sources/Shared/Toolkit/PDF/PDFKit.swift @@ -19,58 +19,8 @@ extension PDFKit.PDFDocument: PDFKitDocumentProviding { } } -/// Extends PDFKit's `PDFDocument` with our shared `PDFDocument` protocol. -/// -/// Unfortunately, PDFKit doesn't support streams, so we need to load the full document in memory. -/// If this is an issue for you, use `CPDFDocumentFactory` instead. -/// -/// Use `PDFKitPDFDocumentFactory` to create a `PDFDocument` from a `Resource`. -extension PDFKit.PDFDocument: PDFDocument { - public func pageCount() async throws -> Int { - pageCount - } - - public func identifier() async throws -> String? { - try await documentRef?.identifier() - } - - public func cover() async throws -> UIImage? { - try await documentRef?.cover() - } - - public func readingProgression() async throws -> ReadingProgression? { - try await documentRef?.readingProgression() - } - - public func title() async throws -> String? { - try await documentRef?.title() - } - - public func author() async throws -> String? { - try await documentRef?.author() - } - - public func subject() async throws -> String? { - try await documentRef?.subject() - } - - public func keywords() async throws -> [String] { - try await documentRef?.keywords() ?? [] - } - - public func tableOfContents() async throws -> [PDFOutlineNode] { - try await documentRef?.tableOfContents() ?? [] - } -} - -extension PDFKit.PDFDocument: PDFDocumentTextProviding { - public func pageText(at pageIndex: Int) async throws -> String? { - page(at: pageIndex)?.string - } -} - /// Creates a `PDFDocument` using PDFKit. -public final class PDFKitPDFDocumentFactory: PDFDocumentFactory, Sendable { +public final class PDFKitPDFDocumentFactory: PDFDocumentFactory { public init() {} public func open(file: FileURL, password: String?) async throws -> PDFDocument { @@ -81,7 +31,7 @@ public final class PDFKitPDFDocumentFactory: PDFDocumentFactory, Sendable { return try open(document: document, password: password) } - public func open(resource: Resource, at href: HREF, password: String?) async throws -> PDFDocument { + public func open(resource: Resource, at href: HREF, password: String?) async throws -> PDFDocument { // Fast-path in case the resource actually references a file on the // disk. if let file = resource.sourceURL?.fileURL { @@ -120,6 +70,70 @@ public final class PDFKitPDFDocumentFactory: PDFDocumentFactory, Sendable { } } - return document + return PDFKitPDFDocument(document) + } +} + +/// Wraps a PDFKit `PDFDocument` to expose it through the toolkit's +/// `PDFDocument` protocol. +/// +/// Use `PDFKitPDFDocumentFactory` to create a `PDFKitPDFDocument` from a +/// `Resource`. +/// +/// ## Concurrency +/// +/// `PDFKit.PDFDocument` is a reference type with mutable internal state and is +/// not annotated as `Sendable` by Apple. Rather than retroactively asserting +/// `@unchecked Sendable` on the system type – which would leak that unsound +/// claim to *every* `PDFKit.PDFDocument` in the app – we confine the assertion +/// to this wrapper. The toolkit only performs read-only operations on the +/// document, and a given instance must not be mutated (e.g. by adding +/// `PDFAnnotation`s) while it is shared across threads. +// FIXME: Note that we share this instance with the PDF navigator through the `PDFDocumentService`. For now this is safe as we only perform read-only operations on the document. But this might change when we start adding `PDFAnnotation`, for example. We might need to revisit this implementation and have a PDFDocument dedicated to the navigator. +private final class PDFKitPDFDocument: PDFDocument, PDFDocumentTextProviding, PDFKitDocumentProviding, @unchecked Sendable { + let pdfKitDocument: PDFKit.PDFDocument + + init(_ document: PDFKit.PDFDocument) { + pdfKitDocument = document + } + + func pageCount() async throws -> Int { + pdfKitDocument.pageCount + } + + func identifier() async throws -> String? { + try await pdfKitDocument.documentRef?.identifier() + } + + func cover() async throws -> UIImage? { + try await pdfKitDocument.documentRef?.cover() + } + + func readingProgression() async throws -> ReadingProgression? { + try await pdfKitDocument.documentRef?.readingProgression() + } + + func title() async throws -> String? { + try await pdfKitDocument.documentRef?.title() + } + + func author() async throws -> String? { + try await pdfKitDocument.documentRef?.author() + } + + func subject() async throws -> String? { + try await pdfKitDocument.documentRef?.subject() + } + + func keywords() async throws -> [String] { + try await pdfKitDocument.documentRef?.keywords() ?? [] + } + + func tableOfContents() async throws -> [PDFOutlineNode] { + try await pdfKitDocument.documentRef?.tableOfContents() ?? [] + } + + func pageText(at pageIndex: Int) async throws -> String? { + pdfKitDocument.page(at: pageIndex)?.string } } diff --git a/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveFactory.swift b/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveFactory.swift index 09dddd1a3d..bcba380f81 100644 --- a/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveFactory.swift +++ b/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveFactory.swift @@ -17,7 +17,7 @@ private let zipEOCDMaximumLength: UInt64 = 65557 + 76 private let maximumZIPLengthToFullyCache = 5.MB /// Creates new ZIPFoundation ``Archive`` objects from a shared ``Resource``. -final class ZIPFoundationArchiveFactory { +final class ZIPFoundationArchiveFactory: Sendable { enum Source { case file(FileURL) case resource(Resource) diff --git a/Sources/Streamer/Parser/EPUB/Resource Transformers/EPUBDeobfuscator.swift b/Sources/Streamer/Parser/EPUB/Resource Transformers/EPUBDeobfuscator.swift index 9d67f68e94..1f780107c6 100644 --- a/Sources/Streamer/Parser/EPUB/Resource Transformers/EPUBDeobfuscator.swift +++ b/Sources/Streamer/Parser/EPUB/Resource Transformers/EPUBDeobfuscator.swift @@ -68,24 +68,30 @@ final class EPUBDeobfuscator { } func stream(range: Range?, consume: @escaping @Sendable (Data) -> Void) async -> ReadResult { - var readPosition = range?.lowerBound ?? 0 - let obfuscatedLength = algorithm.obfuscatedLength + let readPosition = Mutex(range?.lowerBound ?? 0) + let obfuscatedLength = UInt64(algorithm.obfuscatedLength) return await resource.stream( range: range, consume: { data in - var data = data - - if readPosition < obfuscatedLength { - for i in 0 ..< data.count { - if readPosition + UInt64(i) >= obfuscatedLength { - break + // The chunk may be a `Data` slice with non-zero start + // indices (e.g. when streaming a sub-range), so we rebase + // it to a zero-indexed buffer before mutating by position. + var data = Data(data) + + readPosition.withLock { readPos in + if readPos < obfuscatedLength { + for i in 0 ..< data.count { + if readPos + UInt64(i) >= obfuscatedLength { + break + } + let keyIndex = Int((readPos + UInt64(i)) % UInt64(self.key.count)) + data[i] = data[i] ^ self.key[keyIndex] } - data[i] = data[i] ^ self.key[i % self.key.count] } - } - readPosition += UInt64(data.count) + readPos += UInt64(data.count) + } consume(data) } diff --git a/TestApp/Sources/App/Readium.swift b/TestApp/Sources/App/Readium.swift index 6290c7dfba..99e29943bd 100644 --- a/TestApp/Sources/App/Readium.swift +++ b/TestApp/Sources/App/Readium.swift @@ -51,7 +51,7 @@ final class Readium { lazy var lcpAuthentication: LCPAuthenticating = LCPDialogAuthentication() /// Facade to the private R2LCPClient.framework. - class LCPClient: ReadiumLCP.LCPClient { + final class LCPClient: ReadiumLCP.LCPClient { func createContext(jsonLicense: String, hashedPassphrase: LCPPassphraseHash, pemCrl: String) throws -> LCPClientContext { try R2LCPClient.createContext(jsonLicense: jsonLicense, hashedPassphrase: hashedPassphrase, pemCrl: pemCrl) } diff --git a/Tests/SharedTests/ProxyContainer.swift b/Tests/SharedTests/ProxyContainer.swift index 35d4c952cc..6286b8c630 100644 --- a/Tests/SharedTests/ProxyContainer.swift +++ b/Tests/SharedTests/ProxyContainer.swift @@ -8,9 +8,9 @@ import Foundation import ReadiumShared final class ProxyContainer: Container { - private let retrieve: (AnyURL) -> Resource? + private let retrieve: @Sendable (AnyURL) -> (any Resource)? - init(entries: Set = [], _ retrieve: @escaping (AnyURL) -> Resource?) { + init(entries: Set = [], _ retrieve: @escaping @Sendable (AnyURL) -> (any Resource)?) { self.entries = Set(entries.map(\.normalized)) self.retrieve = retrieve } diff --git a/Tests/SharedTests/Publication/Services/Content Protection/ContentProtectionServiceTests.swift b/Tests/SharedTests/Publication/Services/Content Protection/ContentProtectionServiceTests.swift index ef5b48022a..7499aa64be 100644 --- a/Tests/SharedTests/Publication/Services/Content Protection/ContentProtectionServiceTests.swift +++ b/Tests/SharedTests/Publication/Services/Content Protection/ContentProtectionServiceTests.swift @@ -102,12 +102,20 @@ struct TestContentProtectionService: ContentProtectionService { } final class TestUserRights: UserRights { - var copyCount: Int - var printCount: Int + private let _copyCount: Mutex + private let _printCount: Mutex + + var copyCount: Int { + _copyCount.withLock { $0 } + } + + var printCount: Int { + _printCount.withLock { $0 } + } init(copyCount: Int = 10, printCount: Int = 10) { - self.copyCount = copyCount - self.printCount = printCount + _copyCount = Mutex(copyCount) + _printCount = Mutex(printCount) } var canCopy: Bool { @@ -119,11 +127,13 @@ final class TestUserRights: UserRights { } func copy(text: String) -> Bool { - guard canCopy(text: text) else { - return false + _copyCount.withLock { count in + guard count >= text.count else { + return false + } + count -= text.count + return true } - copyCount -= text.count - return true } var canPrint: Bool { @@ -135,10 +145,12 @@ final class TestUserRights: UserRights { } func print(pageCount: Int) -> Bool { - guard canPrint(pageCount: pageCount) else { - return false + _printCount.withLock { count in + guard count >= pageCount else { + return false + } + count -= pageCount + return true } - printCount -= pageCount - return true } } diff --git a/Tests/SharedTests/Publication/Services/Content/Iterators/PDFResourceContentIteratorTests.swift b/Tests/SharedTests/Publication/Services/Content/Iterators/PDFResourceContentIteratorTests.swift index d151531f9a..34b91c2f6f 100644 --- a/Tests/SharedTests/Publication/Services/Content/Iterators/PDFResourceContentIteratorTests.swift +++ b/Tests/SharedTests/Publication/Services/Content/Iterators/PDFResourceContentIteratorTests.swift @@ -313,16 +313,19 @@ private func makeIterator( // MARK: - Mock PDF Documents -private class MockPDFDocument: PDFDocumentTextProviding { +private final class MockPDFDocument: PDFDocumentTextProviding { private let texts: [String?] - private(set) var requestedPageIndices: [Int] = [] + private let _requestedPageIndices = Mutex<[Int]>([]) + var requestedPageIndices: [Int] { + _requestedPageIndices.withLock { $0 } + } init(texts: [String?]) { self.texts = texts } func resetTracking() { - requestedPageIndices = [] + _requestedPageIndices.withLock { $0 = [] } } func identifier() async throws -> String? { @@ -362,12 +365,12 @@ private class MockPDFDocument: PDFDocumentTextProviding { } func pageText(at pageIndex: Int) async throws -> String? { - requestedPageIndices.append(pageIndex) + _requestedPageIndices.withLock { $0.append(pageIndex) } return texts.getOrNil(pageIndex) ?? nil } } -private class MockNonTextPDFDocument: PDFDocument { +private final class MockNonTextPDFDocument: PDFDocument { func identifier() async throws -> String? { nil } diff --git a/Tests/SharedTests/Toolkit/HTTP/HTTPResourceTests.swift b/Tests/SharedTests/Toolkit/HTTP/HTTPResourceTests.swift index c31f0433c0..55c7ac2025 100644 --- a/Tests/SharedTests/Toolkit/HTTP/HTTPResourceTests.swift +++ b/Tests/SharedTests/Toolkit/HTTP/HTTPResourceTests.swift @@ -11,25 +11,33 @@ import Testing struct HTTPResourceTests { private let url = HTTPURL(string: "http://example.com/book.epub")! - class MockHTTPClient: HTTPClient { - struct Response { + final class MockHTTPClient: HTTPClient { + struct Response: Sendable { let response: HTTPResponse let body: Data } - var fetchResults: [String: HTTPResult] = [:] - var fetchCount = 0 + private let _fetchResults = Mutex<[String: HTTPResult]>([:]) + var fetchResults: [String: HTTPResult] { + get { _fetchResults.withLock { $0 } } + set { _fetchResults.withLock { $0 = newValue } } + } + + private let _fetchCount = Mutex(0) + var fetchCount: Int { + _fetchCount.withLock { $0 } + } func stream( - _ request: HTTPRequestConvertible, + _ request: any HTTPRequestConvertible, onReceiveResponse: (@Sendable (HTTPResponse) async -> HTTPResult)?, consume: @Sendable (Data, Double?) -> HTTPResult ) async -> HTTPResult { let req = try! request.httpRequest().get() let key = "\(req.method.rawValue) \(req.url.string)" - fetchCount += 1 + _fetchCount.withLock { $0 += 1 } - if let result = fetchResults[key] { + if let result = _fetchResults.withLock({ $0[key] }) { switch result { case let .success(response): if let onReceiveResponse = onReceiveResponse { diff --git a/Tests/StreamerTests/Parser/EPUB/Resource Transformers/EPUBDeobfuscatorTests.swift b/Tests/StreamerTests/Parser/EPUB/Resource Transformers/EPUBDeobfuscatorTests.swift index 7d9a7c61c3..29ade0d1c6 100644 --- a/Tests/StreamerTests/Parser/EPUB/Resource Transformers/EPUBDeobfuscatorTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/Resource Transformers/EPUBDeobfuscatorTests.swift @@ -19,26 +19,59 @@ class EPUBDeobfuscatorTests: XCTestCase { func testDeobfuscateIDPF() async throws { let sut = try sut(resourcePath: "cut-cut.obf.woff", algorithm: "http://www.idpf.org/2008/embedding") - let result = await sut.deobfuscate() + let result = await sut.deobfuscate(nil) XCTAssertEqual(result, .success(font)) } func testDeobfuscateAdobe() async throws { let sut = try sut(resourcePath: "cut-cut.adb.woff", algorithm: "http://ns.adobe.com/pdf/enc#RC") - let result = await sut.deobfuscate() + let result = await sut.deobfuscate(nil) XCTAssertEqual(result, .success(font)) } + /// Reading a sub-range starting inside the obfuscated region must produce + /// the same bytes as the matching range of the clear-text resource. + /// + /// Regression test: the key index must be derived from the absolute + /// position in the resource, not from the offset within the streamed + /// chunk. We start at a position that is not aligned on the key length so + /// a chunk-local index would yield a different (wrong) key byte. + func testDeobfuscateRangeWithinObfuscatedRegion() async throws { + let range: Range = 105 ..< 400 + + for (path, algorithm) in [ + ("cut-cut.obf.woff", "http://www.idpf.org/2008/embedding"), + ("cut-cut.adb.woff", "http://ns.adobe.com/pdf/enc#RC"), + ] { + let sut = try sut(resourcePath: path, algorithm: algorithm) + let result = await sut.deobfuscate(range) + let expected = Data(font[Int(range.lowerBound) ..< Int(range.upperBound)]) + XCTAssertEqual(result, .success(expected), "algorithm: \(algorithm)") + } + } + + /// Reading a range that spans the boundary of the obfuscated region must + /// deobfuscate only the bytes within the region and leave the rest intact. + func testDeobfuscateRangeAcrossObfuscatedBoundary() async throws { + // IDPF obfuscates the first 1040 bytes. + let range: Range = 1000 ..< 1100 + + let sut = try sut(resourcePath: "cut-cut.obf.woff", algorithm: "http://www.idpf.org/2008/embedding") + let result = await sut.deobfuscate(range) + let expected = Data(font[Int(range.lowerBound) ..< Int(range.upperBound)]) + XCTAssertEqual(result, .success(expected)) + } + /// Fix for https://github.com/readium/r2-streamer-swift/issues/208 func testEmptyPublicationID() async throws { let file = fixtures.data(at: "nav.xhtml") var sut = try sut(publicationID: "urn:uuid:", resourcePath: "nav.xhtml", algorithm: "http://www.idpf.org/2008/embedding") - var result = await sut.deobfuscate() + var result = await sut.deobfuscate(nil) XCTAssertEqual(result, .success(file)) sut = try self.sut(publicationID: "", resourcePath: "nav.xhtml", algorithm: "http://www.idpf.org/2008/embedding") - result = await sut.deobfuscate() + result = await sut.deobfuscate(nil) XCTAssertEqual(result, .success(file)) } @@ -47,7 +80,7 @@ class EPUBDeobfuscatorTests: XCTestCase { resourcePath path: String, algorithm: String ) throws -> ( - deobfuscate: () async -> ReadResult, + deobfuscate: (Range?) async -> ReadResult, resource: DataResource, encryptions: [RelativeURL: Encryption] ) { @@ -60,8 +93,8 @@ class EPUBDeobfuscatorTests: XCTestCase { encryptions: encryptions ) return ( - deobfuscate: { - await deobfuscator.deobfuscate(resource: resource, at: url.anyURL).read() + deobfuscate: { range in + await deobfuscator.deobfuscate(resource: resource, at: url.anyURL).read(range: range) }, resource: resource, encryptions: [url: Encryption(algorithm: algorithm)] diff --git a/Tests/StreamerTests/Parser/EPUB/Services/EPUBPositionsServiceTests.swift b/Tests/StreamerTests/Parser/EPUB/Services/EPUBPositionsServiceTests.swift index 58d8a38614..9c2a7ef1ba 100644 --- a/Tests/StreamerTests/Parser/EPUB/Services/EPUBPositionsServiceTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/Services/EPUBPositionsServiceTests.swift @@ -348,7 +348,7 @@ private func makeProperties(layout: EPUBLayout? = nil, originalLength: Int? = ni return Properties(props) } -private class MockContainer: Container { +private final class MockContainer: Container { private let readingOrder: [(UInt64, Link, ArchiveProperties?)] init(readingOrder: [(UInt64, Link, ArchiveProperties?)]) { From 250cbadbbb527a133833be4025f961cb26f9dc57 Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Fri, 19 Jun 2026 08:10:17 -0500 Subject: [PATCH 15/39] Migrate media and utility types to Swift 6 concurrency (#808) --- .../Navigator/Audiobook/AudioNavigator.swift | 184 +++++++++++------- Sources/Navigator/Navigator.swift | 1 + .../Navigator/Preferences/Configurable.swift | 1 + .../TTS/PublicationSpeechSynthesizer.swift | 39 ++-- Sources/Navigator/VisualNavigator.swift | 1 + Sources/Shared/Toolkit/Cancellable.swift | 83 -------- Sources/Shared/Toolkit/ControlFlow.swift | 58 ++---- .../Shared/Toolkit/Media/AudioSession.swift | 53 ++--- .../Shared/Toolkit/Media/NowPlayingInfo.swift | 4 +- Sources/Shared/Toolkit/Poller.swift | 75 +++++++ .../Common/Preferences/UserPreferences.swift | 13 +- .../Reader/Common/TTS/TTSViewModel.swift | 2 + 12 files changed, 278 insertions(+), 236 deletions(-) delete mode 100644 Sources/Shared/Toolkit/Cancellable.swift create mode 100644 Sources/Shared/Toolkit/Poller.swift diff --git a/Sources/Navigator/Audiobook/AudioNavigator.swift b/Sources/Navigator/Audiobook/AudioNavigator.swift index 2510490939..2b97e1d74e 100644 --- a/Sources/Navigator/Audiobook/AudioNavigator.swift +++ b/Sources/Navigator/Audiobook/AudioNavigator.swift @@ -77,6 +77,7 @@ public extension AudioNavigatorDelegate { /// /// * Readium Audiobook /// * ZAB (Zipped Audio Book) +@MainActor public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Loggable { public weak var delegate: AudioNavigatorDelegate? @@ -110,7 +111,7 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo } } - public nonisolated let publication: Publication + public let publication: Publication private let initialLocation: Locator? private let config: Configuration private let audioSession: AudioSessionManaging @@ -142,16 +143,14 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo ) } - deinit { - if let timeObserver = timeObserver { - player.removeTimeObserver(timeObserver) - } - if let playerItemEndObserver { - NotificationCenter.default.removeObserver(playerItemEndObserver) - } + private var audioSessionToken: AudioSessionToken? + isolated deinit { playTask?.cancel() - audioSession.end(for: self) + notificationTask?.cancel() + if let token = audioSessionToken { + audioSession.end(with: token) + } } /// Returns whether the resource is currently playing or not. @@ -205,7 +204,7 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo /// Resumes or start the playback. public func play() { playTask = Task { @MainActor in - audioSession.start(with: self, isPlaying: false) + audioSessionToken = audioSession.start(with: self, isPlaying: false) if player.currentItem == nil { if let location = initialLocation { @@ -253,8 +252,8 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo private var rateObserver: NSKeyValueObservation? private var timeControlStatusObserver: NSKeyValueObservation? private var currentItemObserver: NSKeyValueObservation? - private var timeObserver: Any? - private var playerItemEndObserver: Any? + private var timeObserverToken: TimeObserverToken? + private var notificationTask: Task? private lazy var mediaLoader = PublicationMediaLoader(publication: publication) @@ -264,56 +263,68 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo player.automaticallyWaitsToMinimizeStalling = false player.volume = Float(settings.volume) - timeObserver = player.addPeriodicTimeObserver( + let periodicObserver = player.addPeriodicTimeObserver( forInterval: CMTime( seconds: config.playbackRefreshInterval, preferredTimescale: 1000 ), queue: .main ) { [weak self] time in - if let self = self { - let time = time.secondsOrZero - self.playbackDidChange(time) + MainActor.assumeIsolated { + guard let self = self else { return } + self.playbackDidChange(time.secondsOrZero) } } + timeObserverToken = TimeObserverToken(player: player, observer: periodicObserver) - rateObserver = player.observe(\.rate, options: [.new, .old]) { [weak self] player, _ in - guard let self = self else { - return - } + rateObserver = player.observe(\.rate, options: [.new, .old]) { [weak self] _, _ in + Task { @MainActor in + guard let self = self else { + return + } - let session = self.audioSession - switch player.timeControlStatus { - case .paused: - session.user(self, didChangePlaying: false) - case .waitingToPlayAtSpecifiedRate, .playing: - session.user(self, didChangePlaying: true) - @unknown default: - break + let session = self.audioSession + switch self.player.timeControlStatus { + case .paused: + session.user(self, didChangePlaying: false) + case .waitingToPlayAtSpecifiedRate, .playing: + session.user(self, didChangePlaying: true) + @unknown default: + break + } } } timeControlStatusObserver = player.observe(\.timeControlStatus, options: [.new, .old]) { [weak self] _, _ in - self?.playbackDidChange() + Task { @MainActor [weak self] in + self?.playbackDidChange() + } } currentItemObserver = player.observe(\.currentItem, options: [.new, .old]) { [weak self] _, _ in - self?.playbackDidChange() + Task { @MainActor [weak self] in + self?.playbackDidChange() + } } - playerItemEndObserver = NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: nil, queue: .main) { [weak self, weak player] notification in - guard - let self, let player, - let currentItem = player.currentItem, - currentItem == (notification.object as? AVPlayerItem) - else { - return - } + notificationTask = Task { @MainActor [weak self] in + for await notification in NotificationCenter.default.notifications(named: .AVPlayerItemDidPlayToEndTime) { + guard + let self = self, + let currentItem = self.player.currentItem, + currentItem == (notification.object as? AVPlayerItem) + else { + continue + } - self.shouldPlayNextResource { playNext in - Task { - if playNext, await self.goForward() { - self.play() + self.shouldPlayNextResource { playNext in + if playNext { + Task { @MainActor [weak self] in + guard let self = self else { return } + if await self.goForward() { + self.play() + } + } } } } @@ -322,7 +333,7 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo return player }() - private func shouldPlayNextResource(completion: @escaping (Bool) -> Void) { + private func shouldPlayNextResource(completion: @escaping @MainActor @Sendable (Bool) -> Void) { guard let delegate = delegate else { completion(true) return @@ -337,28 +348,39 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo if let time = time { let locator = makeLocator(forTime: time) currentLocation = locator - Task { @MainActor in - delegate?.navigator(self, locationDidChange: locator) - } + delegate?.navigator(self, locationDidChange: locator) } - makePlaybackInfo(forTime: time) { info in + makePlaybackInfo(forTime: time) { [weak self] info in + guard let self = self else { return } self.delegate?.navigator(self, playbackDidChange: info) } } - /// A deadlock can occur when loading HTTP assets and creating the playback info from the main thread. - /// To fix this, this is an asynchronous operation. - private func makePlaybackInfo(forTime time: Double? = nil, completion: @escaping @MainActor (MediaPlaybackInfo) -> Void) { - DispatchQueue.global(qos: .userInteractive).async { + private func makePlaybackInfo(forTime time: Double? = nil, completion: @escaping @MainActor @Sendable (MediaPlaybackInfo) -> Void) { + let resourceIndex = resourceIndex + let state = state + let currentTime = time ?? currentTime + let linkDuration = publication.readingOrder[resourceIndex].duration + let currentItem = player.currentItem + + // A deadlock can occur when loading HTTP assets and creating the + // playback info from the main thread. To fix this, this is an + // asynchronous operation. + Task.detached { + var duration: Double? = linkDuration + if let itemDuration = currentItem?.duration, itemDuration.isNumeric { + duration = itemDuration.secondsOrZero + } + let info = MediaPlaybackInfo( - resourceIndex: self.resourceIndex, - state: self.state, - time: time ?? self.currentTime, - duration: self.resourceDuration + resourceIndex: resourceIndex, + state: state, + time: currentTime, + duration: duration ) - DispatchQueue.main.async { + Task { @MainActor in completion(info) } } @@ -394,25 +416,25 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo private var lastLoadedTimeRanges: [Range] = [] private lazy var loadedTimeRangesTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] timer in - guard let self = self else { - timer.invalidate() - return - } - - let ranges: [Range] = (self.player.currentItem?.loadedTimeRanges ?? []) - .map { value in - let range = value.timeRangeValue - let start = range.start.secondsOrZero - let duration = range.duration.secondsOrZero - return start ..< (start + duration) + MainActor.assumeIsolated { + guard let self = self else { + timer.invalidate() + return } - guard ranges != self.lastLoadedTimeRanges else { - return - } + let ranges: [Range] = (self.player.currentItem?.loadedTimeRanges ?? []) + .map { value in + let range = value.timeRangeValue + let start = range.start.secondsOrZero + let duration = range.duration.secondsOrZero + return start ..< (start + duration) + } - self.lastLoadedTimeRanges = ranges - Task { @MainActor in + guard ranges != self.lastLoadedTimeRanges else { + return + } + + self.lastLoadedTimeRanges = ranges self.delegate?.navigator(self, loadedTimeRangesDidChange: ranges) } } @@ -439,7 +461,7 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo player.replaceCurrentItem(with: AVPlayerItem(asset: asset)) resourceIndex = newResourceIndex loadedTimeRangesTimer.fire() - await delegate?.navigator(self, loadedTimeRangesDidChange: []) + delegate?.navigator(self, loadedTimeRangesDidChange: []) } // Seeks to time @@ -447,7 +469,7 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo let finished = await player.seek(to: CMTime(seconds: time, preferredTimescale: 1000)) if finished { - await delegate?.navigator(self, didJumpTo: locator) + delegate?.navigator(self, didJumpTo: locator) } if wasPlaying { @@ -544,3 +566,17 @@ private extension CMTime { isNumeric ? seconds : 0 } } + +private final class TimeObserverToken { + private let player: AVPlayer + private let observer: Any + + init(player: AVPlayer, observer: Any) { + self.player = player + self.observer = observer + } + + deinit { + player.removeTimeObserver(observer) + } +} diff --git a/Sources/Navigator/Navigator.swift b/Sources/Navigator/Navigator.swift index 22ab39dca1..7f9a0944df 100644 --- a/Sources/Navigator/Navigator.swift +++ b/Sources/Navigator/Navigator.swift @@ -9,6 +9,7 @@ import ReadiumInternal import ReadiumShared import SafariServices +@MainActor public protocol Navigator: AnyObject { /// Publication being rendered. var publication: Publication { get } diff --git a/Sources/Navigator/Preferences/Configurable.swift b/Sources/Navigator/Preferences/Configurable.swift index afc6cd377c..ff5240b31b 100644 --- a/Sources/Navigator/Preferences/Configurable.swift +++ b/Sources/Navigator/Preferences/Configurable.swift @@ -8,6 +8,7 @@ import Foundation import ReadiumShared /// A `Configurable` is a component with a set of `ConfigurableSettings`. +@MainActor public protocol Configurable { associatedtype Settings: ConfigurableSettings associatedtype Preferences: ConfigurablePreferences diff --git a/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift b/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift index b9426e7997..9469b7dd30 100644 --- a/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift +++ b/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift @@ -20,6 +20,7 @@ public protocol PublicationSpeechSynthesizerDelegate: AnyObject { /// `PublicationSpeechSynthesizer` orchestrates the rendition of a `Publication` by iterating through its content, /// splitting it into individual utterances using a `ContentTokenizer`, then using a `TTSEngine` to read them aloud. +@MainActor public final class PublicationSpeechSynthesizer: Loggable { public typealias EngineFactory = () -> TTSEngine public typealias TokenizerFactory = (_ defaultLanguage: Language?) -> ContentTokenizer @@ -88,12 +89,10 @@ public final class PublicationSpeechSynthesizer: Loggable { public private(set) var state: State = .stopped { didSet { if oldValue.isPlaying != state.isPlaying { - audioSession.user(audioSessionUser, didChangePlaying: state.isPlaying) + audioSessionUser.didChangePlaying(state.isPlaying) } - Task { - await delegate?.publicationSpeechSynthesizer(self, stateDidChange: state) - } + delegate?.publicationSpeechSynthesizer(self, stateDidChange: state) } } @@ -143,18 +142,14 @@ public final class PublicationSpeechSynthesizer: Loggable { self.publication = publication self.config = config self.audioSession = audioSession - audioSessionUser = AudioSessionUser(config: audioSessionConfig) + audioSessionUser = AudioSessionUser(session: audioSession, config: audioSessionConfig) self.engineFactory = engineFactory self.tokenizerFactory = tokenizerFactory self.delegate = delegate } - deinit { - audioSession.end(for: audioSessionUser) - } - /// The default content tokenizer will split the `Content.Element` items into individual sentences. - public static let defaultTokenizerFactory: TokenizerFactory = { defaultLanguage in + public nonisolated static let defaultTokenizerFactory: TokenizerFactory = { defaultLanguage in makeTextContentTokenizer( defaultLanguage: defaultLanguage, contextSnippetLength: 50, @@ -189,7 +184,7 @@ public final class PublicationSpeechSynthesizer: Loggable { /// (Re)starts the synthesizer from the given locator or the beginning of the publication. public func start(from startLocator: Locator? = nil) { - audioSession.start(with: audioSessionUser, isPlaying: false) + audioSessionUser.start(isPlaying: false) currentTask?.cancel() publicationIterator = publication.content(from: startLocator)?.iterator() @@ -312,7 +307,7 @@ public final class PublicationSpeechSynthesizer: Loggable { await playNextUtterance(.forward) case let .failure(error): state = .paused(utterance) - await delegate?.publicationSpeechSynthesizer(self, utterance: utterance, didFailWithError: .engine(error)) + delegate?.publicationSpeechSynthesizer(self, utterance: utterance, didFailWithError: .engine(error)) } } @@ -425,11 +420,29 @@ public final class PublicationSpeechSynthesizer: Loggable { private final class AudioSessionUser: ReadiumShared.AudioSessionUser { let audioConfiguration: AudioSession.Configuration - init(config: AudioSession.Configuration) { + private let session: any AudioSessionManaging + private var token: AudioSessionToken? + + init(session: any AudioSessionManaging, config: AudioSession.Configuration) { + self.session = session audioConfiguration = config } + isolated deinit { + if let token = token { + session.end(with: token) + } + } + func play() {} + + func start(isPlaying: Bool) { + token = session.start(with: self, isPlaying: isPlaying) + } + + func didChangePlaying(_ isPlaying: Bool) { + session.user(self, didChangePlaying: isPlaying) + } } } diff --git a/Sources/Navigator/VisualNavigator.swift b/Sources/Navigator/VisualNavigator.swift index c66b960ad8..7f3ae74327 100644 --- a/Sources/Navigator/VisualNavigator.swift +++ b/Sources/Navigator/VisualNavigator.swift @@ -9,6 +9,7 @@ import ReadiumShared import UIKit /// A navigator rendering the publication visually on-screen. +@MainActor public protocol VisualNavigator: Navigator, InputObservable { /// Viewport view. var view: UIView! { get } diff --git a/Sources/Shared/Toolkit/Cancellable.swift b/Sources/Shared/Toolkit/Cancellable.swift deleted file mode 100644 index a59090fc14..0000000000 --- a/Sources/Shared/Toolkit/Cancellable.swift +++ /dev/null @@ -1,83 +0,0 @@ -// -// Copyright 2026 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 - -/// A protocol indicating that an activity or action supports cancellation. -public protocol Cancellable { - /// Cancel the on-going activity. - func cancel() -} - -/// A `Cancellable` object saving its cancelled state. -public final class CancellableObject: Cancellable { - public private(set) var isCancelled = false - private let onCancel: () -> Void - - public init(onCancel: @escaping () -> Void = {}) { - self.onCancel = onCancel - } - - public func cancel() { - guard !isCancelled else { - return - } - - isCancelled = true - onCancel() - } -} - -extension DispatchQueue { - func async(unlessCancelled cancellable: CancellableObject, execute work: @escaping () -> Void) { - async { - guard !cancellable.isCancelled else { - return - } - work() - } - } -} - -/// A `Cancellable` acting as a proxy to underlying cancellables. The owner can switch the currently active cancellable -/// with `mediate()`. -/// -/// In practice, this is useful when a task needs to return a single `Cancellable`, but might spawn multiple subtasks. -public final class MediatorCancellable: Cancellable { - private var cancellable: Cancellable? - public private(set) var isCancelled = false - - public init(cancellable: Cancellable? = nil) { - self.cancellable = cancellable - } - - /// Switches the currently active cancellable which will receive the `cancel()` requests. - public func mediate(_ cancellable: Cancellable) { - if isCancelled { - cancellable.cancel() - } else { - self.cancellable = cancellable - } - } - - public func cancel() { - isCancelled = true - cancellable?.cancel() - cancellable = nil - } -} - -public extension Cancellable { - /// Convenience to mediate a cancellable in a call chain. - /// - /// ``` - /// apiReturningACancellable() - /// .mediate(by: mediator) - /// ``` - func mediated(by mediator: MediatorCancellable) { - mediator.mediate(self) - } -} diff --git a/Sources/Shared/Toolkit/ControlFlow.swift b/Sources/Shared/Toolkit/ControlFlow.swift index 3f951cb9e8..62bb6dc2c5 100644 --- a/Sources/Shared/Toolkit/ControlFlow.swift +++ b/Sources/Shared/Toolkit/ControlFlow.swift @@ -8,51 +8,31 @@ import Foundation // A collection of tools to manage the Flow of Control. -/// Throttles the given `block` so that it is executed in `duration` seconds, ignoring additional -/// calls until then. -public func throttle(duration: TimeInterval = 0, on queue: DispatchQueue = .main, _ block: @escaping () -> Void) -> () -> Void { - var throttling = false - return { - guard !throttling else { - return - } - throttling = true - - queue.asyncAfter(deadline: .now() + duration) { - throttling = false - block() - } - } +@MainActor +private final class ThrottlerState: Sendable { + var isThrottling = false } -/// Executes the given `block` if `condition` is true. Otherwise, retries every `pollingInterval` -/// seconds until `condition` gets true. -/// -/// Additional calls are ignored while polling the condition. -public func execute( - when condition: @escaping () -> Bool, - pollingInterval: TimeInterval = 0, - on queue: DispatchQueue = .main, - _ block: @escaping () async -> Void -) -> () -> Void { - var polling = false +/// Throttles the given `block` so that it is executed in `duration` seconds, ignoring additional +/// calls until then. +@MainActor +public func throttle( + duration: TimeInterval = 0, + _ block: @escaping @Sendable @MainActor () -> Void +) -> @Sendable @MainActor () -> Void { + let state = ThrottlerState() return { - guard !polling else { - return - } + guard !state.isThrottling else { return } + state.isThrottling = true - func poll() { - guard condition() else { - polling = true - queue.asyncAfter(deadline: .now() + pollingInterval, execute: poll) + Task { @MainActor in + defer { state.isThrottling = false } + do { + try await Task.sleep(seconds: max(0, duration)) + } catch { return } - polling = false - Task { - await block() - } + block() } - - poll() } } diff --git a/Sources/Shared/Toolkit/Media/AudioSession.swift b/Sources/Shared/Toolkit/Media/AudioSession.swift index ca0689f495..7b9763c27f 100644 --- a/Sources/Shared/Toolkit/Media/AudioSession.swift +++ b/Sources/Shared/Toolkit/Media/AudioSession.swift @@ -9,6 +9,7 @@ import Foundation import UIKit /// An user of the `AudioSession`, for example a media player object. +@MainActor public protocol AudioSessionUser: AnyObject { /// Audio session configuration to use for this user. var audioConfiguration: AudioSession.Configuration { get } @@ -25,15 +26,29 @@ public extension AudioSessionUser { } /// Manages the app's audio session for Readium audio consumers. +@MainActor public protocol AudioSessionManaging { /// Starts a new audio session with the given `user`. - func start(with user: AudioSessionUser, isPlaying: Bool) + /// + /// The returned opaque token can be used to end the session for the same + /// user. + @discardableResult + func start(with user: any AudioSessionUser, isPlaying: Bool) -> AudioSessionToken /// Ends the current audio session. - func end(for user: AudioSessionUser) + func end(with token: AudioSessionToken) /// Indicates whether the `user` is playing. - func user(_ user: AudioSessionUser, didChangePlaying isPlaying: Bool) + func user(_ user: any AudioSessionUser, didChangePlaying isPlaying: Bool) +} + +/// Opaque token identifying an audio session user. +public struct AudioSessionToken: Sendable, Equatable { + public let id: ObjectIdentifier + + public init(id: ObjectIdentifier) { + self.id = id + } } /// Manages an activated `AVAudioSession`. @@ -73,9 +88,9 @@ public final class AudioSession: AudioSessionManaging, Sendable, Loggable { fileprivate struct User { let id: ObjectIdentifier - private(set) weak var user: AudioSessionUser? + private(set) weak var user: (any AudioSessionUser)? - init(_ user: AudioSessionUser) { + init(_ user: any AudioSessionUser) { id = ObjectIdentifier(user) self.user = user } @@ -85,32 +100,28 @@ public final class AudioSession: AudioSessionManaging, Sendable, Loggable { private var user: User? /// Starts a new audio session with the given `user`. - public nonisolated func start(with user: AudioSessionUser, isPlaying: Bool) { - Task { - await start(with: user, isPlaying: isPlaying) - } - } - - private func start(with user: AudioSessionUser, isPlaying: Bool) async { + @discardableResult + public func start(with user: any AudioSessionUser, isPlaying: Bool) -> AudioSessionToken { let id = ObjectIdentifier(user) + let token = AudioSessionToken(id: id) guard self.user?.id != id else { - return + return token } if let oldUser = self.user { end(forUserID: oldUser.id) } self.user = User(user) - self.isPlaying = false + self.isPlaying = isPlaying startSession(with: user.audioConfiguration) + return token } /// Ends the current audio session. - public nonisolated func end(for user: AudioSessionUser) { - let id = ObjectIdentifier(user) + public nonisolated func end(with token: AudioSessionToken) { Task { - await end(forUserID: id) + await end(forUserID: token.id) } } @@ -128,13 +139,7 @@ public final class AudioSession: AudioSessionManaging, Sendable, Loggable { /// Indicates whether the `user` is playing. private var isPlaying: Bool = false - public nonisolated func user(_ user: AudioSessionUser, didChangePlaying isPlaying: Bool) { - Task { - await self.user(user, didChangePlaying: isPlaying) - } - } - - private func user(_ user: AudioSessionUser, didChangePlaying isPlaying: Bool) async { + public func user(_ user: any AudioSessionUser, didChangePlaying isPlaying: Bool) { let id = ObjectIdentifier(user) guard self.user?.id == id, self.isPlaying != isPlaying else { return diff --git a/Sources/Shared/Toolkit/Media/NowPlayingInfo.swift b/Sources/Shared/Toolkit/Media/NowPlayingInfo.swift index 98f8dc341f..7ddc60e9a8 100644 --- a/Sources/Shared/Toolkit/Media/NowPlayingInfo.swift +++ b/Sources/Shared/Toolkit/Media/NowPlayingInfo.swift @@ -12,6 +12,7 @@ import UIKit /// /// Simply set the `playback` and `media` properties when needed, the calls will automatically be /// throttled to avoid updating the Now Playing screen too frequently. +@MainActor public final class NowPlayingInfo { public static let shared = NowPlayingInfo() @@ -95,8 +96,9 @@ public final class NowPlayingInfo { /// Updates the Now Playing screen, maximum once per second. private lazy var update = throttle(duration: 1) { [weak self] in + guard let self = self else { return } var info = [String: Any]() - if let self = self, let media = self.media { + if let media = self.media { info[MPMediaItemPropertyTitle] = media.title if let artist = media.artist { info[MPMediaItemPropertyArtist] = artist diff --git a/Sources/Shared/Toolkit/Poller.swift b/Sources/Shared/Toolkit/Poller.swift new file mode 100644 index 0000000000..68b689f08e --- /dev/null +++ b/Sources/Shared/Toolkit/Poller.swift @@ -0,0 +1,75 @@ +// +// Copyright 2026 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 + +private final class Poller: Sendable { + private let condition: @Sendable @MainActor () -> Bool + private let pollingInterval: TimeInterval + private let block: @Sendable @MainActor () async -> Void + @MainActor private var isPolling = false + + init( + condition: @escaping @Sendable @MainActor () -> Bool, + pollingInterval: TimeInterval, + block: @escaping @Sendable @MainActor () async -> Void + ) { + self.condition = condition + self.pollingInterval = pollingInterval + self.block = block + } + + @MainActor + func start() { + guard !isPolling else { return } + isPolling = true + poll() + } + + @MainActor + private func poll() { + guard condition() else { + Task { @MainActor in + let interval = max(0, pollingInterval) + if interval > 0 { + do { + try await Task.sleep(seconds: interval) + } catch { + isPolling = false + return + } + } else { + await Task.yield() + } + self.poll() + } + return + } + Task { @MainActor in + defer { isPolling = false } + await block() + } + } +} + +/// Executes the given `block` if `condition` is true. Otherwise, retries every `pollingInterval` +/// seconds until `condition` gets true. +/// +/// Additional calls are ignored while polling the condition. +public func execute( + when condition: @escaping @Sendable @MainActor () -> Bool, + pollingInterval: TimeInterval = 0, + _ block: @escaping @Sendable @MainActor () async -> Void +) -> @Sendable @MainActor () -> Void { + let poller = Poller( + condition: condition, + pollingInterval: pollingInterval, + block: block + ) + return { + poller.start() + } +} diff --git a/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift b/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift index cf8a090dbc..c09ef78558 100644 --- a/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift +++ b/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift @@ -10,6 +10,7 @@ import ReadiumNavigator import ReadiumShared import SwiftUI +@MainActor final class UserPreferencesViewModel< S: ConfigurableSettings, P: ConfigurablePreferences, @@ -37,14 +38,22 @@ final class UserPreferencesViewModel< .receive(on: DispatchQueue.main) preferences - .compactMap { configurable.editor(of: $0) } + .compactMap { prefs in + MainActor.assumeIsolated { + configurable.editor(of: prefs) + } + } .assign(to: &$editor) preferences // First one is dropped to avoid refreshing the navigator when // opening the user preferences screen. .dropFirst() - .sink { configurable.submitPreferences($0) } + .sink { prefs in + MainActor.assumeIsolated { + configurable.submitPreferences(prefs) + } + } .store(in: &subscriptions) } diff --git a/TestApp/Sources/Reader/Common/TTS/TTSViewModel.swift b/TestApp/Sources/Reader/Common/TTS/TTSViewModel.swift index e8da6f9a3e..ef44cae87a 100644 --- a/TestApp/Sources/Reader/Common/TTS/TTSViewModel.swift +++ b/TestApp/Sources/Reader/Common/TTS/TTSViewModel.swift @@ -10,6 +10,7 @@ import MediaPlayer import ReadiumNavigator import ReadiumShared +@MainActor final class TTSViewModel: ObservableObject, Loggable { struct State: Equatable { /// Whether the TTS was enabled by the user. @@ -26,6 +27,7 @@ final class TTSViewModel: ObservableObject, Loggable { /// Voices supported by the synthesizer, for the selected language. let availableVoiceIds: [String] + @MainActor init(synthesizer: PublicationSpeechSynthesizer) { let voicesByLanguage: [Language: [TTSVoice]] = Dictionary(grouping: synthesizer.availableVoices, by: \.language) From b1bce763daf4e73cf3739312aa06c5f444cb3ce4 Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:37:11 -0500 Subject: [PATCH 16/39] Fix HTTP streaming in Swift 6 (#819) --- .github/workflows/checks.yml | 8 +- .../Toolkit/HTTP/DefaultHTTPClient.swift | 49 +------ Sources/Shared/Toolkit/HTTP/HTTPClient.swift | 47 ++++-- .../Toolkit/HTTP/HTTPContentByteRange.swift | 69 +++++++++ .../Shared/Toolkit/HTTP/HTTPResource.swift | 44 ++++-- .../HTTP/HTTPContentByteRangeTests.swift | 43 ++++++ .../Toolkit/HTTP/HTTPResourceTests.swift | 138 +++++++++++++++++- .../Toolkit/HTTP/HTTPResponseTests.swift | 39 ++++- 8 files changed, 361 insertions(+), 76 deletions(-) create mode 100644 Sources/Shared/Toolkit/HTTP/HTTPContentByteRange.swift create mode 100644 Tests/SharedTests/Toolkit/HTTP/HTTPContentByteRangeTests.swift diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index a59c5954e6..227c2903a8 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -132,7 +132,9 @@ jobs: defaults: run: working-directory: TestApp - environment: LCP + environment: + name: LCP + deployment: false steps: - name: Checkout uses: actions/checkout@v3 @@ -156,7 +158,9 @@ jobs: defaults: run: working-directory: TestApp - environment: LCP + environment: + name: LCP + deployment: false steps: - name: Checkout uses: actions/checkout@v3 diff --git a/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift b/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift index d26d68a4bb..7c84baa2a2 100644 --- a/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift +++ b/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift @@ -272,7 +272,7 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { delegate?.httpClient(self, request: request, didReceiveResponse: httpResponse) if !httpResponse.status.isSuccess { - let body = try await collectErrorBody(from: stream, task: task, response: httpResponse) + let body = try await collectErrorBody(from: stream, task: task) return .failure(.errorResponse(makeErrorResponse(httpResponse: httpResponse, body: body))) } @@ -290,8 +290,8 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { } } - let expectedBytes = httpResponse.fullContentLength - var readBytes: Int64 = httpResponse.contentRangeOffset + let expectedBytes = httpResponse.resourceLength + var readBytes: Int64 = httpResponse.contentByteRange?.range?.lowerBound ?? 0 for try await chunk in stream { try Task.checkCancellation() @@ -318,24 +318,20 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { } private let maxErrorBodySize = 1024 * 1024 - private let defaultErrorBodySize = 1024 private func collectErrorBody( from stream: AsyncThrowingStream, - task: URLSessionDataTask, - response: HTTPResponse + task: URLSessionDataTask ) async throws -> Data { - let capacity = min(maxErrorBodySize, Int(response.fullContentLength ?? Int64(defaultErrorBodySize))) var data = Data() for try await chunk in stream { - if data.count < capacity { - data.append(chunk) - } else { + data.append(chunk) + if data.count >= maxErrorBodySize { task.cancel() break } } - return data.prefix(capacity) + return data.prefix(maxErrorBodySize) } private func makeURLRequest(_ request: HTTPRequest) -> URLRequest { @@ -540,34 +536,3 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { } } } - -private extension HTTPResponse { - /// The full expected content length for this resource, when known. - /// - /// This will be the total length of the resource, even for byte range requests. - /// Handles headers like `bytes 0-100/1000` and `bytes */1000`. - var fullContentLength: Int64? { - guard - let contentRange = valueForHeader("Content-Range"), - let totalLengthString = contentRange.split(separator: "/").last?.trimmingCharacters(in: .whitespaces), - let totalLength = Int64(totalLengthString) - else { - return contentLength - } - return totalLength - } - - /// Offset of the current response in the full resource. - /// Handles headers like `bytes 0-100/1000`. Returns 0 if the range is unknown (e.g., `bytes */1000`). - var contentRangeOffset: Int64 { - guard - let contentRange = valueForHeader("Content-Range"), - let rangeString = contentRange.split(separator: " ", maxSplits: 1).last, - let rangeStartString = rangeString.split(separator: "-").first?.trimmingCharacters(in: .whitespaces), - let rangeStart = Int64(rangeStartString) - else { - return 0 - } - return rangeStart - } -} diff --git a/Sources/Shared/Toolkit/HTTP/HTTPClient.swift b/Sources/Shared/Toolkit/HTTP/HTTPClient.swift index a88d527a22..0db0f4d1bb 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPClient.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPClient.swift @@ -23,8 +23,9 @@ public protocol HTTPClient: Loggable, Sendable { /// - consume: Callback called for each chunk of data received. Callers /// are responsible to accumulate the data if needed. Return an error /// to abort the request. The `progress` parameter represents the - /// overall resource progress (including any `contentRangeOffset` for - /// range requests), not just the progress of the current chunk. + /// overall resource progress (including any byte-range offset from the + /// `Content-Range` header for range requests), not just the progress + /// of the current chunk. /// Important: `consume` is always called serially. Implementations must /// never invoke it concurrently. func stream( @@ -42,8 +43,9 @@ public extension HTTPClient { /// - consume: Callback called for each chunk of data received. Callers /// are responsible to accumulate the data if needed. Return an error /// to abort the request. The `progress` parameter represents the - /// overall resource progress (including any `contentRangeOffset` for - /// range requests), not just the progress of the current chunk. + /// overall resource progress (including any byte-range offset from the + /// `Content-Range` header for range requests), not just the progress + /// of the current chunk. /// Important: `consume` is always called serially. Implementations must /// never invoke it concurrently. func stream( @@ -314,22 +316,43 @@ public extension HTTPHeadersProviding { return nil } - /// Indicates whether this server supports byte range requests. - var acceptsByteRanges: Bool { - valueForHeader("Accept-Ranges")?.lowercased() == "bytes" - || valueForHeader("Content-Range")?.lowercased().hasPrefix("bytes") == true - } - /// The expected content length for this response, when known. /// - /// Warning: For byte range requests, this will be the length of the current chunk, - /// not the whole resource. + /// - Warning: For byte range requests, this will be the length of the + /// current chunk, not the whole resource. Use `resourceLength` + /// instead. var contentLength: Int64? { valueForHeader("Content-Length") .flatMap { Int64($0) } .takeIf { $0 >= 0 } } + /// The length of the full resource, when known. + /// + /// For byte range requests this reads the size from the `Content-Range` + /// header (e.g. `bytes 0-99/1000` → 1000). Falls back to `Content-Length` + /// only when no `Content-Range` header is present (i.e. a full response). + /// Returns `nil` when the total size cannot be determined. + var resourceLength: Int64? { + if let byteRange = contentByteRange { + return byteRange.size + } + return contentLength + } + + /// Indicates whether this server supports byte range requests. + var acceptsByteRanges: Bool { + valueForHeader("Accept-Ranges")?.lowercased() == "bytes" + || valueForHeader("Content-Range")?.lowercased().hasPrefix("bytes") == true + } + + /// Parsed `Content-Range` header for this response, or `nil` if the header + /// is absent or malformed. + var contentByteRange: HTTPContentByteRange? { + valueForHeader("Content-Range") + .flatMap { HTTPContentByteRange(header: $0) } + } + /// The resource filename as provided by the server in the `Content-Disposition` header. var filename: String? { guard let disposition = valueForHeader("Content-Disposition") else { diff --git a/Sources/Shared/Toolkit/HTTP/HTTPContentByteRange.swift b/Sources/Shared/Toolkit/HTTP/HTTPContentByteRange.swift new file mode 100644 index 0000000000..2cfadd08bd --- /dev/null +++ b/Sources/Shared/Toolkit/HTTP/HTTPContentByteRange.swift @@ -0,0 +1,69 @@ +// +// Copyright 2026 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 + +/// Parsed representation of an HTTP `Content-Range` response header. +public struct HTTPContentByteRange: Equatable, Sendable { + /// Inclusive byte range of the response body within the full resource. + /// + /// `nil` when the range portion of the header is `*` (used in 416 + /// Range Not Satisfiable responses). + public let range: ClosedRange? + + /// Total size of the full resource in bytes. + /// + /// `nil` when the size portion of the header is `*` (total size unknown). + public let size: Int64? + + public init(range: ClosedRange?, size: Int64?) { + self.range = range + self.size = size + } + + /// Parsed `Content-Range` header for this response, or `nil` if the header + /// is incorrect. + /// + /// Covers all three spec-defined formats: + /// - `bytes 0-100/1000` → `range: 0...100, size: 1000` + /// - `bytes 0-100/*` → `range: 0...100, size: nil` + /// - `bytes */1000` → `range: nil, size: 1000` + public init?(header: String) { + guard header.hasPrefix("bytes ") else { + return nil + } + + let parts = header.dropFirst("bytes ".count) + .split(separator: "/", maxSplits: 1) + .map { $0.trimmingCharacters(in: .whitespaces) } + guard parts.count == 2 else { return nil } + + let rangeString = parts[0] + let sizeString = parts[1] + + let size: Int64? = sizeString == "*" ? nil : Int64(sizeString) + if let size, size < 0 { + return nil + } + + guard rangeString != "*" else { + self.init(range: nil, size: size) + return + } + + let rangeParts = rangeString.split(separator: "-", maxSplits: 1) + .map { $0.trimmingCharacters(in: .whitespaces) } + guard + rangeParts.count == 2, + let start = Int64(rangeParts[0]), start >= 0, + let end = Int64(rangeParts[1]), end >= start + else { + return nil + } + + self.init(range: start ... end, size: size) + } +} diff --git a/Sources/Shared/Toolkit/HTTP/HTTPResource.swift b/Sources/Shared/Toolkit/HTTP/HTTPResource.swift index a6dab664de..17f8f70479 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPResource.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPResource.swift @@ -34,8 +34,8 @@ public actor HTTPResource: Resource { } public func estimatedLength() async -> ReadResult { - await headResponse().flatMap { - if let length = $0?.contentLength { + await headResponse().flatMap { response in + if let length = response?.resourceLength { return .success(UInt64(length)) } else { return .success(nil) @@ -51,27 +51,39 @@ public actor HTTPResource: Resource { /// Cached HEAD response to get the expected content length and other /// metadata. /// - /// For compatibility reason, we start a byte range request of 2 bytes and - /// interrupt it right away. + /// To ensure compatibility with servers that do not support HEAD requests, + /// we fall back on a 2-byte range request and interrupt it immediately. private func headResponse() async -> ReadResult { if _headResponse == nil { - var request = HTTPRequest(url: url) - request.setRange(0 ..< 2) - - let result = await client.stream( - request, + let headRequest = HTTPRequest(url: url, method: .head) + let _ = await client.stream( + headRequest, onReceiveResponse: { response in await self.setHeadResponse(.success(response)) - return .failure(.cancelled) + return .success(()) }, - consume: { _, _ in .failure(.cancelled) } + consume: { _, _ in .success(()) } ) - if _headResponse == nil, case let .failure(error) = result { - if let error: ReadError = .wrap(error) { - _headResponse = .failure(error) - } else { - _headResponse = .success(nil) + if _headResponse == nil { + var rangeRequest = HTTPRequest(url: url) + rangeRequest.setRange(0 ..< 2) + + let rangeResult = await client.stream( + rangeRequest, + onReceiveResponse: { response in + await self.setHeadResponse(.success(response)) + return .failure(.cancelled) + }, + consume: { _, _ in .failure(.cancelled) } + ) + + if _headResponse == nil, case let .failure(error) = rangeResult { + if let error: ReadError = .wrap(error) { + _headResponse = .failure(error) + } else { + _headResponse = .success(nil) + } } } } diff --git a/Tests/SharedTests/Toolkit/HTTP/HTTPContentByteRangeTests.swift b/Tests/SharedTests/Toolkit/HTTP/HTTPContentByteRangeTests.swift new file mode 100644 index 0000000000..ff4166430c --- /dev/null +++ b/Tests/SharedTests/Toolkit/HTTP/HTTPContentByteRangeTests.swift @@ -0,0 +1,43 @@ +// +// Copyright 2026 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 +@testable import ReadiumShared +import Testing + +struct HTTPContentByteRangeTests { + @Test func normalRange() { + #expect(HTTPContentByteRange(header: "bytes 0-100/1000") == HTTPContentByteRange(range: 0 ... 100, size: 1000)) + } + + @Test func unknownSize() { + #expect(HTTPContentByteRange(header: "bytes 0-100/*") == HTTPContentByteRange(range: 0 ... 100, size: nil)) + } + + @Test func unknownRange() { + #expect(HTTPContentByteRange(header: "bytes */1000") == HTTPContentByteRange(range: nil, size: 1000)) + } + + @Test func zeroSize() { + #expect(HTTPContentByteRange(header: "bytes */0") == HTTPContentByteRange(range: nil, size: 0)) + } + + @Test func rejectsNegativeSize() { + #expect(HTTPContentByteRange(header: "bytes 0-100/-5") == nil) + } + + @Test func rejectsNonBytesUnit() { + #expect(HTTPContentByteRange(header: "tokens 0-100/1000") == nil) + } + + @Test func rejectsInvertedRange() { + #expect(HTTPContentByteRange(header: "bytes 100-0/1000") == nil) + } + + @Test func toleratesExtraWhitespace() { + #expect(HTTPContentByteRange(header: "bytes 5 - 50 / 200 ") == HTTPContentByteRange(range: 5 ... 50, size: 200)) + } +} diff --git a/Tests/SharedTests/Toolkit/HTTP/HTTPResourceTests.swift b/Tests/SharedTests/Toolkit/HTTP/HTTPResourceTests.swift index 55c7ac2025..8cbe14dfa7 100644 --- a/Tests/SharedTests/Toolkit/HTTP/HTTPResourceTests.swift +++ b/Tests/SharedTests/Toolkit/HTTP/HTTPResourceTests.swift @@ -57,9 +57,9 @@ struct HTTPResourceTests { let client = MockHTTPClient() let resource = HTTPResource(url: url, client: client) - client.fetchResults["GET \(url.string)"] = .success(.init( + client.fetchResults["HEAD \(url.string)"] = .success(.init( response: HTTPResponse( - request: HTTPRequest(url: url), + request: HTTPRequest(url: url, method: .head), url: url, status: .ok, headers: ["Content-Length": "1024"], @@ -82,17 +82,89 @@ struct HTTPResourceTests { let resource = HTTPResource(url: url, client: client) let response = HTTPErrorResponse(status: .methodNotAllowed) + client.fetchResults["HEAD \(url.string)"] = .failure(.errorResponse(response)) client.fetchResults["GET \(url.string)"] = .failure(.errorResponse(response)) let length = await resource.estimatedLength() try #expect(length.get() == nil) - #expect(client.fetchCount == 1) + #expect(client.fetchCount == 2) + } + + @Test func headResponseFallbackToRangeRequestSucceeds() async throws { + let client = MockHTTPClient() + let resource = HTTPResource(url: url, client: client) + + client.fetchResults["HEAD \(url.string)"] = .failure(.errorResponse(HTTPErrorResponse(status: .methodNotAllowed))) + client.fetchResults["GET \(url.string)"] = .success(.init( + response: HTTPResponse( + request: HTTPRequest(url: url), + url: url, + status: .partialContent, + headers: ["Content-Range": "bytes 0-1/512"], + mediaType: .epub + ), + body: Data() + )) + + let length = await resource.estimatedLength() + try #expect(length.get() == 512) + #expect(client.fetchCount == 2) + } + + @Test func propertiesMediaTypeFromHeadResponse() async throws { + let client = MockHTTPClient() + let resource = HTTPResource(url: url, client: client) + + client.fetchResults["HEAD \(url.string)"] = .success(.init( + response: HTTPResponse( + request: HTTPRequest(url: url, method: .head), + url: url, + status: .ok, + headers: [:], + mediaType: .epub + ), + body: Data() + )) + + let props = try await resource.properties().get() + #expect(props.mediaType == .epub) + #expect(props.filename == "book.epub") + } + + @Test func propertiesFilenameFromContentDisposition() async throws { + let client = MockHTTPClient() + let resource = HTTPResource(url: url, client: client) + + client.fetchResults["HEAD \(url.string)"] = .success(.init( + response: HTTPResponse( + request: HTTPRequest(url: url, method: .head), + url: url, + status: .ok, + headers: ["Content-Disposition": "attachment; filename=\"moby-dick.epub\""], + mediaType: .epub + ), + body: Data() + )) + + let props = try await resource.properties().get() + #expect(props.filename == "moby-dick.epub") } @Test func streamWithRange() async throws { let client = MockHTTPClient() let resource = HTTPResource(url: url, client: client) + client.fetchResults["HEAD \(url.string)"] = .success(.init( + response: HTTPResponse( + request: HTTPRequest(url: url, method: .head), + url: url, + status: .ok, + headers: ["Content-Length": "100"], + mediaType: .epub + ), + body: Data() + )) + client.fetchResults["GET \(url.string)"] = try .success(.init( response: HTTPResponse( request: HTTPRequest(url: url), @@ -110,4 +182,64 @@ struct HTTPResourceTests { try result.get() #expect(streamedData.value == "0123456789".data(using: .utf8)) } + + @Test func estimatedLengthFromContentRange() async throws { + let client = MockHTTPClient() + let resource = HTTPResource(url: url, client: client) + + client.fetchResults["HEAD \(url.string)"] = .success(.init( + response: HTTPResponse( + request: HTTPRequest(url: url, method: .head), + url: url, + status: .partialContent, + headers: ["Content-Range": "bytes 0-1/1000"], + mediaType: .epub + ), + body: Data() + )) + + let length = await resource.estimatedLength() + try #expect(length.get() == 1000) + } + + @Test func estimatedLengthUnknownWhenContentRangeSizeIsWildcard() async throws { + let client = MockHTTPClient() + let resource = HTTPResource(url: url, client: client) + + client.fetchResults["HEAD \(url.string)"] = .success(.init( + response: HTTPResponse( + request: HTTPRequest(url: url, method: .head), + url: url, + status: .partialContent, + headers: [ + "Content-Range": "bytes 0-1/*", + "Content-Length": "2", + ], + mediaType: .epub + ), + body: Data() + )) + + let length = await resource.estimatedLength() + try #expect(length.get() == nil) + } + + @Test func estimatedLengthFromContentLength() async throws { + let client = MockHTTPClient() + let resource = HTTPResource(url: url, client: client) + + client.fetchResults["HEAD \(url.string)"] = .success(.init( + response: HTTPResponse( + request: HTTPRequest(url: url, method: .head), + url: url, + status: .ok, + headers: ["Content-Length": "2048"], + mediaType: .epub + ), + body: Data() + )) + + let length = await resource.estimatedLength() + try #expect(length.get() == 2048) + } } diff --git a/Tests/SharedTests/Toolkit/HTTP/HTTPResponseTests.swift b/Tests/SharedTests/Toolkit/HTTP/HTTPResponseTests.swift index aff97b0153..aa28f81b74 100644 --- a/Tests/SharedTests/Toolkit/HTTP/HTTPResponseTests.swift +++ b/Tests/SharedTests/Toolkit/HTTP/HTTPResponseTests.swift @@ -8,7 +8,6 @@ import Foundation @testable import ReadiumShared import Testing -@Suite("HTTPResponse") struct HTTPResponseTests { private let request = HTTPRequest(url: HTTPURL(string: "http://example.com")!) private let url = HTTPURL(string: "http://example.com")! @@ -47,6 +46,44 @@ struct HTTPResponseTests { #expect(responseInvalid.contentLength == nil) } + @Test func resourceLength() { + func response(headers: [String: String]) -> HTTPResponse { + HTTPResponse(request: request, url: url, status: .ok, headers: headers, mediaType: nil) + } + + // No headers: unknown length. + #expect(response(headers: [:]).resourceLength == nil) + + // Full response: Content-Length is the resource length. + #expect(response(headers: ["Content-Length": "1000"]).resourceLength == 1000) + + // Partial response with known total: Content-Range wins over Content-Length. + #expect(response(headers: [ + "Content-Range": "bytes 0-99/1000", + "Content-Length": "100", + ]).resourceLength == 1000) + + // Partial response with unknown total (bytes 0-99/*): returns nil. + #expect(response(headers: [ + "Content-Range": "bytes 0-99/*", + "Content-Length": "100", + ]).resourceLength == nil) + } + + @Test func contentByteRange() { + func response(headers: [String: String]) -> HTTPResponse { + HTTPResponse(request: request, url: url, status: .partialContent, headers: headers, mediaType: nil) + } + + // No range. + var r = response(headers: [:]) + #expect(r.contentByteRange == nil) + + // Actual range: bytes -/ + r = response(headers: ["Content-Range": "bytes 0-100/1000"]) + #expect(r.contentByteRange == HTTPContentByteRange(range: 0 ... 100, size: 1000)) + } + @Test func filename() { var response = HTTPResponse(request: request, url: url, status: .ok, headers: ["Content-Disposition": "attachment; filename=book.epub"], mediaType: nil) #expect(response.filename == "book.epub") From 1393611b3927e45d0316dd5323a5174911b1c071 Mon Sep 17 00:00:00 2001 From: Grigor Hakobyan Date: Wed, 24 Jun 2026 13:59:06 +0400 Subject: [PATCH 17/39] Migrate `ReadiumShared` to Swift 6 (#820) --- Package.swift | 18 ++++- Sources/Internal/Extensions/Result.swift | 10 +-- .../LCPContentProtection.swift | 4 +- .../LCP/Content Protection/LCPDecryptor.swift | 2 +- Sources/LCP/License/LicenseValidation.swift | 2 +- Sources/Shared/Logger/LoggerStub.swift | 2 +- Sources/Shared/OPDS/OPDSAcquisition.swift | 2 +- Sources/Shared/OPDS/OPDSAvailability.swift | 2 +- Sources/Shared/OPDS/OPDSCopies.swift | 2 +- Sources/Shared/OPDS/OPDSHolds.swift | 2 +- Sources/Shared/OPDS/OPDSPrice.swift | 2 +- .../Accessibility/Accessibility.swift | 8 +-- .../AccessibilityMetadataDisplayGuide.swift | 24 +++---- .../Extensions/Encryption/Encryption.swift | 2 +- .../Extensions/HTML/DOMRange.swift | 4 +- Sources/Shared/Publication/LinkRelation.swift | 2 +- Sources/Shared/Publication/Locator.swift | 10 +-- Sources/Shared/Publication/Metadata.swift | 2 +- Sources/Shared/Publication/Properties.swift | 2 +- Sources/Shared/Publication/Publication.swift | 6 +- .../Services/Content/Content.swift | 16 ++--- .../HTMLResourceContentIterator.swift | 8 +-- .../PDFResourceContentIterator.swift | 23 ++++--- .../PublicationContentIterator.swift | 2 +- .../Locator/DefaultLocatorService.swift | 10 +-- .../Positions/InMemoryPositionsService.swift | 2 +- .../PerResourcePositionsService.swift | 2 +- .../Services/PublicationService.swift | 4 +- .../Search/ContentSearchService.swift | 4 +- .../Services/Search/SearchService.swift | 8 +-- .../Search/StringSearchAlgorithm.swift | 2 +- .../Services/Search/StringSearchService.swift | 2 +- .../Toolkit/Archive/ArchiveOpener.swift | 2 +- .../Toolkit/Archive/ArchiveProperties.swift | 2 +- .../Archive/CompositeArchiveOpener.swift | 2 +- .../Archive/DefaultArchiveOpener.swift | 14 +++- Sources/Shared/Toolkit/Data/Asset/Asset.swift | 2 +- .../Toolkit/Data/Asset/AssetRetriever.swift | 2 +- .../Data/Resource/FailureResource.swift | 2 +- .../Resource/ResourceContentExtractor.swift | 6 +- .../Data/Resource/ResourceFactory.swift | 14 ++-- .../Data/Resource/TailCachingResource.swift | 68 ++++++++++--------- .../Data/Resource/TransformingResource.swift | 2 +- Sources/Shared/Toolkit/Data/Streamable.swift | 2 +- Sources/Shared/Toolkit/DocumentTypes.swift | 2 +- Sources/Shared/Toolkit/FileExtension.swift | 2 +- .../Shared/Toolkit/Format/FormatSniffer.swift | 4 +- Sources/Shared/Toolkit/Format/MediaType.swift | 2 +- .../Sniffers/CompositeFormatSniffer.swift | 2 +- .../Sniffers/DefaultFormatSniffer.swift | 18 ++++- Sources/Shared/Toolkit/JSONValue.swift | 2 +- .../Toolkit/Logging/WarningLogger.swift | 12 ++-- Sources/Shared/Toolkit/PDF/CGPDF.swift | 2 +- .../{ControlFlow.swift => Throttle.swift} | 2 - .../Toolkit/URL/Absolute URL/FileURL.swift | 2 +- .../Toolkit/URL/Absolute URL/HTTPURL.swift | 2 +- Sources/Shared/Toolkit/URL/AnyURL.swift | 2 +- Sources/Shared/Toolkit/URL/RelativeURL.swift | 2 +- .../Shared/Toolkit/URL/URLConvertible.swift | 2 +- Sources/Shared/Toolkit/XML/Fuzi.swift | 9 +-- Sources/Shared/Toolkit/XML/XML.swift | 4 +- .../Shared/Toolkit/ZIP/ZIPArchiveOpener.swift | 14 +++- .../Audio/Services/AudioLocatorService.swift | 65 +++++++++++++----- Tests/NavigatorTests/Asserts.swift | 2 +- Tests/SharedTests/Asserts.swift | 4 +- Tests/SharedTests/Fixtures.swift | 2 +- .../Cover/GeneratedCoverServiceTests.swift | 4 +- .../PublicationServicesBuilderTests.swift | 2 +- .../Resource/BufferingResourceTests.swift | 2 +- .../Resource/TailCachingResourceTests.swift | 2 +- .../Search/SearchServiceTests.swift | 4 +- 71 files changed, 292 insertions(+), 189 deletions(-) rename Sources/Shared/Toolkit/{ControlFlow.swift => Throttle.swift} (94%) diff --git a/Package.swift b/Package.swift index 2fd9380051..d4a4ed2be3 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.10 +// swift-tools-version:6.0 // // Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license @@ -192,3 +192,19 @@ let package = Package( ), ] ) + +// FIXME: Remove this once the Swift 6 migration is done. +let swift6EnabledTargets: Set = [ + "ReadiumShared", + "ReadiumSharedTests", +] + +for target in package.targets { + var swiftSettings = target.swiftSettings ?? [] + if swift6EnabledTargets.contains(target.name) { + swiftSettings.append(.swiftLanguageMode(.v6)) + } else { + swiftSettings.append(.swiftLanguageMode(.v5)) + } + target.swiftSettings = swiftSettings +} diff --git a/Sources/Internal/Extensions/Result.swift b/Sources/Internal/Extensions/Result.swift index 9177e23dac..35f409d38c 100644 --- a/Sources/Internal/Extensions/Result.swift +++ b/Sources/Internal/Extensions/Result.swift @@ -28,7 +28,7 @@ public extension Result { /// Asynchronous variant of `map`. @inlinable func asyncMap( - _ transform: (Success) async throws -> NewSuccess + _ transform: @Sendable (Success) async throws -> NewSuccess ) async rethrows -> Result { switch self { case let .success(success): @@ -40,7 +40,7 @@ public extension Result { /// Asynchronous variant of `flatMap`. @inlinable func asyncFlatMap( - _ transform: (Success) async throws -> Result + _ transform: @Sendable (Success) async throws -> Result ) async rethrows -> Result { switch self { case let .success(success): @@ -51,7 +51,7 @@ public extension Result { } @inlinable func asyncRecover( - _ catching: (Failure) async throws -> Self + _ catching: @Sendable (Failure) async throws -> Self ) async rethrows -> Self { switch self { case let .success(success): @@ -69,7 +69,9 @@ public extension Result { } public extension Result where Failure == Error { - func tryMap(_ transform: (Success) throws -> T) -> Result { + func tryMap( + _ transform: (Success) throws -> NewSuccess + ) -> Result { flatMap { do { return try .success(transform($0)) diff --git a/Sources/LCP/Content Protection/LCPContentProtection.swift b/Sources/LCP/Content Protection/LCPContentProtection.swift index 1f57f4a4f3..81ec918c83 100644 --- a/Sources/LCP/Content Protection/LCPContentProtection.swift +++ b/Sources/LCP/Content Protection/LCPContentProtection.swift @@ -134,7 +134,9 @@ final class LCPContentProtection: ContentProtection, Loggable { let decryptor = LCPDecryptor(license: license.getOrNil(), encryptionData: encryptionData) asset.container = asset.container - .map(transform: decryptor.decrypt(at:resource:)) + .map { @Sendable href, resource in + decryptor.decrypt(at: href, resource: resource) + } let cpAsset = ContentProtectionAsset( asset: .container(asset), diff --git a/Sources/LCP/Content Protection/LCPDecryptor.swift b/Sources/LCP/Content Protection/LCPDecryptor.swift index 25a2388d15..f6b1ac28a1 100644 --- a/Sources/LCP/Content Protection/LCPDecryptor.swift +++ b/Sources/LCP/Content Protection/LCPDecryptor.swift @@ -11,7 +11,7 @@ import ReadiumShared private let lcpScheme = "http://readium.org/2014/01/lcp" /// Decrypts a resource protected with LCP. -final class LCPDecryptor { +final class LCPDecryptor: Sendable { enum Error: Swift.Error { case emptyDecryptedData case invalidCBCData diff --git a/Sources/LCP/License/LicenseValidation.swift b/Sources/LCP/License/LicenseValidation.swift index a1079a563d..b4edd29315 100644 --- a/Sources/LCP/License/LicenseValidation.swift +++ b/Sources/LCP/License/LicenseValidation.swift @@ -26,7 +26,7 @@ struct ValidatedDocuments { /// /// Use `validate` to start the validation of a Document. /// Use `observe` to be notified when any validation is done or if an error occurs. -final actor LicenseValidation: Loggable { +actor LicenseValidation: Loggable { // Dependencies for the State's handlers fileprivate let client: LCPClient fileprivate let authentication: LCPAuthenticating? diff --git a/Sources/Shared/Logger/LoggerStub.swift b/Sources/Shared/Logger/LoggerStub.swift index 0bb1286eeb..4927840994 100644 --- a/Sources/Shared/Logger/LoggerStub.swift +++ b/Sources/Shared/Logger/LoggerStub.swift @@ -8,7 +8,7 @@ import Foundation /// A Logger implementation of the Loggable protocol. /// Used as default -public final class LoggerStub: LoggerType, Sendable { +public final class LoggerStub: LoggerType { public init() {} /// Log `message` with a severity of `level`. diff --git a/Sources/Shared/OPDS/OPDSAcquisition.swift b/Sources/Shared/OPDS/OPDSAcquisition.swift index cc8b074a42..1c4184aa5d 100644 --- a/Sources/Shared/OPDS/OPDSAcquisition.swift +++ b/Sources/Shared/OPDS/OPDSAcquisition.swift @@ -9,7 +9,7 @@ import ReadiumInternal /// OPDS Acquisition Object /// https://specs.opds.io/schema/acquisition-object.schema.json -public struct OPDSAcquisition: Equatable, JSONObjectEncodable, JSONValueDecodable, Sendable { +public struct OPDSAcquisition: Equatable, Sendable, JSONObjectEncodable, JSONValueDecodable { public var type: String public var children: [OPDSAcquisition] = [] diff --git a/Sources/Shared/OPDS/OPDSAvailability.swift b/Sources/Shared/OPDS/OPDSAvailability.swift index 40580d6eaa..0389abf566 100644 --- a/Sources/Shared/OPDS/OPDSAvailability.swift +++ b/Sources/Shared/OPDS/OPDSAvailability.swift @@ -9,7 +9,7 @@ import ReadiumInternal /// Indicated the availability of a given resource. /// https://specs.opds.io/schema/properties.schema.json -public struct OPDSAvailability: Equatable, JSONValueDecodable, JSONObjectEncodable, Sendable { +public struct OPDSAvailability: Equatable, Sendable, JSONValueDecodable, JSONObjectEncodable { public let state: State /// Timestamp for the previous state change. diff --git a/Sources/Shared/OPDS/OPDSCopies.swift b/Sources/Shared/OPDS/OPDSCopies.swift index 92b0afb9b2..5789508ac8 100644 --- a/Sources/Shared/OPDS/OPDSCopies.swift +++ b/Sources/Shared/OPDS/OPDSCopies.swift @@ -9,7 +9,7 @@ import ReadiumInternal /// Library-specific feature that contains information about the copies that a library has acquired. /// https://specs.opds.io/schema/properties.schema.json -public struct OPDSCopies: Equatable, JSONValueDecodable, JSONObjectEncodable, Sendable { +public struct OPDSCopies: Equatable, Sendable, JSONValueDecodable, JSONObjectEncodable { public let total: Int? public let available: Int? diff --git a/Sources/Shared/OPDS/OPDSHolds.swift b/Sources/Shared/OPDS/OPDSHolds.swift index 7769ddee4d..523eb044e5 100644 --- a/Sources/Shared/OPDS/OPDSHolds.swift +++ b/Sources/Shared/OPDS/OPDSHolds.swift @@ -9,7 +9,7 @@ import ReadiumInternal /// Library-specific features when a specific book is unavailable but provides a hold list. /// https://specs.opds.io/schema/properties.schema.json -public struct OPDSHolds: Equatable, JSONValueDecodable, JSONObjectEncodable, Sendable { +public struct OPDSHolds: Equatable, Sendable, JSONValueDecodable, JSONObjectEncodable { public let total: Int? public let position: Int? diff --git a/Sources/Shared/OPDS/OPDSPrice.swift b/Sources/Shared/OPDS/OPDSPrice.swift index 3937dbb4c6..23ae2b4483 100644 --- a/Sources/Shared/OPDS/OPDSPrice.swift +++ b/Sources/Shared/OPDS/OPDSPrice.swift @@ -9,7 +9,7 @@ import ReadiumInternal /// The price of a publication in an OPDS link. /// https://specs.opds.io/schema/properties.schema.json -public struct OPDSPrice: Equatable, JSONValueDecodable, JSONObjectEncodable, Sendable { +public struct OPDSPrice: Equatable, Sendable, JSONValueDecodable, JSONObjectEncodable { public var currency: String // eg. EUR /// Should only be used for display purposes, because of precision issues inherent with Double and the JSON parsing. diff --git a/Sources/Shared/Publication/Accessibility/Accessibility.swift b/Sources/Shared/Publication/Accessibility/Accessibility.swift index 9ff67cb0bf..390ddd3bb9 100644 --- a/Sources/Shared/Publication/Accessibility/Accessibility.swift +++ b/Sources/Shared/Publication/Accessibility/Accessibility.swift @@ -53,7 +53,7 @@ public struct Accessibility: Hashable, Sendable, JSONValueDecodable, JSONObjectE public var exemptions: [Exemption] /// Accessibility profile. - public struct Profile: Hashable, RawRepresentable, Sendable { + public struct Profile: Hashable, Sendable, RawRepresentable { public let uri: String public init(_ uri: String) { @@ -237,7 +237,7 @@ public struct Accessibility: Hashable, Sendable, JSONValueDecodable, JSONObjectE case visual } - public struct Feature: Hashable, RawRepresentable, Sendable { + public struct Feature: Hashable, Sendable, RawRepresentable { public let id: String public init(_ id: String) { @@ -483,7 +483,7 @@ public struct Accessibility: Hashable, Sendable, JSONValueDecodable, JSONObjectE } } - public struct Hazard: Hashable, RawRepresentable, Sendable { + public struct Hazard: Hashable, Sendable, RawRepresentable { public let id: String public init(_ id: String) { @@ -551,7 +551,7 @@ public struct Accessibility: Hashable, Sendable, JSONValueDecodable, JSONObjectE /// While this list is currently limited to exemptions covered by the /// European Accessibility Act, it will be extended to cover additional /// exemptions in the future. - public struct Exemption: Hashable, RawRepresentable, Sendable { + public struct Exemption: Hashable, Sendable, RawRepresentable { public let id: String public init(_ id: String) { diff --git a/Sources/Shared/Publication/Accessibility/AccessibilityMetadataDisplayGuide.swift b/Sources/Shared/Publication/Accessibility/AccessibilityMetadataDisplayGuide.swift index 2cde09253a..3da622872f 100644 --- a/Sources/Shared/Publication/Accessibility/AccessibilityMetadataDisplayGuide.swift +++ b/Sources/Shared/Publication/Accessibility/AccessibilityMetadataDisplayGuide.swift @@ -10,7 +10,7 @@ import ReadiumInternal /// When presenting accessibility metadata provided by the publisher, it is /// suggested that the section is introduced using terms such as "claims" or /// "declarations" (e.g., "Accessibility Claims"). -public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { +public struct AccessibilityMetadataDisplayGuide: Equatable, Sendable { /// The ways of reading display field is a banner heading that groups /// together the following information about how the content facilitates /// access. @@ -88,7 +88,7 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { /// access. /// /// https://w3c.github.io/publ-a11y/a11y-meta-display-guide/2.0/guidelines/#ways-of-reading - public struct WaysOfReading: AccessibilityDisplayField, Sendable { + public struct WaysOfReading: AccessibilityDisplayField { /// Indicates if users can modify the appearance of the text and the /// page layout according to the possibilities offered by the reading /// system. @@ -266,7 +266,7 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { /// Identifies the navigation features included in the publication. /// /// https://w3c.github.io/publ-a11y/a11y-meta-display-guide/2.0/guidelines/#navigation - public struct Navigation: AccessibilityDisplayField, Sendable { + public struct Navigation: AccessibilityDisplayField { /// Indicates whether no information about navigation features is /// available. public var noMetadata: Bool { @@ -348,7 +348,7 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { /// for prerecorded audio are available. /// /// https://w3c.github.io/publ-a11y/a11y-meta-display-guide/2.0/guidelines/#rich-content - public struct RichContent: AccessibilityDisplayField, Sendable { + public struct RichContent: AccessibilityDisplayField { /// Indicates whether no information about rich content is available. public var noMetadata: Bool { !extendedAltTextDescriptions && !mathFormula && !mathFormulaAsMathML && @@ -469,7 +469,7 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { /// better understand the accessibility characteristics of digital /// publications. These are for metadata that do not fit into the other /// categories or are rarely used in trade publishing. - public struct AdditionalInformation: AccessibilityDisplayField, Sendable { + public struct AdditionalInformation: AccessibilityDisplayField { /// No information is available. public var noMetadata: Bool { !pageBreakMarkers && !aria && !audioDescriptions && !braille && @@ -629,7 +629,7 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { /// when content is potentially dangerous to them. /// /// https://w3c.github.io/publ-a11y/a11y-meta-display-guide/2.0/guidelines/#hazards - public struct Hazards: AccessibilityDisplayField, Sendable { + public struct Hazards: AccessibilityDisplayField { public enum Hazard: Sendable { case yes case no @@ -780,7 +780,7 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { /// internationally recognized conformance standards for accessibility. /// /// https://w3c.github.io/publ-a11y/a11y-meta-display-guide/2.0/guidelines/#conformance-group - public struct Conformance: AccessibilityDisplayField, Sendable { + public struct Conformance: AccessibilityDisplayField { /// Accessibility conformance profiles. public var profiles: [Accessibility.Profile] @@ -838,7 +838,7 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { /// by legal counsel for each jurisdiction. /// /// https://w3c.github.io/publ-a11y/a11y-meta-display-guide/2.0/guidelines/#legal-considerations - public struct Legal: AccessibilityDisplayField, Sendable { + public struct Legal: AccessibilityDisplayField { /// No information is available. public var noMetadata: Bool { !exemption @@ -888,7 +888,7 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { /// duplicate, the other discoverability metadata. /// /// https://w3c.github.io/publ-a11y/a11y-meta-display-guide/2.0/guidelines/#accessibility-summary - public struct AccessibilitySummary: AccessibilityDisplayField, Sendable { + public struct AccessibilitySummary: AccessibilityDisplayField { public var summary: String? public let id: AccessibilityDisplayString = .accessibilitySummaryTitle @@ -927,7 +927,7 @@ public struct AccessibilityMetadataDisplayGuide: Sendable, Equatable { /// Represents a collection of related accessibility claims which should be /// displayed together in a section -public protocol AccessibilityDisplayField: Sendable, Equatable, Identifiable { +public protocol AccessibilityDisplayField: Equatable, Sendable, Identifiable { /// Unique identifier for this display field. var id: AccessibilityDisplayString { get } @@ -948,7 +948,7 @@ public protocol AccessibilityDisplayField: Sendable, Equatable, Identifiable { /// Represents a single accessibility claim, such as "Appearance can be /// modified". -public struct AccessibilityDisplayStatement: Sendable, Equatable, Identifiable { +public struct AccessibilityDisplayStatement: Equatable, Sendable, Identifiable { /// Display string identifying the statement. /// See https://w3c.github.io/publ-a11y/a11y-meta-display-guide/2.0/draft/localizations/ public let id: AccessibilityDisplayString @@ -997,7 +997,7 @@ public struct AccessibilityDisplayStatement: Sendable, Equatable, Identifiable { /// Localized display string. /// /// See https://w3c.github.io/publ-a11y/a11y-meta-display-guide/2.0/draft/localizations/ -public struct AccessibilityDisplayString: RawRepresentable, ExpressibleByStringLiteral, Sendable, Hashable { +public struct AccessibilityDisplayString: Hashable, Sendable, RawRepresentable, ExpressibleByStringLiteral { /// Special key for the provided summary, which is not localized. static let accessibilitySummary: Self = "readium.a11y.accessibility-summary" diff --git a/Sources/Shared/Publication/Extensions/Encryption/Encryption.swift b/Sources/Shared/Publication/Extensions/Encryption/Encryption.swift index 10ff4c75f6..02309be192 100644 --- a/Sources/Shared/Publication/Extensions/Encryption/Encryption.swift +++ b/Sources/Shared/Publication/Extensions/Encryption/Encryption.swift @@ -9,7 +9,7 @@ import ReadiumInternal /// Indicates that a resource is encrypted/obfuscated and provides relevant information for /// decryption. -public struct Encryption: Equatable, JSONValueDecodable, JSONObjectEncodable, Sendable { +public struct Encryption: Equatable, Sendable, JSONValueDecodable, JSONObjectEncodable { /// Identifies the algorithm used to encrypt the resource. public let algorithm: String // URI diff --git a/Sources/Shared/Publication/Extensions/HTML/DOMRange.swift b/Sources/Shared/Publication/Extensions/HTML/DOMRange.swift index 14a9492d0b..253642b91d 100644 --- a/Sources/Shared/Publication/Extensions/HTML/DOMRange.swift +++ b/Sources/Shared/Publication/Extensions/HTML/DOMRange.swift @@ -22,7 +22,7 @@ import ReadiumInternal /// represents a "collapsed" range that has identical `start` and `end` boundary points. /// /// https://github.com/readium/architecture/blob/master/models/locators/extensions/html.md#the-domrange-object -public struct DOMRange: Hashable, JSONValueDecodable, JSONObjectEncodable, Sendable { +public struct DOMRange: Hashable, Sendable, JSONValueDecodable, JSONObjectEncodable { /// A serializable representation of the "start" boundary point of the DOM Range. let start: Point @@ -71,7 +71,7 @@ public struct DOMRange: Hashable, JSONValueDecodable, JSONObjectEncodable, Senda /// node). /// /// https://github.com/readium/architecture/blob/master/models/locators/extensions/html.md#the-start-and-end-object - public struct Point: Hashable, JSONValueDecodable, JSONObjectEncodable, Sendable { + public struct Point: Hashable, Sendable, JSONValueDecodable, JSONObjectEncodable { let cssSelector: String let textNodeIndex: Int let charOffset: Int? diff --git a/Sources/Shared/Publication/LinkRelation.swift b/Sources/Shared/Publication/LinkRelation.swift index c58e75d7e8..985fd00175 100644 --- a/Sources/Shared/Publication/LinkRelation.swift +++ b/Sources/Shared/Publication/LinkRelation.swift @@ -8,7 +8,7 @@ import Foundation import ReadiumInternal /// Link relations as defined in https://readium.org/webpub-manifest/relationships.html -public struct LinkRelation: Sendable, Hashable, RawRepresentable, JSONValueEncodable { +public struct LinkRelation: Hashable, Sendable, RawRepresentable, JSONValueEncodable { public var rawValue: String /// The string representation of this link relation. diff --git a/Sources/Shared/Publication/Locator.swift b/Sources/Shared/Publication/Locator.swift index d6106496b5..c98596623b 100644 --- a/Sources/Shared/Publication/Locator.swift +++ b/Sources/Shared/Publication/Locator.swift @@ -8,7 +8,7 @@ import Foundation import ReadiumInternal /// https://github.com/readium/architecture/tree/master/locators -public struct Locator: Hashable, CustomStringConvertible, Loggable, Sendable, JSONValueDecodable, JSONObjectEncodable { +public struct Locator: Hashable, Sendable, CustomStringConvertible, Loggable, JSONValueDecodable, JSONObjectEncodable { /// The URI of the resource that the Locator Object points to. public var href: AnyURL @@ -134,7 +134,7 @@ public struct Locator: Hashable, CustomStringConvertible, Loggable, Sendable, JS /// /// Properties are mutable for convenience when making a copy, but the `locations` property /// is immutable in `Locator`, for safety. - public struct Locations: Hashable, Loggable, WarningLogger, Sendable, JSONValueDecodable, JSONObjectEncodable { + public struct Locations: Hashable, Sendable, Loggable, WarningLogger, JSONValueDecodable, JSONObjectEncodable { /// Contains one or more fragment in the resource referenced by the `Locator`. public var fragments: [String] /// Progression in the resource expressed as a percentage (between 0 and 1). @@ -196,7 +196,7 @@ public struct Locator: Hashable, CustomStringConvertible, Loggable, Sendable, JS } } - public struct Text: Hashable, Loggable, Sendable, JSONValueDecodable, JSONObjectEncodable { + public struct Text: Hashable, Sendable, Loggable, JSONValueDecodable, JSONObjectEncodable { public var after: String? public var before: String? public var highlight: String? @@ -275,7 +275,7 @@ public struct Locator: Hashable, CustomStringConvertible, Loggable, Sendable, JS /// Represents a sequential list of `Locator` objects. /// /// For example, a search result or a list of positions. -public struct LocatorCollection: Sendable, Hashable, JSONValueDecodable, JSONObjectEncodable { +public struct LocatorCollection: Hashable, Sendable, JSONValueDecodable, JSONObjectEncodable { public var metadata: Metadata public var links: [Link] public var locators: [Locator] @@ -310,7 +310,7 @@ public struct LocatorCollection: Sendable, Hashable, JSONValueDecodable, JSONObj } /// Holds the metadata of a `LocatorCollection`. - public struct Metadata: Sendable, Hashable, JSONValueDecodable, JSONObjectEncodable { + public struct Metadata: Hashable, Sendable, JSONValueDecodable, JSONObjectEncodable { public var localizedTitle: LocalizedString? public var title: String? { localizedTitle?.string diff --git a/Sources/Shared/Publication/Metadata.swift b/Sources/Shared/Publication/Metadata.swift index 5ee37de2a1..e902831b96 100644 --- a/Sources/Shared/Publication/Metadata.swift +++ b/Sources/Shared/Publication/Metadata.swift @@ -11,7 +11,7 @@ import ReadiumInternal /// Manifest. /// /// See. https://readium.org/webpub-manifest/ -public struct Metadata: Hashable, Loggable, WarningLogger, Sendable, JSONValueDecodable, JSONObjectEncodable { +public struct Metadata: Hashable, Sendable, Loggable, WarningLogger, JSONValueDecodable, JSONObjectEncodable { /// Collection type used for collection/series metadata. /// For convenience, the JSON schema reuse the Contributor's definition. public typealias Collection = Contributor diff --git a/Sources/Shared/Publication/Properties.swift b/Sources/Shared/Publication/Properties.swift index bc9c0cbab7..173c772665 100644 --- a/Sources/Shared/Publication/Properties.swift +++ b/Sources/Shared/Publication/Properties.swift @@ -9,7 +9,7 @@ import ReadiumInternal /// Link Properties /// https://readium.org/webpub-manifest/schema/properties.schema.json -public struct Properties: Hashable, Loggable, WarningLogger, Sendable, JSONValueDecodable, JSONObjectEncodable { +public struct Properties: Hashable, Sendable, Loggable, WarningLogger, JSONValueDecodable, JSONObjectEncodable { /// Additional properties for extensions. public var otherProperties: [String: JSONValue] diff --git a/Sources/Shared/Publication/Publication.swift b/Sources/Shared/Publication/Publication.swift index 17c8fbffd5..4e209d7f87 100644 --- a/Sources/Shared/Publication/Publication.swift +++ b/Sources/Shared/Publication/Publication.swift @@ -9,8 +9,8 @@ import Foundation import ReadiumInternal /// Shared model for a Readium Publication. -public final class Publication: Closeable, Loggable { - public var manifest: Manifest +public final class Publication: Sendable, Closeable, Loggable { + public let manifest: Manifest private let container: Container private let services: [PublicationService] @@ -161,7 +161,7 @@ public final class Publication: Closeable, Loggable { /// /// For a list of supported profiles, see the registry: /// https://readium.org/webpub-manifest/profiles/ - public struct Profile: Hashable, RawRepresentable, Sendable { + public struct Profile: Hashable, Sendable, RawRepresentable { public let uri: String public init(_ uri: String) { diff --git a/Sources/Shared/Publication/Services/Content/Content.swift b/Sources/Shared/Publication/Services/Content/Content.swift index 9aa7e95fc5..c9f1936757 100644 --- a/Sources/Shared/Publication/Services/Content/Content.swift +++ b/Sources/Shared/Publication/Services/Content/Content.swift @@ -56,7 +56,7 @@ public extension ContentElement where Self: Equatable { } /// A type-erasing `ContentElement` object which implements `Equatable`. -public struct AnyEquatableContentElement: Equatable, ContentElement, Sendable { +public struct AnyEquatableContentElement: Equatable, ContentElement { private let element: ContentElement public init(_ element: E) { @@ -101,7 +101,7 @@ public protocol EmbeddedContentElement: ContentElement { } /// An audio clip. -public struct AudioContentElement: Hashable, EmbeddedContentElement, TextualContentElement, Sendable { +public struct AudioContentElement: Hashable, EmbeddedContentElement, TextualContentElement { public var locator: Locator public var embeddedLink: Link public var attributes: [ContentAttribute] @@ -114,7 +114,7 @@ public struct AudioContentElement: Hashable, EmbeddedContentElement, TextualCont } /// A video clip. -public struct VideoContentElement: Hashable, EmbeddedContentElement, TextualContentElement, Sendable { +public struct VideoContentElement: Hashable, EmbeddedContentElement, TextualContentElement { public var locator: Locator public var embeddedLink: Link public var attributes: [ContentAttribute] @@ -127,7 +127,7 @@ public struct VideoContentElement: Hashable, EmbeddedContentElement, TextualCont } /// An embedded image (bitmap or SVG). -public struct ImageContentElement: Hashable, EmbeddedContentElement, TextualContentElement, Sendable { +public struct ImageContentElement: Hashable, EmbeddedContentElement, TextualContentElement { public var locator: Locator public var embeddedLink: Link public var attributes: [ContentAttribute] @@ -149,7 +149,7 @@ public struct ImageContentElement: Hashable, EmbeddedContentElement, TextualCont } /// An inline SVG image. -public struct SVGContentElement: Hashable, TextualContentElement, Sendable { +public struct SVGContentElement: Hashable, TextualContentElement { public var locator: Locator public var attributes: [ContentAttribute] @@ -176,7 +176,7 @@ public struct SVGContentElement: Hashable, TextualContentElement, Sendable { /// /// @param role Purpose of this element in the broader context of the document. /// @param segments Ranged portions of text with associated attributes. -public struct TextContentElement: Hashable, TextualContentElement, Sendable { +public struct TextContentElement: Hashable, TextualContentElement { public var locator: Locator public var role: Role public var segments: [Segment] @@ -213,7 +213,7 @@ public struct TextContentElement: Hashable, TextualContentElement, Sendable { /// @param locator Locator to the segment of text. /// @param text Text in the segment. /// @param attributes Attributes associated with this segment, e.g. language. - public struct Segment: Hashable, ContentAttributesHolder, Sendable { + public struct Segment: Hashable, Sendable, ContentAttributesHolder { public var locator: Locator public var text: String public var attributes: [ContentAttribute] @@ -307,7 +307,7 @@ public extension ContentAttributesHolder { } /// Iterates through a list of `ContentElement` items. -public protocol ContentIterator: AnyObject { +public protocol ContentIterator: AnyObject, Sendable { /// Retrieves the next element, or nil if we reached the end. func next() async throws -> ContentElement? diff --git a/Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift b/Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift index 32c8b6bced..d1e781e16f 100644 --- a/Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift +++ b/Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift @@ -18,7 +18,7 @@ import SwiftSoup /// /// Locators will contain a `before` context of up to `beforeMaxLength` /// characters. -public final class HTMLResourceContentIterator: ContentIterator { +public actor HTMLResourceContentIterator: ContentIterator { /// Factory for an `HTMLResourceContentIterator`. public final class Factory: ResourceContentIteratorFactory, Sendable { public init() {} @@ -55,11 +55,11 @@ public final class HTMLResourceContentIterator: ContentIterator { private let resource: Resource private let locator: Locator private let beforeMaxLength: Int = 50 - private let fetchTotalProgressionRange: () async -> ClosedRange? + private let fetchTotalProgressionRange: @Sendable () async -> ClosedRange? public init( resource: Resource, - totalProgressionRange: @escaping () async -> ClosedRange?, + totalProgressionRange: @escaping @Sendable () async -> ClosedRange?, locator: Locator ) { self.resource = resource @@ -108,7 +108,7 @@ public final class HTMLResourceContentIterator: ContentIterator { .asyncMap { await adjustProgressions(of: $0, totalProgressionRange: range) } } - private func parse(document: Document, locator: Locator, beforeMaxLength: Int) throws -> ParsedElements { + private nonisolated func parse(document: Document, locator: Locator, beforeMaxLength: Int) throws -> ParsedElements { let parser = try ContentParser( baseLocator: locator, startElement: locator.locations.cssSelector diff --git a/Sources/Shared/Publication/Services/Content/Iterators/PDFResourceContentIterator.swift b/Sources/Shared/Publication/Services/Content/Iterators/PDFResourceContentIterator.swift index 7003617a53..ff154056c7 100644 --- a/Sources/Shared/Publication/Services/Content/Iterators/PDFResourceContentIterator.swift +++ b/Sources/Shared/Publication/Services/Content/Iterators/PDFResourceContentIterator.swift @@ -24,7 +24,7 @@ public enum PDFResourceContentIteratorError: Error, Sendable { /// /// This ``ContentIterator`` requires the ``Publication`` to have a /// ``PDFDocumentService``. -public class PDFResourceContentIterator: ContentIterator, Loggable { +public actor PDFResourceContentIterator: ContentIterator, Loggable { /// Factory for a `PDFResourceContentIterator`. public final class Factory: ResourceContentIteratorFactory { public init() {} @@ -76,8 +76,8 @@ public class PDFResourceContentIterator: ContentIterator, Loggable { var totalProgressionRange: ClosedRange? } - private let openDocument: () async throws -> PDFDocument - private let makeResourceInfo: () async -> ResourceInfo + private let openDocument: @Sendable () async throws -> PDFDocument + private let makeResourceInfo: @Sendable () async -> ResourceInfo private let locator: Locator /// The opened PDF document; retained for the lifetime of the iterator. @@ -93,15 +93,16 @@ public class PDFResourceContentIterator: ContentIterator, Loggable { /// locator. private var startPageIndex: Int = 0 - /// Whether initialization has completed. - private var initialized: Bool = false + /// Memoized initialization, so concurrent `next()`/`previous()` calls share + /// one `initialize()` instead of racing to open the document twice. + private lazy var initializationTask = Task { try await initialize() } /// Current page index (0-based). `nil` means iteration hasn't started yet. private var currentPageIndex: Int? init( - openDocument: @escaping () async throws -> PDFDocument, - resourceInfo: @escaping () async -> ResourceInfo, + openDocument: @escaping @Sendable () async throws -> PDFDocument, + resourceInfo: @escaping @Sendable () async -> ResourceInfo, locator: Locator ) { self.openDocument = openDocument @@ -146,22 +147,24 @@ public class PDFResourceContentIterator: ContentIterator, Loggable { // MARK: - Initialization private func initializeIfNeeded() async throws { - guard !initialized else { return } + try await initializationTask.value + } + /// Opens the document and computes the starting page and resource metadata. + /// Runs exactly once, driven by `initializationTask`. + private func initialize() async throws { let info = await makeResourceInfo() resourceInfo = info let doc = try await openDocument() guard let textDoc = doc as? PDFDocumentTextProviding else { log(.warning, "The PDF document does not support text extraction; no content elements will be produced.") - initialized = true return } document = textDoc pageCount = try await textDoc.pageCount() startPageIndex = computeStartPage(positionOffset: info.positionOffset) - initialized = true } /// Computes the 0-based page index to start from, derived from the locator. diff --git a/Sources/Shared/Publication/Services/Content/Iterators/PublicationContentIterator.swift b/Sources/Shared/Publication/Services/Content/Iterators/PublicationContentIterator.swift index 0aed23be0d..d8a1bbbdb1 100644 --- a/Sources/Shared/Publication/Services/Content/Iterators/PublicationContentIterator.swift +++ b/Sources/Shared/Publication/Services/Content/Iterators/PublicationContentIterator.swift @@ -21,7 +21,7 @@ public protocol ResourceContentIteratorFactory: Sendable { /// A composite [Content.Iterator] which iterates through a whole [publication] and delegates the /// iteration inside a given resource to media type-specific iterators. -public final class PublicationContentIterator: ContentIterator, Loggable { +public actor PublicationContentIterator: ContentIterator, Loggable { /// `ContentIterator` for a resource, associated with its index in the reading order. private typealias IndexedIterator = (index: Int, iterator: ContentIterator) diff --git a/Sources/Shared/Publication/Services/Locator/DefaultLocatorService.swift b/Sources/Shared/Publication/Services/Locator/DefaultLocatorService.swift index a4ca37b17a..df4d8d151d 100644 --- a/Sources/Shared/Publication/Services/Locator/DefaultLocatorService.swift +++ b/Sources/Shared/Publication/Services/Locator/DefaultLocatorService.swift @@ -7,8 +7,8 @@ import Foundation /// A default implementation of the `LocatorService` using the `PositionsService` to locate its inputs. -open class DefaultLocatorService: LocatorService, Loggable { - public let publication: Weak +public final class DefaultLocatorService: Sendable, LocatorService, Loggable { + private let publication: Weak public init(publication: Weak) { self.publication = publication @@ -19,7 +19,7 @@ open class DefaultLocatorService: LocatorService, Loggable { /// If `locator.href` can be found in the links, `locator` will be returned directly. /// Otherwise, will attempt to find the closest match using `totalProgression`, `position`, /// `fragments`, etc. - open func locate(_ locator: Locator) async -> Locator? { + public func locate(_ locator: Locator) async -> Locator? { guard let publication = publication() else { return nil } @@ -41,7 +41,7 @@ open class DefaultLocatorService: LocatorService, Loggable { return nil } - open func locate(_ link: Link) async -> Locator? { + public func locate(_ link: Link) async -> Locator? { let originalHREF = link.url() let fragment = originalHREF.fragment let href = originalHREF.removingFragment() @@ -64,7 +64,7 @@ open class DefaultLocatorService: LocatorService, Loggable { ) } - open func locate(progression totalProgression: Double) async -> Locator? { + public func locate(progression totalProgression: Double) async -> Locator? { guard 0.0 ... 1.0 ~= totalProgression else { log(.error, "Progression must be between 0.0 and 1.0, received \(totalProgression)") return nil diff --git a/Sources/Shared/Publication/Services/Positions/InMemoryPositionsService.swift b/Sources/Shared/Publication/Services/Positions/InMemoryPositionsService.swift index 54e8a2487c..6cd284e4a3 100644 --- a/Sources/Shared/Publication/Services/Positions/InMemoryPositionsService.swift +++ b/Sources/Shared/Publication/Services/Positions/InMemoryPositionsService.swift @@ -7,7 +7,7 @@ import Foundation /// A ``PositionsService`` holding the pre-computed position locators in memory. -public final class InMemoryPositionsService: PositionsService, Sendable { +public final class InMemoryPositionsService: PositionsService { private let _positions: [[Locator]] public init(positionsByReadingOrder: [[Locator]]) { diff --git a/Sources/Shared/Publication/Services/Positions/PerResourcePositionsService.swift b/Sources/Shared/Publication/Services/Positions/PerResourcePositionsService.swift index a60f433a5c..ae783f2a6f 100644 --- a/Sources/Shared/Publication/Services/Positions/PerResourcePositionsService.swift +++ b/Sources/Shared/Publication/Services/Positions/PerResourcePositionsService.swift @@ -8,7 +8,7 @@ import Foundation /// Simple `PositionsService` for a `Publication` which generates one position per `readingOrder` /// resource. -public final class PerResourcePositionsService: PositionsService, Sendable { +public final class PerResourcePositionsService: PositionsService { private let positions: [[Locator]] init(readingOrder: [Link], fallbackMediaType: MediaType) { diff --git a/Sources/Shared/Publication/Services/PublicationService.swift b/Sources/Shared/Publication/Services/PublicationService.swift index ac90a932b2..d145c11287 100644 --- a/Sources/Shared/Publication/Services/PublicationService.swift +++ b/Sources/Shared/Publication/Services/PublicationService.swift @@ -7,7 +7,7 @@ import Foundation /// Base interface to be implemented by all publication services. -public protocol PublicationService: Closeable { +public protocol PublicationService: Sendable, Closeable { /// Links which will be added to `Publication.links`. /// It can be used to expose a web API for the service, through `Publication.get()`. /// @@ -50,7 +50,7 @@ public extension PublicationService { public typealias PublicationServiceFactory = (PublicationServiceContext) -> PublicationService? /// Container for the context from which a service is created. -public struct PublicationServiceContext { +public struct PublicationServiceContext: Sendable { /// Weak reference to the parent publication. /// /// Don't store directly the referenced publication, always access it through the `Weak` property. diff --git a/Sources/Shared/Publication/Services/Search/ContentSearchService.swift b/Sources/Shared/Publication/Services/Search/ContentSearchService.swift index 0c5fa2e6fb..36b086368f 100644 --- a/Sources/Shared/Publication/Services/Search/ContentSearchService.swift +++ b/Sources/Shared/Publication/Services/Search/ContentSearchService.swift @@ -24,7 +24,7 @@ import Foundation /// are not affected by this limitation. /// /// This service requires the publication to have a configured `ContentService`. -public class ContentSearchService: SearchService, Loggable { +public final class ContentSearchService: SearchService, Loggable { /// - Parameters: /// - snippetLength: Maximum length of the `before` and `after` text /// snippets in the returned locators. @@ -102,7 +102,7 @@ private struct ElementEntry { var startOffset: Int } -private final class Iterator: SearchIterator, Loggable { +private actor Iterator: SearchIterator, Loggable { private(set) var resultCount: Int? = 0 private let contentIterator: ContentIterator diff --git a/Sources/Shared/Publication/Services/Search/SearchService.swift b/Sources/Shared/Publication/Services/Search/SearchService.swift index 32a615a7f9..b017fccce1 100644 --- a/Sources/Shared/Publication/Services/Search/SearchService.swift +++ b/Sources/Shared/Publication/Services/Search/SearchService.swift @@ -22,14 +22,14 @@ public protocol SearchService: PublicationService { } /// Iterates through search results. -public protocol SearchIterator: AnyObject, Closeable { +public protocol SearchIterator: AnyObject, Sendable, Closeable { /// Number of matches for this search, if known. /// /// Depending on the search algorithm, it may not be possible to know the result count until reaching the end of the /// publication. /// /// The count might be updated after each call to `next()`. - var resultCount: Int? { get } + var resultCount: Int? { get async } /// Retrieves the next page of results. /// @@ -41,8 +41,8 @@ public protocol SearchIterator: AnyObject, Closeable { public extension SearchIterator { /// Iterates over all the search results, calling the given `block` for each page. @discardableResult - func forEach(_ block: @escaping (LocatorCollection) -> Void) async -> SearchResult { - func next() async -> SearchResult { + func forEach(_ block: @escaping @Sendable (LocatorCollection) -> Void) async -> SearchResult { + @Sendable func next() async -> SearchResult { await self.next().asyncFlatMap { locators in if let locators = locators { block(locators) diff --git a/Sources/Shared/Publication/Services/Search/StringSearchAlgorithm.swift b/Sources/Shared/Publication/Services/Search/StringSearchAlgorithm.swift index 2573c6f227..747cdedf1b 100644 --- a/Sources/Shared/Publication/Services/Search/StringSearchAlgorithm.swift +++ b/Sources/Shared/Publication/Services/Search/StringSearchAlgorithm.swift @@ -23,7 +23,7 @@ public protocol StringSearchAlgorithm: Sendable { } /// A basic `StringSearchAlgorithm` using the native `String.range(of:)` APIs. -public final class BasicStringSearchAlgorithm: StringSearchAlgorithm, Sendable { +public final class BasicStringSearchAlgorithm: StringSearchAlgorithm { public let options: SearchOptions = .init( caseSensitive: false, diacriticSensitive: false, diff --git a/Sources/Shared/Publication/Services/Search/StringSearchService.swift b/Sources/Shared/Publication/Services/Search/StringSearchService.swift index 6bb4fbd61b..1520ae80d3 100644 --- a/Sources/Shared/Publication/Services/Search/StringSearchService.swift +++ b/Sources/Shared/Publication/Services/Search/StringSearchService.swift @@ -68,7 +68,7 @@ public final class StringSearchService: SearchService, Sendable { )) } - private class Iterator: SearchIterator, Loggable { + private actor Iterator: SearchIterator, Loggable { private(set) var resultCount: Int? = 0 private let publication: Publication diff --git a/Sources/Shared/Toolkit/Archive/ArchiveOpener.swift b/Sources/Shared/Toolkit/Archive/ArchiveOpener.swift index aebab283b9..2a09fdf3c1 100644 --- a/Sources/Shared/Toolkit/Archive/ArchiveOpener.swift +++ b/Sources/Shared/Toolkit/Archive/ArchiveOpener.swift @@ -7,7 +7,7 @@ import Foundation /// A factory to create ``Container``s from archive ``Resource``s. -public protocol ArchiveOpener { +public protocol ArchiveOpener: Sendable { /// Creates a new ``ContainerAsset`` to access the entries of an archive /// with a known `format`. func open(resource: Resource, format: Format) async -> Result diff --git a/Sources/Shared/Toolkit/Archive/ArchiveProperties.swift b/Sources/Shared/Toolkit/Archive/ArchiveProperties.swift index 30558079c2..4837df58f5 100644 --- a/Sources/Shared/Toolkit/Archive/ArchiveProperties.swift +++ b/Sources/Shared/Toolkit/Archive/ArchiveProperties.swift @@ -8,7 +8,7 @@ import Foundation import ReadiumInternal /// Holds information about how the resource is stored in the archive. -public struct ArchiveProperties: Equatable, JSONValueDecodable, JSONObjectEncodable, Sendable { +public struct ArchiveProperties: Equatable, Sendable, JSONValueDecodable, JSONObjectEncodable { /// The length of the entry stored in the archive. It might be a compressed /// length if the entry is deflated. public let entryLength: UInt64 diff --git a/Sources/Shared/Toolkit/Archive/CompositeArchiveOpener.swift b/Sources/Shared/Toolkit/Archive/CompositeArchiveOpener.swift index 79c57930c1..e305053742 100644 --- a/Sources/Shared/Toolkit/Archive/CompositeArchiveOpener.swift +++ b/Sources/Shared/Toolkit/Archive/CompositeArchiveOpener.swift @@ -8,7 +8,7 @@ import Foundation /// A composite ``ArchiveOpener`` which tries several factories until it finds /// one which supports the format. -public class CompositeArchiveOpener: ArchiveOpener { +public final class CompositeArchiveOpener: ArchiveOpener { private let archiveOpeners: [ArchiveOpener] public init(_ archiveOpeners: [ArchiveOpener]) { diff --git a/Sources/Shared/Toolkit/Archive/DefaultArchiveOpener.swift b/Sources/Shared/Toolkit/Archive/DefaultArchiveOpener.swift index b60d612f58..87974be29f 100644 --- a/Sources/Shared/Toolkit/Archive/DefaultArchiveOpener.swift +++ b/Sources/Shared/Toolkit/Archive/DefaultArchiveOpener.swift @@ -7,9 +7,19 @@ import Foundation /// Default implementation of ``ArchiveOpener`` supporting ZIP archives. -public final class DefaultArchiveOpener: CompositeArchiveOpener { +public final class DefaultArchiveOpener: ArchiveOpener { + private let opener: CompositeArchiveOpener + /// - Parameter additionalArchiveOpeners: Additional archive openers to use. public init(additionalArchiveOpeners: [any ArchiveOpener] = []) { - super.init(additionalArchiveOpeners + [ZIPArchiveOpener()]) + opener = CompositeArchiveOpener(additionalArchiveOpeners + [ZIPArchiveOpener()]) + } + + public func open(resource: any Resource, format: Format) async -> Result { + await opener.open(resource: resource, format: format) + } + + public func sniffOpen(resource: any Resource) async -> Result { + await opener.sniffOpen(resource: resource) } } diff --git a/Sources/Shared/Toolkit/Data/Asset/Asset.swift b/Sources/Shared/Toolkit/Data/Asset/Asset.swift index 9091a34e68..5d3a773657 100644 --- a/Sources/Shared/Toolkit/Data/Asset/Asset.swift +++ b/Sources/Shared/Toolkit/Data/Asset/Asset.swift @@ -6,7 +6,7 @@ import Foundation -public protocol AssetProtocol: Closeable { +public protocol AssetProtocol: Sendable, Closeable { /// Format of the asset. var format: Format { get } } diff --git a/Sources/Shared/Toolkit/Data/Asset/AssetRetriever.swift b/Sources/Shared/Toolkit/Data/Asset/AssetRetriever.swift index 9979728d98..093904a71d 100644 --- a/Sources/Shared/Toolkit/Data/Asset/AssetRetriever.swift +++ b/Sources/Shared/Toolkit/Data/Asset/AssetRetriever.swift @@ -32,7 +32,7 @@ public enum AssetRetrieveURLError: Error, Sendable { /// Retrieves an ``Asset`` instance that provides read-only access to the /// resource(s) of an asset stored at a given ``AbsoluteURL`` and its /// ``Format``. -public final class AssetRetriever { +public final class AssetRetriever: Sendable { private let formatSniffer: FormatSniffer private let resourceFactory: ResourceFactory private let archiveOpener: ArchiveOpener diff --git a/Sources/Shared/Toolkit/Data/Resource/FailureResource.swift b/Sources/Shared/Toolkit/Data/Resource/FailureResource.swift index 6597ab1eca..b3d96bd09b 100644 --- a/Sources/Shared/Toolkit/Data/Resource/FailureResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/FailureResource.swift @@ -7,7 +7,7 @@ import Foundation /// Creates a Resource that will always return the given `error`. -public final class FailureResource: Resource, Sendable { +public final class FailureResource: Resource { private let error: ReadError public let sourceURL: AbsoluteURL? diff --git a/Sources/Shared/Toolkit/Data/Resource/ResourceContentExtractor.swift b/Sources/Shared/Toolkit/Data/Resource/ResourceContentExtractor.swift index 6e7abd1fa1..dbdd299d2f 100644 --- a/Sources/Shared/Toolkit/Data/Resource/ResourceContentExtractor.swift +++ b/Sources/Shared/Toolkit/Data/Resource/ResourceContentExtractor.swift @@ -8,7 +8,7 @@ import Foundation import SwiftSoup /// Extracts pure content from a marked-up (e.g. HTML) or binary (e.g. PDF) resource. -public protocol ResourceContentExtractor { +public protocol ResourceContentExtractor: Sendable { /// Extracts the text content of the given `resource`. func extractText(of resource: Resource) async -> ReadResult } @@ -27,7 +27,7 @@ public protocol ResourceContentExtractorFactory: Sendable { public typealias _ResourceContentExtractorFactory = ResourceContentExtractorFactory /// Default `ResourceContentExtractorFactory` supporting HTML resources. -public final class DefaultResourceContentExtractorFactory: ResourceContentExtractorFactory, Sendable { +public final class DefaultResourceContentExtractorFactory: ResourceContentExtractorFactory { public init() {} public func makeExtractor(for resource: Resource, mediaType: MediaType) -> ResourceContentExtractor? { @@ -43,7 +43,7 @@ public final class DefaultResourceContentExtractorFactory: ResourceContentExtrac public typealias _DefaultResourceContentExtractorFactory = DefaultResourceContentExtractorFactory /// `ResourceContentExtractor` implementation for HTML resources. -class HTMLResourceContentExtractor: ResourceContentExtractor { +final class HTMLResourceContentExtractor: ResourceContentExtractor { private let xmlFactory = DefaultXMLDocumentFactory() func extractText(of resource: Resource) async -> ReadResult { diff --git a/Sources/Shared/Toolkit/Data/Resource/ResourceFactory.swift b/Sources/Shared/Toolkit/Data/Resource/ResourceFactory.swift index f621fec8be..d87864ed73 100644 --- a/Sources/Shared/Toolkit/Data/Resource/ResourceFactory.swift +++ b/Sources/Shared/Toolkit/Data/Resource/ResourceFactory.swift @@ -7,7 +7,7 @@ import Foundation /// A factory to create ``Resource`` instances from absolute URLs. -public protocol ResourceFactory { +public protocol ResourceFactory: Sendable { /// Creates a ``Resource`` to access the content at `url`. func make(url: AbsoluteURL) async -> Result } @@ -19,7 +19,9 @@ public enum ResourceMakeError: Error, Sendable { /// Default implementation of ``ResourceFactory`` supporting file and http /// schemes. -public final class DefaultResourceFactory: CompositeResourceFactory { +public final class DefaultResourceFactory: ResourceFactory { + private let factory: CompositeResourceFactory + /// - Parameters: /// - httpClient: HTTP client used to support HTTP schemes. /// - additionalFactories: Additional ``ResourceFactory`` to support more @@ -28,16 +30,20 @@ public final class DefaultResourceFactory: CompositeResourceFactory { httpClient: HTTPClient, additionalFactories: [ResourceFactory] = [] ) { - super.init(additionalFactories + [ + factory = CompositeResourceFactory(additionalFactories + [ FileResourceFactory(), HTTPResourceFactory(client: httpClient), ]) } + + public func make(url: any AbsoluteURL) async -> Result { + await factory.make(url: url) + } } /// A composite ``ResourceFactory`` which tries several factories until it /// finds one which supports the URL scheme. -public class CompositeResourceFactory: ResourceFactory { +public final class CompositeResourceFactory: ResourceFactory { private let factories: [ResourceFactory] public init(_ factories: [ResourceFactory]) { diff --git a/Sources/Shared/Toolkit/Data/Resource/TailCachingResource.swift b/Sources/Shared/Toolkit/Data/Resource/TailCachingResource.swift index d98bd4e279..58ed9b15ae 100644 --- a/Sources/Shared/Toolkit/Data/Resource/TailCachingResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/TailCachingResource.swift @@ -41,26 +41,28 @@ actor TailCachingResource: Resource, Loggable { return await resource.stream(range: range, consume: consume) } - return await cachedTail() - .asyncFlatMap { data in - guard let data = data else { - return await resource.stream(range: range, consume: consume) - } + switch await cachedTail() { + case let .failure(error): + return .failure(error) + case let .success(data): + guard let data = data else { + return await resource.stream(range: range, consume: consume) + } - if let range = range { - let range = range.clampedToInt() - let lower = Int(range.lowerBound) - Int(cacheFromOffset) - let upper = min(lower + range.count, data.count) - guard lower >= 0 else { - return .failure(.decoding("Cannot satisty requested range from the cached tail")) - } - consume(data[lower ..< upper]) - } else { - consume(data) + if let range = range { + let range = range.clampedToInt() + let lower = Int(range.lowerBound) - Int(cacheFromOffset) + let upper = min(lower + range.count, data.count) + guard lower >= 0 else { + return .failure(.decoding("Cannot satisfy requested range from the cached tail")) } - - return .success(()) + consume(data[lower ..< upper]) + } else { + consume(data) } + + return .success(()) + } } private var cache: ReadResult? @@ -70,22 +72,24 @@ actor TailCachingResource: Resource, Loggable { return cache } - return await estimatedLength() - .asyncFlatMap { length in - let length = length ?? .max - guard cacheFromOffset < length else { - cache = .success(nil) - return cache! - } - - let data = Mutex(Data()) - let streamResult = await resource.stream(range: cacheFromOffset ..< length) { chunk in - data.withLock { $0.append(chunk) } - } - - cache = streamResult.map { data.withLock { $0 } } - + let lengthResult = await estimatedLength() + switch lengthResult { + case let .failure(error): + return .failure(error) + case let .success(length): + let length = length ?? .max + guard cacheFromOffset < length else { + cache = .success(nil) return cache! } + + let data = Mutex(Data()) + let streamResult = await resource.stream(range: cacheFromOffset ..< length) { chunk in + data.withLock { $0.append(chunk) } + } + + cache = streamResult.map { data.withLock { $0 } } + return cache! + } } } diff --git a/Sources/Shared/Toolkit/Data/Resource/TransformingResource.swift b/Sources/Shared/Toolkit/Data/Resource/TransformingResource.swift index 6ad07ac7a2..ed10690f55 100644 --- a/Sources/Shared/Toolkit/Data/Resource/TransformingResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/TransformingResource.swift @@ -15,7 +15,7 @@ import Foundation /// will be read. /// /// Customize the transformation by providing a `transform` closure during construction. -public final class TransformingResource: Resource, Sendable { +public final class TransformingResource: Resource { private let resource: Resource private let data: AsyncMemoizer> diff --git a/Sources/Shared/Toolkit/Data/Streamable.swift b/Sources/Shared/Toolkit/Data/Streamable.swift index 008e1df35d..6d43957023 100644 --- a/Sources/Shared/Toolkit/Data/Streamable.swift +++ b/Sources/Shared/Toolkit/Data/Streamable.swift @@ -7,7 +7,7 @@ import Foundation /// Acts as a proxy to an actual data source by handling read access. -public protocol Streamable: Closeable, Sendable { +public protocol Streamable: Sendable, Closeable { /// Returns data length from metadata if available. /// /// This value must be treated as a hint, as it might not reflect the diff --git a/Sources/Shared/Toolkit/DocumentTypes.swift b/Sources/Shared/Toolkit/DocumentTypes.swift index 914389b93d..22a7e32f53 100644 --- a/Sources/Shared/Toolkit/DocumentTypes.swift +++ b/Sources/Shared/Toolkit/DocumentTypes.swift @@ -90,7 +90,7 @@ public struct DocumentTypes: Sendable { } /// Metadata about a Document Type declared in `CFBundleDocumentTypes`. -public struct DocumentType: Equatable, Loggable, Sendable { +public struct DocumentType: Equatable, Sendable, Loggable { /// Abstract name for the document type, used to refer to the type. public let name: String diff --git a/Sources/Shared/Toolkit/FileExtension.swift b/Sources/Shared/Toolkit/FileExtension.swift index 8f1f76b55b..9f16f778f8 100644 --- a/Sources/Shared/Toolkit/FileExtension.swift +++ b/Sources/Shared/Toolkit/FileExtension.swift @@ -7,7 +7,7 @@ import Foundation /// Represents a file extension. -public struct FileExtension: Hashable, RawRepresentable, ExpressibleByStringLiteral, Sendable { +public struct FileExtension: Hashable, Sendable, RawRepresentable, ExpressibleByStringLiteral { public let rawValue: String public init(rawValue: String) { diff --git a/Sources/Shared/Toolkit/Format/FormatSniffer.swift b/Sources/Shared/Toolkit/Format/FormatSniffer.swift index 30a0cc3df7..971a985bc2 100644 --- a/Sources/Shared/Toolkit/Format/FormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/FormatSniffer.swift @@ -6,12 +6,12 @@ import Foundation -public protocol HintsFormatSniffer { +public protocol HintsFormatSniffer: Sendable { /// Tries to guess a `Format` from media type and file extension hints. func sniffHints(_ hints: FormatHints) -> Format? } -public protocol ContentFormatSniffer { +public protocol ContentFormatSniffer: Sendable { /// Tries to refine the given `format` by sniffing a `blob`. func sniffBlob(_ blob: FormatSnifferBlob, refining format: Format) async -> ReadResult diff --git a/Sources/Shared/Toolkit/Format/MediaType.swift b/Sources/Shared/Toolkit/Format/MediaType.swift index ac3e332331..f5b37dbd36 100644 --- a/Sources/Shared/Toolkit/Format/MediaType.swift +++ b/Sources/Shared/Toolkit/Format/MediaType.swift @@ -18,7 +18,7 @@ import ReadiumInternal /// media type, for example `application/atom+xml;profile=opds-catalog` for an OPDS 1 catalog. /// /// Specification: https://tools.ietf.org/html/rfc6838 -public struct MediaType: Sendable, Hashable, RawRepresentable, JSONValueEncodable, JSONValueDecodable, Loggable { +public struct MediaType: Hashable, Sendable, RawRepresentable, JSONValueEncodable, JSONValueDecodable, Loggable { /// The string representation of this media type. public var string: String { let params = parameters diff --git a/Sources/Shared/Toolkit/Format/Sniffers/CompositeFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/CompositeFormatSniffer.swift index 1fdfdcd79b..449e221c7c 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/CompositeFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/CompositeFormatSniffer.swift @@ -6,7 +6,7 @@ import Foundation -public class CompositeFormatSniffer: FormatSniffer { +public final class CompositeFormatSniffer: FormatSniffer { private let sniffers: [FormatSniffer] public init(_ sniffers: [FormatSniffer]) { diff --git a/Sources/Shared/Toolkit/Format/Sniffers/DefaultFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/DefaultFormatSniffer.swift index 2dcc9212fb..d35654b83a 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/DefaultFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/DefaultFormatSniffer.swift @@ -8,7 +8,9 @@ import Foundation /// Default implementation of ``FormatSniffer`` guessing as well as possible all /// formats known by Readium. -public final class DefaultFormatSniffer: CompositeFormatSniffer { +public final class DefaultFormatSniffer: FormatSniffer { + private let sniffer: CompositeFormatSniffer + /// - Parameters: /// - xmlDocumentFactory: Used to parse XML content when sniffing formats that require /// XML inspection. Defaults to `DefaultXMLDocumentFactory()`. @@ -17,7 +19,7 @@ public final class DefaultFormatSniffer: CompositeFormatSniffer { xmlDocumentFactory: XMLDocumentFactory = DefaultXMLDocumentFactory(), additionalSniffers: [FormatSniffer] = [] ) { - super.init(additionalSniffers + [ + sniffer = CompositeFormatSniffer(additionalSniffers + [ JSONFormatSniffer(), OPDSFormatSniffer(), RWPMFormatSniffer(), @@ -39,4 +41,16 @@ public final class DefaultFormatSniffer: CompositeFormatSniffer { BitmapFormatSniffer(), ]) } + + public func sniffHints(_ hints: FormatHints) -> Format? { + sniffer.sniffHints(hints) + } + + public func sniffBlob(_ blob: FormatSnifferBlob, refining format: Format) async -> ReadResult { + await sniffer.sniffBlob(blob, refining: format) + } + + public func sniffContainer(_ container: C, refining format: Format) async -> ReadResult { + await sniffer.sniffContainer(container, refining: format) + } } diff --git a/Sources/Shared/Toolkit/JSONValue.swift b/Sources/Shared/Toolkit/JSONValue.swift index ca9d696008..338536637a 100644 --- a/Sources/Shared/Toolkit/JSONValue.swift +++ b/Sources/Shared/Toolkit/JSONValue.swift @@ -18,7 +18,7 @@ import Foundation /// ```swift /// let value: JSONValue = ["title": "Moby Dick", "year": 1851] /// ``` -public enum JSONValue: Sendable, Hashable, Loggable { +public enum JSONValue: Hashable, Sendable, Loggable { /// A JSON `null`. case null /// A JSON boolean. diff --git a/Sources/Shared/Toolkit/Logging/WarningLogger.swift b/Sources/Shared/Toolkit/Logging/WarningLogger.swift index c205ee509d..81b799fe6a 100644 --- a/Sources/Shared/Toolkit/Logging/WarningLogger.swift +++ b/Sources/Shared/Toolkit/Logging/WarningLogger.swift @@ -8,7 +8,7 @@ import Foundation /// Interface to be implemented by third-party apps if they want to observe warnings raised, /// for example, during the parsing of a `Publication`. -public protocol WarningLogger { +public protocol WarningLogger: Sendable { /// Notifies that a warning occurred. func log(_ warning: Warning) } @@ -17,7 +17,7 @@ public protocol WarningLogger { /// /// For example, while parsing an EPUB we, might want to report issues in the publication without /// failing the whole parsing. -public protocol Warning { +public protocol Warning: Sendable { /// Tag used to group similar warnings together. /// For example `json`, `metadata`, etc. var tag: String { get } @@ -67,11 +67,15 @@ extension WarningLogger { /// Implementation of a `WarningLogger` which accumulates the warnings in a list, to be used as a /// convenience by reading apps. public final class ListWarningLogger: WarningLogger { + private let _warnings = Mutex<[Warning]>([]) + /// The list of accumulated `Warning`s. - private(set) var warnings: [Warning] = [] + var warnings: [Warning] { + _warnings.withLock { $0 } + } public func log(_ warning: Warning) { - warnings.append(warning) + _warnings.withLock { $0.append(warning) } } } diff --git a/Sources/Shared/Toolkit/PDF/CGPDF.swift b/Sources/Shared/Toolkit/PDF/CGPDF.swift index 41a08a905c..d70213198e 100644 --- a/Sources/Shared/Toolkit/PDF/CGPDF.swift +++ b/Sources/Shared/Toolkit/PDF/CGPDF.swift @@ -15,7 +15,7 @@ import UIKit /// document in memory. /// /// Use `CGPDFDocumentFactory` to create a `CGPDFDocument` from a `Resource`. -extension CGPDFDocument: PDFDocument { +extension CGPDFDocument: PDFDocument, @retroactive @unchecked Sendable { public func identifier() async throws -> String? { guard let identifierArray = fileIdentifier, diff --git a/Sources/Shared/Toolkit/ControlFlow.swift b/Sources/Shared/Toolkit/Throttle.swift similarity index 94% rename from Sources/Shared/Toolkit/ControlFlow.swift rename to Sources/Shared/Toolkit/Throttle.swift index 62bb6dc2c5..d95f72c8b9 100644 --- a/Sources/Shared/Toolkit/ControlFlow.swift +++ b/Sources/Shared/Toolkit/Throttle.swift @@ -6,8 +6,6 @@ import Foundation -// A collection of tools to manage the Flow of Control. - @MainActor private final class ThrottlerState: Sendable { var isThrottling = false diff --git a/Sources/Shared/Toolkit/URL/Absolute URL/FileURL.swift b/Sources/Shared/Toolkit/URL/Absolute URL/FileURL.swift index da8666f1ef..f69927effb 100644 --- a/Sources/Shared/Toolkit/URL/Absolute URL/FileURL.swift +++ b/Sources/Shared/Toolkit/URL/Absolute URL/FileURL.swift @@ -9,7 +9,7 @@ import Foundation /// Represents an absolute URL with the special scheme `file`. /// /// See https://url.spec.whatwg.org/#special-scheme -public struct FileURL: AbsoluteURL, Hashable, Sendable { +public struct FileURL: AbsoluteURL, Hashable { public init?(url: URL) { let url = url.standardizedFileURL guard diff --git a/Sources/Shared/Toolkit/URL/Absolute URL/HTTPURL.swift b/Sources/Shared/Toolkit/URL/Absolute URL/HTTPURL.swift index fa7f3b0577..fe8c5e7e3b 100644 --- a/Sources/Shared/Toolkit/URL/Absolute URL/HTTPURL.swift +++ b/Sources/Shared/Toolkit/URL/Absolute URL/HTTPURL.swift @@ -9,7 +9,7 @@ import Foundation /// Represents an absolute URL with the special schemes `http` or `https`. /// /// See https://url.spec.whatwg.org/#special-scheme -public struct HTTPURL: AbsoluteURL, Hashable, Sendable { +public struct HTTPURL: AbsoluteURL, Hashable { public init?(url: URL) { guard let scheme = url.scheme.map(URLScheme.init(rawValue:)), diff --git a/Sources/Shared/Toolkit/URL/AnyURL.swift b/Sources/Shared/Toolkit/URL/AnyURL.swift index 0c6946b94f..8a341f0522 100644 --- a/Sources/Shared/Toolkit/URL/AnyURL.swift +++ b/Sources/Shared/Toolkit/URL/AnyURL.swift @@ -10,7 +10,7 @@ import ReadiumInternal /// Represents either an absolute or relative URL. /// /// See https://url.spec.whatwg.org -public enum AnyURL: URLProtocol, Sendable { +public enum AnyURL: URLProtocol { /// An absolute URL. case absolute(AbsoluteURL) diff --git a/Sources/Shared/Toolkit/URL/RelativeURL.swift b/Sources/Shared/Toolkit/URL/RelativeURL.swift index f5f0f32530..b84f66f76b 100644 --- a/Sources/Shared/Toolkit/URL/RelativeURL.swift +++ b/Sources/Shared/Toolkit/URL/RelativeURL.swift @@ -7,7 +7,7 @@ import Foundation /// Represents a relative URL. -public struct RelativeURL: URLProtocol, Hashable, Sendable { +public struct RelativeURL: URLProtocol, Hashable { public let url: URL /// Creates a ``RelativeURL`` from a standard Swift `URL`. diff --git a/Sources/Shared/Toolkit/URL/URLConvertible.swift b/Sources/Shared/Toolkit/URL/URLConvertible.swift index b3e4f4ae1a..49171be374 100644 --- a/Sources/Shared/Toolkit/URL/URLConvertible.swift +++ b/Sources/Shared/Toolkit/URL/URLConvertible.swift @@ -7,7 +7,7 @@ import Foundation /// A type that can be converted into an ``AnyURL``. -public protocol URLConvertible { +public protocol URLConvertible: Sendable { /// Converts the receiver to an ``AnyURL``. var anyURL: AnyURL { get } } diff --git a/Sources/Shared/Toolkit/XML/Fuzi.swift b/Sources/Shared/Toolkit/XML/Fuzi.swift index 7276cf180c..efdb5bdbad 100644 --- a/Sources/Shared/Toolkit/XML/Fuzi.swift +++ b/Sources/Shared/Toolkit/XML/Fuzi.swift @@ -5,7 +5,7 @@ // import Foundation -import ReadiumFuzi +@preconcurrency import ReadiumFuzi final class FuziXMLDocument: XMLDocument, Loggable { enum ParseError: Error { @@ -13,6 +13,7 @@ final class FuziXMLDocument: XMLDocument, Loggable { } fileprivate let document: ReadiumFuzi.XMLDocument + let documentElement: XMLElement? convenience init(data: Data, namespaces: [XMLNamespace]) throws { try self.init(document: ReadiumFuzi.XMLDocument(data: data), namespaces: namespaces) @@ -29,11 +30,11 @@ final class FuziXMLDocument: XMLDocument, Loggable { document.definePrefixes(namespaces) self.document = document + documentElement = document.root.map { + FuziXMLElement(document: document, element: $0) + } } - lazy var documentElement: XMLElement? = - document.root.map { FuziXMLElement(document: document, element: $0) } - var textContent: String? { document.root?.stringValue } diff --git a/Sources/Shared/Toolkit/XML/XML.swift b/Sources/Shared/Toolkit/XML/XML.swift index 2c74f83928..97ac8da4b7 100644 --- a/Sources/Shared/Toolkit/XML/XML.swift +++ b/Sources/Shared/Toolkit/XML/XML.swift @@ -31,7 +31,7 @@ public struct XMLNamespace: Sendable { public static let xhtml2 = XMLNamespace(prefix: "xhtml2", uri: "http://www.w3.org/2002/06/xhtml2") } -public protocol XMLNode { +public protocol XMLNode: Sendable { /// Concatenated string content of all descendants. var textContent: String? { get } @@ -96,7 +96,7 @@ public protocol XMLDocumentFactory: Sendable { func open(string: String, namespaces: [XMLNamespace]) throws -> XMLDocument } -public final class DefaultXMLDocumentFactory: XMLDocumentFactory, Loggable, Sendable { +public final class DefaultXMLDocumentFactory: XMLDocumentFactory, Loggable { public init() {} public func open(file: FileURL, namespaces: [XMLNamespace]) async throws -> XMLDocument { diff --git a/Sources/Shared/Toolkit/ZIP/ZIPArchiveOpener.swift b/Sources/Shared/Toolkit/ZIP/ZIPArchiveOpener.swift index 97224051a1..f5a0d752d2 100644 --- a/Sources/Shared/Toolkit/ZIP/ZIPArchiveOpener.swift +++ b/Sources/Shared/Toolkit/ZIP/ZIPArchiveOpener.swift @@ -7,11 +7,21 @@ import Foundation /// An ``ArchiveOpener`` for ZIP resources. -public final class ZIPArchiveOpener: CompositeArchiveOpener { +public final class ZIPArchiveOpener: ArchiveOpener { + private let opener: CompositeArchiveOpener + public init() { - super.init([ + opener = CompositeArchiveOpener([ MinizipArchiveOpener(), ZIPFoundationArchiveOpener(), ]) } + + public func open(resource: any Resource, format: Format) async -> Result { + await opener.open(resource: resource, format: format) + } + + public func sniffOpen(resource: any Resource) async -> Result { + await opener.sniffOpen(resource: resource) + } } diff --git a/Sources/Streamer/Parser/Audio/Services/AudioLocatorService.swift b/Sources/Streamer/Parser/Audio/Services/AudioLocatorService.swift index 88795d7930..bcc4220ccc 100644 --- a/Sources/Streamer/Parser/Audio/Services/AudioLocatorService.swift +++ b/Sources/Streamer/Parser/Audio/Services/AudioLocatorService.swift @@ -8,7 +8,7 @@ import Foundation import ReadiumShared /// Locator service for audio publications. -final class AudioLocatorService: DefaultLocatorService { +final class AudioLocatorService: LocatorService { static func makeFactory() -> @Sendable (PublicationServiceContext) -> AudioLocatorService { { context in AudioLocatorService( @@ -18,6 +18,7 @@ final class AudioLocatorService: DefaultLocatorService { } } + private let publication: Weak private let readingOrder: [Link] /// Duration per reading order index. @@ -26,37 +27,49 @@ final class AudioLocatorService: DefaultLocatorService { /// Total duration of the publication. private let totalDuration: Double? + private let locatorService: DefaultLocatorService + init(readingOrder: [Link], publication: Weak) { + self.publication = publication self.readingOrder = readingOrder let durations = readingOrder.map { $0.duration ?? 0 } self.durations = durations let total = durations.reduce(0, +) totalDuration = (total > 0) ? total : nil - - super.init(publication: publication) + locatorService = DefaultLocatorService(publication: publication) } - /// Finds the reading order item containing the time `position` (in seconds), as well as its - /// start time. - private func readingOrderItemAtPosition(_ position: Double) -> (link: Link, startPosition: Double)? { - var current: Double = 0 - for (i, duration) in durations.enumerated() { - let link = readingOrder[i] - if current ..< current + duration ~= position { - return (link, startPosition: current) - } + func locate(_ locator: Locator) async -> Locator? { + guard let publication = publication() else { + return nil + } - current += duration + if publication.linkWithHREF(locator.href) != nil { + return locator } - if position == totalDuration, let link = readingOrder.last { - return (link, startPosition: current - (link.duration ?? 0)) + // Routes the `totalProgression` fallback through this service's audio + // `locate(progression:)`, which is duration-based. Delegating to + // `locatorService.locate(locator)` would instead use the default + // positions-based progression and lose the audio behavior. + if + let totalProgression = locator.locations.totalProgression, + let target = await locate(progression: totalProgression) + { + return target.copy( + title: locator.title, + text: { $0 = locator.text } + ) } return nil } - override func locate(progression: Double) async -> Locator? { + func locate(_ link: Link) async -> Locator? { + await locatorService.locate(link) + } + + func locate(progression: Double) async -> Locator? { guard let totalDuration = totalDuration else { return nil } @@ -84,4 +97,24 @@ final class AudioLocatorService: DefaultLocatorService { ) ) } + + /// Finds the reading order item containing the time `position` (in seconds), as well as its + /// start time. + private func readingOrderItemAtPosition(_ position: Double) -> (link: Link, startPosition: Double)? { + var current: Double = 0 + for (i, duration) in durations.enumerated() { + let link = readingOrder[i] + if current ..< current + duration ~= position { + return (link, startPosition: current) + } + + current += duration + } + + if position == totalDuration, let link = readingOrder.last { + return (link, startPosition: current - (link.duration ?? 0)) + } + + return nil + } } diff --git a/Tests/NavigatorTests/Asserts.swift b/Tests/NavigatorTests/Asserts.swift index 2b7941693f..e3740bea22 100644 --- a/Tests/NavigatorTests/Asserts.swift +++ b/Tests/NavigatorTests/Asserts.swift @@ -6,6 +6,6 @@ import XCTest -func AssertImageEqual(_ image1: UIImage?, _ image2: UIImage?, file: StaticString = #file, line: UInt = #line) { +func AssertImageEqual(_ image1: UIImage?, _ image2: UIImage?, file: StaticString = #filePath, line: UInt = #line) { XCTAssertEqual(image1?.pngData(), image2?.pngData(), file: file, line: line) } diff --git a/Tests/SharedTests/Asserts.swift b/Tests/SharedTests/Asserts.swift index 934598aa4d..575f7e80fb 100644 --- a/Tests/SharedTests/Asserts.swift +++ b/Tests/SharedTests/Asserts.swift @@ -7,10 +7,10 @@ import ReadiumShared import XCTest -func AssertImageEqual(_ image1: UIImage?, _ image2: UIImage?, file: StaticString = #file, line: UInt = #line) { +func AssertImageEqual(_ image1: UIImage?, _ image2: UIImage?, file: StaticString = #filePath, line: UInt = #line) { XCTAssertEqual(image1?.pngData(), image2?.pngData(), file: file, line: line) } -func AssertImageEqual(_ image1: Result, _ image2: Result, file: StaticString = #file, line: UInt = #line) { +func AssertImageEqual(_ image1: Result, _ image2: Result, file: StaticString = #filePath, line: UInt = #line) { XCTAssertEqual(try image1.get()?.pngData(), try image2.get()?.pngData(), file: file, line: line) } diff --git a/Tests/SharedTests/Fixtures.swift b/Tests/SharedTests/Fixtures.swift index c530e4d54b..df97163361 100644 --- a/Tests/SharedTests/Fixtures.swift +++ b/Tests/SharedTests/Fixtures.swift @@ -14,7 +14,7 @@ import XCTest } #endif -class Fixtures { +final class Fixtures: Sendable { let path: String? init(path: String? = nil) { diff --git a/Tests/SharedTests/Publication/Services/Cover/GeneratedCoverServiceTests.swift b/Tests/SharedTests/Publication/Services/Cover/GeneratedCoverServiceTests.swift index 53edec3049..a372a98c05 100644 --- a/Tests/SharedTests/Publication/Services/Cover/GeneratedCoverServiceTests.swift +++ b/Tests/SharedTests/Publication/Services/Cover/GeneratedCoverServiceTests.swift @@ -20,14 +20,14 @@ class GeneratedCoverServiceTests: XCTestCase { func testLinks() { let expectedLinks = [Link(href: "~readium/cover", mediaType: .png, rels: [.cover])] XCTAssertEqual(GeneratedCoverService(cover: cover).links, expectedLinks) - XCTAssertEqual(GeneratedCoverService(makeCover: { .success(self.cover) }).links, expectedLinks) + XCTAssertEqual(GeneratedCoverService(makeCover: { [cover] in .success(cover!) }).links, expectedLinks) } /// `GeneratedCoverService` serves the provided cover with `get()`. func testGetCover() async throws { for service in [ GeneratedCoverService(cover: cover), - GeneratedCoverService(makeCover: { .success(self.cover) }), + GeneratedCoverService(makeCover: { [cover] in .success(cover!) }), ] { let resource = try XCTUnwrap(try service.get(XCTUnwrap(AnyURL(string: "~readium/cover")))) let result = await resource.read().map(UIImage.init) diff --git a/Tests/SharedTests/Publication/Services/PublicationServicesBuilderTests.swift b/Tests/SharedTests/Publication/Services/PublicationServicesBuilderTests.swift index 845c9b6928..d6d29ccbf1 100644 --- a/Tests/SharedTests/Publication/Services/PublicationServicesBuilderTests.swift +++ b/Tests/SharedTests/Publication/Services/PublicationServicesBuilderTests.swift @@ -9,7 +9,7 @@ import XCTest protocol FooService: PublicationService {} struct FooServiceA: FooService {} -class FooServiceB: FooService {} +final class FooServiceB: FooService {} struct FooServiceC: FooService { let wrapped: FooService? } protocol BarService: PublicationService {} diff --git a/Tests/SharedTests/Toolkit/Data/Resource/BufferingResourceTests.swift b/Tests/SharedTests/Toolkit/Data/Resource/BufferingResourceTests.swift index b0bdd9a759..b2bcff92b2 100644 --- a/Tests/SharedTests/Toolkit/Data/Resource/BufferingResourceTests.swift +++ b/Tests/SharedTests/Toolkit/Data/Resource/BufferingResourceTests.swift @@ -108,7 +108,7 @@ class BufferingResourceTests: XCTestCase { BufferingResource(resource: resource, bufferSize: bufferSize) } - func testRead(_ sut: BufferingResource, range: Range? = nil, file: StaticString = #file, line: UInt = #line) async throws { + func testRead(_ sut: BufferingResource, range: Range? = nil, file: StaticString = #filePath, line: UInt = #line) async throws { let res = await sut.read(range: range) let expected = await resource.read(range: range) XCTAssertEqual(res, expected, file: file, line: line) diff --git a/Tests/SharedTests/Toolkit/Data/Resource/TailCachingResourceTests.swift b/Tests/SharedTests/Toolkit/Data/Resource/TailCachingResourceTests.swift index 515d4dab5e..ee9cad48da 100644 --- a/Tests/SharedTests/Toolkit/Data/Resource/TailCachingResourceTests.swift +++ b/Tests/SharedTests/Toolkit/Data/Resource/TailCachingResourceTests.swift @@ -59,7 +59,7 @@ class TailCachingResourceTests: XCTestCase { TailCachingResource(resource: resource, cacheFromOffset: cacheFrom) } - func testRead(_ sut: TailCachingResource, range: Range? = nil, file: StaticString = #file, line: UInt = #line) async throws { + func testRead(_ sut: TailCachingResource, range: Range? = nil, file: StaticString = #filePath, line: UInt = #line) async throws { let res = await sut.read(range: range) let expected = await resource.read(range: range) XCTAssertEqual(res, expected, file: file, line: line) diff --git a/Tests/StreamerTests/Search/SearchServiceTests.swift b/Tests/StreamerTests/Search/SearchServiceTests.swift index b396f6ecf8..9816d2a59b 100644 --- a/Tests/StreamerTests/Search/SearchServiceTests.swift +++ b/Tests/StreamerTests/Search/SearchServiceTests.swift @@ -460,11 +460,11 @@ struct SearchServiceTests { // "alice" (case-insensitive) appears in chapter1, chapter2, // chapter3 — 3 results total. let iterator = try await pub.search(query: "alice", options: .init(caseSensitive: false)).get() - #expect(iterator.resultCount == 0) + #expect(await iterator.resultCount == 0) var total = 0 while let batch = try await iterator.next().get() { total += batch.locators.count - #expect(iterator.resultCount == total) + #expect(await iterator.resultCount == total) } #expect(total == 3) } From d4352c0e75d429e6bccd5de75fef93ab6917247a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Wed, 24 Jun 2026 16:25:37 +0200 Subject: [PATCH 18/39] Remove deprecated APIs (#826) --- Package.swift | 24 -- .../Adapters/GCDWebServer/GCDHTTPServer.swift | 368 ------------------ .../GCDWebServer/ResourceResponse.swift | 106 ----- Sources/Adapters/LCPSQLite/Database.swift | 38 -- .../SQLiteLCPLicenseRepository.swift | 175 --------- .../SQLiteLCPPassphraseRepository.swift | 107 ----- .../CBZ/CBZNavigatorViewController.swift | 297 -------------- .../Navigator/CBZ/ImageViewController.swift | 94 ----- .../DirectionalNavigationAdapter.swift | 50 --- .../EPUB/EPUBNavigatorViewController.swift | 25 -- .../PDF/PDFNavigatorViewController.swift | 11 - .../Viewport/ViewportObservingNavigator.swift | 14 - .../AccessibilityMetadataDisplayGuide.swift | 14 - Sources/Shared/Publication/Publication.swift | 2 +- .../Services/PublicationService.swift | 2 +- .../Services/Search/SearchService.swift | 2 +- .../Services/Search/StringSearchService.swift | 244 ------------ Sources/Shared/Toolkit/Closeable.swift | 37 -- Sources/Shared/Toolkit/Data/Asset/Asset.swift | 2 +- .../Toolkit/Data/Container/Container.swift | 2 +- Sources/Shared/Toolkit/Data/ReadError.swift | 11 - .../Data/Resource/BorrowedResource.swift | 43 -- .../Data/Resource/BufferingResource.swift | 10 - Sources/Shared/Toolkit/Data/Streamable.swift | 28 +- .../Shared/Toolkit/File/FileSystemError.swift | 14 - Sources/Shared/Toolkit/PDF/CGPDF.swift | 156 +------- Sources/Shared/Toolkit/PDF/PDFDocument.swift | 41 -- Sources/Shared/Toolkit/PDF/PDFKit.swift | 3 +- .../ReadiumAdapterGCDWebServer.podspec | 25 -- .../CocoaPods/ReadiumAdapterLCPSQLite.podspec | 26 -- Support/CocoaPods/Specs.swift | 23 -- scripts/release-publish-podspecs.sh | 4 +- 32 files changed, 24 insertions(+), 1974 deletions(-) delete mode 100644 Sources/Adapters/GCDWebServer/GCDHTTPServer.swift delete mode 100644 Sources/Adapters/GCDWebServer/ResourceResponse.swift delete mode 100644 Sources/Adapters/LCPSQLite/Database.swift delete mode 100644 Sources/Adapters/LCPSQLite/SQLiteLCPLicenseRepository.swift delete mode 100644 Sources/Adapters/LCPSQLite/SQLiteLCPPassphraseRepository.swift delete mode 100644 Sources/Navigator/CBZ/CBZNavigatorViewController.swift delete mode 100644 Sources/Navigator/CBZ/ImageViewController.swift delete mode 100644 Sources/Shared/Publication/Services/Search/StringSearchService.swift delete mode 100644 Sources/Shared/Toolkit/Closeable.swift delete mode 100644 Sources/Shared/Toolkit/Data/Resource/BorrowedResource.swift delete mode 100644 Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec delete mode 100644 Support/CocoaPods/ReadiumAdapterLCPSQLite.podspec diff --git a/Package.swift b/Package.swift index d4a4ed2be3..980f5ca498 100644 --- a/Package.swift +++ b/Package.swift @@ -17,20 +17,14 @@ let package = Package( .library(name: "ReadiumNavigator", targets: ["ReadiumNavigator"]), .library(name: "ReadiumOPDS", targets: ["ReadiumOPDS"]), .library(name: "ReadiumLCP", targets: ["ReadiumLCP"]), - - // Adapters to third-party dependencies. - .library(name: "ReadiumAdapterGCDWebServer", targets: ["ReadiumAdapterGCDWebServer"]), - .library(name: "ReadiumAdapterLCPSQLite", targets: ["ReadiumAdapterLCPSQLite"]), ], dependencies: [ .package(url: "https://github.com/krzyzanowskim/CryptoSwift.git", from: "1.10.0"), .package(url: "https://github.com/marmelroy/Zip.git", from: "2.1.2"), .package(url: "https://github.com/ra1028/DifferenceKit.git", from: "1.3.0"), .package(url: "https://github.com/readium/Fuzi.git", from: "4.0.0"), - .package(url: "https://github.com/readium/GCDWebServer.git", from: "4.0.0"), .package(url: "https://github.com/readium/ZIPFoundation.git", from: "3.0.1"), .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.13.5"), - .package(url: "https://github.com/stephencelis/SQLite.swift.git", from: "0.16.0"), .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.5.0"), ], targets: [ @@ -154,24 +148,6 @@ let package = Package( // path: "Tests/LCPTests" // ), - .target( - name: "ReadiumAdapterGCDWebServer", - dependencies: [ - .product(name: "ReadiumGCDWebServer", package: "GCDWebServer"), - "ReadiumShared", - ], - path: "Sources/Adapters/GCDWebServer" - ), - - .target( - name: "ReadiumAdapterLCPSQLite", - dependencies: [ - .product(name: "SQLite", package: "SQLite.swift"), - "ReadiumLCP", - ], - path: "Sources/Adapters/LCPSQLite" - ), - .target( name: "ReadiumInternal", path: "Sources/Internal" diff --git a/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift b/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift deleted file mode 100644 index d49899a8c6..0000000000 --- a/Sources/Adapters/GCDWebServer/GCDHTTPServer.swift +++ /dev/null @@ -1,368 +0,0 @@ -// -// Copyright 2026 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 -@preconcurrency import ReadiumGCDWebServer -import ReadiumInternal -import ReadiumShared -import UIKit - -@available(*, deprecated, message: "The Readium navigators do not need an HTTP server anymore. This adapter will be removed in a future version of the toolkit.") -public enum GCDHTTPServerError: Error, Sendable { - case failedToStartServer(cause: any Error) - case serverNotStarted - case invalidEndpoint(HTTPServerEndpoint) - case nullServerURL -} - -/// Implementation of `HTTPServer` using ReadiumGCDWebServer under the hood. -@available(*, deprecated, message: "The Readium navigators do not need an HTTP server anymore. This adapter will be removed in a future version of the toolkit.") -public final class GCDHTTPServer: HTTPServer, Loggable { - /// The actual underlying HTTP server instance. - private let server = ReadiumGCDWebServer() - - /// Mapping between endpoints and their handlers. - private var handlers: [HTTPURL: HTTPRequestHandler] = [:] - - /// Mapping between endpoints and resource transformers. - private var transformers: [HTTPURL: [ResourceTransformer]] = [:] - - private let assetRetriever: AssetRetriever - - private enum State { - case stopped - case started(port: UInt, baseURL: HTTPURL) - } - - private var state: State = .stopped - - /// Dispatch queue to protect accesses to the handlers, transformers and - /// state. - private let queue = DispatchQueue( - label: "org.readium.swift-toolkit.adapter.gcdwebserver", - attributes: .concurrent - ) - - /// Creates a new instance of the HTTP server. - /// - /// - Parameters: - /// - assetRetriever: The retriever used to fetch assets for the server. - /// - logLevel: See `ReadiumGCDWebServer.setLogLevel`. - public init( - assetRetriever: AssetRetriever, - logLevel: Int = 3 - ) { - self.assetRetriever = assetRetriever - - ReadiumGCDWebServer.setLogLevel(Int32(logLevel)) - - NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil) - - server.addDefaultHandler( - forMethod: "GET", - request: ReadiumGCDWebServerRequest.self, - asyncProcessBlock: { [weak self] request, completion in - self?.handle(request: request, completion: completion) - } - ) - } - - deinit { - NotificationCenter.default.removeObserver(self) - } - - @objc private func willEnterForeground(_ notification: Notification) { - // Restarts the server if it was stopped while the app was in the - // background. - queue.sync(flags: .barrier) { - guard - case let .started(port, _) = state, - isPortFree(port) - else { - return - } - - do { - try startWithPort(server.port) - } catch { - log(.error, error) - } - } - } - - private func handle(request: ReadiumGCDWebServerRequest, completion: @escaping ReadiumGCDWebServerCompletionBlock) { - responseResource(for: request) { httpServerRequest, httpServerResponse, failureHandler in - Task { - let response: ReadiumGCDWebServerResponse - let resource = httpServerResponse.resource - - func fail(_ error: ReadError) -> ReadiumGCDWebServerResponse { - self.log(.error, error) - failureHandler?(httpServerRequest, error) - return ReadiumGCDWebServerErrorResponse( - statusCode: 500, - error: error - ) - } - - switch await resource.length() { - case let .success(length): - response = await ResourceResponse( - resource: httpServerResponse.resource, - length: length, - range: request.hasByteRange() ? request.byteRange : nil, - mediaType: httpServerResponse.mediaType(using: self.assetRetriever) - ) - case let .failure(error): - response = fail(error) - } - - completion(response) // goes back to ReadiumGCDWebServerConnection.m - } - } - } - - private func responseResource( - for request: ReadiumGCDWebServerRequest, - completion: @escaping (HTTPServerRequest, HTTPServerResponse, HTTPRequestHandler.OnFailure?) -> Void - ) { - let dispatchCompletion = { (request: HTTPServerRequest, resource: HTTPServerResponse, failureHandler: HTTPRequestHandler.OnFailure?) in - // Escape the queue to avoid deadlocks if something is using the - // server in the handler. - DispatchQueue.global().async { - completion(request, resource, failureHandler) - } - } - - queue.async { [self] in - guard let url = request.url.httpURL else { - fatalError("Expected an HTTP URL") - } - - func transform(resource: Resource, request: HTTPServerRequest, at endpoint: HTTPURL) -> Resource { - guard let transformers = transformers[endpoint], !transformers.isEmpty else { - return resource - } - let href = request.href?.anyURL ?? request.url.anyURL - var resource = resource - for transformer in transformers { - resource = transformer(href, resource) - } - return resource - } - - let pathWithoutAnchor = url.removingQuery().removingFragment() - - for (endpoint, handler) in handlers { - let request: HTTPServerRequest - if endpoint.isEquivalentTo(pathWithoutAnchor) { - request = HTTPServerRequest(url: url, href: nil) - } else if let href = endpoint.relativize(url) { - request = HTTPServerRequest(url: url, href: href) - } else { - continue - } - - var response = handler.onRequest(request) - response.resource = transform(resource: response.resource, request: request, at: endpoint) - dispatchCompletion(request, response, handler.onFailure) - return - } - - log(.warning, "Resource not found for request \(request)") - dispatchCompletion( - HTTPServerRequest(url: url, href: nil), - HTTPServerResponse(error: .errorResponse(HTTPErrorResponse( - status: .notFound, - body: Data() - ))), - nil - ) - } - } - - // MARK: HTTPServer - - public func serve( - at endpoint: HTTPServerEndpoint, - handler: HTTPRequestHandler - ) throws -> HTTPURL { - try queue.sync(flags: .barrier) { - if case .stopped = state { - try start() - } - - let url = try url(for: endpoint) - handlers[url] = handler - return url - } - } - - 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[url] = trs - } - } - - 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 - - private func stop() { - dispatchPrecondition(condition: .onQueueAsBarrier(queue)) - server.stop() - state = .stopped - } - - private func start() throws { - func makeRandomPort() -> UInt { - // https://en.wikipedia.org/wiki/Ephemeral_port#Range - let lowerBound = 49152 - let upperBound = 65535 - return UInt(lowerBound + Int(arc4random_uniform(UInt32(upperBound - lowerBound)))) - } - - var attemptsLeft = 50 - while attemptsLeft > 0 { - attemptsLeft -= 1 - - do { - try startWithPort(makeRandomPort()) - return - } catch { - log(.error, error) - if attemptsLeft == 0 { - throw error - } - } - } - } - - private func startWithPort(_ port: UInt) throws { - dispatchPrecondition(condition: .onQueueAsBarrier(queue)) - - stop() - - do { - try server.start(options: [ - ReadiumGCDWebServerOption_Port: port, - ReadiumGCDWebServerOption_BindToLocalhost: true, - // We disable automatically suspending the server in the - // background, to be able to play audiobooks even with the - // screen locked. - ReadiumGCDWebServerOption_AutomaticallySuspendInBackground: false, - ]) - } catch { - throw GCDHTTPServerError.failedToStartServer(cause: error) - } - - guard let baseURL = server.serverURL?.httpURL else { - stop() - throw GCDHTTPServerError.nullServerURL - } - - state = .started(port: server.port, baseURL: baseURL) - } - - /// Checks if the given port is already taken (presumabily by the server). - /// Inspired by https://stackoverflow.com/questions/33086356/swift-2-check-if-port-is-busy - private func isPortFree(_ port: UInt) -> Bool { - let port = in_port_t(port) - - let socketDescriptor = socket(AF_INET, SOCK_STREAM, 0) - if socketDescriptor == -1 { - // Just in case, returns true to attempt restarting the server. - return true - } - defer { - Darwin.shutdown(socketDescriptor, SHUT_RDWR) - close(socketDescriptor) - } - - let addrSize = MemoryLayout.size - var addr = sockaddr_in() - addr.sin_len = __uint8_t(addrSize) - addr.sin_family = sa_family_t(AF_INET) - addr.sin_port = Int(OSHostByteOrder()) == OSLittleEndian ? _OSSwapInt16(port) : port - addr.sin_addr = in_addr(s_addr: inet_addr("0.0.0.0")) - addr.sin_zero = (0, 0, 0, 0, 0, 0, 0, 0) - var bindAddr = sockaddr() - memcpy(&bindAddr, &addr, Int(addrSize)) - - if Darwin.bind(socketDescriptor, &bindAddr, socklen_t(addrSize)) == -1 { - // "Address already in use", the server is already started - if errno == EADDRINUSE { - return false - } - } - - // It might not actually be free, but we'll try to restart the server. - return true - } -} - -private extension Resource { - func length() async -> ReadResult { - await estimatedLength() - .asyncFlatMap { length in - if let length = length { - return .success(length) - } else { - return await read().map { UInt64($0.count) } - } - } - } -} - -private extension HTTPServerResponse { - func mediaType(using assetRetriever: AssetRetriever) async -> MediaType { - if let mediaType = mediaType { - return mediaType - } - - if let properties = try? await resource.properties().get() { - if let mediaType = properties.mediaType { - return mediaType - } - if - let filename = properties.filename, - let uti = UTI.findFrom(mediaTypes: [], fileExtensions: [URL(fileURLWithPath: filename).pathExtension]), - let type = uti.preferredTag(withClass: .mediaType), - let mediaType = MediaType(type) - { - return mediaType - } - } - - if let mediaType = try? await assetRetriever.sniffFormat(of: resource).get().mediaType { - return mediaType - } - - return .binary - } -} diff --git a/Sources/Adapters/GCDWebServer/ResourceResponse.swift b/Sources/Adapters/GCDWebServer/ResourceResponse.swift deleted file mode 100644 index 9bdac0d14e..0000000000 --- a/Sources/Adapters/GCDWebServer/ResourceResponse.swift +++ /dev/null @@ -1,106 +0,0 @@ -// -// Copyright 2026 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 ReadiumGCDWebServer -import ReadiumShared - -/// The object containing the response's ressource data. -/// If the ressource to be served is too big, multiple responses will be created. -class ResourceResponse: ReadiumGCDWebServerResponse, Loggable { - private let bufferSize = 32 * 1024 - - private var resource: Resource - private var range: Range - private let length: UInt64 - private var offset: UInt64 = 0 - private var lastReadData: ReadResult? - private lazy var totalNumberOfBytesRead = UInt64(0) - - init(resource: Resource, length: UInt64, range: NSRange?, mediaType: MediaType) { - self.resource = resource - self.length = length - - // If range is non nil - means it's not the first part (?) - if let range = range { - /// Return a range of what to read next (nothing, next part, whole data). - func getNextRange(after range: NSRange, - forStreamOfLength streamLength: UInt64) -> Range - { - let newRange: Range - - if range.location == Int.max { - let len = min(UInt64(range.length), streamLength) - - newRange = (streamLength - len) ..< streamLength - } else if range.location < 0 { - // Negative range locations are not supported. We return - // the whole data for now. - newRange = 0 ..< streamLength - } else { - let currentPosition = min(UInt64(range.location), streamLength) - let remainingLength = streamLength - currentPosition - let length: UInt64 - - if range.length == -1 { - length = remainingLength - } else { - length = min(UInt64(range.length), remainingLength) - } - newRange = currentPosition ..< (currentPosition + length) - } - return newRange - } - self.range = getNextRange(after: range, - forStreamOfLength: length) - } else /* nil */ { - self.range = 0 ..< length - } - - super.init() - - contentType = mediaType.string - - // Disable HTTP caching for publication resources, because it poses a security threat for protected - // publications. - setValue("no-cache, no-store, must-revalidate", forAdditionalHeader: "Cache-Control") - setValue("no-cache", forAdditionalHeader: "Pragma") - setValue("0", forAdditionalHeader: "Expires") - - // Response - let lower = self.range.lowerBound - let upper = (self.range.upperBound != 0) ? self.range.upperBound - 1 : self.range.upperBound - let contentRange = "bytes \(lower)-\(upper)/\(length)" - let acceptRange = "bytes" - - statusCode = 206 - setValue(contentRange, forAdditionalHeader: "Content-Range") - setValue(acceptRange, forAdditionalHeader: "Accept-Ranges") - contentLength = UInt(self.range.count) - } - - override open func open() throws { - offset = range.lowerBound - } - - /// Read a new chunk of data. - override func asyncReadData() async throws -> Data { - let len = min(bufferSize, range.count - Int(totalNumberOfBytesRead)) - // If nothing to read, return - guard len > 0, offset < length else { - lastReadData = .success(Data()) - return Data() - } - // Read - lastReadData = await resource.read(range: offset ..< (offset + UInt64(len))) - if case let .success(data) = lastReadData { - totalNumberOfBytesRead += UInt64(data.count) - offset += UInt64(data.count) - } - - return (try? lastReadData?.get()) ?? Data() - } -} diff --git a/Sources/Adapters/LCPSQLite/Database.swift b/Sources/Adapters/LCPSQLite/Database.swift deleted file mode 100644 index 4afd1f3406..0000000000 --- a/Sources/Adapters/LCPSQLite/Database.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// Copyright 2026 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 SQLite - -final class Database { - /// Shared instance. - static let shared: Swift.Result = { - do { - return try .success(Database()) - } catch { - return .failure(error) - } - }() - - let connection: Connection - - private init() throws { - var url = try FileManager.default.url( - for: .libraryDirectory, - in: .userDomainMask, - appropriateFor: nil, create: true - ) - url.appendPathComponent("lcpdatabase.sqlite") - connection = try Connection(url.absoluteString) - } -} - -extension Connection { - var userVersion: Int32 { - get { Int32(try! scalar("PRAGMA user_version") as! Int64) } - set { try! run("PRAGMA user_version = \(newValue)") } - } -} diff --git a/Sources/Adapters/LCPSQLite/SQLiteLCPLicenseRepository.swift b/Sources/Adapters/LCPSQLite/SQLiteLCPLicenseRepository.swift deleted file mode 100644 index ad44e0ccfb..0000000000 --- a/Sources/Adapters/LCPSQLite/SQLiteLCPLicenseRepository.swift +++ /dev/null @@ -1,175 +0,0 @@ -// -// Copyright 2026 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 ReadiumLCP -import ReadiumShared -@preconcurrency import SQLite - -@available(*, deprecated, message: "Use LCPKeychainLicenseRepository from ReadiumLCP instead") -public final class LCPSQLiteLicenseRepository: LCPLicenseRepository, Loggable, Sendable { - let licenses = Table("Licenses") - let id = SQLite.Expression("id") - let printsLeft = SQLite.Expression("printsLeft") - let copiesLeft = SQLite.Expression("copiesLeft") - let registered = SQLite.Expression("registered") - - private let db: Connection - - public init() throws { - db = try Database.shared.get().connection - - try db.run(licenses.create(temporary: false, ifNotExists: true) { t in - t.column(id, unique: true) - t.column(printsLeft) - t.column(copiesLeft) - }) - - if db.userVersion == 0 { - try db.run(licenses.addColumn(registered, defaultValue: false)) - db.userVersion = 1 - } - if db.userVersion == 1 { - // This migration is empty because it got deprecated... - db.userVersion = 2 - } - } - - public func addLicense(_ licenseDocument: LicenseDocument) async throws { - guard !exists(licenseDocument.id) else { - return - } - - let query = licenses.insert( - id <- licenseDocument.id, - printsLeft <- licenseDocument.rights.print, - copiesLeft <- licenseDocument.rights.copy - ) - try db.run(query) - } - - public func license(for id: LicenseDocument.ID) async throws -> LicenseDocument? { - // Note: this was not implemented with the legacy SQLite repository, so - // we don't have the license in the database. - nil - } - - public func isDeviceRegistered(for id: LicenseDocument.ID) async throws -> Bool { - try checkExists(id) - let count = try db.scalar(licenses.filter(self.id == id && registered == true).count) - return count != 0 - } - - public func registerDevice(for id: LicenseDocument.ID) async throws { - try checkExists(id) - let filterLicense = licenses.filter(self.id == id) - try db.run(filterLicense.update(registered <- true)) - } - - public func userRights(for id: LicenseDocument.ID) async throws -> LCPConsumableUserRights { - try getRights(for: id) - } - - public func updateUserRights(for id: LicenseDocument.ID, with changes: (inout LCPConsumableUserRights) -> Void) async throws { - try db.transaction { - let rights = try getRights(for: id) - - var newRights = rights - changes(&newRights) - - if rights.copy != newRights.copy { - try set(copiesLeft, to: newRights.copy, for: id) - } - - if rights.print != newRights.print { - try set(printsLeft, to: newRights.print, for: id) - } - } - } - - private func checkExists(_ licenseID: LicenseDocument.ID) throws { - guard exists(licenseID) else { - throw LCPError.runtime("The LCP License doesn't exist in the database") - } - } - - private func exists(_ licenseID: LicenseDocument.ID) -> Bool { - ((try? db.scalar(licenses.filter(id == licenseID).count)) ?? 0) != 0 - } - - private func get(_ column: SQLite.Expression, for licenseId: String) throws -> Int? { - let query = licenses.select(column).filter(id == licenseId) - for row in try db.prepare(query) { - return try row.get(column) - } - return nil - } - - private func set(_ column: SQLite.Expression, to value: Int?, for licenseId: String) throws { - let filterLicense = licenses.filter(id == licenseId) - try db.run(filterLicense.update(column <- value)) - } - - private func getRights(for id: LicenseDocument.ID) throws -> LCPConsumableUserRights { - try LCPConsumableUserRights( - print: get(printsLeft, for: id), - copy: get(copiesLeft, for: id) - ) - } - - /// Migrates all licenses from this SQLite repository to the target - /// keychain repository. - /// - /// This migration transfers consumable rights (print/copy counts) and - /// device registration status to the target repository. The full - /// `LicenseDocument` is not stored in SQLite and will be automatically - /// added to the target repository when each publication is opened - /// for the first time after migration. - /// - /// - Returns: `true` if all the licenses were migrated successfully. - @discardableResult - public func migrate(to target: LCPKeychainLicenseRepository) async throws -> Bool { - let allLicenseData = try db.prepare(licenses).map { row in - try ( - id: row.get(id), - printsLeft: row.get(printsLeft), - copiesLeft: row.get(copiesLeft), - registered: row.get(registered) - ) - } - - var successCount = 0 - var failureCount = 0 - - for licenseData in allLicenseData { - do { - let rights = LCPConsumableUserRights( - print: licenseData.printsLeft, - copy: licenseData.copiesLeft - ) - - try await target.importLicenseRights( - for: licenseData.id, - rights: rights, - registered: licenseData.registered - ) - - successCount += 1 - } catch { - failureCount += 1 - log(.error, "Failed to migrate license \(licenseData.id): \(error)") - } - } - - if failureCount > 0 { - log(.info, "License migration completed with \(successCount) succeeded, \(failureCount) failed") - } else { - log(.info, "License migration completed successfully: \(successCount) licenses migrated") - } - - return failureCount == 0 - } -} diff --git a/Sources/Adapters/LCPSQLite/SQLiteLCPPassphraseRepository.swift b/Sources/Adapters/LCPSQLite/SQLiteLCPPassphraseRepository.swift deleted file mode 100644 index 2305ae467c..0000000000 --- a/Sources/Adapters/LCPSQLite/SQLiteLCPPassphraseRepository.swift +++ /dev/null @@ -1,107 +0,0 @@ -// -// Copyright 2026 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 ReadiumLCP -import ReadiumShared -@preconcurrency import SQLite - -@available(*, deprecated, message: "Use LCPKeychainPassphraseRepository from ReadiumLCP instead") -public final class LCPSQLitePassphraseRepository: LCPPassphraseRepository, Loggable, Sendable { - let transactions = Table("Transactions") - let licenseId = SQLite.Expression("licenseId") - let provider = SQLite.Expression("origin") - let userId = SQLite.Expression("userId") - let passphrase = SQLite.Expression("passphrase") // hashed. - - private let db: Connection - - public init() throws { - db = try Database.shared.get().connection - - try db.run(transactions.create(temporary: false, ifNotExists: true) { t in - t.column(licenseId) - t.column(provider) - t.column(userId) - t.column(passphrase) - }) - } - - public func passphrasesMatching(userID: User.ID?, provider: LicenseDocument.Provider) async throws -> [LCPPassphraseHash] { - try logAndRethrow { - try db.prepare(transactions.select(passphrase) - .filter(self.userId == userID && self.provider == provider)) - .compactMap { try $0.get(passphrase) } - } - } - - public func passphrases() async throws -> [LCPPassphraseHash] { - try logAndRethrow { - try db.prepare(transactions.select(passphrase)) - .compactMap { try $0.get(passphrase) } - } - } - - public func addPassphrase(_ hash: LCPPassphraseHash, userID: User.ID?, provider: LicenseDocument.Provider?) async throws { - try logAndRethrow { - // The legacy schema requires a non-null `licenseId` and `origin`. - // The repository no longer tracks a license, and `licenseId` is no - // longer read back, so we store a synthetic identifier. The table - // has no unique constraint, so this appends a row like before. - try db.run( - transactions.insert( - or: .replace, - self.passphrase <- hash, - self.licenseId <- UUID().uuidString, - self.provider <- (provider ?? ""), - self.userId <- userID - ) - ) - } - } - - /// Migrates all passphrases from this SQLite repository to the target - /// repository. - /// - /// - Returns: `true` if all the passphrases were migrated successfully. - @discardableResult - public func migrate(to target: LCPPassphraseRepository) async throws -> Bool { - let allPassphraseData = try db.prepare(transactions).map { row in - try ( - licenseId: row.get(licenseId), - passphrase: row.get(passphrase), - provider: row.get(provider), - userId: row.get(userId) - ) - } - - var successCount = 0 - var failureCount = 0 - - for passphraseData in allPassphraseData { - do { - try await target.addPassphrase( - passphraseData.passphrase, - userID: passphraseData.userId, - provider: passphraseData.provider - ) - successCount += 1 - } catch { - failureCount += 1 - // Log error but continue with other passphrases - log(.error, "Failed to migrate passphrase for license \(passphraseData.licenseId): \(error)") - } - } - - if failureCount > 0 { - log(.info, "Passphrase migration completed with \(successCount) succeeded, \(failureCount) failed") - } else { - log(.info, "Passphrase migration completed successfully: \(successCount) passphrases migrated") - } - - return failureCount == 0 - } -} diff --git a/Sources/Navigator/CBZ/CBZNavigatorViewController.swift b/Sources/Navigator/CBZ/CBZNavigatorViewController.swift deleted file mode 100644 index c539f87fcf..0000000000 --- a/Sources/Navigator/CBZ/CBZNavigatorViewController.swift +++ /dev/null @@ -1,297 +0,0 @@ -// -// Copyright 2026 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 ReadiumInternal -import ReadiumShared -import UIKit - -@available(*, deprecated, message: "Open a CBZ publication with EPUBNavigatorViewController.") -public protocol CBZNavigatorDelegate: VisualNavigatorDelegate {} - -/// A view controller used to render a CBZ `Publication`. -@available(*, deprecated, message: "Open a CBZ publication with EPUBNavigatorViewController.") -open class CBZNavigatorViewController: - InputObservableViewController, - VisualNavigator, Loggable -{ - enum Error: Swift.Error { - /// The provided publication is restricted. Check that any DRM was - /// properly unlocked using a Content Protection. - case publicationRestricted - } - - public weak var delegate: CBZNavigatorDelegate? - - public let publication: Publication - private let initialIndex: Int - private var positions: [Locator]? - - private let pageViewController: UIPageViewController - - private let server: HTTPServer? - private let publicationEndpoint: HTTPServerEndpoint? - private var publicationBaseURL: HTTPURL! - - public convenience init( - publication: Publication, - initialLocation: Locator?, - editingActions: [EditingAction] = EditingAction.defaultActions, - httpServer: HTTPServer - ) throws { - guard !publication.isRestricted else { - throw Error.publicationRestricted - } - - let publicationEndpoint: HTTPServerEndpoint? - let uuidEndpoint = UUID().uuidString - if publication.baseURL != nil { - publicationEndpoint = nil - } else { - publicationEndpoint = uuidEndpoint - } - - self.init( - publication: publication, - initialLocation: initialLocation, - httpServer: httpServer, - publicationEndpoint: publicationEndpoint - ) - - if let url = publication.baseURL { - publicationBaseURL = url - } else { - publicationBaseURL = try httpServer.serve( - at: uuidEndpoint, - publication: publication, - onFailure: { [weak self] request, error in - DispatchQueue.main.async { [weak self] in - guard let self = self, let href = request.href else { - return - } - self.delegate?.navigator(self, didFailToLoadResourceAt: href, withError: error) - } - } - ) - } - } - - private let tasks = CancellableTasks() - - private init( - publication: Publication, - initialLocation: Locator?, - httpServer: HTTPServer?, - publicationEndpoint: HTTPServerEndpoint? - ) { - self.publication = publication - server = httpServer - self.publicationEndpoint = publicationEndpoint - - initialIndex = { - guard let initialLocation = initialLocation, let initialIndex = publication.readingOrder.firstIndexWithHREF(initialLocation.href) else { - return 0 - } - return initialIndex - }() - - pageViewController = UIPageViewController( - transitionStyle: .scroll, - navigationOrientation: .horizontal - ) - - super.init(nibName: nil, bundle: nil) - - setupLegacyInputCallbacks( - onTap: { [weak self] point in - guard let self else { return } - self.delegate?.navigator(self, didTapAt: point) - }, - onPressKey: { [weak self] event in - guard let self else { return } - self.delegate?.navigator(self, didPressKey: event) - }, - onReleaseKey: { [weak self] event in - guard let self else { return } - self.delegate?.navigator(self, didReleaseKey: event) - } - ) - } - - private func didLoadPositions(_ positions: [Locator]?) { - self.positions = positions ?? [] - } - - @available(*, unavailable) - public required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - if let endpoint = publicationEndpoint { - try? server?.remove(at: endpoint) - } - } - - override open func viewDidLoad() { - super.viewDidLoad() - - pageViewController.dataSource = self - pageViewController.delegate = self - - addChild(pageViewController) - pageViewController.view.frame = view.bounds - pageViewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] - view.addSubview(pageViewController.view) - pageViewController.didMove(toParent: self) - - view.addGestureRecognizer(InputObservingGestureRecognizerAdapter(observer: inputObservers)) - - tasks.add { - try? await didLoadPositions(publication.positions().get()) - await goToResourceAtIndex(initialIndex, options: NavigatorGoOptions(animated: false), isJump: false) - } - } - - private var currentResourceIndex: Int { - guard - let positions = positions, - let imageViewController = pageViewController.viewControllers?.first as? ImageViewController, - positions.indices.contains(imageViewController.index) - else { - return initialIndex - } - return imageViewController.index - } - - @discardableResult - private func goToResourceAtIndex(_ index: Int, options: NavigatorGoOptions, isJump: Bool) async -> Bool { - guard let imageViewController = imageViewController(at: index) else { - return false - } - let direction: UIPageViewController.NavigationDirection = { - let forward: Bool = { - switch readingProgression { - case .ltr, .ttb, .auto: - return currentResourceIndex < index - case .rtl, .btt: - return currentResourceIndex >= index - } - }() - return forward ? .forward : .reverse - }() - - await withCheckedContinuation { continuation in - pageViewController.setViewControllers([imageViewController], direction: direction, animated: options.animated) { [weak self] _ in - guard let self = self, let position = self.currentLocation else { - return - } - self.delegate?.navigator(self, locationDidChange: position) - if isJump { - self.delegate?.navigator(self, didJumpTo: position) - } - continuation.resume() - } - } - - return true - } - - private func imageViewController(at index: Int) -> ImageViewController? { - guard publication.readingOrder.indices.contains(index) else { - return nil - } - let url = publication.readingOrder[index].url(relativeTo: publicationBaseURL) - return ImageViewController(index: index, url: url.url) - } - - // MARK: - Navigator - - public var presentation: VisualNavigatorPresentation { - VisualNavigatorPresentation( - readingProgression: ReadingProgression(publication.metadata.readingProgression) ?? .ltr, - scroll: false, - axis: .horizontal - ) - } - - public var readingProgression: ReadiumShared.ReadingProgression { - ReadiumShared.ReadingProgression(presentation.readingProgression) - } - - public var currentLocation: Locator? { - guard - let positions = positions, - positions.indices.contains(currentResourceIndex) - else { - return nil - } - return positions[currentResourceIndex] - } - - public func go(to locator: Locator, options: NavigatorGoOptions) async -> Bool { - let locator = publication.normalizeLocator(locator) - - guard let index = publication.readingOrder.firstIndexWithHREF(locator.href) else { - return false - } - return await goToResourceAtIndex(index, options: options, isJump: true) - } - - public func go(to link: Link, options: NavigatorGoOptions) async -> Bool { - guard let index = publication.readingOrder.firstIndexWithHREF(link.url()) else { - return false - } - return await goToResourceAtIndex(index, options: options, isJump: true) - } - - public func goForward(options: NavigatorGoOptions) async -> Bool { - await goToResourceAtIndex(currentResourceIndex + 1, options: options, isJump: false) - } - - public func goBackward(options: NavigatorGoOptions) async -> Bool { - await goToResourceAtIndex(currentResourceIndex - 1, options: options, isJump: false) - } -} - -@available(*, deprecated, message: "Open a CBZ publication with EPUBNavigatorViewController.") -extension CBZNavigatorViewController: UIPageViewControllerDataSource { - public func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { - guard let imageVC = viewController as? ImageViewController else { - return nil - } - var index = imageVC.index - switch readingProgression { - case .ltr, .ttb, .auto: - index -= 1 - case .rtl, .btt: - index += 1 - } - return imageViewController(at: index) - } - - public func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { - guard let imageVC = viewController as? ImageViewController else { - return nil - } - var index = imageVC.index - switch readingProgression { - case .ltr, .ttb, .auto: - index += 1 - case .rtl, .btt: - index -= 1 - } - return imageViewController(at: index) - } -} - -@available(*, deprecated, message: "Open a CBZ publication with EPUBNavigatorViewController.") -extension CBZNavigatorViewController: UIPageViewControllerDelegate { - public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) { - if completed, let position = currentLocation { - delegate?.navigator(self, locationDidChange: position) - } - } -} diff --git a/Sources/Navigator/CBZ/ImageViewController.swift b/Sources/Navigator/CBZ/ImageViewController.swift deleted file mode 100644 index 3d6644487a..0000000000 --- a/Sources/Navigator/CBZ/ImageViewController.swift +++ /dev/null @@ -1,94 +0,0 @@ -// -// Copyright 2026 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 ReadiumShared -import UIKit - -/// Zoomable image view controller. -final class ImageViewController: UIViewController, Loggable { - /// Index of the resource. - let index: Int - - /// URL to the image to display. - private let url: URL - - private var scrollView: UIScrollView! - private var imageView: UIImageView! - - init(index: Int, url: URL) { - self.index = index - self.url = url - - super.init(nibName: nil, bundle: nil) - } - - @available(*, unavailable) - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - - view.backgroundColor = .clear - - scrollView = UIScrollView(frame: view.bounds) - scrollView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - scrollView.backgroundColor = .clear - scrollView.delegate = self - scrollView.minimumZoomScale = 1.0 - scrollView.maximumZoomScale = 4.0 - view.addSubview(scrollView) - - imageView = UIImageView(frame: scrollView.bounds) - imageView.backgroundColor = .clear - imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - imageView.contentMode = .scaleAspectFit - scrollView.addSubview(imageView) - - // Adds an empty view before the scroll view to have a consistent behavior on all iOS - // versions, regarding to the content inset adjustements. Even if - // automaticallyAdjustsScrollViewInsets is not set to false on the navigator's parent view - // controller, the scroll view insets won't be adjusted if the scroll view is not the first - // child in the subviews hierarchy. - view.insertSubview(UIView(frame: .zero), at: 0) - // Prevents the pages from jumping down when the status bar is toggled - scrollView.contentInsetAdjustmentBehavior = .never - - loadURL() - } - - private func loadURL() { - URLSession.shared.dataTask(with: url) { data, _, error in - guard let data = data, let image = UIImage(data: data) else { - if let error = error { - self.log(.error, error) - } else { - self.log(.error, "Can't load resource at \(self.url)") - } - return - } - - DispatchQueue.main.async { - UIView.transition( - with: self.imageView, - duration: 0.1, - options: .transitionCrossDissolve, - animations: { - self.imageView.image = image - } - ) - } - }.resume() - } -} - -extension ImageViewController: UIScrollViewDelegate { - func viewForZooming(in scrollView: UIScrollView) -> UIView? { - imageView - } -} diff --git a/Sources/Navigator/DirectionalNavigationAdapter.swift b/Sources/Navigator/DirectionalNavigationAdapter.swift index 574052bb01..588328c7e7 100644 --- a/Sources/Navigator/DirectionalNavigationAdapter.swift +++ b/Sources/Navigator/DirectionalNavigationAdapter.swift @@ -13,9 +13,6 @@ import Foundation /// This takes into account the reading progression of the navigator to turn /// pages in the right direction. @MainActor public final class DirectionalNavigationAdapter { - @available(*, deprecated, renamed: "Edges") - public typealias TapEdges = Edges - /// Indicates which viewport edges trigger page turns on pointer activation. public struct Edges: OptionSet, Sendable { /// The user can turn pages when tapping on the edges of both the @@ -115,9 +112,6 @@ import Foundation private var observerTokens: Set = [] private weak var boundNavigator: (any VisualNavigator)? - @available(*, deprecated, message: "Use `bind(to:)` instead of notifying the event yourself. See the migration guide.") - private weak var navigator: VisualNavigator? - /// Initializes a new `DirectionalNavigationAdapter`. /// /// - Parameters: @@ -274,48 +268,4 @@ import Foundation let options = NavigatorGoOptions(animated: animatedTransition) return await action(options) } - - @available(*, deprecated, message: "Use the new initializer without the navigator parameter and call `bind(to:)`. See the migration guide.") - public init( - navigator: VisualNavigator, - tapEdges: Edges = .horizontal, - handleTapsWhileScrolling: Bool = false, - minimumHorizontalEdgeSize: Double = 80.0, - horizontalEdgeThresholdPercent: Double? = 0.3, - minimumVerticalEdgeSize: Double = 80.0, - verticalEdgeThresholdPercent: Double? = 0.3, - animatedTransition: Bool = false - ) { - self.navigator = navigator - pointerPolicy = PointerPolicy( - types: [.touch, .mouse], - edges: tapEdges, - ignoreWhileScrolling: !handleTapsWhileScrolling, - minimumHorizontalEdgeSize: minimumHorizontalEdgeSize, - horizontalEdgeThresholdPercent: horizontalEdgeThresholdPercent, - minimumVerticalEdgeSize: minimumVerticalEdgeSize, - verticalEdgeThresholdPercent: verticalEdgeThresholdPercent - ) - keyboardPolicy = KeyboardPolicy() - self.animatedTransition = animatedTransition - onNavigation = {} - } - - @available(*, deprecated, message: "Use `bind(to:)` instead of notifying the event yourself. See the migration guide.") - @discardableResult - public func didTap(at point: CGPoint) async -> Bool { - guard let navigator = navigator else { - return false - } - return await onTap(at: point, in: navigator) - } - - @available(*, deprecated, message: "Use `bind(to:)` instead of notifying the event yourself. See the migration guide.") - @discardableResult - public func didPressKey(event: KeyEvent) async -> Bool { - guard let navigator = navigator else { - return false - } - return await onKey(event, in: navigator) - } } diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift index c88802dbd3..dff7f49b7a 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift @@ -37,11 +37,6 @@ open class EPUBNavigatorViewController: InputObservableViewController, /// Returned when calling evaluateJavaScript() before a resource is /// loaded. case spreadNotLoaded - - /// Failed to serve the publication or assets with the provided HTTP - /// server. - @available(*, deprecated, message: "The HTTP server is no longer needed for the EPUB navigator.") - case serverFailure(Error) } public struct Configuration { @@ -144,9 +139,6 @@ open class EPUBNavigatorViewController: InputObservableViewController, } } - @available(*, deprecated, renamed: "NavigatorViewport") - public typealias Viewport = NavigatorViewport - /// Navigation state. private enum State: Equatable { /// Initializing the navigator. @@ -307,23 +299,6 @@ open class EPUBNavigatorViewController: InputObservableViewController, ) } - /// Creates a new instance of `EPUBNavigatorViewController`. - @available(*, deprecated, message: "The HTTP server is no longer needed for the EPUB navigator.") - public convenience init( - publication: Publication, - initialLocation: Locator?, - readingOrder: [Link]? = nil, - config: Configuration = .init(), - httpServer: HTTPServer - ) throws { - try self.init( - publication: publication, - initialLocation: initialLocation, - readingOrder: readingOrder, - config: config - ) - } - private init( viewModel: EPUBNavigatorViewModel, initialLocation: Locator?, diff --git a/Sources/Navigator/PDF/PDFNavigatorViewController.swift b/Sources/Navigator/PDF/PDFNavigatorViewController.swift index ec1f47471c..6689308bbd 100644 --- a/Sources/Navigator/PDF/PDFNavigatorViewController.swift +++ b/Sources/Navigator/PDF/PDFNavigatorViewController.swift @@ -106,17 +106,6 @@ open class PDFNavigatorViewController: editingActions.delegate = self } - @available(*, deprecated, message: "The httpServer is not needed anymore.") - public convenience init( - publication: Publication, - initialLocation: Locator?, - config: Configuration = .init(), - delegate: PDFNavigatorDelegate? = nil, - httpServer: HTTPServer? - ) throws { - try self.init(publication: publication, initialLocation: initialLocation, config: config, delegate: delegate) - } - @available(*, unavailable) public required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") diff --git a/Sources/Navigator/Viewport/ViewportObservingNavigator.swift b/Sources/Navigator/Viewport/ViewportObservingNavigator.swift index 020b8d3fd3..370daa20b6 100644 --- a/Sources/Navigator/Viewport/ViewportObservingNavigator.swift +++ b/Sources/Navigator/Viewport/ViewportObservingNavigator.swift @@ -66,18 +66,4 @@ public struct NavigatorViewport: Equatable, Sendable { self.progression = progression } } - - // MARK: - Deprecated - - /// Visible reading order resource HREFs. - @available(*, deprecated, message: "Use resources instead") - public var readingOrder: [AnyURL] { - resources.map(\.href) - } - - /// Range of visible scroll progressions for each visible reading order resource. - @available(*, deprecated, message: "Use resources instead") - public var progressions: [AnyURL: ClosedRange] { - Dictionary(resources.map { ($0.href, $0.progression) }, uniquingKeysWith: { $1 }) - } } diff --git a/Sources/Shared/Publication/Accessibility/AccessibilityMetadataDisplayGuide.swift b/Sources/Shared/Publication/Accessibility/AccessibilityMetadataDisplayGuide.swift index 3da622872f..62aa34b468 100644 --- a/Sources/Shared/Publication/Accessibility/AccessibilityMetadataDisplayGuide.swift +++ b/Sources/Shared/Publication/Accessibility/AccessibilityMetadataDisplayGuide.swift @@ -1050,17 +1050,3 @@ private extension Array where Element == AccessibilityDisplayStatement { append(AccessibilityDisplayStatement(string: string)) } } - -// MARK: - Deprecated Aliases - -public extension AccessibilityDisplayString { - @available(*, deprecated, renamed: "richContentExtendedDescriptions") - static var richContentExtended: Self { - richContentExtendedDescriptions - } - - @available(*, deprecated, renamed: "richContentMathAsMathml") - static var richContentAccessibleMathAsMathml: Self { - richContentMathAsMathml - } -} diff --git a/Sources/Shared/Publication/Publication.swift b/Sources/Shared/Publication/Publication.swift index 4e209d7f87..32e059b1a0 100644 --- a/Sources/Shared/Publication/Publication.swift +++ b/Sources/Shared/Publication/Publication.swift @@ -9,7 +9,7 @@ import Foundation import ReadiumInternal /// Shared model for a Readium Publication. -public final class Publication: Sendable, Closeable, Loggable { +public final class Publication: Sendable, Loggable { public let manifest: Manifest private let container: Container private let services: [PublicationService] diff --git a/Sources/Shared/Publication/Services/PublicationService.swift b/Sources/Shared/Publication/Services/PublicationService.swift index d145c11287..5e74c5df64 100644 --- a/Sources/Shared/Publication/Services/PublicationService.swift +++ b/Sources/Shared/Publication/Services/PublicationService.swift @@ -7,7 +7,7 @@ import Foundation /// Base interface to be implemented by all publication services. -public protocol PublicationService: Sendable, Closeable { +public protocol PublicationService: Sendable { /// Links which will be added to `Publication.links`. /// It can be used to expose a web API for the service, through `Publication.get()`. /// diff --git a/Sources/Shared/Publication/Services/Search/SearchService.swift b/Sources/Shared/Publication/Services/Search/SearchService.swift index b017fccce1..a19b61bb6d 100644 --- a/Sources/Shared/Publication/Services/Search/SearchService.swift +++ b/Sources/Shared/Publication/Services/Search/SearchService.swift @@ -22,7 +22,7 @@ public protocol SearchService: PublicationService { } /// Iterates through search results. -public protocol SearchIterator: AnyObject, Sendable, Closeable { +public protocol SearchIterator: AnyObject, Sendable { /// Number of matches for this search, if known. /// /// Depending on the search algorithm, it may not be possible to know the result count until reaching the end of the diff --git a/Sources/Shared/Publication/Services/Search/StringSearchService.swift b/Sources/Shared/Publication/Services/Search/StringSearchService.swift deleted file mode 100644 index 1520ae80d3..0000000000 --- a/Sources/Shared/Publication/Services/Search/StringSearchService.swift +++ /dev/null @@ -1,244 +0,0 @@ -// -// Copyright 2026 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 - -/// Base implementation of `SearchService` iterating through the content of -/// Publication's resources. -/// -/// To stay media-type-agnostic, `StringSearchService` relies on -/// `ResourceContentExtractor` implementations to retrieve the pure text -/// content from markups (e.g. HTML) or binary (e.g. PDF) resources. -/// -/// The actual search is implemented by the provided `searchAlgorithm`. -@available(*, deprecated, renamed: "ContentSearchService", message: "Use ContentSearchService for new integrations.") -public final class StringSearchService: SearchService, Sendable { - public static func makeFactory( - snippetLength: Int = 200, - searchAlgorithm: StringSearchAlgorithm = BasicStringSearchAlgorithm(), - extractorFactory: ResourceContentExtractorFactory = DefaultResourceContentExtractorFactory() - ) -> (PublicationServiceContext) -> StringSearchService? { - { context in - StringSearchService( - publication: context.publication, - language: context.manifest.metadata.language, - snippetLength: snippetLength, - searchAlgorithm: searchAlgorithm, - extractorFactory: extractorFactory - ) - } - } - - public let options: SearchOptions - - private let publication: Weak - private let language: Language? - private let snippetLength: Int - private let searchAlgorithm: StringSearchAlgorithm - private let extractorFactory: ResourceContentExtractorFactory - - public init(publication: Weak, language: Language?, snippetLength: Int, searchAlgorithm: StringSearchAlgorithm, extractorFactory: ResourceContentExtractorFactory) { - self.publication = publication - self.language = language - self.snippetLength = snippetLength - self.searchAlgorithm = searchAlgorithm - self.extractorFactory = extractorFactory - - var options = searchAlgorithm.options - options.language = language ?? Language.current - self.options = options - } - - public func search(query: String, options: SearchOptions?) async -> SearchResult { - guard let publication = publication() else { - return .failure(.publicationNotSearchable) - } - - return .success(Iterator( - publication: publication, - language: language, - snippetLength: snippetLength, - searchAlgorithm: searchAlgorithm, - extractorFactory: extractorFactory, - query: query, - options: options - )) - } - - private actor Iterator: SearchIterator, Loggable { - private(set) var resultCount: Int? = 0 - - private let publication: Publication - private let language: Language? - private let snippetLength: Int - private let searchAlgorithm: StringSearchAlgorithm - private let extractorFactory: ResourceContentExtractorFactory - private let query: String - private let options: SearchOptions - - fileprivate init( - publication: Publication, - language: Language?, - snippetLength: Int, - searchAlgorithm: StringSearchAlgorithm, - extractorFactory: ResourceContentExtractorFactory, - query: String, - options: SearchOptions? - ) { - self.publication = publication - self.language = language - self.snippetLength = snippetLength - self.searchAlgorithm = searchAlgorithm - self.extractorFactory = extractorFactory - self.query = query - self.options = options ?? SearchOptions() - } - - /// Index of the last reading order resource searched in. - private var index = -1 - - func next() async -> SearchResult { - while index < publication.readingOrder.count - 1 { - index += 1 - - let link = publication.readingOrder[index] - - guard - let resource = publication.get(link), - let mediaType = link.mediaType, - let extractor = extractorFactory.makeExtractor(for: resource, mediaType: mediaType) - else { - log(.warning, "Cannot extract text from resource: \(link.href)") - continue - } - - switch await extractor.extractText(of: resource) { - case let .success(text): - let locators = await findLocators(in: link, resourceIndex: index, text: text) - // If no occurrences were found in the current resource, skip to the next one automatically. - guard !locators.isEmpty else { - continue - } - - resultCount = (resultCount ?? 0) + locators.count - return .success(LocatorCollection(locators: locators)) - - case let .failure(error): - return .failure(.reading(error)) - } - } - - return .success(nil) - } - - private func findLocators(in link: Link, resourceIndex: Int, text: String) async -> [Locator] { - guard - !text.isEmpty, - var resourceLocator = await publication.locate(link) - else { - return [] - } - - let title = await publication.tableOfContents().getOrNil()?.titleMatchingHREF(link.href) - resourceLocator = resourceLocator.copy( - title: Optional(title ?? link.title) - ) - - var locators: [Locator] = [] - - let currentLanguage = options.language ?? language - - for range in await searchAlgorithm.findRanges(of: query, options: options, in: text, language: currentLanguage) { - guard !Task.isCancelled else { - return locators - } - - await locators.append(makeLocator(resourceIndex: index, resourceLocator: resourceLocator, text: text, range: range)) - } - - return locators - } - - private func makeLocator(resourceIndex: Int, resourceLocator: Locator, text: String, range: Range) async -> Locator { - let progression = max(0.0, min(1.0, Double(range.lowerBound.utf16Offset(in: text)) / Double(text.endIndex.utf16Offset(in: text)))) - - var totalProgression: Double? = nil - let positions = await publication.positionsByReadingOrder().getOrNil() ?? [] - if let resourceStartTotalProg = positions.getOrNil(resourceIndex)?.first?.locations.totalProgression { - let resourceEndTotalProg = positions.getOrNil(resourceIndex + 1)?.first?.locations.totalProgression ?? 1.0 - totalProgression = resourceStartTotalProg + progression * (resourceEndTotalProg - resourceStartTotalProg) - } - - return resourceLocator.copy( - locations: { - $0.progression = progression - $0.totalProgression = totalProgression - }, - text: { - $0 = self.makeSnippet(text: text, range: range) - } - ) - } - - /// Extracts a snippet from the given `text` at the provided highlight `range`. - /// Makes sure that words are not cut off at the boundaries. - private func makeSnippet(text: String, range: Range) -> Locator.Text { - var before = "" - var count = snippetLength - for char in text[...range.lowerBound].reversed().dropFirst() { - guard count >= 0 || !char.isWhitespace else { - break - } - count -= 1 - before.insert(char, at: before.startIndex) - } - - var after = "" - count = snippetLength - for char in text[range.upperBound...] { - guard count >= 0 || !char.isWhitespace else { - break - } - count -= 1 - after.append(char) - } - - // Trim if the entire prefix/suffix is whitespace - if text[.. String? { - for link in self { - if let title = link.titleMatchingHREF(href) { - return title - } - } - return nil - } -} - -private extension Link { - func titleMatchingHREF(_ targetHREF: String) -> String? { - if href.substringBeforeLast("#") == targetHREF { - return title - } - return children.titleMatchingHREF(targetHREF) - } -} diff --git a/Sources/Shared/Toolkit/Closeable.swift b/Sources/Shared/Toolkit/Closeable.swift deleted file mode 100644 index 0ae54d84c2..0000000000 --- a/Sources/Shared/Toolkit/Closeable.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// Copyright 2026 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 - -/// Holds closeable resources, such as open files or streams. -public protocol Closeable { - /// Closes this object and releases any resources associated with it. - /// If the object is already closed then invoking this method has no effect. - @available(*, deprecated, message: "Handle Resource deallocation with `deinit` instead.") - func close() -} - -public extension Closeable { - func close() {} -} - -public extension Closeable { - /// Executes the given block function on this resource and then closes it down correctly whether - /// an error is thrown or not. - @available(*, deprecated, message: "The resource is automatically closed when deallocated") - @inlinable func use(_ block: (Self) throws -> T) rethrows -> T { - // Can't use `defer` with async functions. - do { - let result = try block(self) - close() - return result - - } catch { - close() - throw error - } - } -} diff --git a/Sources/Shared/Toolkit/Data/Asset/Asset.swift b/Sources/Shared/Toolkit/Data/Asset/Asset.swift index 5d3a773657..10cc3f5a55 100644 --- a/Sources/Shared/Toolkit/Data/Asset/Asset.swift +++ b/Sources/Shared/Toolkit/Data/Asset/Asset.swift @@ -6,7 +6,7 @@ import Foundation -public protocol AssetProtocol: Sendable, Closeable { +public protocol AssetProtocol: Sendable { /// Format of the asset. var format: Format { get } } diff --git a/Sources/Shared/Toolkit/Data/Container/Container.swift b/Sources/Shared/Toolkit/Data/Container/Container.swift index 6088b33831..c37e074a08 100644 --- a/Sources/Shared/Toolkit/Data/Container/Container.swift +++ b/Sources/Shared/Toolkit/Data/Container/Container.swift @@ -7,7 +7,7 @@ import Foundation /// A container provides access to a list of `Resource` entries. -public protocol Container: Closeable, Sendable { +public protocol Container: Sendable { /// URL locating this container, when available. /// /// This can be used to optimize access to a container's content for the diff --git a/Sources/Shared/Toolkit/Data/ReadError.swift b/Sources/Shared/Toolkit/Data/ReadError.swift index 8e13a7b5c4..58cfa64379 100644 --- a/Sources/Shared/Toolkit/Data/ReadError.swift +++ b/Sources/Shared/Toolkit/Data/ReadError.swift @@ -139,15 +139,4 @@ public enum AccessError: Error, Sendable { /// For extension purposes. This is not used in the Readium toolkit. case other(Error) - - /// Wraps a native error into an `AccessError`, if possible. - /// - /// Returns `nil` if the error cannot be mapped to a known `AccessError`. - @available(*, deprecated, message: "Use ReadError.wrap() instead") - public static func wrap(_ error: Error) -> AccessError? { - guard case let .access(error) = ReadError.wrap(error) else { - return nil - } - return error - } } diff --git a/Sources/Shared/Toolkit/Data/Resource/BorrowedResource.swift b/Sources/Shared/Toolkit/Data/Resource/BorrowedResource.swift deleted file mode 100644 index 5e0589a62a..0000000000 --- a/Sources/Shared/Toolkit/Data/Resource/BorrowedResource.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// Copyright 2026 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 - -/// Returns a new ``Resource`` accessing the same data but not owning it. -/// -/// This is useful when you want to pass a ``Resource`` to a component which -/// might close it, but you want to keep using it after. -public extension Resource { - @available(*, deprecated, message: "Resources are closed on deallocation now.") - func borrowed() -> Resource { - BorrowedResource(resource: self) - } -} - -@available(*, deprecated, message: "Resources are closed on deallocation now.") -private struct BorrowedResource: Resource { - private let resource: Resource - - init(resource: Resource) { - self.resource = resource - } - - var sourceURL: AbsoluteURL? { - resource.sourceURL - } - - func estimatedLength() async -> ReadResult { - await resource.estimatedLength() - } - - func properties() async -> ReadResult { - await resource.properties() - } - - func stream(range: Range?, consume: @escaping @Sendable (Data) -> Void) async -> ReadResult { - await resource.stream(range: range, consume: consume) - } -} diff --git a/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift b/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift index b615eea901..5b45a18fa2 100644 --- a/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift @@ -35,11 +35,6 @@ public actor BufferingResource: Resource, Loggable { buffer = Buffer(maxSize: bufferSize) } - @available(*, deprecated, message: "Use an Int bufferSize instead.") - public init(resource: Resource, bufferSize: UInt64) { - self.init(resource: resource, bufferSize: Int(bufferSize)) - } - public nonisolated var sourceURL: AbsoluteURL? { resource.sourceURL } @@ -164,9 +159,4 @@ public extension Resource { func buffered(size: Int) -> BufferingResource { BufferingResource(resource: self, bufferSize: size) } - - @available(*, deprecated, message: "Use an Int bufferSize instead.") - func buffered(size: UInt64) -> BufferingResource { - buffered(size: Int(size)) - } } diff --git a/Sources/Shared/Toolkit/Data/Streamable.swift b/Sources/Shared/Toolkit/Data/Streamable.swift index 6d43957023..5b3df3c361 100644 --- a/Sources/Shared/Toolkit/Data/Streamable.swift +++ b/Sources/Shared/Toolkit/Data/Streamable.swift @@ -7,7 +7,7 @@ import Foundation /// Acts as a proxy to an actual data source by handling read access. -public protocol Streamable: Sendable, Closeable { +public protocol Streamable: Sendable { /// Returns data length from metadata if available. /// /// This value must be treated as a hint, as it might not reflect the @@ -61,35 +61,21 @@ public extension Streamable { } /// Reads the whole content as a `String`. - @available(*, deprecated, message: "Use `read().asString()` instead") + @available(*, unavailable, message: "Use `read().asString()` instead") func readAsString(encoding: String.Encoding = .utf8) async -> ReadResult { - await read().flatMap { - guard let string = String(data: $0, encoding: encoding) else { - return .failure(.decoding("Not a valid \(encoding) string")) - } - return .success(string) - } + .failure(.cancelled) } /// Reads the whole content as a JSON value. - @available(*, deprecated, message: "Use `read().asJSON()` instead") + @available(*, unavailable, message: "Use `read().asJSON()` instead") func readAsJSON(options: JSONSerialization.ReadingOptions = []) async -> ReadResult { - await read().flatMap { - do { - guard let json = try JSONSerialization.jsonObject(with: $0) as? T else { - return .failure(.decoding(JSONError.parsing(T.self))) - } - return .success(json) - } catch { - return .failure(.decoding(error)) - } - } + .failure(.cancelled) } /// Reads the whole content as a JSON object. - @available(*, deprecated, message: "Use `read().asJSONObject()` instead") + @available(*, unavailable, message: "Use `read().asJSONObject()` instead") func readAsJSONObject(options: JSONSerialization.ReadingOptions = []) async -> ReadResult<[String: Any]> { - await readAsJSON() + .failure(.cancelled) } } diff --git a/Sources/Shared/Toolkit/File/FileSystemError.swift b/Sources/Shared/Toolkit/File/FileSystemError.swift index 0a94f96a28..c48af4708f 100644 --- a/Sources/Shared/Toolkit/File/FileSystemError.swift +++ b/Sources/Shared/Toolkit/File/FileSystemError.swift @@ -19,18 +19,4 @@ public enum FileSystemError: Error, Sendable { /// An unexpected IO error occurred on the file system. case io(Error?) - - /// Wraps a native error into a `FileSystemError`, if possible. - /// - /// Returns `nil` if the error is not related to the file system. - @available(*, deprecated, message: "Use ReadError.wrap() instead") - public static func wrap(_ error: Error) -> FileSystemError? { - guard - case let .access(error) = ReadError.wrap(error), - case let .fileSystem(error) = error - else { - return nil - } - return error - } } diff --git a/Sources/Shared/Toolkit/PDF/CGPDF.swift b/Sources/Shared/Toolkit/PDF/CGPDF.swift index d70213198e..82d7ee477d 100644 --- a/Sources/Shared/Toolkit/PDF/CGPDF.swift +++ b/Sources/Shared/Toolkit/PDF/CGPDF.swift @@ -7,16 +7,8 @@ import Foundation import UIKit -/// Extends Core Graphics's `CGPDFDocument` to conform to `PDFDocument`. -/// -/// Compared to using PDFKit, Core Graphics offers several advantages: -/// - PDFKit is only available on iOS 11+ -/// - `CGPDFDocument` can use a `CGDataProvider` to read a PDF stream instead of loading the full -/// document in memory. -/// -/// Use `CGPDFDocumentFactory` to create a `CGPDFDocument` from a `Resource`. -extension CGPDFDocument: PDFDocument, @retroactive @unchecked Sendable { - public func identifier() async throws -> String? { +package extension CGPDFDocument { + func identifier() async throws -> String? { guard let identifierArray = fileIdentifier, CGPDFArrayGetCount(identifierArray) > 0 @@ -34,13 +26,13 @@ extension CGPDFDocument: PDFDocument, @retroactive @unchecked Sendable { return identifierData.reduce("") { $0 + String(format: "%02x", $1) } } - public func pageCount() async throws -> Int { + func pageCount() async throws -> Int { numberOfPages } /// The reading progression can be derived from the `Direction` Name object under the /// `/Catalog/ViewerPreferences` dictionary. - public func readingProgression() async throws -> ReadingProgression? { + func readingProgression() async throws -> ReadingProgression? { guard let viewerPreferences = dict(forKey: "ViewerPreferences", in: catalog), let direction = object(forKey: "Direction", in: viewerPreferences) @@ -57,23 +49,23 @@ extension CGPDFDocument: PDFDocument, @retroactive @unchecked Sendable { } } - public func title() async throws -> String? { + func title() async throws -> String? { string(forKey: "Title", in: info) } - public func author() async throws -> String? { + func author() async throws -> String? { string(forKey: "Author", in: info) } - public func subject() async throws -> String? { + func subject() async throws -> String? { string(forKey: "Subject", in: info) } - public func keywords() async throws -> [String] { + func keywords() async throws -> [String] { stringList(forKey: "Keywords", in: info) } - public func cover() async throws -> UIImage? { + func cover() async throws -> UIImage? { guard let page = page(at: 1) else { return nil } @@ -120,7 +112,7 @@ extension CGPDFDocument: PDFDocument, @retroactive @unchecked Sendable { return UIImage(cgImage: cgImage) } - public func tableOfContents() async throws -> [PDFOutlineNode] { + func tableOfContents() async throws -> [PDFOutlineNode] { guard let outline = outline as? [String: Any] else { return [] } @@ -224,131 +216,3 @@ extension CGPDFDocument: PDFDocument, @retroactive @unchecked Sendable { return String(cString: buffer) } } - -/// Creates a `PDFDocument` using Core Graphics. -@available(*, deprecated, renamed: "PDFKitPDFDocumentFactory", message: "The PDFKitPDFDocumentFactory is more capable") -public final class CGPDFDocumentFactory: PDFDocumentFactory, Loggable { - public init() {} - - public func open(file: FileURL, password: String?) async throws -> PDFDocument { - guard let document = CGPDFDocument(file.url as CFURL) else { - throw PDFDocumentError.openFailed - } - - return try open(document: document, password: password) - } - - public func open(resource: Resource, at href: HREF, password: String?) async throws -> PDFDocument { - if let file = resource.sourceURL?.fileURL { - return try await open(file: file, password: password) - } - - var callbacks = CGDataProviderSequentialCallbacks( - version: 0, - - getBytes: { info, buffer, count -> Int in - guard let context = CGPDFDocumentFactory.context(from: info) else { - return 0 - } - - let end = min(context.offset + UInt64(count), context.length) - if context.offset >= end { - return 0 - } - - let resource = context.resource - let offset = context.offset - let resultData = Mutex(Data()) - let semaphore = DispatchSemaphore(value: 0) - Task { - switch await resource.read(range: offset ..< end) { - case let .success(result): - resultData.withLock { $0 = result } - case let .failure(error): - CGPDFDocumentFactory.log(.error, error) - } - semaphore.signal() - } - - _ = semaphore.wait(timeout: .distantFuture) - - let data = resultData.withLock { $0 } - if !data.isEmpty { - data.copyBytes(to: buffer.assumingMemoryBound(to: UInt8.self), count: data.count) - context.offset += UInt64(data.count) - } - return data.count - }, - - skipForward: { info, count -> off_t in - guard let context = CGPDFDocumentFactory.context(from: info) else { - return 0 - } - - let current = context.offset - context.offset = min(context.offset + UInt64(count), context.length) - return off_t(context.offset - current) - }, - - rewind: { info in - guard let context = CGPDFDocumentFactory.context(from: info) else { - return - } - context.offset = 0 - }, - - releaseInfo: { info in - let info = info?.assumingMemoryBound(to: ResourceContext.self) - info?.deinitialize(count: 1) - info?.deallocate() - } - ) - - let context = await ResourceContext(resource: resource) - let contextRef = UnsafeMutablePointer.allocate(capacity: 1) - contextRef.initialize(to: context) - - guard - let provider = CGDataProvider(sequentialInfo: contextRef, callbacks: &callbacks), - let document = UIKit.CGPDFDocument(provider) - else { - throw PDFDocumentError.openFailed - } - - return try open(document: document, password: password) - } - - private func open(document: CGPDFDocument, password: String?) throws -> PDFDocument { - if document.isEncrypted, !document.isUnlocked { - guard - let password = password?.cString(using: .utf8), - document.unlockWithPassword(password) - else { - throw PDFDocumentError.invalidPassword - } - } - - return document - } - - private class ResourceContext { - let resource: Resource - var offset: UInt64 = 0 - let length: UInt64 - - init(resource: Resource) async { - self.resource = resource - length = await (resource.estimatedLength().getOrNil() ?? 0) ?? 0 - } - } - - /// This can't be a nested func in `init(resource:password:)` because the C-function pointers of - /// CGDataProvider's callbacks can't capture context. - private static func context(from info: UnsafeMutableRawPointer?) -> ResourceContext? { - let context = info?.assumingMemoryBound(to: ResourceContext.self).pointee - if context == nil { - log(.error, "Can't get the `ResourceContext` from `CGDataProvider.info`") - } - return context - } -} diff --git a/Sources/Shared/Toolkit/PDF/PDFDocument.swift b/Sources/Shared/Toolkit/PDF/PDFDocument.swift index 7cab9ed419..4caccf450c 100644 --- a/Sources/Shared/Toolkit/PDF/PDFDocument.swift +++ b/Sources/Shared/Toolkit/PDF/PDFDocument.swift @@ -80,44 +80,3 @@ public final class DefaultPDFDocumentFactory: PDFDocumentFactory, Loggable { try await factory.open(resource: resource, at: href, password: password) } } - -/// A PDF document factory which will iterate over a list of factories until one works. -@available(*, deprecated, message: "Not used anymore") -public final class CompositePDFDocumentFactory: PDFDocumentFactory, Loggable { - private let factories: [PDFDocumentFactory] - - public init(factories: [PDFDocumentFactory]) { - self.factories = factories - } - - public func open(file: FileURL, password: String?) async throws -> PDFDocument { - try await eachFactory { try await $0.open(file: file, password: password) } - } - - public func open(resource: Resource, at href: HREF, password: String?) async throws -> PDFDocument { - try await eachFactory { try await $0.open(resource: resource, at: href, password: password) } - } - - private func eachFactory(tryOpen: (PDFDocumentFactory) async throws -> PDFDocument) async throws -> PDFDocument { - for factory in factories { - do { - return try await tryOpen(factory) - } catch PDFDocumentError.openFailed { - continue - } - } - throw PDFDocumentError.openFailed - } -} - -/// Protocol to be implemented by publication services using an overridable PDF factory. -/// -/// This can be used for optimization reasons: to avoid opening a PDF document several times. For -/// example, if a PDF document was opened by a PDF Navigator, we can reuse its instance when used by -/// a PositionsService. In this case, the PDF Navigator can overwrite the `pdfFactory` property -/// of all the services conforming to `PDFPublicationService`. -@available(*, deprecated, message: "Not used anymore") -public protocol PDFPublicationService: AnyObject, PublicationService { - /// Factory used by the publication service to open PDF documents. - var pdfFactory: PDFDocumentFactory { get set } -} diff --git a/Sources/Shared/Toolkit/PDF/PDFKit.swift b/Sources/Shared/Toolkit/PDF/PDFKit.swift index 9b913bdb6c..f21350baf4 100644 --- a/Sources/Shared/Toolkit/PDF/PDFKit.swift +++ b/Sources/Shared/Toolkit/PDF/PDFKit.swift @@ -39,8 +39,7 @@ public final class PDFKitPDFDocumentFactory: PDFDocumentFactory { } // Unfortunately, PDFKit doesn't support streams, so we need to load the - // full document in memory. If this is an issue for you, use - // `CGPDFDocumentFactory` instead. + // full document in memory. // // We read chunk by chunk and monitor available memory to avoid OOM // crashes. diff --git a/Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec b/Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec deleted file mode 100644 index f7dd1687d2..0000000000 --- a/Support/CocoaPods/ReadiumAdapterGCDWebServer.podspec +++ /dev/null @@ -1,25 +0,0 @@ -# This file is generated by `make podspecs`. Do not edit manually. -# Edit Support/CocoaPods/Specs.swift and run `make podspecs` to regenerate. - -Pod::Spec.new do |s| - - s.name = "ReadiumAdapterGCDWebServer" - s.version = "3.11.0" - s.license = "BSD 3-Clause License" - s.summary = "Adapter to use GCDWebServer as an HTTP server in Readium" - s.homepage = "http://readium.github.io" - s.author = { "Readium" => "contact@readium.org" } - s.source = { :git => "https://github.com/readium/swift-toolkit.git", :tag => s.version } - s.requires_arc = true - s.source_files = "Sources/Adapters/GCDWebServer/**/*.{m,h,swift}" - s.swift_version = '5.10' - s.platform = :ios - s.ios.deployment_target = "15.0" - s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } - s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-package-name Readium' } - - s.dependency 'ReadiumInternal', '~> 3.11.0' - s.dependency 'ReadiumShared', '~> 3.11.0' - s.dependency 'ReadiumGCDWebServer', '~> 4.0.0' - -end diff --git a/Support/CocoaPods/ReadiumAdapterLCPSQLite.podspec b/Support/CocoaPods/ReadiumAdapterLCPSQLite.podspec deleted file mode 100644 index 0b6615f1ae..0000000000 --- a/Support/CocoaPods/ReadiumAdapterLCPSQLite.podspec +++ /dev/null @@ -1,26 +0,0 @@ -# This file is generated by `make podspecs`. Do not edit manually. -# Edit Support/CocoaPods/Specs.swift and run `make podspecs` to regenerate. - -Pod::Spec.new do |s| - - s.name = "ReadiumAdapterLCPSQLite" - s.version = "3.11.0" - s.license = "BSD 3-Clause License" - s.summary = "Adapter to use SQLite.swift for the Readium LCP repositories" - s.homepage = "http://readium.github.io" - s.author = { "Readium" => "contact@readium.org" } - s.source = { :git => "https://github.com/readium/swift-toolkit.git", :tag => s.version } - s.requires_arc = true - s.source_files = "Sources/Adapters/LCPSQLite/**/*.{m,h,swift}" - s.swift_version = '5.10' - s.platform = :ios - s.ios.deployment_target = "15.0" - s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } - s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-package-name Readium' } - - s.dependency 'ReadiumInternal', '~> 3.11.0' - s.dependency 'ReadiumShared', '~> 3.11.0' - s.dependency 'ReadiumLCP', '~> 3.11.0' - s.dependency 'SQLite.swift', '~> 0.16.0' - -end diff --git a/Support/CocoaPods/Specs.swift b/Support/CocoaPods/Specs.swift index a12a96eb07..09f5be6dc1 100644 --- a/Support/CocoaPods/Specs.swift +++ b/Support/CocoaPods/Specs.swift @@ -125,27 +125,4 @@ let modules: [ModuleSpec] = [ .pod("CryptoSwift", "~> 1.10.0"), ] ), - ModuleSpec( - name: "ReadiumAdapterGCDWebServer", - sourcePath: "Sources/Adapters/GCDWebServer", - summary: "Adapter to use GCDWebServer as an HTTP server in Readium", - xcconfig: ["HEADER_SEARCH_PATHS": "$(SDKROOT)/usr/include/libxml2"], - dependencies: [ - .readium("ReadiumInternal"), - .readium("ReadiumShared"), - .pod("ReadiumGCDWebServer", "~> 4.0.0"), - ] - ), - ModuleSpec( - name: "ReadiumAdapterLCPSQLite", - sourcePath: "Sources/Adapters/LCPSQLite", - summary: "Adapter to use SQLite.swift for the Readium LCP repositories", - xcconfig: ["HEADER_SEARCH_PATHS": "$(SDKROOT)/usr/include/libxml2"], - dependencies: [ - .readium("ReadiumInternal"), - .readium("ReadiumShared"), - .readium("ReadiumLCP"), - .pod("SQLite.swift", "~> 0.16.0"), - ] - ), ] diff --git a/scripts/release-publish-podspecs.sh b/scripts/release-publish-podspecs.sh index 3e6f320eb3..9252e5235d 100755 --- a/scripts/release-publish-podspecs.sh +++ b/scripts/release-publish-podspecs.sh @@ -2,7 +2,7 @@ # ============================================================================= # release-publish-podspecs.sh [--start INDEX] # ============================================================================= -# Push all 8 podspecs to the Readium CocoaPods repo in dependency-safe order, +# Push all podspecs to the Readium CocoaPods repo in dependency-safe order, # with interactive retry on failure. # # --start INDEX - Resume the sequence from INDEX (0-based). Useful when a @@ -21,8 +21,6 @@ PODSPECS=( "ReadiumNavigator" "ReadiumOPDS" "ReadiumLCP" - "ReadiumAdapterGCDWebServer" - "ReadiumAdapterLCPSQLite" ) # Argument parsing From 5e5bf2f8734ef24ad59410a36d42a0dfe90e9243 Mon Sep 17 00:00:00 2001 From: Grigor Hakobyan Date: Wed, 24 Jun 2026 20:41:38 +0400 Subject: [PATCH 19/39] Migrate `ReadiumStreamer` to Swift 6 (#828) --- Package.swift | 2 ++ .../Search/ContentSearchService.swift | 2 +- .../Services/Search/SearchService.swift | 2 +- .../AudioPublicationManifestAugmentor.swift | 2 +- .../Parser/CompositePublicationParser.swift | 2 +- .../Parser/DefaultPublicationParser.swift | 16 ++++++++-- Sources/Streamer/Parser/EPUB/EPUBParser.swift | 2 +- .../EPUBDeobfuscator.swift | 2 +- .../EPUB/Services/EPUBPositionsService.swift | 29 +++++++++++-------- .../Streamer/Parser/PublicationParser.swift | 2 +- Tests/StreamerTests/Fixtures.swift | 2 +- .../Search/SearchServiceTests.swift | 2 +- 12 files changed, 41 insertions(+), 24 deletions(-) diff --git a/Package.swift b/Package.swift index 980f5ca498..f90fd4375a 100644 --- a/Package.swift +++ b/Package.swift @@ -173,6 +173,8 @@ let package = Package( let swift6EnabledTargets: Set = [ "ReadiumShared", "ReadiumSharedTests", + "ReadiumStreamer", + "ReadiumStreamerTests", ] for target in package.targets { diff --git a/Sources/Shared/Publication/Services/Search/ContentSearchService.swift b/Sources/Shared/Publication/Services/Search/ContentSearchService.swift index 36b086368f..9333a43bd4 100644 --- a/Sources/Shared/Publication/Services/Search/ContentSearchService.swift +++ b/Sources/Shared/Publication/Services/Search/ContentSearchService.swift @@ -33,7 +33,7 @@ public final class ContentSearchService: SearchService, Loggable { public static func makeFactory( snippetLength: Int = 200, searchAlgorithm: StringSearchAlgorithm = BasicStringSearchAlgorithm() - ) -> (PublicationServiceContext) -> ContentSearchService? { + ) -> @Sendable (PublicationServiceContext) -> ContentSearchService? { { context in ContentSearchService( publication: context.publication, diff --git a/Sources/Shared/Publication/Services/Search/SearchService.swift b/Sources/Shared/Publication/Services/Search/SearchService.swift index a19b61bb6d..319317b5be 100644 --- a/Sources/Shared/Publication/Services/Search/SearchService.swift +++ b/Sources/Shared/Publication/Services/Search/SearchService.swift @@ -6,7 +6,7 @@ import Foundation -public typealias SearchServiceFactory = (PublicationServiceContext) -> SearchService? +public typealias SearchServiceFactory = @Sendable (PublicationServiceContext) -> SearchService? /// Provides a way to search terms in a publication. public protocol SearchService: PublicationService { diff --git a/Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift b/Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift index a0ae14a89c..15d21ec90d 100644 --- a/Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift +++ b/Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift @@ -11,7 +11,7 @@ import UIKit /// Implements a strategy to augment a `Manifest` of an audio publication with additional metadata and /// cover, for example by looking into the audio files metadata. -public protocol AudioPublicationManifestAugmentor { +public protocol AudioPublicationManifestAugmentor: Sendable { func augment(_ baseManifest: Manifest, using container: Container) async -> AudioPublicationAugmentedManifest } diff --git a/Sources/Streamer/Parser/CompositePublicationParser.swift b/Sources/Streamer/Parser/CompositePublicationParser.swift index 49f53633aa..5e63f3adb9 100644 --- a/Sources/Streamer/Parser/CompositePublicationParser.swift +++ b/Sources/Streamer/Parser/CompositePublicationParser.swift @@ -9,7 +9,7 @@ import ReadiumShared /// A composite ``PublicationParser`` which tries several parsers until it /// finds one which supports the asset. -public class CompositePublicationParser: PublicationParser { +public final class CompositePublicationParser: PublicationParser { private let parsers: [PublicationParser] public init(_ parsers: [PublicationParser]) { diff --git a/Sources/Streamer/Parser/DefaultPublicationParser.swift b/Sources/Streamer/Parser/DefaultPublicationParser.swift index 1466d9cac8..8fa98f6ad3 100644 --- a/Sources/Streamer/Parser/DefaultPublicationParser.swift +++ b/Sources/Streamer/Parser/DefaultPublicationParser.swift @@ -9,18 +9,28 @@ import ReadiumShared /// Default implementation of ``PublicationParser`` handling all the /// publication formats supported by Readium. -public final class DefaultPublicationParser: CompositePublicationParser { +public final class DefaultPublicationParser: PublicationParser { + private let parser: CompositePublicationParser + public init( httpClient: HTTPClient, assetRetriever: AssetRetriever, pdfFactory: PDFDocumentFactory, additionalParsers: [PublicationParser] = [] ) { - super.init(additionalParsers + Array(ofNotNil: + parser = CompositePublicationParser(additionalParsers + [ EPUBParser(), PDFParser(pdfFactory: pdfFactory), ReadiumWebPubParser(pdfFactory: pdfFactory, httpClient: httpClient), ImageParser(assetRetriever: assetRetriever), - AudioParser(assetRetriever: assetRetriever))) + AudioParser(assetRetriever: assetRetriever), + ]) + } + + public func parse( + asset: Asset, + warnings: WarningLogger? + ) async -> Result { + await parser.parse(asset: asset, warnings: warnings) } } diff --git a/Sources/Streamer/Parser/EPUB/EPUBParser.swift b/Sources/Streamer/Parser/EPUB/EPUBParser.swift index a8085e68bf..4b0ac6c967 100644 --- a/Sources/Streamer/Parser/EPUB/EPUBParser.swift +++ b/Sources/Streamer/Parser/EPUB/EPUBParser.swift @@ -28,7 +28,7 @@ extension EPUBParser: Loggable {} /// An EPUB container parser that extracts the information from the relevant /// files and builds a `Publication` instance out of it. -public final class EPUBParser: PublicationParser, Sendable { +public final class EPUBParser: PublicationParser { private let reflowablePositionsStrategy: EPUBPositionsService.ReflowableStrategy /// - Parameter reflowablePositionsStrategy: Strategy used to calculate the number of positions in a reflowable resource. diff --git a/Sources/Streamer/Parser/EPUB/Resource Transformers/EPUBDeobfuscator.swift b/Sources/Streamer/Parser/EPUB/Resource Transformers/EPUBDeobfuscator.swift index 1f780107c6..118642560a 100644 --- a/Sources/Streamer/Parser/EPUB/Resource Transformers/EPUBDeobfuscator.swift +++ b/Sources/Streamer/Parser/EPUB/Resource Transformers/EPUBDeobfuscator.swift @@ -10,7 +10,7 @@ import ReadiumShared /// Deobfuscates EPUB resources. /// https://www.w3.org/publishing/epub3/epub-ocf.html#sec-resource-obfuscation -final class EPUBDeobfuscator { +final class EPUBDeobfuscator: Sendable { /// Supported obfuscation algorithms. private let algorithms: [ObfuscationAlgorithm] = [IDPFAlgorithm(), AdobeAlgorithm()] diff --git a/Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift b/Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift index bfbf5d9b2c..920382f898 100644 --- a/Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift +++ b/Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift @@ -16,7 +16,9 @@ import ReadiumShared /// https://github.com/readium/architecture/issues/101 /// public actor EPUBPositionsService: PositionsService { - public static func makeFactory(reflowableStrategy: ReflowableStrategy = .recommended) -> (PublicationServiceContext) -> EPUBPositionsService? { + public static func makeFactory( + reflowableStrategy: ReflowableStrategy = .recommended + ) -> @Sendable (PublicationServiceContext) -> EPUBPositionsService? { { context in EPUBPositionsService( readingOrder: context.manifest.readingOrder, @@ -38,7 +40,7 @@ public actor EPUBPositionsService: PositionsService { /// /// This strategy is used by Adobe RMSDK as well. /// See https://github.com/readium/architecture/issues/123 - public static var recommended = archiveEntryLength(pageLength: 1024) + public static let recommended = archiveEntryLength(pageLength: 1024) /// Returns the number of positions in the given `resource` according to the strategy. func positionCount(for link: Link, resource: Resource) async -> Int { @@ -86,17 +88,20 @@ public actor EPUBPositionsService: PositionsService { private func computePositionsByReadingOrder() async -> [[Locator]] { var lastPositionOfPreviousResource = 0 - var positions = await readingOrder.asyncMap { link -> [Locator] in - let (lastPosition, positions): (Int, [Locator]) = await { - switch layout { - case .fixed: - return makePositions(ofFixedResource: link, from: lastPositionOfPreviousResource) - case nil, .reflowable, .scrolled: - return await makePositions(ofReflowableResource: link, from: lastPositionOfPreviousResource) - } - }() + var positions: [[Locator]] = [] + // Positions are computed sequentially because each resource's first + // position depends on the last position of the previous one. + for link in readingOrder { + let lastPosition: Int + let resourcePositions: [Locator] + switch layout { + case .fixed: + (lastPosition, resourcePositions) = makePositions(ofFixedResource: link, from: lastPositionOfPreviousResource) + case nil, .reflowable, .scrolled: + (lastPosition, resourcePositions) = await makePositions(ofReflowableResource: link, from: lastPositionOfPreviousResource) + } lastPositionOfPreviousResource = lastPosition - return positions + positions.append(resourcePositions) } // Calculates totalProgression diff --git a/Sources/Streamer/Parser/PublicationParser.swift b/Sources/Streamer/Parser/PublicationParser.swift index cbf6840b6e..66477ee91e 100644 --- a/Sources/Streamer/Parser/PublicationParser.swift +++ b/Sources/Streamer/Parser/PublicationParser.swift @@ -8,7 +8,7 @@ import Foundation import ReadiumShared /// Parses a Publication from an asset. -public protocol PublicationParser { +public protocol PublicationParser: Sendable { /// Constructs a `Publication.Builder` to build a `Publication` from a /// publication asset. /// diff --git a/Tests/StreamerTests/Fixtures.swift b/Tests/StreamerTests/Fixtures.swift index 78cb22d894..476ebaaffb 100644 --- a/Tests/StreamerTests/Fixtures.swift +++ b/Tests/StreamerTests/Fixtures.swift @@ -13,7 +13,7 @@ import ReadiumShared } #endif -class Fixtures { +final class Fixtures: Sendable { let path: String? init(path: String? = nil) { diff --git a/Tests/StreamerTests/Search/SearchServiceTests.swift b/Tests/StreamerTests/Search/SearchServiceTests.swift index 9816d2a59b..aa8bb86029 100644 --- a/Tests/StreamerTests/Search/SearchServiceTests.swift +++ b/Tests/StreamerTests/Search/SearchServiceTests.swift @@ -10,7 +10,7 @@ import ReadiumShared import Testing /// Configuration for a single `SearchService` test run. -struct SearchServiceTestConfig: CustomTestStringConvertible { +struct SearchServiceTestConfig: CustomTestStringConvertible, Sendable { /// Human-readable description of the config, used for test reporting. let testDescription: String From 6644f97e285d0355e44f8444586ac987afd402d2 Mon Sep 17 00:00:00 2001 From: Grigor Hakobyan Date: Wed, 24 Jun 2026 20:44:58 +0400 Subject: [PATCH 20/39] Fix `Sendable` conformance in `Mutex` (#827) --- Sources/Shared/Toolkit/Mutex.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Shared/Toolkit/Mutex.swift b/Sources/Shared/Toolkit/Mutex.swift index 6d6a1e2cf8..300730698a 100644 --- a/Sources/Shared/Toolkit/Mutex.swift +++ b/Sources/Shared/Toolkit/Mutex.swift @@ -25,12 +25,12 @@ import os /// ``` @available(iOS, introduced: 15, deprecated: 18, message: "Use Mutex from the Synchronization module instead") @frozen -public struct Mutex: ~Copyable, @unchecked Sendable { +public struct Mutex: ~Copyable, Sendable { /// Single heap allocation holds both the lock and the value together. /// os_unfair_lock must never move after first use — the class guarantees a /// stable address for the lifetime of the Mutex. @usableFromInline - final class Storage: @unchecked Sendable { + final class Storage: Sendable { nonisolated(unsafe) var lock = os_unfair_lock() nonisolated(unsafe) var value: Value From 2405d3a790c701db39c5ebcb1d999d2ca18a9a20 Mon Sep 17 00:00:00 2001 From: Grigor Hakobyan Date: Thu, 25 Jun 2026 11:51:23 +0400 Subject: [PATCH 21/39] Fix `Sendable` conformance for `Weak` type (#831) --- Sources/Shared/Toolkit/Weak.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Sources/Shared/Toolkit/Weak.swift b/Sources/Shared/Toolkit/Weak.swift index 5e563f0a18..9f912538bc 100644 --- a/Sources/Shared/Toolkit/Weak.swift +++ b/Sources/Shared/Toolkit/Weak.swift @@ -4,15 +4,13 @@ // available in the top-level LICENSE file of the project. // -import Foundation - /// Smart pointer holding a weak reference to a reference-based object. /// /// Get the reference by calling `weakVar()`. /// Conveniently, the reference can be reset by setting the `ref` property. @dynamicCallable -public class Weak: @unchecked Sendable { - public package(set) weak var ref: T? +public final class Weak: Sendable { + public package(set) nonisolated(unsafe) weak var ref: T? public init(_ ref: T? = nil) { self.ref = ref From b2b55c6beaa86c614d8b6015885ed017881e9e1e Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:21:43 -0500 Subject: [PATCH 22/39] Migrate `ReadiumLCP` to Swift 6 (#829) --- CHANGELOG.md | 4 ++ Package.swift | 2 + .../LCPContentProtection.swift | 55 +++++++++++-------- Sources/LCP/LCPLicenseRepository.swift | 17 ++++-- Sources/LCP/LCPPassphraseRepository.swift | 2 +- Sources/LCP/LCPService.swift | 28 +++++----- .../Container/ContainerLicenseContainer.swift | 4 +- .../License/Container/LicenseContainer.swift | 2 +- Sources/LCP/License/License.swift | 22 +++----- Sources/LCP/License/LicenseValidation.swift | 12 ++-- .../LCPKeychainLicenseRepository.swift | 9 +-- Sources/LCP/Services/CRLService.swift | 2 +- Sources/LCP/Services/LicensesService.swift | 13 ++--- Sources/LCP/Services/PassphrasesService.swift | 33 ++++++++--- TestApp/Sources/App/AppModule.swift | 6 +- TestApp/Sources/App/Readium.swift | 3 +- TestApp/Sources/LCP/LCPModule.swift | 2 +- TestApp/Sources/Library/LibraryModule.swift | 4 +- TestApp/Sources/Library/LibraryService.swift | 2 +- TestApp/Sources/OPDS/OPDSModule.swift | 4 +- TestApp/Sources/Reader/ReaderModule.swift | 6 +- Tests/LCPTests/LCPDecryptionTests.swift | 1 + Tests/LCPTests/LCPTestClient.swift | 4 +- .../InMemoryLCPLicenseRepository.swift | 11 ++-- .../InMemoryLCPPassphraseRepository.swift | 2 +- .../LCPKeychainLicenseRepositoryTests.swift | 7 +-- ...LCPKeychainPassphraseRepositoryTests.swift | 3 +- docs/Migration Guide.md | 17 +++++- 28 files changed, 164 insertions(+), 113 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57d0642ded..61e55c51f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ All notable changes to this project will be documented in this file. Take a look * OPDS models (`Feed`, `Group`, `Facet`, `OpdsMetadata`) are now structs with value semantics. +#### LCP + +* `LCPService.init` now requires an explicit `deviceName` parameter. We recommend passing `UIDevice.current.name`. See [the migration guide](docs/Migration%20Guide.md). + diff --git a/Package.swift b/Package.swift index f90fd4375a..a38c77b928 100644 --- a/Package.swift +++ b/Package.swift @@ -175,6 +175,8 @@ let swift6EnabledTargets: Set = [ "ReadiumSharedTests", "ReadiumStreamer", "ReadiumStreamerTests", + "ReadiumLCP", + "ReadiumLCPTests", ] for target in package.targets { diff --git a/Sources/LCP/Content Protection/LCPContentProtection.swift b/Sources/LCP/Content Protection/LCPContentProtection.swift index 81ec918c83..4a36e88678 100644 --- a/Sources/LCP/Content Protection/LCPContentProtection.swift +++ b/Sources/LCP/Content Protection/LCPContentProtection.swift @@ -53,31 +53,40 @@ final class LCPContentProtection: ContentProtection, Loggable { return .failure(.assetNotSupported(DebugError("The asset does not appear to be an LCP License"))) } - return await asset.resource.read() + let licenseDocumentResult = await asset.resource.read() .asLCPL() - .mapError { .reading($0) } - .asyncFlatMap { licenseDocument in - await assetRetriever.retrieve(link: licenseDocument.publicationLink) - .flatMap { publicationAsset in - switch publicationAsset { - case .resource: - return .failure(.assetNotSupported(DebugError("Cannot open the LCP-protected publication as a Container"))) - case let .container(container): - return .success(container) - } - } - .asyncFlatMap { - await makeLCPAsset( - from: $0, - license: retrieveLicense( - in: .resource(asset), - credentials: credentials, - allowUserInteraction: allowUserInteraction, - sender: sender - ) - ) - } + .mapError { ContentProtectionOpenError.reading($0) } + + let licenseDocument: LicenseDocument + switch licenseDocumentResult { + case let .failure(error): + return .failure(error) + case let .success(doc): + licenseDocument = doc + } + + let publicationAssetResult = await assetRetriever.retrieve(link: licenseDocument.publicationLink) + let container: ContainerAsset + switch publicationAssetResult { + case let .failure(error): + return .failure(error) + case let .success(publicationAsset): + switch publicationAsset { + case .resource: + return .failure(.assetNotSupported(DebugError("Cannot open the LCP-protected publication as a Container"))) + case let .container(c): + container = c } + } + + let licenseResult = await retrieveLicense( + in: .resource(asset), + credentials: credentials, + allowUserInteraction: allowUserInteraction, + sender: sender + ) + + return await makeLCPAsset(from: container, license: licenseResult) } func openPublication( diff --git a/Sources/LCP/LCPLicenseRepository.swift b/Sources/LCP/LCPLicenseRepository.swift index 685ab7fc7f..3482b20073 100644 --- a/Sources/LCP/LCPLicenseRepository.swift +++ b/Sources/LCP/LCPLicenseRepository.swift @@ -31,11 +31,20 @@ public protocol LCPLicenseRepository: Sendable { /// Returns the consumable user rights for the license with given `id`. func userRights(for id: LicenseDocument.ID) async throws -> LCPConsumableUserRights - /// Updates the consumable user rights for the license with given `id`. - func updateUserRights( + /// Atomically reads, mutates, and persists the consumable user rights for + /// the license with the given `id`. + /// + /// The `changes` closure receives the license's current rights as an + /// `inout` value and may modify them in place; any modifications are + /// persisted once the closure returns. Use the closure's return value to + /// surface a result computed while the rights are held — for example, + /// whether a copy/print request was within the allowed budget. + /// + /// - Returns: The value returned by the `changes` closure. + func updateUserRights( for id: LicenseDocument.ID, - with changes: (inout LCPConsumableUserRights) -> Void - ) async throws + with changes: @Sendable (inout LCPConsumableUserRights) throws -> T + ) async throws -> T } /// Holds the current state of consumable user rights for a license. diff --git a/Sources/LCP/LCPPassphraseRepository.swift b/Sources/LCP/LCPPassphraseRepository.swift index 29123f263f..4f71f2ff8b 100644 --- a/Sources/LCP/LCPPassphraseRepository.swift +++ b/Sources/LCP/LCPPassphraseRepository.swift @@ -11,7 +11,7 @@ public typealias LCPPassphraseHash = String /// The passphrase repository stores passphrase hashes, optionally associated /// with a user ID and provider. -public protocol LCPPassphraseRepository { +public protocol LCPPassphraseRepository: Sendable { /// Returns a list of passphrase hashes that may match the given `userID` /// and `provider`. func passphrasesMatching( diff --git a/Sources/LCP/LCPService.swift b/Sources/LCP/LCPService.swift index 5b3ea1030e..a08c964fba 100644 --- a/Sources/LCP/LCPService.swift +++ b/Sources/LCP/LCPService.swift @@ -6,7 +6,6 @@ import Foundation import ReadiumShared -import UIKit /// Service used to acquire and open publications protected with LCP. /// @@ -24,27 +23,26 @@ public final class LCPService: Loggable { /// - Parameters: /// - client: The LCP client used for core license operations. + /// - deviceName: Device name used when registering a license to an LSD + /// server. We recommend using `UIDevice.current.name` and adding the + /// `com.apple.developer.device-information.user-assigned-device-name` + /// entitlement. + /// - deviceId: Device ID used when registering a license to an LSD + /// server. You must ensure the identifier is unique and stable for the + /// device (persist and reuse across app launches). If not provided, the + /// device ID will be generated as a random UUID. /// - licenseRepository: Repository for managing stored licenses. /// - passphraseRepository: Repository for managing user passphrases. /// - assetRetriever: The retriever used to fetch protected assets. /// - httpClient: The HTTP client used for network requests to LSD/LCP servers. - /// - deviceName: Device name used when registering a license to an LSD server. - /// If not provided, the device name will be `UIDevice.current.name`. Since iOS 16, - /// this returns a generic name (e.g. "iPhone") unless the - /// `com.apple.developer.device-information.user-assigned-device-name` entitlement - /// is added to your app. - /// - deviceId: Device ID used when registering a license to an LSD server. - /// You must ensure the identifier is unique and stable for the device (persist and - /// reuse across app launches). If not provided, the device ID will be generated as - /// a random UUID. public init( client: LCPClient, + deviceName: String, + deviceId: String? = nil, licenseRepository: LCPLicenseRepository, passphraseRepository: LCPPassphraseRepository, assetRetriever: AssetRetriever, - httpClient: HTTPClient, - deviceName: String? = nil, - deviceId: String? = nil + httpClient: HTTPClient ) { let passphrases = PassphrasesService( client: client, @@ -56,7 +54,7 @@ public final class LCPService: Loggable { licenses: licenseRepository, crl: CRLService(httpClient: httpClient), device: DeviceService( - deviceName: deviceName ?? UIDevice.current.name, + deviceName: deviceName, deviceId: deviceId, repository: licenseRepository, httpClient: httpClient @@ -99,7 +97,7 @@ public final class LCPService: Loggable { /// Acquires a protected publication from an LCPL. public func acquirePublication( from lcpl: LicenseDocumentSource, - onProgress: @escaping (LCPProgress) -> Void = { _ in } + onProgress: @escaping @Sendable (LCPProgress) -> Void = { _ in } ) async -> Result { await wrap { try await licenses.acquirePublication(from: lcpl, onProgress: onProgress) diff --git a/Sources/LCP/License/Container/ContainerLicenseContainer.swift b/Sources/LCP/License/Container/ContainerLicenseContainer.swift index 3ab1994a28..ff145a7936 100644 --- a/Sources/LCP/License/Container/ContainerLicenseContainer.swift +++ b/Sources/LCP/License/Container/ContainerLicenseContainer.swift @@ -9,9 +9,7 @@ import ReadiumShared import ReadiumZIPFoundation /// Access to a License Document stored in a ``Container``. -/// Meant to be subclassed to customize the pathInZIP property, -/// eg. ``EPUBLicenseContainer``. -class ContainerLicenseContainer: LicenseContainer { +final class ContainerLicenseContainer: LicenseContainer { private let asset: ContainerAsset private let licensePath: RelativeURL diff --git a/Sources/LCP/License/Container/LicenseContainer.swift b/Sources/LCP/License/Container/LicenseContainer.swift index fdd8adab38..68b2e2a201 100644 --- a/Sources/LCP/License/Container/LicenseContainer.swift +++ b/Sources/LCP/License/Container/LicenseContainer.swift @@ -9,7 +9,7 @@ import ReadiumShared /// Encapsulates the read/write access to the packaged License Document (eg. in /// an EPUB container, or a standalone LCPL file) -protocol LicenseContainer { +protocol LicenseContainer: Sendable { /// Returns whether this container currently contains a License Document. /// /// For example, when fulfilling an EPUB publication, it initially doesn't contain the license. diff --git a/Sources/LCP/License/License.swift b/Sources/LCP/License/License.swift index 29f24f8ff9..854f3d7383 100644 --- a/Sources/LCP/License/License.swift +++ b/Sources/LCP/License/License.swift @@ -99,21 +99,18 @@ extension License: LCPLicense { guard !isRestricted else { return false } do { - var allowed = true - try await licenses.updateUserRights(for: license.id) { rights in + return try await licenses.updateUserRights(for: license.id) { rights in guard let copyLeft = rights.copy else { - return + return true } guard text.count <= copyLeft else { - allowed = false - return + return false } rights.copy = max(0, copyLeft - text.count) + return true } - return allowed - } catch { log(.error, error) return false @@ -146,21 +143,18 @@ extension License: LCPLicense { guard !isRestricted else { return false } do { - var allowed = true - try await licenses.updateUserRights(for: license.id) { rights in + return try await licenses.updateUserRights(for: license.id) { rights in guard let printLeft = rights.print else { - return + return true } guard pageCount <= printLeft else { - allowed = false - return + return false } rights.print = max(0, printLeft - pageCount) + return true } - return allowed - } catch { log(.error, error) return false diff --git a/Sources/LCP/License/LicenseValidation.swift b/Sources/LCP/License/LicenseValidation.swift index b4edd29315..1c3d304dc6 100644 --- a/Sources/LCP/License/LicenseValidation.swift +++ b/Sources/LCP/License/LicenseValidation.swift @@ -10,7 +10,7 @@ import ReadiumShared typealias Context = Result /// Holds the License/Status Documents and the DRM context, once validated. -struct ValidatedDocuments { +struct ValidatedDocuments: Sendable { let license: LicenseDocument let context: Context let status: StatusDocument? @@ -31,7 +31,7 @@ actor LicenseValidation: Loggable { fileprivate let client: LCPClient fileprivate let authentication: LCPAuthenticating? fileprivate let allowUserInteraction: Bool - fileprivate let sender: Any? + fileprivate let sender: UncheckedSendable? fileprivate let crl: CRLService fileprivate let device: DeviceService fileprivate let httpClient: HTTPClient @@ -40,7 +40,7 @@ actor LicenseValidation: Loggable { /// List of observers notified when the Documents are validated, or if an error occurred. fileprivate var observers: [(callback: Observer, policy: ObserverPolicy)] = [] - fileprivate let onLicenseValidated: (LicenseDocument) async throws -> Void + fileprivate let onLicenseValidated: @Sendable (LicenseDocument) async throws -> Void /// Current state in the validation steps. private(set) var state: State = .start { @@ -52,13 +52,13 @@ actor LicenseValidation: Loggable { init( authentication: LCPAuthenticating?, allowUserInteraction: Bool, - sender: Any?, + sender: UncheckedSendable?, client: LCPClient, crl: CRLService, device: DeviceService, httpClient: HTTPClient, passphrases: PassphrasesService, - onLicenseValidated: @escaping (LicenseDocument) async throws -> Void + onLicenseValidated: @escaping @Sendable (LicenseDocument) async throws -> Void ) { self.authentication = authentication self.allowUserInteraction = allowUserInteraction @@ -403,7 +403,7 @@ extension LicenseValidation { /// Validation observers extension LicenseValidation { - typealias Observer = (Result) -> Void + typealias Observer = @Sendable (Result) -> Void enum ObserverPolicy { /// The observer is automatically removed when called. diff --git a/Sources/LCP/Repositories/Keychain/LCPKeychainLicenseRepository.swift b/Sources/LCP/Repositories/Keychain/LCPKeychainLicenseRepository.swift index 2a6570a8e1..7003487f4f 100644 --- a/Sources/LCP/Repositories/Keychain/LCPKeychainLicenseRepository.swift +++ b/Sources/LCP/Repositories/Keychain/LCPKeychainLicenseRepository.swift @@ -124,10 +124,10 @@ public actor LCPKeychainLicenseRepository: LCPLicenseRepository, Loggable { ) } - public func updateUserRights( + public func updateUserRights( for id: LicenseDocument.ID, - with changes: (inout LCPConsumableUserRights) -> Void - ) async throws { + with changes: @Sendable (inout LCPConsumableUserRights) throws -> T + ) async throws -> T { var license = try requireLicense(for: id) // Get current rights @@ -137,13 +137,14 @@ public actor LCPKeychainLicenseRepository: LCPLicenseRepository, Loggable { ) // Apply changes - changes(¤tRights) + let result = try changes(¤tRights) // Update the data license.printsLeft = currentRights.print license.copiesLeft = currentRights.copy try updateLicense(license, for: id) + return result } /// Removes all licenses from the repository. diff --git a/Sources/LCP/Services/CRLService.swift b/Sources/LCP/Services/CRLService.swift index aa1766cf40..118688298f 100644 --- a/Sources/LCP/Services/CRLService.swift +++ b/Sources/LCP/Services/CRLService.swift @@ -8,7 +8,7 @@ import Foundation import ReadiumShared /// Certificate Revocation List -final class CRLService { +final class CRLService: Sendable { /// Number of days before the CRL cache expires. private static let expiration = 7 diff --git a/Sources/LCP/Services/LicensesService.swift b/Sources/LCP/Services/LicensesService.swift index 32f37b19b1..4d8b06cba8 100644 --- a/Sources/LCP/Services/LicensesService.swift +++ b/Sources/LCP/Services/LicensesService.swift @@ -62,22 +62,21 @@ final class LicensesService: Loggable { ) async throws -> License { let initialData = try await container.read() - func onLicenseValidated(of license: LicenseDocument) async throws { + let onLicenseValidated: @Sendable (LicenseDocument) async throws -> Void = { [licenses, container, initialData] license in // Any errors are ignored to avoid blocking the publication. - do { try await licenses.addLicense(license) } catch { - log(.error, "Failed to add the LCP License to the local database: \(error)") + Self.log(.error, "Failed to add the LCP License to the local database: \(error)") } // Updates the License in the container if needed if license.jsonData != initialData { do { try await container.write(license) - log(.debug, "Wrote updated License Document in container") + Self.log(.debug, "Wrote updated License Document in container") } catch { - log(.error, "Failed to write updated License Document in container: \(error)") + Self.log(.error, "Failed to write updated License Document in container: \(error)") } } } @@ -85,7 +84,7 @@ final class LicensesService: Loggable { let validation = LicenseValidation( authentication: authentication, allowUserInteraction: allowUserInteraction, - sender: sender, + sender: sender.map { UncheckedSendable($0) }, client: client, crl: crl, device: device, @@ -101,7 +100,7 @@ final class LicensesService: Loggable { func acquirePublication( from lcpl: LicenseDocumentSource, - onProgress: @escaping (LCPProgress) -> Void + onProgress: @escaping @Sendable (LCPProgress) -> Void ) async throws -> LCPAcquiredPublication { guard let license = try await readLicense(from: lcpl) else { throw LCPError.notALicenseDocument(lcpl) diff --git a/Sources/LCP/Services/PassphrasesService.swift b/Sources/LCP/Services/PassphrasesService.swift index 0075da125b..6483c6eb93 100644 --- a/Sources/LCP/Services/PassphrasesService.swift +++ b/Sources/LCP/Services/PassphrasesService.swift @@ -9,12 +9,10 @@ import Foundation import ReadiumInternal import ReadiumShared -final class PassphrasesService: Loggable { +final class PassphrasesService: Loggable, Sendable { private let client: LCPClient private let repository: LCPPassphraseRepository - private let sha256Predicate = NSPredicate(format: "SELF MATCHES[c] %@", "^([a-f0-9]{64})$") - init(client: LCPClient, repository: LCPPassphraseRepository) { self.client = client self.repository = repository @@ -62,7 +60,7 @@ final class PassphrasesService: Loggable { for license: LicenseDocument, authentication: LCPAuthenticating?, allowUserInteraction: Bool, - sender: Any? + sender: UncheckedSendable? ) async throws -> LCPPassphraseHash? { // Look for a stored passphrase matching this license. // @@ -132,10 +130,11 @@ final class PassphrasesService: Loggable { reason: LCPAuthenticationReason, using authentication: LCPAuthenticating, allowUserInteraction: Bool, - sender: Any? + sender: UncheckedSendable? ) async throws -> LCPPassphraseHash? { let authenticatedLicense = LCPAuthenticatedLicense(document: license) - guard let clearPassphrase = await authentication.retrievePassphrase( + guard let clearPassphrase = await retrievePassphrase( + using: authentication, for: authenticatedLicense, reason: reason, allowUserInteraction: allowUserInteraction, @@ -148,7 +147,7 @@ final class PassphrasesService: Loggable { var passphrases = [hashedPassphrase] // Note: The C++ LCP lib crashes if we provide a passphrase that is not a valid // SHA-256 hash. So we check this beforehand. - if sha256Predicate.evaluate(with: clearPassphrase) { + if clearPassphrase.count == 64, clearPassphrase.allSatisfy({ $0.isASCII && $0.isHexDigit }) { passphrases.append(clearPassphrase) } @@ -171,4 +170,24 @@ final class PassphrasesService: Loggable { return passphrase } + + /// Prompts the user for a passphrase on the main actor. + /// + /// The non-`Sendable` `sender` is unwrapped here, inside the main actor, so + /// it never crosses an actor boundary. + @MainActor + private func retrievePassphrase( + using authentication: LCPAuthenticating, + for license: LCPAuthenticatedLicense, + reason: LCPAuthenticationReason, + allowUserInteraction: Bool, + sender: UncheckedSendable? + ) async -> String? { + await authentication.retrievePassphrase( + for: license, + reason: reason, + allowUserInteraction: allowUserInteraction, + sender: sender?.value + ) + } } diff --git a/TestApp/Sources/App/AppModule.swift b/TestApp/Sources/App/AppModule.swift index 071777f357..3a01863c47 100644 --- a/TestApp/Sources/App/AppModule.swift +++ b/TestApp/Sources/App/AppModule.swift @@ -13,7 +13,7 @@ import UIKit /// Base module delegate, that sub-modules' delegate can extend. /// Provides basic shared functionalities. -protocol ModuleDelegate: AnyObject { +@MainActor protocol ModuleDelegate: AnyObject { func presentAlert(_ title: String, message: String, from viewController: UIViewController) func presentError(_ error: Error, from viewController: UIViewController) } @@ -21,7 +21,7 @@ protocol ModuleDelegate: AnyObject { /// Main application module, it: /// - owns the sub-modules (library, reader, etc.) /// - orchestrates the communication between its sub-modules, through the modules' delegates. -final class AppModule: Loggable { +@MainActor final class AppModule: Loggable { // App modules var library: LibraryModuleAPI! var reader: ReaderModuleAPI! @@ -29,7 +29,7 @@ final class AppModule: Loggable { let readium: Readium - init() throws { + @MainActor init() throws { let file = Paths.library.appendingPath("database.db", isDirectory: false) let db = try Database(file: file.url) print("Created database at \(file.path)") diff --git a/TestApp/Sources/App/Readium.swift b/TestApp/Sources/App/Readium.swift index 99e29943bd..889c16a73b 100644 --- a/TestApp/Sources/App/Readium.swift +++ b/TestApp/Sources/App/Readium.swift @@ -14,7 +14,7 @@ import ReadiumStreamer import ReadiumLCP #endif -final class Readium { +@MainActor final class Readium { lazy var httpClient: HTTPClient = DefaultHTTPClient() lazy var formatSniffer: FormatSniffer = DefaultFormatSniffer() @@ -42,6 +42,7 @@ final class Readium { lazy var lcpService = LCPService( client: LCPClient(), + deviceName: UIDevice.current.name, licenseRepository: LCPKeychainLicenseRepository(), passphraseRepository: LCPKeychainPassphraseRepository(), assetRetriever: assetRetriever, diff --git a/TestApp/Sources/LCP/LCPModule.swift b/TestApp/Sources/LCP/LCPModule.swift index 4aaf244d0e..4f257a75f2 100644 --- a/TestApp/Sources/LCP/LCPModule.swift +++ b/TestApp/Sources/LCP/LCPModule.swift @@ -21,7 +21,7 @@ struct LCPPublication { let suggestedFilename: String } -protocol LCPModuleAPI { +@MainActor protocol LCPModuleAPI { init(readium: Readium) func fulfill(_ file: FileURL, progress: @escaping (Double) -> Void) async throws -> LCPPublication } diff --git a/TestApp/Sources/Library/LibraryModule.swift b/TestApp/Sources/Library/LibraryModule.swift index 35fbb9af4b..d933dc0dc7 100644 --- a/TestApp/Sources/Library/LibraryModule.swift +++ b/TestApp/Sources/Library/LibraryModule.swift @@ -11,7 +11,7 @@ import ReadiumStreamer import UIKit /// The Library module handles the presentation of the bookshelf, and the publications' management. -protocol LibraryModuleAPI { +@MainActor protocol LibraryModuleAPI { var delegate: LibraryModuleDelegate? { get } /// Root navigation controller containing the Library. @@ -29,7 +29,7 @@ protocol LibraryModuleAPI { ) async throws -> Book } -protocol LibraryModuleDelegate: ModuleDelegate { +@MainActor protocol LibraryModuleDelegate: ModuleDelegate { /// Called when the user tap on a publication in the library. func libraryDidSelectPublication(_ publication: Publication, book: Book) } diff --git a/TestApp/Sources/Library/LibraryService.swift b/TestApp/Sources/Library/LibraryService.swift index 1d9e965ad8..4e45e02a51 100644 --- a/TestApp/Sources/Library/LibraryService.swift +++ b/TestApp/Sources/Library/LibraryService.swift @@ -15,7 +15,7 @@ import UIKit /// - Import new publications (`Book` in the database). /// - Remove existing publications from the bookshelf. /// - Open publications for presentation in a navigator. -final class LibraryService: Loggable { +@MainActor final class LibraryService: Loggable { private let books: BookRepository private let readium: Readium private let lcp: LCPModuleAPI diff --git a/TestApp/Sources/OPDS/OPDSModule.swift b/TestApp/Sources/OPDS/OPDSModule.swift index 943b6678c3..5120d9c37a 100644 --- a/TestApp/Sources/OPDS/OPDSModule.swift +++ b/TestApp/Sources/OPDS/OPDSModule.swift @@ -15,7 +15,7 @@ enum OPDSError: Error { } /// The OPDS module handles the presentation of OPDS catalogs. -protocol OPDSModuleAPI { +@MainActor protocol OPDSModuleAPI { var delegate: OPDSModuleDelegate? { get } /// Root navigation controller containing the OPDS catalogs. @@ -23,7 +23,7 @@ protocol OPDSModuleAPI { var rootViewController: UINavigationController { get } } -protocol OPDSModuleDelegate: ModuleDelegate { +@MainActor protocol OPDSModuleDelegate: ModuleDelegate { /// Called when an OPDS publication needs to be imported. func opdsDownloadPublication( _ publication: Publication?, diff --git a/TestApp/Sources/Reader/ReaderModule.swift b/TestApp/Sources/Reader/ReaderModule.swift index f9f3d6bf65..078cf44801 100644 --- a/TestApp/Sources/Reader/ReaderModule.swift +++ b/TestApp/Sources/Reader/ReaderModule.swift @@ -11,7 +11,7 @@ import UIKit /// The ReaderModule handles the presentation of publications to be read by the user. /// It contains sub-modules implementing ReaderFormatModule to handle each format of publication (eg. CBZ, EPUB). -protocol ReaderModuleAPI { +@MainActor protocol ReaderModuleAPI { var delegate: ReaderModuleDelegate? { get } /// Presents the given publication to the user, inside the given navigation controller. @@ -19,7 +19,7 @@ protocol ReaderModuleAPI { func presentPublication(publication: Publication, book: Book, in navigationController: UINavigationController) } -protocol ReaderModuleDelegate: ModuleDelegate {} +@MainActor protocol ReaderModuleDelegate: ModuleDelegate {} final class ReaderModule: ReaderModuleAPI { weak var delegate: ReaderModuleDelegate? @@ -82,7 +82,7 @@ final class ReaderModule: ReaderModuleAPI { highlights: highlights, readium: readium ) - await present(readerViewController) + present(readerViewController) } catch { delegate.presentError(error, from: navigationController) } diff --git a/Tests/LCPTests/LCPDecryptionTests.swift b/Tests/LCPTests/LCPDecryptionTests.swift index e2b5c3bdd4..061f48f0bd 100644 --- a/Tests/LCPTests/LCPDecryptionTests.swift +++ b/Tests/LCPTests/LCPDecryptionTests.swift @@ -21,6 +21,7 @@ struct LCPDecryptionTests { let service = LCPService( client: LCPTestClient(), + deviceName: "Test Device", licenseRepository: InMemoryLCPLicenseRepository(), passphraseRepository: InMemoryLCPPassphraseRepository(), assetRetriever: assetRetriever, diff --git a/Tests/LCPTests/LCPTestClient.swift b/Tests/LCPTests/LCPTestClient.swift index c52a8f9af5..65f628cfc0 100644 --- a/Tests/LCPTests/LCPTestClient.swift +++ b/Tests/LCPTests/LCPTestClient.swift @@ -5,10 +5,10 @@ // import Foundation -import R2LCPClient +@preconcurrency import R2LCPClient import ReadiumLCP -class LCPTestClient: LCPClient { +final class LCPTestClient: LCPClient { func createContext(jsonLicense: String, hashedPassphrase: String, pemCrl: String) throws -> LCPClientContext { try R2LCPClient.createContext(jsonLicense: jsonLicense, hashedPassphrase: hashedPassphrase, pemCrl: pemCrl) } diff --git a/Tests/LCPTests/Repositories/InMemoryLCPLicenseRepository.swift b/Tests/LCPTests/Repositories/InMemoryLCPLicenseRepository.swift index 870305b279..b8194a4fa4 100644 --- a/Tests/LCPTests/Repositories/InMemoryLCPLicenseRepository.swift +++ b/Tests/LCPTests/Repositories/InMemoryLCPLicenseRepository.swift @@ -7,7 +7,7 @@ import Foundation @testable import ReadiumLCP -class InMemoryLCPLicenseRepository: LCPLicenseRepository { +actor InMemoryLCPLicenseRepository: LCPLicenseRepository { private var licenses: [LicenseDocument.ID: LicenseDocument] = [:] private var registeredDevices: Set = [] private var rights: [LicenseDocument.ID: LCPConsumableUserRights] = [:] @@ -38,12 +38,13 @@ class InMemoryLCPLicenseRepository: LCPLicenseRepository { rights[id] ?? LCPConsumableUserRights(print: nil, copy: nil) } - func updateUserRights( + func updateUserRights( for id: LicenseDocument.ID, - with changes: (inout LCPConsumableUserRights) -> Void - ) async throws { + with changes: @Sendable (inout LCPConsumableUserRights) throws -> T + ) async throws -> T { var current = rights[id] ?? LCPConsumableUserRights(print: nil, copy: nil) - changes(¤t) + let result = try changes(¤t) rights[id] = current + return result } } diff --git a/Tests/LCPTests/Repositories/InMemoryLCPPassphraseRepository.swift b/Tests/LCPTests/Repositories/InMemoryLCPPassphraseRepository.swift index f2a70f18fd..a33b0ab448 100644 --- a/Tests/LCPTests/Repositories/InMemoryLCPPassphraseRepository.swift +++ b/Tests/LCPTests/Repositories/InMemoryLCPPassphraseRepository.swift @@ -7,7 +7,7 @@ import Foundation @testable import ReadiumLCP -class InMemoryLCPPassphraseRepository: LCPPassphraseRepository { +actor InMemoryLCPPassphraseRepository: LCPPassphraseRepository { private struct Entry { var userID: User.ID? var provider: LicenseDocument.Provider? diff --git a/Tests/LCPTests/Repositories/Keychain/LCPKeychainLicenseRepositoryTests.swift b/Tests/LCPTests/Repositories/Keychain/LCPKeychainLicenseRepositoryTests.swift index dbaf639745..e523db52e5 100644 --- a/Tests/LCPTests/Repositories/Keychain/LCPKeychainLicenseRepositoryTests.swift +++ b/Tests/LCPTests/Repositories/Keychain/LCPKeychainLicenseRepositoryTests.swift @@ -6,16 +6,15 @@ import Foundation @testable import ReadiumLCP -import ReadiumShared +@testable import ReadiumShared import Testing +@Suite(.serialized) struct LCPKeychainLicenseRepositoryTests { let repository: LCPKeychainLicenseRepository init() throws { - repository = LCPKeychainLicenseRepository( - synchronizable: false - ) + repository = LCPKeychainLicenseRepository() // Clean up any existing test data try? cleanupAllTestData() } diff --git a/Tests/LCPTests/Repositories/Keychain/LCPKeychainPassphraseRepositoryTests.swift b/Tests/LCPTests/Repositories/Keychain/LCPKeychainPassphraseRepositoryTests.swift index 5fa2d2f643..146534c46d 100644 --- a/Tests/LCPTests/Repositories/Keychain/LCPKeychainPassphraseRepositoryTests.swift +++ b/Tests/LCPTests/Repositories/Keychain/LCPKeychainPassphraseRepositoryTests.swift @@ -6,9 +6,10 @@ import Foundation @testable import ReadiumLCP -import ReadiumShared +@testable import ReadiumShared import Testing +@Suite(.serialized) struct LCPKeychainPassphraseRepositoryTests { let repository: LCPKeychainPassphraseRepository diff --git a/docs/Migration Guide.md b/docs/Migration Guide.md index 9c2466ea28..02eea86ed8 100644 --- a/docs/Migration Guide.md +++ b/docs/Migration Guide.md @@ -2,7 +2,22 @@ All migration steps necessary in reading apps to upgrade to major versions of the Swift Readium toolkit will be documented in this file. - +## Unreleased + +### Required `deviceName` in `LCPService` + +`LCPService.init` now requires an explicit `deviceName`. We recommend passing `UIDevice.current.name`: + +```diff + let lcpService = LCPService( + client: LCPClient(), ++ deviceName: UIDevice.current.name, + ... + ) +``` + +> [!NOTE] +> Since iOS 16, `UIDevice.current.name` returns a generic name (e.g. "iPhone") unless the `com.apple.developer.device-information.user-assigned-device-name` entitlement is added to your app. ## 3.9.0 From fad57c3c93b38605d96f5594cfacb93e97bb1bdd Mon Sep 17 00:00:00 2001 From: Grigor Hakobyan Date: Wed, 1 Jul 2026 13:42:47 +0400 Subject: [PATCH 23/39] Migrate `ReadiumOPDS` to Swift 6 (#830) --- Package.swift | 6 +- Sources/OPDS/OPDS1Parser.swift | 120 ++++++++---------- Sources/OPDS/OPDS2Parser.swift | 29 ++--- Sources/OPDS/OPDSParser.swift | 38 +++--- Sources/OPDS/ParseData.swift | 6 +- Sources/OPDS/URLHelper.swift | 2 +- Sources/Shared/OPDS/Feed.swift | 2 +- Sources/Shared/OPDS/Group.swift | 2 +- .../OPDS/OPDSFeeds/OPDSFeedViewModel.swift | 38 +++--- 9 files changed, 108 insertions(+), 135 deletions(-) diff --git a/Package.swift b/Package.swift index a38c77b928..a5b6a8d240 100644 --- a/Package.swift +++ b/Package.swift @@ -171,12 +171,14 @@ let package = Package( // FIXME: Remove this once the Swift 6 migration is done. let swift6EnabledTargets: Set = [ + "ReadiumLCP", + "ReadiumLCPTests", + "ReadiumOPDS", + "ReadiumOPDSTests", "ReadiumShared", "ReadiumSharedTests", "ReadiumStreamer", "ReadiumStreamerTests", - "ReadiumLCP", - "ReadiumLCPTests", ] for target in package.targets { diff --git a/Sources/OPDS/OPDS1Parser.swift b/Sources/OPDS/OPDS1Parser.swift index 932ea41c42..6f8384f7b6 100644 --- a/Sources/OPDS/OPDS1Parser.swift +++ b/Sources/OPDS/OPDS1Parser.swift @@ -27,27 +27,20 @@ struct MimeTypeParameters { var parameters = [String: String]() } -public class OPDS1Parser: Loggable { +public enum OPDS1Parser: Loggable { /// Parse an OPDS feed or publication. /// Feed can only be v1 (XML). - /// - Parameters: - /// - url: The feed URL. - /// - completion: A closure called when the parsing is complete, returning the parsed data - /// or an error if the operation failed. - public static func parseURL(url: URL, completion: @escaping (ParseData?, Error?) -> Void) { - URLSession.shared.dataTask(with: url) { data, response, error in - guard let data = data, let response = response else { - completion(nil, error ?? OPDSParserError.documentNotFound) - return - } + /// - Parameter url: The feed URL. + /// - Returns: The parsed `ParseData`. + /// - Throws: An error if the resource could not be fetched or parsed. + public static func parseURL(url: URL) async throws -> ParseData { + let (data, response) = try await URLSession.shared.data(from: url) + return try parse(xmlData: data, url: url, response: response) + } - do { - let parseData = try self.parse(xmlData: data, url: url, response: response) - completion(parseData, nil) - } catch { - completion(nil, error) - } - }.resume() + @available(*, unavailable, message: "Use the async variant of parseURL(url:) instead") + public static func parseURL(url: URL, completion: @escaping (ParseData?, Error?) -> Void) { + fatalError() } /// Parse an OPDS feed or publication. @@ -234,66 +227,59 @@ public class OPDS1Parser: Loggable { return parseEntry(entry: root, feedURL: feedURL) } - /// Fetch an Open Search template from an OPDS feed. - /// - Parameters: - /// - feed: The OPDS feed to search for the template. - /// - completion: A closure called with the OpenSearch template as a `String` if found, - /// or an `Error` if the fetch or parsing failed. + @available(*, unavailable, message: "Use the async variant of fetchOpenSearchTemplate(feed:) instead") public static func fetchOpenSearchTemplate(feed: Feed, completion: @escaping (String?, Error?) -> Void) { + fatalError() + } + + /// Fetch an Open Search template from an OPDS feed. + /// - Parameter feed: The OPDS feed to search for the template. + /// - Returns: The OpenSearch template. + /// - Throws: ``OPDSParserOpenSearchHelperError`` if the search link is missing or the + /// OpenSearch document is invalid. + public static func fetchOpenSearchTemplate(feed: Feed) async throws -> String { guard let openSearchHref = feed.links.firstWithRel(.search)?.href, let openSearchURL = URL(string: openSearchHref) else { - completion(nil, OPDSParserOpenSearchHelperError.searchLinkNotFound) - return + throw OPDSParserOpenSearchHelperError.searchLinkNotFound } - URLSession.shared.dataTask(with: openSearchURL) { data, _, error in - guard let data = data else { - completion(nil, error ?? OPDSParserOpenSearchHelperError.searchDocumentIsInvalid) - return - } - guard let document = try? XMLDocument(data: data) else { - completion(nil, OPDSParserOpenSearchHelperError.searchDocumentIsInvalid) - return - } - guard let urls = document.root?.children(tag: "Url") else { - completion(nil, OPDSParserOpenSearchHelperError.searchDocumentIsInvalid) - return - } - if urls.count == 0 { - completion(nil, OPDSParserOpenSearchHelperError.searchDocumentIsInvalid) - return - } - // The OpenSearch document may contain multiple Urls, and we need to find the closest matching one. - // We match by mimetype and profile; if that fails, by mimetype; and if that fails, the first url is returned - var typeAndProfileMatch: ReadiumFuzi.XMLElement? = nil - var typeMatch: ReadiumFuzi.XMLElement? = nil - if let selfMimeType = feed.links.firstWithRel(.self)?.mediaType { - let selfMimeParams = parseMimeType(mimeTypeString: selfMimeType.string) - for url in urls { - guard let urlMimeType = url.attributes["type"] else { - continue + let (data, _) = try await URLSession.shared.data(from: openSearchURL) + + guard let document = try? XMLDocument(data: data) else { + throw OPDSParserOpenSearchHelperError.searchDocumentIsInvalid + } + guard let urls = document.root?.children(tag: "Url"), !urls.isEmpty else { + throw OPDSParserOpenSearchHelperError.searchDocumentIsInvalid + } + // The OpenSearch document may contain multiple Urls, and we need to find the closest matching one. + // We match by mimetype and profile; if that fails, by mimetype; and if that fails, the first url is returned + var typeAndProfileMatch: ReadiumFuzi.XMLElement? = nil + var typeMatch: ReadiumFuzi.XMLElement? = nil + if let selfMimeType = feed.links.firstWithRel(.self)?.mediaType { + let selfMimeParams = parseMimeType(mimeTypeString: selfMimeType.string) + for url in urls { + guard let urlMimeType = url.attributes["type"] else { + continue + } + let otherMimeParams = parseMimeType(mimeTypeString: urlMimeType) + if selfMimeParams.type == otherMimeParams.type { + if typeMatch == nil { + typeMatch = url } - let otherMimeParams = parseMimeType(mimeTypeString: urlMimeType) - if selfMimeParams.type == otherMimeParams.type { - if typeMatch == nil { - typeMatch = url - } - if selfMimeParams.parameters["profile"] == otherMimeParams.parameters["profile"] { - typeAndProfileMatch = url - break - } + if selfMimeParams.parameters["profile"] == otherMimeParams.parameters["profile"] { + typeAndProfileMatch = url + break } } } - let match = typeAndProfileMatch ?? (typeMatch ?? urls[0]) - guard let template = match.attributes["template"] else { - completion(nil, OPDSParserOpenSearchHelperError.searchDocumentIsInvalid) - return - } + } + let match = typeAndProfileMatch ?? (typeMatch ?? urls[0]) + guard let template = match.attributes["template"] else { + throw OPDSParserOpenSearchHelperError.searchDocumentIsInvalid + } - completion(template, nil) - }.resume() + return template } static func parseMimeType(mimeTypeString: String) -> MimeTypeParameters { diff --git a/Sources/OPDS/OPDS2Parser.swift b/Sources/OPDS/OPDS2Parser.swift index 07bcfe3e5e..8968586fa6 100644 --- a/Sources/OPDS/OPDS2Parser.swift +++ b/Sources/OPDS/OPDS2Parser.swift @@ -18,27 +18,20 @@ public enum OPDS2ParserError: Error, Sendable { case invalidNavigation } -public class OPDS2Parser: Loggable { +public enum OPDS2Parser: Loggable { /// Parse an OPDS feed or publication. /// Feed can only be v2 (JSON). - /// - Parameters: - /// - url: The feed URL. - /// - completion: A closure called when the parsing is complete, returning the - /// parsed `ParseData` on success, or an `Error` if the operation failed. - public static func parseURL(url: URL, completion: @escaping (ParseData?, Error?) -> Void) { - URLSession.shared.dataTask(with: url) { data, response, error in - guard let data = data, let response = response else { - completion(nil, error ?? OPDSParserError.documentNotFound) - return - } + /// - Parameter url: The feed URL. + /// - Returns: The parsed `ParseData`. + /// - Throws: An error if the resource could not be fetched or parsed. + public static func parseURL(url: URL) async throws -> ParseData { + let (data, response) = try await URLSession.shared.data(from: url) + return try parse(jsonData: data, url: url, response: response) + } - do { - let parseData = try self.parse(jsonData: data, url: url, response: response) - completion(parseData, nil) - } catch { - completion(nil, error) - } - }.resume() + @available(*, unavailable, message: "Use the async variant of parseURL(url:) instead") + public static func parseURL(url: URL, completion: @escaping (ParseData?, Error?) -> Void) { + fatalError() } /// Parse an OPDS feed or publication. diff --git a/Sources/OPDS/OPDSParser.swift b/Sources/OPDS/OPDSParser.swift index 904eccc73d..4cbb2d14d1 100644 --- a/Sources/OPDS/OPDSParser.swift +++ b/Sources/OPDS/OPDSParser.swift @@ -12,30 +12,24 @@ public enum OPDSParserError: Error, Sendable { case documentNotValid } -public enum OPDSParser: Sendable { +public enum OPDSParser { /// Parse an OPDS feed or publication. /// Feed can be v1 (XML) or v2 (JSON). - /// - Parameters: - /// - url: The feed URL. - /// - completion: A closure called when the parsing is complete, returning the - /// parsed `ParseData` on success, or an `Error` if the operation failed. - public static func parseURL(url: URL, completion: @escaping (ParseData?, Error?) -> Void) { - URLSession.shared.dataTask(with: url) { data, response, error in - guard let data = data, let response = response else { - completion(nil, error ?? OPDSParserError.documentNotFound) - return - } + /// - Parameter url: The feed URL. + /// - Returns: The parsed `ParseData`. + /// - Throws: An error if the resource could not be fetched, or is not a valid OPDS resource. + public static func parseURL(url: URL) async throws -> ParseData { + let (data, response) = try await URLSession.shared.data(from: url) - // We try to parse as an OPDS v1 feed, - // then, if it fails, we try as an OPDS v2 feed. - if let parseData = try? OPDS1Parser.parse(xmlData: data, url: url, response: response) { - completion(parseData, nil) - } else if let parseData = try? OPDS2Parser.parse(jsonData: data, url: url, response: response) { - completion(parseData, nil) - } else { - // Not a valid OPDS ressource - completion(nil, OPDSParserError.documentNotValid) - } - }.resume() + // We try to parse as an OPDS v1 feed, + // then, if it fails, we try as an OPDS v2 feed. + if let parseData = try? OPDS1Parser.parse(xmlData: data, url: url, response: response) { + return parseData + } else if let parseData = try? OPDS2Parser.parse(jsonData: data, url: url, response: response) { + return parseData + } else { + // Not a valid OPDS ressource + throw OPDSParserError.documentNotValid + } } } diff --git a/Sources/OPDS/ParseData.swift b/Sources/OPDS/ParseData.swift index bb8cb7223a..91dd5dafa6 100644 --- a/Sources/OPDS/ParseData.swift +++ b/Sources/OPDS/ParseData.swift @@ -15,9 +15,9 @@ public enum Version: Sendable { case OPDS2 } -/// An intermediate structure return when the generic helper method public static -/// func parseURL(url: URL, completion: (ParseData?, Error?) -> Void) from OPDSParser class is called. -public struct ParseData { +/// An intermediate structure returned by the generic helper method +/// `OPDSParser.parseURL(url:)`. +public struct ParseData: Sendable { /// The ressource URL public var url: URL diff --git a/Sources/OPDS/URLHelper.swift b/Sources/OPDS/URLHelper.swift index 5157fc5dff..587e857637 100644 --- a/Sources/OPDS/URLHelper.swift +++ b/Sources/OPDS/URLHelper.swift @@ -6,7 +6,7 @@ import Foundation -class URLHelper { +enum URLHelper { /** Check if an href destination is absolute or not. diff --git a/Sources/Shared/OPDS/Feed.swift b/Sources/Shared/OPDS/Feed.swift index 8bde94d08a..8f7b299246 100644 --- a/Sources/Shared/OPDS/Feed.swift +++ b/Sources/Shared/OPDS/Feed.swift @@ -5,7 +5,7 @@ // /// Main structure of an OPDS catalog. -public struct Feed { +public struct Feed: Sendable { public var metadata: OpdsMetadata public var links = [Link]() public var facets = [Facet]() diff --git a/Sources/Shared/OPDS/Group.swift b/Sources/Shared/OPDS/Group.swift index dbc0ae96a6..584bee6019 100644 --- a/Sources/Shared/OPDS/Group.swift +++ b/Sources/Shared/OPDS/Group.swift @@ -5,7 +5,7 @@ // /// A substructure of a feed. -public struct Group { +public struct Group: Sendable { public var metadata: OpdsMetadata public var links = [Link]() public var publications = [Publication]() diff --git a/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedViewModel.swift b/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedViewModel.swift index 6ffacc67b3..2c763796cb 100644 --- a/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedViewModel.swift +++ b/TestApp/Sources/OPDS/OPDSFeeds/OPDSFeedViewModel.swift @@ -36,20 +36,19 @@ class OPDSFeedViewModel: ObservableObject { error = nil nextPageURL = nil // Reset next page URL - OPDSParser.parseURL(url: feedURL) { [weak self] data, error in - DispatchQueue.main.async { - guard let self = self else { return } - - if let data = data, let feed = data.feed { + Task { + do { + let data = try await OPDSParser.parseURL(url: feedURL) + if let feed = data.feed { self.feed = feed // Find and store the next page URL self.nextPageURL = self.findNextPageURL(feed: feed) - } else if let error = error { - self.error = error - print("Failed to parse feed: \(error)") } else { - self.error = OPDSError.invalidURL(self.feedURL.absoluteString) + self.error = OPDSError.invalidURL(feedURL.absoluteString) } + } catch { + self.error = error + print("Failed to parse feed: \(error)") } } } @@ -63,21 +62,20 @@ class OPDSFeedViewModel: ObservableObject { isLoadingNextPage = true - OPDSParser.parseURL(url: url) { [weak self] data, error in - DispatchQueue.main.async { - guard let self = self else { return } - - if let data = data, let newFeed = data.feed { + Task { + do { + let data = try await OPDSParser.parseURL(url: url) + if let newFeed = data.feed { // Append new publications to the existing feed self.feed?.publications.append(contentsOf: newFeed.publications) // Find the *next* next page URL self.nextPageURL = self.findNextPageURL(feed: newFeed) - } else if let error = error { - print("Failed to load next page: \(error)") } - - self.isLoadingNextPage = false + } catch { + print("Failed to load next page: \(error)") } + + self.isLoadingNextPage = false } } @@ -109,7 +107,7 @@ class OPDSFeedViewModel: ObservableObject { /// True if the feed contains only publications and no navigation or groups. /// The View uses this to decide whether to show a grid or a list. var isPublicationOnly: Bool { - guard let feed = feed else { return false } + guard let feed else { return false } return !feed.publications.isEmpty && feed.navigation.isEmpty && feed.groups.isEmpty @@ -117,7 +115,7 @@ class OPDSFeedViewModel: ObservableObject { /// True if the feed contains any content at all. var hasContent: Bool { - guard let feed = feed else { return false } + guard let feed else { return false } return !feed.navigation.isEmpty || !feed.groups.isEmpty || !feed.publications.isEmpty From 566f3a617f09a4ece21d13df3f2b0db34be681ec Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Fri, 3 Jul 2026 07:48:35 -0500 Subject: [PATCH 24/39] Migrate `ReadiumNavigator` to Swift 6 (#832) --- .github/workflows/checks.yml | 34 ++--- Package.swift | 4 +- README.md | 2 +- .../Navigator/Audiobook/AudioNavigator.swift | 40 +++--- .../Decorator/DecorableNavigator.swift | 1 + .../DirectionalNavigationAdapter.swift | 9 +- .../EPUB/EPUBNavigatorViewController.swift | 136 +++++++++++------- .../EPUB/EPUBNavigatorViewModel.swift | 2 + Sources/Navigator/EPUB/EPUBSpreadView.swift | 32 +++-- .../EPUBViewportAndLocationCalculator.swift | 1 + .../EPUB/HTMLDecorationTemplate.swift | 9 +- Sources/Navigator/EditingAction.swift | 3 + .../Input/InputObservableViewController.swift | 5 + .../PDF/PDFNavigatorViewController.swift | 5 +- .../PDF/PDFTapGestureController.swift | 1 + .../Preferences/MappedPreference.swift | 8 +- .../Navigator/Preferences/Preference.swift | 2 +- .../Preferences/PreferencesEditor.swift | 16 +-- .../Preferences/ProxyPreference.swift | 6 +- Sources/Navigator/TTS/AVTTSEngine.swift | 112 ++++++++------- .../TTS/PublicationSpeechSynthesizer.swift | 2 +- Sources/Navigator/TTS/TTSEngine.swift | 1 + .../Navigator/Toolkit/CompletionList.swift | 8 +- Sources/Navigator/Toolkit/HTMLInjection.swift | 7 +- .../Navigator/Toolkit/PaginationView.swift | 6 +- Sources/Navigator/Toolkit/TargetAction.swift | 1 + Sources/Shared/Toolkit/Cache.swift | 37 ----- .../Shared/Toolkit/Media/NowPlayingInfo.swift | 6 +- 28 files changed, 266 insertions(+), 230 deletions(-) delete mode 100644 Sources/Shared/Toolkit/Cache.swift diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 227c2903a8..1d92a47245 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -11,21 +11,21 @@ concurrency: env: platform: ${{ 'iOS Simulator' }} - device: ${{ 'iPhone 16 Pro' }} + device: ${{ 'iPhone 17 Pro' }} commit_sha: ${{ github.sha }} - DEVELOPER_DIR: /Applications/Xcode_16.4.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_26.4.app/Contents/Developer jobs: build: name: Build - runs-on: macos-15 + runs-on: macos-26 if: ${{ !github.event.pull_request.draft }} env: scheme: ${{ 'Readium-Package' }} steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: Install dependencies run: | brew update @@ -52,11 +52,11 @@ jobs: # navigator-ui-tests: # name: Navigator UI Tests - # runs-on: macos-15 + # runs-on: macos-26 # if: ${{ !github.event.pull_request.draft }} # steps: # - name: Checkout - # uses: actions/checkout@v3 + # uses: actions/checkout@v6 # - name: Install dependencies # run: | # brew update @@ -71,11 +71,11 @@ jobs: playground: name: Playground - runs-on: macos-15 + runs-on: macos-26 if: ${{ !github.event.pull_request.draft }} steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: Install dependencies run: | brew update @@ -93,23 +93,23 @@ jobs: lint: name: Lint - runs-on: macos-15 + runs-on: macos-26 if: ${{ !github.event.pull_request.draft }} env: scripts: ${{ 'Sources/Navigator/EPUB/Scripts' }} steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: Install pnpm - uses: pnpm/action-setup@v2 + uses: pnpm/action-setup@v6 with: package_json_file: Sources/Navigator/EPUB/Scripts/package.json run_install: false - name: Setup cache - uses: actions/setup-node@v3 + uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 24 cache: 'pnpm' cache-dependency-path: Sources/Navigator/EPUB/Scripts/pnpm-lock.yaml - name: Install dependencies @@ -127,7 +127,7 @@ jobs: int-dev: name: Integration (Local) - runs-on: macos-15 + runs-on: macos-26 if: ${{ !github.event.pull_request.draft }} defaults: run: @@ -137,7 +137,7 @@ jobs: deployment: false steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: Install dependencies run: | brew update @@ -153,7 +153,7 @@ jobs: int-spm: name: Integration (Swift Package Manager) - runs-on: macos-15 + runs-on: macos-26 if: ${{ !github.event.pull_request.draft }} defaults: run: @@ -163,7 +163,7 @@ jobs: deployment: false steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v6 # We can't use the current github.sha with pull_request event, because they will # reference the merge commit which cannot be fetched with SPM. - name: Set commit SHA diff --git a/Package.swift b/Package.swift index a5b6a8d240..644b492bb3 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:6.0 +// swift-tools-version:6.2 // // Copyright 2026 Readium Foundation. All rights reserved. // Use of this source code is governed by the BSD-style license @@ -173,6 +173,8 @@ let package = Package( let swift6EnabledTargets: Set = [ "ReadiumLCP", "ReadiumLCPTests", + "ReadiumNavigator", + "ReadiumNavigatorTests", "ReadiumOPDS", "ReadiumOPDSTests", "ReadiumShared", diff --git a/README.md b/README.md index cde1cb7c43..8e18208a23 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ Guides are available to help you make the most of the toolkit. | Readium | iOS | Swift compiler | Xcode | |---------|-----|----------------|-------| -| `develop` | 15.0 | 6.0 | 16.4 | +| `develop` | 15.0 | 6.0 | 26.4 | | 3.8.0 | 15.0 | 6.0 | 16.4 | | 3.0.0 | 13.4 | 5.10 | 15.4 | | 2.5.1 | 11.0 | 5.6.1 | 13.4 | diff --git a/Sources/Navigator/Audiobook/AudioNavigator.swift b/Sources/Navigator/Audiobook/AudioNavigator.swift index 2b97e1d74e..d2015220fe 100644 --- a/Sources/Navigator/Audiobook/AudioNavigator.swift +++ b/Sources/Navigator/Audiobook/AudioNavigator.swift @@ -257,7 +257,9 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo private lazy var mediaLoader = PublicationMediaLoader(publication: publication) - private lazy var player: AVPlayer = { + private lazy var player: AVPlayer = makePlayer() + + private func makePlayer() -> AVPlayer { let player = AVPlayer() player.allowsExternalPlayback = false player.automaticallyWaitsToMinimizeStalling = false @@ -331,7 +333,7 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo } return player - }() + } private func shouldPlayNextResource(completion: @escaping @MainActor @Sendable (Bool) -> Void) { guard let delegate = delegate else { @@ -360,29 +362,25 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo private func makePlaybackInfo(forTime time: Double? = nil, completion: @escaping @MainActor @Sendable (MediaPlaybackInfo) -> Void) { let resourceIndex = resourceIndex let state = state - let currentTime = time ?? currentTime - let linkDuration = publication.readingOrder[resourceIndex].duration + let time = time ?? currentTime + let defaultDuration = publication.readingOrder[resourceIndex].duration let currentItem = player.currentItem - // A deadlock can occur when loading HTTP assets and creating the - // playback info from the main thread. To fix this, this is an - // asynchronous operation. - Task.detached { - var duration: Double? = linkDuration - if let itemDuration = currentItem?.duration, itemDuration.isNumeric { - duration = itemDuration.secondsOrZero + Task { + // A deadlock can occur when loading HTTP assets and creating the playback info from the main thread. + // To fix this, we load the duration asynchronously. + var duration = defaultDuration + if let currentItem = currentItem, let seconds = try? await currentItem.asset.load(.duration).seconds, seconds.isFinite { + duration = seconds } let info = MediaPlaybackInfo( resourceIndex: resourceIndex, state: state, - time: currentTime, + time: time, duration: duration ) - - Task { @MainActor in - completion(info) - } + completion(info) } } @@ -416,12 +414,12 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo private var lastLoadedTimeRanges: [Range] = [] private lazy var loadedTimeRangesTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] timer in - MainActor.assumeIsolated { - guard let self = self else { - timer.invalidate() - return - } + guard let self = self else { + timer.invalidate() + return + } + MainActor.assumeIsolated { let ranges: [Range] = (self.player.currentItem?.loadedTimeRanges ?? []) .map { value in let range = value.timeRangeValue diff --git a/Sources/Navigator/Decorator/DecorableNavigator.swift b/Sources/Navigator/Decorator/DecorableNavigator.swift index fb7673a121..56c4bd7eb2 100644 --- a/Sources/Navigator/Decorator/DecorableNavigator.swift +++ b/Sources/Navigator/Decorator/DecorableNavigator.swift @@ -9,6 +9,7 @@ import ReadiumShared import UIKit /// A navigator able to render arbitrary decorations over a publication. +@MainActor public protocol DecorableNavigator { /// Declares the current state of the decorations in the given decoration `group`. /// diff --git a/Sources/Navigator/DirectionalNavigationAdapter.swift b/Sources/Navigator/DirectionalNavigationAdapter.swift index 588328c7e7..cbd5d85f54 100644 --- a/Sources/Navigator/DirectionalNavigationAdapter.swift +++ b/Sources/Navigator/DirectionalNavigationAdapter.swift @@ -132,16 +132,13 @@ import Foundation self.onNavigation = onNavigation } - deinit { + isolated deinit { guard let nav = boundNavigator else { return } - let tokens = observerTokens - Task { @MainActor [weak nav] in - for token in tokens { - nav?.removeObserver(token) - } + for token in observerTokens { + nav.removeObserver(token) } } diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift index dff7f49b7a..12577bcbc7 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift @@ -91,6 +91,7 @@ open class EPUBNavigatorViewController: InputObservableViewController, /// Logs the state changes when true. public var debugState: Bool + @MainActor public init( preferences: EPUBPreferences = .empty, defaults: EPUBDefaults = EPUBDefaults(), @@ -285,17 +286,23 @@ open class EPUBNavigatorViewController: InputObservableViewController, config: config ) + // Positions and total progression only make sense in the context + // of the publication's actual reading order. Therefore when + // provided with a different reading order, we should assume the + // positions list is empty, and also not compute the + // totalProgression when calculating the current locator. + let positionsByReadingOrderClosure: () async -> ReadResult<[[Locator]]> + if readingOrder != nil { + positionsByReadingOrderClosure = { .success([]) } + } else { + positionsByReadingOrderClosure = { await publication.positionsByReadingOrder() } + } + self.init( viewModel: viewModel, initialLocation: initialLocation, readingOrder: viewModel.readingOrder, - positionsByReadingOrder: - // Positions and total progression only make sense in the context - // of the publication's actual reading order. Therefore when - // provided with a different reading order, we should assume the - // positions list is empty, and also not compute the - // totalProgression when calculating the current locator. - (readingOrder != nil) ? { .success([]) } : publication.positionsByReadingOrder + positionsByReadingOrder: positionsByReadingOrderClosure ) } @@ -804,43 +811,32 @@ open class EPUBNavigatorViewController: InputObservableViewController, return } - await withTaskGroup(of: Void.self) { tasks in - guard !Task.isCancelled else { return } + guard !Task.isCancelled else { return } - let source = self.decorations[group] ?? [] - let target = decorations.map { - var d = $0 - d.locator = self.publication.normalizeLocator(d.locator) - return DiffableDecoration(decoration: d) - } - self.decorations[group] = target - - if decorations.isEmpty { - for (_, pageView) in paginationView.loadedViews { - tasks.addTask { - guard !Task.isCancelled else { return } - await (pageView as? EPUBSpreadView)?.evaluateScript( - // The updates command are using `requestAnimationFrame()`, so we need it for - // `clear()` as well otherwise we might recreate a highlight after it has been - // cleared. - "requestAnimationFrame(function () { readium.getDecorations('\(group)').clear(); });" - ) + let source = self.decorations[group] ?? [] + let target = decorations.map { + var d = $0 + d.locator = self.publication.normalizeLocator(d.locator) + return DiffableDecoration(decoration: d) + } + self.decorations[group] = target + + if decorations.isEmpty { + await withTaskGroup(of: Void.self) { tasks in + for index in paginationView.loadedViews.keys { + tasks.addTask { [weak self] in + await self?.clearDecorations(group: group, atSpreadIndex: index) } } - } else { + } + } else { + await withTaskGroup(of: Void.self) { tasks in for (href, changes) in target.changesByHREF(from: source) { - guard let script = changes.javascript(forGroup: group, styles: self.config.decorationTemplates) else { + guard let script = changes.javascript(forGroup: group, styles: config.decorationTemplates) else { continue } - tasks.addTask { @MainActor [weak self] in - guard - !Task.isCancelled, - let spreadView = self?.loadedSpreadViewForHREF(href), - spreadView.isSpreadLoaded - else { - return - } - await spreadView.evaluateScript(script, inHREF: href) + tasks.addTask { [weak self] in + await self?.evaluateScript(script, inHREF: href) } } } @@ -854,23 +850,39 @@ open class EPUBNavigatorViewController: InputObservableViewController, callbacks.append(onActivated) decorationCallbacks[group] = callbacks - Task { - await initialized() + Task { [weak self] in + guard let self else { return } + await self.initialized() - guard let paginationView = paginationView else { + guard let paginationView = self.paginationView else { return } await withTaskGroup(of: Void.self) { tasks in - for (_, view) in paginationView.loadedViews { - tasks.addTask { - await (view as? EPUBSpreadView)?.evaluateScript("readium.getDecorations('\(group)').setActivable();") + for index in paginationView.loadedViews.keys { + tasks.addTask { [weak self] in + await self?.setDecorationsActivable(group: group, atSpreadIndex: index) } } } } } + @MainActor + private func clearDecorations(group: DecorationGroup, atSpreadIndex index: Int) async { + // requestAnimationFrame() is needed for clear() too, otherwise we might + // recreate a highlight after it has been cleared. + await evaluateScript( + "requestAnimationFrame(function () { readium.getDecorations('\(group)').clear(); });", + atSpreadIndex: index + ) + } + + @MainActor + private func setDecorationsActivable(group: DecorationGroup, atSpreadIndex index: Int) async { + await evaluateScript("readium.getDecorations('\(group)').setActivable();", atSpreadIndex: index) + } + // MARK: - Configurable public var settings: EPUBSettings { @@ -910,6 +922,34 @@ open class EPUBNavigatorViewController: InputObservableViewController, return await spreadView.evaluateScript(script) } + /// Evaluates the given JavaScript in the initialized resource at `href`. + /// + /// This is best-effort: if the resource's spread isn't fully initialized + /// yet (i.e. its decoration templates aren't registered), the script is + /// skipped rather than queued. + @MainActor + private func evaluateScript(_ script: String, inHREF href: AnyURL) async { + guard + !Task.isCancelled, + let spreadView = loadedSpreadViewForHREF(href), + spreadView.isSpreadInitialized + else { return } + _ = await spreadView.evaluateScript(script, inHREF: href) + } + + /// Evaluates the given JavaScript in the initialized spread at `index`. + /// + /// Best-effort in the same way as `evaluateScript(_:inHREF:)`. + @MainActor + private func evaluateScript(_ script: String, atSpreadIndex index: Int) async { + guard + !Task.isCancelled, + let spreadView = paginationView?.loadedViews[index] as? EPUBSpreadView, + spreadView.isSpreadInitialized + else { return } + _ = await spreadView.evaluateScript(script) + } + // MARK: - UIAccessibilityAction override open func accessibilityScroll(_ direction: UIAccessibilityScrollDirection) -> Bool { @@ -955,12 +995,8 @@ extension EPUBNavigatorViewController: EPUBNavigatorViewModelDelegate { await (paginationView.currentView as? EPUBSpreadView)?.evaluateScript(script) case .loadedResources: - await withTaskGroup(of: Void.self) { tasks in - for (_, view) in paginationView.loadedViews { - tasks.addTask { - await (view as? EPUBSpreadView)?.evaluateScript(script) - } - } + for (_, view) in paginationView.loadedViews { + _ = await (view as? EPUBSpreadView)?.evaluateScript(script) } case let .resource(href): diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift index 06f5cb0b1a..9a777c3744 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift @@ -8,6 +8,7 @@ import Foundation import ReadiumShared import UIKit +@MainActor protocol EPUBNavigatorViewModelDelegate: AnyObject { func epubNavigatorViewModel(_ viewModel: EPUBNavigatorViewModel, runScript script: String, in scope: EPUBScriptScope) func epubNavigatorViewModelInvalidatePaginationView(_ viewModel: EPUBNavigatorViewModel) @@ -399,6 +400,7 @@ enum EPUBScriptScope { } private extension EPUBSettings { + @MainActor init( preferences: EPUBPreferences? = nil, publication: Publication, diff --git a/Sources/Navigator/EPUB/EPUBSpreadView.swift b/Sources/Navigator/EPUB/EPUBSpreadView.swift index 5bd7570feb..d53a979a21 100644 --- a/Sources/Navigator/EPUB/EPUBSpreadView.swift +++ b/Sources/Navigator/EPUB/EPUBSpreadView.swift @@ -7,6 +7,7 @@ import ReadiumShared @preconcurrency import WebKit +@MainActor protocol EPUBSpreadViewDelegate: AnyObject { /// Returns the content inset the spread view should use. func spreadViewContentInset(_ spreadView: EPUBSpreadView) -> UIEdgeInsets @@ -55,7 +56,18 @@ class EPUBSpreadView: UIView, Loggable, PageView { weak var activityIndicatorView: UIActivityIndicatorView? private var activityIndicatorStopWorkItem: DispatchWorkItem? + /// Set once the spread's DOM is loaded and its subclass may operate on it + /// (e.g. to scroll to a pending location). Note that decoration templates + /// and other delegate-provided setup are not yet in place at this point; + /// use `isSpreadInitialized` for that. private(set) var isSpreadLoaded = false + + /// Set once the spread is fully initialized, i.e. after + /// `spreadViewDidLoad(_:)` has registered decoration templates and run + /// other delegate-provided setup. External script evaluation should gate on + /// this rather than `isSpreadLoaded` to avoid racing the setup. + private(set) var isSpreadInitialized = false + private var spreadLoadTask: Task? required init( @@ -103,7 +115,6 @@ class EPUBSpreadView: UIView, Loggable, PageView { deinit { NotificationCenter.default.removeObserver(self) - clear() } /// Called when the spread view is removed from the view hierarchy, to @@ -173,15 +184,13 @@ class EPUBSpreadView: UIView, Loggable, PageView { await spreadLoaded() log(.trace, "Evaluate script: \(script)") - return await withCheckedContinuation { continuation in - webView.evaluateJavaScript(script) { [weak self] res, error in - if let error = error { - self?.log(.error, error) - continuation.resume(returning: .failure(error)) - } else { - continuation.resume(returning: .success(res ?? ())) - } - } + + do { + let res = try await webView.evaluateJavaScript(script) + return .success(res ?? ()) + } catch { + log(.error, error) + return .failure(error) } } @@ -399,6 +408,7 @@ class EPUBSpreadView: UIView, Loggable, PageView { applySettings() await spreadDidLoad() await delegate?.spreadViewDidLoad(self) + isSpreadInitialized = true onSpreadLoadedCallbacks.complete() showSpread() } @@ -645,7 +655,7 @@ extension EPUBSpreadView: WKNavigationDelegate { setNeedsStopActivityIndicator() } - func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping @MainActor (WKNavigationActionPolicy) -> Void) { var policy: WKNavigationActionPolicy = .allow if navigationAction.navigationType == .linkActivated { diff --git a/Sources/Navigator/EPUB/EPUBViewportAndLocationCalculator.swift b/Sources/Navigator/EPUB/EPUBViewportAndLocationCalculator.swift index f471fdad0a..1320dd3ffb 100644 --- a/Sources/Navigator/EPUB/EPUBViewportAndLocationCalculator.swift +++ b/Sources/Navigator/EPUB/EPUBViewportAndLocationCalculator.swift @@ -9,6 +9,7 @@ import ReadiumShared /// Computes the current `Locator` and `Viewport` from a spread's visible /// progressions and the publication's position list. +@MainActor enum EPUBViewportAndLocationCalculator { /// Computes the locator and viewport for the currently visible spread. /// diff --git a/Sources/Navigator/EPUB/HTMLDecorationTemplate.swift b/Sources/Navigator/EPUB/HTMLDecorationTemplate.swift index b3b39cb97b..ec92d82631 100644 --- a/Sources/Navigator/EPUB/HTMLDecorationTemplate.swift +++ b/Sources/Navigator/EPUB/HTMLDecorationTemplate.swift @@ -143,9 +143,12 @@ public struct HTMLDecorationTemplate: JSONObjectEncodable { ) } - private static var classNamesId = 0 + private static let classNamesId = Mutex(0) private static func makeUniqueClassName(key: String) -> String { - classNamesId += 1 - return "readium-\(key)-\(classNamesId)" + let id = classNamesId.withLock { value -> Int in + value += 1 + return value + } + return "readium-\(key)-\(id)" } } diff --git a/Sources/Navigator/EditingAction.swift b/Sources/Navigator/EditingAction.swift index 449c8b6f13..efaa0236a9 100644 --- a/Sources/Navigator/EditingAction.swift +++ b/Sources/Navigator/EditingAction.swift @@ -16,6 +16,7 @@ import UIKit /// Then, implement the selector in one of your classes in the responder chain. /// Typically, in the `UIViewController` wrapping the navigator view /// controller. +@MainActor public struct EditingAction: Hashable, Sendable { /// Default editing actions enabled in the navigator. public static var defaultActions: [EditingAction] { @@ -83,6 +84,7 @@ public struct EditingAction: Hashable, Sendable { } } +@MainActor protocol EditingActionsControllerDelegate: AnyObject { func editingActionsDidPreventCopy(_ editingActions: EditingActionsController) func editingActions(_ editingActions: EditingActionsController, shouldShowMenuForSelection selection: Selection) -> Bool @@ -90,6 +92,7 @@ protocol EditingActionsControllerDelegate: AnyObject { } /// Handles the authorization and check of editing actions. +@MainActor final class EditingActionsController { weak var delegate: EditingActionsControllerDelegate? diff --git a/Sources/Navigator/Input/InputObservableViewController.swift b/Sources/Navigator/Input/InputObservableViewController.swift index 9287638153..452107a2bf 100644 --- a/Sources/Navigator/Input/InputObservableViewController.swift +++ b/Sources/Navigator/Input/InputObservableViewController.swift @@ -128,6 +128,7 @@ open class InputObservableViewController: UIViewController, InputObservable { } } +@MainActor extension Pointer { init(touch: UITouch, event: UIEvent?) { let id = PointerId.object(ObjectIdentifier(touch)) @@ -143,6 +144,7 @@ extension Pointer { } } +@MainActor extension KeyEvent { init?(phase: KeyEvent.Phase, uiPress: UIPress) { guard @@ -160,6 +162,7 @@ extension KeyEvent { } } +@MainActor extension Key { init?(uiPress: UIPress) { guard let key = uiPress.key else { @@ -209,6 +212,7 @@ extension Key { } } +@MainActor extension MouseButtons { init(event: UIEvent?) { self.init() @@ -226,6 +230,7 @@ extension MouseButtons { } } +@MainActor extension KeyModifiers { init(event: UIEvent?) { if let flags = event?.modifierFlags { diff --git a/Sources/Navigator/PDF/PDFNavigatorViewController.swift b/Sources/Navigator/PDF/PDFNavigatorViewController.swift index 6689308bbd..73906ac7ac 100644 --- a/Sources/Navigator/PDF/PDFNavigatorViewController.swift +++ b/Sources/Navigator/PDF/PDFNavigatorViewController.swift @@ -5,7 +5,7 @@ // import Foundation -import PDFKit +@preconcurrency import PDFKit import ReadiumShared import UIKit @@ -40,6 +40,7 @@ open class PDFNavigatorViewController: /// The default set of editing actions is `EditingAction.defaultActions`. public var editingActions: [EditingAction] + @MainActor public init( preferences: PDFPreferences = PDFPreferences(), defaults: PDFDefaults = PDFDefaults(), @@ -782,7 +783,7 @@ open class PDFNavigatorViewController: } } -extension PDFNavigatorViewController: PDFViewDelegate { +extension PDFNavigatorViewController: @preconcurrency PDFViewDelegate { public func pdfViewWillClick(onLink sender: PDFView, with url: URL) { let url = url.addingSchemeWhenMissing("http") delegate?.navigator(self, presentExternalURL: url) diff --git a/Sources/Navigator/PDF/PDFTapGestureController.swift b/Sources/Navigator/PDF/PDFTapGestureController.swift index a652c23a02..fe782858bb 100644 --- a/Sources/Navigator/PDF/PDFTapGestureController.swift +++ b/Sources/Navigator/PDF/PDFTapGestureController.swift @@ -10,6 +10,7 @@ import UIKit /// Since iOS 13, the way to add a properly functioning tap gesture recognizer on a `PDFView` /// significantly changed. This class handles the setup depending on the current iOS version. +@MainActor final class PDFTapGestureController: NSObject { private let pdfView: PDFView private let tapAction: TargetAction diff --git a/Sources/Navigator/Preferences/MappedPreference.swift b/Sources/Navigator/Preferences/MappedPreference.swift index 1046b6148c..02744d8d50 100644 --- a/Sources/Navigator/Preferences/MappedPreference.swift +++ b/Sources/Navigator/Preferences/MappedPreference.swift @@ -134,7 +134,7 @@ public extension RangePreference { } } -public class MappedPreference: Preference { +public class MappedPreference: Preference { let original: AnyPreference let from: (OldValue) -> NewValue let to: (NewValue) -> OldValue @@ -166,7 +166,7 @@ public class MappedPreference: Preference { } } -public final class PreferenceWithSupportedValues: MappedPreference, EnumPreference { +public final class PreferenceWithSupportedValues: MappedPreference, EnumPreference { public let supportedValues: [Value] init(original: AnyPreference, supportedValues: [Value]) { @@ -175,7 +175,7 @@ public final class PreferenceWithSupportedValues: MappedPrefere } } -public final class MappedEnumPreference: +public final class MappedEnumPreference: MappedPreference, EnumPreference { let originalEnum: AnyEnumPreference @@ -203,7 +203,7 @@ public final class MappedEnumPreference: } } -public final class MappedRangePreference: +public final class MappedRangePreference: MappedPreference, RangePreference { let originalRange: AnyRangePreference diff --git a/Sources/Navigator/Preferences/Preference.swift b/Sources/Navigator/Preferences/Preference.swift index bb8ab926d2..7821659a5e 100644 --- a/Sources/Navigator/Preferences/Preference.swift +++ b/Sources/Navigator/Preferences/Preference.swift @@ -9,7 +9,7 @@ import Foundation /// A handle to edit the value of a specific preference which is able to predict /// which value the `Configurable` will effectively use. public protocol Preference { - associatedtype Value + associatedtype Value: Sendable /// The current value of the preference. var value: Value? { get } diff --git a/Sources/Navigator/Preferences/PreferencesEditor.swift b/Sources/Navigator/Preferences/PreferencesEditor.swift index 407f7b890b..ec8b1388a3 100644 --- a/Sources/Navigator/Preferences/PreferencesEditor.swift +++ b/Sources/Navigator/Preferences/PreferencesEditor.swift @@ -54,7 +54,7 @@ public class StatefulPreferencesEditor( + func preference( preference prefKP: WritableKeyPath, setting settingKP: KeyPath, defaultEffectiveValue: Value, @@ -68,7 +68,7 @@ public class StatefulPreferencesEditor( + func preference( preference prefKP: WritableKeyPath, effectiveValue: @escaping (State) -> Value?, defaultEffectiveValue: Value, @@ -99,7 +99,7 @@ public class StatefulPreferencesEditor( + func preference( preference prefKP: WritableKeyPath, setting settingKP: KeyPath, isEffective: @escaping (State) -> Bool @@ -126,7 +126,7 @@ public class StatefulPreferencesEditor( + func enumPreference( preference prefKP: WritableKeyPath, setting settingKP: KeyPath, defaultEffectiveValue: Value, @@ -142,7 +142,7 @@ public class StatefulPreferencesEditor( + func enumPreference( preference prefKP: WritableKeyPath, effectiveValue: @escaping (State) -> Value?, defaultEffectiveValue: Value, @@ -175,7 +175,7 @@ public class StatefulPreferencesEditor( + func enumPreference( preference prefKP: WritableKeyPath, setting settingKP: KeyPath, isEffective: @escaping (State) -> Bool, @@ -204,7 +204,7 @@ public class StatefulPreferencesEditor( + func rangePreference( preference prefKP: WritableKeyPath, setting settingKP: KeyPath, defaultEffectiveValue: Value, @@ -224,7 +224,7 @@ public class StatefulPreferencesEditor( + func rangePreference( preference prefKP: WritableKeyPath, effectiveValue: @escaping (State) -> Value?, defaultEffectiveValue: Value, diff --git a/Sources/Navigator/Preferences/ProxyPreference.swift b/Sources/Navigator/Preferences/ProxyPreference.swift index cba59436f7..838264f01a 100644 --- a/Sources/Navigator/Preferences/ProxyPreference.swift +++ b/Sources/Navigator/Preferences/ProxyPreference.swift @@ -6,7 +6,7 @@ import Foundation -public class ProxyPreference: Preference { +public class ProxyPreference: Preference { private let _value: () -> Value? private let _effectiveValue: () -> Value private let _isEffective: () -> Bool @@ -41,7 +41,7 @@ public class ProxyPreference: Preference { } } -public final class ProxyEnumPreference: ProxyPreference, EnumPreference { +public final class ProxyEnumPreference: ProxyPreference, EnumPreference { public let supportedValues: [Value] init( @@ -61,7 +61,7 @@ public final class ProxyEnumPreference: ProxyPreference, } } -public final class ProxyRangePreference: ProxyPreference, RangePreference { +public final class ProxyRangePreference: ProxyPreference, RangePreference { public var supportedRange: ClosedRange private let progressionStrategy: AnyProgressionStrategy private let valueFormatter: (Value) -> String diff --git a/Sources/Navigator/TTS/AVTTSEngine.swift b/Sources/Navigator/TTS/AVTTSEngine.swift index ce9e34f0a1..28bbaeb964 100644 --- a/Sources/Navigator/TTS/AVTTSEngine.swift +++ b/Sources/Navigator/TTS/AVTTSEngine.swift @@ -15,7 +15,8 @@ public protocol AVTTSEngineDelegate: AnyObject, Sendable { } /// Implementation of a `TTSEngine` using Apple AVFoundation's `AVSpeechSynthesizer`. -public final class AVTTSEngine: NSObject, TTSEngine, AVSpeechSynthesizerDelegate, Loggable { +@MainActor +public final class AVTTSEngine: NSObject, TTSEngine, Loggable { /// Range of valid values for an AVUtterance rate. /// /// > The speech rate is a decimal representation within the range of `AVSpeechUtteranceMinimumSpeechRate` and @@ -75,7 +76,7 @@ public final class AVTTSEngine: NSObject, TTSEngine, AVSpeechSynthesizerDelegate _ utterance: TTSUtterance, onSpeakRange: @escaping (Range) -> Void ) async -> Result { - let task = Task( + let task = SpeechTask( utterance: utterance, onSpeakRange: onSpeakRange ) @@ -86,12 +87,15 @@ public final class AVTTSEngine: NSObject, TTSEngine, AVSpeechSynthesizerDelegate on(.play(task)) } } onCancel: { - task.cancel() - on(.stop(task)) + Task { @MainActor in + task.cancel() + on(.stop(task)) + } } } - private class Task: Equatable, CustomStringConvertible { + @MainActor + private class SpeechTask: Equatable, CustomStringConvertible { let utterance: TTSUtterance private let onSpeakRange: (Range) -> Void var continuation: CheckedContinuation, Never>! @@ -102,11 +106,11 @@ public final class AVTTSEngine: NSObject, TTSEngine, AVSpeechSynthesizerDelegate self.onSpeakRange = onSpeakRange } - var description: String { + nonisolated var description: String { utterance.text } - static func == (lhs: Task, rhs: Task) -> Bool { + nonisolated static func == (lhs: SpeechTask, rhs: SpeechTask) -> Bool { ObjectIdentifier(lhs) == ObjectIdentifier(rhs) } @@ -126,7 +130,7 @@ public final class AVTTSEngine: NSObject, TTSEngine, AVSpeechSynthesizerDelegate } } - private func taskUtterance(with task: Task) -> TaskUtterance { + private func taskUtterance(with task: SpeechTask) -> TaskUtterance { let utter = TaskUtterance(task: task) // utter.rate = rateMultiplierToAVRate(task.utterance.rateMultiplier) // utter.pitchMultiplier = Float(task.utterance.pitchMultiplier) @@ -137,9 +141,9 @@ public final class AVTTSEngine: NSObject, TTSEngine, AVSpeechSynthesizerDelegate } private class TaskUtterance: AVSpeechUtterance { - let task: Task + let task: SpeechTask - init(task: Task) { + init(task: SpeechTask) { self.task = task super.init(string: task.utterance.text) } @@ -150,41 +154,6 @@ public final class AVTTSEngine: NSObject, TTSEngine, AVSpeechSynthesizerDelegate } } - // MARK: AVSpeechSynthesizerDelegate - - public func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didStart utterance: AVSpeechUtterance) { - guard let task = (utterance as? TaskUtterance)?.task else { - return - } - on(.didStart(task)) - } - - public func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) { - guard let task = (utterance as? TaskUtterance)?.task else { - return - } - on(.didFinish(task)) - } - - public func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { - guard let task = (utterance as? TaskUtterance)?.task else { - return - } - on(.didFinish(task)) - } - - public func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, willSpeakRangeOfSpeechString characterRange: NSRange, utterance avUtterance: AVSpeechUtterance) { - guard - let task = (avUtterance as? TaskUtterance)?.task, - characterRange.upperBound <= task.utterance.text.count, - let range = Range(characterRange, in: task.utterance.text) - else { - return - } - - on(.willSpeakRange(range, task: task)) - } - // MARK: State machine // Submitting new utterances to `AVSpeechSynthesizer` when the `didStart` or @@ -222,25 +191,25 @@ public final class AVTTSEngine: NSObject, TTSEngine, AVSpeechSynthesizerDelegate /// The TTS engine is waiting for the next utterance to play. case stopped /// A new utterance is being processed by the TTS engine, we wait for didStart. - case starting(Task) + case starting(SpeechTask) /// The utterance is currently playing and the engine is ready to process other commands. - case playing(Task) + case playing(SpeechTask) /// The engine was stopped while processing the previous utterance, we wait for didStart /// and/or didFinish. The queued utterance will be played once the engine is successfully stopped. - case stopping(Task, queued: Task?) + case stopping(SpeechTask, queued: SpeechTask?) } /// State machine events triggered by the `AVSpeechSynthesizer` or the client /// of `AVTTSEngine`. private enum Event: Equatable { // AVTTSEngine commands - case play(Task) - case stop(Task) + case play(SpeechTask) + case stop(SpeechTask) // AVSpeechSynthesizer delegate events - case didStart(Task) - case willSpeakRange(Range, task: Task) - case didFinish(Task) + case didStart(SpeechTask) + case willSpeakRange(Range, task: SpeechTask) + case didFinish(SpeechTask) } private var state: State = .stopped { @@ -321,7 +290,7 @@ public final class AVTTSEngine: NSObject, TTSEngine, AVSpeechSynthesizerDelegate } } - private func startEngine(with task: Task) { + private func startEngine(with task: SpeechTask) { synthesizer.speak(taskUtterance(with: task)) } @@ -398,3 +367,38 @@ private extension AVSpeechSynthesisVoice { self.init(language: language.code.bcp47) } } + +extension AVTTSEngine: @preconcurrency AVSpeechSynthesizerDelegate { + public func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didStart utterance: AVSpeechUtterance) { + guard let task = (utterance as? TaskUtterance)?.task else { + return + } + on(.didStart(task)) + } + + public func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) { + guard let task = (utterance as? TaskUtterance)?.task else { + return + } + on(.didFinish(task)) + } + + public func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { + guard let task = (utterance as? TaskUtterance)?.task else { + return + } + on(.didFinish(task)) + } + + public func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, willSpeakRangeOfSpeechString characterRange: NSRange, utterance avUtterance: AVSpeechUtterance) { + guard + let task = (avUtterance as? TaskUtterance)?.task, + characterRange.upperBound <= task.utterance.text.count, + let range = Range(characterRange, in: task.utterance.text) + else { + return + } + + on(.willSpeakRange(range, task: task)) + } +} diff --git a/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift b/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift index 9469b7dd30..0d55840915 100644 --- a/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift +++ b/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift @@ -149,7 +149,7 @@ public final class PublicationSpeechSynthesizer: Loggable { } /// The default content tokenizer will split the `Content.Element` items into individual sentences. - public nonisolated static let defaultTokenizerFactory: TokenizerFactory = { defaultLanguage in + public static let defaultTokenizerFactory: TokenizerFactory = { defaultLanguage in makeTextContentTokenizer( defaultLanguage: defaultLanguage, contextSnippetLength: 50, diff --git a/Sources/Navigator/TTS/TTSEngine.swift b/Sources/Navigator/TTS/TTSEngine.swift index 857476a19e..af611e0e04 100644 --- a/Sources/Navigator/TTS/TTSEngine.swift +++ b/Sources/Navigator/TTS/TTSEngine.swift @@ -11,6 +11,7 @@ import ReadiumShared /// /// Implement this interface to support third-party engines with /// ``PublicationSpeechSynthesizer``. +@MainActor public protocol TTSEngine: AnyObject { /// List of available synthesizer voices. var availableVoices: [TTSVoice] { get } diff --git a/Sources/Navigator/Toolkit/CompletionList.swift b/Sources/Navigator/Toolkit/CompletionList.swift index a6a2bba587..ed81b84669 100644 --- a/Sources/Navigator/Toolkit/CompletionList.swift +++ b/Sources/Navigator/Toolkit/CompletionList.swift @@ -18,6 +18,7 @@ import Foundation /// ... /// } /// ``` +@MainActor final class CompletionList { private var blocks: [() -> Void] = [] @@ -37,11 +38,12 @@ final class CompletionList { /// Calls all the registered completion blocks. func complete() { - DispatchQueue.main.async { - for block in self.blocks { + Task { @MainActor in + let currentBlocks = self.blocks + self.blocks.removeAll() + for block in currentBlocks { block() } - self.blocks.removeAll() } } } diff --git a/Sources/Navigator/Toolkit/HTMLInjection.swift b/Sources/Navigator/Toolkit/HTMLInjection.swift index 1b91e21054..bbe7c0afff 100644 --- a/Sources/Navigator/Toolkit/HTMLInjection.swift +++ b/Sources/Navigator/Toolkit/HTMLInjection.swift @@ -221,14 +221,13 @@ private func escapeAttribute(_ value: String) -> String { value.replacingOccurrences(of: "\"", with: """) } -private let regexCache: Cache = Cache() +private let regexCache = Mutex<[String: NSRegularExpression]>([:]) private func regex(for pattern: String) -> NSRegularExpression { - let key = pattern as NSString - if let cached = regexCache[key] { + if let cached = regexCache.withLock({ $0[pattern] }) { return cached } let regex = NSRegularExpression(pattern, options: [.caseInsensitive]) - regexCache[key] = regex + regexCache.withLock { $0[pattern] = regex } return regex } diff --git a/Sources/Navigator/Toolkit/PaginationView.swift b/Sources/Navigator/Toolkit/PaginationView.swift index dc3afa97ce..ce3a850085 100644 --- a/Sources/Navigator/Toolkit/PaginationView.swift +++ b/Sources/Navigator/Toolkit/PaginationView.swift @@ -34,6 +34,7 @@ protocol PageView { func go(to location: PageLocation, animated: Bool) async } +@MainActor protocol PaginationViewDelegate: AnyObject { /// Creates the page view for the page at given index. func paginationView(_ paginationView: PaginationView, pageViewAtIndex index: Int) -> (UIView & PageView)? @@ -160,7 +161,7 @@ final class PaginationView: UIView, Loggable { super.didMoveToWindow() if window == nil { - loadPagesTask.cancel() + loadPagesTask?.cancel() } else { loadPages() } @@ -229,7 +230,8 @@ final class PaginationView: UIView, Loggable { } private func loadPages() { - loadPagesTask.replace { @MainActor in + loadPagesTask?.cancel() + loadPagesTask = Task { @MainActor in await loadNextPage() delegate?.paginationViewDidUpdateViews(self) } diff --git a/Sources/Navigator/Toolkit/TargetAction.swift b/Sources/Navigator/Toolkit/TargetAction.swift index 8195efc7d5..c813a4bf5b 100644 --- a/Sources/Navigator/Toolkit/TargetAction.swift +++ b/Sources/Navigator/Toolkit/TargetAction.swift @@ -8,6 +8,7 @@ import Foundation import UIKit /// Represents a couple (`target`, `action`) which can be invoked from a `sender`. +@MainActor final class TargetAction { private weak var target: AnyObject? private let action: Selector diff --git a/Sources/Shared/Toolkit/Cache.swift b/Sources/Shared/Toolkit/Cache.swift deleted file mode 100644 index 49456bd435..0000000000 --- a/Sources/Shared/Toolkit/Cache.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// Copyright 2026 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 - -/// A modern Swift wrapper around `NSCache`, with `Sendable` capabilities. -package final class Cache { - let cache: NSCache - - package init() { - cache = NSCache() - } - - /// Clears out the cache. - package func clear() { - cache.removeAllObjects() - } - - package subscript(key: Key) -> Value? { - get { cache.object(forKey: key) } - set { - if let newValue { - cache.setObject(newValue, forKey: key) - } else { - cache.removeObject(forKey: key) - } - } - } -} - -/// `NSCache` is naturally thread-safe, but could be used to transfer non- -/// sendable values across isolation domains. So we mark it as `Sendable` only -/// if its types are `Sendable` themselves. -extension Cache: @unchecked Sendable where Key: Sendable, Value: Sendable {} diff --git a/Sources/Shared/Toolkit/Media/NowPlayingInfo.swift b/Sources/Shared/Toolkit/Media/NowPlayingInfo.swift index 7ddc60e9a8..4e36a52e03 100644 --- a/Sources/Shared/Toolkit/Media/NowPlayingInfo.swift +++ b/Sources/Shared/Toolkit/Media/NowPlayingInfo.swift @@ -66,7 +66,7 @@ public final class NowPlayingInfo { return } mpArtwork = media?.artwork.map { image in - MPMediaItemArtwork(boundsSize: image.size, requestHandler: { _ in image }) + Self.makeArtwork(image: image) } playback.clear() update() @@ -92,6 +92,10 @@ public final class NowPlayingInfo { MPNowPlayingInfoCenter.default().nowPlayingInfo = nil } + private nonisolated static func makeArtwork(image: UIImage) -> MPMediaItemArtwork { + MPMediaItemArtwork(boundsSize: image.size, requestHandler: { _ in image }) + } + private var mpArtwork: MPMediaItemArtwork? /// Updates the Now Playing screen, maximum once per second. From 06d0a138a3fd9a6f1f264d8754dbf07711f02559 Mon Sep 17 00:00:00 2001 From: Grigor Hakobyan Date: Fri, 3 Jul 2026 18:14:43 +0400 Subject: [PATCH 25/39] Remove the `sender` parameter from `PublicationOpener` and LCP APIs (#833) --- CHANGELOG.md | 1 + .../Authentications/LCPAuthenticating.swift | 16 ++++- .../LCPDialogAuthentication.swift | 58 ++++++++++++------- .../LCPDialogViewController.swift | 24 +++++++- .../LCPObservableAuthentication.swift | 18 ++---- .../LCPPassphraseAuthentication.swift | 4 +- .../LCPContentProtection.swift | 27 +++------ Sources/LCP/LCPService.swift | 31 +++++----- Sources/LCP/License/LicenseValidation.swift | 6 +- Sources/LCP/Services/LicensesService.swift | 10 +--- Sources/LCP/Services/PassphrasesService.swift | 24 +++----- .../Protection/ContentProtection.swift | 13 ++++- .../FallbackContentProtection.swift | 3 +- .../Shared/Toolkit/UncheckedSendable.swift | 18 ------ Sources/Streamer/PublicationOpener.swift | 20 +++++-- TestApp/Sources/App/AppModule.swift | 3 +- TestApp/Sources/App/Readium.swift | 19 +++++- TestApp/Sources/AppDelegate.swift | 2 +- .../Toolkit/Extensions/UIViewController.swift | 16 +++++ TestApp/Sources/Library/LibraryModule.swift | 4 +- TestApp/Sources/Library/LibraryService.swift | 17 +++--- .../Library/LibraryViewController.swift | 8 +-- TestApp/Sources/OPDS/OPDSModule.swift | 1 - .../OPDSPublicationInfoViewController.swift | 2 +- .../UITests/NavigatorTestHost/Container.swift | 3 +- docs/Guides/Getting Started.md | 2 +- docs/Guides/Open Publication.md | 7 ++- docs/Guides/Readium LCP.md | 35 +++++++---- docs/Migration Guide.md | 41 ++++++++++++- 29 files changed, 263 insertions(+), 170 deletions(-) delete mode 100644 Sources/Shared/Toolkit/UncheckedSendable.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 61e55c51f9..7f1ab396c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ All notable changes to this project will be documented in this file. Take a look #### LCP * `LCPService.init` now requires an explicit `deviceName` parameter. We recommend passing `UIDevice.current.name`. See [the migration guide](docs/Migration%20Guide.md). +* `LCPDialogAuthentication` no longer takes a `sender` view controller. It now presents its passphrase dialog through a new `LCPDialogAuthenticationDelegate` that you implement and retain for the lifetime of the authentication. See [the Readium LCP guide](docs/Guides/Readium%20LCP.md) and [the migration guide](docs/Migration%20Guide.md). diff --git a/Sources/LCP/Authentications/LCPAuthenticating.swift b/Sources/LCP/Authentications/LCPAuthenticating.swift index 2401167b3f..01c2d8a0cc 100644 --- a/Sources/LCP/Authentications/LCPAuthenticating.swift +++ b/Sources/LCP/Authentications/LCPAuthenticating.swift @@ -20,15 +20,25 @@ public protocol LCPAuthenticating: Sendable { /// - allowUserInteraction: Indicates whether the user can be prompted for their passphrase. /// If your implementation requires it and `allowUserInteraction` is false, terminate /// quickly by returning `nil`. - /// - sender: Free object that can be used by reading apps to give some UX context when - /// presenting dialogs. For example, the host `UIViewController`. + @MainActor + func retrievePassphrase( + for license: LCPAuthenticatedLicense, + reason: LCPAuthenticationReason, + allowUserInteraction: Bool + ) async -> String? +} + +public extension LCPAuthenticating { + @available(*, unavailable, message: "The `sender` parameter has been removed. Present any UI from your `LCPDialogAuthenticationDelegate` implementation and use the variant without `sender`.") @MainActor func retrievePassphrase( for license: LCPAuthenticatedLicense, reason: LCPAuthenticationReason, allowUserInteraction: Bool, sender: Any? - ) async -> String? + ) async -> String? { + fatalError() + } } public enum LCPAuthenticationReason: Sendable { diff --git a/Sources/LCP/Authentications/LCPDialogAuthentication.swift b/Sources/LCP/Authentications/LCPDialogAuthentication.swift index fb9e8d01cb..505f319b95 100644 --- a/Sources/LCP/Authentications/LCPDialogAuthentication.swift +++ b/Sources/LCP/Authentications/LCPDialogAuthentication.swift @@ -8,32 +8,42 @@ import Foundation import ReadiumShared import UIKit +/// Delegate presenting the passphrase dialog produced by +/// `LCPDialogAuthentication`. +@MainActor public protocol LCPDialogAuthenticationDelegate: AnyObject, Sendable { + /// Presents the LCP passphrase dialog view controller. + /// + /// The dialog dismisses itself once the user submits or cancels, so you + /// only need to present it. + func lcpDialogAuthentication( + _ authentication: LCPDialogAuthentication, + present dialogViewController: UIViewController + ) +} + /// An `LCPAuthenticating` implementation presenting a dialog to the user. /// -/// For this authentication to trigger, you must provide a `sender` parameter of type -/// `UIViewController` to `Streamer.open()` or `LCPService.retrieveLicense()`. It will be used -/// as the presenting view controller for the dialog. -public final class LCPDialogAuthentication: LCPAuthenticating, Loggable, Sendable { - private let animated: Bool - private let modalPresentationStyle: UIModalPresentationStyle - private let modalTransitionStyle: UIModalTransitionStyle +/// For this authentication to trigger, you must provide a ``delegate`` that +/// presents the dialog (for example on your top-most view controller). +@MainActor +public final class LCPDialogAuthentication: LCPAuthenticating, Loggable { + /// Delegate responsible for presenting the passphrase dialog. + private weak var delegate: LCPDialogAuthenticationDelegate? - public init(animated: Bool = true, modalPresentationStyle: UIModalPresentationStyle = .formSheet, modalTransitionStyle: UIModalTransitionStyle = .coverVertical) { - self.animated = animated - self.modalPresentationStyle = modalPresentationStyle - self.modalTransitionStyle = modalTransitionStyle + public init(delegate: LCPDialogAuthenticationDelegate) { + self.delegate = delegate } public func retrievePassphrase( for license: LCPAuthenticatedLicense, reason: LCPAuthenticationReason, - allowUserInteraction: Bool, - sender: Any? + allowUserInteraction: Bool ) async -> String? { - guard allowUserInteraction, let viewController = sender as? UIViewController else { - if !(sender is UIViewController) { - log(.error, "Tried to present the LCP dialog without providing a `UIViewController` as `sender`") - } + guard allowUserInteraction else { + return nil + } + guard let delegate = delegate else { + log(.error, "The `LCPDialogAuthentication` delegate was deallocated before it could present the passphrase dialog. Make sure you retain it for the lifetime of the authentication.") return nil } @@ -41,11 +51,15 @@ public final class LCPDialogAuthentication: LCPAuthenticating, Loggable, Sendabl let dialogViewController = LCPDialogViewController(license: license, reason: reason) { passphrase in continuation.resume(returning: passphrase) } - - dialogViewController.modalPresentationStyle = modalPresentationStyle - dialogViewController.modalTransitionStyle = modalTransitionStyle - - viewController.present(dialogViewController, animated: animated) + delegate.lcpDialogAuthentication(self, present: dialogViewController) } } + + @available(*, unavailable, message: "Set the modal presentation and transition styles from your LCPDialogAuthenticationDelegate implementation") + public convenience init( + modalPresentationStyle: UIModalPresentationStyle = .formSheet, + modalTransitionStyle: UIModalTransitionStyle = .coverVertical + ) { + fatalError() + } } diff --git a/Sources/LCP/Authentications/LCPDialogViewController.swift b/Sources/LCP/Authentications/LCPDialogViewController.swift index ee32b59ef3..e5c740629f 100644 --- a/Sources/LCP/Authentications/LCPDialogViewController.swift +++ b/Sources/LCP/Authentications/LCPDialogViewController.swift @@ -63,7 +63,27 @@ final class LCPDialogViewController: UIViewController { return } isCompleted = true - completion(passphrase) - dismiss(animated: true) + + guard presentingViewController != nil else { + completion(passphrase) + return + } + + // The completion must be called only after the dialog is fully + // dismissed. Otherwise, when retrying an invalid passphrase, the next + // dialog might be presented while this one is still animating its + // dismissal, which fails and leaks the caller's continuation. + dismiss(animated: true) { + self.completion(passphrase) + } + } + + isolated deinit { + // Safety net: if the dialog is deallocated without being submitted or + // cancelled (e.g. the delegate failed to present it), we still need + // to call the completion to avoid leaking the caller's continuation. + if !isCompleted { + completion(nil) + } } } diff --git a/Sources/LCP/Authentications/LCPObservableAuthentication.swift b/Sources/LCP/Authentications/LCPObservableAuthentication.swift index 7472ba7833..70cbc8be64 100644 --- a/Sources/LCP/Authentications/LCPObservableAuthentication.swift +++ b/Sources/LCP/Authentications/LCPObservableAuthentication.swift @@ -24,26 +24,20 @@ public final class LCPObservableAuthentication: LCPAuthenticating, ObservableObj /// Reason for this authentication request. public let reason: LCPAuthenticationReason - /// Sender given to the component requesting the authentication. - /// - /// For example, this is the `sender` you provided to the - /// `PublicationOpener.open()` API. - /// - /// Readium does not use this internally. You can pass any object to - /// help you determine how to present the LCP authentication dialog. - public let sender: Any? + @available(*, unavailable, message: "The `sender` parameter has been removed. Present any UI from your `LCPDialogAuthenticationDelegate` implementation instead.") + public var sender: Any? { + fatalError() + } private var continuation: CheckedContinuation? init( license: LCPAuthenticatedLicense, reason: LCPAuthenticationReason, - sender: Any?, continuation: CheckedContinuation ) { self.license = license self.reason = reason - self.sender = sender self.continuation = continuation } @@ -75,8 +69,7 @@ public final class LCPObservableAuthentication: LCPAuthenticating, ObservableObj public func retrievePassphrase( for license: LCPAuthenticatedLicense, reason: LCPAuthenticationReason, - allowUserInteraction: Bool, - sender: Any? + allowUserInteraction: Bool ) async -> String? { guard allowUserInteraction else { return nil @@ -88,7 +81,6 @@ public final class LCPObservableAuthentication: LCPAuthenticating, ObservableObj self.request = Request( license: license, reason: reason, - sender: sender, continuation: $0 ) } diff --git a/Sources/LCP/Authentications/LCPPassphraseAuthentication.swift b/Sources/LCP/Authentications/LCPPassphraseAuthentication.swift index 3f678def10..9fc3bfbf13 100644 --- a/Sources/LCP/Authentications/LCPPassphraseAuthentication.swift +++ b/Sources/LCP/Authentications/LCPPassphraseAuthentication.swift @@ -19,10 +19,10 @@ public final class LCPPassphraseAuthentication: LCPAuthenticating, Sendable { self.fallback = fallback } - public func retrievePassphrase(for license: LCPAuthenticatedLicense, reason: LCPAuthenticationReason, allowUserInteraction: Bool, sender: Any?) async -> String? { + public func retrievePassphrase(for license: LCPAuthenticatedLicense, reason: LCPAuthenticationReason, allowUserInteraction: Bool) async -> String? { guard reason == .passphraseNotFound else { if let fallback = fallback { - return await fallback.retrievePassphrase(for: license, reason: reason, allowUserInteraction: allowUserInteraction, sender: sender) + return await fallback.retrievePassphrase(for: license, reason: reason, allowUserInteraction: allowUserInteraction) } else { return nil } diff --git a/Sources/LCP/Content Protection/LCPContentProtection.swift b/Sources/LCP/Content Protection/LCPContentProtection.swift index 4a36e88678..f2193bb6d1 100644 --- a/Sources/LCP/Content Protection/LCPContentProtection.swift +++ b/Sources/LCP/Content Protection/LCPContentProtection.swift @@ -21,24 +21,21 @@ final class LCPContentProtection: ContentProtection, Loggable { func open( asset: Asset, credentials: String?, - allowUserInteraction: Bool, - sender: Any? + allowUserInteraction: Bool ) async -> Result { switch asset { case let .resource(resource): return await openLicense( using: resource, credentials: credentials, - allowUserInteraction: allowUserInteraction, - sender: sender + allowUserInteraction: allowUserInteraction ) case let .container(container): return await openPublication( in: container, credentials: credentials, - allowUserInteraction: allowUserInteraction, - sender: sender + allowUserInteraction: allowUserInteraction ) } } @@ -46,8 +43,7 @@ final class LCPContentProtection: ContentProtection, Loggable { func openLicense( using asset: ResourceAsset, credentials: String?, - allowUserInteraction: Bool, - sender: Any? + allowUserInteraction: Bool ) async -> Result { guard asset.format.conformsTo(.lcpLicense) else { return .failure(.assetNotSupported(DebugError("The asset does not appear to be an LCP License"))) @@ -82,8 +78,7 @@ final class LCPContentProtection: ContentProtection, Loggable { let licenseResult = await retrieveLicense( in: .resource(asset), credentials: credentials, - allowUserInteraction: allowUserInteraction, - sender: sender + allowUserInteraction: allowUserInteraction ) return await makeLCPAsset(from: container, license: licenseResult) @@ -92,8 +87,7 @@ final class LCPContentProtection: ContentProtection, Loggable { func openPublication( in asset: ContainerAsset, credentials: String?, - allowUserInteraction: Bool, - sender: Any? + allowUserInteraction: Bool ) async -> Result { guard asset.format.conformsTo(.lcp) else { return .failure(.assetNotSupported(DebugError("The asset does not appear to be protected with LCP"))) @@ -109,8 +103,7 @@ final class LCPContentProtection: ContentProtection, Loggable { license: retrieveLicense( in: .container(asset), credentials: credentials, - allowUserInteraction: allowUserInteraction, - sender: sender + allowUserInteraction: allowUserInteraction ) ) } @@ -118,8 +111,7 @@ final class LCPContentProtection: ContentProtection, Loggable { private func retrieveLicense( in asset: Asset, credentials: String?, - allowUserInteraction: Bool, - sender: Any? + allowUserInteraction: Bool ) async -> Result { let authentication = credentials.map { LCPPassphraseAuthentication($0, fallback: self.authentication) } ?? self.authentication @@ -127,8 +119,7 @@ final class LCPContentProtection: ContentProtection, Loggable { return await service.retrieveLicense( from: asset, authentication: authentication, - allowUserInteraction: allowUserInteraction, - sender: sender + allowUserInteraction: allowUserInteraction ) } diff --git a/Sources/LCP/LCPService.swift b/Sources/LCP/LCPService.swift index a08c964fba..6e80ebded6 100644 --- a/Sources/LCP/LCPService.swift +++ b/Sources/LCP/LCPService.swift @@ -9,13 +9,12 @@ import ReadiumShared /// Service used to acquire and open publications protected with LCP. /// -/// If an `LCPAuthenticating` instance is not given when expected, the request is cancelled if no -/// passphrase is found in the local database. This can be the desired behavior when trying to -/// import a license in the background, without prompting the user for its passphrase. -/// -/// You can freely use the `sender` parameter to give some UI context which will be forwarded to -/// your instance of `LCPAuthenticating`. This can be useful to provide the host `UIViewController` -/// when presenting a dialog, for example. +/// When a passphrase is not already stored in the `passphraseRepository`, it +/// is requested from the provided `LCPAuthenticating` instance. If +/// `allowUserInteraction` is false then the `authentication` implementation +/// will not present any dialog to the user. This can be the desired behavior +/// when trying to import a license in the background, without prompting the +/// user for their passphrase. public final class LCPService: Loggable { private let licenses: LicensesService private let passphrases: PassphrasesService @@ -134,24 +133,30 @@ public final class LCPService: Loggable { /// `authentication`. /// - allowUserInteraction: Indicates whether the user can be prompted /// for their passphrase. - /// - sender: Free object that can be used by reading apps to give some - /// UX context when presenting dialogs with ``LCPAuthenticating``. public func retrieveLicense( from asset: Asset, authentication: LCPAuthenticating, - allowUserInteraction: Bool, - sender: Any? + allowUserInteraction: Bool ) async -> Result { await wrap { try await licenses.retrieve( from: asset, authentication: authentication, - allowUserInteraction: allowUserInteraction, - sender: sender + allowUserInteraction: allowUserInteraction ) } } + @available(*, unavailable, message: "The `sender` parameter has been removed. Present any UI from your `LCPDialogAuthenticationDelegate` implementation and use the variant without `sender`.") + public func retrieveLicense( + from asset: Asset, + authentication: LCPAuthenticating, + allowUserInteraction: Bool, + sender: Any? + ) async -> Result { + fatalError() + } + /// Creates a `ContentProtection` instance which can be used with a `Streamer` to unlock /// LCP protected publications. /// diff --git a/Sources/LCP/License/LicenseValidation.swift b/Sources/LCP/License/LicenseValidation.swift index 1c3d304dc6..6937bd4e1d 100644 --- a/Sources/LCP/License/LicenseValidation.swift +++ b/Sources/LCP/License/LicenseValidation.swift @@ -31,7 +31,6 @@ actor LicenseValidation: Loggable { fileprivate let client: LCPClient fileprivate let authentication: LCPAuthenticating? fileprivate let allowUserInteraction: Bool - fileprivate let sender: UncheckedSendable? fileprivate let crl: CRLService fileprivate let device: DeviceService fileprivate let httpClient: HTTPClient @@ -52,7 +51,6 @@ actor LicenseValidation: Loggable { init( authentication: LCPAuthenticating?, allowUserInteraction: Bool, - sender: UncheckedSendable?, client: LCPClient, crl: CRLService, device: DeviceService, @@ -62,7 +60,6 @@ actor LicenseValidation: Loggable { ) { self.authentication = authentication self.allowUserInteraction = allowUserInteraction - self.sender = sender self.client = client self.crl = crl self.device = device @@ -345,8 +342,7 @@ extension LicenseValidation { if let passphrase = try await passphrases.request( for: license, authentication: authentication, - allowUserInteraction: allowUserInteraction, - sender: sender + allowUserInteraction: allowUserInteraction ) { try await raise(.retrievedPassphrase(passphrase)) } else { diff --git a/Sources/LCP/Services/LicensesService.swift b/Sources/LCP/Services/LicensesService.swift index 4d8b06cba8..c48aab7817 100644 --- a/Sources/LCP/Services/LicensesService.swift +++ b/Sources/LCP/Services/LicensesService.swift @@ -43,22 +43,19 @@ final class LicensesService: Loggable { func retrieve( from asset: Asset, authentication: LCPAuthenticating?, - allowUserInteraction: Bool, - sender: Any? + allowUserInteraction: Bool ) async throws -> LCPLicense { try await retrieve( from: makeLicenseContainer(for: asset), authentication: authentication, - allowUserInteraction: allowUserInteraction, - sender: sender + allowUserInteraction: allowUserInteraction ) } private func retrieve( from container: LicenseContainer, authentication: LCPAuthenticating?, - allowUserInteraction: Bool, - sender: Any? + allowUserInteraction: Bool ) async throws -> License { let initialData = try await container.read() @@ -84,7 +81,6 @@ final class LicensesService: Loggable { let validation = LicenseValidation( authentication: authentication, allowUserInteraction: allowUserInteraction, - sender: sender.map { UncheckedSendable($0) }, client: client, crl: crl, device: device, diff --git a/Sources/LCP/Services/PassphrasesService.swift b/Sources/LCP/Services/PassphrasesService.swift index 6483c6eb93..a4027bc869 100644 --- a/Sources/LCP/Services/PassphrasesService.swift +++ b/Sources/LCP/Services/PassphrasesService.swift @@ -59,8 +59,7 @@ final class PassphrasesService: Loggable, Sendable { func request( for license: LicenseDocument, authentication: LCPAuthenticating?, - allowUserInteraction: Bool, - sender: UncheckedSendable? + allowUserInteraction: Bool ) async throws -> LCPPassphraseHash? { // Look for a stored passphrase matching this license. // @@ -74,8 +73,7 @@ final class PassphrasesService: Loggable, Sendable { for: license, reason: .passphraseNotFound, using: authentication, - allowUserInteraction: allowUserInteraction, - sender: sender + allowUserInteraction: allowUserInteraction ) } @@ -129,16 +127,14 @@ final class PassphrasesService: Loggable, Sendable { for license: LicenseDocument, reason: LCPAuthenticationReason, using authentication: LCPAuthenticating, - allowUserInteraction: Bool, - sender: UncheckedSendable? + allowUserInteraction: Bool ) async throws -> LCPPassphraseHash? { let authenticatedLicense = LCPAuthenticatedLicense(document: license) guard let clearPassphrase = await retrievePassphrase( using: authentication, for: authenticatedLicense, reason: reason, - allowUserInteraction: allowUserInteraction, - sender: sender + allowUserInteraction: allowUserInteraction ) else { return nil } @@ -163,8 +159,7 @@ final class PassphrasesService: Loggable, Sendable { for: license, reason: .invalidPassphrase, using: authentication, - allowUserInteraction: allowUserInteraction, - sender: sender + allowUserInteraction: allowUserInteraction ) } @@ -172,22 +167,17 @@ final class PassphrasesService: Loggable, Sendable { } /// Prompts the user for a passphrase on the main actor. - /// - /// The non-`Sendable` `sender` is unwrapped here, inside the main actor, so - /// it never crosses an actor boundary. @MainActor private func retrievePassphrase( using authentication: LCPAuthenticating, for license: LCPAuthenticatedLicense, reason: LCPAuthenticationReason, - allowUserInteraction: Bool, - sender: UncheckedSendable? + allowUserInteraction: Bool ) async -> String? { await authentication.retrievePassphrase( for: license, reason: reason, - allowUserInteraction: allowUserInteraction, - sender: sender?.value + allowUserInteraction: allowUserInteraction ) } } diff --git a/Sources/Shared/Publication/Protection/ContentProtection.swift b/Sources/Shared/Publication/Protection/ContentProtection.swift index 53620b1b46..1236064895 100644 --- a/Sources/Shared/Publication/Protection/ContentProtection.swift +++ b/Sources/Shared/Publication/Protection/ContentProtection.swift @@ -17,12 +17,23 @@ public protocol ContentProtection { /// - Returns: An ``Asset`` in case of success or an /// ``ContentProtectionOpenError`` if the asset can't be successfully /// opened even in restricted mode. + func open( + asset: Asset, + credentials: String?, + allowUserInteraction: Bool + ) async -> Result +} + +public extension ContentProtection { + @available(*, unavailable, message: "The `sender` parameter has been removed. Use the variant without `sender`.") func open( asset: Asset, credentials: String?, allowUserInteraction: Bool, sender: Any? - ) async -> Result + ) async -> Result { + fatalError() + } } public enum ContentProtectionOpenError: Error, Sendable { diff --git a/Sources/Shared/Publication/Protection/FallbackContentProtection.swift b/Sources/Shared/Publication/Protection/FallbackContentProtection.swift index 169f9b4dce..694ba4b04a 100644 --- a/Sources/Shared/Publication/Protection/FallbackContentProtection.swift +++ b/Sources/Shared/Publication/Protection/FallbackContentProtection.swift @@ -14,8 +14,7 @@ public final class _FallbackContentProtection: ContentProtection, Sendable { public func open( asset: Asset, credentials: String?, - allowUserInteraction: Bool, - sender: Any? + allowUserInteraction: Bool ) async -> Result { guard case .container = asset else { return .failure(.assetNotSupported(nil)) diff --git a/Sources/Shared/Toolkit/UncheckedSendable.swift b/Sources/Shared/Toolkit/UncheckedSendable.swift deleted file mode 100644 index 34534a8bc6..0000000000 --- a/Sources/Shared/Toolkit/UncheckedSendable.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// Copyright 2026 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 - -/// A wrapper to force a value to be `Sendable`. -/// -/// **Warning**: Use this wrapper only if you are sure that the value is thread-safe. -package struct UncheckedSendable: @unchecked Sendable { - package let value: T - - package init(_ value: T) { - self.value = value - } -} diff --git a/Sources/Streamer/PublicationOpener.swift b/Sources/Streamer/PublicationOpener.swift index b8b293a9ac..aeeb0637dd 100644 --- a/Sources/Streamer/PublicationOpener.swift +++ b/Sources/Streamer/PublicationOpener.swift @@ -52,15 +52,12 @@ public final class PublicationOpener { /// Publication Builder. It can be used to modify the manifest, the root /// container or the list of service factories of the `Publication`. /// - warnings: Logger used to broadcast non-fatal parsing warnings. - /// - sender: Free object that can be used by reading apps to give some - /// UX context when presenting dialogs. public func open( asset: Asset, allowUserInteraction: Bool, credentials: String? = nil, onCreatePublication: @escaping Publication.Builder.Transform = { _, _, _ in }, - warnings: WarningLogger? = nil, - sender: Any? = nil + warnings: WarningLogger? = nil ) async -> Result { var asset = asset var builderTransforms: [Publication.Builder.Transform] = [ @@ -72,8 +69,7 @@ public final class PublicationOpener { switch await protection.open( asset: asset, credentials: credentials, - allowUserInteraction: allowUserInteraction, - sender: sender + allowUserInteraction: allowUserInteraction ) { case let .success(contentProtectionAsset): asset = contentProtectionAsset.asset @@ -106,6 +102,18 @@ public final class PublicationOpener { } } } + + @available(*, unavailable, message: "The `sender` parameter has been removed. Use the variant without `sender`.") + public func open( + asset: Asset, + allowUserInteraction: Bool, + credentials: String? = nil, + onCreatePublication: @escaping Publication.Builder.Transform = { _, _, _ in }, + warnings: WarningLogger? = nil, + sender: Any? + ) async -> Result { + fatalError() + } } public enum PublicationOpenError: Error, Sendable { diff --git a/TestApp/Sources/App/AppModule.swift b/TestApp/Sources/App/AppModule.swift index 3a01863c47..b3cf0492ea 100644 --- a/TestApp/Sources/App/AppModule.swift +++ b/TestApp/Sources/App/AppModule.swift @@ -100,7 +100,6 @@ extension AppModule: OPDSModuleDelegate { func opdsDownloadPublication( _ publication: Publication?, at link: ReadiumShared.Link, - sender: UIViewController, progress: @escaping @Sendable (Double) -> Void ) async throws -> Book { guard let url = link.url(relativeTo: publication?.baseURL).httpURL else { @@ -108,6 +107,6 @@ extension AppModule: OPDSModuleDelegate { } let fileURL = try await readium.httpClient.download(url, onProgress: progress).get().location - return try await library.importPublication(from: fileURL, sender: sender, progress: progress) + return try await library.importPublication(from: fileURL, progress: progress) } } diff --git a/TestApp/Sources/App/Readium.swift b/TestApp/Sources/App/Readium.swift index 889c16a73b..8b7843a6a5 100644 --- a/TestApp/Sources/App/Readium.swift +++ b/TestApp/Sources/App/Readium.swift @@ -8,6 +8,7 @@ import Foundation import ReadiumNavigator import ReadiumShared import ReadiumStreamer +import UIKit #if LCP import R2LCPClient @@ -49,7 +50,23 @@ import ReadiumStreamer httpClient: httpClient ) - lazy var lcpAuthentication: LCPAuthenticating = LCPDialogAuthentication() + lazy var lcpAuthentication: LCPAuthenticating = LCPDialogAuthentication(delegate: lcpDialogPresenter) + + /// The dialog authentication holds its delegate weakly, so we retain + /// the presenter for the lifetime of the application. + private let lcpDialogPresenter = LCPDialogPresenter() + + /// Presents the LCP passphrase dialog on the app's top-most view + /// controller, replacing the former `sender` parameter. + @MainActor + private final class LCPDialogPresenter: LCPDialogAuthenticationDelegate { + func lcpDialogAuthentication( + _ authentication: LCPDialogAuthentication, + present dialogViewController: UIViewController + ) { + UIViewController.topMost?.present(dialogViewController, animated: true) + } + } /// Facade to the private R2LCPClient.framework. final class LCPClient: ReadiumLCP.LCPClient { diff --git a/TestApp/Sources/AppDelegate.swift b/TestApp/Sources/AppDelegate.swift index 8c3888cbc5..03ac3459d2 100644 --- a/TestApp/Sources/AppDelegate.swift +++ b/TestApp/Sources/AppDelegate.swift @@ -59,7 +59,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { Task { do { - try await app.library.importPublication(from: url, sender: vc, progress: { _ in }) + try await app.library.importPublication(from: url, progress: { _ in }) } catch { guard let error = error as? UserErrorConvertible else { print(error) diff --git a/TestApp/Sources/Common/Toolkit/Extensions/UIViewController.swift b/TestApp/Sources/Common/Toolkit/Extensions/UIViewController.swift index 9c19a0bb4a..797f8ac902 100644 --- a/TestApp/Sources/Common/Toolkit/Extensions/UIViewController.swift +++ b/TestApp/Sources/Common/Toolkit/Extensions/UIViewController.swift @@ -8,6 +8,22 @@ import Foundation import UIKit extension UIViewController { + /// The top-most presented view controller in the app's key window, if any. + static var topMost: UIViewController? { + let keyWindow = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .first { $0.isKeyWindow } + + guard var top = keyWindow?.rootViewController else { + return nil + } + while let presented = top.presentedViewController { + top = presented + } + return top + } + /// Finds the first child view controller with the given type, recursively. func findChildViewController() -> T? { for childViewController in children { diff --git a/TestApp/Sources/Library/LibraryModule.swift b/TestApp/Sources/Library/LibraryModule.swift index d933dc0dc7..8d64fd2585 100644 --- a/TestApp/Sources/Library/LibraryModule.swift +++ b/TestApp/Sources/Library/LibraryModule.swift @@ -24,7 +24,6 @@ import UIKit @discardableResult func importPublication( from url: AbsoluteURL, - sender: UIViewController, progress: @escaping (Double) -> Void ) async throws -> Book } @@ -67,9 +66,8 @@ final class LibraryModule: LibraryModuleAPI { func importPublication( from url: AbsoluteURL, - sender: UIViewController, progress: @escaping (Double) -> Void ) async throws -> Book { - try await library.importPublication(from: url, sender: sender, progress: progress) + try await library.importPublication(from: url, progress: progress) } } diff --git a/TestApp/Sources/Library/LibraryService.swift b/TestApp/Sources/Library/LibraryService.swift index 4e45e02a51..5754a8d9b0 100644 --- a/TestApp/Sources/Library/LibraryService.swift +++ b/TestApp/Sources/Library/LibraryService.swift @@ -33,8 +33,8 @@ import UIKit // MARK: Opening /// Opens the Readium 2 Publication for the given `book`. - func openBook(_ book: Book, sender: UIViewController) async throws -> Publication? { - let (pub, _) = try await openPublication(at: book.absoluteURL(), allowUserInteraction: true, sender: sender) + func openBook(_ book: Book) async throws -> Publication? { + let (pub, _) = try await openPublication(at: book.absoluteURL(), allowUserInteraction: true) guard try checkIsReadable(publication: pub) else { return nil } @@ -44,16 +44,14 @@ import UIKit /// Opens the Readium 2 Publication at the given `url`. private func openPublication( at url: AbsoluteURL, - allowUserInteraction: Bool, - sender: UIViewController? + allowUserInteraction: Bool ) async throws -> (Publication, Format) { do { let asset = try await readium.assetRetriever.retrieve(url: url).get() let publication = try await readium.publicationOpener.open( asset: asset, - allowUserInteraction: allowUserInteraction, - sender: sender + allowUserInteraction: allowUserInteraction ).get() return (publication, asset.format) @@ -79,12 +77,12 @@ import UIKit // MARK: Importation /// Imports a bunch of publications. - func importPublications(from sourceURLs: [URL], sender: UIViewController) async throws { + func importPublications(from sourceURLs: [URL]) async throws { for url in sourceURLs { guard let url = url.anyURL.absoluteURL else { continue } - try await importPublication(from: url, sender: sender, progress: { _ in }) + try await importPublication(from: url, progress: { _ in }) } } @@ -99,7 +97,6 @@ import UIKit @discardableResult func importPublication( from url: AbsoluteURL, - sender: UIViewController, progress: @escaping (Double) -> Void ) async throws -> Book { // Necessary to read URL exported from the Files app, for example. @@ -115,7 +112,7 @@ import UIKit url = try await fulfillIfNeeded(file, progress: progress) } - let (pub, format) = try await openPublication(at: url, allowUserInteraction: false, sender: sender) + let (pub, format) = try await openPublication(at: url, allowUserInteraction: false) let title = pub.metadata.title ?? url.url.deletingPathExtension().lastPathComponent let coverPath = try await importCover(of: pub) diff --git a/TestApp/Sources/Library/LibraryViewController.swift b/TestApp/Sources/Library/LibraryViewController.swift index 57518ea469..96d437cb45 100644 --- a/TestApp/Sources/Library/LibraryViewController.swift +++ b/TestApp/Sources/Library/LibraryViewController.swift @@ -174,7 +174,7 @@ class LibraryViewController: UIViewController, Loggable { private func importPublication(from url: HTTPURL) { Task { do { - try await library.importPublication(from: url, sender: self, progress: { _ in }) + try await library.importPublication(from: url, progress: { _ in }) } catch { alert(UserError(error)) } @@ -210,7 +210,7 @@ extension LibraryViewController: UIDocumentPickerDelegate { private func importFiles(at urls: [URL]) { Task { do { - try await library.importPublications(from: urls, sender: self) + try await library.importPublications(from: urls) } catch { libraryDelegate?.presentError(error, from: self) } @@ -299,7 +299,7 @@ extension LibraryViewController: UICollectionViewDelegateFlowLayout, UICollectio let book = books[indexPath.item] do { - guard let pub = try await library.openBook(book, sender: self) else { + guard let pub = try await library.openBook(book) else { return } libraryDelegate.libraryDidSelectPublication(pub, book: book) @@ -340,7 +340,7 @@ extension LibraryViewController: PublicationCollectionViewCellDelegate { Task { do { - guard let pub = try await library.openBook(book, sender: self) else { + guard let pub = try await library.openBook(book) else { return } let pubMetadataViewController = UIHostingController(rootView: PublicationMetadataView(publication: pub)) diff --git a/TestApp/Sources/OPDS/OPDSModule.swift b/TestApp/Sources/OPDS/OPDSModule.swift index 5120d9c37a..76e0b7e4cb 100644 --- a/TestApp/Sources/OPDS/OPDSModule.swift +++ b/TestApp/Sources/OPDS/OPDSModule.swift @@ -28,7 +28,6 @@ enum OPDSError: Error { func opdsDownloadPublication( _ publication: Publication?, at link: ReadiumShared.Link, - sender: UIViewController, progress: @escaping @Sendable (Double) -> Void ) async throws -> Book } diff --git a/TestApp/Sources/OPDS/OPDSPublicationInfoViewController.swift b/TestApp/Sources/OPDS/OPDSPublicationInfoViewController.swift index edc8273f1b..bc67fe2f05 100644 --- a/TestApp/Sources/OPDS/OPDSPublicationInfoViewController.swift +++ b/TestApp/Sources/OPDS/OPDSPublicationInfoViewController.swift @@ -96,7 +96,7 @@ class OPDSPublicationInfoViewController: UIViewController, Loggable { downloadButton.isEnabled = false do { - let book = try await delegate.opdsDownloadPublication(publication, at: downloadLink, sender: self, progress: { _ in }) + let book = try await delegate.opdsDownloadPublication(publication, at: downloadLink, progress: { _ in }) delegate.presentAlert( NSLocalizedString("success_title", comment: "Title of the alert when a publication is successfully downloaded"), message: String(format: NSLocalizedString("library_download_success_message", comment: "Message of the alert when a publication is successfully downloaded"), book.title), diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/Container.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/Container.swift index 66f87fc296..b38cc2519b 100644 --- a/Tests/NavigatorTests/UITests/NavigatorTestHost/Container.swift +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/Container.swift @@ -36,8 +36,7 @@ import UIKit let asset = try await assetRetriever.retrieve(url: url).get() let publication = try await publicationOpener.open( asset: asset, - allowUserInteraction: false, - sender: nil + allowUserInteraction: false ).get() memoryTracker.track(publication) diff --git a/docs/Guides/Getting Started.md b/docs/Guides/Getting Started.md index 596357d908..b07b0f779a 100644 --- a/docs/Guides/Getting Started.md +++ b/docs/Guides/Getting Started.md @@ -121,7 +121,7 @@ let url: URL = URL(...) switch await assetRetriever.retrieve(url: url.anyURL.absoluteURL!) { case .success(let asset): // Open a `Publication` from the `Asset`. - switch await publicationOpener.open(asset: asset, allowUserInteraction: true, sender: view) { + switch await publicationOpener.open(asset: asset, allowUserInteraction: true) { case .success(let publication): print("Opened \(publication.metadata.title)") diff --git a/docs/Guides/Open Publication.md b/docs/Guides/Open Publication.md index 3c68384555..658db683e2 100644 --- a/docs/Guides/Open Publication.md +++ b/docs/Guides/Open Publication.md @@ -63,11 +63,13 @@ let publicationOpener = PublicationOpener( pdfFactory: DefaultPDFDocumentFactory() ), contentProtections: [ - lcpService.contentProtection(with: LCPDialogAuthentication()) + lcpService.contentProtection(with: authentication) ] ) ``` +[See the Readium LCP guide](Readium%20LCP.md#opening-a-publication-protected-with-lcp) to learn how to create the `LCPAuthenticating` instance. + ### Opening a `Publication` Now that you have a `PublicationOpener` ready, you can use it to create a `Publication` from an `Asset` that was previously obtained using the `AssetRetriever`. @@ -77,8 +79,7 @@ The `allowUserInteraction` parameter is useful when supporting Readium LCP. When ```swift let result = await publicationOpener.open( asset: asset, - allowUserInteraction: true, - sender: sender + allowUserInteraction: true ) ``` diff --git a/docs/Guides/Readium LCP.md b/docs/Guides/Readium LCP.md index afb8b3cebd..20c778fe2b 100644 --- a/docs/Guides/Readium LCP.md +++ b/docs/Guides/Readium LCP.md @@ -268,7 +268,8 @@ A publication protected with LCP can be opened using the `PublicationOpener` com ```swift let httpClient = DefaultHTTPClient() -let authentication = LCPDialogAuthentication() +let dialogPresenter = LCPDialogPresenter() +let authentication = LCPDialogAuthentication(delegate: dialogPresenter) let publicationOpener = PublicationOpener( parser: DefaultPublicationParser( @@ -282,7 +283,22 @@ let publicationOpener = PublicationOpener( ) ``` -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 UIKit `LCPDialogAuthentication` which displays a pop-up to enter the passphrase, or implement your own method for passphrase retrieval. If your application is built using SwiftUI, [prefer using the new `LCPDialog`](#using-the-swiftui-lcp-authentication-dialog) +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 UIKit `LCPDialogAuthentication` which displays a pop-up to enter the passphrase, or implement your own method for passphrase retrieval. If your application is built using SwiftUI, [prefer using the new `LCPDialog`](#using-the-swiftui-lcp-authentication-dialog). + +`LCPDialogAuthentication` delegates the presentation of the dialog to an `LCPDialogAuthenticationDelegate`, for example on the top-most view controller of your application. As the delegate is held weakly, you must retain it yourself for the lifetime of the authentication. + +```swift +final class LCPDialogPresenter: LCPDialogAuthenticationDelegate { + func lcpDialogAuthentication( + _ authentication: LCPDialogAuthentication, + present dialogViewController: UIViewController + ) { + // Present the dialog on your top-most view controller. It will + // dismiss itself automatically once the user submits or cancels. + hostViewController.present(dialogViewController, animated: true) + } +} +``` > [!NOTE] > The user will be prompted once per passphrase since `ReadiumLCP` stores known passphrases on the device. @@ -299,8 +315,7 @@ let asset = try await assetRetriever.retrieve(url: url).get() // Open a `Publication` from the `Asset`. let result = await publicationOpener.open( asset: asset, - allowUserInteraction: true, - sender: hostViewController + allowUserInteraction: true ) switch result { @@ -311,7 +326,7 @@ case .failure(let error): } ``` -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`. +The `allowUserInteraction` argument is forwarded to the `LCPAuthenticating` implementation when the passphrase is unknown. `LCPDialogAuthentication` shows a pop-up only if `allowUserInteraction` is `true`. 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. @@ -353,7 +368,7 @@ let publicationOpener = PublicationOpener( assetRetriever: assetRetriever ), contentProtections: [ - lcpService.contentProtection(with: LCPDialogAuthentication()), + lcpService.contentProtection(with: LCPDialogAuthentication(delegate: dialogPresenter)), ] ) @@ -364,8 +379,7 @@ let asset = try await assetRetriever.retrieve(url: url).get() // Open a `Publication` from the LCPL `Asset`. let publication = try await publicationOpener.open( asset: asset, - allowUserInteraction: true, - sender: hostViewController + allowUserInteraction: true ).get() print("Opened \(publication.metadata.title)") @@ -380,9 +394,8 @@ Use the `LCPService` to retrieve the `LCPLicense` instance for a publication. ```swift let result = await lcpService.retrieveLicense( from: asset, - authentication: LCPDialogAuthentication(), - allowUserInteraction: true, - sender: hostViewController + authentication: LCPDialogAuthentication(delegate: dialogPresenter), + allowUserInteraction: true ) switch result { diff --git a/docs/Migration Guide.md b/docs/Migration Guide.md index 02eea86ed8..f740b182a3 100644 --- a/docs/Migration Guide.md +++ b/docs/Migration Guide.md @@ -4,7 +4,9 @@ All migration steps necessary in reading apps to upgrade to major versions of th ## Unreleased -### Required `deviceName` in `LCPService` +### Readium LCP + +#### Required `deviceName` in `LCPService` `LCPService.init` now requires an explicit `deviceName`. We recommend passing `UIDevice.current.name`: @@ -19,6 +21,43 @@ All migration steps necessary in reading apps to upgrade to major versions of th > [!NOTE] > Since iOS 16, `UIDevice.current.name` returns a generic name (e.g. "iPhone") unless the `com.apple.developer.device-information.user-assigned-device-name` entitlement is added to your app. +#### Removal of the `sender` parameter from the LCP authentication APIs + +The `sender` parameter used to give UX context (e.g. the host `UIViewController`) when presenting an LCP passphrase dialog has been removed from `PublicationOpener.open(...)` and `LCPService.retrieveLicense(...)`. + +If you use the SwiftUI `LCPDialog`, just remove the `sender` argument from your calls. + +But if you use the UIKit `LCPDialogAuthentication`, you need to provide a `LCPDialogAuthenticationDelegate` instead: + +```diff +-let authentication = LCPDialogAuthentication() ++let dialogPresenter = LCPDialogPresenter() ++let authentication = LCPDialogAuthentication(delegate: dialogPresenter) +``` + +```swift +final class LCPDialogPresenter: LCPDialogAuthenticationDelegate { + func lcpDialogAuthentication( + _ authentication: LCPDialogAuthentication, + present dialogViewController: UIViewController + ) { + hostViewController.present(dialogViewController, animated: true) + } +} +``` + +Then drop the `sender` argument from your calls: + +```diff + let result = await publicationOpener.open( + asset: asset, +- allowUserInteraction: true, +- sender: hostViewController ++ allowUserInteraction: true + ) +``` + + ## 3.9.0 ### Removing the HTTP Server from the PDF Navigator From 3d515f04f38a401e75f2fc13bb7fa51e6612fd58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Fri, 3 Jul 2026 17:16:52 +0200 Subject: [PATCH 26/39] Fix LCP hashed-passphrase validation --- Sources/LCP/Services/PassphrasesService.swift | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Sources/LCP/Services/PassphrasesService.swift b/Sources/LCP/Services/PassphrasesService.swift index a4027bc869..4f3411f7d5 100644 --- a/Sources/LCP/Services/PassphrasesService.swift +++ b/Sources/LCP/Services/PassphrasesService.swift @@ -30,7 +30,7 @@ final class PassphrasesService: Loggable, Sendable { ) async throws(LCPAddPassphraseError) { let hash: LCPPassphraseHash if isHashed { - guard sha256Predicate.evaluate(with: passphrase) else { + guard isValidHashedPassphrase(passphrase) else { throw .invalidHash } // Normalize to lowercase to match `sha256()` output, so a hashed @@ -141,9 +141,9 @@ final class PassphrasesService: Loggable, Sendable { let hashedPassphrase = clearPassphrase.sha256() var passphrases = [hashedPassphrase] - // Note: The C++ LCP lib crashes if we provide a passphrase that is not a valid - // SHA-256 hash. So we check this beforehand. - if clearPassphrase.count == 64, clearPassphrase.allSatisfy({ $0.isASCII && $0.isHexDigit }) { + // Note: The C++ LCP lib crashes if we provide a passphrase that is not + // a valid SHA-256 hash. So we check this beforehand. + if isValidHashedPassphrase(clearPassphrase) { passphrases.append(clearPassphrase) } @@ -180,4 +180,10 @@ final class PassphrasesService: Loggable, Sendable { allowUserInteraction: allowUserInteraction ) } + + /// Returns whether the provided `passphrase` is actually a valid hashed + /// passphrase, and not just a clear passphrase. + private func isValidHashedPassphrase(_ passphrase: String) -> Bool { + passphrase.count == 64 && passphrase.allSatisfy { $0.isASCII && $0.isHexDigit } + } } From 621b3675eafa029209b74c60f994b6cf88c269c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Sun, 12 Jul 2026 18:51:52 +0200 Subject: [PATCH 27/39] Adopt Swift 6.2 approachable concurrency and harden the strict-concurrency migration (#852) --- .github/workflows/checks.yml | 101 +++++++++--------- .github/workflows/docs.yml | 10 +- CHANGELOG.md | 13 +++ Package.swift | 31 ++---- Playground/.xcodegen | 3 +- .../Playground.xcodeproj/project.pbxproj | 6 +- .../App/Common/UserError+Readium.swift | 16 +-- Playground/Sources/App/Common/UserError.swift | 10 +- .../Sources/App/Data/DocumentRepository.swift | 8 +- Playground/project.yml | 1 + Sources/Internal/Extensions/Task.swift | 26 ----- Sources/LCP/Authentications/LCPDialog.swift | 5 +- .../LCPDialogViewController.swift | 2 + .../LCPObservableAuthentication.swift | 1 + .../Content Protection/EncryptionParser.swift | 1 + .../LCPContentProtection.swift | 1 + .../LCP/Content Protection/LCPDecryptor.swift | 6 +- Sources/LCP/LCPRenewDelegate.swift | 9 ++ Sources/LCP/License/License.swift | 1 + Sources/LCP/License/LicenseValidation.swift | 39 ++++--- .../Preferences/AudioPreferencesEditor.swift | 1 + .../DirectionalNavigationAdapter.swift | 1 + .../EPUB/EPUBNavigatorViewController.swift | 2 +- .../EPUB/EPUBNavigatorViewModel.swift | 3 +- .../EPUB/EPUBReflowableSpreadView.swift | 32 ++++-- Sources/Navigator/EPUB/EPUBSpread.swift | 1 + Sources/Navigator/EPUB/EPUBSpreadView.swift | 40 +++---- .../EPUBViewportAndLocationCalculator.swift | 1 + .../Preferences/EPUBPreferences+Legacy.swift | 1 + .../EPUB/Preferences/EPUBPreferences.swift | 1 + .../Preferences/EPUBPreferencesEditor.swift | 1 + .../PDF/PDFNavigatorViewController.swift | 1 + .../Navigator/PDF/PDFPageNumberResolver.swift | 1 + .../Navigator/PDF/PDFViewportCalculator.swift | 1 + .../Preferences/MappedPreference.swift | 1 + .../Preferences/ProgressionStrategy.swift | 1 + .../Preferences/ProxyPreference.swift | 1 + Sources/Navigator/Preferences/Types.swift | 1 + .../TTS/PublicationSpeechSynthesizer.swift | 1 + Sources/Navigator/TTS/TTSVoice.swift | 1 + .../Navigator/Toolkit/PaginationView.swift | 1 + .../ViewportProgressionCalculator.swift | 1 + Sources/OPDS/OPDS1Parser.swift | 1 + Sources/OPDS/OPDS2Parser.swift | 1 + .../Services/Content/Content.swift | 7 +- .../Services/Content/ContentTokenizer.swift | 1 + .../PDFResourceContentIterator.swift | 1 + .../PublicationContentIterator.swift | 1 + .../Cover/GeneratedCoverService.swift | 2 +- .../Locator/DefaultLocatorService.swift | 1 + .../Services/Search/SearchService.swift | 1 + .../Search/StringSearchAlgorithm.swift | 2 +- Sources/Shared/Toolkit/AsyncMemoizer.swift | 2 +- Sources/Shared/Toolkit/CancellableTasks.swift | 41 +++++++ .../Toolkit/Data/Asset/AssetRetriever.swift | 1 + .../Toolkit/Data/Container/Container.swift | 1 + .../Data/Resource/BufferingResource.swift | 4 + .../Toolkit/Data/Resource/DataResource.swift | 6 +- .../Resource/ResourceContentExtractor.swift | 3 +- .../Data/Resource/TransformingResource.swift | 3 +- Sources/Shared/Toolkit/Data/Streamable.swift | 6 +- .../Shared/Toolkit/File/FileResource.swift | 7 +- .../Shared/Toolkit/Format/FormatSniffer.swift | 1 + .../Toolkit/Format/FormatSnifferBlob.swift | 1 + .../Sniffers/CompositeFormatSniffer.swift | 1 + .../Format/Sniffers/EPUBFormatSniffer.swift | 1 + .../Format/Sniffers/HTMLFormatSniffer.swift | 1 + .../Toolkit/HTTP/DefaultHTTPClient.swift | 3 +- Sources/Shared/Toolkit/HTTP/HTTPClient.swift | 1 + Sources/Shared/Toolkit/JSONValue.swift | 1 + Sources/Shared/Toolkit/Mutex.swift | 6 ++ Sources/Shared/Toolkit/Poller.swift | 1 + Sources/Shared/Toolkit/Throttle.swift | 1 + .../URL/Absolute URL/AbsoluteURL.swift | 1 + .../Toolkit/URL/Absolute URL/FileURL.swift | 1 + Sources/Shared/Toolkit/URL/RelativeURL.swift | 1 + Sources/Shared/Toolkit/Weak.swift | 5 +- .../ZIP/Minizip/MinizipContainer.swift | 10 ++ .../ZIPFoundationArchiveFactory.swift | 1 + .../ZIPFoundationContainer.swift | 7 +- .../Streamer/Parser/Audio/AudioParser.swift | 1 + .../AudioPublicationManifestAugmentor.swift | 1 + .../Parser/EPUB/EPUBMetadataParser.swift | 1 + Sources/Streamer/Parser/EPUB/OPFParser.swift | 1 + .../EPUB/Services/EPUBPositionsService.swift | 3 +- Sources/Streamer/Parser/PDF/PDFParser.swift | 1 + .../Parser/Readium/ReadiumWebPubParser.swift | 1 + Support/CocoaPods/ReadiumInternal.podspec | 2 +- Support/CocoaPods/ReadiumLCP.podspec | 2 +- Support/CocoaPods/ReadiumNavigator.podspec | 2 +- Support/CocoaPods/ReadiumOPDS.podspec | 2 +- Support/CocoaPods/ReadiumShared.podspec | 2 +- Support/CocoaPods/ReadiumStreamer.podspec | 2 +- Support/CocoaPods/Specs.swift | 2 +- TestApp/Integrations/CocoaPods/Podfile | 8 +- TestApp/Integrations/CocoaPods/Podfile+lcp | 10 +- .../Integrations/CocoaPods/project+lcp.yml | 3 + TestApp/Integrations/CocoaPods/project.yml | 3 + TestApp/Integrations/Local/TestApp.xctestplan | 7 ++ TestApp/Integrations/Local/project+lcp.yml | 3 + TestApp/Integrations/Local/project.yml | 3 + TestApp/Integrations/SPM/project+lcp.yml | 5 +- TestApp/Integrations/SPM/project.yml | 5 +- TestApp/Makefile | 6 +- TestApp/Sources/App/Readium.swift | 2 +- TestApp/Sources/Common/Paths.swift | 4 +- .../Toolkit/Extensions/AnyPublisher.swift | 5 +- .../Common/Toolkit/Extensions/Future.swift | 17 ++- TestApp/Sources/Common/UserError.swift | 6 +- TestApp/Sources/Data/Book.swift | 13 ++- TestApp/Sources/Data/Bookmark.swift | 8 +- TestApp/Sources/Data/Database.swift | 14 +-- TestApp/Sources/Data/Highlight.swift | 10 +- TestApp/Sources/LCP/LCPModule.swift | 6 +- TestApp/Sources/Library/LibraryModule.swift | 4 +- TestApp/Sources/Library/LibraryService.swift | 4 +- .../PublicationCollectionViewCell.swift | 12 ++- .../Common/Preferences/UserPreferences.swift | 7 +- .../Common/VisualReaderViewController.swift | 2 +- .../Audio/PublicationMediaLoaderTests.swift | 1 + .../OPDS/OPDSAvailabilityTests.swift | 1 + .../Extensions/Audio/Locator+AudioTests.swift | 1 + .../PDFResourceContentIteratorTests.swift | 1 + .../Toolkit/CancellableTasksTests.swift | 47 ++++++++ .../Resource/BufferingResourceTests.swift | 1 + .../Resource/TailCachingResourceTests.swift | 1 + .../Resource/TransformingResourceTests.swift | 1 + .../Toolkit/File/FileResourceTests.swift | 35 ++++++ .../Toolkit/HTTP/DefaultHTTPClientTests.swift | 1 + .../Parser/EPUB/EPUBManifestParserTests.swift | 1 + .../Parser/EPUB/EPUBMetadataParserTests.swift | 1 + docs/Guides/Readium LCP.md | 3 +- docs/Migration Guide.md | 70 +++++++++++- scripts/test.sh | 7 ++ 134 files changed, 628 insertions(+), 255 deletions(-) create mode 100644 Sources/Shared/Toolkit/CancellableTasks.swift create mode 100644 Tests/SharedTests/Toolkit/CancellableTasksTests.swift create mode 100644 Tests/SharedTests/Toolkit/File/FileResourceTests.swift diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 1d92a47245..3e89ad7c06 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -9,9 +9,12 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +permissions: + contents: read + env: - platform: ${{ 'iOS Simulator' }} - device: ${{ 'iPhone 17 Pro' }} + platform: iOS Simulator + device: iPhone 17 Pro commit_sha: ${{ github.sha }} DEVELOPER_DIR: /Applications/Xcode_26.4.app/Contents/Developer @@ -20,24 +23,18 @@ jobs: name: Build runs-on: macos-26 if: ${{ !github.event.pull_request.draft }} + timeout-minutes: 60 env: - scheme: ${{ 'Readium-Package' }} + scheme: Readium-Package steps: - name: Checkout uses: actions/checkout@v6 - name: Install dependencies run: | - brew update brew install xcodegen # Preload the list of simulator for xcodebuild. The workflow is flaky without it. xcrun simctl list - - name: Check CocoaPods podspecs - run: | - # Check that the podspecs are up to date. - make podspecs - git diff --exit-code Support/CocoaPods/ - if git ls-files --others --exclude-standard Support/CocoaPods/ | grep -q .; then echo "Untracked podspec files found. Run 'make podspecs' and commit the result."; exit 1; fi - name: Build run: | set -eo pipefail @@ -50,53 +47,36 @@ jobs: run: | jq -r '.pins[] | "\(.identity): \(.state.version)"' Package.resolved - # navigator-ui-tests: - # name: Navigator UI Tests - # runs-on: macos-26 - # if: ${{ !github.event.pull_request.draft }} - # steps: - # - name: Checkout - # uses: actions/checkout@v6 - # - name: Install dependencies - # run: | - # brew update - # brew install xcodegen - # # Preload the list of simulator for xcodebuild. The workflow is flaky without it. - # xcrun simctl list - # - name: Test - # run: | - # set -eo pipefail - # make navigator-ui-tests-project - # xcodebuild test -project Tests/NavigatorTests/UITests/NavigatorUITests.xcodeproj -scheme NavigatorTestHost -destination "platform=$platform,name=$device" | xcbeautify --renderer github-actions - - playground: - name: Playground + # Runs the test suite under the Thread Sanitizer to validate the + # `@unchecked Sendable` and `nonisolated(unsafe)` sites at runtime. + # Only runs on pushes to main/develop to keep pull requests light. + test-tsan: + name: Test (Thread Sanitizer) runs-on: macos-26 - if: ${{ !github.event.pull_request.draft }} + if: github.event_name == 'push' + timeout-minutes: 60 + env: + scheme: Readium-Package + steps: - name: Checkout uses: actions/checkout@v6 - name: Install dependencies run: | - brew update - brew install xcodegen # Preload the list of simulator for xcodebuild. The workflow is flaky without it. xcrun simctl list - - name: Check Playground project is up-to-date - run: | - make playground - git diff --exit-code Playground/.xcodegen - - name: Build + - name: Test run: | set -eo pipefail - xcodebuild build -scheme Playground -project Playground/Playground.xcodeproj -destination "platform=$platform,name=$device" | xcbeautify --renderer github-actions + xcodebuild test -scheme "$scheme" -destination "platform=$platform,name=$device" -enableThreadSanitizer YES | xcbeautify --renderer github-actions lint: name: Lint runs-on: macos-26 if: ${{ !github.event.pull_request.draft }} + timeout-minutes: 60 env: - scripts: ${{ 'Sources/Navigator/EPUB/Scripts' }} + scripts: Sources/Navigator/EPUB/Scripts steps: - name: Checkout @@ -122,16 +102,21 @@ jobs: run: | make scripts git diff --exit-code --name-only Sources/Navigator/EPUB/Assets/Static/scripts/*.js + if git ls-files --others --exclude-standard Sources/Navigator/EPUB/Assets/Static/scripts/ | grep -q .; then echo "Untracked bundled scripts found. Run 'make scripts' and commit the result."; exit 1; fi - name: Lint Swift formatting run: make lint-format + - name: Check CocoaPods podspecs + run: | + # Check that the podspecs are up to date. + make podspecs + git diff --exit-code Support/CocoaPods/ + if git ls-files --others --exclude-standard Support/CocoaPods/ | grep -q .; then echo "Untracked podspec files found. Run 'make podspecs' and commit the result."; exit 1; fi int-dev: name: Integration (Local) runs-on: macos-26 if: ${{ !github.event.pull_request.draft }} - defaults: - run: - working-directory: TestApp + timeout-minutes: 60 environment: name: LCP deployment: false @@ -140,13 +125,25 @@ jobs: uses: actions/checkout@v6 - name: Install dependencies run: | - brew update brew install xcodegen # Preload the list of simulator for xcodebuild. The workflow is flaky without it. xcrun simctl list - - name: Generate project - run: make dev lcp=${{ secrets.LCP_URL_SPM }} - - name: Build + - name: Check Playground project is up-to-date + run: | + make playground + git diff --exit-code Playground/.xcodegen + if git ls-files --others --exclude-standard Playground/Playground.xcodeproj/ | grep -q .; then echo "Untracked Playground project files found. Run 'make playground' and commit the result."; exit 1; fi + - name: Build Playground + run: | + set -eo pipefail + xcodebuild build -scheme Playground -project Playground/Playground.xcodeproj -destination "platform=$platform,name=$device" | xcbeautify --renderer github-actions + - name: Generate TestApp project + working-directory: TestApp + env: + LCP_URL_SPM: ${{ secrets.LCP_URL_SPM }} + run: make dev lcp="$LCP_URL_SPM" + - name: Build TestApp + working-directory: TestApp run: | set -eo pipefail xcodebuild build -scheme TestApp -destination "platform=$platform,name=$device" | xcbeautify --renderer github-actions @@ -155,6 +152,7 @@ jobs: name: Integration (Swift Package Manager) runs-on: macos-26 if: ${{ !github.event.pull_request.draft }} + timeout-minutes: 60 defaults: run: working-directory: TestApp @@ -172,15 +170,14 @@ jobs: echo "commit_sha=${{ github.event.pull_request.head.sha }}" >> "$GITHUB_ENV" - name: Install dependencies run: | - brew update brew install xcodegen # Preload the list of simulator for xcodebuild. The workflow is flaky without it. xcrun simctl list - name: Generate project - run: make spm lcp=${{ secrets.LCP_URL_SPM }} commit=$commit_sha + env: + LCP_URL_SPM: ${{ secrets.LCP_URL_SPM }} + run: make spm lcp="$LCP_URL_SPM" commit="$commit_sha" - name: Build run: | set -eo pipefail xcodebuild build -scheme TestApp -destination "platform=$platform,name=$device" | xcbeautify --renderer github-actions - - diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 39cc595745..edf5e95115 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -16,7 +16,8 @@ concurrency: jobs: build-and-deploy: - runs-on: macos-15 + runs-on: macos-26 + timeout-minutes: 60 steps: - name: Checkout uses: actions/checkout@v6 @@ -28,16 +29,19 @@ jobs: run: | git fetch --tags --force VERSION=$(git describe --tag --match "[0-9]*" --abbrev=0) - echo "READIUM_VERSION=$VERSION" >> $GITHUB_OUTPUT + echo "READIUM_VERSION=$VERSION" >> "$GITHUB_OUTPUT" if [[ $GITHUB_REF == refs/tags/* ]]; then echo "folder=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT else echo "folder=latest" >> $GITHUB_OUTPUT fi + # The docs are generated twice because the version is baked into the + # output (DocC --hosting-base-path and the 404/redirect pages embed + # /swift-toolkit// URLs), so the "latest" site cannot be a + # plain copy of the versioned one. - name: Generate Documentation run: | - chmod +x scripts/generate-docs.sh ./scripts/generate-docs.sh ${{ steps.versioning.outputs.READIUM_VERSION }} ./scripts/generate-docs.sh latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f1ab396c0..163d7a6638 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,15 +7,28 @@ All notable changes to this project will be documented in this file. Take a look ### Changed +* The toolkit is migrated to Swift 6 with strict concurrency checking. All packages compile in the Swift 6 language mode. See [the migration guide](docs/Migration%20Guide.md). + * The toolkit adopts the `NonisolatedNonsendingByDefault` (SE-0461), `InferIsolatedConformances` and `MemberImportVisibility` upcoming Swift features. + #### Shared * OPDS models (`Feed`, `Group`, `Facet`, `OpdsMetadata`) are now structs with value semantics. +* `Publication`, `Resource`, `Container` and related types are now `Sendable`. Custom implementations of `Resource`, `Container`, `HTTPClient` or `PublicationService` must be `Sendable` too. +* `Resource.stream()` now cooperates with task cancellation: the built-in resources fail with `ReadError.cancelled` when the surrounding task is cancelled, and custom implementations are expected to do the same. + +#### Navigator + +* The `Navigator` and `VisualNavigator` protocols and their delegates are now isolated to the main actor. #### LCP * `LCPService.init` now requires an explicit `deviceName` parameter. We recommend passing `UIDevice.current.name`. See [the migration guide](docs/Migration%20Guide.md). * `LCPDialogAuthentication` no longer takes a `sender` view controller. It now presents its passphrase dialog through a new `LCPDialogAuthenticationDelegate` that you implement and retain for the lifetime of the authentication. See [the Readium LCP guide](docs/Guides/Readium%20LCP.md) and [the migration guide](docs/Migration%20Guide.md). +### Removed + +* The deprecated `ReadiumAdapterGCDWebServer` and `ReadiumAdapterLCPSQLite` adapter packages have been removed. + diff --git a/Package.swift b/Package.swift index 644b492bb3..3c6239851f 100644 --- a/Package.swift +++ b/Package.swift @@ -169,26 +169,15 @@ let package = Package( ] ) -// FIXME: Remove this once the Swift 6 migration is done. -let swift6EnabledTargets: Set = [ - "ReadiumLCP", - "ReadiumLCPTests", - "ReadiumNavigator", - "ReadiumNavigatorTests", - "ReadiumOPDS", - "ReadiumOPDSTests", - "ReadiumShared", - "ReadiumSharedTests", - "ReadiumStreamer", - "ReadiumStreamerTests", -] - for target in package.targets { - var swiftSettings = target.swiftSettings ?? [] - if swift6EnabledTargets.contains(target.name) { - swiftSettings.append(.swiftLanguageMode(.v6)) - } else { - swiftSettings.append(.swiftLanguageMode(.v5)) - } - target.swiftSettings = swiftSettings + // Adopt the future language defaults now, to avoid a second + // behavioral break for integrators when they become the default. + // In particular, `NonisolatedNonsendingByDefault` (SE-0461) runs + // `nonisolated async` functions on the caller's actor; CPU-heavy + // implementations are marked `@concurrent` to stay off-actor. + target.swiftSettings = (target.swiftSettings ?? []) + [ + .enableUpcomingFeature("NonisolatedNonsendingByDefault"), + .enableUpcomingFeature("InferIsolatedConformances"), + .enableUpcomingFeature("MemberImportVisibility"), + ] } diff --git a/Playground/.xcodegen b/Playground/.xcodegen index eee52ea4cd..dfb2723d10 100644 --- a/Playground/.xcodegen +++ b/Playground/.xcodegen @@ -53,7 +53,8 @@ "platform" : "iOS", "settings" : { "SWIFT_APPROACHABLE_CONCURRENCY" : true, - "SWIFT_DEFAULT_ACTOR_ISOLATION" : "MainActor" + "SWIFT_DEFAULT_ACTOR_ISOLATION" : "MainActor", + "SWIFT_VERSION" : 6 }, "sources" : [ { diff --git a/Playground/Playground.xcodeproj/project.pbxproj b/Playground/Playground.xcodeproj/project.pbxproj index a9e8a47796..ef29989202 100644 --- a/Playground/Playground.xcodeproj/project.pbxproj +++ b/Playground/Playground.xcodeproj/project.pbxproj @@ -30,6 +30,7 @@ /* Begin PBXFileReference section */ 21B9812F732ED2F093918E79 /* Logger+Ext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Logger+Ext.swift"; sourceTree = ""; }; 2C1CFC0B9AEB966345163620 /* PublicationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicationView.swift; sourceTree = ""; }; + 2E399BE85546465BB3B37527 /* swift-toolkit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = swift-toolkit; path = ..; sourceTree = SOURCE_ROOT; }; 3A9F2917BE7D720CB89EBC9C /* UserError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserError.swift; sourceTree = ""; }; 4DC581D9DDE636037C5FAB4A /* HTMLText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLText.swift; sourceTree = ""; }; 59844953100C517348EF23D0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -44,7 +45,6 @@ CCB6D3C4C19C2038573D2B90 /* JSONView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONView.swift; sourceTree = ""; }; D608867E2F9CC0B751114DE9 /* A02-ReadMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "A02-ReadMetadata.swift"; sourceTree = ""; }; E40DD68F934F5F0D2981ACA1 /* Playground.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Playground.app; sourceTree = BUILT_PRODUCTS_DIR; }; - ED646581982F3FF78FB275C8 /* swift-toolkit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = swift-toolkit; path = ..; sourceTree = SOURCE_ROOT; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -129,7 +129,7 @@ 75054112A41CDCE58ADACF92 /* Packages */ = { isa = PBXGroup; children = ( - ED646581982F3FF78FB275C8 /* swift-toolkit */, + 2E399BE85546465BB3B37527 /* swift-toolkit */, ); name = Packages; sourceTree = ""; @@ -336,6 +336,7 @@ SDKROOT = iphoneos; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -355,6 +356,7 @@ SDKROOT = iphoneos; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; diff --git a/Playground/Sources/App/Common/UserError+Readium.swift b/Playground/Sources/App/Common/UserError+Readium.swift index 24a92a9e8f..d17c02fc2e 100644 --- a/Playground/Sources/App/Common/UserError+Readium.swift +++ b/Playground/Sources/App/Common/UserError+Readium.swift @@ -9,11 +9,11 @@ import ReadiumStreamer /// Generic fallback message for errors that have no meaningful user-facing /// description. -let unexpected = "Something went wrong. Please try again." +nonisolated let unexpected = "Something went wrong. Please try again." // MARK: - ReadiumShared Errors -extension ReadiumShared.AssetRetrieveError: UserErrorConvertible { +nonisolated extension ReadiumShared.AssetRetrieveError: UserErrorConvertible { var userErrorMessage: String? { switch self { case .formatNotSupported: "Unsupported file type. Please try a different file." @@ -22,7 +22,7 @@ extension ReadiumShared.AssetRetrieveError: UserErrorConvertible { } } -extension ReadiumShared.AssetRetrieveURLError: UserErrorConvertible { +nonisolated extension ReadiumShared.AssetRetrieveURLError: UserErrorConvertible { var userErrorMessage: String? { switch self { case .schemeNotSupported, .formatNotSupported: "Unsupported file type. Please try a different file." @@ -31,7 +31,7 @@ extension ReadiumShared.AssetRetrieveURLError: UserErrorConvertible { } } -extension ReadiumShared.ReadError: UserErrorConvertible { +nonisolated extension ReadiumShared.ReadError: UserErrorConvertible { var userErrorMessage: String? { switch self { case let .access(error): error.userErrorMessage @@ -43,7 +43,7 @@ extension ReadiumShared.ReadError: UserErrorConvertible { } } -extension ReadiumShared.AccessError: UserErrorConvertible { +nonisolated extension ReadiumShared.AccessError: UserErrorConvertible { var userErrorMessage: String? { switch self { case let .http(error): error.userErrorMessage @@ -53,7 +53,7 @@ extension ReadiumShared.AccessError: UserErrorConvertible { } } -extension ReadiumShared.FileSystemError: UserErrorConvertible { +nonisolated extension ReadiumShared.FileSystemError: UserErrorConvertible { var userErrorMessage: String? { switch self { case .fileNotFound: "Couldn't open file. The file was not found." @@ -64,7 +64,7 @@ extension ReadiumShared.FileSystemError: UserErrorConvertible { } } -extension ReadiumShared.HTTPError: UserErrorConvertible { +nonisolated extension ReadiumShared.HTTPError: UserErrorConvertible { var userErrorMessage: String? { switch self { case .malformedRequest, .redirection, .cancelled, .other: @@ -91,7 +91,7 @@ extension ReadiumShared.HTTPError: UserErrorConvertible { // MARK: - ReadiumStreamer Errors -extension ReadiumStreamer.PublicationOpenError: UserErrorConvertible { +nonisolated extension ReadiumStreamer.PublicationOpenError: UserErrorConvertible { var userErrorMessage: String? { switch self { case .formatNotSupported: "Unsupported file type. Please try a different file." diff --git a/Playground/Sources/App/Common/UserError.swift b/Playground/Sources/App/Common/UserError.swift index acb9541913..f112738394 100644 --- a/Playground/Sources/App/Common/UserError.swift +++ b/Playground/Sources/App/Common/UserError.swift @@ -55,12 +55,12 @@ struct UserError: LocalizedError { /// Convenience protocol for an object (usually an ``Error``) that can be /// converted into a ``UserError``. -protocol UserErrorConvertible { +nonisolated protocol UserErrorConvertible { var userErrorMessage: String? { get } var userErrorCause: (any Error)? { get } } -extension UserErrorConvertible { +nonisolated extension UserErrorConvertible { var userError: UserError? { guard let message = userErrorMessage else { return nil @@ -69,13 +69,13 @@ extension UserErrorConvertible { } } -extension UserErrorConvertible where Self: Error { +nonisolated extension UserErrorConvertible where Self: Error { var userErrorCause: (any Error)? { self } } -extension UserError: UserErrorConvertible { +nonisolated extension UserError: UserErrorConvertible { var userErrorMessage: String? { message } @@ -85,7 +85,7 @@ extension UserError: UserErrorConvertible { } } -extension String: UserErrorConvertible { +nonisolated extension String: UserErrorConvertible { var userErrorMessage: String? { self } diff --git a/Playground/Sources/App/Data/DocumentRepository.swift b/Playground/Sources/App/Data/DocumentRepository.swift index 79d0400185..fc7e7d9d3b 100644 --- a/Playground/Sources/App/Data/DocumentRepository.swift +++ b/Playground/Sources/App/Data/DocumentRepository.swift @@ -71,16 +71,16 @@ import OSLog return } + // The source must target the main queue, as the event handler is + // MainActor-isolated. dispatchSource = DispatchSource.makeFileSystemObjectSource( fileDescriptor: fileDescriptor, eventMask: .all, - queue: .global() + queue: .main ) dispatchSource?.setEventHandler { [weak self] in - Task { @MainActor in - self?.loadDocuments() - } + self?.loadDocuments() } dispatchSource?.setCancelHandler { diff --git a/Playground/project.yml b/Playground/project.yml index 68c73519d5..25401c4664 100644 --- a/Playground/project.yml +++ b/Playground/project.yml @@ -30,6 +30,7 @@ targets: - package: Readium product: ReadiumOPDS settings: + SWIFT_VERSION: 6.0 SWIFT_APPROACHABLE_CONCURRENCY: Yes SWIFT_DEFAULT_ACTOR_ISOLATION: MainActor diff --git a/Sources/Internal/Extensions/Task.swift b/Sources/Internal/Extensions/Task.swift index 46fa09859c..260826a807 100644 --- a/Sources/Internal/Extensions/Task.swift +++ b/Sources/Internal/Extensions/Task.swift @@ -6,32 +6,6 @@ import Foundation -@MainActor -public final class CancellableTasks: Sendable { - private var tasks: Set> = [] - - public nonisolated init() {} - - public nonisolated func add(@_implicitSelfCapture _ task: @Sendable @escaping () async -> Void) { - Task { - await add(task) - } - } - - public func add(@_implicitSelfCapture _ task: @Sendable @escaping () async -> Void) async { - let task = Task(operation: task) - tasks.insert(task) - _ = await task.value - tasks.remove(task) - } - - deinit { - for task in tasks { - task.cancel() - } - } -} - public extension Task where Success == Never, Failure == Never { static func sleep(seconds: TimeInterval) async throws { try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) diff --git a/Sources/LCP/Authentications/LCPDialog.swift b/Sources/LCP/Authentications/LCPDialog.swift index 6922aadd9b..b961ee01b6 100644 --- a/Sources/LCP/Authentications/LCPDialog.swift +++ b/Sources/LCP/Authentications/LCPDialog.swift @@ -4,6 +4,8 @@ // available in the top-level LICENSE file of the project. // +import ReadiumInternal +import ReadiumShared import SwiftUI /// A SwiftUI dialog used to prompt the user for its LCP passphrase. @@ -117,7 +119,8 @@ public struct LCPDialog: View, Sendable { .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { _ in // Wait for the @StateFocus animation to settle before // scrolling, otherwise it won't work. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + Task { + try? await Task.sleep(seconds: 0.5) withAnimation { scrollProxy.scrollTo(openButtonId, anchor: .bottom) } diff --git a/Sources/LCP/Authentications/LCPDialogViewController.swift b/Sources/LCP/Authentications/LCPDialogViewController.swift index e5c740629f..4afd39691a 100644 --- a/Sources/LCP/Authentications/LCPDialogViewController.swift +++ b/Sources/LCP/Authentications/LCPDialogViewController.swift @@ -4,6 +4,8 @@ // available in the top-level LICENSE file of the project. // +import ReadiumInternal +import ReadiumShared import SwiftUI import UIKit diff --git a/Sources/LCP/Authentications/LCPObservableAuthentication.swift b/Sources/LCP/Authentications/LCPObservableAuthentication.swift index 70cbc8be64..254a5e2e19 100644 --- a/Sources/LCP/Authentications/LCPObservableAuthentication.swift +++ b/Sources/LCP/Authentications/LCPObservableAuthentication.swift @@ -4,6 +4,7 @@ // available in the top-level LICENSE file of the project. // +import Combine import SwiftUI /// An ``LCPAuthenticating`` implementation which can be used to observe diff --git a/Sources/LCP/Content Protection/EncryptionParser.swift b/Sources/LCP/Content Protection/EncryptionParser.swift index 191d61394c..2f661a3629 100644 --- a/Sources/LCP/Content Protection/EncryptionParser.swift +++ b/Sources/LCP/Content Protection/EncryptionParser.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal import ReadiumShared func parseEncryptionData(in asset: ContainerAsset) async -> ReadResult<[AnyURL: ReadiumShared.Encryption]> { diff --git a/Sources/LCP/Content Protection/LCPContentProtection.swift b/Sources/LCP/Content Protection/LCPContentProtection.swift index f2193bb6d1..0a9a788c31 100644 --- a/Sources/LCP/Content Protection/LCPContentProtection.swift +++ b/Sources/LCP/Content Protection/LCPContentProtection.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal import ReadiumShared final class LCPContentProtection: ContentProtection, Loggable { diff --git a/Sources/LCP/Content Protection/LCPDecryptor.swift b/Sources/LCP/Content Protection/LCPDecryptor.swift index f6b1ac28a1..ed74093967 100644 --- a/Sources/LCP/Content Protection/LCPDecryptor.swift +++ b/Sources/LCP/Content Protection/LCPDecryptor.swift @@ -121,7 +121,7 @@ final class LCPDecryptor: Sendable { await plainTextSize() } - func stream(range: Range?, consume: @escaping @Sendable (Data) -> Void) async -> ReadResult { + @concurrent func stream(range: Range?, consume: @escaping @Sendable (Data) -> Void) async -> ReadResult { guard let range = range else { return await license.decryptFully(data: resource.read(), isDeflated: encryption.isDeflated) .map { @@ -203,7 +203,7 @@ private extension LCPLicense { /// /// - Returns: The decrypted content length in bytes, or a failure if /// the resource is not a valid CBC chunk or cannot be deciphered. - func plainTextSizeOfCBCResource(_ resource: Resource) async -> ReadResult { + @concurrent func plainTextSizeOfCBCResource(_ resource: Resource) async -> ReadResult { await resource.estimatedLength().asyncFlatMap { length in guard let length = length else { return .failure(.decoding(LCPDecryptor.Error.requiredEstimatedLength)) @@ -235,7 +235,7 @@ private extension LCPLicense { } } - func decryptFully(data: ReadResult, isDeflated: Bool) async -> ReadResult { + @concurrent func decryptFully(data: ReadResult, isDeflated: Bool) async -> ReadResult { data.flatMap { guard UInt64($0.count).isValidAESChunk else { return .failure(.decoding(LCPDecryptor.Error.invalidCBCData)) diff --git a/Sources/LCP/LCPRenewDelegate.swift b/Sources/LCP/LCPRenewDelegate.swift index 84f82f51c7..6771744036 100644 --- a/Sources/LCP/LCPRenewDelegate.swift +++ b/Sources/LCP/LCPRenewDelegate.swift @@ -45,6 +45,15 @@ public final class LCPDefaultRenewDelegate: NSObject, LCPRenewDelegate { @MainActor public func presentWebPage(url: HTTPURL) async throws { await withCheckedContinuation { continuation in + guard presentingViewController.presentedViewController == nil else { + // `present(_:animated:)` would fail silently and neither + // delegate callback would ever fire, leaving the caller + // suspended forever. + continuation.resume(returning: ()) + return + } + + webPageContinuation?.resume(returning: ()) webPageContinuation = continuation let safariVC = SFSafariViewController(url: url.url) diff --git a/Sources/LCP/License/License.swift b/Sources/LCP/License/License.swift index 854f3d7383..953243dbc5 100644 --- a/Sources/LCP/License/License.swift +++ b/Sources/LCP/License/License.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal import ReadiumShared final class License: Loggable, Sendable { diff --git a/Sources/LCP/License/LicenseValidation.swift b/Sources/LCP/License/LicenseValidation.swift index 6937bd4e1d..68d9bd3b23 100644 --- a/Sources/LCP/License/LicenseValidation.swift +++ b/Sources/LCP/License/LicenseValidation.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal import ReadiumShared typealias Context = Result @@ -41,6 +42,8 @@ actor LicenseValidation: Loggable { fileprivate let onLicenseValidated: @Sendable (LicenseDocument) async throws -> Void + private let tasks = CancellableTasks() + /// Current state in the validation steps. private(set) var state: State = .start { didSet { @@ -409,26 +412,28 @@ extension LicenseValidation { } nonisolated func observe(_ policy: ObserverPolicy = .always, _ observer: @escaping Observer) { - Task { - // If the state is already valid or a failure, we notify it to the observer right away. - var notified = true - switch await state { - case let .valid(documents): - observer(.success(documents)) - case let .failure(error): - observer(.failure(error)) - default: - notified = false - } - - guard !notified || policy == .always else { - return - } - await addObserver(observer, policy: policy) + tasks.add { + await register(observer, policy: policy) } } - private func addObserver(_ observer: @escaping Observer, policy: ObserverPolicy) { + /// Atomically checks the current state and registers the observer, so it + /// cannot miss a notification fired between the two. + private func register(_ observer: @escaping Observer, policy: ObserverPolicy) { + // If the state is already valid or a failure, we notify it to the observer right away. + var notified = true + switch state { + case let .valid(documents): + observer(.success(documents)) + case let .failure(error): + observer(.failure(error)) + default: + notified = false + } + + guard !notified || policy == .always else { + return + } observers.append((observer, policy)) } diff --git a/Sources/Navigator/Audiobook/Preferences/AudioPreferencesEditor.swift b/Sources/Navigator/Audiobook/Preferences/AudioPreferencesEditor.swift index 3a92538b14..359956ff57 100644 --- a/Sources/Navigator/Audiobook/Preferences/AudioPreferencesEditor.swift +++ b/Sources/Navigator/Audiobook/Preferences/AudioPreferencesEditor.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal /// Editor for a set of `AudioPreferences`. /// diff --git a/Sources/Navigator/DirectionalNavigationAdapter.swift b/Sources/Navigator/DirectionalNavigationAdapter.swift index cbd5d85f54..92d50b71c8 100644 --- a/Sources/Navigator/DirectionalNavigationAdapter.swift +++ b/Sources/Navigator/DirectionalNavigationAdapter.swift @@ -6,6 +6,7 @@ import CoreGraphics import Foundation +import UIKit /// Helper handling directional UI events (e.g. edge taps or arrow keys) to turn /// the pages of a `VisualNavigator`. diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift index 12577bcbc7..e45a69c928 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift @@ -1020,7 +1020,7 @@ extension EPUBNavigatorViewController: EPUBNavigatorViewModelDelegate { didFailToLoadResourceAt href: RelativeURL, withError error: ReadError ) { - DispatchQueue.main.async { + Task { @MainActor in self.delegate?.navigator(self, didFailToLoadResourceAt: href, withError: error) } } diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift index 9a777c3744..c7db21dd05 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal import ReadiumShared import UIKit @@ -162,7 +163,7 @@ enum EPUBScriptScope { return } needsInvalidatePagination = true - DispatchQueue.main.async { [self] in + Task { @MainActor [self] in needsInvalidatePagination = false delegate?.epubNavigatorViewModelInvalidatePaginationView(self) } diff --git a/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift b/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift index abdde9284f..0efd18a762 100644 --- a/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift +++ b/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift @@ -201,7 +201,7 @@ final class EPUBReflowableSpreadView: EPUBSpreadView { if options.animated { // Waits for the scroll animation to finish. await withCheckedContinuation { continuation in - let request = ScrollAnimationRequest(continuation) + let request = CompletionRequest(continuation) pendingScrollAnimation?.resume() pendingScrollAnimation = request @@ -249,7 +249,17 @@ final class EPUBReflowableSpreadView: EPUBSpreadView { private func waitGoToCompletion() async { await withCheckedContinuation { continuation in - goToContinuations.append(continuation) + let request = CompletionRequest(continuation) + goToContinuations.append(request) + + // Safety net in case the spread never finishes loading (e.g. the + // resource fails to load) and `didCompleteGoTo()` never fires. + // `CompletionRequest.resume()` is idempotent and cancels this + // timeout, so a normal completion beats the timeout harmlessly. + request.timeoutTask = Task { @MainActor in + try? await Task.sleep(seconds: 5.0) + request.resume() + } } } @@ -260,15 +270,19 @@ final class EPUBReflowableSpreadView: EPUBSpreadView { goToContinuations.removeAll() } - private var goToContinuations: [CheckedContinuation] = [] + private var goToContinuations: [CompletionRequest] = [] - private var pendingScrollAnimation: ScrollAnimationRequest? + private var pendingScrollAnimation: CompletionRequest? - /// Represents an in-flight animated page turn, waiting for the scroll - /// animation to settle before completing. - private class ScrollAnimationRequest { + /// Represents an in-flight operation (animated page turn, pending go-to) + /// waiting for a completion signal before resuming its awaiter. + private class CompletionRequest { private var continuation: CheckedContinuation? + /// Optional safety-net timeout that resumes this request if the + /// expected completion signal never arrives. Cancelled on `resume()`. + var timeoutTask: Task? + init(_ continuation: CheckedContinuation) { self.continuation = continuation } @@ -278,10 +292,12 @@ final class EPUBReflowableSpreadView: EPUBSpreadView { func resume() { continuation?.resume() continuation = nil + timeoutTask?.cancel() + timeoutTask = nil } } - private func scrollDidEnd(for request: ScrollAnimationRequest? = nil) { + private func scrollDidEnd(for request: CompletionRequest? = nil) { guard request == nil || pendingScrollAnimation === request else { return } diff --git a/Sources/Navigator/EPUB/EPUBSpread.swift b/Sources/Navigator/EPUB/EPUBSpread.swift index e067390383..75843fc431 100644 --- a/Sources/Navigator/EPUB/EPUBSpread.swift +++ b/Sources/Navigator/EPUB/EPUBSpread.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal import ReadiumShared /// Common interface for spread types. diff --git a/Sources/Navigator/EPUB/EPUBSpreadView.swift b/Sources/Navigator/EPUB/EPUBSpreadView.swift index d53a979a21..f0c61b9cdc 100644 --- a/Sources/Navigator/EPUB/EPUBSpreadView.swift +++ b/Sources/Navigator/EPUB/EPUBSpreadView.swift @@ -4,6 +4,7 @@ // available in the top-level LICENSE file of the project. // +import ReadiumInternal import ReadiumShared @preconcurrency import WebKit @@ -54,7 +55,7 @@ class EPUBSpreadView: UIView, Loggable, PageView { let animatedLoad: Bool weak var activityIndicatorView: UIActivityIndicatorView? - private var activityIndicatorStopWorkItem: DispatchWorkItem? + private var activityIndicatorStopTask: Task? /// Set once the spread's DOM is loaded and its subclass may operate on it /// (e.g. to scroll to a pending location). Note that decoration templates @@ -397,7 +398,12 @@ class EPUBSpreadView: UIView, Loggable, PageView { } } - private func spreadLoadDidStart(_ body: Any) {} + private func spreadLoadDidStart(_ body: Any) { + // The spread began loading, so we cancel the safety-net task that would + // otherwise stop the activity indicator after 2 seconds. The indicator + // is stopped once the spread is fully loaded, in `showSpread()`. + activityIndicatorStopTask.cancel() + } /// Called by the javascript code when the spread contents is fully loaded. /// The JS message `spreadLoaded` needs to be emitted by a subclass script, EPUBSpreadView's scripts don't. @@ -442,7 +448,7 @@ class EPUBSpreadView: UIView, Loggable, PageView { func showSpread() { activityIndicatorView?.stopAnimating() - activityIndicatorStopWorkItem?.cancel() + activityIndicatorStopTask.cancel() UIView.animate(withDuration: animatedLoad ? 0.3 : 0, animations: { self.scrollView.alpha = 1 }) @@ -723,19 +729,24 @@ private extension EPUBSpreadView { } private func setNeedsStopActivityIndicator() { - guard activityIndicatorStopWorkItem == nil else { + guard activityIndicatorStopTask == nil else { return } - activityIndicatorStopWorkItem = DispatchWorkItem { [weak self] in + // If the spread doesn't begin loading within 2 seconds it means that + // we likely encountered an error. In that case the task we start + // below will stop the activity indicator. + // If the spread begins to load it will send a `spreadLoadStart` JS + // event which will cancel this task. + trace("scheduling activity indicator stop") + activityIndicatorStopTask = Task { [weak self] in defer { - self?.activityIndicatorStopWorkItem = nil + self?.activityIndicatorStopTask = nil } guard - let self = self, - let workItem = activityIndicatorStopWorkItem, - !workItem.isCancelled + await (try? Task.sleep(seconds: 2)) != nil, + let self = self else { return } @@ -743,17 +754,6 @@ private extension EPUBSpreadView { trace("stopping activity indicator because spread \(spread.first.link.href) did not load") activityIndicatorView?.stopAnimating() } - - // If the spread doesn't begin loading within 2 seconds it means that we - // likely encountered an error. In that case the work item we - // schedule below will stop the activity indicator. - // If the spread begins to load it will send a `spreadLoadStart` JS - // event which will cancel the work item being scheduled here. - trace("scheduling activity indicator stop") - DispatchQueue.main.asyncAfter( - deadline: .now() + 2, - execute: activityIndicatorStopWorkItem! - ) } } diff --git a/Sources/Navigator/EPUB/EPUBViewportAndLocationCalculator.swift b/Sources/Navigator/EPUB/EPUBViewportAndLocationCalculator.swift index 1320dd3ffb..5575b90487 100644 --- a/Sources/Navigator/EPUB/EPUBViewportAndLocationCalculator.swift +++ b/Sources/Navigator/EPUB/EPUBViewportAndLocationCalculator.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal import ReadiumShared /// Computes the current `Locator` and `Viewport` from a spread's visible diff --git a/Sources/Navigator/EPUB/Preferences/EPUBPreferences+Legacy.swift b/Sources/Navigator/EPUB/Preferences/EPUBPreferences+Legacy.swift index b70a9af8d4..8918e83983 100644 --- a/Sources/Navigator/EPUB/Preferences/EPUBPreferences+Legacy.swift +++ b/Sources/Navigator/EPUB/Preferences/EPUBPreferences+Legacy.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal import ReadiumShared public extension EPUBPreferences { diff --git a/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift b/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift index 248620fd53..27129ff2fc 100644 --- a/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift +++ b/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal import ReadiumShared /// Preferences for the `EPUBNavigatorViewController`. diff --git a/Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift b/Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift index 986a05af65..56d3e4b6ce 100644 --- a/Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift +++ b/Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal import ReadiumShared /// Editor for a set of `EPUBPreferences`. diff --git a/Sources/Navigator/PDF/PDFNavigatorViewController.swift b/Sources/Navigator/PDF/PDFNavigatorViewController.swift index 73906ac7ac..59ddf67b6a 100644 --- a/Sources/Navigator/PDF/PDFNavigatorViewController.swift +++ b/Sources/Navigator/PDF/PDFNavigatorViewController.swift @@ -6,6 +6,7 @@ import Foundation @preconcurrency import PDFKit +import ReadiumInternal import ReadiumShared import UIKit diff --git a/Sources/Navigator/PDF/PDFPageNumberResolver.swift b/Sources/Navigator/PDF/PDFPageNumberResolver.swift index 064dc31793..83bc204a07 100644 --- a/Sources/Navigator/PDF/PDFPageNumberResolver.swift +++ b/Sources/Navigator/PDF/PDFPageNumberResolver.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal import ReadiumShared /// Resolves a PDF page number from a `Locator`. diff --git a/Sources/Navigator/PDF/PDFViewportCalculator.swift b/Sources/Navigator/PDF/PDFViewportCalculator.swift index 1928b1bc17..8efb5bcc93 100644 --- a/Sources/Navigator/PDF/PDFViewportCalculator.swift +++ b/Sources/Navigator/PDF/PDFViewportCalculator.swift @@ -4,6 +4,7 @@ // available in the top-level LICENSE file of the project. // +import ReadiumInternal import ReadiumShared /// Computes the current `Locator` and `NavigatorViewport` from the focused and diff --git a/Sources/Navigator/Preferences/MappedPreference.swift b/Sources/Navigator/Preferences/MappedPreference.swift index 02744d8d50..c284e8fb32 100644 --- a/Sources/Navigator/Preferences/MappedPreference.swift +++ b/Sources/Navigator/Preferences/MappedPreference.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal public extension Preference { /// Creates a new `Preference` object wrapping the receiver and converting diff --git a/Sources/Navigator/Preferences/ProgressionStrategy.swift b/Sources/Navigator/Preferences/ProgressionStrategy.swift index 3eee3e6132..f3d8c59cac 100644 --- a/Sources/Navigator/Preferences/ProgressionStrategy.swift +++ b/Sources/Navigator/Preferences/ProgressionStrategy.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal /// A strategy to increment or decrement a setting. public protocol ProgressionStrategy: Sendable { diff --git a/Sources/Navigator/Preferences/ProxyPreference.swift b/Sources/Navigator/Preferences/ProxyPreference.swift index 838264f01a..9c224fd457 100644 --- a/Sources/Navigator/Preferences/ProxyPreference.swift +++ b/Sources/Navigator/Preferences/ProxyPreference.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal public class ProxyPreference: Preference { private let _value: () -> Value? diff --git a/Sources/Navigator/Preferences/Types.swift b/Sources/Navigator/Preferences/Types.swift index a91d145cdf..b2380551be 100644 --- a/Sources/Navigator/Preferences/Types.swift +++ b/Sources/Navigator/Preferences/Types.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal import ReadiumShared import UIKit diff --git a/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift b/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift index 0d55840915..adbff1c898 100644 --- a/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift +++ b/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift @@ -6,6 +6,7 @@ import AVFoundation import Foundation +import ReadiumInternal import ReadiumShared public protocol PublicationSpeechSynthesizerDelegate: AnyObject { diff --git a/Sources/Navigator/TTS/TTSVoice.swift b/Sources/Navigator/TTS/TTSVoice.swift index a2f36a7034..bc9dcc0939 100644 --- a/Sources/Navigator/TTS/TTSVoice.swift +++ b/Sources/Navigator/TTS/TTSVoice.swift @@ -6,6 +6,7 @@ import AVFoundation import Foundation +import ReadiumInternal import ReadiumShared /// Represents a voice provided by the TTS engine which can speak an utterance. diff --git a/Sources/Navigator/Toolkit/PaginationView.swift b/Sources/Navigator/Toolkit/PaginationView.swift index ce3a850085..73b6b988d9 100644 --- a/Sources/Navigator/Toolkit/PaginationView.swift +++ b/Sources/Navigator/Toolkit/PaginationView.swift @@ -4,6 +4,7 @@ // available in the top-level LICENSE file of the project. // +import ReadiumInternal import ReadiumShared import UIKit diff --git a/Sources/Navigator/Viewport/ViewportProgressionCalculator.swift b/Sources/Navigator/Viewport/ViewportProgressionCalculator.swift index b88310335c..16bf1d1f4a 100644 --- a/Sources/Navigator/Viewport/ViewportProgressionCalculator.swift +++ b/Sources/Navigator/Viewport/ViewportProgressionCalculator.swift @@ -4,6 +4,7 @@ // available in the top-level LICENSE file of the project. // +import ReadiumInternal import ReadiumShared /// Computes total publication progression from resource-level progressions and diff --git a/Sources/OPDS/OPDS1Parser.swift b/Sources/OPDS/OPDS1Parser.swift index 6f8384f7b6..79468f0024 100644 --- a/Sources/OPDS/OPDS1Parser.swift +++ b/Sources/OPDS/OPDS1Parser.swift @@ -6,6 +6,7 @@ import Foundation import ReadiumFuzi +import ReadiumInternal import ReadiumShared public enum OPDS1ParserError: Error, Sendable { diff --git a/Sources/OPDS/OPDS2Parser.swift b/Sources/OPDS/OPDS2Parser.swift index 8968586fa6..4c9caeca34 100644 --- a/Sources/OPDS/OPDS2Parser.swift +++ b/Sources/OPDS/OPDS2Parser.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal import ReadiumShared public enum OPDS2ParserError: Error, Sendable { diff --git a/Sources/Shared/Publication/Services/Content/Content.swift b/Sources/Shared/Publication/Services/Content/Content.swift index c9f1936757..a131c5a878 100644 --- a/Sources/Shared/Publication/Services/Content/Content.swift +++ b/Sources/Shared/Publication/Services/Content/Content.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal /// Provides an iterable list of `ContentElement`s. public protocol Content { @@ -20,7 +21,11 @@ public extension Content { /// Returns all the elements as a list. func elements() async -> [ContentElement] { - await sequence().reduce(into: [ContentElement]()) { $0.append($1) } + var elements: [ContentElement] = [] + for await element in sequence() { + elements.append(element) + } + return elements } /// Extracts the full raw text, or returns null if no text content can be found. diff --git a/Sources/Shared/Publication/Services/Content/ContentTokenizer.swift b/Sources/Shared/Publication/Services/Content/ContentTokenizer.swift index 7445039461..789c008bd5 100644 --- a/Sources/Shared/Publication/Services/Content/ContentTokenizer.swift +++ b/Sources/Shared/Publication/Services/Content/ContentTokenizer.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal /// A tokenizer splitting a `ContentElement` into smaller pieces. public typealias ContentTokenizer = Tokenizer diff --git a/Sources/Shared/Publication/Services/Content/Iterators/PDFResourceContentIterator.swift b/Sources/Shared/Publication/Services/Content/Iterators/PDFResourceContentIterator.swift index ff154056c7..bba68e5c35 100644 --- a/Sources/Shared/Publication/Services/Content/Iterators/PDFResourceContentIterator.swift +++ b/Sources/Shared/Publication/Services/Content/Iterators/PDFResourceContentIterator.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal public enum PDFResourceContentIteratorError: Error, Sendable { /// The publication must have a ``PDFDocumentService`` to open the document. diff --git a/Sources/Shared/Publication/Services/Content/Iterators/PublicationContentIterator.swift b/Sources/Shared/Publication/Services/Content/Iterators/PublicationContentIterator.swift index d8a1bbbdb1..527f813cbf 100644 --- a/Sources/Shared/Publication/Services/Content/Iterators/PublicationContentIterator.swift +++ b/Sources/Shared/Publication/Services/Content/Iterators/PublicationContentIterator.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal public protocol ResourceContentIteratorFactory: Sendable { /// Creates a `ContentIterator` instance for the `resource`, starting from diff --git a/Sources/Shared/Publication/Services/Cover/GeneratedCoverService.swift b/Sources/Shared/Publication/Services/Cover/GeneratedCoverService.swift index 1a32070936..5a80b4d005 100644 --- a/Sources/Shared/Publication/Services/Cover/GeneratedCoverService.swift +++ b/Sources/Shared/Publication/Services/Cover/GeneratedCoverService.swift @@ -70,7 +70,7 @@ public final class GeneratedCoverService: CoverService, Sendable { .success(ResourceProperties()) } - func stream(range: Range?, consume: @escaping @Sendable (Data) -> Void) async -> ReadResult { + @concurrent func stream(range: Range?, consume: @escaping @Sendable (Data) -> Void) async -> ReadResult { await cover().flatMap { guard let data = $0.pngData() else { return .failure(.decoding("Failed to convert the cover bitmap to PNG data")) diff --git a/Sources/Shared/Publication/Services/Locator/DefaultLocatorService.swift b/Sources/Shared/Publication/Services/Locator/DefaultLocatorService.swift index df4d8d151d..b59edd5f21 100644 --- a/Sources/Shared/Publication/Services/Locator/DefaultLocatorService.swift +++ b/Sources/Shared/Publication/Services/Locator/DefaultLocatorService.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal /// A default implementation of the `LocatorService` using the `PositionsService` to locate its inputs. public final class DefaultLocatorService: Sendable, LocatorService, Loggable { diff --git a/Sources/Shared/Publication/Services/Search/SearchService.swift b/Sources/Shared/Publication/Services/Search/SearchService.swift index 319317b5be..9c277e9709 100644 --- a/Sources/Shared/Publication/Services/Search/SearchService.swift +++ b/Sources/Shared/Publication/Services/Search/SearchService.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal public typealias SearchServiceFactory = @Sendable (PublicationServiceContext) -> SearchService? diff --git a/Sources/Shared/Publication/Services/Search/StringSearchAlgorithm.swift b/Sources/Shared/Publication/Services/Search/StringSearchAlgorithm.swift index 747cdedf1b..be33d323a8 100644 --- a/Sources/Shared/Publication/Services/Search/StringSearchAlgorithm.swift +++ b/Sources/Shared/Publication/Services/Search/StringSearchAlgorithm.swift @@ -33,7 +33,7 @@ public final class BasicStringSearchAlgorithm: StringSearchAlgorithm { public init() {} - public func findRanges( + @concurrent public func findRanges( of query: String, options: SearchOptions, in text: String, diff --git a/Sources/Shared/Toolkit/AsyncMemoizer.swift b/Sources/Shared/Toolkit/AsyncMemoizer.swift index 6d14cbfe0c..5a7c7e3614 100644 --- a/Sources/Shared/Toolkit/AsyncMemoizer.swift +++ b/Sources/Shared/Toolkit/AsyncMemoizer.swift @@ -28,7 +28,7 @@ package actor AsyncMemoizer { if let task { return await task.value } - let newTask = Task(operation: compute) + let newTask = Task { await compute() } task = newTask return await newTask.value } diff --git a/Sources/Shared/Toolkit/CancellableTasks.swift b/Sources/Shared/Toolkit/CancellableTasks.swift new file mode 100644 index 0000000000..a3cf040ac8 --- /dev/null +++ b/Sources/Shared/Toolkit/CancellableTasks.swift @@ -0,0 +1,41 @@ +// +// Copyright 2026 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 + +/// Holds a set of tasks whose lifetime is bound to this instance: any task +/// still running when the instance is deallocated gets cancelled. +package final class CancellableTasks: Sendable { + private let tasks = Mutex<[UUID: Task]>([:]) + + package init() {} + + package func add(@_implicitSelfCapture _ operation: @Sendable @escaping () async -> Void) { + let id = UUID() + tasks.withLock { + // The task is registered while holding the lock, so its + // self-removal cannot run before the registration. + $0[id] = Task { [weak self] in + await operation() + self?.remove(id) + } + } + } + + private func remove(_ id: UUID) { + tasks.withLock { + $0.removeValue(forKey: id) + } + } + + deinit { + tasks.withLock { + for task in $0.values { + task.cancel() + } + } + } +} diff --git a/Sources/Shared/Toolkit/Data/Asset/AssetRetriever.swift b/Sources/Shared/Toolkit/Data/Asset/AssetRetriever.swift index 093904a71d..3aabd68358 100644 --- a/Sources/Shared/Toolkit/Data/Asset/AssetRetriever.swift +++ b/Sources/Shared/Toolkit/Data/Asset/AssetRetriever.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal /// Error while trying to retrieve an asset from a ``Resource`` or a /// ``Container``. diff --git a/Sources/Shared/Toolkit/Data/Container/Container.swift b/Sources/Shared/Toolkit/Data/Container/Container.swift index c37e074a08..31d2cfa8aa 100644 --- a/Sources/Shared/Toolkit/Data/Container/Container.swift +++ b/Sources/Shared/Toolkit/Data/Container/Container.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal /// A container provides access to a list of `Resource` entries. public protocol Container: Sendable { diff --git a/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift b/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift index 5b45a18fa2..d949193aa8 100644 --- a/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift @@ -60,6 +60,10 @@ public actor BufferingResource: Resource, Loggable { range: Range?, consume: @escaping @Sendable (Data) -> Void ) async -> ReadResult { + guard !Task.isCancelled else { + return .failure(.cancelled) + } + // Reading the whole resource bypasses buffering to keep things simple. guard let requestedRange = range, !requestedRange.isEmpty else { return await resource.stream(range: range, consume: consume) diff --git a/Sources/Shared/Toolkit/Data/Resource/DataResource.swift b/Sources/Shared/Toolkit/Data/Resource/DataResource.swift index c3943c2585..738a109ead 100644 --- a/Sources/Shared/Toolkit/Data/Resource/DataResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/DataResource.swift @@ -10,11 +10,11 @@ import Foundation public actor DataResource: Resource { public let sourceURL: AbsoluteURL? - private let makeData: @Sendable () async -> ReadResult + private let makeData: () async -> ReadResult /// Creates a `Resource` serving an array of bytes. public init( - data: @autoclosure @escaping @Sendable () -> Data, + data: sending @autoclosure @escaping () -> Data, sourceURL: AbsoluteURL? = nil ) { self.init(sourceURL: sourceURL) { @@ -34,7 +34,7 @@ public actor DataResource: Resource { /// Creates a `Resource` serving an array of bytes. public init( sourceURL: AbsoluteURL? = nil, - makeData: @escaping @Sendable () async -> ReadResult + makeData: sending @escaping () async -> ReadResult ) { self.makeData = makeData self.sourceURL = sourceURL diff --git a/Sources/Shared/Toolkit/Data/Resource/ResourceContentExtractor.swift b/Sources/Shared/Toolkit/Data/Resource/ResourceContentExtractor.swift index dbdd299d2f..22a3355ed6 100644 --- a/Sources/Shared/Toolkit/Data/Resource/ResourceContentExtractor.swift +++ b/Sources/Shared/Toolkit/Data/Resource/ResourceContentExtractor.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal import SwiftSoup /// Extracts pure content from a marked-up (e.g. HTML) or binary (e.g. PDF) resource. @@ -46,7 +47,7 @@ public typealias _DefaultResourceContentExtractorFactory = DefaultResourceConten final class HTMLResourceContentExtractor: ResourceContentExtractor { private let xmlFactory = DefaultXMLDocumentFactory() - func extractText(of resource: Resource) async -> ReadResult { + @concurrent func extractText(of resource: Resource) async -> ReadResult { await resource.read() .asString() .asyncFlatMap { content in diff --git a/Sources/Shared/Toolkit/Data/Resource/TransformingResource.swift b/Sources/Shared/Toolkit/Data/Resource/TransformingResource.swift index ed10690f55..7c010186e6 100644 --- a/Sources/Shared/Toolkit/Data/Resource/TransformingResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/TransformingResource.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal /// Transforms the bytes of `resource` on-the-fly. /// @@ -43,7 +44,7 @@ public final class TransformingResource: Resource { await resource.properties() } - public func stream(range: Range?, consume: @escaping @Sendable (Data) -> Void) async -> ReadResult { + @concurrent public func stream(range: Range?, consume: @escaping @Sendable (Data) -> Void) async -> ReadResult { await data().map { data in if let range = range?.clamped(to: 0 ..< UInt64(data.count)) { consume(data[range]) diff --git a/Sources/Shared/Toolkit/Data/Streamable.swift b/Sources/Shared/Toolkit/Data/Streamable.swift index 5b3df3c361..b2cb98ba2b 100644 --- a/Sources/Shared/Toolkit/Data/Streamable.swift +++ b/Sources/Shared/Toolkit/Data/Streamable.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal /// Acts as a proxy to an actual data source by handling read access. public protocol Streamable: Sendable { @@ -26,6 +27,10 @@ public protocol Streamable: Sendable { /// a sub-range). Do not assume zero-based indexing: index relative to /// `chunk.startIndex`, or rebase with `Data(chunk)` before accessing /// bytes by position. + /// + /// Implementations must cooperate with task cancellation: check + /// `Task.isCancelled` between chunks (at minimum when entering the + /// method) and fail with `ReadError.cancelled`. func stream( range: Range?, consume: @escaping @Sendable (Data) -> Void @@ -38,7 +43,6 @@ public extension Streamable { /// - Parameters: /// - consume: Callback called for each chunk of data received. Callers /// are responsible to accumulate the data if needed. - // FIXME: Task cancellation func stream(consume: @escaping @Sendable (Data) -> Void) async -> ReadResult { await stream(range: nil, consume: consume) } diff --git a/Sources/Shared/Toolkit/File/FileResource.swift b/Sources/Shared/Toolkit/File/FileResource.swift index 524883a167..c96cb33e42 100644 --- a/Sources/Shared/Toolkit/File/FileResource.swift +++ b/Sources/Shared/Toolkit/File/FileResource.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal /// Creates a `Resource` serving the contents of a local file. public actor FileResource: Resource, Loggable { @@ -43,7 +44,11 @@ public actor FileResource: Resource, Loggable { } public func stream(range: Range?, consume: @escaping @Sendable (Data) -> Void) async -> ReadResult { - await handle().flatMap { handle in + guard !Task.isCancelled else { + return .failure(.cancelled) + } + + return await handle().flatMap { handle in do { if var range = range { range = range.clampedToInt() diff --git a/Sources/Shared/Toolkit/Format/FormatSniffer.swift b/Sources/Shared/Toolkit/Format/FormatSniffer.swift index 971a985bc2..0504d47ead 100644 --- a/Sources/Shared/Toolkit/Format/FormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/FormatSniffer.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal public protocol HintsFormatSniffer: Sendable { /// Tries to guess a `Format` from media type and file extension hints. diff --git a/Sources/Shared/Toolkit/Format/FormatSnifferBlob.swift b/Sources/Shared/Toolkit/Format/FormatSnifferBlob.swift index b1c7393132..5a8284ac50 100644 --- a/Sources/Shared/Toolkit/Format/FormatSnifferBlob.swift +++ b/Sources/Shared/Toolkit/Format/FormatSnifferBlob.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal public actor FormatSnifferBlob { private let source: Streamable diff --git a/Sources/Shared/Toolkit/Format/Sniffers/CompositeFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/CompositeFormatSniffer.swift index 449e221c7c..7dbf81be59 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/CompositeFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/CompositeFormatSniffer.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal public final class CompositeFormatSniffer: FormatSniffer { private let sniffers: [FormatSniffer] diff --git a/Sources/Shared/Toolkit/Format/Sniffers/EPUBFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/EPUBFormatSniffer.swift index 89e6947504..d63505bc96 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/EPUBFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/EPUBFormatSniffer.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal /// Sniffs an EPUB publication. /// diff --git a/Sources/Shared/Toolkit/Format/Sniffers/HTMLFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/HTMLFormatSniffer.swift index bb53a64117..1ba558ea4d 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/HTMLFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/HTMLFormatSniffer.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal /// Sniffs an HTML or XHTML document. public struct HTMLFormatSniffer: FormatSniffer, Sendable { diff --git a/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift b/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift index 7c84baa2a2..b0e82dd658 100644 --- a/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift +++ b/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal public enum URLAuthenticationChallengeResponse: Sendable { /// Use the specified credential. @@ -195,7 +196,7 @@ public final class DefaultHTTPClient: HTTPClient, Loggable { session.invalidateAndCancel() } - public func stream( + @concurrent public func stream( _ request: any HTTPRequestConvertible, onReceiveResponse: (@Sendable (HTTPResponse) async -> HTTPResult)? = nil, consume: @Sendable (Data, Double?) -> HTTPResult diff --git a/Sources/Shared/Toolkit/HTTP/HTTPClient.swift b/Sources/Shared/Toolkit/HTTP/HTTPClient.swift index 0db0f4d1bb..4af6c0f2af 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPClient.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPClient.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal #if canImport(UIKit) import UIKit #endif diff --git a/Sources/Shared/Toolkit/JSONValue.swift b/Sources/Shared/Toolkit/JSONValue.swift index 338536637a..1d3a8e4529 100644 --- a/Sources/Shared/Toolkit/JSONValue.swift +++ b/Sources/Shared/Toolkit/JSONValue.swift @@ -6,6 +6,7 @@ import CoreFoundation import Foundation +import ReadiumInternal /// A type-safe representation of a JSON value. /// diff --git a/Sources/Shared/Toolkit/Mutex.swift b/Sources/Shared/Toolkit/Mutex.swift index 300730698a..db9965fb7e 100644 --- a/Sources/Shared/Toolkit/Mutex.swift +++ b/Sources/Shared/Toolkit/Mutex.swift @@ -31,6 +31,12 @@ public struct Mutex: ~Copyable, Sendable { /// stable address for the lifetime of the Mutex. @usableFromInline final class Storage: Sendable { + /// Known caveat: passing `&lock` to `os_unfair_lock_lock/unlock` is a + /// formal law-of-exclusivity violation (overlapping `inout` accesses + /// from different threads) that the Thread Sanitizer may flag. The + /// address is stable and the pattern behaves correctly on current + /// runtimes; it is accepted until the replacement planned in the + /// FIXME above. nonisolated(unsafe) var lock = os_unfair_lock() nonisolated(unsafe) var value: Value diff --git a/Sources/Shared/Toolkit/Poller.swift b/Sources/Shared/Toolkit/Poller.swift index 68b689f08e..5411041d6e 100644 --- a/Sources/Shared/Toolkit/Poller.swift +++ b/Sources/Shared/Toolkit/Poller.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal private final class Poller: Sendable { private let condition: @Sendable @MainActor () -> Bool diff --git a/Sources/Shared/Toolkit/Throttle.swift b/Sources/Shared/Toolkit/Throttle.swift index d95f72c8b9..4481d0be38 100644 --- a/Sources/Shared/Toolkit/Throttle.swift +++ b/Sources/Shared/Toolkit/Throttle.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal @MainActor private final class ThrottlerState: Sendable { diff --git a/Sources/Shared/Toolkit/URL/Absolute URL/AbsoluteURL.swift b/Sources/Shared/Toolkit/URL/Absolute URL/AbsoluteURL.swift index 363e166ce8..3893f8b063 100644 --- a/Sources/Shared/Toolkit/URL/Absolute URL/AbsoluteURL.swift +++ b/Sources/Shared/Toolkit/URL/Absolute URL/AbsoluteURL.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal /// A type that can represent an absolute URL with a scheme. public protocol AbsoluteURL: URLProtocol { diff --git a/Sources/Shared/Toolkit/URL/Absolute URL/FileURL.swift b/Sources/Shared/Toolkit/URL/Absolute URL/FileURL.swift index f69927effb..23a74887e1 100644 --- a/Sources/Shared/Toolkit/URL/Absolute URL/FileURL.swift +++ b/Sources/Shared/Toolkit/URL/Absolute URL/FileURL.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal /// Represents an absolute URL with the special scheme `file`. /// diff --git a/Sources/Shared/Toolkit/URL/RelativeURL.swift b/Sources/Shared/Toolkit/URL/RelativeURL.swift index b84f66f76b..7e9f4f3214 100644 --- a/Sources/Shared/Toolkit/URL/RelativeURL.swift +++ b/Sources/Shared/Toolkit/URL/RelativeURL.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal /// Represents a relative URL. public struct RelativeURL: URLProtocol, Hashable { diff --git a/Sources/Shared/Toolkit/Weak.swift b/Sources/Shared/Toolkit/Weak.swift index 9f912538bc..60e29f1979 100644 --- a/Sources/Shared/Toolkit/Weak.swift +++ b/Sources/Shared/Toolkit/Weak.swift @@ -9,7 +9,10 @@ /// Get the reference by calling `weakVar()`. /// Conveniently, the reference can be reset by setting the `ref` property. @dynamicCallable -public final class Weak: Sendable { +public final class Weak: Sendable { + /// Invariant making `nonisolated(unsafe)` sound: `ref` is written exactly + /// once, before the `Weak` instance is shared with other isolation + /// domains (see `Publication.init`), and is read-only afterwards. public package(set) nonisolated(unsafe) weak var ref: T? public init(_ ref: T? = nil) { diff --git a/Sources/Shared/Toolkit/ZIP/Minizip/MinizipContainer.swift b/Sources/Shared/Toolkit/ZIP/Minizip/MinizipContainer.swift index d067974b66..61d6d280e3 100644 --- a/Sources/Shared/Toolkit/ZIP/Minizip/MinizipContainer.swift +++ b/Sources/Shared/Toolkit/ZIP/Minizip/MinizipContainer.swift @@ -6,6 +6,7 @@ import Foundation import Minizip +import ReadiumInternal /// A ZIP ``Container`` using the Minizip library. final class MinizipContainer: Container, Loggable { @@ -97,6 +98,9 @@ private actor MinizipResource: Resource, Loggable { self.metadata = metadata } + /// Closing is best-effort: the underlying file is closed asynchronously, + /// and in any case when this resource is deallocated (see + /// `MinizipFile.deinit`). nonisolated func close() { Task { await doClose() } } @@ -127,6 +131,10 @@ private actor MinizipResource: Resource, Loggable { } func stream(range: Range?, consume: @escaping @Sendable (Data) -> Void) async -> ReadResult { + guard !Task.isCancelled else { + return .failure(.cancelled) + } + let range = range ?? 0 ..< metadata.length return await zipFile().flatMap { zipFile in @@ -323,6 +331,8 @@ private final class MinizipFile { } while totalBytesRead < length { + try Task.checkCancellation() + let bytesToRead = min(UInt64(bufferLength), length - totalBytesRead) var buffer = [CUnsignedChar](repeating: 0, count: Int(bytesToRead)) let bytesRead = UInt64(unzReadCurrentFile(file, &buffer, UInt32(bytesToRead))) diff --git a/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveFactory.swift b/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveFactory.swift index bcba380f81..22a3798215 100644 --- a/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveFactory.swift +++ b/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveFactory.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal import ReadiumZIPFoundation /// The ZIP End of Central Directory Record should be at most 65557 bytes, diff --git a/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift b/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift index 0a207212be..dc78844eac 100644 --- a/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift +++ b/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal import ReadiumZIPFoundation /// A ZIP ``Container`` using the ZIPFoundation library. @@ -105,16 +106,20 @@ private actor ZIPFoundationResource: Resource, Loggable { } func stream(range: Range?, consume: @escaping @Sendable (Data) -> Void) async -> ReadResult { - if range != nil {} + guard !Task.isCancelled else { + return .failure(.cancelled) + } return await archive().asyncFlatMap { archive in do { if let range = range { try await archive.extractRange(range, of: entry) { data in + try Task.checkCancellation() consume(data) } } else { _ = try await archive.extract(entry, skipCRC32: true) { data in + try Task.checkCancellation() consume(data) } } diff --git a/Sources/Streamer/Parser/Audio/AudioParser.swift b/Sources/Streamer/Parser/Audio/AudioParser.swift index 821415a608..b66a8f3378 100644 --- a/Sources/Streamer/Parser/Audio/AudioParser.swift +++ b/Sources/Streamer/Parser/Audio/AudioParser.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal import ReadiumShared /// Parses an audiobook Publication from an unstructured archive format containing audio files, diff --git a/Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift b/Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift index 15d21ec90d..54ea3d1fe1 100644 --- a/Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift +++ b/Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift @@ -6,6 +6,7 @@ import AVFoundation import Foundation +import ReadiumInternal import ReadiumShared import UIKit diff --git a/Sources/Streamer/Parser/EPUB/EPUBMetadataParser.swift b/Sources/Streamer/Parser/EPUB/EPUBMetadataParser.swift index f00443f470..66a8640a6f 100644 --- a/Sources/Streamer/Parser/EPUB/EPUBMetadataParser.swift +++ b/Sources/Streamer/Parser/EPUB/EPUBMetadataParser.swift @@ -6,6 +6,7 @@ import Foundation import ReadiumFuzi +import ReadiumInternal import ReadiumShared /// Reference: https://github.com/readium/architecture/blob/master/streamer/parser/metadata.md diff --git a/Sources/Streamer/Parser/EPUB/OPFParser.swift b/Sources/Streamer/Parser/EPUB/OPFParser.swift index 62c5406b54..fc83da9aa3 100644 --- a/Sources/Streamer/Parser/EPUB/OPFParser.swift +++ b/Sources/Streamer/Parser/EPUB/OPFParser.swift @@ -6,6 +6,7 @@ import Foundation import ReadiumFuzi +import ReadiumInternal import ReadiumShared /// http://www.idpf.org/epub/30/spec/epub30-publications.html#title-type diff --git a/Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift b/Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift index 920382f898..960b9373ba 100644 --- a/Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift +++ b/Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal import ReadiumShared /// Positions Service for an EPUB from its `readingOrder` and `fetcher`. @@ -105,7 +106,7 @@ public actor EPUBPositionsService: PositionsService { } // Calculates totalProgression - let totalPageCount = await positions.asyncMap(\.count).reduce(0, +) + let totalPageCount = positions.map(\.count).reduce(0, +) if totalPageCount > 0 { positions = positions.map { locators in locators.map { locator in diff --git a/Sources/Streamer/Parser/PDF/PDFParser.swift b/Sources/Streamer/Parser/PDF/PDFParser.swift index 0bd05c628d..f7beadee32 100644 --- a/Sources/Streamer/Parser/PDF/PDFParser.swift +++ b/Sources/Streamer/Parser/PDF/PDFParser.swift @@ -6,6 +6,7 @@ import CoreGraphics import Foundation +import ReadiumInternal import ReadiumShared public final class PDFParser: PublicationParser, Loggable { diff --git a/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift b/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift index 062a9c918a..f48e397eab 100644 --- a/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift +++ b/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal import ReadiumShared public enum ReadiumWebPubParserError: Error, Sendable { diff --git a/Support/CocoaPods/ReadiumInternal.podspec b/Support/CocoaPods/ReadiumInternal.podspec index 6299671478..b7377e16ac 100644 --- a/Support/CocoaPods/ReadiumInternal.podspec +++ b/Support/CocoaPods/ReadiumInternal.podspec @@ -12,7 +12,7 @@ Pod::Spec.new do |s| s.source = { :git => "https://github.com/readium/swift-toolkit.git", :tag => s.version } s.requires_arc = true s.source_files = "Sources/Internal/**/*.{m,h,swift}" - s.swift_version = '5.10' + s.swift_version = '6.0' s.platform = :ios s.ios.deployment_target = "15.0" s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } diff --git a/Support/CocoaPods/ReadiumLCP.podspec b/Support/CocoaPods/ReadiumLCP.podspec index ec870dbd19..4066871386 100644 --- a/Support/CocoaPods/ReadiumLCP.podspec +++ b/Support/CocoaPods/ReadiumLCP.podspec @@ -18,7 +18,7 @@ Pod::Spec.new do |s| ], } s.source_files = "Sources/LCP/**/*.{m,h,swift}" - s.swift_version = '5.10' + s.swift_version = '6.0' s.platform = :ios s.ios.deployment_target = "15.0" s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } diff --git a/Support/CocoaPods/ReadiumNavigator.podspec b/Support/CocoaPods/ReadiumNavigator.podspec index 82e2395e34..e71fae0554 100644 --- a/Support/CocoaPods/ReadiumNavigator.podspec +++ b/Support/CocoaPods/ReadiumNavigator.podspec @@ -18,7 +18,7 @@ Pod::Spec.new do |s| ], } s.source_files = "Sources/Navigator/**/*.{m,h,swift}" - s.swift_version = '5.10' + s.swift_version = '6.0' s.platform = :ios s.ios.deployment_target = "15.0" s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-package-name Readium' } diff --git a/Support/CocoaPods/ReadiumOPDS.podspec b/Support/CocoaPods/ReadiumOPDS.podspec index dc3c94af2f..cf0f37accd 100644 --- a/Support/CocoaPods/ReadiumOPDS.podspec +++ b/Support/CocoaPods/ReadiumOPDS.podspec @@ -12,7 +12,7 @@ Pod::Spec.new do |s| s.source = { :git => "https://github.com/readium/swift-toolkit.git", :tag => s.version } s.requires_arc = true s.source_files = "Sources/OPDS/**/*.{m,h,swift}" - s.swift_version = '5.10' + s.swift_version = '6.0' s.platform = :ios s.ios.deployment_target = "15.0" s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } diff --git a/Support/CocoaPods/ReadiumShared.podspec b/Support/CocoaPods/ReadiumShared.podspec index 2b8009adf5..227ffcd46f 100644 --- a/Support/CocoaPods/ReadiumShared.podspec +++ b/Support/CocoaPods/ReadiumShared.podspec @@ -15,7 +15,7 @@ Pod::Spec.new do |s| 'ReadiumShared' => ['Sources/Shared/Resources/**'], } s.source_files = "Sources/Shared/**/*.{m,h,swift}" - s.swift_version = '5.10' + s.swift_version = '6.0' s.platform = :ios s.ios.deployment_target = "15.0" s.frameworks = "CoreServices" diff --git a/Support/CocoaPods/ReadiumStreamer.podspec b/Support/CocoaPods/ReadiumStreamer.podspec index b052caedf6..0f0de3b3e0 100644 --- a/Support/CocoaPods/ReadiumStreamer.podspec +++ b/Support/CocoaPods/ReadiumStreamer.podspec @@ -18,7 +18,7 @@ Pod::Spec.new do |s| ], } s.source_files = "Sources/Streamer/**/*.{m,h,swift}" - s.swift_version = '5.10' + s.swift_version = '6.0' s.platform = :ios s.ios.deployment_target = "15.0" s.libraries = 'z', 'xml2' diff --git a/Support/CocoaPods/Specs.swift b/Support/CocoaPods/Specs.swift index 09f5be6dc1..0b9928cf6c 100644 --- a/Support/CocoaPods/Specs.swift +++ b/Support/CocoaPods/Specs.swift @@ -11,7 +11,7 @@ let version = "3.11.0" let iosTarget = "15.0" /// Swift version requirement shared by all modules. -let swiftVersion = "5.10" +let swiftVersion = "6.0" /// Swift package name (from Package.swift). All modules share this so that `package` access /// level works across module boundaries, matching the SPM build behaviour. diff --git a/TestApp/Integrations/CocoaPods/Podfile b/TestApp/Integrations/CocoaPods/Podfile index e99ddfd1dd..25f0f404bc 100644 --- a/TestApp/Integrations/CocoaPods/Podfile +++ b/TestApp/Integrations/CocoaPods/Podfile @@ -7,10 +7,10 @@ target 'TestApp' do # Comment the next line if you don't want to use dynamic frameworks use_frameworks! - pod 'ReadiumShared', '~> VERSION' - pod 'ReadiumStreamer', '~> VERSION' - pod 'ReadiumNavigator', '~> VERSION' - pod 'ReadiumOPDS', '~> VERSION' + pod 'ReadiumShared', '~> READIUM_VERSION' + pod 'ReadiumStreamer', '~> READIUM_VERSION' + pod 'ReadiumNavigator', '~> READIUM_VERSION' + pod 'ReadiumOPDS', '~> READIUM_VERSION' pod 'GRDB.swift', '~> 6.0' pod 'Kingfisher', '~> 5.0' diff --git a/TestApp/Integrations/CocoaPods/Podfile+lcp b/TestApp/Integrations/CocoaPods/Podfile+lcp index ba2c88eacf..33d0834b5f 100644 --- a/TestApp/Integrations/CocoaPods/Podfile+lcp +++ b/TestApp/Integrations/CocoaPods/Podfile+lcp @@ -7,11 +7,11 @@ target 'TestApp' do # Comment the next line if you don't want to use dynamic frameworks use_frameworks! - pod 'ReadiumShared', '~> VERSION' - pod 'ReadiumStreamer', '~> VERSION' - pod 'ReadiumNavigator', '~> VERSION' - pod 'ReadiumOPDS', '~> VERSION' - pod 'ReadiumLCP', '~> VERSION' + pod 'ReadiumShared', '~> READIUM_VERSION' + pod 'ReadiumStreamer', '~> READIUM_VERSION' + pod 'ReadiumNavigator', '~> READIUM_VERSION' + pod 'ReadiumOPDS', '~> READIUM_VERSION' + pod 'ReadiumLCP', '~> READIUM_VERSION' pod 'R2LCPClient', podspec: 'LCP_URL' pod 'GRDB.swift', '~> 6.0' diff --git a/TestApp/Integrations/CocoaPods/project+lcp.yml b/TestApp/Integrations/CocoaPods/project+lcp.yml index e312a331cf..7c465358b3 100644 --- a/TestApp/Integrations/CocoaPods/project+lcp.yml +++ b/TestApp/Integrations/CocoaPods/project+lcp.yml @@ -13,5 +13,8 @@ targets: - path: Sources/Resources/Fonts type: folder settings: + SWIFT_VERSION: 6.0 + SWIFT_APPROACHABLE_CONCURRENCY: Yes + SWIFT_DEFAULT_ACTOR_ISOLATION: MainActor OTHER_SWIFT_FLAGS: $(inherited) -DLCP DEVELOPMENT_TEAM: ${RD_DEVELOPMENT_TEAM} diff --git a/TestApp/Integrations/CocoaPods/project.yml b/TestApp/Integrations/CocoaPods/project.yml index 76cf72d4e9..1ca0aadc8f 100644 --- a/TestApp/Integrations/CocoaPods/project.yml +++ b/TestApp/Integrations/CocoaPods/project.yml @@ -13,4 +13,7 @@ targets: - path: Sources/Resources/Fonts type: folder settings: + SWIFT_VERSION: 6.0 + SWIFT_APPROACHABLE_CONCURRENCY: Yes + SWIFT_DEFAULT_ACTOR_ISOLATION: MainActor DEVELOPMENT_TEAM: ${RD_DEVELOPMENT_TEAM} diff --git a/TestApp/Integrations/Local/TestApp.xctestplan b/TestApp/Integrations/Local/TestApp.xctestplan index fe319b50e5..1e2348e301 100644 --- a/TestApp/Integrations/Local/TestApp.xctestplan +++ b/TestApp/Integrations/Local/TestApp.xctestplan @@ -6,6 +6,13 @@ "options" : { } + }, + { + "id" : "3E52C740-96D1-4E51-9D6B-1B4E1F7A2C11", + "name" : "Thread Sanitizer", + "options" : { + "threadSanitizerEnabled" : true + } } ], "defaultOptions" : { diff --git a/TestApp/Integrations/Local/project+lcp.yml b/TestApp/Integrations/Local/project+lcp.yml index b78acde543..797e4adb12 100644 --- a/TestApp/Integrations/Local/project+lcp.yml +++ b/TestApp/Integrations/Local/project+lcp.yml @@ -56,6 +56,9 @@ targets: - package: MBProgressHUD - package: SwiftSoup settings: + SWIFT_VERSION: 6.0 + SWIFT_APPROACHABLE_CONCURRENCY: Yes + SWIFT_DEFAULT_ACTOR_ISOLATION: MainActor OTHER_SWIFT_FLAGS: -DLCP DEVELOPMENT_TEAM: ${RD_DEVELOPMENT_TEAM} diff --git a/TestApp/Integrations/Local/project.yml b/TestApp/Integrations/Local/project.yml index 862a55d987..8f6e794a48 100644 --- a/TestApp/Integrations/Local/project.yml +++ b/TestApp/Integrations/Local/project.yml @@ -50,5 +50,8 @@ targets: - package: MBProgressHUD - package: SwiftSoup settings: + SWIFT_VERSION: 6.0 + SWIFT_APPROACHABLE_CONCURRENCY: Yes + SWIFT_DEFAULT_ACTOR_ISOLATION: MainActor DEVELOPMENT_TEAM: ${RD_DEVELOPMENT_TEAM} diff --git a/TestApp/Integrations/SPM/project+lcp.yml b/TestApp/Integrations/SPM/project+lcp.yml index 534bffac7d..e88c4958be 100644 --- a/TestApp/Integrations/SPM/project+lcp.yml +++ b/TestApp/Integrations/SPM/project+lcp.yml @@ -4,7 +4,7 @@ options: packages: Readium: url: https://github.com/readium/swift-toolkit.git - VERSION + READIUM_VERSION R2LCPClient: path: R2LCPClient GRDB: @@ -48,5 +48,8 @@ targets: - package: MBProgressHUD - package: SwiftSoup settings: + SWIFT_VERSION: 6.0 + SWIFT_APPROACHABLE_CONCURRENCY: Yes + SWIFT_DEFAULT_ACTOR_ISOLATION: MainActor OTHER_SWIFT_FLAGS: -DLCP DEVELOPMENT_TEAM: ${RD_DEVELOPMENT_TEAM} diff --git a/TestApp/Integrations/SPM/project.yml b/TestApp/Integrations/SPM/project.yml index eac10bd905..1828c0bb76 100644 --- a/TestApp/Integrations/SPM/project.yml +++ b/TestApp/Integrations/SPM/project.yml @@ -4,7 +4,7 @@ options: packages: Readium: url: https://github.com/readium/swift-toolkit.git - VERSION + READIUM_VERSION GRDB: url: https://github.com/groue/GRDB.swift.git from: 6.9.23 @@ -41,5 +41,8 @@ targets: - package: Kingfisher - package: MBProgressHUD settings: + SWIFT_VERSION: 6.0 + SWIFT_APPROACHABLE_CONCURRENCY: Yes + SWIFT_DEFAULT_ACTOR_ISOLATION: MainActor DEVELOPMENT_TEAM: ${RD_DEVELOPMENT_TEAM} diff --git a/TestApp/Makefile b/TestApp/Makefile index 843636e3b3..a4ac660263 100644 --- a/TestApp/Makefile +++ b/TestApp/Makefile @@ -34,9 +34,9 @@ else @cp Integrations/SPM/project.yml . endif ifdef commit - @sed -i '' -e "s>VERSION>revision: $(commit)>g" project.yml + @sed -i '' -e "s>READIUM_VERSION>revision: $(commit)>g" project.yml else - @sed -i '' -e "s>VERSION>from: $(version)>g" project.yml + @sed -i '' -e "s>READIUM_VERSION>from: $(version)>g" project.yml endif xcodegen generate @echo "\nopen TestApp.xcodeproj" @@ -49,7 +49,7 @@ else @cp Integrations/CocoaPods/project.yml . @cp Integrations/CocoaPods/Podfile . endif - @sed -i '' -e "s>VERSION>$(version)>g" Podfile + @sed -i '' -e "s>READIUM_VERSION>$(version)>g" Podfile xcodegen generate pod install @echo "\nopen TestApp.xcworkspace" diff --git a/TestApp/Sources/App/Readium.swift b/TestApp/Sources/App/Readium.swift index 8b7843a6a5..59aeaa1112 100644 --- a/TestApp/Sources/App/Readium.swift +++ b/TestApp/Sources/App/Readium.swift @@ -11,7 +11,7 @@ import ReadiumStreamer import UIKit #if LCP - import R2LCPClient + @preconcurrency import R2LCPClient import ReadiumLCP #endif diff --git a/TestApp/Sources/Common/Paths.swift b/TestApp/Sources/Common/Paths.swift index d33e8f0037..dd4d42ee16 100644 --- a/TestApp/Sources/Common/Paths.swift +++ b/TestApp/Sources/Common/Paths.swift @@ -8,7 +8,7 @@ import Combine import Foundation import ReadiumShared -final class Paths { +final nonisolated class Paths { private init() {} static let home: FileURL = @@ -50,7 +50,7 @@ final class Paths { } } -extension FileURL { +nonisolated extension FileURL { func appendingUniquePathComponent(_ pathComponent: String? = nil) -> FileURL { /// Returns the first path component matching the given `validation` closure. /// Numbers are appended to the path component until a valid candidate is found. diff --git a/TestApp/Sources/Common/Toolkit/Extensions/AnyPublisher.swift b/TestApp/Sources/Common/Toolkit/Extensions/AnyPublisher.swift index b7805048e1..dae6e41436 100644 --- a/TestApp/Sources/Common/Toolkit/Extensions/AnyPublisher.swift +++ b/TestApp/Sources/Common/Toolkit/Extensions/AnyPublisher.swift @@ -38,7 +38,10 @@ extension AnyPublisher { cancellable?.cancel() } receiveValue: { value in completedBeforeOutput = false - continuation.resume(with: .success(value)) + // Safe: `first()` guarantees this closure runs at most + // once, so the value's region is transferred exactly once. + nonisolated(unsafe) let value = value + continuation.resume(returning: value) } } } diff --git a/TestApp/Sources/Common/Toolkit/Extensions/Future.swift b/TestApp/Sources/Common/Toolkit/Extensions/Future.swift index 64df77cb41..710217bcad 100644 --- a/TestApp/Sources/Common/Toolkit/Extensions/Future.swift +++ b/TestApp/Sources/Common/Toolkit/Extensions/Future.swift @@ -9,11 +9,24 @@ import Foundation public extension Future { /// Creates a `Future` which runs asynchronously on the given `queue`. - convenience init(on queue: DispatchQueue, _ attemptToFulfill: @escaping (@escaping Future.Promise) -> Void) { + convenience init(on queue: DispatchQueue, _ attemptToFulfill: @escaping @Sendable (@escaping Future.Promise) -> Void) { self.init { promise in + // `Combine.Future.Promise` is not `Sendable`, but it is safe to + // call from any thread, so we box it to hop onto `queue`. + let box = UncheckedSendable(promise) queue.async { - attemptToFulfill(promise) + attemptToFulfill(box.value) } } } } + +/// Wraps a non-`Sendable` value to allow capturing it in a `@Sendable` +/// closure, when the value is known to be safe to transfer. +private nonisolated struct UncheckedSendable: @unchecked Sendable { + let value: Value + + init(_ value: Value) { + self.value = value + } +} diff --git a/TestApp/Sources/Common/UserError.swift b/TestApp/Sources/Common/UserError.swift index 0dfaa3d493..1e8bb4a2ce 100644 --- a/TestApp/Sources/Common/UserError.swift +++ b/TestApp/Sources/Common/UserError.swift @@ -52,11 +52,11 @@ struct UserError: LocalizedError { /// Convenience protocol for an object (usually an ``Error``) that can be /// converted into a ``UserError``. -protocol UserErrorConvertible { +nonisolated protocol UserErrorConvertible { func userError() -> UserError? } -extension UserError: UserErrorConvertible { +nonisolated extension UserError: UserErrorConvertible { func userError() -> UserError? { self } @@ -90,7 +90,7 @@ extension UIViewController { } } -extension String { +nonisolated extension String { var localized: String { NSLocalizedString(self, comment: "") } diff --git a/TestApp/Sources/Data/Book.swift b/TestApp/Sources/Data/Book.swift index 8dcd9af053..902f3cabe9 100644 --- a/TestApp/Sources/Data/Book.swift +++ b/TestApp/Sources/Data/Book.swift @@ -9,8 +9,8 @@ import Foundation import GRDB import ReadiumShared -struct Book: Codable { - struct Id: EntityId { let rawValue: Int64 } +nonisolated struct Book: Codable { + nonisolated struct Id: EntityId { let rawValue: Int64 } let id: Id? /// Canonical identifier for the publication, extracted from its metadata. @@ -85,7 +85,7 @@ struct Book: Codable { } } -extension Book: TableRecord, FetchableRecord, PersistableRecord { +nonisolated extension Book: TableRecord, FetchableRecord, PersistableRecord { enum Columns: String, ColumnExpression { case id, identifier, title, type, url, coverPath, locator, progression, created, preferencesJSON } @@ -143,12 +143,17 @@ final class BookRepository { } func savePreferences(_ preferences: Preferences, of id: Book.Id) async throws { + // Encoded eagerly, as `preferences` is not `Sendable` and cannot be + // captured in the database closure. + let data = try JSONEncoder().encode(preferences) + let json = String(data: data, encoding: .utf8) + try await db.write { db in guard var book = try Book.fetchOne(db, key: id) else { return } - try book.setPreferences(preferences) + book.preferencesJSON = json try book.save(db) } } diff --git a/TestApp/Sources/Data/Bookmark.swift b/TestApp/Sources/Data/Bookmark.swift index 6c0040cac8..8a3215e902 100644 --- a/TestApp/Sources/Data/Bookmark.swift +++ b/TestApp/Sources/Data/Bookmark.swift @@ -9,8 +9,8 @@ import Foundation import GRDB import ReadiumShared -struct Bookmark: Codable { - struct Id: EntityId { let rawValue: Int64 } +nonisolated struct Bookmark: Codable { + nonisolated struct Id: EntityId { let rawValue: Int64 } let id: Id? /// Foreign key to the publication. @@ -31,7 +31,7 @@ struct Bookmark: Codable { } } -extension Bookmark: TableRecord, FetchableRecord, PersistableRecord { +nonisolated extension Bookmark: TableRecord, FetchableRecord, PersistableRecord { enum Columns: String, ColumnExpression { case id, bookId, locator, progression, created } @@ -67,4 +67,4 @@ final class BookmarkRepository { } /// for the default SwiftUI support -extension Bookmark: Hashable {} +nonisolated extension Bookmark: Hashable {} diff --git a/TestApp/Sources/Data/Database.swift b/TestApp/Sources/Data/Database.swift index 0d49400581..ddae7d9e4d 100644 --- a/TestApp/Sources/Data/Database.swift +++ b/TestApp/Sources/Data/Database.swift @@ -9,7 +9,7 @@ import Foundation import GRDB import ReadiumShared -final class Database { +final nonisolated class Database { convenience init(file: URL) throws { try self.init(writer: DatabaseQueue(path: file.path)) } @@ -60,7 +60,7 @@ final class Database { try migrator.migrate(writer) } - func read(_ query: @escaping (GRDB.Database) throws -> T) async throws -> T { + func read(_ query: @escaping @Sendable (GRDB.Database) throws -> T) async throws -> T { try await withCheckedThrowingContinuation { cont in writer.asyncRead { db in do { @@ -74,7 +74,7 @@ final class Database { } @discardableResult - func write(_ updates: @escaping (GRDB.Database) throws -> T) async throws -> T { + func write(_ updates: @escaping @Sendable (GRDB.Database) throws -> T) async throws -> T { try await withCheckedThrowingContinuation { cont in writer.asyncWrite { try updates($0) @@ -84,7 +84,7 @@ final class Database { } } - func observe(_ query: @escaping (GRDB.Database) throws -> T) -> AnyPublisher { + func observe(_ query: @escaping @Sendable (GRDB.Database) throws -> T) -> AnyPublisher { ValueObservation.tracking(query) .publisher(in: writer) .eraseToAnyPublisher() @@ -95,9 +95,9 @@ final class Database { /// /// Using this instead of regular integers makes the code safer, because we can only give ids of the /// right model in APIs. It also helps self-document APIs. -protocol EntityId: Codable, Hashable, RawRepresentable, ExpressibleByIntegerLiteral, CustomStringConvertible, DatabaseValueConvertible where RawValue == Int64 {} +nonisolated protocol EntityId: Codable, Hashable, RawRepresentable, ExpressibleByIntegerLiteral, CustomStringConvertible, DatabaseValueConvertible where RawValue == Int64 {} -extension EntityId { +nonisolated extension EntityId { var string: String { String(rawValue) } @@ -110,7 +110,7 @@ extension EntityId { } } -extension EntityId { +nonisolated extension EntityId { // MARK: - ExpressibleByIntegerLiteral init(integerLiteral value: Int64) { diff --git a/TestApp/Sources/Data/Highlight.swift b/TestApp/Sources/Data/Highlight.swift index 06b4aeb436..f21bd1440e 100644 --- a/TestApp/Sources/Data/Highlight.swift +++ b/TestApp/Sources/Data/Highlight.swift @@ -11,7 +11,7 @@ import ReadiumNavigator import ReadiumShared import UIKit -enum HighlightColor: UInt8, Codable, SQLExpressible { +nonisolated enum HighlightColor: UInt8, Codable, SQLExpressible { case red = 1 case green = 2 case blue = 3 @@ -33,8 +33,8 @@ extension HighlightColor { } } -struct Highlight: Codable { - struct Id: EntityId { let rawValue: Int64 } +nonisolated struct Highlight: Codable { + nonisolated struct Id: EntityId { let rawValue: Int64 } let id: Id? /// Foreign key to the publication. @@ -64,7 +64,7 @@ struct Highlight: Codable { } } -extension Highlight: TableRecord, FetchableRecord, PersistableRecord { +nonisolated extension Highlight: TableRecord, FetchableRecord, PersistableRecord { enum Columns: String, ColumnExpression { case id, bookId, locator, color, created, progression } @@ -119,4 +119,4 @@ final class HighlightRepository { } /// for the default SwiftUI support -extension Highlight: Hashable {} +nonisolated extension Highlight: Hashable {} diff --git a/TestApp/Sources/LCP/LCPModule.swift b/TestApp/Sources/LCP/LCPModule.swift index 4f257a75f2..9bb163163f 100644 --- a/TestApp/Sources/LCP/LCPModule.swift +++ b/TestApp/Sources/LCP/LCPModule.swift @@ -23,7 +23,7 @@ struct LCPPublication { @MainActor protocol LCPModuleAPI { init(readium: Readium) - func fulfill(_ file: FileURL, progress: @escaping (Double) -> Void) async throws -> LCPPublication + func fulfill(_ file: FileURL, progress: @escaping @Sendable (Double) -> Void) async throws -> LCPPublication } extension LCPModuleAPI { @@ -40,7 +40,7 @@ extension LCPModuleAPI { lcpService = readium.lcpService } - func fulfill(_ file: FileURL, progress: @escaping (Double) -> Void) async throws -> LCPPublication { + func fulfill(_ file: FileURL, progress: @escaping @Sendable (Double) -> Void) async throws -> LCPPublication { let pub = try await lcpService.acquirePublication( from: .file(file), onProgress: { p in @@ -71,7 +71,7 @@ extension LCPModuleAPI { final class LCPModule: LCPModuleAPI { init(readium: Readium) {} - func fulfill(_ file: FileURL, progress: @escaping (Double) -> Void) async throws -> LCPPublication { + func fulfill(_ file: FileURL, progress: @escaping @Sendable (Double) -> Void) async throws -> LCPPublication { throw LCPModuleError.lcpNotEnabled } } diff --git a/TestApp/Sources/Library/LibraryModule.swift b/TestApp/Sources/Library/LibraryModule.swift index 8d64fd2585..1bb4452acb 100644 --- a/TestApp/Sources/Library/LibraryModule.swift +++ b/TestApp/Sources/Library/LibraryModule.swift @@ -24,7 +24,7 @@ import UIKit @discardableResult func importPublication( from url: AbsoluteURL, - progress: @escaping (Double) -> Void + progress: @escaping @Sendable (Double) -> Void ) async throws -> Book } @@ -66,7 +66,7 @@ final class LibraryModule: LibraryModuleAPI { func importPublication( from url: AbsoluteURL, - progress: @escaping (Double) -> Void + progress: @escaping @Sendable (Double) -> Void ) async throws -> Book { try await library.importPublication(from: url, progress: progress) } diff --git a/TestApp/Sources/Library/LibraryService.swift b/TestApp/Sources/Library/LibraryService.swift index 5754a8d9b0..88ad0f6093 100644 --- a/TestApp/Sources/Library/LibraryService.swift +++ b/TestApp/Sources/Library/LibraryService.swift @@ -97,7 +97,7 @@ import UIKit @discardableResult func importPublication( from url: AbsoluteURL, - progress: @escaping (Double) -> Void + progress: @escaping @Sendable (Double) -> Void ) async throws -> Book { // Necessary to read URL exported from the Files app, for example. let shouldRelinquishAccess = url.url.startAccessingSecurityScopedResource() @@ -134,7 +134,7 @@ import UIKit } /// Fulfills the given `url` if it's a DRM license file. - private func fulfillIfNeeded(_ url: FileURL, progress: @escaping (Double) -> Void) async throws -> FileURL { + private func fulfillIfNeeded(_ url: FileURL, progress: @escaping @Sendable (Double) -> Void) async throws -> FileURL { guard lcp.canFulfill(url) else { return url } diff --git a/TestApp/Sources/Library/PublicationCollectionViewCell.swift b/TestApp/Sources/Library/PublicationCollectionViewCell.swift index de660b7ce7..a731976d0a 100644 --- a/TestApp/Sources/Library/PublicationCollectionViewCell.swift +++ b/TestApp/Sources/Library/PublicationCollectionViewCell.swift @@ -54,12 +54,16 @@ class PublicationCollectionViewCell: UICollectionViewCell { }() - override func awakeFromNib() { + override nonisolated func awakeFromNib() { super.awakeFromNib() - publicationMenuViewController.delegate = self - publicationMenuViewController.view.isHidden = !isMenuDisplayed - contentView.addSubview(publicationMenuViewController.view) + // `awakeFromNib` is always called on the main thread, but is declared + // nonisolated in the UIKit SDK. + MainActor.assumeIsolated { + publicationMenuViewController.delegate = self + publicationMenuViewController.view.isHidden = !isMenuDisplayed + contentView.addSubview(publicationMenuViewController.view) + } } override func layoutSubviews() { diff --git a/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift b/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift index c09ef78558..01b91297e3 100644 --- a/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift +++ b/TestApp/Sources/Reader/Common/Preferences/UserPreferences.swift @@ -38,12 +38,13 @@ final class UserPreferencesViewModel< .receive(on: DispatchQueue.main) preferences - .compactMap { prefs in + .sink { [weak self] prefs in + // The publisher delivers on the main queue. MainActor.assumeIsolated { - configurable.editor(of: prefs) + self?.editor = configurable.editor(of: prefs) } } - .assign(to: &$editor) + .store(in: &subscriptions) preferences // First one is dropped to avoid refreshing the navigator when diff --git a/TestApp/Sources/Reader/Common/VisualReaderViewController.swift b/TestApp/Sources/Reader/Common/VisualReaderViewController.swift index fb15a6d0ed..f8d04bdfc2 100644 --- a/TestApp/Sources/Reader/Common/VisualReaderViewController.swift +++ b/TestApp/Sources/Reader/Common/VisualReaderViewController.swift @@ -407,7 +407,7 @@ extension Decoration.Style.Id { static let pageList: Decoration.Style.Id = "page_list" } -struct PageListConfig: Hashable, Sendable { +nonisolated struct PageListConfig: Hashable, Sendable { /// Page number label, taken from `publication.pageList[].title`. var label: String } diff --git a/Tests/NavigatorTests/Audio/PublicationMediaLoaderTests.swift b/Tests/NavigatorTests/Audio/PublicationMediaLoaderTests.swift index 1a2d62a0e9..50564ccb29 100644 --- a/Tests/NavigatorTests/Audio/PublicationMediaLoaderTests.swift +++ b/Tests/NavigatorTests/Audio/PublicationMediaLoaderTests.swift @@ -5,6 +5,7 @@ // @testable import ReadiumNavigator +import ReadiumShared import XCTest class PublicationMediaLoaderTests: XCTestCase { diff --git a/Tests/SharedTests/OPDS/OPDSAvailabilityTests.swift b/Tests/SharedTests/OPDS/OPDSAvailabilityTests.swift index b366a7bc8f..1da7746ec2 100644 --- a/Tests/SharedTests/OPDS/OPDSAvailabilityTests.swift +++ b/Tests/SharedTests/OPDS/OPDSAvailabilityTests.swift @@ -4,6 +4,7 @@ // available in the top-level LICENSE file of the project. // +import ReadiumInternal @testable import ReadiumShared import XCTest diff --git a/Tests/SharedTests/Publication/Extensions/Audio/Locator+AudioTests.swift b/Tests/SharedTests/Publication/Extensions/Audio/Locator+AudioTests.swift index a483ceee78..f537adac0e 100644 --- a/Tests/SharedTests/Publication/Extensions/Audio/Locator+AudioTests.swift +++ b/Tests/SharedTests/Publication/Extensions/Audio/Locator+AudioTests.swift @@ -4,6 +4,7 @@ // available in the top-level LICENSE file of the project. // +import ReadiumInternal @testable import ReadiumShared import XCTest diff --git a/Tests/SharedTests/Publication/Services/Content/Iterators/PDFResourceContentIteratorTests.swift b/Tests/SharedTests/Publication/Services/Content/Iterators/PDFResourceContentIteratorTests.swift index 34b91c2f6f..a23408091c 100644 --- a/Tests/SharedTests/Publication/Services/Content/Iterators/PDFResourceContentIteratorTests.swift +++ b/Tests/SharedTests/Publication/Services/Content/Iterators/PDFResourceContentIteratorTests.swift @@ -4,6 +4,7 @@ // available in the top-level LICENSE file of the project. // +import ReadiumInternal @testable import ReadiumShared import Testing import UIKit diff --git a/Tests/SharedTests/Toolkit/CancellableTasksTests.swift b/Tests/SharedTests/Toolkit/CancellableTasksTests.swift new file mode 100644 index 0000000000..8e26fd2a36 --- /dev/null +++ b/Tests/SharedTests/Toolkit/CancellableTasksTests.swift @@ -0,0 +1,47 @@ +// +// Copyright 2026 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 ReadiumShared +import Testing + +struct CancellableTasksTests { + @Test func deinitCancelsInFlightTasks() async { + let started = AsyncStream.makeStream(of: Void.self) + let cancelled = AsyncStream.makeStream(of: Void.self) + + var sut: CancellableTasks? = CancellableTasks() + sut!.add { + started.continuation.yield(()) + while !Task.isCancelled { + await Task.yield() + } + cancelled.continuation.yield(()) + } + + // Waits for the task to be running before releasing its owner. + var startedIterator = started.stream.makeAsyncIterator() + _ = await startedIterator.next() + + sut = nil + + // Hangs (and times out) if deallocating the owner did not cancel the + // task. + var cancelledIterator = cancelled.stream.makeAsyncIterator() + _ = await cancelledIterator.next() + } + + @Test func tasksRunToCompletionWhileOwnerIsAlive() async { + let finished = AsyncStream.makeStream(of: Void.self) + + let sut = CancellableTasks() + sut.add { + finished.continuation.yield(()) + } + + var iterator = finished.stream.makeAsyncIterator() + _ = await iterator.next() + } +} diff --git a/Tests/SharedTests/Toolkit/Data/Resource/BufferingResourceTests.swift b/Tests/SharedTests/Toolkit/Data/Resource/BufferingResourceTests.swift index b2bcff92b2..14df76a170 100644 --- a/Tests/SharedTests/Toolkit/Data/Resource/BufferingResourceTests.swift +++ b/Tests/SharedTests/Toolkit/Data/Resource/BufferingResourceTests.swift @@ -4,6 +4,7 @@ // available in the top-level LICENSE file of the project. // +import ReadiumInternal @testable import ReadiumShared import TestPublications import XCTest diff --git a/Tests/SharedTests/Toolkit/Data/Resource/TailCachingResourceTests.swift b/Tests/SharedTests/Toolkit/Data/Resource/TailCachingResourceTests.swift index ee9cad48da..dcdab4bc0c 100644 --- a/Tests/SharedTests/Toolkit/Data/Resource/TailCachingResourceTests.swift +++ b/Tests/SharedTests/Toolkit/Data/Resource/TailCachingResourceTests.swift @@ -4,6 +4,7 @@ // available in the top-level LICENSE file of the project. // +import ReadiumInternal @testable import ReadiumShared import TestPublications import XCTest diff --git a/Tests/SharedTests/Toolkit/Data/Resource/TransformingResourceTests.swift b/Tests/SharedTests/Toolkit/Data/Resource/TransformingResourceTests.swift index c3182c84cf..603b9f96dc 100644 --- a/Tests/SharedTests/Toolkit/Data/Resource/TransformingResourceTests.swift +++ b/Tests/SharedTests/Toolkit/Data/Resource/TransformingResourceTests.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal @testable import ReadiumShared import Testing diff --git a/Tests/SharedTests/Toolkit/File/FileResourceTests.swift b/Tests/SharedTests/Toolkit/File/FileResourceTests.swift new file mode 100644 index 0000000000..fa25f5a42e --- /dev/null +++ b/Tests/SharedTests/Toolkit/File/FileResourceTests.swift @@ -0,0 +1,35 @@ +// +// Copyright 2026 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 ReadiumShared +import Testing +import TestPublications + +struct FileResourceTests { + private let file = FileURL(url: TestPublications.url(for: "childrens-literature.epub"))! + + @Test func streamFailsWhenTaskIsCancelled() async { + let resource = FileResource(file: file) + + let task = Task { () -> ReadResult in + // Waits until the cancellation below is requested, to make the + // test deterministic. + while !Task.isCancelled { + await Task.yield() + } + return await resource.stream(range: nil) { _ in + Issue.record("Received a chunk from a cancelled task") + } + } + task.cancel() + + let result = await task.value + guard case .failure(.cancelled) = result else { + Issue.record("Expected .failure(.cancelled), got \(result)") + return + } + } +} diff --git a/Tests/SharedTests/Toolkit/HTTP/DefaultHTTPClientTests.swift b/Tests/SharedTests/Toolkit/HTTP/DefaultHTTPClientTests.swift index b530b26586..9cfee19ad2 100644 --- a/Tests/SharedTests/Toolkit/HTTP/DefaultHTTPClientTests.swift +++ b/Tests/SharedTests/Toolkit/HTTP/DefaultHTTPClientTests.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal @testable import ReadiumShared import Testing diff --git a/Tests/StreamerTests/Parser/EPUB/EPUBManifestParserTests.swift b/Tests/StreamerTests/Parser/EPUB/EPUBManifestParserTests.swift index 87e7d39052..aa5ac88526 100644 --- a/Tests/StreamerTests/Parser/EPUB/EPUBManifestParserTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/EPUBManifestParserTests.swift @@ -4,6 +4,7 @@ // available in the top-level LICENSE file of the project. // +import ReadiumInternal import ReadiumShared @testable import ReadiumStreamer import XCTest diff --git a/Tests/StreamerTests/Parser/EPUB/EPUBMetadataParserTests.swift b/Tests/StreamerTests/Parser/EPUB/EPUBMetadataParserTests.swift index ff00b346f2..4f8adbc59b 100644 --- a/Tests/StreamerTests/Parser/EPUB/EPUBMetadataParserTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/EPUBMetadataParserTests.swift @@ -5,6 +5,7 @@ // import ReadiumFuzi +import ReadiumInternal import ReadiumShared @testable import ReadiumStreamer import XCTest diff --git a/docs/Guides/Readium LCP.md b/docs/Guides/Readium LCP.md index 20c778fe2b..dcdb24d962 100644 --- a/docs/Guides/Readium LCP.md +++ b/docs/Guides/Readium LCP.md @@ -193,7 +193,7 @@ Alternatively, you can supply your own device name when initializing `LCPService The `LCPService` expects repositories to store the opened licenses and passphrases. `ReadiumLCP` provides built-in Keychain-based implementations that store data securely in the iOS/macOS Keychain. Unlike database-based storage, Keychain data persists across app reinstalls and can optionally be synchronized across the user's devices via iCloud Keychain. ```swift -import R2LCPClient +@preconcurrency import R2LCPClient import ReadiumLCP let httpClient = DefaultHTTPClient() @@ -288,6 +288,7 @@ An LCP package is secured with a *user passphrase* for decrypting the content. T `LCPDialogAuthentication` delegates the presentation of the dialog to an `LCPDialogAuthenticationDelegate`, for example on the top-most view controller of your application. As the delegate is held weakly, you must retain it yourself for the lifetime of the authentication. ```swift +@MainActor final class LCPDialogPresenter: LCPDialogAuthenticationDelegate { func lcpDialogAuthentication( _ authentication: LCPDialogAuthentication, diff --git a/docs/Migration Guide.md b/docs/Migration Guide.md index f740b182a3..9dba34ef78 100644 --- a/docs/Migration Guide.md +++ b/docs/Migration Guide.md @@ -4,6 +4,62 @@ All migration steps necessary in reading apps to upgrade to major versions of th ## Unreleased +### Swift 6 and strict concurrency + +The toolkit is now built with the Swift 6 language mode and strict concurrency checking. Building requires Xcode 26 (Swift 6.2 toolchain) or later. Your app does not need to adopt the Swift 6 language mode itself, but some APIs changed shape. + +#### Core types are `Sendable` + +`Publication`, `Manifest`, `Link`, `Locator`, `Resource`, `Container` and most other Shared models are now `Sendable` and can safely cross concurrency domains. + +If you implement custom `Resource`, `Container`, `HTTPClient` or `PublicationService` types, they must now conform to `Sendable`: + +* For stateless types, add the conformance – structs of `Sendable` values get it for free. +* For types holding mutable state (file handles, caches...), we recommend converting the class to an `actor`, as the toolkit does for its own resources (e.g. `FileResource`). + +Custom `Resource` implementations should also cooperate with task cancellation in `stream()`: check `Task.isCancelled` between chunks – at minimum when entering the method – and fail with `ReadError.cancelled`. + +#### Navigators are isolated to the main actor + +The `Navigator` and `VisualNavigator` protocols – and their delegates such as `NavigatorDelegate` – are now `@MainActor`. In practice: + +* Call navigator APIs from the main actor (which you most likely already do, as they drive UIKit views). +* Conformances to the delegate protocols must be main-actor-isolated. If your delegate is a `UIViewController`, nothing changes. Otherwise, annotate the type with `@MainActor`. + +```diff +-final class ReaderCoordinator: NavigatorDelegate { ++@MainActor final class ReaderCoordinator: NavigatorDelegate { +``` + +#### Navigator Pointer identifiers are `Sendable` + +To keep input events `Sendable` and type-safe, the pointer identifiers are no longer type-erased as `AnyHashable`. This only matters if you implement a custom `InputObserving`, in which case switch to the concrete types: + +* `Pointer.id`, `TouchPointer.id` and `MousePointer.id` are now `PointerId`. +* `InputObservableToken.id` is now a `UUID`. + +#### Updated `stream(...)` signature for custom `HTTPClient` + +If you provide your own `HTTPClient` implementation, the `stream(...)` method changed shape. Its closures are now `@Sendable` for strict concurrency, and it gained an `onReceiveResponse` callback, invoked once the response headers arrive so you can inspect them and cancel early (e.g. on an unexpected status) before the body is consumed: + +```diff + func stream( +- request: HTTPRequestConvertible, +- consume: @escaping (_ chunk: Data, _ progress: Double?) -> HTTPResult ++ _ request: HTTPRequestConvertible, ++ onReceiveResponse: (@Sendable (HTTPResponse) async -> HTTPResult)?, ++ consume: @Sendable (_ chunk: Data, _ progress: Double?) -> HTTPResult + ) async -> HTTPResult +``` + +Watch out for one behavior change in your implementation: `HTTPStatus.isSuccess` is now `true` only for `2xx` codes – `3xx` redirects no longer count as a success as they are supposed to be handled by the `HTTPClient` implementation before being returned to the caller. + +#### Async APIs run on the caller's actor + +The toolkit adopts the [`NonisolatedNonsendingByDefault`](https://docs.swift.org/compiler/documentation/diagnostics/nonisolated-nonsending-by-default/) upcoming feature ([SE-0461](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0461-async-function-isolation.md)), which will become the language default. Its `async` APIs now run on the calling actor instead of hopping to a background thread, except for CPU-heavy operations (decryption, parsing, search...) which are marked `@concurrent` and stay off your actor. + +You usually don't need to change anything. But if you implement a custom `Resource`, `HTTPClient` or search algorithm as a plain class or struct (not an actor), consider annotating CPU-heavy `async` methods with `@concurrent` so they don't block the main actor when called from UI code. + ### Readium LCP #### Required `deviceName` in `LCPService` @@ -23,7 +79,7 @@ All migration steps necessary in reading apps to upgrade to major versions of th #### Removal of the `sender` parameter from the LCP authentication APIs -The `sender` parameter used to give UX context (e.g. the host `UIViewController`) when presenting an LCP passphrase dialog has been removed from `PublicationOpener.open(...)` and `LCPService.retrieveLicense(...)`. +The `sender` parameter used to give UX context (e.g. the host `UIViewController`) when presenting an LCP passphrase dialog has been removed from `PublicationOpener.open(...)`, `LCPService.retrieveLicense(...)` and `LCPAuthenticating.retrievePassphrase(...)`. If you use the SwiftUI `LCPDialog`, just remove the `sender` argument from your calls. @@ -36,6 +92,7 @@ But if you use the UIKit `LCPDialogAuthentication`, you need to provide a `LCPDi ``` ```swift +@MainActor final class LCPDialogPresenter: LCPDialogAuthenticationDelegate { func lcpDialogAuthentication( _ authentication: LCPDialogAuthentication, @@ -57,6 +114,17 @@ Then drop the `sender` argument from your calls: ) ``` +#### New `updateUserRights` signature in `LCPLicenseRepository` + +If you implement a custom `LCPLicenseRepository`, `updateUserRights` changed shape. It is now `async` and `throws`, and it is generic so it can return a value computed while the rights are locked – for example, whether a copy or print request fit within the remaining budget. Update your implementation to match: + +```swift +func updateUserRights( + for id: LicenseDocument.ID, + with changes: @Sendable (inout LCPConsumableUserRights) throws -> T +) async throws -> T +``` + ## 3.9.0 diff --git a/scripts/test.sh b/scripts/test.sh index 58e332de8b..e9789ba8a9 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -5,6 +5,9 @@ # Run the test suite. # # FILTER - Optional target to run (e.g. ReadiumSharedTests) +# +# Set TSAN=1 to run the "Thread Sanitizer" test plan configuration instead of +# the default one. # ============================================================================= set -euo pipefail @@ -15,10 +18,14 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" DESTINATION="platform=iOS Simulator,name=iPad (A16)" FILTER="${1:-}" +CONFIGURATION="Test Scheme Action" +[ "${TSAN:-0}" = "1" ] && CONFIGURATION="Thread Sanitizer" + ARGS=( -project "$REPO_ROOT/TestApp/TestApp.xcodeproj" -scheme TestApp -testPlan TestApp + -only-test-configuration "$CONFIGURATION" -destination "$DESTINATION" ) [ -n "$FILTER" ] && ARGS+=(-only-testing:"$FILTER") From 7df24b74f3fb8f86b538005fb2c193af800e6e74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Tue, 14 Jul 2026 16:42:06 +0200 Subject: [PATCH 28/39] Remove the `ReadiumInternal` package (#855) --- CHANGELOG.md | 1 + Package.swift | 13 - Playground/Playground.xctestplan | 7 - .../Sources/App/Data/DocumentRepository.swift | 4 +- Sources/Internal/Extensions/Optional.swift | 29 --- Sources/Internal/Extensions/Range.swift | 53 ---- Sources/Internal/Extensions/URL.swift | 78 ------ Sources/LCP/Authentications/LCPDialog.swift | 1 - .../LCPDialogViewController.swift | 1 - .../Content Protection/EncryptionParser.swift | 1 - .../LCPContentProtection.swift | 1 - .../LCP/Content Protection/LCPDecryptor.swift | 1 - Sources/LCP/License/License.swift | 1 - Sources/LCP/License/LicenseValidation.swift | 1 - Sources/LCP/Services/PassphrasesService.swift | 1 - .../Preferences/AudioPreferencesEditor.swift | 2 +- .../Audiobook/PublicationMediaLoader.swift | 1 - Sources/Navigator/EPUB/CSS/ReadiumCSS.swift | 1 - .../EPUB/EPUBNavigatorViewController.swift | 1 - .../EPUB/EPUBNavigatorViewModel.swift | 1 - .../EPUB/EPUBReflowableSpreadView.swift | 1 - Sources/Navigator/EPUB/EPUBSpread.swift | 1 - Sources/Navigator/EPUB/EPUBSpreadView.swift | 1 - .../EPUBViewportAndLocationCalculator.swift | 1 - .../Preferences/EPUBPreferences+Legacy.swift | 1 - .../EPUB/Preferences/EPUBPreferences.swift | 1 - .../Preferences/EPUBPreferencesEditor.swift | 1 - Sources/Navigator/EPUB/WebViewServer.swift | 1 - Sources/Navigator/Navigator.swift | 1 - .../PDF/PDFNavigatorViewController.swift | 1 - .../Navigator/PDF/PDFPageNumberResolver.swift | 1 - .../Navigator/PDF/PDFViewportCalculator.swift | 1 - .../PDF/Preferences/PDFSettings.swift | 1 - .../Preferences/MappedPreference.swift | 2 +- .../Preferences/ProgressionStrategy.swift | 2 +- .../Preferences/ProxyPreference.swift | 2 +- Sources/Navigator/Preferences/Types.swift | 1 - .../TTS/PublicationSpeechSynthesizer.swift | 1 - Sources/Navigator/TTS/TTSVoice.swift | 1 - Sources/Navigator/Toolkit/HTMLInjection.swift | 1 - .../Navigator/Toolkit/PaginationView.swift | 1 - .../ViewportProgressionCalculator.swift | 1 - Sources/OPDS/OPDS1Parser.swift | 1 - Sources/OPDS/OPDS2Parser.swift | 1 - Sources/Shared/OPDS/OPDSAcquisition.swift | 1 - Sources/Shared/OPDS/OPDSAvailability.swift | 1 - Sources/Shared/OPDS/OPDSCopies.swift | 1 - Sources/Shared/OPDS/OPDSHolds.swift | 1 - Sources/Shared/OPDS/OPDSPrice.swift | 1 - .../Accessibility/Accessibility.swift | 1 - .../AccessibilityMetadataDisplayGuide.swift | 1 - Sources/Shared/Publication/Contributor.swift | 1 - .../Extensions/EPUB/EPUBMediaOverlay.swift | 1 - .../Extensions/EPUB/Properties+EPUB.swift | 1 - .../Extensions/Encryption/Encryption.swift | 1 - .../Extensions/HTML/DOMRange.swift | 1 - .../Extensions/OPDS/Properties+OPDS.swift | 1 - .../GuidedNavigationDocument.swift | 1 - .../GuidedNavigationObject.swift | 1 - Sources/Shared/Publication/Link.swift | 1 - Sources/Shared/Publication/LinkRelation.swift | 1 - .../Shared/Publication/LocalizedString.swift | 1 - Sources/Shared/Publication/Locator.swift | 1 - Sources/Shared/Publication/Manifest.swift | 1 - Sources/Shared/Publication/Metadata.swift | 1 - Sources/Shared/Publication/Properties.swift | 1 - Sources/Shared/Publication/Publication.swift | 1 - .../Publication/PublicationCollection.swift | 1 - .../Services/Content/Content.swift | 1 - .../Services/Content/ContentTokenizer.swift | 1 - .../HTMLResourceContentIterator.swift | 1 - .../PDFResourceContentIterator.swift | 1 - .../PublicationContentIterator.swift | 1 - .../Locator/DefaultLocatorService.swift | 1 - .../Services/Search/SearchService.swift | 1 - Sources/Shared/Publication/Subject.swift | 1 - Sources/Shared/Publication/TDM.swift | 1 - .../Toolkit/Archive/ArchiveProperties.swift | 1 - .../Toolkit/Data/Asset/AssetRetriever.swift | 1 - .../Toolkit/Data/Container/Container.swift | 1 - .../Data/Resource/BufferingResource.swift | 1 - .../Resource/ResourceContentExtractor.swift | 1 - .../Data/Resource/TailCachingResource.swift | 1 - .../Data/Resource/TransformingResource.swift | 1 - Sources/Shared/Toolkit/Data/Streamable.swift | 1 - Sources/Shared/Toolkit/DocumentTypes.swift | 1 - .../Toolkit}/Extensions/Array.swift | 12 +- .../Toolkit}/Extensions/Collection.swift | 2 +- .../Toolkit}/Extensions/Comparable.swift | 2 +- .../Toolkit}/Extensions/Data.swift | 2 +- .../Toolkit}/Extensions/Date+ISO8601.swift | 6 +- .../Toolkit/Extensions/Deprecations.swift | 110 ++++++++ .../Toolkit}/Extensions/Double.swift | 2 +- .../Extensions/NSRegularExpression.swift | 16 +- .../Toolkit}/Extensions/Number.swift | 2 +- .../Shared/Toolkit/Extensions/Optional.swift | 22 +- Sources/Shared/Toolkit/Extensions/Range.swift | 46 ++++ .../Toolkit}/Extensions/Result.swift | 15 +- .../Toolkit}/Extensions/Sequence.swift | 2 +- .../Toolkit}/Extensions/String.swift | 40 +-- .../Toolkit}/Extensions/Task.swift | 10 +- .../Toolkit}/Extensions/UInt64.swift | 2 +- .../Shared/Toolkit/File/FileResource.swift | 1 - Sources/Shared/Toolkit/Format/Format.swift | 1 - .../Shared/Toolkit/Format/FormatSniffer.swift | 1 - .../Toolkit/Format/FormatSnifferBlob.swift | 1 - Sources/Shared/Toolkit/Format/MediaType.swift | 1 - .../Sniffers/CompositeFormatSniffer.swift | 1 - .../Format/Sniffers/EPUBFormatSniffer.swift | 1 - .../Format/Sniffers/HTMLFormatSniffer.swift | 1 - .../Toolkit/HTTP/DefaultHTTPClient.swift | 1 - Sources/Shared/Toolkit/HTTP/HTTPClient.swift | 1 - Sources/Shared/Toolkit/JSONValue.swift | 1 - Sources/Shared/Toolkit/Poller.swift | 1 - Sources/Shared/Toolkit/Throttle.swift | 1 - .../URL/Absolute URL/AbsoluteURL.swift | 1 - .../Toolkit/URL/Absolute URL/FileURL.swift | 1 - Sources/Shared/Toolkit/URL/AnyURL.swift | 1 - Sources/Shared/Toolkit/URL/RelativeURL.swift | 1 - Sources/Shared/Toolkit/URL/URITemplate.swift | 1 - .../Shared/Toolkit/URL/URLExtensions.swift | 43 ++++ Sources/Shared/Toolkit/URL/URLProtocol.swift | 1 - .../{Internal => Shared/Toolkit}/UTI.swift | 26 +- .../ZIP/Minizip/MinizipContainer.swift | 1 - .../ZIPFoundationArchiveFactory.swift | 1 - .../ZIPFoundationContainer.swift | 1 - .../Streamer/Parser/Audio/AudioParser.swift | 1 - .../AudioPublicationManifestAugmentor.swift | 1 - .../Parser/EPUB/EPUBMetadataParser.swift | 1 - Sources/Streamer/Parser/EPUB/OPFParser.swift | 1 - .../EPUB/Services/EPUBPositionsService.swift | 1 - Sources/Streamer/Parser/PDF/PDFParser.swift | 1 - .../PDF/Services/LCPDFPositionsService.swift | 1 - .../LCPDFTableOfContentsService.swift | 1 - .../Parser/Readium/ReadiumWebPubParser.swift | 1 - Support/CocoaPods/ReadiumInternal.podspec | 21 -- Support/CocoaPods/ReadiumLCP.podspec | 1 - Support/CocoaPods/ReadiumNavigator.podspec | 1 - Support/CocoaPods/ReadiumOPDS.podspec | 1 - Support/CocoaPods/ReadiumShared.podspec | 1 - Support/CocoaPods/ReadiumStreamer.podspec | 1 - Support/CocoaPods/Specs.swift | 11 - TestApp/Integrations/Local/TestApp.xctestplan | 7 - .../Common/Toolkit/Extensions/Array.swift | 30 +++ .../Toolkit/Extensions/Collection.swift | 14 ++ .../Common/Toolkit/Extensions/Optional.swift | 30 +++ .../Common/Toolkit/Extensions/String.swift | 21 ++ .../Extensions/StringTests.swift | 17 -- Tests/InternalTests/KeychainTests.swift | 235 ------------------ Tests/LCPTests/KeychainTests.swift | 232 +++++++++++++++++ .../OPDS/OPDSAvailabilityTests.swift | 1 - .../Extensions/Audio/Locator+AudioTests.swift | 5 +- .../PDFResourceContentIteratorTests.swift | 1 - .../Resource/BufferingResourceTests.swift | 1 - .../Resource/TailCachingResourceTests.swift | 1 - .../Resource/TransformingResourceTests.swift | 1 - .../Extensions/Date+ISO8601Tests.swift | 2 +- .../Toolkit}/Extensions/RangeTests.swift | 2 +- .../Toolkit}/Extensions/URLTests.swift | 2 +- .../Toolkit/HTTP/DefaultHTTPClientTests.swift | 1 - .../Parser/EPUB/EPUBManifestParserTests.swift | 1 - .../Parser/EPUB/EPUBMetadataParserTests.swift | 1 - scripts/release-publish-podspecs.sh | 1 - 163 files changed, 599 insertions(+), 703 deletions(-) delete mode 100644 Sources/Internal/Extensions/Optional.swift delete mode 100644 Sources/Internal/Extensions/Range.swift delete mode 100644 Sources/Internal/Extensions/URL.swift rename Sources/{Internal => Shared/Toolkit}/Extensions/Array.swift (83%) rename Sources/{Internal => Shared/Toolkit}/Extensions/Collection.swift (92%) rename Sources/{Internal => Shared/Toolkit}/Extensions/Comparable.swift (91%) rename Sources/{Internal => Shared/Toolkit}/Extensions/Data.swift (96%) rename Sources/{Internal => Shared/Toolkit}/Extensions/Date+ISO8601.swift (95%) create mode 100644 Sources/Shared/Toolkit/Extensions/Deprecations.swift rename Sources/{Internal => Shared/Toolkit}/Extensions/Double.swift (97%) rename Sources/{Internal => Shared/Toolkit}/Extensions/NSRegularExpression.swift (75%) rename Sources/{Internal => Shared/Toolkit}/Extensions/Number.swift (91%) rename Sources/{Internal => Shared/Toolkit}/Extensions/Result.swift (85%) rename Sources/{Internal => Shared/Toolkit}/Extensions/Sequence.swift (95%) rename Sources/{Internal => Shared/Toolkit}/Extensions/String.swift (57%) rename Sources/{Internal => Shared/Toolkit}/Extensions/Task.swift (57%) rename Sources/{Internal => Shared/Toolkit}/Extensions/UInt64.swift (93%) rename Sources/{Internal => Shared/Toolkit}/UTI.swift (78%) delete mode 100644 Support/CocoaPods/ReadiumInternal.podspec create mode 100644 TestApp/Sources/Common/Toolkit/Extensions/Array.swift create mode 100644 TestApp/Sources/Common/Toolkit/Extensions/Collection.swift create mode 100644 TestApp/Sources/Common/Toolkit/Extensions/Optional.swift create mode 100644 TestApp/Sources/Common/Toolkit/Extensions/String.swift delete mode 100644 Tests/InternalTests/Extensions/StringTests.swift delete mode 100644 Tests/InternalTests/KeychainTests.swift create mode 100644 Tests/LCPTests/KeychainTests.swift rename Tests/{InternalTests => SharedTests/Toolkit}/Extensions/Date+ISO8601Tests.swift (95%) rename Tests/{InternalTests => SharedTests/Toolkit}/Extensions/RangeTests.swift (99%) rename Tests/{InternalTests => SharedTests/Toolkit}/Extensions/URLTests.swift (96%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 163d7a6638..9e04b26a7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ All notable changes to this project will be documented in this file. Take a look ### Removed * The deprecated `ReadiumAdapterGCDWebServer` and `ReadiumAdapterLCPSQLite` adapter packages have been removed. +* The `ReadiumInternal` package has been removed. Its utilities were internal helpers and are now folded into `ReadiumShared` with `package` visibility. If you imported `ReadiumInternal` directly, remove the import. diff --git a/Package.swift b/Package.swift index 3c6239851f..1249ac14cf 100644 --- a/Package.swift +++ b/Package.swift @@ -31,7 +31,6 @@ let package = Package( .target( name: "ReadiumShared", dependencies: [ - "ReadiumInternal", "SwiftSoup", "Zip", .product(name: "ReadiumFuzi", package: "Fuzi"), @@ -82,7 +81,6 @@ let package = Package( .target( name: "ReadiumNavigator", dependencies: [ - "ReadiumInternal", "ReadiumShared", "DifferenceKit", "SwiftSoup", @@ -126,7 +124,6 @@ let package = Package( name: "ReadiumLCP", dependencies: [ "CryptoSwift", - "ReadiumInternal", "ReadiumShared", .product(name: "ReadiumZIPFoundation", package: "ZIPFoundation"), ], @@ -148,16 +145,6 @@ let package = Package( // path: "Tests/LCPTests" // ), - .target( - name: "ReadiumInternal", - path: "Sources/Internal" - ), - .testTarget( - name: "ReadiumInternalTests", - dependencies: ["ReadiumInternal"], - path: "Tests/InternalTests" - ), - // Shared test publications used across multiple test targets. .target( name: "TestPublications", diff --git a/Playground/Playground.xctestplan b/Playground/Playground.xctestplan index b3bf0b4dac..25536a82c6 100644 --- a/Playground/Playground.xctestplan +++ b/Playground/Playground.xctestplan @@ -19,13 +19,6 @@ } }, "testTargets" : [ - { - "target" : { - "containerPath" : "container:..", - "identifier" : "ReadiumInternalTests", - "name" : "ReadiumInternalTests" - } - }, { "target" : { "containerPath" : "container:..", diff --git a/Playground/Sources/App/Data/DocumentRepository.swift b/Playground/Sources/App/Data/DocumentRepository.swift index fc7e7d9d3b..fc9bc9abe8 100644 --- a/Playground/Sources/App/Data/DocumentRepository.swift +++ b/Playground/Sources/App/Data/DocumentRepository.swift @@ -33,7 +33,9 @@ import OSLog /// Returns the files at the given index offsets in the current `documents` /// list. func get(atOffsets offsets: IndexSet) -> [URL] { - offsets.compactMap { documents.getOrNil($0) } + offsets.compactMap { + documents.indices.contains($0) ? documents[$0] : nil + } } /// Copies `file` into the Documents directory, replacing any existing file diff --git a/Sources/Internal/Extensions/Optional.swift b/Sources/Internal/Extensions/Optional.swift deleted file mode 100644 index e9fe54171f..0000000000 --- a/Sources/Internal/Extensions/Optional.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// Copyright 2026 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 Optional { - /// Asynchronous variant of `map`. - @inlinable func asyncMap(_ transform: (Wrapped) async throws -> U) async rethrows -> U? { - switch self { - case let .some(wrapped): - return try await .some(transform(wrapped)) - case .none: - return .none - } - } - - /// Asynchronous variant of `flatMap`. - @inlinable func asyncFlatMap(_ transform: (Wrapped) async throws -> U?) async rethrows -> U? { - switch self { - case let .some(wrapped): - return try await transform(wrapped) - case .none: - return .none - } - } -} diff --git a/Sources/Internal/Extensions/Range.swift b/Sources/Internal/Extensions/Range.swift deleted file mode 100644 index 546ce91bc6..0000000000 --- a/Sources/Internal/Extensions/Range.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// Copyright 2026 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 Range where Bound == UInt64 { - func clampedToInt() -> Range { - clamped(to: 0 ..< UInt64(Int.max)) - } - - /// Parses an HTTP `Range` header value (RFC 7233) into a byte range. - /// - /// Supports: - /// - `bytes=0-1023` → `0..<1024` - /// - `bytes=1024-` → `1024.. 0 else { return nil } - let start = totalLength > suffix ? totalLength - suffix : 0 - self = start ..< totalLength - return - } - - let parts = spec.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false) - guard parts.count == 2, let start = UInt64(parts[0]) else { return nil } - - if parts[1].isEmpty { - // Open-ended range: bytes=N- - guard start < totalLength else { return nil } - self = start ..< totalLength - return - } - - // Closed range: bytes=N-M - guard let end = UInt64(parts[1]), end >= start else { return nil } - let clampedEnd = Swift.min(end + 1, totalLength) - guard start < clampedEnd else { return nil } - self = start ..< clampedEnd - } -} diff --git a/Sources/Internal/Extensions/URL.swift b/Sources/Internal/Extensions/URL.swift deleted file mode 100644 index a09e089b98..0000000000 --- a/Sources/Internal/Extensions/URL.swift +++ /dev/null @@ -1,78 +0,0 @@ -// -// Copyright 2026 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 - } - - /// Returns the first available URL by appending the given `pathComponent`. - /// - /// If `pathComponent` is already taken, then it appends a number to it. - func appendingUniquePathSegment(_ pathComponent: String? = nil) async -> URL { - /// Returns the first path component matching the given `validation` closure. - /// Numbers are appended to the path component until a valid candidate is found. - func uniquify(_ pathComponent: String?, validation: (String) -> Bool) async -> String { - let pathComponent = pathComponent ?? UUID().uuidString - var ext = (pathComponent as NSString).pathExtension - if !ext.isEmpty { - ext = ".\(ext)" - } - let pathComponentWithoutExtension = (pathComponent as NSString).deletingPathExtension - - var candidate = pathComponent - var i = 0 - while !validation(candidate) { - i += 1 - candidate = "\(pathComponentWithoutExtension) \(i)\(ext)" - } - return candidate - } - - let pathComponent = await uniquify(pathComponent) { candidate in - let destination = appendingPathComponent(candidate) - return !((try? destination.checkResourceIsReachable()) ?? false) - } - - return appendingPathComponent(pathComponent) - } - - /// Adds the given `newScheme` to the URL, but only if the URL doesn't already have one. - func addingSchemeWhenMissing(_ newScheme: String) -> URL { - guard scheme == nil else { - return self - } - - var components = URLComponents(url: self, resolvingAgainstBaseURL: true) - components?.scheme = newScheme - return components?.url ?? self - } -} diff --git a/Sources/LCP/Authentications/LCPDialog.swift b/Sources/LCP/Authentications/LCPDialog.swift index b961ee01b6..a5b9360a32 100644 --- a/Sources/LCP/Authentications/LCPDialog.swift +++ b/Sources/LCP/Authentications/LCPDialog.swift @@ -4,7 +4,6 @@ // available in the top-level LICENSE file of the project. // -import ReadiumInternal import ReadiumShared import SwiftUI diff --git a/Sources/LCP/Authentications/LCPDialogViewController.swift b/Sources/LCP/Authentications/LCPDialogViewController.swift index 4afd39691a..6923aac8b8 100644 --- a/Sources/LCP/Authentications/LCPDialogViewController.swift +++ b/Sources/LCP/Authentications/LCPDialogViewController.swift @@ -4,7 +4,6 @@ // available in the top-level LICENSE file of the project. // -import ReadiumInternal import ReadiumShared import SwiftUI import UIKit diff --git a/Sources/LCP/Content Protection/EncryptionParser.swift b/Sources/LCP/Content Protection/EncryptionParser.swift index 2f661a3629..191d61394c 100644 --- a/Sources/LCP/Content Protection/EncryptionParser.swift +++ b/Sources/LCP/Content Protection/EncryptionParser.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal import ReadiumShared func parseEncryptionData(in asset: ContainerAsset) async -> ReadResult<[AnyURL: ReadiumShared.Encryption]> { diff --git a/Sources/LCP/Content Protection/LCPContentProtection.swift b/Sources/LCP/Content Protection/LCPContentProtection.swift index 0a9a788c31..f2193bb6d1 100644 --- a/Sources/LCP/Content Protection/LCPContentProtection.swift +++ b/Sources/LCP/Content Protection/LCPContentProtection.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal import ReadiumShared final class LCPContentProtection: ContentProtection, Loggable { diff --git a/Sources/LCP/Content Protection/LCPDecryptor.swift b/Sources/LCP/Content Protection/LCPDecryptor.swift index ed74093967..86f23cf5b4 100644 --- a/Sources/LCP/Content Protection/LCPDecryptor.swift +++ b/Sources/LCP/Content Protection/LCPDecryptor.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal import ReadiumShared private let lcpScheme = "http://readium.org/2014/01/lcp" diff --git a/Sources/LCP/License/License.swift b/Sources/LCP/License/License.swift index 953243dbc5..854f3d7383 100644 --- a/Sources/LCP/License/License.swift +++ b/Sources/LCP/License/License.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal import ReadiumShared final class License: Loggable, Sendable { diff --git a/Sources/LCP/License/LicenseValidation.swift b/Sources/LCP/License/LicenseValidation.swift index 68d9bd3b23..0ecf887a94 100644 --- a/Sources/LCP/License/LicenseValidation.swift +++ b/Sources/LCP/License/LicenseValidation.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal import ReadiumShared typealias Context = Result diff --git a/Sources/LCP/Services/PassphrasesService.swift b/Sources/LCP/Services/PassphrasesService.swift index 4f3411f7d5..5d0404ddc1 100644 --- a/Sources/LCP/Services/PassphrasesService.swift +++ b/Sources/LCP/Services/PassphrasesService.swift @@ -6,7 +6,6 @@ import CryptoSwift import Foundation -import ReadiumInternal import ReadiumShared final class PassphrasesService: Loggable, Sendable { diff --git a/Sources/Navigator/Audiobook/Preferences/AudioPreferencesEditor.swift b/Sources/Navigator/Audiobook/Preferences/AudioPreferencesEditor.swift index 359956ff57..ae36da02e9 100644 --- a/Sources/Navigator/Audiobook/Preferences/AudioPreferencesEditor.swift +++ b/Sources/Navigator/Audiobook/Preferences/AudioPreferencesEditor.swift @@ -5,7 +5,7 @@ // import Foundation -import ReadiumInternal +import ReadiumShared /// Editor for a set of `AudioPreferences`. /// diff --git a/Sources/Navigator/Audiobook/PublicationMediaLoader.swift b/Sources/Navigator/Audiobook/PublicationMediaLoader.swift index 0c918bb768..c36d1a3208 100644 --- a/Sources/Navigator/Audiobook/PublicationMediaLoader.swift +++ b/Sources/Navigator/Audiobook/PublicationMediaLoader.swift @@ -6,7 +6,6 @@ import AVFoundation import Foundation -import ReadiumInternal import ReadiumShared /// Serves `Publication`'s `Resource`s as an `AVURLAsset`. diff --git a/Sources/Navigator/EPUB/CSS/ReadiumCSS.swift b/Sources/Navigator/EPUB/CSS/ReadiumCSS.swift index b8a00aab4f..b2cc55b060 100644 --- a/Sources/Navigator/EPUB/CSS/ReadiumCSS.swift +++ b/Sources/Navigator/EPUB/CSS/ReadiumCSS.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal import ReadiumShared struct ReadiumCSS { diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift index e45a69c928..572c295024 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift @@ -4,7 +4,6 @@ // available in the top-level LICENSE file of the project. // -import ReadiumInternal import ReadiumShared import SafariServices import SwiftSoup diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift index c7db21dd05..cf9aece9ce 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewModel.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal import ReadiumShared import UIKit diff --git a/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift b/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift index 0efd18a762..74684baf8e 100644 --- a/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift +++ b/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal import ReadiumShared import UIKit import WebKit diff --git a/Sources/Navigator/EPUB/EPUBSpread.swift b/Sources/Navigator/EPUB/EPUBSpread.swift index 75843fc431..e067390383 100644 --- a/Sources/Navigator/EPUB/EPUBSpread.swift +++ b/Sources/Navigator/EPUB/EPUBSpread.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal import ReadiumShared /// Common interface for spread types. diff --git a/Sources/Navigator/EPUB/EPUBSpreadView.swift b/Sources/Navigator/EPUB/EPUBSpreadView.swift index f0c61b9cdc..71bc8fd9c4 100644 --- a/Sources/Navigator/EPUB/EPUBSpreadView.swift +++ b/Sources/Navigator/EPUB/EPUBSpreadView.swift @@ -4,7 +4,6 @@ // available in the top-level LICENSE file of the project. // -import ReadiumInternal import ReadiumShared @preconcurrency import WebKit diff --git a/Sources/Navigator/EPUB/EPUBViewportAndLocationCalculator.swift b/Sources/Navigator/EPUB/EPUBViewportAndLocationCalculator.swift index 5575b90487..1320dd3ffb 100644 --- a/Sources/Navigator/EPUB/EPUBViewportAndLocationCalculator.swift +++ b/Sources/Navigator/EPUB/EPUBViewportAndLocationCalculator.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal import ReadiumShared /// Computes the current `Locator` and `Viewport` from a spread's visible diff --git a/Sources/Navigator/EPUB/Preferences/EPUBPreferences+Legacy.swift b/Sources/Navigator/EPUB/Preferences/EPUBPreferences+Legacy.swift index 8918e83983..b70a9af8d4 100644 --- a/Sources/Navigator/EPUB/Preferences/EPUBPreferences+Legacy.swift +++ b/Sources/Navigator/EPUB/Preferences/EPUBPreferences+Legacy.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal import ReadiumShared public extension EPUBPreferences { diff --git a/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift b/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift index 27129ff2fc..248620fd53 100644 --- a/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift +++ b/Sources/Navigator/EPUB/Preferences/EPUBPreferences.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal import ReadiumShared /// Preferences for the `EPUBNavigatorViewController`. diff --git a/Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift b/Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift index 56d3e4b6ce..986a05af65 100644 --- a/Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift +++ b/Sources/Navigator/EPUB/Preferences/EPUBPreferencesEditor.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal import ReadiumShared /// Editor for a set of `EPUBPreferences`. diff --git a/Sources/Navigator/EPUB/WebViewServer.swift b/Sources/Navigator/EPUB/WebViewServer.swift index a1cbac3fb6..6316dfc8de 100644 --- a/Sources/Navigator/EPUB/WebViewServer.swift +++ b/Sources/Navigator/EPUB/WebViewServer.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal import ReadiumShared import WebKit diff --git a/Sources/Navigator/Navigator.swift b/Sources/Navigator/Navigator.swift index 7f9a0944df..863a1f1506 100644 --- a/Sources/Navigator/Navigator.swift +++ b/Sources/Navigator/Navigator.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal import ReadiumShared import SafariServices diff --git a/Sources/Navigator/PDF/PDFNavigatorViewController.swift b/Sources/Navigator/PDF/PDFNavigatorViewController.swift index 59ddf67b6a..73906ac7ac 100644 --- a/Sources/Navigator/PDF/PDFNavigatorViewController.swift +++ b/Sources/Navigator/PDF/PDFNavigatorViewController.swift @@ -6,7 +6,6 @@ import Foundation @preconcurrency import PDFKit -import ReadiumInternal import ReadiumShared import UIKit diff --git a/Sources/Navigator/PDF/PDFPageNumberResolver.swift b/Sources/Navigator/PDF/PDFPageNumberResolver.swift index 83bc204a07..064dc31793 100644 --- a/Sources/Navigator/PDF/PDFPageNumberResolver.swift +++ b/Sources/Navigator/PDF/PDFPageNumberResolver.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal import ReadiumShared /// Resolves a PDF page number from a `Locator`. diff --git a/Sources/Navigator/PDF/PDFViewportCalculator.swift b/Sources/Navigator/PDF/PDFViewportCalculator.swift index 8efb5bcc93..1928b1bc17 100644 --- a/Sources/Navigator/PDF/PDFViewportCalculator.swift +++ b/Sources/Navigator/PDF/PDFViewportCalculator.swift @@ -4,7 +4,6 @@ // available in the top-level LICENSE file of the project. // -import ReadiumInternal import ReadiumShared /// Computes the current `Locator` and `NavigatorViewport` from the focused and diff --git a/Sources/Navigator/PDF/Preferences/PDFSettings.swift b/Sources/Navigator/PDF/Preferences/PDFSettings.swift index 1e328380c9..4c24f6d742 100644 --- a/Sources/Navigator/PDF/Preferences/PDFSettings.swift +++ b/Sources/Navigator/PDF/Preferences/PDFSettings.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal import ReadiumShared /// Setting values of the `PDFNavigatorViewController`. diff --git a/Sources/Navigator/Preferences/MappedPreference.swift b/Sources/Navigator/Preferences/MappedPreference.swift index c284e8fb32..1104903b1a 100644 --- a/Sources/Navigator/Preferences/MappedPreference.swift +++ b/Sources/Navigator/Preferences/MappedPreference.swift @@ -5,7 +5,7 @@ // import Foundation -import ReadiumInternal +import ReadiumShared public extension Preference { /// Creates a new `Preference` object wrapping the receiver and converting diff --git a/Sources/Navigator/Preferences/ProgressionStrategy.swift b/Sources/Navigator/Preferences/ProgressionStrategy.swift index f3d8c59cac..090821a97e 100644 --- a/Sources/Navigator/Preferences/ProgressionStrategy.swift +++ b/Sources/Navigator/Preferences/ProgressionStrategy.swift @@ -5,7 +5,7 @@ // import Foundation -import ReadiumInternal +import ReadiumShared /// A strategy to increment or decrement a setting. public protocol ProgressionStrategy: Sendable { diff --git a/Sources/Navigator/Preferences/ProxyPreference.swift b/Sources/Navigator/Preferences/ProxyPreference.swift index 9c224fd457..f7b6356f9b 100644 --- a/Sources/Navigator/Preferences/ProxyPreference.swift +++ b/Sources/Navigator/Preferences/ProxyPreference.swift @@ -5,7 +5,7 @@ // import Foundation -import ReadiumInternal +import ReadiumShared public class ProxyPreference: Preference { private let _value: () -> Value? diff --git a/Sources/Navigator/Preferences/Types.swift b/Sources/Navigator/Preferences/Types.swift index b2380551be..a91d145cdf 100644 --- a/Sources/Navigator/Preferences/Types.swift +++ b/Sources/Navigator/Preferences/Types.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal import ReadiumShared import UIKit diff --git a/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift b/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift index adbff1c898..0d55840915 100644 --- a/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift +++ b/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift @@ -6,7 +6,6 @@ import AVFoundation import Foundation -import ReadiumInternal import ReadiumShared public protocol PublicationSpeechSynthesizerDelegate: AnyObject { diff --git a/Sources/Navigator/TTS/TTSVoice.swift b/Sources/Navigator/TTS/TTSVoice.swift index bc9dcc0939..a2f36a7034 100644 --- a/Sources/Navigator/TTS/TTSVoice.swift +++ b/Sources/Navigator/TTS/TTSVoice.swift @@ -6,7 +6,6 @@ import AVFoundation import Foundation -import ReadiumInternal import ReadiumShared /// Represents a voice provided by the TTS engine which can speak an utterance. diff --git a/Sources/Navigator/Toolkit/HTMLInjection.swift b/Sources/Navigator/Toolkit/HTMLInjection.swift index bbe7c0afff..4905036eed 100644 --- a/Sources/Navigator/Toolkit/HTMLInjection.swift +++ b/Sources/Navigator/Toolkit/HTMLInjection.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal import ReadiumShared /// An object that can be injected into an HTML document. diff --git a/Sources/Navigator/Toolkit/PaginationView.swift b/Sources/Navigator/Toolkit/PaginationView.swift index 73b6b988d9..ce3a850085 100644 --- a/Sources/Navigator/Toolkit/PaginationView.swift +++ b/Sources/Navigator/Toolkit/PaginationView.swift @@ -4,7 +4,6 @@ // available in the top-level LICENSE file of the project. // -import ReadiumInternal import ReadiumShared import UIKit diff --git a/Sources/Navigator/Viewport/ViewportProgressionCalculator.swift b/Sources/Navigator/Viewport/ViewportProgressionCalculator.swift index 16bf1d1f4a..b88310335c 100644 --- a/Sources/Navigator/Viewport/ViewportProgressionCalculator.swift +++ b/Sources/Navigator/Viewport/ViewportProgressionCalculator.swift @@ -4,7 +4,6 @@ // available in the top-level LICENSE file of the project. // -import ReadiumInternal import ReadiumShared /// Computes total publication progression from resource-level progressions and diff --git a/Sources/OPDS/OPDS1Parser.swift b/Sources/OPDS/OPDS1Parser.swift index 79468f0024..6f8384f7b6 100644 --- a/Sources/OPDS/OPDS1Parser.swift +++ b/Sources/OPDS/OPDS1Parser.swift @@ -6,7 +6,6 @@ import Foundation import ReadiumFuzi -import ReadiumInternal import ReadiumShared public enum OPDS1ParserError: Error, Sendable { diff --git a/Sources/OPDS/OPDS2Parser.swift b/Sources/OPDS/OPDS2Parser.swift index 4c9caeca34..8968586fa6 100644 --- a/Sources/OPDS/OPDS2Parser.swift +++ b/Sources/OPDS/OPDS2Parser.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal import ReadiumShared public enum OPDS2ParserError: Error, Sendable { diff --git a/Sources/Shared/OPDS/OPDSAcquisition.swift b/Sources/Shared/OPDS/OPDSAcquisition.swift index 1c4184aa5d..7235a46bf7 100644 --- a/Sources/Shared/OPDS/OPDSAcquisition.swift +++ b/Sources/Shared/OPDS/OPDSAcquisition.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// OPDS Acquisition Object /// https://specs.opds.io/schema/acquisition-object.schema.json diff --git a/Sources/Shared/OPDS/OPDSAvailability.swift b/Sources/Shared/OPDS/OPDSAvailability.swift index 0389abf566..3d16fa02f3 100644 --- a/Sources/Shared/OPDS/OPDSAvailability.swift +++ b/Sources/Shared/OPDS/OPDSAvailability.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// Indicated the availability of a given resource. /// https://specs.opds.io/schema/properties.schema.json diff --git a/Sources/Shared/OPDS/OPDSCopies.swift b/Sources/Shared/OPDS/OPDSCopies.swift index 5789508ac8..025d4a86a4 100644 --- a/Sources/Shared/OPDS/OPDSCopies.swift +++ b/Sources/Shared/OPDS/OPDSCopies.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// Library-specific feature that contains information about the copies that a library has acquired. /// https://specs.opds.io/schema/properties.schema.json diff --git a/Sources/Shared/OPDS/OPDSHolds.swift b/Sources/Shared/OPDS/OPDSHolds.swift index 523eb044e5..83ae684d1e 100644 --- a/Sources/Shared/OPDS/OPDSHolds.swift +++ b/Sources/Shared/OPDS/OPDSHolds.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// Library-specific features when a specific book is unavailable but provides a hold list. /// https://specs.opds.io/schema/properties.schema.json diff --git a/Sources/Shared/OPDS/OPDSPrice.swift b/Sources/Shared/OPDS/OPDSPrice.swift index 23ae2b4483..645c0dd92b 100644 --- a/Sources/Shared/OPDS/OPDSPrice.swift +++ b/Sources/Shared/OPDS/OPDSPrice.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// The price of a publication in an OPDS link. /// https://specs.opds.io/schema/properties.schema.json diff --git a/Sources/Shared/Publication/Accessibility/Accessibility.swift b/Sources/Shared/Publication/Accessibility/Accessibility.swift index 390ddd3bb9..43eb0c76b5 100644 --- a/Sources/Shared/Publication/Accessibility/Accessibility.swift +++ b/Sources/Shared/Publication/Accessibility/Accessibility.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// Holds the accessibility metadata of a Publication. /// diff --git a/Sources/Shared/Publication/Accessibility/AccessibilityMetadataDisplayGuide.swift b/Sources/Shared/Publication/Accessibility/AccessibilityMetadataDisplayGuide.swift index 62aa34b468..77e09b7e58 100644 --- a/Sources/Shared/Publication/Accessibility/AccessibilityMetadataDisplayGuide.swift +++ b/Sources/Shared/Publication/Accessibility/AccessibilityMetadataDisplayGuide.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// When presenting accessibility metadata provided by the publisher, it is /// suggested that the section is introduced using terms such as "claims" or diff --git a/Sources/Shared/Publication/Contributor.swift b/Sources/Shared/Publication/Contributor.swift index d5a0eaef6f..616711f469 100644 --- a/Sources/Shared/Publication/Contributor.swift +++ b/Sources/Shared/Publication/Contributor.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// https://readium.org/webpub-manifest/schema/contributor.schema.json public struct Contributor: Hashable, Sendable, JSONValueDecodable, JSONObjectEncodable { diff --git a/Sources/Shared/Publication/Extensions/EPUB/EPUBMediaOverlay.swift b/Sources/Shared/Publication/Extensions/EPUB/EPUBMediaOverlay.swift index c496d4a30c..ba0658ec37 100644 --- a/Sources/Shared/Publication/Extensions/EPUB/EPUBMediaOverlay.swift +++ b/Sources/Shared/Publication/Extensions/EPUB/EPUBMediaOverlay.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// EPUB Media Overlay metadata. /// https://readium.org/webpub-manifest/profiles/epub#5-metadata diff --git a/Sources/Shared/Publication/Extensions/EPUB/Properties+EPUB.swift b/Sources/Shared/Publication/Extensions/EPUB/Properties+EPUB.swift index 134a64e169..d8bd00a4ef 100644 --- a/Sources/Shared/Publication/Extensions/EPUB/Properties+EPUB.swift +++ b/Sources/Shared/Publication/Extensions/EPUB/Properties+EPUB.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// EPUB Link Properties Extension /// https://readium.org/webpub-manifest/schema/extensions/epub/properties.schema.json diff --git a/Sources/Shared/Publication/Extensions/Encryption/Encryption.swift b/Sources/Shared/Publication/Extensions/Encryption/Encryption.swift index 02309be192..03933e3a1c 100644 --- a/Sources/Shared/Publication/Extensions/Encryption/Encryption.swift +++ b/Sources/Shared/Publication/Extensions/Encryption/Encryption.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// Indicates that a resource is encrypted/obfuscated and provides relevant information for /// decryption. diff --git a/Sources/Shared/Publication/Extensions/HTML/DOMRange.swift b/Sources/Shared/Publication/Extensions/HTML/DOMRange.swift index 253642b91d..6cbca17468 100644 --- a/Sources/Shared/Publication/Extensions/HTML/DOMRange.swift +++ b/Sources/Shared/Publication/Extensions/HTML/DOMRange.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// This construct enables a serializable representation of a DOM Range. /// diff --git a/Sources/Shared/Publication/Extensions/OPDS/Properties+OPDS.swift b/Sources/Shared/Publication/Extensions/OPDS/Properties+OPDS.swift index cece2a9ff8..bc57ca3f81 100644 --- a/Sources/Shared/Publication/Extensions/OPDS/Properties+OPDS.swift +++ b/Sources/Shared/Publication/Extensions/OPDS/Properties+OPDS.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// OPDS Link Properties Extension /// https://specs.opds.io/schema/properties.schema.json diff --git a/Sources/Shared/Publication/GuidedNavigation/GuidedNavigationDocument.swift b/Sources/Shared/Publication/GuidedNavigation/GuidedNavigationDocument.swift index 39cd0af098..577c4605f7 100644 --- a/Sources/Shared/Publication/GuidedNavigation/GuidedNavigationDocument.swift +++ b/Sources/Shared/Publication/GuidedNavigation/GuidedNavigationDocument.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// Represents a Guided Navigation Document, as defined in the /// Readium Guided Navigation specification. diff --git a/Sources/Shared/Publication/GuidedNavigation/GuidedNavigationObject.swift b/Sources/Shared/Publication/GuidedNavigation/GuidedNavigationObject.swift index 433f13eb59..411c3d1026 100644 --- a/Sources/Shared/Publication/GuidedNavigation/GuidedNavigationObject.swift +++ b/Sources/Shared/Publication/GuidedNavigation/GuidedNavigationObject.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// Represents a single Guided Navigation Object, as defined in the /// Readium Guided Navigation specification. diff --git a/Sources/Shared/Publication/Link.swift b/Sources/Shared/Publication/Link.swift index 1277e96b0c..f8e4f3b36c 100644 --- a/Sources/Shared/Publication/Link.swift +++ b/Sources/Shared/Publication/Link.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal public enum LinkError: Error, Equatable, Sendable { /// The link's HREF is not a valid URL. diff --git a/Sources/Shared/Publication/LinkRelation.swift b/Sources/Shared/Publication/LinkRelation.swift index 985fd00175..fc1841af37 100644 --- a/Sources/Shared/Publication/LinkRelation.swift +++ b/Sources/Shared/Publication/LinkRelation.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// Link relations as defined in https://readium.org/webpub-manifest/relationships.html public struct LinkRelation: Hashable, Sendable, RawRepresentable, JSONValueEncodable { diff --git a/Sources/Shared/Publication/LocalizedString.swift b/Sources/Shared/Publication/LocalizedString.swift index f4cd6c8fda..7fe9c6e9c3 100644 --- a/Sources/Shared/Publication/LocalizedString.swift +++ b/Sources/Shared/Publication/LocalizedString.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// Represents a potentially localized string. /// Can be either: diff --git a/Sources/Shared/Publication/Locator.swift b/Sources/Shared/Publication/Locator.swift index c98596623b..4770a09755 100644 --- a/Sources/Shared/Publication/Locator.swift +++ b/Sources/Shared/Publication/Locator.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// https://github.com/readium/architecture/tree/master/locators public struct Locator: Hashable, Sendable, CustomStringConvertible, Loggable, JSONValueDecodable, JSONObjectEncodable { diff --git a/Sources/Shared/Publication/Manifest.swift b/Sources/Shared/Publication/Manifest.swift index d29ddc6de6..7022711bb9 100644 --- a/Sources/Shared/Publication/Manifest.swift +++ b/Sources/Shared/Publication/Manifest.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// Holds the metadata of a Readium publication, as described in the Readium Web Publication /// Manifest. diff --git a/Sources/Shared/Publication/Metadata.swift b/Sources/Shared/Publication/Metadata.swift index e902831b96..568fb5b800 100644 --- a/Sources/Shared/Publication/Metadata.swift +++ b/Sources/Shared/Publication/Metadata.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// Holds the metadata of a Readium publication, as described in the Readium Web Publication /// Manifest. diff --git a/Sources/Shared/Publication/Properties.swift b/Sources/Shared/Publication/Properties.swift index 173c772665..dc1d2466d8 100644 --- a/Sources/Shared/Publication/Properties.swift +++ b/Sources/Shared/Publication/Properties.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// Link Properties /// https://readium.org/webpub-manifest/schema/properties.schema.json diff --git a/Sources/Shared/Publication/Publication.swift b/Sources/Shared/Publication/Publication.swift index 32e059b1a0..ef09ff5cf2 100644 --- a/Sources/Shared/Publication/Publication.swift +++ b/Sources/Shared/Publication/Publication.swift @@ -6,7 +6,6 @@ import CoreServices import Foundation -import ReadiumInternal /// Shared model for a Readium Publication. public final class Publication: Sendable, Loggable { diff --git a/Sources/Shared/Publication/PublicationCollection.swift b/Sources/Shared/Publication/PublicationCollection.swift index 546901ce32..16d4f3d371 100644 --- a/Sources/Shared/Publication/PublicationCollection.swift +++ b/Sources/Shared/Publication/PublicationCollection.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// Core Collection Model /// https://readium.org/webpub-manifest/schema/subcollection.schema.json diff --git a/Sources/Shared/Publication/Services/Content/Content.swift b/Sources/Shared/Publication/Services/Content/Content.swift index a131c5a878..65a6a273f8 100644 --- a/Sources/Shared/Publication/Services/Content/Content.swift +++ b/Sources/Shared/Publication/Services/Content/Content.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// Provides an iterable list of `ContentElement`s. public protocol Content { diff --git a/Sources/Shared/Publication/Services/Content/ContentTokenizer.swift b/Sources/Shared/Publication/Services/Content/ContentTokenizer.swift index 789c008bd5..7445039461 100644 --- a/Sources/Shared/Publication/Services/Content/ContentTokenizer.swift +++ b/Sources/Shared/Publication/Services/Content/ContentTokenizer.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// A tokenizer splitting a `ContentElement` into smaller pieces. public typealias ContentTokenizer = Tokenizer diff --git a/Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift b/Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift index d1e781e16f..39a9c44094 100644 --- a/Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift +++ b/Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal import SwiftSoup /// Iterates an HTML `resource`, starting from the given `locator`. diff --git a/Sources/Shared/Publication/Services/Content/Iterators/PDFResourceContentIterator.swift b/Sources/Shared/Publication/Services/Content/Iterators/PDFResourceContentIterator.swift index bba68e5c35..ff154056c7 100644 --- a/Sources/Shared/Publication/Services/Content/Iterators/PDFResourceContentIterator.swift +++ b/Sources/Shared/Publication/Services/Content/Iterators/PDFResourceContentIterator.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal public enum PDFResourceContentIteratorError: Error, Sendable { /// The publication must have a ``PDFDocumentService`` to open the document. diff --git a/Sources/Shared/Publication/Services/Content/Iterators/PublicationContentIterator.swift b/Sources/Shared/Publication/Services/Content/Iterators/PublicationContentIterator.swift index 527f813cbf..d8a1bbbdb1 100644 --- a/Sources/Shared/Publication/Services/Content/Iterators/PublicationContentIterator.swift +++ b/Sources/Shared/Publication/Services/Content/Iterators/PublicationContentIterator.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal public protocol ResourceContentIteratorFactory: Sendable { /// Creates a `ContentIterator` instance for the `resource`, starting from diff --git a/Sources/Shared/Publication/Services/Locator/DefaultLocatorService.swift b/Sources/Shared/Publication/Services/Locator/DefaultLocatorService.swift index b59edd5f21..df4d8d151d 100644 --- a/Sources/Shared/Publication/Services/Locator/DefaultLocatorService.swift +++ b/Sources/Shared/Publication/Services/Locator/DefaultLocatorService.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// A default implementation of the `LocatorService` using the `PositionsService` to locate its inputs. public final class DefaultLocatorService: Sendable, LocatorService, Loggable { diff --git a/Sources/Shared/Publication/Services/Search/SearchService.swift b/Sources/Shared/Publication/Services/Search/SearchService.swift index 9c277e9709..319317b5be 100644 --- a/Sources/Shared/Publication/Services/Search/SearchService.swift +++ b/Sources/Shared/Publication/Services/Search/SearchService.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal public typealias SearchServiceFactory = @Sendable (PublicationServiceContext) -> SearchService? diff --git a/Sources/Shared/Publication/Subject.swift b/Sources/Shared/Publication/Subject.swift index 985cf5dba5..708bf9085f 100644 --- a/Sources/Shared/Publication/Subject.swift +++ b/Sources/Shared/Publication/Subject.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// https://github.com/readium/webpub-manifest/tree/master/contexts/default#subjects public struct Subject: Hashable, Sendable, JSONValueDecodable, JSONObjectEncodable { diff --git a/Sources/Shared/Publication/TDM.swift b/Sources/Shared/Publication/TDM.swift index a737c795e5..9746fd0812 100644 --- a/Sources/Shared/Publication/TDM.swift +++ b/Sources/Shared/Publication/TDM.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// Publications can indicate whether they allow third parties to use their /// content for text and data mining purposes using the [TDM Rep protocol](https://www.w3.org/community/tdmrep/), diff --git a/Sources/Shared/Toolkit/Archive/ArchiveProperties.swift b/Sources/Shared/Toolkit/Archive/ArchiveProperties.swift index 4837df58f5..a506ef8101 100644 --- a/Sources/Shared/Toolkit/Archive/ArchiveProperties.swift +++ b/Sources/Shared/Toolkit/Archive/ArchiveProperties.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// Holds information about how the resource is stored in the archive. public struct ArchiveProperties: Equatable, Sendable, JSONValueDecodable, JSONObjectEncodable { diff --git a/Sources/Shared/Toolkit/Data/Asset/AssetRetriever.swift b/Sources/Shared/Toolkit/Data/Asset/AssetRetriever.swift index 3aabd68358..093904a71d 100644 --- a/Sources/Shared/Toolkit/Data/Asset/AssetRetriever.swift +++ b/Sources/Shared/Toolkit/Data/Asset/AssetRetriever.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// Error while trying to retrieve an asset from a ``Resource`` or a /// ``Container``. diff --git a/Sources/Shared/Toolkit/Data/Container/Container.swift b/Sources/Shared/Toolkit/Data/Container/Container.swift index 31d2cfa8aa..c37e074a08 100644 --- a/Sources/Shared/Toolkit/Data/Container/Container.swift +++ b/Sources/Shared/Toolkit/Data/Container/Container.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// A container provides access to a list of `Resource` entries. public protocol Container: Sendable { diff --git a/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift b/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift index d949193aa8..735d8ab54b 100644 --- a/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/BufferingResource.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// Wraps an existing `Resource` and buffers its content. /// diff --git a/Sources/Shared/Toolkit/Data/Resource/ResourceContentExtractor.swift b/Sources/Shared/Toolkit/Data/Resource/ResourceContentExtractor.swift index 22a3355ed6..189ece7a33 100644 --- a/Sources/Shared/Toolkit/Data/Resource/ResourceContentExtractor.swift +++ b/Sources/Shared/Toolkit/Data/Resource/ResourceContentExtractor.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal import SwiftSoup /// Extracts pure content from a marked-up (e.g. HTML) or binary (e.g. PDF) resource. diff --git a/Sources/Shared/Toolkit/Data/Resource/TailCachingResource.swift b/Sources/Shared/Toolkit/Data/Resource/TailCachingResource.swift index 58ed9b15ae..318d7c1918 100644 --- a/Sources/Shared/Toolkit/Data/Resource/TailCachingResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/TailCachingResource.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// Caches in memory the tail of the given `resource`, starting from /// `cacheFromOffset`. diff --git a/Sources/Shared/Toolkit/Data/Resource/TransformingResource.swift b/Sources/Shared/Toolkit/Data/Resource/TransformingResource.swift index 7c010186e6..4d79a02a59 100644 --- a/Sources/Shared/Toolkit/Data/Resource/TransformingResource.swift +++ b/Sources/Shared/Toolkit/Data/Resource/TransformingResource.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// Transforms the bytes of `resource` on-the-fly. /// diff --git a/Sources/Shared/Toolkit/Data/Streamable.swift b/Sources/Shared/Toolkit/Data/Streamable.swift index b2cb98ba2b..3c4d5941cb 100644 --- a/Sources/Shared/Toolkit/Data/Streamable.swift +++ b/Sources/Shared/Toolkit/Data/Streamable.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// Acts as a proxy to an actual data source by handling read access. public protocol Streamable: Sendable { diff --git a/Sources/Shared/Toolkit/DocumentTypes.swift b/Sources/Shared/Toolkit/DocumentTypes.swift index 22a7e32f53..ff1ca56eb8 100644 --- a/Sources/Shared/Toolkit/DocumentTypes.swift +++ b/Sources/Shared/Toolkit/DocumentTypes.swift @@ -6,7 +6,6 @@ import CoreServices import Foundation -import ReadiumInternal #if canImport(UniformTypeIdentifiers) import UniformTypeIdentifiers diff --git a/Sources/Internal/Extensions/Array.swift b/Sources/Shared/Toolkit/Extensions/Array.swift similarity index 83% rename from Sources/Internal/Extensions/Array.swift rename to Sources/Shared/Toolkit/Extensions/Array.swift index 2cc4257601..c630090916 100644 --- a/Sources/Internal/Extensions/Array.swift +++ b/Sources/Shared/Toolkit/Extensions/Array.swift @@ -6,7 +6,7 @@ import Foundation -public extension Array { +package extension Array { init(builder: (inout Self) -> Void) { self.init() builder(&self) @@ -34,21 +34,15 @@ public extension Array { return removeFirst() } } - - @inlinable func appending(_ newElement: Element) -> Self { - var array = self - array.append(newElement) - return array - } } -public extension Array where Element: Equatable { +package extension Array where Element: Equatable { @inlinable func containsAny(_ elements: Element...) -> Bool { contains { elements.contains($0) } } } -public extension Array where Element: Hashable { +package extension Array where Element: Hashable { /// Creates a new `Array` after removing all the element duplicates. func removingDuplicates() -> Array { var result = Array() diff --git a/Sources/Internal/Extensions/Collection.swift b/Sources/Shared/Toolkit/Extensions/Collection.swift similarity index 92% rename from Sources/Internal/Extensions/Collection.swift rename to Sources/Shared/Toolkit/Extensions/Collection.swift index f8419f53d7..bfbcb8ab8e 100644 --- a/Sources/Internal/Extensions/Collection.swift +++ b/Sources/Shared/Toolkit/Extensions/Collection.swift @@ -6,7 +6,7 @@ import Foundation -public extension Collection { +package extension Collection { /// Returns the element at the specified index if it is within bounds, otherwise nil. func getOrNil(_ index: Index) -> Element? { indices.contains(index) ? self[index] : nil diff --git a/Sources/Internal/Extensions/Comparable.swift b/Sources/Shared/Toolkit/Extensions/Comparable.swift similarity index 91% rename from Sources/Internal/Extensions/Comparable.swift rename to Sources/Shared/Toolkit/Extensions/Comparable.swift index 3b7480cac6..f689cbefae 100644 --- a/Sources/Internal/Extensions/Comparable.swift +++ b/Sources/Shared/Toolkit/Extensions/Comparable.swift @@ -6,7 +6,7 @@ import Foundation -public extension Comparable { +package extension Comparable { func clamped(to limits: ClosedRange) -> Self { min(max(self, limits.lowerBound), limits.upperBound) } diff --git a/Sources/Internal/Extensions/Data.swift b/Sources/Shared/Toolkit/Extensions/Data.swift similarity index 96% rename from Sources/Internal/Extensions/Data.swift rename to Sources/Shared/Toolkit/Extensions/Data.swift index 257b17a8fa..d57a2ccb85 100644 --- a/Sources/Internal/Extensions/Data.swift +++ b/Sources/Shared/Toolkit/Extensions/Data.swift @@ -6,7 +6,7 @@ import Foundation -public extension Data { +package extension Data { /// Reads a sub-range of `self` after shifting the given absolute range /// to be relative to `self`. subscript(_ range: Range, offsetBy dataStartOffset: UInt64) -> Data? { diff --git a/Sources/Internal/Extensions/Date+ISO8601.swift b/Sources/Shared/Toolkit/Extensions/Date+ISO8601.swift similarity index 95% rename from Sources/Internal/Extensions/Date+ISO8601.swift rename to Sources/Shared/Toolkit/Extensions/Date+ISO8601.swift index d115a3d576..00ec7d8fd5 100644 --- a/Sources/Internal/Extensions/Date+ISO8601.swift +++ b/Sources/Shared/Toolkit/Extensions/Date+ISO8601.swift @@ -6,13 +6,13 @@ import Foundation -public extension Date { +package extension Date { var iso8601: String { DateFormatter.iso8601.string(from: self) } } -public extension DateFormatter { +package extension DateFormatter { static let iso8601: DateFormatter = { let formatter = DateFormatter() formatter.calendar = Calendar(identifier: .iso8601) @@ -46,7 +46,7 @@ public extension DateFormatter { } } -public extension String { +package extension String { var dateFromISO8601: Date? { // Removing .SSSS precision if found. var string = self diff --git a/Sources/Shared/Toolkit/Extensions/Deprecations.swift b/Sources/Shared/Toolkit/Extensions/Deprecations.swift new file mode 100644 index 0000000000..18c6c1acbf --- /dev/null +++ b/Sources/Shared/Toolkit/Extensions/Deprecations.swift @@ -0,0 +1,110 @@ +// +// Copyright 2026 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 + +// The `ReadiumInternal` package was removed and its utilities are now internal +// (`package`) to `ReadiumShared`. A few of these helpers used to leak through +// `ReadiumShared`'s public API surface. The declarations below are kept as +// unavailable tombstones so that apps relying on them get a clear migration +// message instead of a cryptic error. The original implementations are kept so +// you can easily copy the helper into your own codebase if you still need it. + +public extension Array { + @available(*, unavailable, message: "This utility was an internal helper that leaked through ReadiumShared. It is no longer part of the public API. Copy it into your own codebase if you still need it.") + func appending(_ newElement: Element) -> Self { + var array = self + array.append(newElement) + return array + } +} + +public extension Result { + @available(*, unavailable, message: "This utility was an internal helper that leaked through ReadiumShared. It is no longer part of the public API. Copy it into your own codebase if you still need it.") + func get(or def: Success) -> Success { + (try? get()) ?? def + } + + @available(*, unavailable, message: "This utility was an internal helper that leaked through ReadiumShared. It is no longer part of the public API. Copy it into your own codebase if you still need it.") + func `catch`(_ recover: (Failure) -> Self) -> Self { + if case let .failure(error) = self { + return recover(error) + } + return self + } +} + +public extension String { + @available(*, unavailable, message: "This utility was an internal helper that leaked through ReadiumShared. It is no longer part of the public API. Copy it into your own codebase if you still need it.") + var sanitizedPathComponent: String { + // See https://superuser.com/a/358861 + let invalidCharacters = CharacterSet(charactersIn: "\\/:*?\"<>|") + .union(.newlines) + .union(.illegalCharacters) + .union(.controlCharacters) + + return components(separatedBy: invalidCharacters) + .joined(separator: " ") + } + + @available(*, unavailable, message: "This utility was an internal helper that leaked through ReadiumShared. It is no longer part of the public API. Copy it into your own codebase if you still need it.") + func addingPrefix(_ prefix: String) -> String { + if hasPrefix(prefix) { + return self + } else { + return prefix + self + } + } + + @available(*, unavailable, message: "This utility was an internal helper that leaked through ReadiumShared. It is no longer part of the public API. Copy it into your own codebase if you still need it.") + func replacingPrefix(_ prefix: String, by replacement: String) -> String { + guard hasPrefix(prefix) else { + return self + } + return replacement + dropFirst(prefix.count) + } + + @available(*, unavailable, message: "This utility was an internal helper that leaked through ReadiumShared. It is no longer part of the public API. Copy it into your own codebase if you still need it.") + func substringBeforeLast(_ delimiter: String) -> String? { + guard let range = range(of: delimiter, options: [.backwards, .literal]) else { + return self + } + return String(self[..? { + @available(*, unavailable, message: "This utility was an internal helper that leaked through ReadiumShared. It is no longer part of the public API. Copy it into your own codebase if you still need it.") + mutating func replace(@_implicitSelfCapture with operation: sending @escaping @isolated(any) () async -> Void) { + self?.cancel() + self = Task(operation: operation) + } +} + +public extension URL { + @available(*, unavailable, message: "This utility was an internal helper that leaked through ReadiumShared. It is no longer part of the public API. Copy it into your own codebase if you still need it.") + mutating func removeFragment() -> String? { + guard var components = URLComponents(url: self, resolvingAgainstBaseURL: true) else { + return nil + } + let fragment = components.fragment + components.fragment = nil + guard let result = components.url else { + return nil + } + self = result + return fragment + } + + @available(*, unavailable, message: "This utility was an internal helper that leaked through ReadiumShared. It is no longer part of the public API. Copy it into your own codebase if you still need it.") + func removingFragment() -> URL? { + guard var components = URLComponents(url: self, resolvingAgainstBaseURL: true) else { + return nil + } + components.fragment = nil + return components.url + } +} diff --git a/Sources/Internal/Extensions/Double.swift b/Sources/Shared/Toolkit/Extensions/Double.swift similarity index 97% rename from Sources/Internal/Extensions/Double.swift rename to Sources/Shared/Toolkit/Extensions/Double.swift index efe9dde61e..22a79a2cde 100644 --- a/Sources/Internal/Extensions/Double.swift +++ b/Sources/Shared/Toolkit/Extensions/Double.swift @@ -6,7 +6,7 @@ import Foundation -public extension Double { +package extension Double { var percentageString: String { formatPercentage() } diff --git a/Sources/Internal/Extensions/NSRegularExpression.swift b/Sources/Shared/Toolkit/Extensions/NSRegularExpression.swift similarity index 75% rename from Sources/Internal/Extensions/NSRegularExpression.swift rename to Sources/Shared/Toolkit/Extensions/NSRegularExpression.swift index 614d135e6c..9067f589c3 100644 --- a/Sources/Internal/Extensions/NSRegularExpression.swift +++ b/Sources/Shared/Toolkit/Extensions/NSRegularExpression.swift @@ -6,7 +6,7 @@ import Foundation -public extension NSRegularExpression { +package extension NSRegularExpression { convenience init(_ pattern: String, options: NSRegularExpression.Options = []) { do { try self.init(pattern: pattern, options: options) @@ -25,7 +25,7 @@ public extension NSRegularExpression { } } -public extension NSTextCheckingResult { +package extension NSTextCheckingResult { func range(in text: String) -> Range? { range.range(in: text) } @@ -40,7 +40,7 @@ public extension NSTextCheckingResult { } } -public extension NSRange { +package extension NSRange { func range(in text: String) -> Range? { guard location != NSNotFound else { return nil @@ -49,12 +49,12 @@ public extension NSRange { } } -public final class ReplacingRegularExpression: NSRegularExpression, @unchecked Sendable { - public typealias Replace = (NSTextCheckingResult, [String]) -> String +package final class ReplacingRegularExpression: NSRegularExpression, @unchecked Sendable { + package typealias Replace = (NSTextCheckingResult, [String]) -> String private let replace: Replace - public init(_ pattern: String, replace: @escaping Replace) { + package init(_ pattern: String, replace: @escaping Replace) { do { self.replace = replace try super.init(pattern: pattern) @@ -68,11 +68,11 @@ public final class ReplacingRegularExpression: NSRegularExpression, @unchecked S fatalError("init(coder:) has not been implemented") } - override public func replacementString(for result: NSTextCheckingResult, in string: String, offset: Int, template templ: String) -> String { + override package func replacementString(for result: NSTextCheckingResult, in string: String, offset: Int, template templ: String) -> String { replace(result, result.groups(in: string)) } - public func stringByReplacingMatches(in string: String, options: NSRegularExpression.MatchingOptions = []) -> String { + package func stringByReplacingMatches(in string: String, options: NSRegularExpression.MatchingOptions = []) -> String { let range = NSRange(string.startIndex..., in: string) return stringByReplacingMatches(in: string, options: options, range: range, withTemplate: "") } diff --git a/Sources/Internal/Extensions/Number.swift b/Sources/Shared/Toolkit/Extensions/Number.swift similarity index 91% rename from Sources/Internal/Extensions/Number.swift rename to Sources/Shared/Toolkit/Extensions/Number.swift index bd6a9da34b..090fe9c824 100644 --- a/Sources/Internal/Extensions/Number.swift +++ b/Sources/Shared/Toolkit/Extensions/Number.swift @@ -4,7 +4,7 @@ // available in the top-level LICENSE file of the project. // -public extension Numeric { +package extension Numeric { var kB: Self { self * 1024 } diff --git a/Sources/Shared/Toolkit/Extensions/Optional.swift b/Sources/Shared/Toolkit/Extensions/Optional.swift index 41fd1b947b..3e5b47e260 100644 --- a/Sources/Shared/Toolkit/Extensions/Optional.swift +++ b/Sources/Shared/Toolkit/Extensions/Optional.swift @@ -6,7 +6,7 @@ import Foundation -public extension Optional { +package extension Optional { /// Unwraps the optional or throws the given `error`. func orThrow(_ error: @autoclosure () -> Error) throws -> Wrapped { switch self { @@ -27,4 +27,24 @@ public extension Optional { } return value } + + /// Asynchronous variant of `map`. + @inlinable func asyncMap(_ transform: (Wrapped) async throws -> U) async rethrows -> U? { + switch self { + case let .some(wrapped): + return try await .some(transform(wrapped)) + case .none: + return .none + } + } + + /// Asynchronous variant of `flatMap`. + @inlinable func asyncFlatMap(_ transform: (Wrapped) async throws -> U?) async rethrows -> U? { + switch self { + case let .some(wrapped): + return try await transform(wrapped) + case .none: + return .none + } + } } diff --git a/Sources/Shared/Toolkit/Extensions/Range.swift b/Sources/Shared/Toolkit/Extensions/Range.swift index df5f1ebaff..d09e238e06 100644 --- a/Sources/Shared/Toolkit/Extensions/Range.swift +++ b/Sources/Shared/Toolkit/Extensions/Range.swift @@ -6,6 +6,52 @@ import Foundation +package extension Range where Bound == UInt64 { + func clampedToInt() -> Range { + clamped(to: 0 ..< UInt64(Int.max)) + } + + /// Parses an HTTP `Range` header value (RFC 7233) into a byte range. + /// + /// Supports: + /// - `bytes=0-1023` → `0..<1024` + /// - `bytes=1024-` → `1024.. 0 else { return nil } + let start = totalLength > suffix ? totalLength - suffix : 0 + self = start ..< totalLength + return + } + + let parts = spec.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2, let start = UInt64(parts[0]) else { return nil } + + if parts[1].isEmpty { + // Open-ended range: bytes=N- + guard start < totalLength else { return nil } + self = start ..< totalLength + return + } + + // Closed range: bytes=N-M + guard let end = UInt64(parts[1]), end >= start else { return nil } + let clampedEnd = Swift.min(end + 1, totalLength) + guard start < clampedEnd else { return nil } + self = start ..< clampedEnd + } +} + extension Range where Bound == String.Index { /// Trims leading and trailing whitespaces and newlines from this range in the given `string`. func trimmingWhitespaces(in string: String) -> Self { diff --git a/Sources/Internal/Extensions/Result.swift b/Sources/Shared/Toolkit/Extensions/Result.swift similarity index 85% rename from Sources/Internal/Extensions/Result.swift rename to Sources/Shared/Toolkit/Extensions/Result.swift index 35f409d38c..f21417c9b4 100644 --- a/Sources/Internal/Extensions/Result.swift +++ b/Sources/Shared/Toolkit/Extensions/Result.swift @@ -6,22 +6,11 @@ import Foundation -public extension Result { +package extension Result { func getOrNil() -> Success? { try? get() } - func get(or def: Success) -> Success { - (try? get()) ?? def - } - - func `catch`(_ recover: (Failure) -> Self) -> Self { - if case let .failure(error) = self { - return recover(error) - } - return self - } - func eraseToAnyError() -> Result { mapError { $0 as Error } } @@ -68,7 +57,7 @@ public extension Result { } } -public extension Result where Failure == Error { +package extension Result where Failure == Error { func tryMap( _ transform: (Success) throws -> NewSuccess ) -> Result { diff --git a/Sources/Internal/Extensions/Sequence.swift b/Sources/Shared/Toolkit/Extensions/Sequence.swift similarity index 95% rename from Sources/Internal/Extensions/Sequence.swift rename to Sources/Shared/Toolkit/Extensions/Sequence.swift index cc9ed5727f..2dc0ae6f64 100644 --- a/Sources/Internal/Extensions/Sequence.swift +++ b/Sources/Shared/Toolkit/Extensions/Sequence.swift @@ -6,7 +6,7 @@ import Foundation -public extension Sequence { +package extension Sequence { /// Asynchronous variant of `map`. @inlinable func asyncMap( _ transform: (Element) async throws -> NewElement diff --git a/Sources/Internal/Extensions/String.swift b/Sources/Shared/Toolkit/Extensions/String.swift similarity index 57% rename from Sources/Internal/Extensions/String.swift rename to Sources/Shared/Toolkit/Extensions/String.swift index 603687e799..d5b96b3a39 100644 --- a/Sources/Internal/Extensions/String.swift +++ b/Sources/Shared/Toolkit/Extensions/String.swift @@ -6,28 +6,7 @@ import Foundation -public extension String { - /// Returns this string after removing any character forbidden in a single path component. - var sanitizedPathComponent: String { - // See https://superuser.com/a/358861 - let invalidCharacters = CharacterSet(charactersIn: "\\/:*?\"<>|") - .union(.newlines) - .union(.illegalCharacters) - .union(.controlCharacters) - - return components(separatedBy: invalidCharacters) - .joined(separator: " ") - } - - /// Returns a copy of the string after adding the given `prefix` if it's not already there. - func addingPrefix(_ prefix: String) -> String { - if hasPrefix(prefix) { - return self - } else { - return prefix + self - } - } - +package extension String { /// Returns a copy of the string after removing the given `prefix`, when present. func removingPrefix(_ prefix: String) -> String { guard hasPrefix(prefix) else { @@ -36,14 +15,6 @@ public extension String { return String(dropFirst(prefix.count)) } - /// Replaces the `prefix`, if present, by the given `replacement` prefix. - func replacingPrefix(_ prefix: String, by replacement: String) -> String { - guard hasPrefix(prefix) else { - return self - } - return removingPrefix(prefix).addingPrefix(replacement) - } - /// Returns a copy of the string after adding the given `suffix` if it's not already there. func addingSuffix(_ suffix: String) -> String { if hasSuffix(suffix) { @@ -61,15 +32,6 @@ public extension String { return String(dropLast(suffix.count)) } - /// Returns a substring before the last occurrence of `delimiter`. - /// If the string does not contain the delimiter, returns the original string itself. - func substringBeforeLast(_ delimiter: String) -> String? { - guard let range = range(of: delimiter, options: [.backwards, .literal]) else { - return self - } - return String(self[.. String { replacingOccurrences(of: "[\\s\n]+", with: " ", options: .regularExpression, range: nil) diff --git a/Sources/Internal/Extensions/Task.swift b/Sources/Shared/Toolkit/Extensions/Task.swift similarity index 57% rename from Sources/Internal/Extensions/Task.swift rename to Sources/Shared/Toolkit/Extensions/Task.swift index 260826a807..9409dd7a5e 100644 --- a/Sources/Internal/Extensions/Task.swift +++ b/Sources/Shared/Toolkit/Extensions/Task.swift @@ -6,19 +6,13 @@ import Foundation -public extension Task where Success == Never, Failure == Never { +package extension Task where Success == Never, Failure == Never { static func sleep(seconds: TimeInterval) async throws { try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) } } -public extension Task? { - /// Cancels the current task and starts a new one. - mutating func replace(@_implicitSelfCapture with operation: sending @escaping @isolated(any) () async -> Void) { - self?.cancel() - self = Task(operation: operation) - } - +package extension Task? { /// Cancels and nils out the task. mutating func cancel() { self?.cancel() diff --git a/Sources/Internal/Extensions/UInt64.swift b/Sources/Shared/Toolkit/Extensions/UInt64.swift similarity index 93% rename from Sources/Internal/Extensions/UInt64.swift rename to Sources/Shared/Toolkit/Extensions/UInt64.swift index cc8704c9d6..a27c239b07 100644 --- a/Sources/Internal/Extensions/UInt64.swift +++ b/Sources/Shared/Toolkit/Extensions/UInt64.swift @@ -4,7 +4,7 @@ // available in the top-level LICENSE file of the project. // -public extension UInt64 { +package extension UInt64 { func ceilMultiple(of divisor: UInt64) -> UInt64 { divisor * (self / divisor + ((self % divisor == 0) ? 0 : 1)) } diff --git a/Sources/Shared/Toolkit/File/FileResource.swift b/Sources/Shared/Toolkit/File/FileResource.swift index c96cb33e42..77117b89a4 100644 --- a/Sources/Shared/Toolkit/File/FileResource.swift +++ b/Sources/Shared/Toolkit/File/FileResource.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// Creates a `Resource` serving the contents of a local file. public actor FileResource: Resource, Loggable { diff --git a/Sources/Shared/Toolkit/Format/Format.swift b/Sources/Shared/Toolkit/Format/Format.swift index a4728bbb50..03f71745d1 100644 --- a/Sources/Shared/Toolkit/Format/Format.swift +++ b/Sources/Shared/Toolkit/Format/Format.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// Represents and holds information about the document format of an asset. public struct Format: Hashable, Sendable { diff --git a/Sources/Shared/Toolkit/Format/FormatSniffer.swift b/Sources/Shared/Toolkit/Format/FormatSniffer.swift index 0504d47ead..971a985bc2 100644 --- a/Sources/Shared/Toolkit/Format/FormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/FormatSniffer.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal public protocol HintsFormatSniffer: Sendable { /// Tries to guess a `Format` from media type and file extension hints. diff --git a/Sources/Shared/Toolkit/Format/FormatSnifferBlob.swift b/Sources/Shared/Toolkit/Format/FormatSnifferBlob.swift index 5a8284ac50..b1c7393132 100644 --- a/Sources/Shared/Toolkit/Format/FormatSnifferBlob.swift +++ b/Sources/Shared/Toolkit/Format/FormatSnifferBlob.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal public actor FormatSnifferBlob { private let source: Streamable diff --git a/Sources/Shared/Toolkit/Format/MediaType.swift b/Sources/Shared/Toolkit/Format/MediaType.swift index f5b37dbd36..c63ebf0f39 100644 --- a/Sources/Shared/Toolkit/Format/MediaType.swift +++ b/Sources/Shared/Toolkit/Format/MediaType.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// Represents a RFC 6838 media type. /// diff --git a/Sources/Shared/Toolkit/Format/Sniffers/CompositeFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/CompositeFormatSniffer.swift index 7dbf81be59..449e221c7c 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/CompositeFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/CompositeFormatSniffer.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal public final class CompositeFormatSniffer: FormatSniffer { private let sniffers: [FormatSniffer] diff --git a/Sources/Shared/Toolkit/Format/Sniffers/EPUBFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/EPUBFormatSniffer.swift index d63505bc96..89e6947504 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/EPUBFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/EPUBFormatSniffer.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// Sniffs an EPUB publication. /// diff --git a/Sources/Shared/Toolkit/Format/Sniffers/HTMLFormatSniffer.swift b/Sources/Shared/Toolkit/Format/Sniffers/HTMLFormatSniffer.swift index 1ba558ea4d..bb53a64117 100644 --- a/Sources/Shared/Toolkit/Format/Sniffers/HTMLFormatSniffer.swift +++ b/Sources/Shared/Toolkit/Format/Sniffers/HTMLFormatSniffer.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// Sniffs an HTML or XHTML document. public struct HTMLFormatSniffer: FormatSniffer, Sendable { diff --git a/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift b/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift index b0e82dd658..fc8bc415c5 100644 --- a/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift +++ b/Sources/Shared/Toolkit/HTTP/DefaultHTTPClient.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal public enum URLAuthenticationChallengeResponse: Sendable { /// Use the specified credential. diff --git a/Sources/Shared/Toolkit/HTTP/HTTPClient.swift b/Sources/Shared/Toolkit/HTTP/HTTPClient.swift index 4af6c0f2af..0db0f4d1bb 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPClient.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPClient.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal #if canImport(UIKit) import UIKit #endif diff --git a/Sources/Shared/Toolkit/JSONValue.swift b/Sources/Shared/Toolkit/JSONValue.swift index 1d3a8e4529..338536637a 100644 --- a/Sources/Shared/Toolkit/JSONValue.swift +++ b/Sources/Shared/Toolkit/JSONValue.swift @@ -6,7 +6,6 @@ import CoreFoundation import Foundation -import ReadiumInternal /// A type-safe representation of a JSON value. /// diff --git a/Sources/Shared/Toolkit/Poller.swift b/Sources/Shared/Toolkit/Poller.swift index 5411041d6e..68b689f08e 100644 --- a/Sources/Shared/Toolkit/Poller.swift +++ b/Sources/Shared/Toolkit/Poller.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal private final class Poller: Sendable { private let condition: @Sendable @MainActor () -> Bool diff --git a/Sources/Shared/Toolkit/Throttle.swift b/Sources/Shared/Toolkit/Throttle.swift index 4481d0be38..d95f72c8b9 100644 --- a/Sources/Shared/Toolkit/Throttle.swift +++ b/Sources/Shared/Toolkit/Throttle.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal @MainActor private final class ThrottlerState: Sendable { diff --git a/Sources/Shared/Toolkit/URL/Absolute URL/AbsoluteURL.swift b/Sources/Shared/Toolkit/URL/Absolute URL/AbsoluteURL.swift index 3893f8b063..363e166ce8 100644 --- a/Sources/Shared/Toolkit/URL/Absolute URL/AbsoluteURL.swift +++ b/Sources/Shared/Toolkit/URL/Absolute URL/AbsoluteURL.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// A type that can represent an absolute URL with a scheme. public protocol AbsoluteURL: URLProtocol { diff --git a/Sources/Shared/Toolkit/URL/Absolute URL/FileURL.swift b/Sources/Shared/Toolkit/URL/Absolute URL/FileURL.swift index 23a74887e1..f69927effb 100644 --- a/Sources/Shared/Toolkit/URL/Absolute URL/FileURL.swift +++ b/Sources/Shared/Toolkit/URL/Absolute URL/FileURL.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// Represents an absolute URL with the special scheme `file`. /// diff --git a/Sources/Shared/Toolkit/URL/AnyURL.swift b/Sources/Shared/Toolkit/URL/AnyURL.swift index 8a341f0522..402f7282c0 100644 --- a/Sources/Shared/Toolkit/URL/AnyURL.swift +++ b/Sources/Shared/Toolkit/URL/AnyURL.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// Represents either an absolute or relative URL. /// diff --git a/Sources/Shared/Toolkit/URL/RelativeURL.swift b/Sources/Shared/Toolkit/URL/RelativeURL.swift index 7e9f4f3214..b84f66f76b 100644 --- a/Sources/Shared/Toolkit/URL/RelativeURL.swift +++ b/Sources/Shared/Toolkit/URL/RelativeURL.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// Represents a relative URL. public struct RelativeURL: URLProtocol, Hashable { diff --git a/Sources/Shared/Toolkit/URL/URITemplate.swift b/Sources/Shared/Toolkit/URL/URITemplate.swift index d73d9da81a..ef0c0b20f8 100644 --- a/Sources/Shared/Toolkit/URL/URITemplate.swift +++ b/Sources/Shared/Toolkit/URL/URITemplate.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// A lightweight implementation of URI Template (RFC 6570). /// diff --git a/Sources/Shared/Toolkit/URL/URLExtensions.swift b/Sources/Shared/Toolkit/URL/URLExtensions.swift index 9252f7ab45..ff9513246a 100644 --- a/Sources/Shared/Toolkit/URL/URLExtensions.swift +++ b/Sources/Shared/Toolkit/URL/URLExtensions.swift @@ -22,6 +22,7 @@ extension URL { } } + /// 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 @@ -29,4 +30,46 @@ extension URL { changes(&components) return components.url } + + /// Returns the first available URL by appending the given `pathComponent`. + /// + /// If `pathComponent` is already taken, then it appends a number to it. + func appendingUniquePathSegment(_ pathComponent: String? = nil) async -> URL { + /// Returns the first path component matching the given `validation` closure. + /// Numbers are appended to the path component until a valid candidate is found. + func uniquify(_ pathComponent: String?, validation: (String) -> Bool) async -> String { + let pathComponent = pathComponent ?? UUID().uuidString + var ext = (pathComponent as NSString).pathExtension + if !ext.isEmpty { + ext = ".\(ext)" + } + let pathComponentWithoutExtension = (pathComponent as NSString).deletingPathExtension + + var candidate = pathComponent + var i = 0 + while !validation(candidate) { + i += 1 + candidate = "\(pathComponentWithoutExtension) \(i)\(ext)" + } + return candidate + } + + let pathComponent = await uniquify(pathComponent) { candidate in + let destination = appendingPathComponent(candidate) + return !((try? destination.checkResourceIsReachable()) ?? false) + } + + return appendingPathComponent(pathComponent) + } + + /// Adds the given `newScheme` to the URL, but only if the URL doesn't already have one. + package func addingSchemeWhenMissing(_ newScheme: String) -> URL { + guard scheme == nil else { + return self + } + + var components = URLComponents(url: self, resolvingAgainstBaseURL: true) + components?.scheme = newScheme + return components?.url ?? self + } } diff --git a/Sources/Shared/Toolkit/URL/URLProtocol.swift b/Sources/Shared/Toolkit/URL/URLProtocol.swift index 25fae7dbfd..f8b2d04914 100644 --- a/Sources/Shared/Toolkit/URL/URLProtocol.swift +++ b/Sources/Shared/Toolkit/URL/URLProtocol.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal /// A type that can represent a URL. public protocol URLProtocol: URLConvertible, Sendable, CustomStringConvertible { diff --git a/Sources/Internal/UTI.swift b/Sources/Shared/Toolkit/UTI.swift similarity index 78% rename from Sources/Internal/UTI.swift rename to Sources/Shared/Toolkit/UTI.swift index c9ac46bda7..25595a230b 100644 --- a/Sources/Internal/UTI.swift +++ b/Sources/Shared/Toolkit/UTI.swift @@ -8,49 +8,49 @@ import Foundation import UniformTypeIdentifiers /// Uniform Type Identifier. -public struct UTI: Sendable { +package struct UTI: Sendable { /// Type tag class, eg. UTTagClass.mimeType. - public enum TagClass: Sendable { + package enum TagClass: Sendable { case mediaType, fileExtension } - public let type: UTType + package let type: UTType - public init(type: UTType) { + package init(type: UTType) { self.type = type } - public init?(_ identifier: String) { + package init?(_ identifier: String) { guard let type = UTType(identifier) else { return nil } self.init(type: type) } - public init?(mediaType: String) { + package init?(mediaType: String) { guard let type = UTType(mimeType: mediaType) else { return nil } self.init(type: type) } - public init?(fileExtension: String) { + package init?(fileExtension: String) { guard let type = UTType(filenameExtension: fileExtension) else { return nil } self.init(type: type) } - public var name: String? { + package var name: String? { type.localizedDescription } - public var string: String { + package var string: String { type.identifier } /// Returns the preferred tag for this `UTI`, with the given type `tagClass`. - public func preferredTag(withClass tagClass: TagClass) -> String? { + package func preferredTag(withClass tagClass: TagClass) -> String? { switch tagClass { case .mediaType: return type.preferredMIMEType @@ -60,7 +60,7 @@ public struct UTI: Sendable { } /// Returns all tags for this `UTI`, with the given type `tagClass`. - public func tags(withClass tagClass: TagClass) -> [String] { + package func tags(withClass tagClass: TagClass) -> [String] { switch tagClass { case .mediaType: return type.tags[.mimeType] ?? [] @@ -70,7 +70,7 @@ public struct UTI: Sendable { } /// Finds the first `UTI` recognizing any of the given `mediaTypes` or `fileExtensions`. - public static func findFrom(mediaTypes: [String], fileExtensions: [String]) -> UTI? { + package static func findFrom(mediaTypes: [String], fileExtensions: [String]) -> UTI? { for mediaType in mediaTypes { if let uti = UTI(mediaType: mediaType) { return uti @@ -85,7 +85,7 @@ public struct UTI: Sendable { } } -public extension Array where Element == UTI { +package extension Array where Element == UTI { /// Returns the first preferred tag found in the list of `UTI`, with the given type `tagClass`. func preferredTag(withClass tagClass: UTI.TagClass) -> String? { for uti in self { diff --git a/Sources/Shared/Toolkit/ZIP/Minizip/MinizipContainer.swift b/Sources/Shared/Toolkit/ZIP/Minizip/MinizipContainer.swift index 61d6d280e3..b663ef20ca 100644 --- a/Sources/Shared/Toolkit/ZIP/Minizip/MinizipContainer.swift +++ b/Sources/Shared/Toolkit/ZIP/Minizip/MinizipContainer.swift @@ -6,7 +6,6 @@ import Foundation import Minizip -import ReadiumInternal /// A ZIP ``Container`` using the Minizip library. final class MinizipContainer: Container, Loggable { diff --git a/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveFactory.swift b/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveFactory.swift index 22a3798215..bcba380f81 100644 --- a/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveFactory.swift +++ b/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationArchiveFactory.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal import ReadiumZIPFoundation /// The ZIP End of Central Directory Record should be at most 65557 bytes, diff --git a/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift b/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift index dc78844eac..7aa3e61397 100644 --- a/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift +++ b/Sources/Shared/Toolkit/ZIP/ZIPFoundation/ZIPFoundationContainer.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal import ReadiumZIPFoundation /// A ZIP ``Container`` using the ZIPFoundation library. diff --git a/Sources/Streamer/Parser/Audio/AudioParser.swift b/Sources/Streamer/Parser/Audio/AudioParser.swift index b66a8f3378..821415a608 100644 --- a/Sources/Streamer/Parser/Audio/AudioParser.swift +++ b/Sources/Streamer/Parser/Audio/AudioParser.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal import ReadiumShared /// Parses an audiobook Publication from an unstructured archive format containing audio files, diff --git a/Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift b/Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift index 54ea3d1fe1..15d21ec90d 100644 --- a/Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift +++ b/Sources/Streamer/Parser/Audio/AudioPublicationManifestAugmentor.swift @@ -6,7 +6,6 @@ import AVFoundation import Foundation -import ReadiumInternal import ReadiumShared import UIKit diff --git a/Sources/Streamer/Parser/EPUB/EPUBMetadataParser.swift b/Sources/Streamer/Parser/EPUB/EPUBMetadataParser.swift index 66a8640a6f..f00443f470 100644 --- a/Sources/Streamer/Parser/EPUB/EPUBMetadataParser.swift +++ b/Sources/Streamer/Parser/EPUB/EPUBMetadataParser.swift @@ -6,7 +6,6 @@ import Foundation import ReadiumFuzi -import ReadiumInternal import ReadiumShared /// Reference: https://github.com/readium/architecture/blob/master/streamer/parser/metadata.md diff --git a/Sources/Streamer/Parser/EPUB/OPFParser.swift b/Sources/Streamer/Parser/EPUB/OPFParser.swift index fc83da9aa3..62c5406b54 100644 --- a/Sources/Streamer/Parser/EPUB/OPFParser.swift +++ b/Sources/Streamer/Parser/EPUB/OPFParser.swift @@ -6,7 +6,6 @@ import Foundation import ReadiumFuzi -import ReadiumInternal import ReadiumShared /// http://www.idpf.org/epub/30/spec/epub30-publications.html#title-type diff --git a/Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift b/Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift index 960b9373ba..4f35164458 100644 --- a/Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift +++ b/Sources/Streamer/Parser/EPUB/Services/EPUBPositionsService.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal import ReadiumShared /// Positions Service for an EPUB from its `readingOrder` and `fetcher`. diff --git a/Sources/Streamer/Parser/PDF/PDFParser.swift b/Sources/Streamer/Parser/PDF/PDFParser.swift index f7beadee32..0bd05c628d 100644 --- a/Sources/Streamer/Parser/PDF/PDFParser.swift +++ b/Sources/Streamer/Parser/PDF/PDFParser.swift @@ -6,7 +6,6 @@ import CoreGraphics import Foundation -import ReadiumInternal import ReadiumShared public final class PDFParser: PublicationParser, Loggable { diff --git a/Sources/Streamer/Parser/PDF/Services/LCPDFPositionsService.swift b/Sources/Streamer/Parser/PDF/Services/LCPDFPositionsService.swift index 67f36c84b3..dd11ab49ee 100644 --- a/Sources/Streamer/Parser/PDF/Services/LCPDFPositionsService.swift +++ b/Sources/Streamer/Parser/PDF/Services/LCPDFPositionsService.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal import ReadiumShared /// Generates positions for an LCPDF publication by opening each PDF resource diff --git a/Sources/Streamer/Parser/PDF/Services/LCPDFTableOfContentsService.swift b/Sources/Streamer/Parser/PDF/Services/LCPDFTableOfContentsService.swift index dee060dae4..2c8c38da38 100644 --- a/Sources/Streamer/Parser/PDF/Services/LCPDFTableOfContentsService.swift +++ b/Sources/Streamer/Parser/PDF/Services/LCPDFTableOfContentsService.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal import ReadiumShared /// Loads the table of contents of the single PDF resource in an LCPDF package, diff --git a/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift b/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift index f48e397eab..062a9c918a 100644 --- a/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift +++ b/Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal import ReadiumShared public enum ReadiumWebPubParserError: Error, Sendable { diff --git a/Support/CocoaPods/ReadiumInternal.podspec b/Support/CocoaPods/ReadiumInternal.podspec deleted file mode 100644 index b7377e16ac..0000000000 --- a/Support/CocoaPods/ReadiumInternal.podspec +++ /dev/null @@ -1,21 +0,0 @@ -# This file is generated by `make podspecs`. Do not edit manually. -# Edit Support/CocoaPods/Specs.swift and run `make podspecs` to regenerate. - -Pod::Spec.new do |s| - - s.name = "ReadiumInternal" - s.version = "3.11.0" - s.license = "BSD 3-Clause License" - s.summary = "Private utilities used by the Readium modules" - s.homepage = "http://readium.github.io" - s.author = { "Readium" => "contact@readium.org" } - s.source = { :git => "https://github.com/readium/swift-toolkit.git", :tag => s.version } - s.requires_arc = true - s.source_files = "Sources/Internal/**/*.{m,h,swift}" - s.swift_version = '6.0' - s.platform = :ios - s.ios.deployment_target = "15.0" - s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } - s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-package-name Readium' } - -end diff --git a/Support/CocoaPods/ReadiumLCP.podspec b/Support/CocoaPods/ReadiumLCP.podspec index 4066871386..a500efcb38 100644 --- a/Support/CocoaPods/ReadiumLCP.podspec +++ b/Support/CocoaPods/ReadiumLCP.podspec @@ -24,7 +24,6 @@ Pod::Spec.new do |s| s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-package-name Readium' } - s.dependency 'ReadiumInternal', '~> 3.11.0' s.dependency 'ReadiumShared', '~> 3.11.0' s.dependency 'ReadiumZIPFoundation', '~> 3.0.1' s.dependency 'CryptoSwift', '~> 1.10.0' diff --git a/Support/CocoaPods/ReadiumNavigator.podspec b/Support/CocoaPods/ReadiumNavigator.podspec index e71fae0554..aa2d5208c4 100644 --- a/Support/CocoaPods/ReadiumNavigator.podspec +++ b/Support/CocoaPods/ReadiumNavigator.podspec @@ -23,7 +23,6 @@ Pod::Spec.new do |s| s.ios.deployment_target = "15.0" s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-package-name Readium' } - s.dependency 'ReadiumInternal', '~> 3.11.0' s.dependency 'ReadiumShared', '~> 3.11.0' s.dependency 'DifferenceKit', '~> 1.0' s.dependency 'SwiftSoup', '~> 2.11.0' diff --git a/Support/CocoaPods/ReadiumOPDS.podspec b/Support/CocoaPods/ReadiumOPDS.podspec index cf0f37accd..b9bc30afbd 100644 --- a/Support/CocoaPods/ReadiumOPDS.podspec +++ b/Support/CocoaPods/ReadiumOPDS.podspec @@ -18,7 +18,6 @@ Pod::Spec.new do |s| s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-package-name Readium' } - s.dependency 'ReadiumInternal', '~> 3.11.0' s.dependency 'ReadiumShared', '~> 3.11.0' s.dependency 'ReadiumFuzi', '~> 4.0.0' diff --git a/Support/CocoaPods/ReadiumShared.podspec b/Support/CocoaPods/ReadiumShared.podspec index 227ffcd46f..2d8ad4889f 100644 --- a/Support/CocoaPods/ReadiumShared.podspec +++ b/Support/CocoaPods/ReadiumShared.podspec @@ -23,7 +23,6 @@ Pod::Spec.new do |s| s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-package-name Readium' } - s.dependency 'ReadiumInternal', '~> 3.11.0' s.dependency 'Minizip', '~> 1.0.0' s.dependency 'SwiftSoup', '~> 2.11.0' s.dependency 'ReadiumFuzi', '~> 4.0.0' diff --git a/Support/CocoaPods/ReadiumStreamer.podspec b/Support/CocoaPods/ReadiumStreamer.podspec index 0f0de3b3e0..4603733f95 100644 --- a/Support/CocoaPods/ReadiumStreamer.podspec +++ b/Support/CocoaPods/ReadiumStreamer.podspec @@ -25,7 +25,6 @@ Pod::Spec.new do |s| s.xcconfig = { 'HEADER_SEARCH_PATHS' => '$(SDKROOT)/usr/include/libxml2' } s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-package-name Readium' } - s.dependency 'ReadiumInternal', '~> 3.11.0' s.dependency 'ReadiumShared', '~> 3.11.0' s.dependency 'ReadiumFuzi', '~> 4.0.0' s.dependency 'CryptoSwift', '~> 1.10.0' diff --git a/Support/CocoaPods/Specs.swift b/Support/CocoaPods/Specs.swift index 0b9928cf6c..6778daf516 100644 --- a/Support/CocoaPods/Specs.swift +++ b/Support/CocoaPods/Specs.swift @@ -42,12 +42,6 @@ enum Dependency { // MARK: - Module Definitions (ordered by podspec push order) let modules: [ModuleSpec] = [ - ModuleSpec( - name: "ReadiumInternal", - sourcePath: "Sources/Internal", - summary: "Private utilities used by the Readium modules", - xcconfig: ["HEADER_SEARCH_PATHS": "$(SDKROOT)/usr/include/libxml2"] - ), ModuleSpec( name: "ReadiumShared", sourcePath: "Sources/Shared", @@ -57,7 +51,6 @@ let modules: [ModuleSpec] = [ xcconfig: ["HEADER_SEARCH_PATHS": "$(SDKROOT)/usr/include/libxml2"], resourceBundles: ["ReadiumShared": ["Sources/Shared/Resources/**"]], dependencies: [ - .readium("ReadiumInternal"), .pod("Minizip", "~> 1.0.0"), // SwiftSoup's podspec is stuck at 2.11. .pod("SwiftSoup", "~> 2.11.0"), @@ -76,7 +69,6 @@ let modules: [ModuleSpec] = [ "Sources/Streamer/Assets", ]], dependencies: [ - .readium("ReadiumInternal"), .readium("ReadiumShared"), .pod("ReadiumFuzi", "~> 4.0.0"), .pod("CryptoSwift", "~> 1.10.0"), @@ -91,7 +83,6 @@ let modules: [ModuleSpec] = [ "Sources/Navigator/EPUB/Assets", ]], dependencies: [ - .readium("ReadiumInternal"), .readium("ReadiumShared"), .pod("DifferenceKit", "~> 1.0"), // SwiftSoup's podspec is stuck at 2.11. @@ -104,7 +95,6 @@ let modules: [ModuleSpec] = [ summary: "Readium OPDS", xcconfig: ["HEADER_SEARCH_PATHS": "$(SDKROOT)/usr/include/libxml2"], dependencies: [ - .readium("ReadiumInternal"), .readium("ReadiumShared"), .pod("ReadiumFuzi", "~> 4.0.0"), ] @@ -119,7 +109,6 @@ let modules: [ModuleSpec] = [ "Sources/LCP/**/*.xib", ]], dependencies: [ - .readium("ReadiumInternal"), .readium("ReadiumShared"), .pod("ReadiumZIPFoundation", "~> 3.0.1"), .pod("CryptoSwift", "~> 1.10.0"), diff --git a/TestApp/Integrations/Local/TestApp.xctestplan b/TestApp/Integrations/Local/TestApp.xctestplan index 1e2348e301..c1c83771d8 100644 --- a/TestApp/Integrations/Local/TestApp.xctestplan +++ b/TestApp/Integrations/Local/TestApp.xctestplan @@ -24,13 +24,6 @@ } }, "testTargets" : [ - { - "target" : { - "containerPath" : "container:..", - "identifier" : "ReadiumInternalTests", - "name" : "ReadiumInternalTests" - } - }, { "target" : { "containerPath" : "container:..", diff --git a/TestApp/Sources/Common/Toolkit/Extensions/Array.swift b/TestApp/Sources/Common/Toolkit/Extensions/Array.swift new file mode 100644 index 0000000000..cf769dca34 --- /dev/null +++ b/TestApp/Sources/Common/Toolkit/Extensions/Array.swift @@ -0,0 +1,30 @@ +// +// Copyright 2026 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 Array { + nonisolated func appending(_ newElement: Element) -> Self { + var array = self + array.append(newElement) + return array + } +} + +public extension Array where Element: Hashable { + /// Creates a new `Array` after removing all the element duplicates. + nonisolated func removingDuplicates() -> Array { + var result = Array() + var added = Set() + for element in self { + if !added.contains(element) { + result.append(element) + added.insert(element) + } + } + return result + } +} diff --git a/TestApp/Sources/Common/Toolkit/Extensions/Collection.swift b/TestApp/Sources/Common/Toolkit/Extensions/Collection.swift new file mode 100644 index 0000000000..11b0a8f0bf --- /dev/null +++ b/TestApp/Sources/Common/Toolkit/Extensions/Collection.swift @@ -0,0 +1,14 @@ +// +// Copyright 2026 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 Collection { + /// Returns the element at the specified index if it is within bounds, otherwise nil. + nonisolated func getOrNil(_ index: Index) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/TestApp/Sources/Common/Toolkit/Extensions/Optional.swift b/TestApp/Sources/Common/Toolkit/Extensions/Optional.swift new file mode 100644 index 0000000000..22109ffeb4 --- /dev/null +++ b/TestApp/Sources/Common/Toolkit/Extensions/Optional.swift @@ -0,0 +1,30 @@ +// +// Copyright 2026 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 Optional { + /// Unwraps the optional or throws the given `error`. + nonisolated func orThrow(_ error: @autoclosure () -> Error) throws -> Wrapped { + switch self { + case let .some(value): + return value + case .none: + throw error() + } + } + + /// Returns `nil` if the value doesn't pass the given `condition`. + nonisolated func takeIf(_ condition: (Wrapped) -> Bool) -> Self { + guard + case let .some(value) = self, + condition(value) + else { + return nil + } + return value + } +} diff --git a/TestApp/Sources/Common/Toolkit/Extensions/String.swift b/TestApp/Sources/Common/Toolkit/Extensions/String.swift new file mode 100644 index 0000000000..64ba253591 --- /dev/null +++ b/TestApp/Sources/Common/Toolkit/Extensions/String.swift @@ -0,0 +1,21 @@ +// +// Copyright 2026 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 String { + /// Returns this string after removing any character forbidden in a single path component. + nonisolated var sanitizedPathComponent: String { + // See https://superuser.com/a/358861 + let invalidCharacters = CharacterSet(charactersIn: "\\/:*?\"<>|") + .union(.newlines) + .union(.illegalCharacters) + .union(.controlCharacters) + + return components(separatedBy: invalidCharacters) + .joined(separator: " ") + } +} diff --git a/Tests/InternalTests/Extensions/StringTests.swift b/Tests/InternalTests/Extensions/StringTests.swift deleted file mode 100644 index 880c60f1d3..0000000000 --- a/Tests/InternalTests/Extensions/StringTests.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// Copyright 2026 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 -@testable import ReadiumInternal -import XCTest - -class StringTests: XCTestCase { - func testSubstringBeforeLast() { - XCTAssertEqual("href".substringBeforeLast("#"), "href") - XCTAssertEqual("href#anchor".substringBeforeLast("#"), "href") - XCTAssertEqual("href#anchor#test".substringBeforeLast("#"), "href#anchor") - } -} diff --git a/Tests/InternalTests/KeychainTests.swift b/Tests/InternalTests/KeychainTests.swift deleted file mode 100644 index c5c285a87f..0000000000 --- a/Tests/InternalTests/KeychainTests.swift +++ /dev/null @@ -1,235 +0,0 @@ -// -// Copyright 2026 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 -@testable import ReadiumInternal -import Testing - -// FIXME: Keychain testing require an host application with entitlements. -/* - @Suite struct KeychainTests { - let keychain: Keychain - let testServiceName = "org.readium.lcp.test.keychain-helper" - - init() throws { - keychain = Keychain( - serviceName: testServiceName, - synchronizable: false - ) - // Clean up any existing test data - try? keychain.deleteAll() - } - - // MARK: - Save Tests - - @Test func saveData() throws { - defer { try? keychain.deleteAll() } - - let testData = "Test Value".data(using: .utf8)! - try keychain.save(data: testData, forKey: "test-key") - - let retrieved = try keychain.load(forKey: "test-key") - #expect(retrieved == testData) - } - - @Test func saveDuplicateKeyThrowsError() throws { - defer { try? keychain.deleteAll() } - - let testData = "Test Value".data(using: .utf8)! - try keychain.save(data: testData, forKey: "duplicate-key") - - #expect(throws: KeychainError.self) { - try keychain.save(data: testData, forKey: "duplicate-key") - } - } - - @Test func saveMultipleKeys() throws { - defer { try? keychain.deleteAll() } - - let data1 = "Value 1".data(using: .utf8)! - let data2 = "Value 2".data(using: .utf8)! - let data3 = "Value 3".data(using: .utf8)! - - try keychain.save(data: data1, forKey: "key1") - try keychain.save(data: data2, forKey: "key2") - try keychain.save(data: data3, forKey: "key3") - - let loaded1 = try keychain.load(forKey: "key1") - let loaded2 = try keychain.load(forKey: "key2") - let loaded3 = try keychain.load(forKey: "key3") - #expect(loaded1 == data1) - #expect(loaded2 == data2) - #expect(loaded3 == data3) - } - - // MARK: - Load Tests - - @Test func loadNonExistentKeyReturnsNil() throws { - defer { try? keychain.deleteAll() } - - let result = try keychain.load(forKey: "non-existent") - #expect(result == nil) - } - - @Test func loadAfterSave() throws { - defer { try? keychain.deleteAll() } - - let testData = "Persistent Value".data(using: .utf8)! - try keychain.save(data: testData, forKey: "persistent-key") - - let loaded = try keychain.load(forKey: "persistent-key") - #expect(loaded == testData) - } - - // MARK: - Update Tests - - @Test func updateExistingKey() throws { - defer { try? keychain.deleteAll() } - - let originalData = "Original".data(using: .utf8)! - let updatedData = "Updated".data(using: .utf8)! - - try keychain.save(data: originalData, forKey: "update-key") - try keychain.update(data: updatedData, forKey: "update-key") - - let result = try keychain.load(forKey: "update-key") - #expect(result == updatedData) - } - - @Test func updateNonExistentKeyThrowsError() throws { - defer { try? keychain.deleteAll() } - - let testData = "Test".data(using: .utf8)! - - #expect(throws: KeychainError.self) { - try keychain.update(data: testData, forKey: "non-existent") - } - } - - // MARK: - Delete Tests - - @Test func deleteExistingKey() throws { - defer { try? keychain.deleteAll() } - - let testData = "Delete Me".data(using: .utf8)! - try keychain.save(data: testData, forKey: "delete-key") - - try keychain.delete(forKey: "delete-key") - - let result = try keychain.load(forKey: "delete-key") - #expect(result == nil) - } - - @Test func deleteNonExistentKeyDoesNotThrow() throws { - defer { try? keychain.deleteAll() } - - // Should not throw an error - #expect(throws: Never.self) { - try keychain.delete(forKey: "non-existent") - } - } - - // MARK: - DeleteAll Tests - - @Test func deleteAll() throws { - defer { try? keychain.deleteAll() } - - let data1 = "Value 1".data(using: .utf8)! - let data2 = "Value 2".data(using: .utf8)! - let data3 = "Value 3".data(using: .utf8)! - - try keychain.save(data: data1, forKey: "key1") - try keychain.save(data: data2, forKey: "key2") - try keychain.save(data: data3, forKey: "key3") - - try keychain.deleteAll() - - let loaded1 = try keychain.load(forKey: "key1") - let loaded2 = try keychain.load(forKey: "key2") - let loaded3 = try keychain.load(forKey: "key3") - #expect(loaded1 == nil) - #expect(loaded2 == nil) - #expect(loaded3 == nil) - } - - @Test func deleteAllWithNoItemsDoesNotThrow() throws { - #expect(throws: Never.self) { - try keychain.deleteAll() - } - } - - // MARK: - AllKeys Tests - - @Test func allKeysEmpty() throws { - defer { try? keychain.deleteAll() } - - let keys = try keychain.allKeys() - #expect(keys.isEmpty) - } - - @Test func allKeysReturnsSavedKeys() throws { - defer { try? keychain.deleteAll() } - - let data = "Test".data(using: .utf8)! - try keychain.save(data: data, forKey: "key1") - try keychain.save(data: data, forKey: "key2") - try keychain.save(data: data, forKey: "key3") - - let keys = try keychain.allKeys() - #expect(Set(keys) == Set(["key1", "key2", "key3"])) - } - - // MARK: - AllItems Tests - - @Test func allItemsEmpty() throws { - defer { try? keychain.deleteAll() } - - let items = try keychain.allItems() - #expect(items.isEmpty) - } - - @Test func allItemsReturnsSavedData() throws { - defer { try? keychain.deleteAll() } - - let data1 = "Value 1".data(using: .utf8)! - let data2 = "Value 2".data(using: .utf8)! - - try keychain.save(data: data1, forKey: "key1") - try keychain.save(data: data2, forKey: "key2") - - let items = try keychain.allItems() - #expect(items.count == 2) - #expect(items["key1"] == data1) - #expect(items["key2"] == data2) - } - - // MARK: - Service Isolation Tests - - @Test func serviceIsolation() throws { - // Create two keychains with different service names - let keychain1 = Keychain( - serviceName: "org.readium.lcp.test.service1", - synchronizable: false - ) - let keychain2 = Keychain( - serviceName: "org.readium.lcp.test.service2", - synchronizable: false - ) - - defer { - try? keychain1.deleteAll() - try? keychain2.deleteAll() - } - - let data = "Test".data(using: .utf8)! - try keychain1.save(data: data, forKey: "shared-key") - - // keychain2 should not see the data from keychain1 - let loaded = try keychain2.load(forKey: "shared-key") - #expect(loaded == nil) - } - } - */ diff --git a/Tests/LCPTests/KeychainTests.swift b/Tests/LCPTests/KeychainTests.swift new file mode 100644 index 0000000000..ed38e62343 --- /dev/null +++ b/Tests/LCPTests/KeychainTests.swift @@ -0,0 +1,232 @@ +// +// Copyright 2026 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 +@testable import ReadiumInternal +import Testing + +struct KeychainTests { + let keychain: Keychain + let testServiceName = "org.readium.lcp.test.keychain-helper" + + init() throws { + keychain = Keychain( + serviceName: testServiceName, + synchronizable: false + ) + // Clean up any existing test data + try? keychain.deleteAll() + } + + // MARK: - Save Tests + + @Test func saveData() throws { + defer { try? keychain.deleteAll() } + + let testData = "Test Value".data(using: .utf8)! + try keychain.save(data: testData, forKey: "test-key") + + let retrieved = try keychain.load(forKey: "test-key") + #expect(retrieved == testData) + } + + @Test func saveDuplicateKeyThrowsError() throws { + defer { try? keychain.deleteAll() } + + let testData = "Test Value".data(using: .utf8)! + try keychain.save(data: testData, forKey: "duplicate-key") + + #expect(throws: KeychainError.self) { + try keychain.save(data: testData, forKey: "duplicate-key") + } + } + + @Test func saveMultipleKeys() throws { + defer { try? keychain.deleteAll() } + + let data1 = "Value 1".data(using: .utf8)! + let data2 = "Value 2".data(using: .utf8)! + let data3 = "Value 3".data(using: .utf8)! + + try keychain.save(data: data1, forKey: "key1") + try keychain.save(data: data2, forKey: "key2") + try keychain.save(data: data3, forKey: "key3") + + let loaded1 = try keychain.load(forKey: "key1") + let loaded2 = try keychain.load(forKey: "key2") + let loaded3 = try keychain.load(forKey: "key3") + #expect(loaded1 == data1) + #expect(loaded2 == data2) + #expect(loaded3 == data3) + } + + // MARK: - Load Tests + + @Test func loadNonExistentKeyReturnsNil() throws { + defer { try? keychain.deleteAll() } + + let result = try keychain.load(forKey: "non-existent") + #expect(result == nil) + } + + @Test func loadAfterSave() throws { + defer { try? keychain.deleteAll() } + + let testData = "Persistent Value".data(using: .utf8)! + try keychain.save(data: testData, forKey: "persistent-key") + + let loaded = try keychain.load(forKey: "persistent-key") + #expect(loaded == testData) + } + + // MARK: - Update Tests + + @Test func updateExistingKey() throws { + defer { try? keychain.deleteAll() } + + let originalData = "Original".data(using: .utf8)! + let updatedData = "Updated".data(using: .utf8)! + + try keychain.save(data: originalData, forKey: "update-key") + try keychain.update(data: updatedData, forKey: "update-key") + + let result = try keychain.load(forKey: "update-key") + #expect(result == updatedData) + } + + @Test func updateNonExistentKeyThrowsError() throws { + defer { try? keychain.deleteAll() } + + let testData = "Test".data(using: .utf8)! + + #expect(throws: KeychainError.self) { + try keychain.update(data: testData, forKey: "non-existent") + } + } + + // MARK: - Delete Tests + + @Test func deleteExistingKey() throws { + defer { try? keychain.deleteAll() } + + let testData = "Delete Me".data(using: .utf8)! + try keychain.save(data: testData, forKey: "delete-key") + + try keychain.delete(forKey: "delete-key") + + let result = try keychain.load(forKey: "delete-key") + #expect(result == nil) + } + + @Test func deleteNonExistentKeyDoesNotThrow() throws { + defer { try? keychain.deleteAll() } + + // Should not throw an error + #expect(throws: Never.self) { + try keychain.delete(forKey: "non-existent") + } + } + + // MARK: - DeleteAll Tests + + @Test func deleteAll() throws { + defer { try? keychain.deleteAll() } + + let data1 = "Value 1".data(using: .utf8)! + let data2 = "Value 2".data(using: .utf8)! + let data3 = "Value 3".data(using: .utf8)! + + try keychain.save(data: data1, forKey: "key1") + try keychain.save(data: data2, forKey: "key2") + try keychain.save(data: data3, forKey: "key3") + + try keychain.deleteAll() + + let loaded1 = try keychain.load(forKey: "key1") + let loaded2 = try keychain.load(forKey: "key2") + let loaded3 = try keychain.load(forKey: "key3") + #expect(loaded1 == nil) + #expect(loaded2 == nil) + #expect(loaded3 == nil) + } + + @Test func deleteAllWithNoItemsDoesNotThrow() throws { + #expect(throws: Never.self) { + try keychain.deleteAll() + } + } + + // MARK: - AllKeys Tests + + @Test func allKeysEmpty() throws { + defer { try? keychain.deleteAll() } + + let keys = try keychain.allKeys() + #expect(keys.isEmpty) + } + + @Test func allKeysReturnsSavedKeys() throws { + defer { try? keychain.deleteAll() } + + let data = "Test".data(using: .utf8)! + try keychain.save(data: data, forKey: "key1") + try keychain.save(data: data, forKey: "key2") + try keychain.save(data: data, forKey: "key3") + + let keys = try keychain.allKeys() + #expect(Set(keys) == Set(["key1", "key2", "key3"])) + } + + // MARK: - AllItems Tests + + @Test func allItemsEmpty() throws { + defer { try? keychain.deleteAll() } + + let items = try keychain.allItems() + #expect(items.isEmpty) + } + + @Test func allItemsReturnsSavedData() throws { + defer { try? keychain.deleteAll() } + + let data1 = "Value 1".data(using: .utf8)! + let data2 = "Value 2".data(using: .utf8)! + + try keychain.save(data: data1, forKey: "key1") + try keychain.save(data: data2, forKey: "key2") + + let items = try keychain.allItems() + #expect(items.count == 2) + #expect(items["key1"] == data1) + #expect(items["key2"] == data2) + } + + // MARK: - Service Isolation Tests + + @Test func serviceIsolation() throws { + // Create two keychains with different service names + let keychain1 = Keychain( + serviceName: "org.readium.lcp.test.service1", + synchronizable: false + ) + let keychain2 = Keychain( + serviceName: "org.readium.lcp.test.service2", + synchronizable: false + ) + + defer { + try? keychain1.deleteAll() + try? keychain2.deleteAll() + } + + let data = "Test".data(using: .utf8)! + try keychain1.save(data: data, forKey: "shared-key") + + // keychain2 should not see the data from keychain1 + let loaded = try keychain2.load(forKey: "shared-key") + #expect(loaded == nil) + } +} diff --git a/Tests/SharedTests/OPDS/OPDSAvailabilityTests.swift b/Tests/SharedTests/OPDS/OPDSAvailabilityTests.swift index 1da7746ec2..b366a7bc8f 100644 --- a/Tests/SharedTests/OPDS/OPDSAvailabilityTests.swift +++ b/Tests/SharedTests/OPDS/OPDSAvailabilityTests.swift @@ -4,7 +4,6 @@ // available in the top-level LICENSE file of the project. // -import ReadiumInternal @testable import ReadiumShared import XCTest diff --git a/Tests/SharedTests/Publication/Extensions/Audio/Locator+AudioTests.swift b/Tests/SharedTests/Publication/Extensions/Audio/Locator+AudioTests.swift index f537adac0e..45116e72b3 100644 --- a/Tests/SharedTests/Publication/Extensions/Audio/Locator+AudioTests.swift +++ b/Tests/SharedTests/Publication/Extensions/Audio/Locator+AudioTests.swift @@ -4,7 +4,6 @@ // available in the top-level LICENSE file of the project. // -import ReadiumInternal @testable import ReadiumShared import XCTest @@ -31,10 +30,10 @@ class LocatorLocationsAudioTests: XCTestCase { case let .begin(begin): XCTAssertEqual(begin, Double(beginStr)) case let .end(end): - XCTAssertEqual(end, Double(endStr.replacingPrefix(",", by: ""))) + XCTAssertEqual(end, Double(endStr.removingPrefix(","))) case let .interval(begin, end): XCTAssertEqual(begin, Double(beginStr)) - XCTAssertEqual(end, Double(endStr.replacingPrefix(",", by: ""))) + XCTAssertEqual(end, Double(endStr.removingPrefix(","))) case nil: XCTAssertNotNil(time) } diff --git a/Tests/SharedTests/Publication/Services/Content/Iterators/PDFResourceContentIteratorTests.swift b/Tests/SharedTests/Publication/Services/Content/Iterators/PDFResourceContentIteratorTests.swift index a23408091c..34b91c2f6f 100644 --- a/Tests/SharedTests/Publication/Services/Content/Iterators/PDFResourceContentIteratorTests.swift +++ b/Tests/SharedTests/Publication/Services/Content/Iterators/PDFResourceContentIteratorTests.swift @@ -4,7 +4,6 @@ // available in the top-level LICENSE file of the project. // -import ReadiumInternal @testable import ReadiumShared import Testing import UIKit diff --git a/Tests/SharedTests/Toolkit/Data/Resource/BufferingResourceTests.swift b/Tests/SharedTests/Toolkit/Data/Resource/BufferingResourceTests.swift index 14df76a170..b2bcff92b2 100644 --- a/Tests/SharedTests/Toolkit/Data/Resource/BufferingResourceTests.swift +++ b/Tests/SharedTests/Toolkit/Data/Resource/BufferingResourceTests.swift @@ -4,7 +4,6 @@ // available in the top-level LICENSE file of the project. // -import ReadiumInternal @testable import ReadiumShared import TestPublications import XCTest diff --git a/Tests/SharedTests/Toolkit/Data/Resource/TailCachingResourceTests.swift b/Tests/SharedTests/Toolkit/Data/Resource/TailCachingResourceTests.swift index dcdab4bc0c..ee9cad48da 100644 --- a/Tests/SharedTests/Toolkit/Data/Resource/TailCachingResourceTests.swift +++ b/Tests/SharedTests/Toolkit/Data/Resource/TailCachingResourceTests.swift @@ -4,7 +4,6 @@ // available in the top-level LICENSE file of the project. // -import ReadiumInternal @testable import ReadiumShared import TestPublications import XCTest diff --git a/Tests/SharedTests/Toolkit/Data/Resource/TransformingResourceTests.swift b/Tests/SharedTests/Toolkit/Data/Resource/TransformingResourceTests.swift index 603b9f96dc..c3182c84cf 100644 --- a/Tests/SharedTests/Toolkit/Data/Resource/TransformingResourceTests.swift +++ b/Tests/SharedTests/Toolkit/Data/Resource/TransformingResourceTests.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal @testable import ReadiumShared import Testing diff --git a/Tests/InternalTests/Extensions/Date+ISO8601Tests.swift b/Tests/SharedTests/Toolkit/Extensions/Date+ISO8601Tests.swift similarity index 95% rename from Tests/InternalTests/Extensions/Date+ISO8601Tests.swift rename to Tests/SharedTests/Toolkit/Extensions/Date+ISO8601Tests.swift index 96c5614f2e..1de1d78fc0 100644 --- a/Tests/InternalTests/Extensions/Date+ISO8601Tests.swift +++ b/Tests/SharedTests/Toolkit/Extensions/Date+ISO8601Tests.swift @@ -4,7 +4,7 @@ // available in the top-level LICENSE file of the project. // -@testable import ReadiumInternal +@testable import ReadiumShared import XCTest class DateISO8601Tests: XCTestCase { diff --git a/Tests/InternalTests/Extensions/RangeTests.swift b/Tests/SharedTests/Toolkit/Extensions/RangeTests.swift similarity index 99% rename from Tests/InternalTests/Extensions/RangeTests.swift rename to Tests/SharedTests/Toolkit/Extensions/RangeTests.swift index 3f3d638129..816bea44ea 100644 --- a/Tests/InternalTests/Extensions/RangeTests.swift +++ b/Tests/SharedTests/Toolkit/Extensions/RangeTests.swift @@ -5,7 +5,7 @@ // import Foundation -@testable import ReadiumInternal +@testable import ReadiumShared import Testing enum RangeTests { diff --git a/Tests/InternalTests/Extensions/URLTests.swift b/Tests/SharedTests/Toolkit/Extensions/URLTests.swift similarity index 96% rename from Tests/InternalTests/Extensions/URLTests.swift rename to Tests/SharedTests/Toolkit/Extensions/URLTests.swift index ebe9377aa7..b0540d437c 100644 --- a/Tests/InternalTests/Extensions/URLTests.swift +++ b/Tests/SharedTests/Toolkit/Extensions/URLTests.swift @@ -5,7 +5,7 @@ // import Foundation -@testable import ReadiumInternal +@testable import ReadiumShared import Testing enum URLTests { diff --git a/Tests/SharedTests/Toolkit/HTTP/DefaultHTTPClientTests.swift b/Tests/SharedTests/Toolkit/HTTP/DefaultHTTPClientTests.swift index 9cfee19ad2..b530b26586 100644 --- a/Tests/SharedTests/Toolkit/HTTP/DefaultHTTPClientTests.swift +++ b/Tests/SharedTests/Toolkit/HTTP/DefaultHTTPClientTests.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumInternal @testable import ReadiumShared import Testing diff --git a/Tests/StreamerTests/Parser/EPUB/EPUBManifestParserTests.swift b/Tests/StreamerTests/Parser/EPUB/EPUBManifestParserTests.swift index aa5ac88526..87e7d39052 100644 --- a/Tests/StreamerTests/Parser/EPUB/EPUBManifestParserTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/EPUBManifestParserTests.swift @@ -4,7 +4,6 @@ // available in the top-level LICENSE file of the project. // -import ReadiumInternal import ReadiumShared @testable import ReadiumStreamer import XCTest diff --git a/Tests/StreamerTests/Parser/EPUB/EPUBMetadataParserTests.swift b/Tests/StreamerTests/Parser/EPUB/EPUBMetadataParserTests.swift index 4f8adbc59b..ff00b346f2 100644 --- a/Tests/StreamerTests/Parser/EPUB/EPUBMetadataParserTests.swift +++ b/Tests/StreamerTests/Parser/EPUB/EPUBMetadataParserTests.swift @@ -5,7 +5,6 @@ // import ReadiumFuzi -import ReadiumInternal import ReadiumShared @testable import ReadiumStreamer import XCTest diff --git a/scripts/release-publish-podspecs.sh b/scripts/release-publish-podspecs.sh index 9252e5235d..26b268b7a8 100755 --- a/scripts/release-publish-podspecs.sh +++ b/scripts/release-publish-podspecs.sh @@ -15,7 +15,6 @@ set -euo pipefail # Podspec order (dependency-safe) PODSPECS=( - "ReadiumInternal" "ReadiumShared" "ReadiumStreamer" "ReadiumNavigator" From e76d064effe26eff63ac41eeed3c92257b86de00 Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:46:41 -0500 Subject: [PATCH 29/39] Replace DifferenceKit and CryptoSwift with native Swift code (#875) --- Package.swift | 5 - .../Playground.xcodeproj/project.pbxproj | 4 +- Sources/LCP/Services/PassphrasesService.swift | 1 - Sources/LCP/Toolkit/String+SHA256.swift | 15 ++ .../Decorator/DiffableDecoration.swift | 39 +++-- .../Navigator/Preferences/Configurable.swift | 1 - .../Parser/EPUB/EPUBManifestParser.swift | 1 - Sources/Streamer/Parser/EPUB/EPUBParser.swift | 1 - .../EPUBDeobfuscator.swift | 4 +- Support/CocoaPods/ReadiumLCP.podspec | 1 - Support/CocoaPods/ReadiumNavigator.podspec | 1 - Support/CocoaPods/ReadiumStreamer.podspec | 1 - Support/CocoaPods/Specs.swift | 3 - Tests/LCPTests/KeychainTests.swift | 2 +- .../Services/DeviceServiceTests.swift | 2 +- .../Services/PassphrasesServiceTests.swift | 151 ++++++++++++++++++ .../Decorator/DiffableDecorationTests.swift | 84 ++++++++++ 17 files changed, 274 insertions(+), 42 deletions(-) create mode 100644 Sources/LCP/Toolkit/String+SHA256.swift create mode 100644 Tests/LCPTests/Services/PassphrasesServiceTests.swift create mode 100644 Tests/NavigatorTests/Decorator/DiffableDecorationTests.swift diff --git a/Package.swift b/Package.swift index 1249ac14cf..9597a3496b 100644 --- a/Package.swift +++ b/Package.swift @@ -19,9 +19,7 @@ let package = Package( .library(name: "ReadiumLCP", targets: ["ReadiumLCP"]), ], dependencies: [ - .package(url: "https://github.com/krzyzanowskim/CryptoSwift.git", from: "1.10.0"), .package(url: "https://github.com/marmelroy/Zip.git", from: "2.1.2"), - .package(url: "https://github.com/ra1028/DifferenceKit.git", from: "1.3.0"), .package(url: "https://github.com/readium/Fuzi.git", from: "4.0.0"), .package(url: "https://github.com/readium/ZIPFoundation.git", from: "3.0.1"), .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.13.5"), @@ -60,7 +58,6 @@ let package = Package( .target( name: "ReadiumStreamer", dependencies: [ - "CryptoSwift", "ReadiumShared", .product(name: "ReadiumFuzi", package: "Fuzi"), ], @@ -82,7 +79,6 @@ let package = Package( name: "ReadiumNavigator", dependencies: [ "ReadiumShared", - "DifferenceKit", "SwiftSoup", ], path: "Sources/Navigator", @@ -123,7 +119,6 @@ let package = Package( .target( name: "ReadiumLCP", dependencies: [ - "CryptoSwift", "ReadiumShared", .product(name: "ReadiumZIPFoundation", package: "ZIPFoundation"), ], diff --git a/Playground/Playground.xcodeproj/project.pbxproj b/Playground/Playground.xcodeproj/project.pbxproj index ef29989202..291d5149f0 100644 --- a/Playground/Playground.xcodeproj/project.pbxproj +++ b/Playground/Playground.xcodeproj/project.pbxproj @@ -30,8 +30,8 @@ /* Begin PBXFileReference section */ 21B9812F732ED2F093918E79 /* Logger+Ext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Logger+Ext.swift"; sourceTree = ""; }; 2C1CFC0B9AEB966345163620 /* PublicationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicationView.swift; sourceTree = ""; }; - 2E399BE85546465BB3B37527 /* swift-toolkit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = swift-toolkit; path = ..; sourceTree = SOURCE_ROOT; }; 3A9F2917BE7D720CB89EBC9C /* UserError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserError.swift; sourceTree = ""; }; + 4168CCA0CF528F54A6DD0681 /* swift-toolkit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = swift-toolkit; path = ..; sourceTree = SOURCE_ROOT; }; 4DC581D9DDE636037C5FAB4A /* HTMLText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLText.swift; sourceTree = ""; }; 59844953100C517348EF23D0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 5BD99839EEEAFBFAF8A2264F /* PublicationMetadataView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicationMetadataView.swift; sourceTree = ""; }; @@ -129,7 +129,7 @@ 75054112A41CDCE58ADACF92 /* Packages */ = { isa = PBXGroup; children = ( - 2E399BE85546465BB3B37527 /* swift-toolkit */, + 4168CCA0CF528F54A6DD0681 /* swift-toolkit */, ); name = Packages; sourceTree = ""; diff --git a/Sources/LCP/Services/PassphrasesService.swift b/Sources/LCP/Services/PassphrasesService.swift index 5d0404ddc1..75dc3f9066 100644 --- a/Sources/LCP/Services/PassphrasesService.swift +++ b/Sources/LCP/Services/PassphrasesService.swift @@ -4,7 +4,6 @@ // available in the top-level LICENSE file of the project. // -import CryptoSwift import Foundation import ReadiumShared diff --git a/Sources/LCP/Toolkit/String+SHA256.swift b/Sources/LCP/Toolkit/String+SHA256.swift new file mode 100644 index 0000000000..b8d2b48803 --- /dev/null +++ b/Sources/LCP/Toolkit/String+SHA256.swift @@ -0,0 +1,15 @@ +// +// Copyright 2026 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 CryptoKit +import Foundation + +extension String { + func sha256() -> String { + let digest = SHA256.hash(data: Data(utf8)) + return digest.map { String(format: "%02x", $0) }.joined() + } +} diff --git a/Sources/Navigator/Decorator/DiffableDecoration.swift b/Sources/Navigator/Decorator/DiffableDecoration.swift index 75ed4ee8f6..edf7d19375 100644 --- a/Sources/Navigator/Decorator/DiffableDecoration.swift +++ b/Sources/Navigator/Decorator/DiffableDecoration.swift @@ -4,15 +4,11 @@ // available in the top-level LICENSE file of the project. // -import DifferenceKit import Foundation import ReadiumShared -struct DiffableDecoration: Hashable, Differentiable { +struct DiffableDecoration: Hashable { let decoration: Decoration - var differenceIdentifier: Decoration.Id { - decoration.id - } } enum DecorationChange { @@ -23,28 +19,29 @@ enum DecorationChange { extension Array where Element == DiffableDecoration { func changesByHREF(from source: [DiffableDecoration]) -> [AnyURL: [DecorationChange]] { - let changeset = StagedChangeset(source: source, target: self) - var changes: [AnyURL: [DecorationChange]] = [:] func register(_ change: DecorationChange, at locator: Locator) { - var resourceChanges: [DecorationChange] = changes[locator.href] ?? [] - resourceChanges.append(change) - changes[locator.href] = resourceChanges + changes[locator.href, default: []].append(change) } - for change in changeset { - for deleted in change.elementDeleted { - let decoration = source[deleted.element].decoration - register(.remove(decoration.id), at: decoration.locator) - } - for inserted in change.elementInserted { - let decoration = self[inserted.element].decoration - register(.add(decoration), at: decoration.locator) + let sourceById = Dictionary(source.map { ($0.decoration.id, $0) }, uniquingKeysWith: { first, _ in first }) + let targetById = Dictionary(map { ($0.decoration.id, $0) }, uniquingKeysWith: { first, _ in first }) + + for sourceElement in source { + let id = sourceElement.decoration.id + if let targetElement = targetById[id] { + if sourceElement != targetElement { + register(.update(targetElement.decoration), at: targetElement.decoration.locator) + } + } else { + register(.remove(id), at: sourceElement.decoration.locator) } - for updated in change.elementUpdated { - let decoration = self[updated.element].decoration - register(.update(decoration), at: decoration.locator) + } + + for targetElement in self { + if sourceById[targetElement.decoration.id] == nil { + register(.add(targetElement.decoration), at: targetElement.decoration.locator) } } diff --git a/Sources/Navigator/Preferences/Configurable.swift b/Sources/Navigator/Preferences/Configurable.swift index ff5240b31b..cf5dc6b897 100644 --- a/Sources/Navigator/Preferences/Configurable.swift +++ b/Sources/Navigator/Preferences/Configurable.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumShared /// A `Configurable` is a component with a set of `ConfigurableSettings`. @MainActor diff --git a/Sources/Streamer/Parser/EPUB/EPUBManifestParser.swift b/Sources/Streamer/Parser/EPUB/EPUBManifestParser.swift index 19a5ac62b2..dc05d9eefd 100644 --- a/Sources/Streamer/Parser/EPUB/EPUBManifestParser.swift +++ b/Sources/Streamer/Parser/EPUB/EPUBManifestParser.swift @@ -4,7 +4,6 @@ // available in the top-level LICENSE file of the project. // -import ReadiumFuzi import ReadiumShared final class EPUBManifestParser { diff --git a/Sources/Streamer/Parser/EPUB/EPUBParser.swift b/Sources/Streamer/Parser/EPUB/EPUBParser.swift index 4b0ac6c967..ee55e36860 100644 --- a/Sources/Streamer/Parser/EPUB/EPUBParser.swift +++ b/Sources/Streamer/Parser/EPUB/EPUBParser.swift @@ -5,7 +5,6 @@ // import Foundation -import ReadiumFuzi import ReadiumShared /// Errors thrown during the parsing of the EPUB diff --git a/Sources/Streamer/Parser/EPUB/Resource Transformers/EPUBDeobfuscator.swift b/Sources/Streamer/Parser/EPUB/Resource Transformers/EPUBDeobfuscator.swift index 118642560a..eb95a45fe7 100644 --- a/Sources/Streamer/Parser/EPUB/Resource Transformers/EPUBDeobfuscator.swift +++ b/Sources/Streamer/Parser/EPUB/Resource Transformers/EPUBDeobfuscator.swift @@ -4,7 +4,7 @@ // available in the top-level LICENSE file of the project. // -import CryptoSwift +import CryptoKit import Foundation import ReadiumShared @@ -122,7 +122,7 @@ private final class IDPFAlgorithm: ObfuscationAlgorithm { let obfuscatedLength = 1040 func key(for publicationId: String) -> [UInt8] { - publicationId.sha1().hexaToBytes + Array(Insecure.SHA1.hash(data: Data(publicationId.utf8))) } } diff --git a/Support/CocoaPods/ReadiumLCP.podspec b/Support/CocoaPods/ReadiumLCP.podspec index a500efcb38..7fcac4970f 100644 --- a/Support/CocoaPods/ReadiumLCP.podspec +++ b/Support/CocoaPods/ReadiumLCP.podspec @@ -26,6 +26,5 @@ Pod::Spec.new do |s| s.dependency 'ReadiumShared', '~> 3.11.0' s.dependency 'ReadiumZIPFoundation', '~> 3.0.1' - s.dependency 'CryptoSwift', '~> 1.10.0' end diff --git a/Support/CocoaPods/ReadiumNavigator.podspec b/Support/CocoaPods/ReadiumNavigator.podspec index aa2d5208c4..c5b808c00d 100644 --- a/Support/CocoaPods/ReadiumNavigator.podspec +++ b/Support/CocoaPods/ReadiumNavigator.podspec @@ -24,7 +24,6 @@ Pod::Spec.new do |s| s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-package-name Readium' } s.dependency 'ReadiumShared', '~> 3.11.0' - s.dependency 'DifferenceKit', '~> 1.0' s.dependency 'SwiftSoup', '~> 2.11.0' end diff --git a/Support/CocoaPods/ReadiumStreamer.podspec b/Support/CocoaPods/ReadiumStreamer.podspec index 4603733f95..c4e3bef913 100644 --- a/Support/CocoaPods/ReadiumStreamer.podspec +++ b/Support/CocoaPods/ReadiumStreamer.podspec @@ -27,6 +27,5 @@ Pod::Spec.new do |s| s.dependency 'ReadiumShared', '~> 3.11.0' s.dependency 'ReadiumFuzi', '~> 4.0.0' - s.dependency 'CryptoSwift', '~> 1.10.0' end diff --git a/Support/CocoaPods/Specs.swift b/Support/CocoaPods/Specs.swift index 6778daf516..063cba85dd 100644 --- a/Support/CocoaPods/Specs.swift +++ b/Support/CocoaPods/Specs.swift @@ -71,7 +71,6 @@ let modules: [ModuleSpec] = [ dependencies: [ .readium("ReadiumShared"), .pod("ReadiumFuzi", "~> 4.0.0"), - .pod("CryptoSwift", "~> 1.10.0"), ] ), ModuleSpec( @@ -84,7 +83,6 @@ let modules: [ModuleSpec] = [ ]], dependencies: [ .readium("ReadiumShared"), - .pod("DifferenceKit", "~> 1.0"), // SwiftSoup's podspec is stuck at 2.11. .pod("SwiftSoup", "~> 2.11.0"), ] @@ -111,7 +109,6 @@ let modules: [ModuleSpec] = [ dependencies: [ .readium("ReadiumShared"), .pod("ReadiumZIPFoundation", "~> 3.0.1"), - .pod("CryptoSwift", "~> 1.10.0"), ] ), ] diff --git a/Tests/LCPTests/KeychainTests.swift b/Tests/LCPTests/KeychainTests.swift index ed38e62343..aedf391b8e 100644 --- a/Tests/LCPTests/KeychainTests.swift +++ b/Tests/LCPTests/KeychainTests.swift @@ -5,7 +5,7 @@ // import Foundation -@testable import ReadiumInternal +@testable import ReadiumShared import Testing struct KeychainTests { diff --git a/Tests/LCPTests/Services/DeviceServiceTests.swift b/Tests/LCPTests/Services/DeviceServiceTests.swift index 428fedf942..a00b54e02c 100644 --- a/Tests/LCPTests/Services/DeviceServiceTests.swift +++ b/Tests/LCPTests/Services/DeviceServiceTests.swift @@ -6,7 +6,7 @@ import Foundation @testable import ReadiumLCP -import ReadiumShared +@testable import ReadiumShared import Testing /// Serialized because the legacy migration path reads a process-global diff --git a/Tests/LCPTests/Services/PassphrasesServiceTests.swift b/Tests/LCPTests/Services/PassphrasesServiceTests.swift new file mode 100644 index 0000000000..35c561178f --- /dev/null +++ b/Tests/LCPTests/Services/PassphrasesServiceTests.swift @@ -0,0 +1,151 @@ +// +// Copyright 2026 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 +@testable import ReadiumLCP +@testable import ReadiumShared +import Testing + +final class MockLCPClient: LCPClient, @unchecked Sendable { + var validPassphrase: LCPPassphraseHash? + + func createContext(jsonLicense: String, hashedPassphrase: ReadiumLCP.LCPPassphraseHash, pemCrl: String) throws -> ReadiumLCP.LCPClientContext { + "" + } + + func decrypt(data: Data, using context: ReadiumLCP.LCPClientContext) -> Data? { + data + } + + func findOneValidPassphrase(jsonLicense: String, hashedPassphrases: [ReadiumLCP.LCPPassphraseHash]) -> ReadiumLCP.LCPPassphraseHash? { + if let valid = validPassphrase, hashedPassphrases.contains(valid) { + return valid + } + return nil + } + + func getSupportedLCPProfileURIs() -> [String] { + [] + } +} + +final class MockLCPAuthenticating: LCPAuthenticating, @unchecked Sendable { + var passphraseToReturn: String? + + @MainActor + func retrievePassphrase( + for license: ReadiumLCP.LCPAuthenticatedLicense, + reason: ReadiumLCP.LCPAuthenticationReason, + allowUserInteraction: Bool + ) async -> String? { + passphraseToReturn + } +} + +struct PassphrasesServiceTests { + let client: MockLCPClient + let repository: InMemoryLCPPassphraseRepository + let service: PassphrasesService + + init() { + client = MockLCPClient() + repository = InMemoryLCPPassphraseRepository() + service = PassphrasesService(client: client, repository: repository) + } + + private func createTestLicenseDocument(id: String = UUID().uuidString) throws -> LicenseDocument { + let licenseJSON = """ + { + "provider": "https://test.provider.com", + "id": "\(id)", + "issued": "2024-01-01T00:00:00Z", + "updated": "2024-01-01T00:00:00Z", + "encryption": { + "profile": "http://readium.org/lcp/basic-profile", + "content_key": { + "algorithm": "http://www.w3.org/2001/04/xmlenc#aes256-cbc", + "encrypted_value": "dGVzdA==" + }, + "user_key": { + "algorithm": "http://www.w3.org/2001/04/xmlenc#sha256", + "text_hint": "Enter your passphrase", + "key_check": "dGVzdA==" + } + }, + "links": [ + { + "rel": "publication", + "href": "https://test.com/publication", + "type": "application/epub+zip" + } + ], + "user": { + "id": "user123", + "email": "test@example.com", + "name": "Test User" + }, + "rights": { + "start": "2024-01-01T00:00:00Z" + }, + "signature": { + "algorithm": "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256", + "certificate": "dGVzdA==", + "value": "dGVzdA==" + } + } + """ + let data = licenseJSON.data(using: .utf8)! + return try LicenseDocument(data: data) + } + + @Test func addPassphraseHashesAndStores() async throws { + try await service.addPassphrase("my_passphrase", isHashed: false, userID: "user123", provider: "https://test.provider.com") + + let stored = try await repository.passphrases() + #expect(stored.count == 1) + #expect(stored.contains("my_passphrase".sha256())) + } + + @Test func addHashedPassphraseStoresDirectly() async throws { + let hash = "a".padding(toLength: 64, withPad: "0", startingAt: 0) // 64 hex characters + try await service.addPassphrase(hash, isHashed: true, userID: "user123", provider: "https://test.provider.com") + + let stored = try await repository.passphrases() + #expect(stored.count == 1) + #expect(stored.contains(hash.lowercased())) + } + + @Test func requestReturnsValidPassphraseFromRepository() async throws { + let license = try createTestLicenseDocument() + let validHash = "my_passphrase".sha256() + + client.validPassphrase = validHash + + try await service.addPassphrase("my_passphrase", isHashed: false, userID: "user123", provider: "https://test.provider.com") + + let auth = MockLCPAuthenticating() + + let result = try await service.request(for: license, authentication: auth, allowUserInteraction: true) + #expect(result == validHash) + } + + @Test func requestFallsBackToAuthentication() async throws { + let license = try createTestLicenseDocument() + let clearPassphrase = "my_passphrase" + let validHash = clearPassphrase.sha256() + + client.validPassphrase = validHash + + let auth = MockLCPAuthenticating() + auth.passphraseToReturn = clearPassphrase + + let result = try await service.request(for: license, authentication: auth, allowUserInteraction: true) + #expect(result == validHash) + + let stored = try await repository.passphrases() + #expect(stored.contains(validHash)) + } +} diff --git a/Tests/NavigatorTests/Decorator/DiffableDecorationTests.swift b/Tests/NavigatorTests/Decorator/DiffableDecorationTests.swift new file mode 100644 index 0000000000..63e489d61a --- /dev/null +++ b/Tests/NavigatorTests/Decorator/DiffableDecorationTests.swift @@ -0,0 +1,84 @@ +// +// Copyright 2026 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 +@testable import ReadiumNavigator +@testable import ReadiumShared +import Testing + +struct DiffableDecorationTests { + private func makeLocator(href: String) -> Locator { + Locator(href: AnyURL(string: href)!, mediaType: .html) + } + + private func makeDecoration(id: String, href: String, style: Decoration.Style) -> Decoration { + Decoration(id: id, locator: makeLocator(href: href), style: style) + } + + @Test func changesByHREF() { + let dec1 = makeDecoration(id: "1", href: "/chapter1.html", style: .highlight(isActive: false)) + let dec2 = makeDecoration(id: "2", href: "/chapter1.html", style: .highlight(isActive: false)) + let dec3 = makeDecoration(id: "3", href: "/chapter2.html", style: .highlight(isActive: false)) + + let source = [ + DiffableDecoration(decoration: dec1), + DiffableDecoration(decoration: dec2), + DiffableDecoration(decoration: dec3), + ] + + // Modify dec1 (update), remove dec2, keep dec3, add dec4 + var updatedDec1 = dec1 + updatedDec1.style = .highlight(isActive: true) + + let dec4 = makeDecoration(id: "4", href: "/chapter1.html", style: .underline(isActive: false)) + let dec5 = makeDecoration(id: "5", href: "/chapter3.html", style: .highlight(isActive: false)) + + let target = [ + DiffableDecoration(decoration: updatedDec1), + DiffableDecoration(decoration: dec3), + DiffableDecoration(decoration: dec4), + DiffableDecoration(decoration: dec5), + ] + + let changes = target.changesByHREF(from: source) + + // Verify /chapter1.html + let ch1 = changes[AnyURL(string: "/chapter1.html")!] ?? [] + #expect(ch1.count == 3) + + var hasUpdate1 = false + var hasRemove2 = false + var hasAdd4 = false + + for change in ch1 { + switch change { + case let .update(dec): + if dec.id == "1" { hasUpdate1 = true } + case let .remove(id): + if id == "2" { hasRemove2 = true } + case let .add(dec): + if dec.id == "4" { hasAdd4 = true } + } + } + + #expect(hasUpdate1) + #expect(hasRemove2) + #expect(hasAdd4) + + // Verify /chapter2.html has no changes + #expect(changes[AnyURL(string: "/chapter2.html")!] == nil) + + // Verify /chapter3.html + let ch3 = changes[AnyURL(string: "/chapter3.html")!] ?? [] + #expect(ch3.count == 1) + + if case let .add(dec) = ch3.first, dec.id == "5" { + // success + } else { + Issue.record("Expected add change for decoration 5") + } + } +} From 1ace16cad394a90885ad5eab4df5204b5178319b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Thu, 6 Aug 2026 14:53:37 +0200 Subject: [PATCH 30/39] Add missing `Sendable` conformances (#881) --- Sources/LCP/LCPService.swift | 2 +- Sources/LCP/Services/LicensesService.swift | 2 +- Sources/Shared/Publication/LocalizedString.swift | 2 +- .../Shared/Publication/ManifestTransformer.swift | 4 ++-- .../Protection/ContentProtection.swift | 4 ++-- .../Protection/FallbackContentProtection.swift | 2 +- Sources/Shared/Publication/Publication.swift | 2 +- .../Publication/Services/Content/Content.swift | 6 +++--- .../Services/Content/ContentService.swift | 2 +- Sources/Shared/Toolkit/HTTP/HTTPClient.swift | 2 +- Sources/Shared/Toolkit/HTTP/HTTPServer.swift | 16 ++++++++-------- Sources/Shared/Toolkit/Media/AudioSession.swift | 2 +- Sources/Streamer/PublicationOpener.swift | 2 +- 13 files changed, 24 insertions(+), 24 deletions(-) diff --git a/Sources/LCP/LCPService.swift b/Sources/LCP/LCPService.swift index 6e80ebded6..61144d8051 100644 --- a/Sources/LCP/LCPService.swift +++ b/Sources/LCP/LCPService.swift @@ -15,7 +15,7 @@ import ReadiumShared /// will not present any dialog to the user. This can be the desired behavior /// when trying to import a license in the background, without prompting the /// user for their passphrase. -public final class LCPService: Loggable { +public final class LCPService: Loggable, Sendable { private let licenses: LicensesService private let passphrases: PassphrasesService private let assetRetriever: AssetRetriever diff --git a/Sources/LCP/Services/LicensesService.swift b/Sources/LCP/Services/LicensesService.swift index c48aab7817..f5dd70d592 100644 --- a/Sources/LCP/Services/LicensesService.swift +++ b/Sources/LCP/Services/LicensesService.swift @@ -7,7 +7,7 @@ import Foundation import ReadiumShared -final class LicensesService: Loggable { +final class LicensesService: Loggable, Sendable { /// Mapping between an unprotected format to the matching LCP protected format. private let mediaTypesMapping: [MediaType: MediaType] = [ .readiumAudiobook: .lcpProtectedAudiobook, diff --git a/Sources/Shared/Publication/LocalizedString.swift b/Sources/Shared/Publication/LocalizedString.swift index 7fe9c6e9c3..e395bd4582 100644 --- a/Sources/Shared/Publication/LocalizedString.swift +++ b/Sources/Shared/Publication/LocalizedString.swift @@ -89,7 +89,7 @@ extension LocalizedString: CustomStringConvertible { } /// Provides syntactic sugar when initializing a LocalizedString from a regular String (nonlocalized) or a [String: String] (localized). -public protocol LocalizedStringConvertible { +public protocol LocalizedStringConvertible: Sendable { var localizedString: LocalizedString { get } } diff --git a/Sources/Shared/Publication/ManifestTransformer.swift b/Sources/Shared/Publication/ManifestTransformer.swift index 3056198829..797e592c92 100644 --- a/Sources/Shared/Publication/ManifestTransformer.swift +++ b/Sources/Shared/Publication/ManifestTransformer.swift @@ -7,13 +7,13 @@ import Foundation /// Transforms a ``Manifest``'s components. -public protocol ManifestTransformer { +public protocol ManifestTransformer: Sendable { func transform(manifest: inout Manifest) throws func transform(metadata: inout Metadata) throws func transform(link: inout Link) throws } -public protocol ManifestTransformable { +public protocol ManifestTransformable: Sendable { mutating func transform(_ transformer: ManifestTransformer) throws } diff --git a/Sources/Shared/Publication/Protection/ContentProtection.swift b/Sources/Shared/Publication/Protection/ContentProtection.swift index 1236064895..aee16ab3e9 100644 --- a/Sources/Shared/Publication/Protection/ContentProtection.swift +++ b/Sources/Shared/Publication/Protection/ContentProtection.swift @@ -11,7 +11,7 @@ import Foundation /// Its responsibilities are to: /// - Unlock a publication by returning a customized `Fetcher`. /// - Create a `ContentProtectionService` publication service. -public protocol ContentProtection { +public protocol ContentProtection: Sendable { /// Attempts to unlock a potentially protected publication asset. /// /// - Returns: An ``Asset`` in case of success or an @@ -69,7 +69,7 @@ public struct ContentProtectionSchemeNotSupportedError: Error, Sendable { } /// Holds the result of opening an ``Asset`` with a ``ContentProtection``. -public struct ContentProtectionAsset { +public struct ContentProtectionAsset: Sendable { /// Asset granting access to the decrypted content. public let asset: Asset diff --git a/Sources/Shared/Publication/Protection/FallbackContentProtection.swift b/Sources/Shared/Publication/Protection/FallbackContentProtection.swift index 694ba4b04a..4f7d828882 100644 --- a/Sources/Shared/Publication/Protection/FallbackContentProtection.swift +++ b/Sources/Shared/Publication/Protection/FallbackContentProtection.swift @@ -8,7 +8,7 @@ import Foundation /// ``ContentProtection`` implementation used as a fallback when detecting /// known DRMs not supported by the app. -public final class _FallbackContentProtection: ContentProtection, Sendable { +public final class _FallbackContentProtection: ContentProtection { public init() {} public func open( diff --git a/Sources/Shared/Publication/Publication.swift b/Sources/Shared/Publication/Publication.swift index ef09ff5cf2..fabaa902a8 100644 --- a/Sources/Shared/Publication/Publication.swift +++ b/Sources/Shared/Publication/Publication.swift @@ -196,7 +196,7 @@ public final class Publication: Sendable, Loggable { /// Transform which can be used to modify a `Publication`'s components /// before building it. For example, to add Publication Services or /// wrap the root Container. - public typealias Transform = ( + public typealias Transform = @Sendable ( _ manifest: inout Manifest, _ container: inout Container, _ services: inout PublicationServicesBuilder diff --git a/Sources/Shared/Publication/Services/Content/Content.swift b/Sources/Shared/Publication/Services/Content/Content.swift index 65a6a273f8..19e4a9d120 100644 --- a/Sources/Shared/Publication/Services/Content/Content.swift +++ b/Sources/Shared/Publication/Services/Content/Content.swift @@ -7,7 +7,7 @@ import Foundation /// Provides an iterable list of `ContentElement`s. -public protocol Content { +public protocol Content: Sendable { /// Creates a new fallible bidirectional iterator for this content. func iterator() -> ContentIterator } @@ -268,7 +268,7 @@ public struct ContentAttribute: Hashable, Sendable { } /// Object associated with a list of attributes. -public protocol ContentAttributesHolder { +public protocol ContentAttributesHolder: Sendable { /// Associated list of attributes. var attributes: [ContentAttribute] { get } } @@ -320,7 +320,7 @@ public protocol ContentIterator: AnyObject, Sendable { } /// Helper class to treat a `Content` as a `Sequence`. -public final class ContentSequence: AsyncSequence { +public final class ContentSequence: AsyncSequence, Sendable { public typealias Element = ContentElement private let content: Content diff --git a/Sources/Shared/Publication/Services/Content/ContentService.swift b/Sources/Shared/Publication/Services/Content/ContentService.swift index 95d39577b2..a76ace59ed 100644 --- a/Sources/Shared/Publication/Services/Content/ContentService.swift +++ b/Sources/Shared/Publication/Services/Content/ContentService.swift @@ -41,7 +41,7 @@ public final class DefaultContentService: ContentService, Sendable { return DefaultContent(publication: pub, start: start, resourceContentIteratorFactories: resourceContentIteratorFactories) } - private class DefaultContent: Content { + private final class DefaultContent: Content { let publication: Publication let start: Locator? let resourceContentIteratorFactories: [ResourceContentIteratorFactory] diff --git a/Sources/Shared/Toolkit/HTTP/HTTPClient.swift b/Sources/Shared/Toolkit/HTTP/HTTPClient.swift index 0db0f4d1bb..9a941f6b51 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPClient.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPClient.swift @@ -297,7 +297,7 @@ public struct HTTPDownload: Equatable, Sendable { /// /// Conforming types must provide a dictionary of HTTP headers. The protocol /// extension provides convenient typed accessors for common HTTP headers. -public protocol HTTPHeadersProviding { +public protocol HTTPHeadersProviding: Sendable { /// HTTP response headers, indexed by their name. var headers: [String: String] { get } } diff --git a/Sources/Shared/Toolkit/HTTP/HTTPServer.swift b/Sources/Shared/Toolkit/HTTP/HTTPServer.swift index f2ae372a24..b0d70493b5 100644 --- a/Sources/Shared/Toolkit/HTTP/HTTPServer.swift +++ b/Sources/Shared/Toolkit/HTTP/HTTPServer.swift @@ -10,7 +10,7 @@ import Foundation /// /// This is required by some Navigators to access a local publication's /// resources. -public protocol HTTPServer { +public protocol HTTPServer: Sendable { /// Serves resources at the given `endpoint`. /// /// Subsequent calls with the same `endpoint` overwrite each other. @@ -47,7 +47,7 @@ public extension HTTPServer { contentsOf url: FileURL, onFailure: HTTPRequestHandler.OnFailure? = nil ) throws -> HTTPURL { - func onRequest(request: HTTPServerRequest) -> HTTPServerResponse { + let onRequest: HTTPRequestHandler.OnRequest = { request in let file = request.href.flatMap { url.resolve($0) } ?? url @@ -78,8 +78,8 @@ public extension HTTPServer { publication: Publication, onFailure: HTTPRequestHandler.OnFailure? = nil ) throws -> HTTPURL { - func onRequest(request: HTTPServerRequest) -> HTTPServerResponse { - lazy var notFound: HTTPError = .errorResponse(HTTPErrorResponse(status: .notFound)) + let onRequest: HTTPRequestHandler.OnRequest = { request in + let notFound: HTTPError = .errorResponse(HTTPErrorResponse(status: .notFound)) guard let href = request.href, @@ -125,7 +125,7 @@ public struct HTTPServerRequest: Sendable { } /// Response sent from the `HTTPServer` when receiving a request. -public struct HTTPServerResponse { +public struct HTTPServerResponse: Sendable { public var resource: Resource public var mediaType: MediaType? @@ -145,9 +145,9 @@ public struct HTTPServerResponse { /// Callbacks handling a request. /// /// If the resource cannot be served, the `onFailure` callback is called. -public struct HTTPRequestHandler { - public typealias OnRequest = (_ request: HTTPServerRequest) -> HTTPServerResponse - public typealias OnFailure = (_ request: HTTPServerRequest, _ error: ReadError) -> Void +public struct HTTPRequestHandler: Sendable { + public typealias OnRequest = @Sendable (_ request: HTTPServerRequest) -> HTTPServerResponse + public typealias OnFailure = @Sendable (_ request: HTTPServerRequest, _ error: ReadError) -> Void public let onRequest: OnRequest public let onFailure: OnFailure? diff --git a/Sources/Shared/Toolkit/Media/AudioSession.swift b/Sources/Shared/Toolkit/Media/AudioSession.swift index 7b9763c27f..0e5dd97c62 100644 --- a/Sources/Shared/Toolkit/Media/AudioSession.swift +++ b/Sources/Shared/Toolkit/Media/AudioSession.swift @@ -27,7 +27,7 @@ public extension AudioSessionUser { /// Manages the app's audio session for Readium audio consumers. @MainActor -public protocol AudioSessionManaging { +public protocol AudioSessionManaging: Sendable { /// Starts a new audio session with the given `user`. /// /// The returned opaque token can be used to end the session for the same diff --git a/Sources/Streamer/PublicationOpener.swift b/Sources/Streamer/PublicationOpener.swift index aeeb0637dd..d5063ff3d8 100644 --- a/Sources/Streamer/PublicationOpener.swift +++ b/Sources/Streamer/PublicationOpener.swift @@ -15,7 +15,7 @@ import ReadiumShared /// - onCreatePublication: Called on every parsed `Publication.Builder`. It /// can be used to modify the manifest, the root container or the list of /// service factories of a `Publication`. -public final class PublicationOpener { +public final class PublicationOpener: Sendable { private let parser: PublicationParser private let contentProtections: [ContentProtection] private let onCreatePublication: Publication.Builder.Transform From 1a14032e59b5875134f56e16274b9121ad18bcdf Mon Sep 17 00:00:00 2001 From: Steven Zeck <8315038+stevenzeck@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:47:39 -0500 Subject: [PATCH 31/39] Generate the Playground project with XcodeGen and unify the dev workflow (#849) --- .github/workflows/checks.yml | 29 +- .gitignore | 3 + CONTRIBUTING.md | 23 + MAINTAINING.md | 16 +- Makefile | 31 +- Package.swift | 12 - Playground/.gitignore | 3 + Playground/.xcodegen | 109 ---- .../Playground.xcodeproj/project.pbxproj | 469 ------------------ .../contents.xcworkspacedata | 7 - .../xcschemes/Playground.xcscheme | 97 ---- .../{ => Support}/Playground.xctestplan | 7 +- Playground/Support/project+lcp.yml | 40 ++ Playground/{ => Support}/project.yml | 12 +- .../contents.xcworkspacedata | 23 + TestApp/.gitignore | 15 +- TestApp/Integrations/Local/TestApp.xctestplan | 57 --- TestApp/Integrations/Local/project+lcp.yml | 11 +- TestApp/Integrations/Local/project.yml | 6 +- TestApp/Makefile | 13 +- Tests/LCPTests/LCPDecryptionTests.swift | 1 - Tests/Publications/TestPublications.swift | 12 +- scripts/gen-lcp-testplan.py | 51 ++ scripts/test.sh | 87 +++- 24 files changed, 314 insertions(+), 820 deletions(-) create mode 100644 Playground/.gitignore delete mode 100644 Playground/.xcodegen delete mode 100644 Playground/Playground.xcodeproj/project.pbxproj delete mode 100644 Playground/Playground.xcodeproj/project.xcworkspace/contents.xcworkspacedata delete mode 100644 Playground/Playground.xcodeproj/xcshareddata/xcschemes/Playground.xcscheme rename Playground/{ => Support}/Playground.xctestplan (82%) create mode 100644 Playground/Support/project+lcp.yml rename Playground/{ => Support}/project.yml (65%) create mode 100644 Support/Readium.xcworkspace/contents.xcworkspacedata delete mode 100644 TestApp/Integrations/Local/TestApp.xctestplan create mode 100755 scripts/gen-lcp-testplan.py diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 3e89ad7c06..0e27ee9afd 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -4,6 +4,8 @@ on: push: branches: [ main, develop ] pull_request: + types: [opened, synchronize, reopened, ready_for_review] + workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -120,6 +122,11 @@ jobs: environment: name: LCP deployment: false + env: + # Empty for pull requests opened from a fork: GitHub never passes secrets + # to `pull_request` runs from forks. The LCP steps are skipped in that + # case, and the projects are generated without LCP. + LCP_URL_SPM: ${{ secrets.LCP_URL_SPM }} steps: - name: Checkout uses: actions/checkout@v6 @@ -128,19 +135,23 @@ jobs: brew install xcodegen # Preload the list of simulator for xcodebuild. The workflow is flaky without it. xcrun simctl list - - name: Check Playground project is up-to-date - run: | - make playground - git diff --exit-code Playground/.xcodegen - if git ls-files --others --exclude-standard Playground/Playground.xcodeproj/ | grep -q .; then echo "Untracked Playground project files found. Run 'make playground' and commit the result."; exit 1; fi + - name: Generate Playground project + run: make playground lcp="$LCP_URL_SPM" - name: Build Playground + # Without the LCP secret we can't run the LCP tests, but we still check + # that the Playground compiles. With it, the test step below builds the + # Playground itself. + if: env.LCP_URL_SPM == '' run: | set -eo pipefail xcodebuild build -scheme Playground -project Playground/Playground.xcodeproj -destination "platform=$platform,name=$device" | xcbeautify --renderer github-actions + - name: Test LCP + if: env.LCP_URL_SPM != '' + run: | + set -eo pipefail + xcodebuild test -scheme Playground -project Playground/Playground.xcodeproj -destination "platform=$platform,name=$device" -only-testing:ReadiumLCPTests | xcbeautify --renderer github-actions - name: Generate TestApp project working-directory: TestApp - env: - LCP_URL_SPM: ${{ secrets.LCP_URL_SPM }} run: make dev lcp="$LCP_URL_SPM" - name: Build TestApp working-directory: TestApp @@ -159,6 +170,10 @@ jobs: environment: name: LCP deployment: false + env: + # See the note in the `int-dev` job: this is empty for pull requests + # opened from a fork, in which case the project is generated without LCP. + LCP_URL_SPM: ${{ secrets.LCP_URL_SPM }} steps: - name: Checkout uses: actions/checkout@v6 diff --git a/.gitignore b/.gitignore index fd73e022df..40751f51f7 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,6 @@ out/ # DocC generation .build-docs docs-site + +/Support/Readium.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +/Support/R2LCPClient/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9753e914ad..1b105e4cb6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,6 +6,29 @@ We welcome many kind of contributions such as improving the documentation, submi ## Writing code +### Setting up the Xcode projects + +The Xcode projects (Playground and TestApp) are not committed to this repository, they are generated with [XcodeGen](https://github.com/yonaskolb/XcodeGen). Install it first, then run from the project's root directory: + +```sh +make dev +``` + +This generates both the [Playground](Playground) project, which is used to run the unit tests and try out the toolkit, and the [Test App](TestApp) project. + +To enable Readium LCP, provide the liblcp URL given to you by EDRLab: + +```sh +make dev lcp=https://.../Package.swift +``` + +Then, use the `Support/Readium.xcworkspace` workspace for working on the toolkit. + +> [!IMPORTANT] +> Run `make dev` again after pulling any change from the repository, as the projects may be out of date. + +If you only need one of the two applications, use `make playground` for the Playground, or run `make dev` from the `TestApp` directory for the Test App. + ### Coding standard We use [`SwiftFormat`](https://github.com/nicklockwood/SwiftFormat) to ensure code formatting and avoid bikeshedding. diff --git a/MAINTAINING.md b/MAINTAINING.md index b7a32eb8e1..995314450c 100644 --- a/MAINTAINING.md +++ b/MAINTAINING.md @@ -44,7 +44,7 @@ To bump the minimum required iOS version, update these files: ## Creating a New Package -A new package is a separately distributable SPM library product. It requires updates to four places. +A new package is a separately distributable SPM library product. It requires updates to a few places. ### 1. `Package.swift` @@ -71,6 +71,20 @@ Add a new product and its source/test targets: Add an entry to `Support/CocoaPods/Specs.swift` and run `make podspecs` to generate the podspec file. +### 3. `Playground/Support/Playground.xctestplan` + +Add the new test target to the test plan, otherwise it will not be run by `make test`. `make playground` copies this file to `Playground/Playground.xctestplan`, which is the one referenced by the scheme, hence the `container:..` paths relative to the `Playground` folder: + +```json +{ + "target" : { + "containerPath" : "container:..", + "identifier" : "ReadiumTests", + "name" : "ReadiumTests" + } +} +``` + ## Releasing a New Version You are ready to release a new version of the Swift toolkit? Great, follow these steps: diff --git a/Makefile b/Makefile index cad4d73735..fde9410cae 100644 --- a/Makefile +++ b/Makefile @@ -3,9 +3,13 @@ SCRIPTS_PATH := Sources/Navigator/EPUB/Scripts help: @echo "Usage: make \n\n\ playground\t\tGenerate the Playground project\n\ + \t\t\tUse 'lcp=' to enable LCP.\n\ + dev\t\t\tGenerate both the Playground and TestApp projects for development\n\ + \t\t\tUse 'lcp=' to enable LCP.\n\ podspecs\t\tGenerate the CocoaPods podspecs\n\ scripts\t\tBundle the Navigator EPUB scripts\n\ test\t\t\tRun unit tests\n\ + \t\t\tUse 'only=' to run a single test target.\n\ lint-format\t\tVerify formatting\n\ format\t\tFormat sources\n\ update-locales\tUpdate the localization files\n\ @@ -13,20 +17,33 @@ help: .PHONY: test test: - xcodebuild test -project "TestApp/TestApp.xcodeproj" -scheme TestApp -destination "platform=iOS Simulator,name=iPhone Air" 2> /dev/null \ - | xcbeautify --quieter --disable-logging \ - | grep -Ev "^Executed |Test Suite 'All tests'|Test run started\.|Test session results:"; true + ./scripts/test.sh $(only) .SILENT: .PHONY: playground playground: - cd Playground; \ - find . -name ".DS_Store" -delete; \ - xcodegen --use-cache --cache-path .xcodegen; \ + cp Playground/Support/Playground.xctestplan Playground/Playground.xctestplan +ifdef lcp + # The liblcp package is downloaded in Support, so that the Playground and the + # TestApp share it. Two local packages named `R2LCPClient` in the same Xcode + # workspace conflict. + @curl --fail --silent --show-error -L --create-dirs --output Support/R2LCPClient/Package.swift "$(lcp)" + cd Playground; xcodegen -s Support/project+lcp.yml --project . --project-root . + # The plan only declares the test targets of the package. Add the LCP tests, + # whose target identifier is only known once the project has been generated. + scripts/gen-lcp-testplan.py Playground/Playground.xctestplan Playground/Playground.xcodeproj/project.pbxproj +else + cd Playground; xcodegen -s Support/project.yml --project . --project-root . +endif # The repository might be cloned to a different location than "swift-toolkit". # XcodeGen will use the name of the folder in the project, which is not desirable. # This will replace all occurrences of this folder by "swift-toolkit". - perl -i -0777 -pe 'if (/name = "?([^"]+)"?; path = \.\.; /) { my $$n = $$1; s/name = "?\Q$$n\E"?; path = \.\./name = swift-toolkit; path = ../; s|/\* \Q$$n\E \*/|/* swift-toolkit */|g; }' Playground/Playground.xcodeproj/project.pbxproj + perl -i -0777 -pe 'if (/name = "?([^";\n]+)"?; path = \.\.; /) { my $$n = $$1; s/name = "?\Q$$n\E"?; path = \.\.;/name = swift-toolkit; path = ..;/; s|/\* \Q$$n\E \*/|/* swift-toolkit */|g; }' Playground/Playground.xcodeproj/project.pbxproj + +.PHONY: dev +dev: playground + $(MAKE) -C TestApp dev lcp=$(lcp) + @echo "\n☝️ Open Support/Readium.xcworkspace" .PHONY: podspecs podspecs: diff --git a/Package.swift b/Package.swift index 9597a3496b..103b445194 100644 --- a/Package.swift +++ b/Package.swift @@ -127,18 +127,6 @@ let package = Package( .process("Resources"), ] ), - // These tests require a R2LCPClient.framework to run. - // TODO: Find a solution to run the tests with GitHub action. - // .testTarget( - // name: "ReadiumLCPTests", - // dependencies: [ - // "ReadiumLCP", - // "ReadiumShared", - // "ReadiumStreamer", - // "TestPublications", - // ], - // path: "Tests/LCPTests" - // ), // Shared test publications used across multiple test targets. .target( diff --git a/Playground/.gitignore b/Playground/.gitignore new file mode 100644 index 0000000000..a67861a830 --- /dev/null +++ b/Playground/.gitignore @@ -0,0 +1,3 @@ +/Playground.xcodeproj +/Playground.xctestplan +/.xcodegen diff --git a/Playground/.xcodegen b/Playground/.xcodegen deleted file mode 100644 index dfb2723d10..0000000000 --- a/Playground/.xcodegen +++ /dev/null @@ -1,109 +0,0 @@ -# XCODEGEN VERSION -2.46.0 - -# SPEC -{ - "name" : "Playground", - "options" : { - "bundleIdPrefix" : "org.readium" - }, - "packages" : { - "Readium" : { - "path" : ".." - } - }, - "schemes" : { - "Playground" : { - "build" : { - "targets" : { - "Playground" : "" - } - }, - "test" : { - "testPlans" : [ - { - "defaultPlan" : true, - "path" : "Playground.xctestplan" - } - ] - } - } - }, - "targets" : { - "Playground" : { - "dependencies" : [ - { - "package" : "Readium", - "product" : "ReadiumShared" - }, - { - "package" : "Readium", - "product" : "ReadiumStreamer" - }, - { - "package" : "Readium", - "product" : "ReadiumNavigator" - }, - { - "package" : "Readium", - "product" : "ReadiumOPDS" - } - ], - "deploymentTarget" : "16.0", - "platform" : "iOS", - "settings" : { - "SWIFT_APPROACHABLE_CONCURRENCY" : true, - "SWIFT_DEFAULT_ACTOR_ISOLATION" : "MainActor", - "SWIFT_VERSION" : 6 - }, - "sources" : [ - { - "path" : "Sources" - } - ], - "type" : "application" - } - } -} - -# FILES -Sources -Sources/App -Sources/App/Common -Sources/App/Common/Extensions -Sources/App/Common/Extensions/FileManager+Ext.swift -Sources/App/Common/Extensions/Logger+Ext.swift -Sources/App/Common/UserError.swift -Sources/App/Common/UserError+Readium.swift -Sources/App/Common/Views -Sources/App/Common/Views/HTMLText.swift -Sources/App/Common/Views/JSONView.swift -Sources/App/Data -Sources/App/Data/DocumentList.swift -Sources/App/Data/DocumentRepository.swift -Sources/App/PlaygroundApp.swift -Sources/App/Publication -Sources/App/Publication/PublicationMetadataView.swift -Sources/App/Publication/PublicationView.swift -Sources/Assets.xcassets -Sources/Assets.xcassets/AppIcon.appiconset -Sources/Assets.xcassets/AppIcon.appiconset/AppIcon29x29.png -Sources/Assets.xcassets/AppIcon.appiconset/AppIcon29x29@2x-1.png -Sources/Assets.xcassets/AppIcon.appiconset/AppIcon29x29@2x.png -Sources/Assets.xcassets/AppIcon.appiconset/AppIcon29x29@3x.png -Sources/Assets.xcassets/AppIcon.appiconset/AppIcon40x40.png -Sources/Assets.xcassets/AppIcon.appiconset/AppIcon40x40@2x-1.png -Sources/Assets.xcassets/AppIcon.appiconset/AppIcon40x40@2x.png -Sources/Assets.xcassets/AppIcon.appiconset/AppIcon40x40@3x.png -Sources/Assets.xcassets/AppIcon.appiconset/AppIcon60x60@2x.png -Sources/Assets.xcassets/AppIcon.appiconset/AppIcon60x60@3x.png -Sources/Assets.xcassets/AppIcon.appiconset/AppIcon76x76.png -Sources/Assets.xcassets/AppIcon.appiconset/AppIcon76x76@2x.png -Sources/Assets.xcassets/AppIcon.appiconset/Contents.json -Sources/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png -Sources/Assets.xcassets/AppIcon.appiconset/readiumlogo_2048-83.5@2x.png -Sources/Assets.xcassets/Contents.json -Sources/Info.plist -Sources/Recipes -Sources/Recipes/A01-OpenPublication.swift -Sources/Recipes/A02-ReadMetadata.swift" diff --git a/Playground/Playground.xcodeproj/project.pbxproj b/Playground/Playground.xcodeproj/project.pbxproj deleted file mode 100644 index 291d5149f0..0000000000 --- a/Playground/Playground.xcodeproj/project.pbxproj +++ /dev/null @@ -1,469 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 77; - objects = { - -/* Begin PBXBuildFile section */ - 07FE4C9817951411354484C0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 59844953100C517348EF23D0 /* Assets.xcassets */; }; - 1BCA1AEC8E94F11FFEF3A0C8 /* ReadiumOPDS in Frameworks */ = {isa = PBXBuildFile; productRef = 41FF3979DE8082AF5E23D69D /* ReadiumOPDS */; }; - 2EE593DC2038FDF776324278 /* FileManager+Ext.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5AE9B43C414342BBB5CDDEF /* FileManager+Ext.swift */; }; - 3FA679B6B90C8BD7B5A7DF58 /* JSONView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCB6D3C4C19C2038573D2B90 /* JSONView.swift */; }; - 52805E26E8DF05E511042B97 /* UserError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A9F2917BE7D720CB89EBC9C /* UserError.swift */; }; - 52AF5B85A8E5285B966E7CB5 /* DocumentList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D8B2B0C8575F4648E445A44 /* DocumentList.swift */; }; - 6C9BEF9E1487605B673E48AA /* PublicationMetadataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BD99839EEEAFBFAF8A2264F /* PublicationMetadataView.swift */; }; - 7EBCAA279457CB27B0A3F136 /* PublicationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C1CFC0B9AEB966345163620 /* PublicationView.swift */; }; - 7FB8FD4A23F144C56E82ED91 /* ReadiumStreamer in Frameworks */ = {isa = PBXBuildFile; productRef = 79CBFD1B8193030A2DB6A839 /* ReadiumStreamer */; }; - B19623280F8C457F051B3110 /* PlaygroundApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B77C0B458C816697C5C670E9 /* PlaygroundApp.swift */; }; - BEB5501D6869FDCA129A87B9 /* A02-ReadMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = D608867E2F9CC0B751114DE9 /* A02-ReadMetadata.swift */; }; - CA6204854C325EE686871A2C /* ReadiumNavigator in Frameworks */ = {isa = PBXBuildFile; productRef = 872E0CB31611AD93E229C627 /* ReadiumNavigator */; }; - D2EF387DADE049BCBDBEE738 /* UserError+Readium.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7E5C61917B53108BA01753E /* UserError+Readium.swift */; }; - D4ACEB3498FF70895A6B9405 /* HTMLText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DC581D9DDE636037C5FAB4A /* HTMLText.swift */; }; - D5BAB0C814A7AE71E58FED39 /* A01-OpenPublication.swift in Sources */ = {isa = PBXBuildFile; fileRef = B75178DB65AF67E034F3C4A5 /* A01-OpenPublication.swift */; }; - E1D8BAC3D0B056A27DA65AA1 /* Logger+Ext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21B9812F732ED2F093918E79 /* Logger+Ext.swift */; }; - E46744897A309BECE20573D9 /* ReadiumShared in Frameworks */ = {isa = PBXBuildFile; productRef = E01892658E366AE70B7B1386 /* ReadiumShared */; }; - EED944ABA27DA5FBD6370C23 /* DocumentRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7CDF2688AF525420855AC5 /* DocumentRepository.swift */; }; -/* End PBXBuildFile section */ - -/* Begin PBXFileReference section */ - 21B9812F732ED2F093918E79 /* Logger+Ext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Logger+Ext.swift"; sourceTree = ""; }; - 2C1CFC0B9AEB966345163620 /* PublicationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicationView.swift; sourceTree = ""; }; - 3A9F2917BE7D720CB89EBC9C /* UserError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserError.swift; sourceTree = ""; }; - 4168CCA0CF528F54A6DD0681 /* swift-toolkit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = swift-toolkit; path = ..; sourceTree = SOURCE_ROOT; }; - 4DC581D9DDE636037C5FAB4A /* HTMLText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLText.swift; sourceTree = ""; }; - 59844953100C517348EF23D0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 5BD99839EEEAFBFAF8A2264F /* PublicationMetadataView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicationMetadataView.swift; sourceTree = ""; }; - 5C7CDF2688AF525420855AC5 /* DocumentRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentRepository.swift; sourceTree = ""; }; - 5D8B2B0C8575F4648E445A44 /* DocumentList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentList.swift; sourceTree = ""; }; - A5AE9B43C414342BBB5CDDEF /* FileManager+Ext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Ext.swift"; sourceTree = ""; }; - A7E5C61917B53108BA01753E /* UserError+Readium.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserError+Readium.swift"; sourceTree = ""; }; - B75178DB65AF67E034F3C4A5 /* A01-OpenPublication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "A01-OpenPublication.swift"; sourceTree = ""; }; - B77C0B458C816697C5C670E9 /* PlaygroundApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaygroundApp.swift; sourceTree = ""; }; - BDA9169E926B14087F3B1BA2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; - CCB6D3C4C19C2038573D2B90 /* JSONView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONView.swift; sourceTree = ""; }; - D608867E2F9CC0B751114DE9 /* A02-ReadMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "A02-ReadMetadata.swift"; sourceTree = ""; }; - E40DD68F934F5F0D2981ACA1 /* Playground.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Playground.app; sourceTree = BUILT_PRODUCTS_DIR; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 2F8D7CF22299B8D14091AB8E /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - E46744897A309BECE20573D9 /* ReadiumShared in Frameworks */, - 7FB8FD4A23F144C56E82ED91 /* ReadiumStreamer in Frameworks */, - CA6204854C325EE686871A2C /* ReadiumNavigator in Frameworks */, - 1BCA1AEC8E94F11FFEF3A0C8 /* ReadiumOPDS in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 125CAF6B72840AFE3F05FC95 /* Views */ = { - isa = PBXGroup; - children = ( - 4DC581D9DDE636037C5FAB4A /* HTMLText.swift */, - CCB6D3C4C19C2038573D2B90 /* JSONView.swift */, - ); - path = Views; - sourceTree = ""; - }; - 3F42D23B0ABB28C66695E5A5 /* Recipes */ = { - isa = PBXGroup; - children = ( - B75178DB65AF67E034F3C4A5 /* A01-OpenPublication.swift */, - D608867E2F9CC0B751114DE9 /* A02-ReadMetadata.swift */, - ); - path = Recipes; - sourceTree = ""; - }; - 3F6B916872C30820F6241E84 /* Products */ = { - isa = PBXGroup; - children = ( - E40DD68F934F5F0D2981ACA1 /* Playground.app */, - ); - name = Products; - sourceTree = ""; - }; - 4D0CAE133B02D7D170557CBE /* App */ = { - isa = PBXGroup; - children = ( - B77C0B458C816697C5C670E9 /* PlaygroundApp.swift */, - B8E0BBE1E017E8FF9795D4AF /* Common */, - 6C3B230691D6AF2F58824345 /* Data */, - 6BE58A63F0E2175D1974228F /* Publication */, - ); - path = App; - sourceTree = ""; - }; - 64AF3FB125E0EA956222B31B /* Extensions */ = { - isa = PBXGroup; - children = ( - A5AE9B43C414342BBB5CDDEF /* FileManager+Ext.swift */, - 21B9812F732ED2F093918E79 /* Logger+Ext.swift */, - ); - path = Extensions; - sourceTree = ""; - }; - 6BE58A63F0E2175D1974228F /* Publication */ = { - isa = PBXGroup; - children = ( - 5BD99839EEEAFBFAF8A2264F /* PublicationMetadataView.swift */, - 2C1CFC0B9AEB966345163620 /* PublicationView.swift */, - ); - path = Publication; - sourceTree = ""; - }; - 6C3B230691D6AF2F58824345 /* Data */ = { - isa = PBXGroup; - children = ( - 5D8B2B0C8575F4648E445A44 /* DocumentList.swift */, - 5C7CDF2688AF525420855AC5 /* DocumentRepository.swift */, - ); - path = Data; - sourceTree = ""; - }; - 75054112A41CDCE58ADACF92 /* Packages */ = { - isa = PBXGroup; - children = ( - 4168CCA0CF528F54A6DD0681 /* swift-toolkit */, - ); - name = Packages; - sourceTree = ""; - }; - AABC9BE64A1302199D5D2AF8 /* Sources */ = { - isa = PBXGroup; - children = ( - 59844953100C517348EF23D0 /* Assets.xcassets */, - BDA9169E926B14087F3B1BA2 /* Info.plist */, - 4D0CAE133B02D7D170557CBE /* App */, - 3F42D23B0ABB28C66695E5A5 /* Recipes */, - ); - path = Sources; - sourceTree = ""; - }; - B8E0BBE1E017E8FF9795D4AF /* Common */ = { - isa = PBXGroup; - children = ( - 3A9F2917BE7D720CB89EBC9C /* UserError.swift */, - A7E5C61917B53108BA01753E /* UserError+Readium.swift */, - 64AF3FB125E0EA956222B31B /* Extensions */, - 125CAF6B72840AFE3F05FC95 /* Views */, - ); - path = Common; - sourceTree = ""; - }; - E23F411CF4F5D5291A0322DE = { - isa = PBXGroup; - children = ( - 75054112A41CDCE58ADACF92 /* Packages */, - AABC9BE64A1302199D5D2AF8 /* Sources */, - 3F6B916872C30820F6241E84 /* Products */, - ); - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 739B1FD817D42F0264714A50 /* Playground */ = { - isa = PBXNativeTarget; - buildConfigurationList = CBF67F902A381FB2989A98D4 /* Build configuration list for PBXNativeTarget "Playground" */; - buildPhases = ( - E40BECB45945A673BB0CC3F9 /* Sources */, - C071FD4667C21888C52DF25C /* Resources */, - 2F8D7CF22299B8D14091AB8E /* Frameworks */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = Playground; - packageProductDependencies = ( - E01892658E366AE70B7B1386 /* ReadiumShared */, - 79CBFD1B8193030A2DB6A839 /* ReadiumStreamer */, - 872E0CB31611AD93E229C627 /* ReadiumNavigator */, - 41FF3979DE8082AF5E23D69D /* ReadiumOPDS */, - ); - productName = Playground; - productReference = E40DD68F934F5F0D2981ACA1 /* Playground.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - DF84942AD828BBDD499F04C0 /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = YES; - LastUpgradeCheck = 1430; - TargetAttributes = { - }; - }; - buildConfigurationList = C1581E14B552D0BE7FA2423D /* Build configuration list for PBXProject "Playground" */; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - Base, - en, - ); - mainGroup = E23F411CF4F5D5291A0322DE; - minimizedProjectReferenceProxies = 1; - packageReferences = ( - 69DDD3FA2655009065C0DDED /* XCLocalSwiftPackageReference ".." */, - ); - preferredProjectObjectVersion = 77; - productRefGroup = 3F6B916872C30820F6241E84 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 739B1FD817D42F0264714A50 /* Playground */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - C071FD4667C21888C52DF25C /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 07FE4C9817951411354484C0 /* Assets.xcassets in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - E40BECB45945A673BB0CC3F9 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - D5BAB0C814A7AE71E58FED39 /* A01-OpenPublication.swift in Sources */, - BEB5501D6869FDCA129A87B9 /* A02-ReadMetadata.swift in Sources */, - 52AF5B85A8E5285B966E7CB5 /* DocumentList.swift in Sources */, - EED944ABA27DA5FBD6370C23 /* DocumentRepository.swift in Sources */, - 2EE593DC2038FDF776324278 /* FileManager+Ext.swift in Sources */, - D4ACEB3498FF70895A6B9405 /* HTMLText.swift in Sources */, - 3FA679B6B90C8BD7B5A7DF58 /* JSONView.swift in Sources */, - E1D8BAC3D0B056A27DA65AA1 /* Logger+Ext.swift in Sources */, - B19623280F8C457F051B3110 /* PlaygroundApp.swift in Sources */, - 6C9BEF9E1487605B673E48AA /* PublicationMetadataView.swift in Sources */, - 7EBCAA279457CB27B0A3F136 /* PublicationView.swift in Sources */, - D2EF387DADE049BCBDBEE738 /* UserError+Readium.swift in Sources */, - 52805E26E8DF05E511042B97 /* UserError.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin XCBuildConfiguration section */ - 18883859C44E4AE8042B204F /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "$(inherited)", - "DEBUG=1", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - ONLY_ACTIVE_ARCH = YES; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = iphoneos; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - 4F8F737A22C49C70A327F32E /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_IDENTITY = "iPhone Developer"; - INFOPLIST_FILE = Sources/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = org.readium.Playground; - SDKROOT = iphoneos; - SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; - SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - DF83FEB514DF89BC00722881 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_IDENTITY = "iPhone Developer"; - INFOPLIST_FILE = Sources/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = org.readium.Playground; - SDKROOT = iphoneos; - SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; - SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Release; - }; - FE96C092F5D790A83D093866 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - C1581E14B552D0BE7FA2423D /* Build configuration list for PBXProject "Playground" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 18883859C44E4AE8042B204F /* Debug */, - FE96C092F5D790A83D093866 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Debug; - }; - CBF67F902A381FB2989A98D4 /* Build configuration list for PBXNativeTarget "Playground" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 4F8F737A22C49C70A327F32E /* Debug */, - DF83FEB514DF89BC00722881 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Debug; - }; -/* End XCConfigurationList section */ - -/* Begin XCLocalSwiftPackageReference section */ - 69DDD3FA2655009065C0DDED /* XCLocalSwiftPackageReference ".." */ = { - isa = XCLocalSwiftPackageReference; - relativePath = ..; - }; -/* End XCLocalSwiftPackageReference section */ - -/* Begin XCSwiftPackageProductDependency section */ - 41FF3979DE8082AF5E23D69D /* ReadiumOPDS */ = { - isa = XCSwiftPackageProductDependency; - productName = ReadiumOPDS; - }; - 79CBFD1B8193030A2DB6A839 /* ReadiumStreamer */ = { - isa = XCSwiftPackageProductDependency; - productName = ReadiumStreamer; - }; - 872E0CB31611AD93E229C627 /* ReadiumNavigator */ = { - isa = XCSwiftPackageProductDependency; - productName = ReadiumNavigator; - }; - E01892658E366AE70B7B1386 /* ReadiumShared */ = { - isa = XCSwiftPackageProductDependency; - productName = ReadiumShared; - }; -/* End XCSwiftPackageProductDependency section */ - }; - rootObject = DF84942AD828BBDD499F04C0 /* Project object */; -} diff --git a/Playground/Playground.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Playground/Playground.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a625..0000000000 --- a/Playground/Playground.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/Playground/Playground.xcodeproj/xcshareddata/xcschemes/Playground.xcscheme b/Playground/Playground.xcodeproj/xcshareddata/xcschemes/Playground.xcscheme deleted file mode 100644 index 42d9cba221..0000000000 --- a/Playground/Playground.xcodeproj/xcshareddata/xcschemes/Playground.xcscheme +++ /dev/null @@ -1,97 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Playground/Playground.xctestplan b/Playground/Support/Playground.xctestplan similarity index 82% rename from Playground/Playground.xctestplan rename to Playground/Support/Playground.xctestplan index 25536a82c6..9012e4f148 100644 --- a/Playground/Playground.xctestplan +++ b/Playground/Support/Playground.xctestplan @@ -11,12 +11,7 @@ "defaultOptions" : { "codeCoverage" : false, "language" : "en", - "region" : "US", - "targetForVariableExpansion" : { - "containerPath" : "container:Playground.xcodeproj", - "identifier" : "BED194980D56484BC288A866", - "name" : "Playground" - } + "region" : "US" }, "testTargets" : [ { diff --git a/Playground/Support/project+lcp.yml b/Playground/Support/project+lcp.yml new file mode 100644 index 0000000000..3a4c5e8ea3 --- /dev/null +++ b/Playground/Support/project+lcp.yml @@ -0,0 +1,40 @@ +# Playground project with Readium LCP, generated by `make playground lcp=`. +include: + # `relativePaths: false` keeps the included paths relative to the project + # root rather than to the Support folder. + - path: Support/project.yml + relativePaths: false + +packages: + R2LCPClient: + # The repository's Support folder, shared with the TestApp project. See the + # root Makefile. + path: ../Support/R2LCPClient + +settings: + OTHER_SWIFT_FLAGS: -DLCP + +targets: + Playground: + dependencies: + - package: R2LCPClient + product: R2LCPClient + - package: Readium + product: ReadiumLCP + + ReadiumLCPTests: + type: bundle.unit-test + platform: iOS + deploymentTarget: "16.0" + sources: + - path: ../Tests/LCPTests + group: ReadiumLCPTests + - path: ../Tests/Publications/TestPublications.swift + group: ReadiumLCPTests + - path: ../Tests/Publications/Publications + type: folder + group: ReadiumLCPTests + dependencies: + - target: Playground + settings: + GENERATE_INFOPLIST_FILE: YES diff --git a/Playground/project.yml b/Playground/Support/project.yml similarity index 65% rename from Playground/project.yml rename to Playground/Support/project.yml index 25401c4664..ebc616afb9 100644 --- a/Playground/project.yml +++ b/Playground/Support/project.yml @@ -1,6 +1,11 @@ +# Playground project without Readium LCP, generated by `make playground`. name: Playground options: bundleIdPrefix: org.readium +settings: + SWIFT_VERSION: 6.0 + SWIFT_APPROACHABLE_CONCURRENCY: Yes + SWIFT_DEFAULT_ACTOR_ISOLATION: MainActor packages: Readium: path: .. @@ -11,6 +16,8 @@ schemes: Playground: test: testPlans: + # Generated by `make playground` from Support/Playground.xctestplan, + # with the LCP tests when `lcp=` is provided. - path: Playground.xctestplan defaultPlan: true targets: @@ -29,8 +36,3 @@ targets: product: ReadiumNavigator - package: Readium product: ReadiumOPDS - settings: - SWIFT_VERSION: 6.0 - SWIFT_APPROACHABLE_CONCURRENCY: Yes - SWIFT_DEFAULT_ACTOR_ISOLATION: MainActor - diff --git a/Support/Readium.xcworkspace/contents.xcworkspacedata b/Support/Readium.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..ebc7cc75a8 --- /dev/null +++ b/Support/Readium.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + diff --git a/TestApp/.gitignore b/TestApp/.gitignore index 1c32784ad9..9a0c89bceb 100644 --- a/TestApp/.gitignore +++ b/TestApp/.gitignore @@ -1,12 +1,9 @@ -TestApp.xcodeproj -TestApp.xcworkspace -TestApp.xctestplan -project.yml +/TestApp.xcodeproj +/TestApp.xcworkspace +/project.yml # IntelliJ AppCode .idea -Pods/ -Podfile -Podfile.lock - -R2LCPClient/ +/Pods/ +/Podfile +/Podfile.lock diff --git a/TestApp/Integrations/Local/TestApp.xctestplan b/TestApp/Integrations/Local/TestApp.xctestplan deleted file mode 100644 index c1c83771d8..0000000000 --- a/TestApp/Integrations/Local/TestApp.xctestplan +++ /dev/null @@ -1,57 +0,0 @@ -{ - "configurations" : [ - { - "id" : "8C854D1A-8E77-4ADF-98B5-287FB5B8B996", - "name" : "Test Scheme Action", - "options" : { - - } - }, - { - "id" : "3E52C740-96D1-4E51-9D6B-1B4E1F7A2C11", - "name" : "Thread Sanitizer", - "options" : { - "threadSanitizerEnabled" : true - } - } - ], - "defaultOptions" : { - "codeCoverage" : false, - "targetForVariableExpansion" : { - "containerPath" : "container:TestApp.xcodeproj", - "identifier" : "BED194980D56484BC288A866", - "name" : "TestApp" - } - }, - "testTargets" : [ - { - "target" : { - "containerPath" : "container:..", - "identifier" : "ReadiumOPDSTests", - "name" : "ReadiumOPDSTests" - } - }, - { - "target" : { - "containerPath" : "container:..", - "identifier" : "ReadiumNavigatorTests", - "name" : "ReadiumNavigatorTests" - } - }, - { - "target" : { - "containerPath" : "container:..", - "identifier" : "ReadiumSharedTests", - "name" : "ReadiumSharedTests" - } - }, - { - "target" : { - "containerPath" : "container:..", - "identifier" : "ReadiumStreamerTests", - "name" : "ReadiumStreamerTests" - } - } - ], - "version" : 1 -} diff --git a/TestApp/Integrations/Local/project+lcp.yml b/TestApp/Integrations/Local/project+lcp.yml index 797e4adb12..64bdf10d08 100644 --- a/TestApp/Integrations/Local/project+lcp.yml +++ b/TestApp/Integrations/Local/project+lcp.yml @@ -5,7 +5,9 @@ packages: Readium: path: .. R2LCPClient: - path: R2LCPClient + # The repository's Support folder, shared with the Playground project. See + # TestApp/Makefile. + path: ../Support/R2LCPClient GRDB: url: https://github.com/groue/GRDB.swift.git from: 6.9.23 @@ -22,11 +24,7 @@ schemes: TestApp: build: targets: - TestApp: none - test: - testPlans: - - path: TestApp.xctestplan - defaultPlan: true + TestApp: all targets: TestApp: type: application @@ -61,4 +59,3 @@ targets: SWIFT_DEFAULT_ACTOR_ISOLATION: MainActor OTHER_SWIFT_FLAGS: -DLCP DEVELOPMENT_TEAM: ${RD_DEVELOPMENT_TEAM} - diff --git a/TestApp/Integrations/Local/project.yml b/TestApp/Integrations/Local/project.yml index 8f6e794a48..64324fbea0 100644 --- a/TestApp/Integrations/Local/project.yml +++ b/TestApp/Integrations/Local/project.yml @@ -20,11 +20,7 @@ schemes: TestApp: build: targets: - TestApp: none - test: - testPlans: - - path: TestApp.xctestplan - defaultPlan: true + TestApp: all targets: TestApp: type: application diff --git a/TestApp/Makefile b/TestApp/Makefile index a4ac660263..f4e394a8b2 100644 --- a/TestApp/Makefile +++ b/TestApp/Makefile @@ -23,13 +23,12 @@ clean: @rm -rf Pods @rm -rf TestApp.xcodeproj @rm -rf TestApp.xcworkspace - @rm -rf TestApp.xctestplan @rm -rf R2LCPClient spm: clean ifdef lcp @cp Integrations/SPM/project+lcp.yml project.yml - curl --create-dirs --output R2LCPClient/Package.swift "$(lcp)" + @curl --fail --silent --show-error -L --create-dirs --output R2LCPClient/Package.swift "$(lcp)" else @cp Integrations/SPM/project.yml . endif @@ -57,10 +56,12 @@ endif dev: clean ifdef lcp @cp Integrations/Local/project+lcp.yml project.yml - curl --create-dirs --output R2LCPClient/Package.swift "$(lcp)" + # The liblcp package is shared with the Playground, in the repository's + # Support folder. Two local packages named `R2LCPClient` in the same Xcode + # workspace conflict. + @curl --fail --silent --show-error -L --create-dirs --output ../Support/R2LCPClient/Package.swift "$(lcp)" else - @cp Integrations/Local/project.yml . + @cp Integrations/Local/project.yml project.yml endif - @cp -r Integrations/Local/TestApp.xctestplan . + @touch ../Package.swift xcodegen generate - diff --git a/Tests/LCPTests/LCPDecryptionTests.swift b/Tests/LCPTests/LCPDecryptionTests.swift index 061f48f0bd..3abb935ed4 100644 --- a/Tests/LCPTests/LCPDecryptionTests.swift +++ b/Tests/LCPTests/LCPDecryptionTests.swift @@ -9,7 +9,6 @@ import PDFKit import ReadiumShared import ReadiumStreamer import Testing -import TestPublications struct LCPDecryptionTests { let encryptedResource: Resource diff --git a/Tests/Publications/TestPublications.swift b/Tests/Publications/TestPublications.swift index 420e90b60a..5509ece657 100644 --- a/Tests/Publications/TestPublications.swift +++ b/Tests/Publications/TestPublications.swift @@ -6,10 +6,18 @@ import Foundation +#if !SWIFT_PACKAGE + private class BundleFinder {} +#endif + /// Provides access to shared test publication files. public enum TestPublications { - /// Returns the resource bundle containing shared test publications. - public static let bundle = Bundle.module + // Returns the resource bundle containing shared test publications. + #if SWIFT_PACKAGE + public static let bundle = Bundle.module + #else + public static let bundle = Bundle(for: BundleFinder.self) + #endif /// Returns a URL for the specified publication file. /// diff --git a/scripts/gen-lcp-testplan.py b/scripts/gen-lcp-testplan.py new file mode 100755 index 0000000000..8347fbf0c8 --- /dev/null +++ b/scripts/gen-lcp-testplan.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +""" +gen-lcp-testplan.py PLAN PBXPROJ + +Modifies the Playground test plan at PLAN by adding the ReadiumLCPTests target. + +The other test targets are declared in Package.swift and referenced by name. +ReadiumLCPTests is generated by XcodeGen in the Playground project instead, and +Xcode expects its identifier to be the UUID of the target in the project. It is +resolved from PBXPROJ, which is only available after XcodeGen ran. +""" + +import json +import re +import sys + +TARGET_NAME = "ReadiumLCPTests" + +def identifier(pbxproj_path): + with open(pbxproj_path) as file: + contents = file.read() + + match = re.search( + r"([0-9A-F]{24}) /\* " + TARGET_NAME + r" \*/ = \{\s*isa = PBXNativeTarget;", + contents, + ) + if match is None: + sys.exit("error: target {} not found in {}".format(TARGET_NAME, pbxproj_path)) + + return match.group(1) + +if len(sys.argv) != 3: + sys.exit(__doc__.strip()) + +plan_path, pbxproj_path = sys.argv[1], sys.argv[2] + +with open(plan_path) as file: + plan = json.load(file) + +plan["testTargets"].append( + { + "target": { + "containerPath": "container:Playground.xcodeproj", + "identifier": identifier(pbxproj_path), + "name": TARGET_NAME, + } + } +) + +with open(plan_path, "w") as file: + json.dump(plan, file, indent=2) diff --git a/scripts/test.sh b/scripts/test.sh index e9789ba8a9..7451eab191 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -5,9 +5,6 @@ # Run the test suite. # # FILTER - Optional target to run (e.g. ReadiumSharedTests) -# -# Set TSAN=1 to run the "Thread Sanitizer" test plan configuration instead of -# the default one. # ============================================================================= set -euo pipefail @@ -15,19 +12,83 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -DESTINATION="platform=iOS Simulator,name=iPad (A16)" +DEVICE_NAME="iPad (A16)" FILTER="${1:-}" -CONFIGURATION="Test Scheme Action" -[ "${TSAN:-0}" = "1" ] && CONFIGURATION="Thread Sanitizer" +PROJECT="$REPO_ROOT/Playground/Playground.xcodeproj" +if [ ! -d "$PROJECT" ]; then + echo "Playground project not found. Run 'make playground' first, or" >&2 + echo "'make playground lcp=' to also run the LCP tests." >&2 + exit 1 +fi + +# Pin the simulator to the runtime matching the selected Xcode, otherwise +# looking it up by name alone can pick a newer runtime. Tests depending on +# system frameworks such as PDFKit fail on a mismatched runtime. +OS_VERSION="$(xcrun --sdk iphonesimulator --show-sdk-version)" +RUNTIME_ID="com.apple.CoreSimulator.SimRuntime.iOS-${OS_VERSION//./-}" + +UDID="$( + xcrun simctl list devices --json | python3 -c ' +import json, sys + +runtime, name = sys.argv[1], sys.argv[2] +for device in json.load(sys.stdin)["devices"].get(runtime, []): + if device["name"] == name and device.get("isAvailable"): + print(device["udid"]) + break +' "$RUNTIME_ID" "$DEVICE_NAME" +)" + +if [ -z "$UDID" ]; then + echo "error: no available '$DEVICE_NAME' simulator for iOS $OS_VERSION" >&2 + exit 1 +fi + +# Boot the simulator up-front and leave it running, so that repeated runs skip +# the cold boot. +xcrun simctl bootstatus "$UDID" -b > /dev/null ARGS=( - -project "$REPO_ROOT/TestApp/TestApp.xcodeproj" - -scheme TestApp - -testPlan TestApp - -only-test-configuration "$CONFIGURATION" - -destination "$DESTINATION" + -project "$PROJECT" + -scheme Playground + -destination "platform=iOS Simulator,id=$UDID" + # Skip the package graph resolution, which hits the network on every run. + # Fails loudly when Package.resolved is out of date. + -disableAutomaticPackageResolution + -onlyUsePackageVersionsFromResolvedFile + -skipPackagePluginValidation + -skipMacroValidation ) -[ -n "$FILTER" ] && ARGS+=(-only-testing:"$FILTER") +if [ -n "$FILTER" ]; then + ARGS+=(-only-testing:"$FILTER") +fi + +STDERR_LOG="$(mktemp -t readium-test)" +trap 'rm -f "$STDERR_LOG"' EXIT + +# `set +e` around the pipeline only, to read xcodebuild's status from +# PIPESTATUS instead of aborting on a test failure. +set +e +xcodebuild test "${ARGS[@]}" \ + 2> "$STDERR_LOG" \ + | xcbeautify --quieter --disable-logging \ + | { grep -Ev "^Executed |Test Suite 'All tests'|Test run started\.|Test session results:" || true; } +STATUSES=("${PIPESTATUS[@]}") +set -e + +# Report xcodebuild's failure in priority, but don't let a broken formatting +# stage (e.g. a missing or crashing xcbeautify) go unnoticed either. +STATUS=0 +for status in "${STATUSES[@]}"; do + if [ "$status" -ne 0 ]; then + STATUS=$status + break + fi +done + +if [ "$STATUS" -ne 0 ]; then + cat "$STDERR_LOG" >&2 +fi -xcodebuild test "${ARGS[@]}" 2> /dev/null | xcbeautify --quieter --disable-logging | grep -Ev "^Executed |Test Suite 'All tests'|Test run started\.|Test session results:"; true +exit "$STATUS" From cd2ff51955baffe603fa86266efbd54807291fc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Mon, 10 Aug 2026 17:43:54 +0200 Subject: [PATCH 32/39] Add accessible name and description to content elements (#882) --- CHANGELOG.md | 13 + CONTEXT.md | 6 + Makefile | 14 +- Package.swift | 1 + .../Assets/Static/scripts/readium-fixed.js | 2 +- .../Static/scripts/readium-reflowable.js | 2 +- Sources/Navigator/EPUB/EPUBSpreadView.swift | 7 +- .../Navigator/EPUB/Scripts/jest.config.cjs | 7 + Sources/Navigator/EPUB/Scripts/package.json | 12 +- Sources/Navigator/EPUB/Scripts/pnpm-lock.yaml | 2448 ++++++++++++++++- Sources/Navigator/EPUB/Scripts/src/accname.ts | 264 ++ .../Navigator/EPUB/Scripts/src/gestures.js | 52 +- .../EPUB/Scripts/test/accname-sample.test.ts | 82 + .../EPUB/Scripts/test/figure-caption.test.ts | 65 + .../Scripts/test/fixtures/accname/image.xhtml | 465 ++++ .../Scripts/test/fixtures/accname/media.xhtml | 116 + .../Scripts/test/fixtures/accname/svg.xhtml | 149 + .../Scripts/test/fixtures/accname/table.xhtml | 143 + Sources/Navigator/EPUB/Scripts/tsconfig.json | 12 + .../Navigator/EPUB/Scripts/tsconfig.test.json | 7 + .../Services/Content/Content.swift | 76 +- .../HTMLAccessibilityProperties.swift | 262 ++ .../HTMLResourceContentIterator.swift | 36 +- .../Common/ImagePreview/ImagePreview.swift | 12 +- .../Services/Content/accname/image.xhtml | 465 ++++ .../Services/Content/accname/media.xhtml | 116 + .../Services/Content/accname/svg.xhtml | 149 + .../Services/Content/accname/table.xhtml | 143 + .../Iterators/AccnameSampleTests.swift | 149 + .../HTMLResourceContentIteratorTests.swift | 200 +- docs/Guides/Content.md | 9 +- docs/Guides/Navigator/EPUB Image Preview.md | 15 +- docs/adr/0001-pragmatic-accname-subset.md | 38 + scripts/accname-sample/.gitignore | 1 + scripts/accname-sample/README.md | 47 + scripts/accname-sample/assets/apple.png | Bin 0 -> 613 bytes scripts/accname-sample/assets/clip.mp4 | Bin 0 -> 1726 bytes scripts/accname-sample/assets/star.svg | 6 + scripts/accname-sample/assets/tone.m4a | Bin 0 -> 1184 bytes scripts/accname-sample/cases.toml | 676 +++++ scripts/accname-sample/epubcheck-baseline.txt | 10 + scripts/accname-sample/generate.py | 586 ++++ 42 files changed, 6671 insertions(+), 192 deletions(-) create mode 100644 CONTEXT.md create mode 100644 Sources/Navigator/EPUB/Scripts/jest.config.cjs create mode 100644 Sources/Navigator/EPUB/Scripts/src/accname.ts create mode 100644 Sources/Navigator/EPUB/Scripts/test/accname-sample.test.ts create mode 100644 Sources/Navigator/EPUB/Scripts/test/figure-caption.test.ts create mode 100644 Sources/Navigator/EPUB/Scripts/test/fixtures/accname/image.xhtml create mode 100644 Sources/Navigator/EPUB/Scripts/test/fixtures/accname/media.xhtml create mode 100644 Sources/Navigator/EPUB/Scripts/test/fixtures/accname/svg.xhtml create mode 100644 Sources/Navigator/EPUB/Scripts/test/fixtures/accname/table.xhtml create mode 100644 Sources/Navigator/EPUB/Scripts/tsconfig.json create mode 100644 Sources/Navigator/EPUB/Scripts/tsconfig.test.json create mode 100644 Sources/Shared/Publication/Services/Content/Iterators/HTMLAccessibilityProperties.swift create mode 100644 Tests/SharedTests/Fixtures/Publication/Services/Content/accname/image.xhtml create mode 100644 Tests/SharedTests/Fixtures/Publication/Services/Content/accname/media.xhtml create mode 100644 Tests/SharedTests/Fixtures/Publication/Services/Content/accname/svg.xhtml create mode 100644 Tests/SharedTests/Fixtures/Publication/Services/Content/accname/table.xhtml create mode 100644 Tests/SharedTests/Publication/Services/Content/Iterators/AccnameSampleTests.swift create mode 100644 docs/adr/0001-pragmatic-accname-subset.md create mode 100644 scripts/accname-sample/.gitignore create mode 100644 scripts/accname-sample/README.md create mode 100644 scripts/accname-sample/assets/apple.png create mode 100644 scripts/accname-sample/assets/clip.mp4 create mode 100644 scripts/accname-sample/assets/star.svg create mode 100644 scripts/accname-sample/assets/tone.m4a create mode 100644 scripts/accname-sample/cases.toml create mode 100644 scripts/accname-sample/epubcheck-baseline.txt create mode 100644 scripts/accname-sample/generate.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e04b26a7a..978792df1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. Take a look ## [Unreleased: swift6] +### Added + +#### Shared + +* Content elements now expose `accessibleName` and `accessibleDescription` attributes, computed following a subset of [the W3C accessible name computation](https://www.w3.org/TR/accname-1.2). +* The HTML content iterator now emits inline `` elements as `SVGContentElement`, with a caption from the enclosing figure's `figcaption`. +* `AudioContentElement` and `VideoContentElement` now expose a `caption` property, filled from the enclosing figure's `figcaption` like images and SVGs already were. + ### Changed * The toolkit is migrated to Swift 6 with strict concurrency checking. All packages compile in the Swift 6 language mode. See [the migration guide](docs/Migration%20Guide.md). @@ -12,6 +20,11 @@ All notable changes to this project will be documented in this file. Take a look #### Shared +* `ImageContentElement.caption` and `SVGContentElement.caption` are now strictly the text of the enclosing figure's `figcaption`. Other sources (such as `alt`) contribute to `accessibleName` instead. +* Audio and video content elements now expose accessibility attributes, so the text-to-speech may start speaking their labels. + +#### Shared + * OPDS models (`Feed`, `Group`, `Facet`, `OpdsMetadata`) are now structs with value semantics. * `Publication`, `Resource`, `Container` and related types are now `Sendable`. Custom implementations of `Resource`, `Container`, `HTTPClient` or `PublicationService` must be `Sendable` too. * `Resource.stream()` now cooperates with task cancellation: the built-in resources fail with `ReadError.cancelled` when the surrounding task is cancelled, and custom implementations are expected to do the same. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000000..0ec45686e9 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,6 @@ +# Glossary + +- **Caption**: the visible, authored caption of an image or graphic, i.e. the `
` of its enclosing `
`. Distinct from any accessibility text. +- **Accessible Name**: the short text identifying an element for assistive technologies, computed per [accname-1.2](https://www.w3.org/TR/accname-1.2) (aria-labelledby → aria-label → host-language sources such as `alt` → `title`). An empty `alt` marks a decorative image with no name. +- **Accessible Description**: supplementary text extending the accessible name, computed per [accname-1.2](https://www.w3.org/TR/accname-1.2) (aria-describedby → aria-description → unused `title`). Always a flat string. +- **Extended Description**: a structured, navigable long description of an image, associated via `aria-details` (per the DAISY extended-description best practices), living inline or in a separate resource with a backlink. NOT part of the accessible name/description computations and not implemented yet; when supported, it will be a separate attribute carrying a link/Locator, not a string. diff --git a/Makefile b/Makefile index fde9410cae..4f21cb571c 100644 --- a/Makefile +++ b/Makefile @@ -57,12 +57,14 @@ navigator-ui-tests-project: 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; \ + cd $(SCRIPTS_PATH) && \ + rm -rf "node_modules" && \ + corepack install && \ + pnpm install --frozen-lockfile && \ + pnpm run format && \ + pnpm run lint && \ + pnpm run typecheck && \ + pnpm run test && \ pnpm run bundle .PHONY: update-scripts diff --git a/Package.swift b/Package.swift index 103b445194..417dc0a20e 100644 --- a/Package.swift +++ b/Package.swift @@ -48,6 +48,7 @@ let package = Package( dependencies: [ "ReadiumShared", "TestPublications", + "SwiftSoup", ], path: "Tests/SharedTests", resources: [ diff --git a/Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed.js b/Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed.js index 5913290ee5..207a76fe4c 100644 --- a/Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed.js +++ b/Sources/Navigator/EPUB/Assets/Static/scripts/readium-fixed.js @@ -1,2 +1,2 @@ -(()=>{var t={3618(t,e){"use strict";function r(t){return t.split("").reverse().join("")}function n(t){return(t|-t)>>31&1}function o(t,e,r,o){var i=t.P[r],a=t.M[r],s=o>>>31,u=e[r]|s,l=u|a,c=(u&i)+i^i|u,f=a|~(c|i),p=i&c,d=n(f&t.lastRowMask[r])-n(p&t.lastRowMask[r]);return f<<=1,p<<=1,i=(p|=s)|~(l|(f|=n(o)-s)),a=f&l,t.P[r]=i,t.M[r]=a,d}function i(t,e,r){if(0===e.length)return[];r=Math.min(r,e.length);var n=[],i=32,a=Math.ceil(e.length/i)-1,s={P:new Uint32Array(a+1),M:new Uint32Array(a+1),lastRowMask:new Uint32Array(a+1)};s.lastRowMask.fill(1<<31),s.lastRowMask[a]=1<<(e.length-1)%i;for(var u=new Uint32Array(a+1),l=new Map,c=[],f=0;f<256;f++)c.push(u);for(var p=0;p=e.length||e.charCodeAt(m)===d&&(y[h]|=1<0&&v[b]>=r+i;)b-=1;b===a&&v[b]<=r&&(v[b]0?r:0,!0)},o?o(t.exports,"apply",{value:a}):t.exports.apply=a},5298(t,e,r){"use strict";var n=r(703),o=r(5312),i=o([n("%String.prototype.indexOf%")]);t.exports=function(t,e){var r=n(t,!!e);return"function"==typeof r&&i(t,".prototype.")>-1?o([r]):r}},7517(t,e,r){"use strict";var n=r(9173),o=r(7388),i=r(7379),a=r(3492);t.exports=function(t,e,r){if(!t||"object"!=typeof t&&"function"!=typeof t)throw new i("`obj` must be an object or a function`");if("string"!=typeof e&&"symbol"!=typeof e)throw new i("`property` must be a string or a symbol`");if(arguments.length>3&&"boolean"!=typeof arguments[3]&&null!==arguments[3])throw new i("`nonEnumerable`, if provided, must be a boolean or null");if(arguments.length>4&&"boolean"!=typeof arguments[4]&&null!==arguments[4])throw new i("`nonWritable`, if provided, must be a boolean or null");if(arguments.length>5&&"boolean"!=typeof arguments[5]&&null!==arguments[5])throw new i("`nonConfigurable`, if provided, must be a boolean or null");if(arguments.length>6&&"boolean"!=typeof arguments[6])throw new i("`loose`, if provided, must be a boolean");var s=arguments.length>3?arguments[3]:null,u=arguments.length>4?arguments[4]:null,l=arguments.length>5?arguments[5]:null,c=arguments.length>6&&arguments[6],f=!!a&&a(t,e);if(n)n(t,e,{configurable:null===l&&f?f.configurable:!l,enumerable:null===s&&f?f.enumerable:!s,value:r,writable:null===u&&f?f.writable:!u});else{if(!c&&(s||u||l))throw new o("This environment does not support defining a property as non-configurable, non-writable, or non-enumerable.");t[e]=r}}},8189(t,e,r){"use strict";var n=r(1748),o="function"==typeof Symbol&&"symbol"==typeof Symbol("foo"),i=Object.prototype.toString,a=Array.prototype.concat,s=r(7517),u=r(708)(),l=function(t,e,r,n){if(e in t)if(!0===n){if(t[e]===r)return}else if("function"!=typeof(o=n)||"[object Function]"!==i.call(o)||!n())return;var o;u?s(t,e,r,!0):s(t,e,r)},c=function(t,e){var r=arguments.length>2?arguments[2]:{},i=n(e);o&&(i=a.call(i,Object.getOwnPropertySymbols(e)));for(var s=0;s0&&arguments[1]||"Cannot call method on "+t);return t}},9253(t){"use strict";t.exports=Object},4938(t){"use strict";t.exports=function(t){return!!t&&("function"==typeof t||"object"==typeof t)}},3148(t,e,r){"use strict";var n=r(703)("%Object.defineProperty%",!0),o=r(6618)(),i=r(9939),a=r(7379),s=o?Symbol.toStringTag:null;t.exports=function(t,e){var r=arguments.length>2&&!!arguments[2]&&arguments[2].force,o=arguments.length>2&&!!arguments[2]&&arguments[2].nonConfigurable;if(void 0!==r&&"boolean"!=typeof r||void 0!==o&&"boolean"!=typeof o)throw new a("if provided, the `overrideIfSet` and `nonConfigurable` options must be booleans");!s||!r&&i(t,s)||(n?n(t,s,{configurable:!o,enumerable:!1,value:e,writable:!1}):t[s]=e)}},2632(t,e,r){"use strict";var n="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator,o=r(2719),i=r(5833),a=r(1718),s=r(7379),u=r(5465),l=r(7377);t.exports=function(t){if(u(t))return t;var e,r="default";if(arguments.length>1&&(arguments[1]===String?r="string":arguments[1]===Number&&(r="number")),n&&(Symbol.toPrimitive?e=l(t,Symbol.toPrimitive):a(t)&&(e=Symbol.prototype.valueOf)),void 0!==e){var c=e.call(t,r);if(u(c))return c;throw new s("unable to convert exotic object to primitive")}return"default"===r&&(i(t)||a(t))&&(r="string"),function(t,e){if(null==t)throw new s("Cannot call method on "+t);if("string"!=typeof e||"number"!==e&&"string"!==e)throw new s('hint must be "string" or "number"');var r,n,i,a="string"===e?["toString","valueOf"]:["valueOf","toString"];for(i=0;i1&&"boolean"!=typeof e)throw new c('"allowMissing" argument must be a boolean');if(null===z(/^%?[^%]*%?$/,t))throw new l("`%` may not be present anywhere but at the beginning and end of the intrinsic name");var r=function(t){var e=U(t,0,1),r=U(t,-1);if("%"===e&&"%"!==r)throw new l("invalid intrinsic syntax, expected closing `%`");if("%"===r&&"%"!==e)throw new l("invalid intrinsic syntax, expected opening `%`");var n=[];return W(t,V,function(t,e,r,o){n[n.length]=r?W(o,H,"$1"):e||t}),n}(t),n=r.length>0?r[0]:"",o=G("%"+n+"%",e),i=o.name,a=o.value,s=!1,u=o.alias;u&&(n=u[0],D(r,L([0,1],u)));for(var f=1,p=!0;f=r.length){var g=x(a,d);a=(p=!!g)&&"get"in g&&!("originalValue"in g.get)?g.get:a[d]}else p=B(a,d),a=a[d];p&&!s&&(I[i]=a)}}return a}},8819(t,e,r){"use strict";var n=r(9253);t.exports=n.getPrototypeOf||null},2517(t){"use strict";t.exports="undefined"!=typeof Reflect&&Reflect.getPrototypeOf||null},1611(t,e,r){"use strict";var n=r(2517),o=r(8819),i=r(1449);t.exports=n?function(t){return n(t)}:o?function(t){if(!t||"object"!=typeof t&&"function"!=typeof t)throw new TypeError("getProto: not an object");return o(t)}:i?function(t){return i(t)}:null},4656(t){"use strict";t.exports=Object.getOwnPropertyDescriptor},3492(t,e,r){"use strict";var n=r(4656);if(n)try{n([],"length")}catch(t){n=null}t.exports=n},708(t,e,r){"use strict";var n=r(9173),o=function(){return!!n};o.hasArrayLengthDefineBug=function(){if(!n)return null;try{return 1!==n([],"length",{value:1}).length}catch(t){return!0}},t.exports=o},6052(t){"use strict";var e={__proto__:null,foo:{}},r={__proto__:e}.foo===e.foo&&!(e instanceof Object);t.exports=function(){return r}},7657(t,e,r){"use strict";var n="undefined"!=typeof Symbol&&Symbol,o=r(8123);t.exports=function(){return"function"==typeof n&&"function"==typeof Symbol&&"symbol"==typeof n("foo")&&"symbol"==typeof Symbol("bar")&&o()}},8123(t){"use strict";t.exports=function(){if("function"!=typeof Symbol||"function"!=typeof Object.getOwnPropertySymbols)return!1;if("symbol"==typeof Symbol.iterator)return!0;var t={},e=Symbol("test"),r=Object(e);if("string"==typeof e)return!1;if("[object Symbol]"!==Object.prototype.toString.call(e))return!1;if("[object Symbol]"!==Object.prototype.toString.call(r))return!1;for(var n in t[e]=42,t)return!1;if("function"==typeof Object.keys&&0!==Object.keys(t).length)return!1;if("function"==typeof Object.getOwnPropertyNames&&0!==Object.getOwnPropertyNames(t).length)return!1;var o=Object.getOwnPropertySymbols(t);if(1!==o.length||o[0]!==e)return!1;if(!Object.prototype.propertyIsEnumerable.call(t,e))return!1;if("function"==typeof Object.getOwnPropertyDescriptor){var i=Object.getOwnPropertyDescriptor(t,e);if(42!==i.value||!0!==i.enumerable)return!1}return!0}},6618(t,e,r){"use strict";var n=r(8123);t.exports=function(){return n()&&!!Symbol.toStringTag}},9939(t,e,r){"use strict";var n=Function.prototype.call,o=Object.prototype.hasOwnProperty,i=r(3583);t.exports=i.call(n,o)},6561(t,e,r){"use strict";var n=r(9939),o=r(6746)(),i=r(7379),a={assert:function(t,e){if(!t||"object"!=typeof t&&"function"!=typeof t)throw new i("`O` is not an object");if("string"!=typeof e)throw new i("`slot` must be a string");if(o.assert(t),!a.has(t,e))throw new i("`"+e+"` is not present on `O`")},get:function(t,e){if(!t||"object"!=typeof t&&"function"!=typeof t)throw new i("`O` is not an object");if("string"!=typeof e)throw new i("`slot` must be a string");var r=o.get(t);return r&&r["$"+e]},has:function(t,e){if(!t||"object"!=typeof t&&"function"!=typeof t)throw new i("`O` is not an object");if("string"!=typeof e)throw new i("`slot` must be a string");var r=o.get(t);return!!r&&n(r,"$"+e)},set:function(t,e,r){if(!t||"object"!=typeof t&&"function"!=typeof t)throw new i("`O` is not an object");if("string"!=typeof e)throw new i("`slot` must be a string");var n=o.get(t);n||(n={},o.set(t,n)),n["$"+e]=r}};Object.freeze&&Object.freeze(a),t.exports=a},2719(t){"use strict";var e,r,n=Function.prototype.toString,o="object"==typeof Reflect&&null!==Reflect&&Reflect.apply;if("function"==typeof o&&"function"==typeof Object.defineProperty)try{e=Object.defineProperty({},"length",{get:function(){throw r}}),r={},o(function(){throw 42},null,e)}catch(t){t!==r&&(o=null)}else o=null;var i=/^\s*class\b/,a=function(t){try{var e=n.call(t);return i.test(e)}catch(t){return!1}},s=function(t){try{return!a(t)&&(n.call(t),!0)}catch(t){return!1}},u=Object.prototype.toString,l="function"==typeof Symbol&&!!Symbol.toStringTag,c=!(0 in[,]),f=function(){return!1};if("object"==typeof document){var p=document.all;u.call(p)===u.call(document.all)&&(f=function(t){if((c||!t)&&(void 0===t||"object"==typeof t))try{var e=u.call(t);return("[object HTMLAllCollection]"===e||"[object HTML document.all class]"===e||"[object HTMLCollection]"===e||"[object Object]"===e)&&null==t("")}catch(t){}return!1})}t.exports=o?function(t){if(f(t))return!0;if(!t)return!1;if("function"!=typeof t&&"object"!=typeof t)return!1;try{o(t,null,e)}catch(t){if(t!==r)return!1}return!a(t)&&s(t)}:function(t){if(f(t))return!0;if(!t)return!1;if("function"!=typeof t&&"object"!=typeof t)return!1;if(l)return s(t);if(a(t))return!1;var e=u.call(t);return!("[object Function]"!==e&&"[object GeneratorFunction]"!==e&&!/^\[object HTML/.test(e))&&s(t)}},5833(t,e,r){"use strict";var n=r(5298),o=n("Date.prototype.getDay"),i=n("Object.prototype.toString"),a=r(6618)();t.exports=function(t){return"object"==typeof t&&null!==t&&(a?function(t){try{return o(t),!0}catch(t){return!1}}(t):"[object Date]"===i(t))}},4587(t,e,r){"use strict";var n,o=r(5298),i=r(6618)(),a=r(9939),s=r(3492);if(i){var u=o("RegExp.prototype.exec"),l={},c=function(){throw l},f={toString:c,valueOf:c};"symbol"==typeof Symbol.toPrimitive&&(f[Symbol.toPrimitive]=c),n=function(t){if(!t||"object"!=typeof t)return!1;var e=s(t,"lastIndex");if(!e||!a(e,"value"))return!1;try{u(t,f)}catch(t){return t===l}}}else{var p=o("Object.prototype.toString");n=function(t){return!(!t||"object"!=typeof t&&"function"!=typeof t)&&"[object RegExp]"===p(t)}}t.exports=n},1718(t,e,r){"use strict";var n=r(5298),o=n("Object.prototype.toString"),i=r(7657)(),a=r(5537);if(i){var s=n("Symbol.prototype.toString"),u=a(/^Symbol\(.*\)$/);t.exports=function(t){if("symbol"==typeof t)return!0;if(!t||"object"!=typeof t||"[object Symbol]"!==o(t))return!1;try{return function(t){return"symbol"==typeof t.valueOf()&&u(s(t))}(t)}catch(t){return!1}}}else t.exports=function(t){return!1}},9895(t){"use strict";t.exports=Math.abs},6241(t){"use strict";t.exports=Number.MAX_SAFE_INTEGER||9007199254740991},2153(t){"use strict";t.exports=Math.floor},1084(t,e,r){"use strict";var n=r(5518);t.exports=function(t){return("number"==typeof t||"bigint"==typeof t)&&!n(t)&&t!==1/0&&t!==-1/0}},1029(t,e,r){"use strict";var n=r(9895),o=r(2153),i=r(5518),a=r(1084);t.exports=function(t){if("number"!=typeof t||i(t)||!a(t))return!1;var e=n(t);return o(e)===e}},5518(t){"use strict";t.exports=Number.isNaN||function(t){return t!=t}},457(t){"use strict";t.exports=Math.max},1179(t){"use strict";t.exports=Math.min},5985(t){"use strict";t.exports=Math.pow},8639(t){"use strict";t.exports=Math.round},5738(t,e,r){"use strict";var n=r(5518);t.exports=function(t){return n(t)||0===t?t:t<0?-1:1}},4922(t,e,r){var n="function"==typeof Map&&Map.prototype,o=Object.getOwnPropertyDescriptor&&n?Object.getOwnPropertyDescriptor(Map.prototype,"size"):null,i=n&&o&&"function"==typeof o.get?o.get:null,a=n&&Map.prototype.forEach,s="function"==typeof Set&&Set.prototype,u=Object.getOwnPropertyDescriptor&&s?Object.getOwnPropertyDescriptor(Set.prototype,"size"):null,l=s&&u&&"function"==typeof u.get?u.get:null,c=s&&Set.prototype.forEach,f="function"==typeof WeakMap&&WeakMap.prototype?WeakMap.prototype.has:null,p="function"==typeof WeakSet&&WeakSet.prototype?WeakSet.prototype.has:null,d="function"==typeof WeakRef&&WeakRef.prototype?WeakRef.prototype.deref:null,y=Boolean.prototype.valueOf,h=Object.prototype.toString,g=Function.prototype.toString,m=String.prototype.match,b=String.prototype.slice,v=String.prototype.replace,w=String.prototype.toUpperCase,x=String.prototype.toLowerCase,S=RegExp.prototype.test,E=Array.prototype.concat,A=Array.prototype.join,O=Array.prototype.slice,j=Math.floor,R="function"==typeof BigInt?BigInt.prototype.valueOf:null,P=Object.getOwnPropertySymbols,T="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?Symbol.prototype.toString:null,C="function"==typeof Symbol&&"object"==typeof Symbol.iterator,N="function"==typeof Symbol&&Symbol.toStringTag&&(Symbol.toStringTag,1)?Symbol.toStringTag:null,M=Object.prototype.propertyIsEnumerable,I=("function"==typeof Reflect?Reflect.getPrototypeOf:Object.getPrototypeOf)||([].__proto__===Array.prototype?function(t){return t.__proto__}:null);function k(t,e){if(t===1/0||t===-1/0||t!=t||t&&t>-1e3&&t<1e3||S.call(/e/,e))return e;var r=/[0-9](?=(?:[0-9]{3})+(?![0-9]))/g;if("number"==typeof t){var n=t<0?-j(-t):j(t);if(n!==t){var o=String(n),i=b.call(e,o.length+1);return v.call(o,r,"$&_")+"."+v.call(v.call(i,/([0-9]{3})/g,"$&_"),/_$/,"")}}return v.call(e,r,"$&_")}var $=r(2179),F=$.custom,_=H(F)?F:null,B={__proto__:null,double:'"',single:"'"},L={__proto__:null,double:/(["\\])/g,single:/(['\\])/g};function D(t,e,r){var n=r.quoteStyle||e,o=B[n];return o+t+o}function W(t){return v.call(String(t),/"/g,""")}function U(t){return!N||!("object"==typeof t&&(N in t||void 0!==t[N]))}function z(t){return"[object Array]"===X(t)&&U(t)}function V(t){return"[object RegExp]"===X(t)&&U(t)}function H(t){if(C)return t&&"object"==typeof t&&t instanceof Symbol;if("symbol"==typeof t)return!0;if(!t||"object"!=typeof t||!T)return!1;try{return T.call(t),!0}catch(t){}return!1}t.exports=function t(e,r,n,o){var s=r||{};if(q(s,"quoteStyle")&&!q(B,s.quoteStyle))throw new TypeError('option "quoteStyle" must be "single" or "double"');if(q(s,"maxStringLength")&&("number"==typeof s.maxStringLength?s.maxStringLength<0&&s.maxStringLength!==1/0:null!==s.maxStringLength))throw new TypeError('option "maxStringLength", if provided, must be a positive integer, Infinity, or `null`');var u=!q(s,"customInspect")||s.customInspect;if("boolean"!=typeof u&&"symbol"!==u)throw new TypeError("option \"customInspect\", if provided, must be `true`, `false`, or `'symbol'`");if(q(s,"indent")&&null!==s.indent&&"\t"!==s.indent&&!(parseInt(s.indent,10)===s.indent&&s.indent>0))throw new TypeError('option "indent" must be "\\t", an integer > 0, or `null`');if(q(s,"numericSeparator")&&"boolean"!=typeof s.numericSeparator)throw new TypeError('option "numericSeparator", if provided, must be `true` or `false`');var h=s.numericSeparator;if(void 0===e)return"undefined";if(null===e)return"null";if("boolean"==typeof e)return e?"true":"false";if("string"==typeof e)return Y(e,s);if("number"==typeof e){if(0===e)return 1/0/e>0?"0":"-0";var w=String(e);return h?k(e,w):w}if("bigint"==typeof e){var S=String(e)+"n";return h?k(e,S):S}var j=void 0===s.depth?5:s.depth;if(void 0===n&&(n=0),n>=j&&j>0&&"object"==typeof e)return z(e)?"[Array]":"[Object]";var P,F=function(t,e){var r;if("\t"===t.indent)r="\t";else{if(!("number"==typeof t.indent&&t.indent>0))return null;r=A.call(Array(t.indent+1)," ")}return{base:r,prev:A.call(Array(e+1),r)}}(s,n);if(void 0===o)o=[];else if(K(o,e)>=0)return"[Circular]";function L(e,r,i){if(r&&(o=O.call(o)).push(r),i){var a={depth:s.depth};return q(s,"quoteStyle")&&(a.quoteStyle=s.quoteStyle),t(e,a,n+1,o)}return t(e,s,n+1,o)}if("function"==typeof e&&!V(e)){var G=function(t){if(t.name)return t.name;var e=m.call(g.call(t),/^function\s*([\w$]+)/);return e?e[1]:null}(e),J=rt(e,L);return"[Function"+(G?": "+G:" (anonymous)")+"]"+(J.length>0?" { "+A.call(J,", ")+" }":"")}if(H(e)){var nt=C?v.call(String(e),/^(Symbol\(.*\))_[^)]*$/,"$1"):T.call(e);return"object"!=typeof e||C?nt:Q(nt)}if((P=e)&&"object"==typeof P&&("undefined"!=typeof HTMLElement&&P instanceof HTMLElement||"string"==typeof P.nodeName&&"function"==typeof P.getAttribute)){for(var ot="<"+x.call(String(e.nodeName)),it=e.attributes||[],at=0;at"}if(z(e)){if(0===e.length)return"[]";var st=rt(e,L);return F&&!function(t){for(var e=0;e=0)return!1;return!0}(st)?"["+et(st,F)+"]":"[ "+A.call(st,", ")+" ]"}if(function(t){return"[object Error]"===X(t)&&U(t)}(e)){var ut=rt(e,L);return"cause"in Error.prototype||!("cause"in e)||M.call(e,"cause")?0===ut.length?"["+String(e)+"]":"{ ["+String(e)+"] "+A.call(ut,", ")+" }":"{ ["+String(e)+"] "+A.call(E.call("[cause]: "+L(e.cause),ut),", ")+" }"}if("object"==typeof e&&u){if(_&&"function"==typeof e[_]&&$)return $(e,{depth:j-n});if("symbol"!==u&&"function"==typeof e.inspect)return e.inspect()}if(function(t){if(!i||!t||"object"!=typeof t)return!1;try{i.call(t);try{l.call(t)}catch(t){return!0}return t instanceof Map}catch(t){}return!1}(e)){var lt=[];return a&&a.call(e,function(t,r){lt.push(L(r,e,!0)+" => "+L(t,e))}),tt("Map",i.call(e),lt,F)}if(function(t){if(!l||!t||"object"!=typeof t)return!1;try{l.call(t);try{i.call(t)}catch(t){return!0}return t instanceof Set}catch(t){}return!1}(e)){var ct=[];return c&&c.call(e,function(t){ct.push(L(t,e))}),tt("Set",l.call(e),ct,F)}if(function(t){if(!f||!t||"object"!=typeof t)return!1;try{f.call(t,f);try{p.call(t,p)}catch(t){return!0}return t instanceof WeakMap}catch(t){}return!1}(e))return Z("WeakMap");if(function(t){if(!p||!t||"object"!=typeof t)return!1;try{p.call(t,p);try{f.call(t,f)}catch(t){return!0}return t instanceof WeakSet}catch(t){}return!1}(e))return Z("WeakSet");if(function(t){if(!d||!t||"object"!=typeof t)return!1;try{return d.call(t),!0}catch(t){}return!1}(e))return Z("WeakRef");if(function(t){return"[object Number]"===X(t)&&U(t)}(e))return Q(L(Number(e)));if(function(t){if(!t||"object"!=typeof t||!R)return!1;try{return R.call(t),!0}catch(t){}return!1}(e))return Q(L(R.call(e)));if(function(t){return"[object Boolean]"===X(t)&&U(t)}(e))return Q(y.call(e));if(function(t){return"[object String]"===X(t)&&U(t)}(e))return Q(L(String(e)));if("undefined"!=typeof window&&e===window)return"{ [object Window] }";if("undefined"!=typeof globalThis&&e===globalThis||"undefined"!=typeof globalThis&&e===globalThis)return"{ [object globalThis] }";if(!function(t){return"[object Date]"===X(t)&&U(t)}(e)&&!V(e)){var ft=rt(e,L),pt=I?I(e)===Object.prototype:e instanceof Object||e.constructor===Object,dt=e instanceof Object?"":"null prototype",yt=!pt&&N&&Object(e)===e&&N in e?b.call(X(e),8,-1):dt?"Object":"",ht=(pt||"function"!=typeof e.constructor?"":e.constructor.name?e.constructor.name+" ":"")+(yt||dt?"["+A.call(E.call([],yt||[],dt||[]),": ")+"] ":"");return 0===ft.length?ht+"{}":F?ht+"{"+et(ft,F)+"}":ht+"{ "+A.call(ft,", ")+" }"}return String(e)};var G=Object.prototype.hasOwnProperty||function(t){return t in this};function q(t,e){return G.call(t,e)}function X(t){return h.call(t)}function K(t,e){if(t.indexOf)return t.indexOf(e);for(var r=0,n=t.length;re.maxStringLength){var r=t.length-e.maxStringLength,n="... "+r+" more character"+(r>1?"s":"");return Y(b.call(t,0,e.maxStringLength),e)+n}var o=L[e.quoteStyle||"single"];return o.lastIndex=0,D(v.call(v.call(t,o,"\\$1"),/[\x00-\x1f]/g,J),"single",e)}function J(t){var e=t.charCodeAt(0),r={8:"b",9:"t",10:"n",12:"f",13:"r"}[e];return r?"\\"+r:"\\x"+(e<16?"0":"")+w.call(e.toString(16))}function Q(t){return"Object("+t+")"}function Z(t){return t+" { ? }"}function tt(t,e,r,n){return t+" ("+e+") {"+(n?et(r,n):A.call(r,", "))+"}"}function et(t,e){if(0===t.length)return"";var r="\n"+e.prev+e.base;return r+A.call(t,","+r)+"\n"+e.prev}function rt(t,e){var r=z(t),n=[];if(r){n.length=t.length;for(var o=0;o0&&!o.call(t,0))for(var h=0;h0)for(var g=0;g=0&&"[object Function]"===e.call(t.callee)),n}},3743(t,e,r){"use strict";var n=r(7843),o=r(7379),i=Object;t.exports=n(function(){if(null==this||this!==i(this))throw new o("RegExp.prototype.flags getter called on non-object");var t="";return this.hasIndices&&(t+="d"),this.global&&(t+="g"),this.ignoreCase&&(t+="i"),this.multiline&&(t+="m"),this.dotAll&&(t+="s"),this.unicode&&(t+="u"),this.unicodeSets&&(t+="v"),this.sticky&&(t+="y"),t},"get flags",!0)},1721(t,e,r){"use strict";var n=r(8189),o=r(7965),i=r(3743),a=r(4510),s=r(3980),u=o(a());n(u,{getPolyfill:a,implementation:i,shim:s}),t.exports=u},4510(t,e,r){"use strict";var n=r(3743),o=r(8189).supportsDescriptors,i=Object.getOwnPropertyDescriptor;t.exports=function(){if(o&&"gim"===/a/gim.flags){var t=i(RegExp.prototype,"flags");if(t&&"function"==typeof t.get&&"dotAll"in RegExp.prototype&&"hasIndices"in RegExp.prototype){var e="",r={};if(Object.defineProperty(r,"hasIndices",{get:function(){e+="d"}}),Object.defineProperty(r,"sticky",{get:function(){e+="y"}}),t.get.call(r),"dy"===e)return t.get}}return n}},3980(t,e,r){"use strict";var n=r(8189).supportsDescriptors,o=r(4510),i=r(3492),a=Object.defineProperty,s=r(9183),u=r(1611),l=/a/;t.exports=function(){if(!n||!u)throw new s("RegExp.prototype.flags requires a true ES5 environment that supports property descriptors");var t=o(),e=u(l),r=i(e,"flags");return r&&r.get===t||a(e,"flags",{configurable:!0,enumerable:!1,get:t}),t}},5537(t,e,r){"use strict";var n=r(5298),o=r(4587),i=n("RegExp.prototype.exec"),a=r(7379);t.exports=function(t){if(!o(t))throw new a("`regex` must be a RegExp");return function(e){return null!==i(t,e)}}},2644(t,e,r){"use strict";var n=r(703),o=r(7517),i=r(708)(),a=r(3492),s=r(7379),u=n("%Math.floor%");t.exports=function(t,e){if("function"!=typeof t)throw new s("`fn` is not a function");if("number"!=typeof e||e<0||e>4294967295||u(e)!==e)throw new s("`length` must be a positive 32-bit integer");var r=arguments.length>2&&!!arguments[2],n=!0,l=!0;if("length"in t&&a){var c=a(t,"length");c&&!c.configurable&&(n=!1),c&&!c.writable&&(l=!1)}return(n||l||!r)&&(i?o(t,"length",e,!0,!0):o(t,"length",e)),t}},7843(t,e,r){"use strict";var n=r(7517),o=r(708)(),i=r(3749).functionsHaveConfigurableNames(),a=r(7379);t.exports=function(t,e){if("function"!=typeof t)throw new a("`fn` is not a function");return arguments.length>2&&!!arguments[2]&&!i||(o?n(t,"name",e,!0,!0):n(t,"name",e)),t}},7797(t,e,r){"use strict";var n=r(4922),o=r(7379),i=function(t,e,r){for(var n,o=t;null!=(n=o.next);o=n)if(n.key===e)return o.next=n.next,r||(n.next=t.next,t.next=n),n};t.exports=function(){var t,e={assert:function(t){if(!e.has(t))throw new o("Side channel does not contain "+n(t))},delete:function(e){var r=function(t,e){if(t)return i(t,e,!0)}(t,e);return r&&t&&!t.next&&(t=void 0),!!r},get:function(e){return function(t,e){if(t){var r=i(t,e);return r&&r.value}}(t,e)},has:function(e){return function(t,e){return!!t&&!!i(t,e)}(t,e)},set:function(e,r){t||(t={next:void 0}),function(t,e,r){var n=i(t,e);n?n.value=r:t.next={key:e,next:t.next,value:r}}(t,e,r)}};return e}},1085(t,e,r){"use strict";var n=r(703),o=r(5298),i=r(4922),a=r(7379),s=n("%Map%",!0),u=o("Map.prototype.get",!0),l=o("Map.prototype.set",!0),c=o("Map.prototype.has",!0),f=o("Map.prototype.delete",!0),p=o("Map.prototype.size",!0);t.exports=!!s&&function(){var t,e={assert:function(t){if(!e.has(t))throw new a("Side channel does not contain "+i(t))},delete:function(e){if(t){var r=f(t,e);return 0===p(t)&&(t=void 0),r}return!1},get:function(e){if(t)return u(t,e)},has:function(e){return!!t&&c(t,e)},set:function(e,r){t||(t=new s),l(t,e,r)}};return e}},2468(t,e,r){"use strict";var n=r(703),o=r(5298),i=r(4922),a=r(1085),s=r(7379),u=n("%WeakMap%",!0),l=o("WeakMap.prototype.get",!0),c=o("WeakMap.prototype.set",!0),f=o("WeakMap.prototype.has",!0),p=o("WeakMap.prototype.delete",!0);t.exports=u?function(){var t,e,r={assert:function(t){if(!r.has(t))throw new s("Side channel does not contain "+i(t))},delete:function(r){if(u&&r&&("object"==typeof r||"function"==typeof r)){if(t)return p(t,r)}else if(a&&e)return e.delete(r);return!1},get:function(r){return u&&r&&("object"==typeof r||"function"==typeof r)&&t?l(t,r):e&&e.get(r)},has:function(r){return u&&r&&("object"==typeof r||"function"==typeof r)&&t?f(t,r):!!e&&e.has(r)},set:function(r,n){u&&r&&("object"==typeof r||"function"==typeof r)?(t||(t=new u),c(t,r,n)):a&&(e||(e=a()),e.set(r,n))}};return r}:a},6746(t,e,r){"use strict";var n=r(7379),o=r(4922),i=r(7797),a=r(1085),s=r(2468)||a||i;t.exports=function(){var t,e={assert:function(t){if(!e.has(t)){var r=t&&Object(t)===t?"the given object key":o(t);throw new n("Side channel does not contain "+r)}},delete:function(e){return!!t&&t.delete(e)},get:function(e){return t&&t.get(e)},has:function(e){return!!t&&t.has(e)},set:function(e,r){t||(t=s()),t.set(e,r)}};return e}},4290(t,e,r){"use strict";var n=r(6520),o=r(7630),i=r(4111),a=r(333),s=r(1076),u=r(5363),l=r(5298),c=r(7657)(),f=r(1721),p=r(703),d=r(7379),y=p("%RegExp%"),h=l("String.prototype.indexOf"),g=r(2570),m=function(t){var e=g();if(c&&"symbol"==typeof Symbol.matchAll){var r=i(t,Symbol.matchAll);return r===y.prototype[Symbol.matchAll]&&r!==e?e:r}if(a(t))return e};t.exports=function(t){var e=u(this);if(null!=t){if(a(t)){var r="flags"in t?o(t,"flags"):f(t);if(u(r),h(s(r),"g")<0)throw new d("matchAll requires a global regular expression")}var i=m(t);if(void 0!==i)return n(i,t,[e])}var l=s(e),c=new y(t,"g");return n(m(c),c,[l])}},6410(t,e,r){"use strict";var n=r(7965),o=r(8189),i=r(4290),a=r(4683),s=r(3197),u=n(i);o(u,{getPolyfill:a,implementation:i,shim:s}),t.exports=u},2570(t,e,r){"use strict";var n=r(7657)(),o=r(1930);t.exports=function(){return n&&"symbol"==typeof Symbol.matchAll&&"function"==typeof RegExp.prototype[Symbol.matchAll]?RegExp.prototype[Symbol.matchAll]:o}},4683(t,e,r){"use strict";var n=r(4290);t.exports=function(){if(String.prototype.matchAll)try{"".matchAll(RegExp.prototype)}catch(t){return String.prototype.matchAll}return n}},1930(t,e,r){"use strict";var n=r(3990),o=r(7630),i=r(5234),a=r(518),s=r(6117),u=r(1076),l=r(192),c=r(1721),f=r(7843),p=r(5298),d=r(703),y=r(7379),h=p("String.prototype.indexOf"),g=d("%RegExp%"),m="flags"in g.prototype,b=f(function(t){var e=this;if("Object"!==l(e))throw new y('"this" value must be an Object');var r=u(t),f=function(t,e){var r="flags"in e?o(e,"flags"):u(c(e));return{flags:r,matcher:new t(m&&"string"==typeof r?e:t===g?e.source:e,r)}}(a(e,g),e),p=f.flags,d=f.matcher,b=s(o(e,"lastIndex"));i(d,"lastIndex",b,!0);var v=h(p,"g")>-1,w=h(p,"u")>-1;return n(d,r,v,w)},"[Symbol.matchAll]",!0);t.exports=b},3197(t,e,r){"use strict";var n=r(8189),o=r(7657)(),i=r(3492),a=r(4683),s=r(2570),u=Object.defineProperty;t.exports=function(){var t=a();if(n(String.prototype,{matchAll:t},{matchAll:function(){return String.prototype.matchAll!==t}}),o){var e=Symbol.matchAll||(Symbol.for?Symbol.for("Symbol.matchAll"):Symbol("Symbol.matchAll"));if(n(Symbol,{matchAll:e},{matchAll:function(){return Symbol.matchAll!==e}}),u&&i){var r=i(Symbol,e);r&&!r.configurable||u(Symbol,e,{configurable:!1,enumerable:!1,value:e,writable:!1})}var l=s(),c={};c[e]=l;var f={};f[e]=function(){return RegExp.prototype[e]!==l},n(RegExp.prototype,c,f)}return t}},3952(t,e,r){"use strict";var n=r(5363),o=r(2501),i=r(5298),a=r(5537),s=i("String.prototype.replace"),u=i("String.prototype.charAt"),l=i("String.prototype.slice"),c=/^\s$/.test("᠎"),f=c?/^[\x09\x0A\x0B\x0C\x0D\x20\xA0\u1680\u180E\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000\u2028\u2029\uFEFF]+/:/^[\x09\x0A\x0B\x0C\x0D\x20\xA0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000\u2028\u2029\uFEFF]+/,p=a(c?/[\x09\x0A\x0B\x0C\x0D\x20\xA0\u1680\u180E\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000\u2028\u2029\uFEFF]$/:/[\x09\x0A\x0B\x0C\x0D\x20\xA0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000\u2028\u2029\uFEFF]$/);t.exports=function(){for(var t=s(o(n(this)),f,""),e=t.length;e>0&&p(u(t,e-1));)e-=1;return l(t,0,e)}},7724(t,e,r){"use strict";var n=r(7965),o=r(8189),i=r(5363),a=r(3952),s=r(8821),u=r(5795),l=n(s()),c=function(t){return i(t),l(t)};o(c,{getPolyfill:s,implementation:a,shim:u}),t.exports=c},8821(t,e,r){"use strict";var n=r(3952);t.exports=function(){return String.prototype.trim&&"​"==="​".trim()&&"᠎"==="᠎".trim()&&"_᠎"==="_᠎".trim()&&"᠎_"==="᠎_".trim()?String.prototype.trim:n}},5795(t,e,r){"use strict";var n=r(708)(),o=r(7517),i=r(8821);t.exports=function(){var t=i();return String.prototype.trim!==t&&(n?o(String.prototype,"trim",t,!0):o(String.prototype,"trim",t)),t}},2179(){},6917(t,e,r){"use strict";var n=r(6562),o=r(7379),i=r(1029),a=r(6241);t.exports=function(t,e,r){if("string"!=typeof t)throw new o("Assertion failed: `S` must be a String");if(!i(e)||e<0||e>a)throw new o("Assertion failed: `length` must be an integer >= 0 and <= 2**53");if("boolean"!=typeof r)throw new o("Assertion failed: `unicode` must be a Boolean");return r?e+1>=t.length?e+1:e+n(t,e)["[[CodeUnitCount]]"]:e+1}},6520(t,e,r){"use strict";var n=r(703),o=r(5298),i=r(7379),a=r(3443),s=n("%Reflect.apply%",!0)||o("Function.prototype.apply");t.exports=function(t,e){var r=arguments.length>2?arguments[2]:[];if(!a(r))throw new i("Assertion failed: optional `argumentsList`, if provided, must be a List");return s(t,e,r)}},6562(t,e,r){"use strict";var n=r(7379),o=r(5298),i=r(3283),a=r(8537),s=r(1300),u=o("String.prototype.charAt"),l=o("String.prototype.charCodeAt");t.exports=function(t,e){if("string"!=typeof t)throw new n("Assertion failed: `string` must be a String");var r=t.length;if(e<0||e>=r)throw new n("Assertion failed: `position` must be >= 0, and < the length of `string`");var o=l(t,e),c=u(t,e),f=i(o),p=a(o);if(!f&&!p)return{"[[CodePoint]]":c,"[[CodeUnitCount]]":1,"[[IsUnpairedSurrogate]]":!1};if(p||e+1===r)return{"[[CodePoint]]":c,"[[CodeUnitCount]]":1,"[[IsUnpairedSurrogate]]":!0};var d=l(t,e+1);return a(d)?{"[[CodePoint]]":s(o,d),"[[CodeUnitCount]]":2,"[[IsUnpairedSurrogate]]":!1}:{"[[CodePoint]]":c,"[[CodeUnitCount]]":1,"[[IsUnpairedSurrogate]]":!0}}},44(t,e,r){"use strict";var n=r(7379);t.exports=function(t,e){if("boolean"!=typeof e)throw new n("Assertion failed: Type(done) is not Boolean");return{value:t,done:e}}},3990(t,e,r){"use strict";var n=r(703),o=r(7657)(),i=r(7379),a=r(4938),s=n("%IteratorPrototype%",!0),u=r(6917),l=r(44),c=r(355),f=r(7630),p=r(2021),d=r(3936),y=r(5234),h=r(6117),g=r(1076),m=r(6561),b=r(3148),v=function(t,e,r,n){if("string"!=typeof e)throw new i("`S` must be a string");if("boolean"!=typeof r)throw new i("`global` must be a boolean");if("boolean"!=typeof n)throw new i("`fullUnicode` must be a boolean");m.set(this,"[[IteratingRegExp]]",t),m.set(this,"[[IteratedString]]",e),m.set(this,"[[Global]]",r),m.set(this,"[[Unicode]]",n),m.set(this,"[[Done]]",!1)};s&&(v.prototype=p(s)),c(v.prototype,"next",function(){var t=this;if(!a(t))throw new i("receiver must be an object");if(!(t instanceof v&&m.has(t,"[[IteratingRegExp]]")&&m.has(t,"[[IteratedString]]")&&m.has(t,"[[Global]]")&&m.has(t,"[[Unicode]]")&&m.has(t,"[[Done]]")))throw new i('"this" value must be a RegExpStringIterator instance');if(m.get(t,"[[Done]]"))return l(void 0,!0);var e=m.get(t,"[[IteratingRegExp]]"),r=m.get(t,"[[IteratedString]]"),n=m.get(t,"[[Global]]"),o=m.get(t,"[[Unicode]]"),s=d(e,r);if(null===s)return m.set(t,"[[Done]]",!0),l(void 0,!0);if(n){if(""===g(f(s,"0"))){var c=h(f(e,"lastIndex")),p=u(r,c,o);y(e,"lastIndex",p,!0)}return l(s,!1)}return m.set(t,"[[Done]]",!0),l(s,!1)},!1),o&&(b(v.prototype,"RegExp String Iterator"),Symbol.iterator&&"function"!=typeof v.prototype[Symbol.iterator])&&c(v.prototype,Symbol.iterator,function(){return this},!1),t.exports=function(t,e,r,n){return new v(t,e,r,n)}},355(t,e,r){"use strict";var n=r(7379),o=r(4938),i=r(9271),a=r(1181),s=r(5855);t.exports=function(t,e,r,u){if(!o(t))throw new n("Assertion failed: `homeObject` is not an Object");if(!s(e))throw new n("Assertion failed: `key` is not a Property Key or a Private Name");if("function"!=typeof r)throw new n("Assertion failed: `closure` is not a function");if("boolean"!=typeof u)throw new n("Assertion failed: `enumerable` is not a Boolean");if(!a(t))throw new n("Assertion failed: `homeObject` is not an ordinary, extensible object, with no non-configurable properties");i(t,e,{"[[Value]]":r,"[[Writable]]":!0,"[[Enumerable]]":u,"[[Configurable]]":!0})}},9271(t,e,r){"use strict";var n=r(7379),o=r(4938),i=r(6843),a=r(9999),s=r(5848),u=r(7817),l=r(5855),c=r(925),f=r(6309);t.exports=function(t,e,r){if(!o(t))throw new n("Assertion failed: Type(O) is not Object");if(!l(e))throw new n("Assertion failed: P is not a Property Key");var p=i(r)?r:f(r);if(!i(p))throw new n("Assertion failed: Desc is not a valid Property Descriptor");return a(u,c,s,t,e,p)}},5848(t,e,r){"use strict";var n=r(7379),o=r(6843),i=r(3003);t.exports=function(t){if(void 0!==t&&!o(t))throw new n("Assertion failed: `Desc` must be a Property Descriptor");return i(t)}},7630(t,e,r){"use strict";var n=r(7379),o=r(4922),i=r(5855),a=r(4938);t.exports=function(t,e){if(!a(t))throw new n("Assertion failed: Type(O) is not Object");if(!i(e))throw new n("Assertion failed: P is not a Property Key, got "+o(e));return t[e]}},4111(t,e,r){"use strict";var n=r(7379),o=r(7818),i=r(1816),a=r(5855),s=r(4922);t.exports=function(t,e){if(!a(e))throw new n("Assertion failed: P is not a Property Key");var r=o(t,e);if(null!=r){if(!i(r))throw new n(s(e)+" is not a function: "+s(r));return r}}},7818(t,e,r){"use strict";var n=r(7379),o=r(4922),i=r(5855);t.exports=function(t,e){if(!i(e))throw new n("Assertion failed: P is not a Property Key, got "+o(e));return t[e]}},3443(t,e,r){"use strict";t.exports=r(8622)},1816(t,e,r){"use strict";t.exports=r(2719)},3478(t,e,r){"use strict";var n=r(4334)("%Reflect.construct%",!0),o=r(9271);try{o({},"",{"[[Get]]":function(){}})}catch(t){o=null}if(o&&n){var i={},a={};o(a,"length",{"[[Get]]":function(){throw i},"[[Enumerable]]":!0}),t.exports=function(t){try{n(t,a)}catch(t){return t===i}}}else t.exports=function(t){return"function"==typeof t&&!!t.prototype}},7817(t,e,r){"use strict";var n=r(7379),o=r(9939),i=r(6843);t.exports=function(t){if(void 0===t)return!1;if(!i(t))throw new n("Assertion failed: `Desc` must be a Property Descriptor");return!(!o(t,"[[Value]]")&&!o(t,"[[Writable]]"))}},1181(t,e,r){"use strict";var n=r(703),o=n("%Object.preventExtensions%",!0),i=n("%Object.isExtensible%",!0),a=r(9258);t.exports=o?function(t){return!a(t)&&i(t)}:function(t){return!a(t)}},333(t,e,r){"use strict";var n=r(703)("%Symbol.match%",!0),o=r(4587),i=r(4938),a=r(4801);t.exports=function(t){if(!i(t))return!1;if(n){var e=t[n];if(void 0!==e)return a(e)}return o(t)}},2021(t,e,r){"use strict";var n=r(703)("%Object.create%",!0),o=r(7379),i=r(7388),a=r(4938),s=r(3443),u=r(5713),l=r(6561),c=r(6052)();t.exports=function(t){if(null!==t&&!a(t))throw new o("Assertion failed: `proto` must be null or an object");var e,r=arguments.length<2?[]:arguments[1];if(!s(r))throw new o("Assertion failed: `additionalInternalSlotsList` must be an Array");if(c)e={__proto__:t};else if(n)e=n(t);else{if(null===t)throw new i("native Object.create support is required to create null objects");var f=function(){};f.prototype=t,e=new f}return r.length>0&&u(r,function(t){l.set(e,t,void 0)}),e}},3936(t,e,r){"use strict";var n=r(7379),o=r(4938),i=r(5298)("RegExp.prototype.exec"),a=r(6520),s=r(7630),u=r(1816);t.exports=function(t,e){if(!o(t))throw new n("Assertion failed: `R` must be an Object");if("string"!=typeof e)throw new n("Assertion failed: `S` must be a String");var r=s(t,"exec");if(u(r)){var l=a(r,t,[e]);if(null===l||o(l))return l;throw new n('"exec" method must return `null` or an Object')}return i(t,e)}},925(t,e,r){"use strict";var n=r(5518);t.exports=function(t,e){return t===e?0!==t||1/t==1/e:n(t)&&n(e)}},5234(t,e,r){"use strict";var n=r(7379),o=r(4938),i=r(5855),a=r(925),s=function(){try{return delete[].length,!0}catch(t){return!1}}();t.exports=function(t,e,r,u){if(!o(t))throw new n("Assertion failed: `O` must be an Object");if(!i(e))throw new n("Assertion failed: `P` must be a Property Key");if("boolean"!=typeof u)throw new n("Assertion failed: `Throw` must be a Boolean");if(u){if(t[e]=r,s&&!a(t[e],r))throw new n("Attempted to assign to readonly property.");return!0}try{return t[e]=r,!s||a(t[e],r)}catch(t){return!1}}},518(t,e,r){"use strict";var n=r(703)("%Symbol.species%",!0),o=r(7379),i=r(4938),a=r(3478);t.exports=function(t,e){if(!i(t))throw new o("Assertion failed: Type(O) is not Object");var r=t.constructor;if(void 0===r)return e;if(!i(r))throw new o("O.constructor is not an Object");var s=n?r[n]:void 0;if(null==s)return e;if(a(s))return s;throw new o("no constructor found")}},9893(t,e,r){"use strict";var n=r(703),o=n("%RegExp%"),i=r(7379),a=n("%parseInt%"),s=r(5298),u=r(5537),l=s("String.prototype.slice"),c=u(/^0b[01]+$/i),f=u(/^0o[0-7]+$/i),p=u(/^[-+]0x[0-9a-f]+$/i),d=u(new o("["+["…","​","￾"].join("")+"]","g")),y=r(7724);t.exports=function t(e){if("string"!=typeof e)throw new i("Assertion failed: `argument` is not a String");if(c(e))return+a(l(e,2),2);if(f(e))return+a(l(e,2),8);if(d(e)||p(e))return NaN;var r=y(e);return r!==e?t(r):+e}},4801(t){"use strict";t.exports=function(t){return!!t}},7210(t,e,r){"use strict";var n=r(3312),o=r(6354),i=r(5518),a=r(1084);t.exports=function(t){var e=n(t);return i(e)||0===e?0:a(e)?o(e):e}},6117(t,e,r){"use strict";var n=r(6241),o=r(7210);t.exports=function(t){var e=o(t);return e<=0?0:e>n?n:e}},3312(t,e,r){"use strict";var n=r(703),o=r(7379),i=n("%Number%"),a=r(9258),s=r(3760),u=r(9893);t.exports=function(t){var e=a(t)?t:s(t,i);if("symbol"==typeof e)throw new o("Cannot convert a Symbol value to a number");if("bigint"==typeof e)throw new o("Conversion from 'BigInt' to 'number' is not allowed.");return"string"==typeof e?u(e):+e}},3760(t,e,r){"use strict";var n=r(2632);t.exports=function(t){return arguments.length>1?n(t,arguments[1]):n(t)}},6309(t,e,r){"use strict";var n=r(9939),o=r(7379),i=r(4938),a=r(1816),s=r(4801);t.exports=function(t){if(!i(t))throw new o("ToPropertyDescriptor requires an object");var e={};if(n(t,"enumerable")&&(e["[[Enumerable]]"]=s(t.enumerable)),n(t,"configurable")&&(e["[[Configurable]]"]=s(t.configurable)),n(t,"value")&&(e["[[Value]]"]=t.value),n(t,"writable")&&(e["[[Writable]]"]=s(t.writable)),n(t,"get")){var r=t.get;if(void 0!==r&&!a(r))throw new o("getter must be a function");e["[[Get]]"]=r}if(n(t,"set")){var u=t.set;if(void 0!==u&&!a(u))throw new o("setter must be a function");e["[[Set]]"]=u}if((n(e,"[[Get]]")||n(e,"[[Set]]"))&&(n(e,"[[Value]]")||n(e,"[[Writable]]")))throw new o("Invalid property descriptor. Cannot both specify accessors and a value or writable attribute");return e}},1076(t,e,r){"use strict";var n=r(703)("%String%"),o=r(7379);t.exports=function(t){if("symbol"==typeof t)throw new o("Cannot convert a Symbol value to a string");return n(t)}},192(t,e,r){"use strict";var n=r(3225);t.exports=function(t){return"symbol"==typeof t?"Symbol":"bigint"==typeof t?"BigInt":n(t)}},1300(t,e,r){"use strict";var n=r(703),o=r(7379),i=n("%String.fromCharCode%"),a=r(3283),s=r(8537);t.exports=function(t,e){if(!a(t)||!s(e))throw new o("Assertion failed: `lead` must be a leading surrogate char code, and `trail` must be a trailing surrogate char code");return i(t)+i(e)}},7060(t,e,r){"use strict";var n=r(2153);t.exports=function(t){return"bigint"==typeof t?t:n(t)}},6354(t,e,r){"use strict";var n=r(7060),o=r(7379);t.exports=function(t){if("number"!=typeof t&&"bigint"!=typeof t)throw new o("argument must be a Number or a BigInt");var e=t<0?-n(-t):n(t);return 0===e?0:e}},2501(t,e,r){"use strict";var n=r(703)("%String%"),o=r(7379);t.exports=function(t){if("symbol"==typeof t)throw new o("Cannot convert a Symbol value to a string");return n(t)}},3225(t,e,r){"use strict";var n=r(4938);t.exports=function(t){return null===t?"Null":void 0===t?"Undefined":n(t)?"Object":"number"==typeof t?"Number":"boolean"==typeof t?"Boolean":"string"==typeof t?"String":void 0}},4334(t,e,r){"use strict";t.exports=r(703)},9999(t,e,r){"use strict";var n=r(708),o=r(9173),i=n.hasArrayLengthDefineBug(),a=i&&r(8622),s=r(5298)("Object.prototype.propertyIsEnumerable");t.exports=function(t,e,r,n,u,l){if(!o){if(!t(l))return!1;if(!l["[[Configurable]]"]||!l["[[Writable]]"])return!1;if(u in n&&s(n,u)!==!!l["[[Enumerable]]"])return!1;var c=l["[[Value]]"];return n[u]=c,e(n[u],c)}return i&&"length"===u&&"[[Value]]"in l&&a(n)&&n.length!==l["[[Value]]"]?(n.length=l["[[Value]]"],n.length===l["[[Value]]"]):(o(n,u,r(l)),!0)}},8622(t,e,r){"use strict";var n=r(703)("%Array%"),o=!n.isArray&&r(5298)("Object.prototype.toString");t.exports=n.isArray||function(t){return"[object Array]"===o(t)}},5713(t){"use strict";t.exports=function(t,e){for(var r=0;r=55296&&t<=56319}},9258(t){"use strict";t.exports=function(t){return null===t||"function"!=typeof t&&"object"!=typeof t}},5855(t){"use strict";t.exports=function(t){return"string"==typeof t||"symbol"==typeof t}},8537(t){"use strict";t.exports=function(t){return"number"==typeof t&&t>=56320&&t<=57343}},6843(t,e,r){"use strict";var n=r(7379),o=r(9939),i={__proto__:null,"[[Configurable]]":!0,"[[Enumerable]]":!0,"[[Get]]":!0,"[[Set]]":!0,"[[Value]]":!0,"[[Writable]]":!0};t.exports=function(t){if(!t||"object"!=typeof t)return!1;for(var e in t)if(o(t,e)&&!i[e])return!1;var r=o(t,"[[Value]]")||o(t,"[[Writable]]"),a=o(t,"[[Get]]")||o(t,"[[Set]]");if(r&&a)throw new n("Property Descriptors may not be both accessor and data descriptors");return!0}}},e={};function r(n){var o=e[n];if(void 0!==o)return o.exports;var i=e[n]={exports:{}};return t[n](i,i.exports,r),i.exports}r.n=t=>{var e=t&&t.__esModule?()=>t.default:()=>t;return r.d(e,{a:e}),e},r.d=(t,e)=>{for(var n in e)r.o(e,n)&&!r.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:e[n]})},r.o=(t,e)=>Object.prototype.hasOwnProperty.call(t,e),(()=>{"use strict";var t=r(3618);function e(e,r,n){let o=0;const i=[];for(;-1!==o;)o=e.indexOf(r,o),-1!==o&&(i.push({start:o,end:o+r.length,errors:0}),o+=1);return i.length>0?i:(0,t.A)(e,r,n)}function n(t,r){return 0===r.length||0===t.length?0:1-e(t,r,r.length)[0].errors/r.length}function o(t){const e=document.createElement("div");return e.appendChild(t.cloneContents()),function(t){var e;for(const e of Array.from(t.querySelectorAll("br")))e.replaceWith(document.createTextNode(" "));return null!==(e=t.textContent)&&void 0!==e?e:""}(e)}function i(t,e){let r=0;for(const n of t){if(!(n{if(i=e===a.Forwards?r.nextNode():r.previousNode(),i){const t=i.textContent,r=e===a.Forwards?0:t.length;u=s(t,r,e)}};for(;i&&-1===u&&i!==o;)l();if(i&&u>=0)return{node:i,offset:u};throw new RangeError("No text nodes with non-whitespace text found in range")}function l(t,e){(null==e||e>t.length)&&(e=t.length);for(var r=0,n=Array(e);r1?e-1:0),n=1;no?(a.push({node:s,offset:o-l}),o=r.shift()):(u=i.nextNode(),l+=s.data.length);for(;void 0!==o&&s&&l===o;)a.push({node:s,offset:s.data.length}),o=r.shift();if(void 0!==o)throw new RangeError("Offset exceeds text length");return a}let d=function(t){return t[t.FORWARDS=1]="FORWARDS",t[t.BACKWARDS=2]="BACKWARDS",t}({});class y{constructor(t,e){if(e<0)throw new Error("Offset is invalid");this.element=t,this.offset=e}relativeTo(t){if(!t.contains(this.element))throw new Error("Parent is not an ancestor of current element");let e=this.element,r=this.offset;for(;e!==t;)r+=f(e),e=e.parentElement;return new y(e,r)}resolve(){let t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};try{return p(this.element,this.offset)[0]}catch(e){if(0===this.offset&&void 0!==t.direction){const r=document.createTreeWalker(this.element.getRootNode(),NodeFilter.SHOW_TEXT);r.currentNode=this.element;const n=t.direction===d.FORWARDS,o=n?r.nextNode():r.previousNode();if(!o)throw e;return{node:o,offset:n?0:o.data.length}}throw e}}static fromCharOffset(t,e){switch(t.nodeType){case Node.TEXT_NODE:return y.fromPoint(t,e);case Node.ELEMENT_NODE:return new y(t,e);default:throw new Error("Node is not an element or text node")}}static fromPoint(t,e){switch(t.nodeType){case Node.TEXT_NODE:{if(e<0||e>t.data.length)throw new Error("Text node offset is out of range");if(!t.parentElement)throw new Error("Text node has no parent");const r=f(t)+e;return new y(t.parentElement,r)}case Node.ELEMENT_NODE:{if(e<0||e>t.childNodes.length)throw new Error("Child node offset is out of range");let r=0;for(let n=0;n=0&&(e.setStart(t.startContainer,o.start),r=!0),o.end>0&&(e.setEnd(t.endContainer,o.end),n=!0),r&&n)return e;if(!r){const t=u(e,a.Forwards),r=t.node,n=t.offset;r&&n>=0&&e.setStart(r,n)}if(!n){const t=u(e,a.Backwards),r=t.node,n=t.offset;r&&n>0&&e.setEnd(r,n)}return e}(h.fromRange(t).toRange())}}class g{constructor(t,e,r){this.root=t,this.start=e,this.end=r}static fromRange(t,e){const r=h.fromRange(e).relativeTo(t);return new g(t,r.start.offset,r.end.offset)}static fromSelector(t,e){return new g(t,e.start,e.end)}toSelector(){return{type:"TextPositionSelector",start:this.start,end:this.end}}toRange(){return h.fromOffsets(this.root,this.start,this.end).toRange()}}class m{constructor(t,e){let r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};this.root=t,this.exact=e,this.context=r}static fromRange(t,e){var r;const n=null!==(r=t.textContent)&&void 0!==r?r:"",i=h.fromRange(e).relativeTo(t),a=i.start.offset,s=i.end.offset,u=o(e),l=o(h.fromOffsets(t,Math.max(0,a-32),a).toRange()),c=o(h.fromOffsets(t,s,Math.min(n.length,s+32)).toRange());return new m(t,u,{prefix:l,suffix:c})}static fromSelector(t,e){const r=e.prefix,n=e.suffix;return new m(t,e.exact,{prefix:r,suffix:n})}toSelector(){return{type:"TextQuoteSelector",exact:this.exact,prefix:this.context.prefix,suffix:this.context.suffix}}toRange(){let t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return this.toPositionAnchor(t).toRange()}toPositionAnchor(){let t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};const r=function(t){let e="";const r=[],n=t=>{var o;if(t.nodeType!==Node.TEXT_NODE){if(t.nodeType===Node.ELEMENT_NODE){if("BR"===t.tagName)return r.push(e.length),void(e+=" ");for(const e of Array.from(t.childNodes))n(e)}}else e+=null!==(o=t.textContent)&&void 0!==o?o:""};return n(t),{text:e,brPositionsInText:r}}(this.root),o=r.text,a=r.brPositionsInText,s=function(t,r){let o=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};if(0===r.length)return null;const i=Math.min(256,r.length/2),a=e(t,r,i);if(0===a.length)return null;const s=e=>{const i=1-e.errors/r.length,a=o.prefix?n(t.slice(Math.max(0,e.start-o.prefix.length),e.start),o.prefix):1,s=o.suffix?n(t.slice(e.end,e.end+o.suffix.length),o.suffix):1;let u=1;return"number"==typeof o.hint&&(u=1-Math.abs(e.start-o.hint)/t.length),(50*i+20*a+20*s+2*u)/92},u=a.map(t=>({start:t.start,end:t.end,score:s(t)}));return u.sort((t,e)=>e.score-t.score),u[0]}(o,this.exact,{...this.context,hint:t.hint});if(!s)throw new Error("Quote not found");return new g(this.root,i(a,s.start),i(a,s.end))}}var b,v=r(6410);function w(){if(!readium.link)return null;const t=readium.link.href;if(!t)return null;const e=function(){const t=window.getSelection();if(!t)return;if(t.isCollapsed)return;const e=t.toString();if(0===e.trim().replace(/\n/g," ").replace(/\s\s+/g," ").length)return;if(!t.anchorNode||!t.focusNode)return;const r=1===t.rangeCount?t.getRangeAt(0):function(t,e,r,n){const o=new Range;if(o.setStart(t,e),o.setEnd(r,n),!o.collapsed)return o;x(">>> createOrderedRange COLLAPSED ... RANGE REVERSE?");const i=new Range;if(i.setStart(r,n),i.setEnd(t,e),!i.collapsed)return x(">>> createOrderedRange RANGE REVERSE OK."),o;x(">>> createOrderedRange RANGE REVERSE ALSO COLLAPSED?!")}(t.anchorNode,t.anchorOffset,t.focusNode,t.focusOffset);if(!r||r.collapsed)return void x("$$$$$$$$$$$$$$$$$ CANNOT GET NON-COLLAPSED SELECTION RANGE?!");const n=document.body.textContent,o=h.fromRange(r).relativeTo(document.body),i=o.start.offset,a=o.end.offset;let s=n.slice(Math.max(0,i-200),i),u=s.search(/\P{L}\p{L}/gu);-1!==u&&(s=s.slice(u+1));let l=n.slice(a,Math.min(n.length,a+200)),c=Array.from(l.matchAll(/\p{L}\P{L}/gu)).pop();return void 0!==c&&c.index>1&&(l=l.slice(0,c.index+1)),{highlight:e,before:s,after:l}}();return e?{href:t,text:e,rect:function(){try{let t=window.getSelection();if(!t)return;return D(t.getRangeAt(0).getBoundingClientRect())}catch(t){return L(t),null}}()}:null}function x(){_.apply(null,arguments)}r.n(v)().shim(),window.addEventListener("error",function(t){webkit.messageHandlers.logError.postMessage({message:t.message,filename:t.filename,line:t.lineno})},!1),window.addEventListener("load",function(){var t;new ResizeObserver(()=>{t&&window.cancelAnimationFrame(t),t=window.requestAnimationFrame(function(){O=window.innerWidth,function(){const t="readium-virtual-page";var e=document.getElementById(t);if(R()||2!=parseInt(window.getComputedStyle(document.documentElement).getPropertyValue("column-count"))){var r;null===(r=e)||void 0===r||r.remove()}else{var n=document.scrollingElement.scrollWidth/window.innerWidth;Math.round(2*n)/2%1>.1&&(e?e.remove():((e=document.createElement("div")).setAttribute("id",t),e.style.breakBefore="column",e.innerHTML="​",document.body.appendChild(e)))}}(),function(){if(!R()){var t=I(window.scrollX+1);document.scrollingElement.scrollLeft=t}}(),j()})}).observe(document.body)},!1);var S,E,A=!1,O=0;function j(){if(readium.isFixedLayout)return;let t=document.scrollingElement;if(R()&&!P()){const e=window.scrollY,r=window.innerHeight,n=t.scrollHeight;b={first:e/n,last:(e+r)/n}}else{let e=window.scrollX;const r=window.innerWidth,n=t.scrollWidth;T()&&(e=Math.abs(e)),b={first:e/n,last:(e+r)/n}}0!==t.scrollWidth&&0!==t.scrollHeight&&(A||window.requestAnimationFrame(function(){var t;t=b,webkit.messageHandlers.progressionChanged.postMessage(t),A=!1}),A=!0)}function R(){return"readium-scroll-on"==document.documentElement.style.getPropertyValue("--USER__view").trim()}function P(){return window.getComputedStyle(document.documentElement).getPropertyValue("writing-mode").startsWith("vertical")}function T(){const t=window.getComputedStyle(document.documentElement);return"rtl"==t.getPropertyValue("direction")||"vertical-rl"==t.getPropertyValue("writing-mode")}function C(t,e){return R()?M({top:t.top+window.scrollY,animated:e}):M({left:I(t.left+window.scrollX),animated:e}),!0}function N(t,e){var r=window.scrollX,n=window.innerWidth,o=Math.abs(r-t)/n>.01;return o&&M({left:t,animated:e}),o}function M(){let t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},e=t.left,r=t.top,n=t.animated;document.scrollingElement.scrollTo({left:e,top:r,behavior:n?"smooth":"instant"})}function I(t){const e=t+(T()?-1:1);return e-e%O}function k(t){try{let n=t.locations,o=t.text;var e;if(o&&o.highlight)return n&&n.cssSelector&&(e=document.querySelector(n.cssSelector)),e||(e=document.body),new m(e,o.highlight,{prefix:o.before,suffix:o.after}).toRange();if(n){var r=null;if(!r&&n.cssSelector&&(r=document.querySelector(n.cssSelector)),!r&&n.fragments)for(const t of n.fragments)if(r=document.getElementById(t))break;if(r){let t=document.createRange();return t.setStartBefore(r),t.setEndAfter(r),t}}}catch(t){L(t)}return null}function $(t,e){null===e?F(t):document.documentElement.style.setProperty(t,e,"important")}function F(t){document.documentElement.style.removeProperty(t)}function _(){var t=Array.prototype.slice.call(arguments).join(" ");webkit.messageHandlers.log.postMessage(t)}function B(t){L(new Error(t))}function L(t){webkit.messageHandlers.logError.postMessage({message:t.message})}function D(t){let e=W({x:t.left,y:t.top});const r=t.width,n=t.height,o=e.x,i=e.y;return{width:r,height:n,left:o,top:i,right:o+r,bottom:i+n}}function W(t){if(!frameElement)return t;let e=frameElement.getBoundingClientRect();if(!e)return t;let r=window.top.document.documentElement;return{x:t.x+e.x+r.scrollLeft,y:t.y+e.y+r.scrollTop}}function U(t,e){let r=t.getClientRects();const n=[];for(const t of r)n.push({bottom:t.bottom,height:t.height,left:t.left,right:t.right,top:t.top,width:t.width});const o=q(function(t){const e=new Set(t);for(const r of t)if(r.width>1&&r.height>1){for(const n of t)if(r!==n&&e.has(n)&&H(n,r,1)){J(),e.delete(r);break}}else J(),e.delete(r);return Array.from(e)}(z(n,1,e)));for(let t=o.length-1;t>=0;t--){const e=o[t];if(!(e.width*e.height>4)){if(!(o.length>1)){J();break}J(),o.splice(t,1)}}return J((n.length,o.length)),o}function z(t,e,r){for(let n=0;nt!==i&&t!==a),o=V(i,a);return n.push(o),z(n,e,r)}}return t}function V(t,e){const r=Math.min(t.left,e.left),n=Math.max(t.right,e.right),o=Math.min(t.top,e.top),i=Math.max(t.bottom,e.bottom);return{bottom:i,height:i-o,left:r,right:n,top:o,width:n-r}}function H(t,e,r){return G(t,e.left,e.top,r)&&G(t,e.right,e.top,r)&&G(t,e.left,e.bottom,r)&&G(t,e.right,e.bottom,r)}function G(t,e,r,n){return(t.lefte||Y(t.right,e,n))&&(t.topr||Y(t.bottom,r,n))}function q(t){for(let e=0;et!==e);return Array.prototype.push.apply(a,r),q(a)}}else J()}return t}function X(t,e){const r=function(t,e){const r=Math.max(t.left,e.left),n=Math.min(t.right,e.right),o=Math.max(t.top,e.top),i=Math.min(t.bottom,e.bottom);return{bottom:i,height:Math.max(0,i-o),left:r,right:n,top:o,width:Math.max(0,n-r)}}(e,t);if(0===r.height||0===r.width)return[t];const n=[];{const e={bottom:t.bottom,height:0,left:t.left,right:r.left,top:t.top,width:0};e.width=e.right-e.left,e.height=e.bottom-e.top,0!==e.height&&0!==e.width&&n.push(e)}{const e={bottom:r.top,height:0,left:r.left,right:r.right,top:t.top,width:0};e.width=e.right-e.left,e.height=e.bottom-e.top,0!==e.height&&0!==e.width&&n.push(e)}{const e={bottom:t.bottom,height:0,left:r.left,right:r.right,top:r.bottom,width:0};e.width=e.right-e.left,e.height=e.bottom-e.top,0!==e.height&&0!==e.width&&n.push(e)}{const e={bottom:t.bottom,height:0,left:r.right,right:t.right,top:t.top,width:0};e.width=e.right-e.left,e.height=e.bottom-e.top,0!==e.height&&0!==e.width&&n.push(e)}return n}function K(t,e,r){return(t.left=0&&Y(t.left,e.right,r))&&(e.left=0&&Y(e.left,t.right,r))&&(t.top=0&&Y(t.top,e.bottom,r))&&(e.top=0&&Y(e.top,t.bottom,r))}function Y(t,e,r){return Math.abs(t-e)<=r}function J(){}window.addEventListener("scroll",j),document.addEventListener("selectionchange",(S=function(){webkit.messageHandlers.selectionChanged.postMessage(w())},function(){var t=this,e=arguments;clearTimeout(E),E=setTimeout(function(){S.apply(t,e),E=null},50)}));var Q,Z=[],tt=function(){return Z.some(function(t){return t.activeTargets.length>0})},et="ResizeObserver loop completed with undelivered notifications.";!function(t){t.BORDER_BOX="border-box",t.CONTENT_BOX="content-box",t.DEVICE_PIXEL_CONTENT_BOX="device-pixel-content-box"}(Q||(Q={}));var rt,nt=function(t){return Object.freeze(t)},ot=function(t,e){this.inlineSize=t,this.blockSize=e,nt(this)},it=function(){function t(t,e,r,n){return this.x=t,this.y=e,this.width=r,this.height=n,this.top=this.y,this.left=this.x,this.bottom=this.top+this.height,this.right=this.left+this.width,nt(this)}return t.prototype.toJSON=function(){var t=this;return{x:t.x,y:t.y,top:t.top,right:t.right,bottom:t.bottom,left:t.left,width:t.width,height:t.height}},t.fromRect=function(e){return new t(e.x,e.y,e.width,e.height)},t}(),at=function(t){return t instanceof SVGElement&&"getBBox"in t},st=function(t){if(at(t)){var e=t.getBBox(),r=e.width,n=e.height;return!r&&!n}var o=t,i=o.offsetWidth,a=o.offsetHeight;return!(i||a||t.getClientRects().length)},ut=function(t){var e;if(t instanceof Element)return!0;var r=null===(e=null==t?void 0:t.ownerDocument)||void 0===e?void 0:e.defaultView;return!!(r&&t instanceof r.Element)},lt="undefined"!=typeof window?window:{},ct=new WeakMap,ft=/auto|scroll/,pt=/^tb|vertical/,dt=/msie|trident/i.test(lt.navigator&<.navigator.userAgent),yt=function(t){return parseFloat(t||"0")},ht=function(t,e,r){return void 0===t&&(t=0),void 0===e&&(e=0),void 0===r&&(r=!1),new ot((r?e:t)||0,(r?t:e)||0)},gt=nt({devicePixelContentBoxSize:ht(),borderBoxSize:ht(),contentBoxSize:ht(),contentRect:new it(0,0,0,0)}),mt=function(t,e){if(void 0===e&&(e=!1),ct.has(t)&&!e)return ct.get(t);if(st(t))return ct.set(t,gt),gt;var r=getComputedStyle(t),n=at(t)&&t.ownerSVGElement&&t.getBBox(),o=!dt&&"border-box"===r.boxSizing,i=pt.test(r.writingMode||""),a=!n&&ft.test(r.overflowY||""),s=!n&&ft.test(r.overflowX||""),u=n?0:yt(r.paddingTop),l=n?0:yt(r.paddingRight),c=n?0:yt(r.paddingBottom),f=n?0:yt(r.paddingLeft),p=n?0:yt(r.borderTopWidth),d=n?0:yt(r.borderRightWidth),y=n?0:yt(r.borderBottomWidth),h=f+l,g=u+c,m=(n?0:yt(r.borderLeftWidth))+d,b=p+y,v=s?t.offsetHeight-b-t.clientHeight:0,w=a?t.offsetWidth-m-t.clientWidth:0,x=o?h+m:0,S=o?g+b:0,E=n?n.width:yt(r.width)-x-w,A=n?n.height:yt(r.height)-S-v,O=E+h+w+m,j=A+g+v+b,R=nt({devicePixelContentBoxSize:ht(Math.round(E*devicePixelRatio),Math.round(A*devicePixelRatio),i),borderBoxSize:ht(O,j,i),contentBoxSize:ht(E,A,i),contentRect:new it(f,u,E,A)});return ct.set(t,R),R},bt=function(t,e,r){var n=mt(t,r),o=n.borderBoxSize,i=n.contentBoxSize,a=n.devicePixelContentBoxSize;switch(e){case Q.DEVICE_PIXEL_CONTENT_BOX:return a;case Q.BORDER_BOX:return o;default:return i}},vt=function(t){var e=mt(t);this.target=t,this.contentRect=e.contentRect,this.borderBoxSize=nt([e.borderBoxSize]),this.contentBoxSize=nt([e.contentBoxSize]),this.devicePixelContentBoxSize=nt([e.devicePixelContentBoxSize])},wt=function(t){if(st(t))return 1/0;for(var e=0,r=t.parentNode;r;)e+=1,r=r.parentNode;return e},xt=function(){var t=1/0,e=[];Z.forEach(function(r){if(0!==r.activeTargets.length){var n=[];r.activeTargets.forEach(function(e){var r=new vt(e.target),o=wt(e.target);n.push(r),e.lastReportedSize=bt(e.target,e.observedBox),ot?e.activeTargets.push(r):e.skippedTargets.push(r))})})},Et=[],At=0,Ot={attributes:!0,characterData:!0,childList:!0,subtree:!0},jt=["resize","load","transitionend","animationend","animationstart","animationiteration","keyup","keydown","mouseup","mousedown","mouseover","mouseout","blur","focus"],Rt=function(t){return void 0===t&&(t=0),Date.now()+t},Pt=!1,Tt=function(){function t(){var t=this;this.stopped=!0,this.listener=function(){return t.schedule()}}return t.prototype.run=function(t){var e=this;if(void 0===t&&(t=250),!Pt){Pt=!0;var r,n=Rt(t);r=function(){var r=!1;try{r=function(){var t,e=0;for(St(e);tt();)e=xt(),St(e);return Z.some(function(t){return t.skippedTargets.length>0})&&("function"==typeof ErrorEvent?t=new ErrorEvent("error",{message:et}):((t=document.createEvent("Event")).initEvent("error",!1,!1),t.message=et),window.dispatchEvent(t)),e>0}()}finally{if(Pt=!1,t=n-Rt(),!At)return;r?e.run(1e3):t>0?e.run(t):e.start()}},function(t){if(!rt){var e=0,r=document.createTextNode("");new MutationObserver(function(){return Et.splice(0).forEach(function(t){return t()})}).observe(r,{characterData:!0}),rt=function(){r.textContent="".concat(e?e--:e++)}}Et.push(t),rt()}(function(){requestAnimationFrame(r)})}},t.prototype.schedule=function(){this.stop(),this.run()},t.prototype.observe=function(){var t=this,e=function(){return t.observer&&t.observer.observe(document.body,Ot)};document.body?e():lt.addEventListener("DOMContentLoaded",e)},t.prototype.start=function(){var t=this;this.stopped&&(this.stopped=!1,this.observer=new MutationObserver(this.listener),this.observe(),jt.forEach(function(e){return lt.addEventListener(e,t.listener,!0)}))},t.prototype.stop=function(){var t=this;this.stopped||(this.observer&&this.observer.disconnect(),jt.forEach(function(e){return lt.removeEventListener(e,t.listener,!0)}),this.stopped=!0)},t}(),Ct=new Tt,Nt=function(t){!At&&t>0&&Ct.start(),!(At+=t)&&Ct.stop()},Mt=function(){function t(t,e){this.target=t,this.observedBox=e||Q.CONTENT_BOX,this.lastReportedSize={inlineSize:0,blockSize:0}}return t.prototype.isActive=function(){var t,e=bt(this.target,this.observedBox,!0);return t=this.target,at(t)||function(t){switch(t.tagName){case"INPUT":if("image"!==t.type)break;case"VIDEO":case"AUDIO":case"EMBED":case"OBJECT":case"CANVAS":case"IFRAME":case"IMG":return!0}return!1}(t)||"inline"!==getComputedStyle(t).display||(this.lastReportedSize=e),this.lastReportedSize.inlineSize!==e.inlineSize||this.lastReportedSize.blockSize!==e.blockSize},t}(),It=function(t,e){this.activeTargets=[],this.skippedTargets=[],this.observationTargets=[],this.observer=t,this.callback=e},kt=new WeakMap,$t=function(t,e){for(var r=0;r=0&&(o&&Z.splice(Z.indexOf(r),1),r.observationTargets.splice(n,1),Nt(-1))},t.disconnect=function(t){var e=this,r=kt.get(t);r.observationTargets.slice().forEach(function(r){return e.unobserve(t,r.target)}),r.activeTargets.splice(0,r.activeTargets.length)},t}(),_t=function(){function t(t){if(0===arguments.length)throw new TypeError("Failed to construct 'ResizeObserver': 1 argument required, but only 0 present.");if("function"!=typeof t)throw new TypeError("Failed to construct 'ResizeObserver': The callback provided as parameter 1 is not a function.");Ft.connect(this,t)}return t.prototype.observe=function(t,e){if(0===arguments.length)throw new TypeError("Failed to execute 'observe' on 'ResizeObserver': 1 argument required, but only 0 present.");if(!ut(t))throw new TypeError("Failed to execute 'observe' on 'ResizeObserver': parameter 1 is not of type 'Element");Ft.observe(this,t,e)},t.prototype.unobserve=function(t){if(0===arguments.length)throw new TypeError("Failed to execute 'unobserve' on 'ResizeObserver': 1 argument required, but only 0 present.");if(!ut(t))throw new TypeError("Failed to execute 'unobserve' on 'ResizeObserver': parameter 1 is not of type 'Element");Ft.unobserve(this,t)},t.prototype.disconnect=function(){Ft.disconnect(this)},t.toString=function(){return"function ResizeObserver () { [polyfill code] }"},t}();function Bt(t,e){return function(t){if(Array.isArray(t))return t}(t)||function(t,e){var r=null==t?null:"undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(null!=r){var n,o,i,a,s=[],u=!0,l=!1;try{if(i=(r=r.call(t)).next,0===e){if(Object(r)!==r)return;u=!1}else for(;!(u=(n=i.call(r)).done)&&(s.push(n.value),s.length!==e);u=!0);}catch(t){l=!0,o=t}finally{try{if(!u&&null!=r.return&&(a=r.return(),Object(a)!==a))return}finally{if(l)throw o}}return s}}(t,e)||function(t,e){if(t){if("string"==typeof t)return Lt(t,e);var r={}.toString.call(t).slice(8,-1);return"Object"===r&&t.constructor&&(r=t.constructor.name),"Map"===r||"Set"===r?Array.from(t):"Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?Lt(t,e):void 0}}(t,e)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function Lt(t,e){(null==e||e>t.length)&&(e=t.length);for(var r=0,n=Array(e);r{e.width===t.clientWidth&&e.height===t.clientHeight||(e={width:t.clientWidth,height:t.clientHeight},Ut.forEach(function(t){t.requestLayout()}))}).observe(t)},!1);const Gt={id:"id",class:"class",tag:"tag",attribute:"attribute",nthchild:"nthchild",nthoftype:"nthoftype"};function qt(t="unknown problem",...e){console.warn(`CssSelectorGenerator: ${t}`,...e)}const Xt={selectors:[Gt.id,Gt.class,Gt.tag,Gt.attribute],includeTag:!1,whitelist:[],blacklist:[],combineWithinSelector:!0,combineBetweenSelectors:!0,root:null,maxCombinations:Number.POSITIVE_INFINITY,maxCandidates:Number.POSITIVE_INFINITY,useScope:!1,ignoreGeneratedClassNames:!1};function Kt(t){return!!t}function Yt(t){return t instanceof RegExp}function Jt(t){return["string","function"].includes(typeof t)||Yt(t)}function Qt(t){return Array.isArray(t)?t.filter(Jt):[]}function Zt(t){const e=[Node.DOCUMENT_NODE,Node.DOCUMENT_FRAGMENT_NODE,Node.ELEMENT_NODE];return function(t){return null!=t&&"object"==typeof t&&"nodeType"in t&&"number"==typeof t.nodeType}(t)&&e.includes(t.nodeType)}function te(t,e){if(Zt(t))return t.contains(e)||qt("element root mismatch","Provided root does not contain the element. This will most likely result in producing a fallback selector using element's real root node. If you plan to use the selector using provided root (e.g. `root.querySelector`), it will not work as intended."),t;const r=e.getRootNode({composed:!1});return Zt(r)?(r!==document&&qt("shadow root inferred","You did not provide a root and the element is a child of Shadow DOM. This will produce a selector using ShadowRoot as a root. If you plan to use the selector using document as a root (e.g. `document.querySelector`), it will not work as intended."),r):se(e)}function ee(t){return"number"==typeof t?t:Number.POSITIVE_INFINITY}function re(t=[]){const[e=[],...r]=t;return 0===r.length?e:r.reduce((t,e)=>t.filter(t=>e.includes(t)),e)}function ne(t){const e=t.map(t=>{if(Yt(t))return e=>t.test(e);if("function"==typeof t)return e=>{const r=t(e);return"boolean"!=typeof r?(qt("pattern matcher function invalid","Provided pattern matching function does not return boolean. It's result will be ignored.",t),!1):r};if("string"==typeof t){const e=new RegExp("^"+t.replace(/[|\\{}()[\]^$+?.]/g,"\\$&").replace(/\*/g,".+")+"$");return t=>e.test(t)}return qt("pattern matcher invalid","Pattern matching only accepts strings, regular expressions and/or functions. This item is invalid and will be ignored.",t),()=>!1});return t=>e.some(e=>e(t))}function oe(t,e,r){const n=Array.from(te(r,t[0]).querySelectorAll(e));return n.length===t.length&&t.every(t=>n.includes(t))}function ie(t,e){e=null!=e?e:se(t);const r=[];let n=t;for(;n&&n!==e;)Ht(n)&&r.push(n),n=n.parentNode;return r}function ae(t,e){return re(t.map(t=>ie(t,e)))}function se(t){return t.ownerDocument.querySelector(":root")}const ue=new RegExp(["^$","\\s"].join("|")),le=new RegExp(["^$"].join("|")),ce=[Gt.nthoftype,Gt.tag,Gt.id,Gt.class,Gt.attribute,Gt.nthchild],fe=ne(["class","id","ng-*"]);function pe({name:t}){return`[${t}]`}function de({name:t,value:e}){return`[${t}='${e}']`}function ye({nodeName:t,nodeValue:e}){return{name:Te(t),value:Te(null!=e?e:void 0)}}function he(t,e){const r=Array.from(t.attributes).filter(e=>function({nodeName:t,nodeValue:e},r){const n=r.tagName.toLowerCase();return!(["input","option"].includes(n)&&"value"===t||"src"===t&&(null==e?void 0:e.startsWith("data:"))||fe(t))}(e,t)).map(ye);return[...r.map(pe),...r.map(de)]}const ge=/^[a-z_-]{3,}$/i,me=/[bcdfghjklmnpqrstvwxyz]{4,}/i;function be(t,e){var r;const n=(null!==(r=t.getAttribute("class"))&&void 0!==r?r:"").trim().split(/\s+/).filter(t=>!le.test(t));let o=n;if(null==e?void 0:e.ignoreGeneratedClassNames){const t=ne(e.whitelist);o=n.filter(e=>{const r=`.${Te(e)}`;return!!t(r)||function(t){if(!ge.test(t))return!1;if(t.includes("_")&&!t.includes("__"))return!1;if(/^(css|sc|jsx|emotion|makeStyles|MuiButton|MuiBox)-/i.test(t))return!1;const e=t.split(/--|__|[-]|(?<=[a-z])(?=[A-Z])/).filter(t=>t.length>0);if(0===e.length)return!1;if(1===e.length&&e[0].length<4)return!1;for(const t of e){if(t.length<=2)return!1;if(me.test(t))return!1}return!0}(e)})}return o.map(t=>`.${Te(t)}`)}function ve(t,e){var r;const n=null!==(r=t.getAttribute("id"))&&void 0!==r?r:"",o=`#${Te(n)}`,i=t.getRootNode({composed:!1});return!ue.test(n)&&oe([t],o,i)?[o]:[]}function we(t,e){const r=t.parentNode,n=r&&"children"in r?r.children:null;if(n)for(let e=0;exe(t)),[].concat(...n)))];var n;return 0===r.length||r.length>1?[]:[r[0]]}function Ee(t,e){const r=Se([t])[0],n=t.parentNode,o=n&&"children"in n?n:null;if(o){const e=Array.from(o.children).filter(t=>t.tagName.toLowerCase()===r),n=e.indexOf(t);if(n>-1)return[`${r}:nth-of-type(${String(n+1)})`]}return[]}function*Ae(t=[],{maxResults:e=Number.POSITIVE_INFINITY}={}){let r=0,n=je(1);for(;n.length<=t.length&&rt[e]);yield e,n=Oe(n,t.length-1)}}function Oe(t=[],e=0){const r=t.length;if(0===r)return[];const n=[...t];n[r-1]+=1;for(let t=r-1;t>=0;t--)if(n[t]>e){if(0===t)return je(r+1);n[t-1]++,n[t]=n[t-1]+1}return n[r-1]>e?je(r+1):n}function je(t=1){return Array.from(Array(t).keys())}const Re=":".charCodeAt(0).toString(16).toUpperCase(),Pe=/[ !"#$%&'()\[\]{|}<>*+,./;=?@^`~\\]/;function Te(t=""){return CSS?CSS.escape(t):function(t=""){return t.split("").map(t=>":"===t?`\\${Re} `:Pe.test(t)?`\\${t}`:escape(t).replace(/%/g,"\\")).join("")}(t)}const Ce={tag:Se,id:function(t,e){return 0===t.length||t.length>1?[]:ve(t[0])},class:function(t,e){return re(t.map(t=>be(t,e)))},attribute:function(t,e){return re(t.map(t=>he(t)))},nthchild:function(t,e){return re(t.map(t=>we(t)))},nthoftype:function(t,e){return re(t.map(t=>Ee(t)))}},Ne={tag:xe,id:ve,class:be,attribute:he,nthchild:we,nthoftype:Ee};function Me(t){return t.includes(Gt.tag)||t.includes(Gt.nthoftype)?[...t]:[...t,Gt.tag]}function*Ie(t,e){const r={};for(const n of t){const t=e[n];t&&t.length>0&&(r[n]=t)}for(const t of function*(t={}){const e=Object.entries(t);if(0===e.length)return;const r=[{index:e.length-1,partial:{}}];for(;r.length>0;){const t=r.pop();if(!t)break;const{index:n,partial:o}=t;if(n<0){yield o;continue}const[i,a]=e[n];for(let t=a.length-1;t>=0;t--)r.push({index:n-1,partial:Object.assign(Object.assign({},o),{[i]:a[t]})})}}(r))yield ke(t)}function ke(t={}){const e=[...ce];return t[Gt.tag]&&t[Gt.nthoftype]&&e.splice(e.indexOf(Gt.tag),1),e.map(e=>{return(n=t)[r=e]?n[r].join(""):"";var r,n}).join("")}function $e(t,e){return[...t.map(t=>e+" "+t),...t.map(t=>e+" > "+t)]}function*Fe(t,e,r="",n){const o=function*(t,e){const r=new Set,n=function(t,e){const{blacklist:r,whitelist:n,combineWithinSelector:o,maxCombinations:i}=e,a=ne(r),s=ne(n);return function(t){const{selectors:e,includeTag:r}=t,n=[...e];return r&&!n.includes("tag")&&n.push("tag"),n}(e).reduce((r,n)=>{const u=function(t,e,r){return(0,Ce[e])(t,r)}(t,n,e),l=function(t=[],e,r){return t.filter(t=>r(t)||!e(t))}(u,a,s),c=function(t=[],e){return t.sort((t,r)=>{const n=e(t),o=e(r);return n&&!o?-1:!n&&o?1:0})}(l,s);return r[n]=o?Array.from(Ae(c,{maxResults:i})):c.map(t=>[t]),r},{})}(t,e);for(const t of function*(t,e){for(const r of function(t){const{selectors:e,combineBetweenSelectors:r,includeTag:n,maxCandidates:o}=t,i=r?function(t=[],{maxResults:e=Number.POSITIVE_INFINITY}={}){return Array.from(Ae(t,{maxResults:e}))}(e,{maxResults:o}):e.map(t=>[t]);return n?i.map(Me):i}(e))yield*Ie(r,t)}(n,e))r.has(t)||(r.add(t),yield t)}(t,n);for(const n of function*(t,e){if(""===e)yield*t;else for(const r of t)yield*$e([r],e)}(o,r))oe(t,n,e)&&(yield n)}function*_e(t,e,r="",n){if(0===t.length)return null;const o=[t.length>1?t:[],...ae(t,e).map(t=>[t])];for(const t of o)for(const o of Fe(t,e,r,n))yield{foundElements:t,selector:o}}function Be(t){return{value:t,include:!1}}function Le({selectors:t,operator:e}){let r=[...ce];t[Gt.tag]&&t[Gt.nthoftype]&&(r=r.filter(t=>t!==Gt.tag));let n="";return r.forEach(e=>{var r;(null!==(r=t[e])&&void 0!==r?r:[]).forEach(({value:t,include:e})=>{e&&(n+=t)})}),e+n}function De(t,e){return t.map(t=>function(t,e){const r=ie(t,e).reverse(),n=e instanceof ShadowRoot,o=r.map((t,e)=>{var r;const o=function(t,e,r=""){const n={};return e.forEach(e=>{Reflect.set(n,e,function(t,e){return Ne[e](t,void 0)}(t,e).map(Be))}),{element:t,operator:r,selectors:n}}(t,[Gt.nthchild],n&&0===e?"":" > ");return(null!==(r=o.selectors.nthchild)&&void 0!==r?r:[]).forEach(t=>{t.include=!0}),o});return[n?"":e?":scope":":root",...o.map(Le)].join("")}(t,e)).join(", ")}function We(t,e={}){const r=function*(t,e={}){var r;const n=function(t){(t instanceof NodeList||t instanceof HTMLCollection)&&(t=Array.from(t));const e=(Array.isArray(t)?t:[t]).filter(Ht);return[...new Set(e)]}(t),o=function(t,e={}){const r=Object.assign(Object.assign({},Xt),e);return{selectors:(n=r.selectors,Array.isArray(n)?n.filter(t=>{return e=Gt,r=t,Object.values(e).includes(r);var e,r}):[]),whitelist:Qt(r.whitelist),blacklist:Qt(r.blacklist),root:te(r.root,t),combineWithinSelector:Kt(r.combineWithinSelector),combineBetweenSelectors:Kt(r.combineBetweenSelectors),includeTag:Kt(r.includeTag),maxCombinations:ee(r.maxCombinations),maxCandidates:ee(r.maxCandidates),useScope:Kt(r.useScope),maxResults:ee(r.maxResults),ignoreGeneratedClassNames:Kt(r.ignoreGeneratedClassNames)};var n}(n[0],e),i=null!==(r=o.root)&&void 0!==r?r:se(n[0]);let a=0;for(const t of function*({elements:t,root:e,rootSelector:r="",options:n}){let o=e,i=r,a=!0;for(;a;){let r=!1;for(const a of _e(t,o,i,n)){const{foundElements:n,selector:s}=a;if(r=!0,!oe(t,s,e)){o=n[0],i=s;break}yield s}r||(a=!1)}}({elements:n,options:o,root:i,rootSelector:""}))if(yield t,a++,a>=o.maxResults)return;if(n.length>1){const{maxResults:t}=e,r=function(t,e){var r={};for(var n in t)Object.prototype.hasOwnProperty.call(t,n)&&e.indexOf(n)<0&&(r[n]=t[n]);if(null!=t&&"function"==typeof Object.getOwnPropertySymbols){var o=0;for(n=Object.getOwnPropertySymbols(t);oWe(t,r)).join(", "),a++,a>=o.maxResults)return}const s=void 0!==e.root;yield De(n,o.useScope||s?i:void 0)}(t,Object.assign(Object.assign({},e),{maxResults:1}));return r.next().value}function Ue(t){return null==t?null:-1!==["a","audio","button","canvas","details","input","label","option","select","submit","textarea","video"].indexOf(t.nodeName.toLowerCase())||t.hasAttribute("contenteditable")&&"false"!=t.getAttribute("contenteditable").toLowerCase()?t.outerHTML:t.parentElement?Ue(t.parentElement):null}function ze(t){for(var e=0;e0&&e.top0&&e.left title"))||void 0===r?void 0:r.textContent.trim();if(i)return i;const a=null===(n=t.querySelector(":scope > desc"))||void 0===n?void 0:n.textContent.trim();if(a)return a;const s=t.closest("figure");if(s){var u;const t=null===(u=s.querySelector("figcaption"))||void 0===u?void 0:u.textContent.trim();if(t)return t}return null}function tr(t){return t.defaultPrevented||null!=Ue(document.activeElement)}function er(t){t.stopPropagation(),t.preventDefault()}function rr(t,e){e.repeat||webkit.messageHandlers.keyEventReceived.postMessage({phase:t,code:e.code,key:String.fromCharCode(e.keyCode),option:e.altKey,control:e.ctrlKey,shift:e.shiftKey,command:e.metaKey})}window.addEventListener("DOMContentLoaded",function(){document.addEventListener("click",qe,!1),document.addEventListener("pointerdown",Xe,!1),document.addEventListener("pointerup",Ke,!1),document.addEventListener("pointermove",Ye,!1),document.addEventListener("pointercancel",Je,!1),document.addEventListener("selectionchange",function(){Ge=!window.getSelection().isCollapsed})}),window.addEventListener("keydown",t=>{tr(t)||(er(t),rr("down",t))}),window.addEventListener("keyup",t=>{tr(t)||(er(t),rr("up",t))}),globalThis.readium={scrollToId:function(t,e){let r=document.getElementById(t);return!!r&&(C(r.getBoundingClientRect(),e),!0)},scrollToPosition:function(t,e,r){t<0||t>1?console.error(`Expected a valid progression in scrollToPosition, got ${t}`):R()?P()?M({left:-document.scrollingElement.scrollWidth*t,animated:r}):M({top:document.scrollingElement.scrollHeight*t,animated:r}):M({left:I(document.scrollingElement.scrollWidth*t*("rtl"==e?-1:1)),animated:r})},scrollToLocator:function(t,e){let r=k(t);return!!r&&function(t,e){return C(t.getBoundingClientRect(),e)}(r,e)},scrollLeft:function(t,e){var r="rtl"==t,n=document.scrollingElement.scrollWidth,o=window.innerWidth,i=window.scrollX-o,a=r?-(n-o):0;return N(Math.max(i,a),e)},scrollRight:function(t,e){var r="rtl"==t,n=document.scrollingElement.scrollWidth,o=window.innerWidth,i=window.scrollX+o,a=r?0:n-o;return N(Math.min(i,a),e)},setCSSProperties:function(t){for(const e in t)$(e,t[e])},setProperty:$,removeProperty:F,registerDecorationTemplates:function(t){var e="";for(const n of Object.entries(t)){var r=Bt(n,2);const t=r[0],o=r[1];Wt.set(t,o),o.stylesheet&&(e+=o.stylesheet+"\n")}if(e){let t=document.createElement("style");t.innerHTML=e,document.getElementsByTagName("head")[0].appendChild(t)}},getDecorations:function(t){var e=Ut.get(t);return e||(e=function(t,e){var r=[],n=0,o=null,i=!1;function a(e){let o=t+"-"+n++,i=k(e.locator);if(!i)return void _("Can't locate DOM range for decoration",e);let a={id:o,decoration:e,range:i};r.push(a),u(a)}function s(t){let e=r.findIndex(e=>e.decoration.id===t);if(-1===e)return;let n=r[e];r.splice(e,1),n.clickableElements=null,n.container&&(n.container.remove(),n.container=null)}function u(r){let n=(o||((o=document.createElement("div")).id=t,o.dataset.group=e,o.style.pointerEvents="none",requestAnimationFrame(function(){null!=o&&document.body.append(o)})),o),i=Wt.get(r.decoration.style);if(!i)return void B(`Unknown decoration style: ${r.decoration.style}`);let a=document.createElement("div");a.id=r.id,a.dataset.style=r.decoration.style,a.style.pointerEvents="none";const s=getComputedStyle(document.body).writingMode,u="vertical-rl"===s||"vertical-lr"===s,l=document.scrollingElement,c=l.scrollLeft,f=l.scrollTop,p=u?window.innerHeight:window.innerWidth,d=u?window.innerWidth:window.innerHeight,y=parseInt(getComputedStyle(document.documentElement).getPropertyValue("column-count"))||1,h=(u?d:p)/y;function g(t,e,r,n){t.style.position="absolute";const o="vertical-rl"===n;if(o||"vertical-lr"===n){if("wrap"===i.width)t.style.width=`${e.width}px`,t.style.height=`${e.height}px`,o?t.style.right=`${-e.right-c+l.clientWidth}px`:t.style.left=`${e.left+c}px`,t.style.top=`${e.top+f}px`;else if("viewport"===i.width){t.style.width=`${e.height}px`,t.style.height=`${p}px`;const r=Math.floor(e.top/p)*p;o?t.style.right=-e.right-c+"px":t.style.left=`${e.left+c}px`,t.style.top=`${r+f}px`}else if("bounds"===i.width)t.style.width=`${r.height}px`,t.style.height=`${p}px`,o?t.style.right=`${-r.right-c+l.clientWidth}px`:t.style.left=`${r.left+c}px`,t.style.top=`${r.top+f}px`;else if("page"===i.width){t.style.width=`${e.height}px`,t.style.height=`${h}px`;const r=Math.floor(e.top/h)*h;o?t.style.right=`${-e.right-c+l.clientWidth}px`:t.style.left=`${e.left+c}px`,t.style.top=`${r+f}px`}}else if("wrap"===i.width)t.style.width=`${e.width}px`,t.style.height=`${e.height}px`,t.style.left=`${e.left+c}px`,t.style.top=`${e.top+f}px`;else if("viewport"===i.width){t.style.width=`${p}px`,t.style.height=`${e.height}px`;const r=Math.floor(e.left/p)*p;t.style.left=`${r+c}px`,t.style.top=`${e.top+f}px`}else if("bounds"===i.width)t.style.width=`${r.width}px`,t.style.height=`${e.height}px`,t.style.left=`${r.left+c}px`,t.style.top=`${e.top+f}px`;else if("page"===i.width){t.style.width=`${h}px`,t.style.height=`${e.height}px`;const r=Math.floor(e.left/h)*h;t.style.left=`${r+c}px`,t.style.top=`${e.top+f}px`}}let m,b=r.range.getBoundingClientRect();try{let t=document.createElement("template");t.innerHTML=r.decoration.element.trim(),m=t.content.firstElementChild}catch(t){return void B(`Invalid decoration element "${r.decoration.element}": ${t.message}`)}if("boxes"===i.layout){const t=!s.startsWith("vertical"),e=(v=r.range.startContainer).nodeType===Node.ELEMENT_NODE?v:v.parentElement,n=getComputedStyle(e).writingMode,o=U(r.range,t).sort((t,e)=>t.top!==e.top?t.top-e.top:"vertical-rl"===n?e.left-t.left:t.left-e.left);for(let t of o){const e=m.cloneNode(!0);e.style.pointerEvents="none",e.dataset.writingMode=n,g(e,t,b,s),a.append(e)}}else if("bounds"===i.layout){const t=m.cloneNode(!0);t.style.pointerEvents="none",t.dataset.writingMode=s,g(t,b,b,s),a.append(t)}var v;n.append(a),r.container=a,r.clickableElements=Array.from(a.querySelectorAll("[data-activable='1']")),0===r.clickableElements.length&&(r.clickableElements=Array.from(a.children))}function l(){o&&(o.remove(),o=null)}return{add:a,remove:s,update:function(t){s(t.id),a(t)},clear:function(){l(),r.length=0},items:r,requestLayout:function(){l(),r.forEach(t=>u(t))},isActivable:function(){return i},setActivable:function(){i=!0}}}("r2-decoration-"+zt++,t),Ut.set(t,e)),e},findFirstVisibleLocator:function(){const t=ze(document.body);return{href:"#",type:"application/xhtml+xml",locations:{cssSelector:We(t)},text:{highlight:t.textContent}}}},window.readium.isFixedLayout=!0,webkit.messageHandlers.spreadLoadStarted.postMessage({})})()})(); +(()=>{var t={3618(t,e){"use strict";function r(t){return t.split("").reverse().join("")}function n(t){return(t|-t)>>31&1}function o(t,e,r,o){var i=t.P[r],a=t.M[r],s=o>>>31,u=e[r]|s,l=u|a,c=(u&i)+i^i|u,f=a|~(c|i),p=i&c,d=n(f&t.lastRowMask[r])-n(p&t.lastRowMask[r]);return f<<=1,p<<=1,i=(p|=s)|~(l|(f|=n(o)-s)),a=f&l,t.P[r]=i,t.M[r]=a,d}function i(t,e,r){if(0===e.length)return[];r=Math.min(r,e.length);var n=[],i=32,a=Math.ceil(e.length/i)-1,s={P:new Uint32Array(a+1),M:new Uint32Array(a+1),lastRowMask:new Uint32Array(a+1)};s.lastRowMask.fill(1<<31),s.lastRowMask[a]=1<<(e.length-1)%i;for(var u=new Uint32Array(a+1),l=new Map,c=[],f=0;f<256;f++)c.push(u);for(var p=0;p=e.length||e.charCodeAt(m)===d&&(y[h]|=1<0&&v[b]>=r+i;)b-=1;b===a&&v[b]<=r&&(v[b]0?r:0,!0)},o?o(t.exports,"apply",{value:a}):t.exports.apply=a},5298(t,e,r){"use strict";var n=r(703),o=r(5312),i=o([n("%String.prototype.indexOf%")]);t.exports=function(t,e){var r=n(t,!!e);return"function"==typeof r&&i(t,".prototype.")>-1?o([r]):r}},7517(t,e,r){"use strict";var n=r(9173),o=r(7388),i=r(7379),a=r(3492);t.exports=function(t,e,r){if(!t||"object"!=typeof t&&"function"!=typeof t)throw new i("`obj` must be an object or a function`");if("string"!=typeof e&&"symbol"!=typeof e)throw new i("`property` must be a string or a symbol`");if(arguments.length>3&&"boolean"!=typeof arguments[3]&&null!==arguments[3])throw new i("`nonEnumerable`, if provided, must be a boolean or null");if(arguments.length>4&&"boolean"!=typeof arguments[4]&&null!==arguments[4])throw new i("`nonWritable`, if provided, must be a boolean or null");if(arguments.length>5&&"boolean"!=typeof arguments[5]&&null!==arguments[5])throw new i("`nonConfigurable`, if provided, must be a boolean or null");if(arguments.length>6&&"boolean"!=typeof arguments[6])throw new i("`loose`, if provided, must be a boolean");var s=arguments.length>3?arguments[3]:null,u=arguments.length>4?arguments[4]:null,l=arguments.length>5?arguments[5]:null,c=arguments.length>6&&arguments[6],f=!!a&&a(t,e);if(n)n(t,e,{configurable:null===l&&f?f.configurable:!l,enumerable:null===s&&f?f.enumerable:!s,value:r,writable:null===u&&f?f.writable:!u});else{if(!c&&(s||u||l))throw new o("This environment does not support defining a property as non-configurable, non-writable, or non-enumerable.");t[e]=r}}},8189(t,e,r){"use strict";var n=r(1748),o="function"==typeof Symbol&&"symbol"==typeof Symbol("foo"),i=Object.prototype.toString,a=Array.prototype.concat,s=r(7517),u=r(708)(),l=function(t,e,r,n){if(e in t)if(!0===n){if(t[e]===r)return}else if("function"!=typeof(o=n)||"[object Function]"!==i.call(o)||!n())return;var o;u?s(t,e,r,!0):s(t,e,r)},c=function(t,e){var r=arguments.length>2?arguments[2]:{},i=n(e);o&&(i=a.call(i,Object.getOwnPropertySymbols(e)));for(var s=0;s0&&arguments[1]||"Cannot call method on "+t);return t}},9253(t){"use strict";t.exports=Object},4938(t){"use strict";t.exports=function(t){return!!t&&("function"==typeof t||"object"==typeof t)}},3148(t,e,r){"use strict";var n=r(703)("%Object.defineProperty%",!0),o=r(6618)(),i=r(9939),a=r(7379),s=o?Symbol.toStringTag:null;t.exports=function(t,e){var r=arguments.length>2&&!!arguments[2]&&arguments[2].force,o=arguments.length>2&&!!arguments[2]&&arguments[2].nonConfigurable;if(void 0!==r&&"boolean"!=typeof r||void 0!==o&&"boolean"!=typeof o)throw new a("if provided, the `overrideIfSet` and `nonConfigurable` options must be booleans");!s||!r&&i(t,s)||(n?n(t,s,{configurable:!o,enumerable:!1,value:e,writable:!1}):t[s]=e)}},2632(t,e,r){"use strict";var n="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator,o=r(2719),i=r(5833),a=r(1718),s=r(7379),u=r(5465),l=r(7377);t.exports=function(t){if(u(t))return t;var e,r="default";if(arguments.length>1&&(arguments[1]===String?r="string":arguments[1]===Number&&(r="number")),n&&(Symbol.toPrimitive?e=l(t,Symbol.toPrimitive):a(t)&&(e=Symbol.prototype.valueOf)),void 0!==e){var c=e.call(t,r);if(u(c))return c;throw new s("unable to convert exotic object to primitive")}return"default"===r&&(i(t)||a(t))&&(r="string"),function(t,e){if(null==t)throw new s("Cannot call method on "+t);if("string"!=typeof e||"number"!==e&&"string"!==e)throw new s('hint must be "string" or "number"');var r,n,i,a="string"===e?["toString","valueOf"]:["valueOf","toString"];for(i=0;i1&&"boolean"!=typeof e)throw new c('"allowMissing" argument must be a boolean');if(null===z(/^%?[^%]*%?$/,t))throw new l("`%` may not be present anywhere but at the beginning and end of the intrinsic name");var r=function(t){var e=U(t,0,1),r=U(t,-1);if("%"===e&&"%"!==r)throw new l("invalid intrinsic syntax, expected closing `%`");if("%"===r&&"%"!==e)throw new l("invalid intrinsic syntax, expected opening `%`");var n=[];return W(t,V,function(t,e,r,o){n[n.length]=r?W(o,H,"$1"):e||t}),n}(t),n=r.length>0?r[0]:"",o=G("%"+n+"%",e),i=o.name,a=o.value,s=!1,u=o.alias;u&&(n=u[0],D(r,L([0,1],u)));for(var f=1,p=!0;f=r.length){var g=x(a,d);a=(p=!!g)&&"get"in g&&!("originalValue"in g.get)?g.get:a[d]}else p=B(a,d),a=a[d];p&&!s&&(I[i]=a)}}return a}},8819(t,e,r){"use strict";var n=r(9253);t.exports=n.getPrototypeOf||null},2517(t){"use strict";t.exports="undefined"!=typeof Reflect&&Reflect.getPrototypeOf||null},1611(t,e,r){"use strict";var n=r(2517),o=r(8819),i=r(1449);t.exports=n?function(t){return n(t)}:o?function(t){if(!t||"object"!=typeof t&&"function"!=typeof t)throw new TypeError("getProto: not an object");return o(t)}:i?function(t){return i(t)}:null},4656(t){"use strict";t.exports=Object.getOwnPropertyDescriptor},3492(t,e,r){"use strict";var n=r(4656);if(n)try{n([],"length")}catch(t){n=null}t.exports=n},708(t,e,r){"use strict";var n=r(9173),o=function(){return!!n};o.hasArrayLengthDefineBug=function(){if(!n)return null;try{return 1!==n([],"length",{value:1}).length}catch(t){return!0}},t.exports=o},6052(t){"use strict";var e={__proto__:null,foo:{}},r={__proto__:e}.foo===e.foo&&!(e instanceof Object);t.exports=function(){return r}},7657(t,e,r){"use strict";var n="undefined"!=typeof Symbol&&Symbol,o=r(8123);t.exports=function(){return"function"==typeof n&&"function"==typeof Symbol&&"symbol"==typeof n("foo")&&"symbol"==typeof Symbol("bar")&&o()}},8123(t){"use strict";t.exports=function(){if("function"!=typeof Symbol||"function"!=typeof Object.getOwnPropertySymbols)return!1;if("symbol"==typeof Symbol.iterator)return!0;var t={},e=Symbol("test"),r=Object(e);if("string"==typeof e)return!1;if("[object Symbol]"!==Object.prototype.toString.call(e))return!1;if("[object Symbol]"!==Object.prototype.toString.call(r))return!1;for(var n in t[e]=42,t)return!1;if("function"==typeof Object.keys&&0!==Object.keys(t).length)return!1;if("function"==typeof Object.getOwnPropertyNames&&0!==Object.getOwnPropertyNames(t).length)return!1;var o=Object.getOwnPropertySymbols(t);if(1!==o.length||o[0]!==e)return!1;if(!Object.prototype.propertyIsEnumerable.call(t,e))return!1;if("function"==typeof Object.getOwnPropertyDescriptor){var i=Object.getOwnPropertyDescriptor(t,e);if(42!==i.value||!0!==i.enumerable)return!1}return!0}},6618(t,e,r){"use strict";var n=r(8123);t.exports=function(){return n()&&!!Symbol.toStringTag}},9939(t,e,r){"use strict";var n=Function.prototype.call,o=Object.prototype.hasOwnProperty,i=r(3583);t.exports=i.call(n,o)},6561(t,e,r){"use strict";var n=r(9939),o=r(6746)(),i=r(7379),a={assert:function(t,e){if(!t||"object"!=typeof t&&"function"!=typeof t)throw new i("`O` is not an object");if("string"!=typeof e)throw new i("`slot` must be a string");if(o.assert(t),!a.has(t,e))throw new i("`"+e+"` is not present on `O`")},get:function(t,e){if(!t||"object"!=typeof t&&"function"!=typeof t)throw new i("`O` is not an object");if("string"!=typeof e)throw new i("`slot` must be a string");var r=o.get(t);return r&&r["$"+e]},has:function(t,e){if(!t||"object"!=typeof t&&"function"!=typeof t)throw new i("`O` is not an object");if("string"!=typeof e)throw new i("`slot` must be a string");var r=o.get(t);return!!r&&n(r,"$"+e)},set:function(t,e,r){if(!t||"object"!=typeof t&&"function"!=typeof t)throw new i("`O` is not an object");if("string"!=typeof e)throw new i("`slot` must be a string");var n=o.get(t);n||(n={},o.set(t,n)),n["$"+e]=r}};Object.freeze&&Object.freeze(a),t.exports=a},2719(t){"use strict";var e,r,n=Function.prototype.toString,o="object"==typeof Reflect&&null!==Reflect&&Reflect.apply;if("function"==typeof o&&"function"==typeof Object.defineProperty)try{e=Object.defineProperty({},"length",{get:function(){throw r}}),r={},o(function(){throw 42},null,e)}catch(t){t!==r&&(o=null)}else o=null;var i=/^\s*class\b/,a=function(t){try{var e=n.call(t);return i.test(e)}catch(t){return!1}},s=function(t){try{return!a(t)&&(n.call(t),!0)}catch(t){return!1}},u=Object.prototype.toString,l="function"==typeof Symbol&&!!Symbol.toStringTag,c=!(0 in[,]),f=function(){return!1};if("object"==typeof document){var p=document.all;u.call(p)===u.call(document.all)&&(f=function(t){if((c||!t)&&(void 0===t||"object"==typeof t))try{var e=u.call(t);return("[object HTMLAllCollection]"===e||"[object HTML document.all class]"===e||"[object HTMLCollection]"===e||"[object Object]"===e)&&null==t("")}catch(t){}return!1})}t.exports=o?function(t){if(f(t))return!0;if(!t)return!1;if("function"!=typeof t&&"object"!=typeof t)return!1;try{o(t,null,e)}catch(t){if(t!==r)return!1}return!a(t)&&s(t)}:function(t){if(f(t))return!0;if(!t)return!1;if("function"!=typeof t&&"object"!=typeof t)return!1;if(l)return s(t);if(a(t))return!1;var e=u.call(t);return!("[object Function]"!==e&&"[object GeneratorFunction]"!==e&&!/^\[object HTML/.test(e))&&s(t)}},5833(t,e,r){"use strict";var n=r(5298),o=n("Date.prototype.getDay"),i=n("Object.prototype.toString"),a=r(6618)();t.exports=function(t){return"object"==typeof t&&null!==t&&(a?function(t){try{return o(t),!0}catch(t){return!1}}(t):"[object Date]"===i(t))}},4587(t,e,r){"use strict";var n,o=r(5298),i=r(6618)(),a=r(9939),s=r(3492);if(i){var u=o("RegExp.prototype.exec"),l={},c=function(){throw l},f={toString:c,valueOf:c};"symbol"==typeof Symbol.toPrimitive&&(f[Symbol.toPrimitive]=c),n=function(t){if(!t||"object"!=typeof t)return!1;var e=s(t,"lastIndex");if(!e||!a(e,"value"))return!1;try{u(t,f)}catch(t){return t===l}}}else{var p=o("Object.prototype.toString");n=function(t){return!(!t||"object"!=typeof t&&"function"!=typeof t)&&"[object RegExp]"===p(t)}}t.exports=n},1718(t,e,r){"use strict";var n=r(5298),o=n("Object.prototype.toString"),i=r(7657)(),a=r(5537);if(i){var s=n("Symbol.prototype.toString"),u=a(/^Symbol\(.*\)$/);t.exports=function(t){if("symbol"==typeof t)return!0;if(!t||"object"!=typeof t||"[object Symbol]"!==o(t))return!1;try{return function(t){return"symbol"==typeof t.valueOf()&&u(s(t))}(t)}catch(t){return!1}}}else t.exports=function(t){return!1}},9895(t){"use strict";t.exports=Math.abs},6241(t){"use strict";t.exports=Number.MAX_SAFE_INTEGER||9007199254740991},2153(t){"use strict";t.exports=Math.floor},1084(t,e,r){"use strict";var n=r(5518);t.exports=function(t){return("number"==typeof t||"bigint"==typeof t)&&!n(t)&&t!==1/0&&t!==-1/0}},1029(t,e,r){"use strict";var n=r(9895),o=r(2153),i=r(5518),a=r(1084);t.exports=function(t){if("number"!=typeof t||i(t)||!a(t))return!1;var e=n(t);return o(e)===e}},5518(t){"use strict";t.exports=Number.isNaN||function(t){return t!=t}},457(t){"use strict";t.exports=Math.max},1179(t){"use strict";t.exports=Math.min},5985(t){"use strict";t.exports=Math.pow},8639(t){"use strict";t.exports=Math.round},5738(t,e,r){"use strict";var n=r(5518);t.exports=function(t){return n(t)||0===t?t:t<0?-1:1}},4922(t,e,r){var n="function"==typeof Map&&Map.prototype,o=Object.getOwnPropertyDescriptor&&n?Object.getOwnPropertyDescriptor(Map.prototype,"size"):null,i=n&&o&&"function"==typeof o.get?o.get:null,a=n&&Map.prototype.forEach,s="function"==typeof Set&&Set.prototype,u=Object.getOwnPropertyDescriptor&&s?Object.getOwnPropertyDescriptor(Set.prototype,"size"):null,l=s&&u&&"function"==typeof u.get?u.get:null,c=s&&Set.prototype.forEach,f="function"==typeof WeakMap&&WeakMap.prototype?WeakMap.prototype.has:null,p="function"==typeof WeakSet&&WeakSet.prototype?WeakSet.prototype.has:null,d="function"==typeof WeakRef&&WeakRef.prototype?WeakRef.prototype.deref:null,y=Boolean.prototype.valueOf,h=Object.prototype.toString,g=Function.prototype.toString,m=String.prototype.match,b=String.prototype.slice,v=String.prototype.replace,w=String.prototype.toUpperCase,x=String.prototype.toLowerCase,S=RegExp.prototype.test,E=Array.prototype.concat,A=Array.prototype.join,O=Array.prototype.slice,j=Math.floor,R="function"==typeof BigInt?BigInt.prototype.valueOf:null,P=Object.getOwnPropertySymbols,C="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?Symbol.prototype.toString:null,T="function"==typeof Symbol&&"object"==typeof Symbol.iterator,N="function"==typeof Symbol&&Symbol.toStringTag&&(Symbol.toStringTag,1)?Symbol.toStringTag:null,M=Object.prototype.propertyIsEnumerable,I=("function"==typeof Reflect?Reflect.getPrototypeOf:Object.getPrototypeOf)||([].__proto__===Array.prototype?function(t){return t.__proto__}:null);function k(t,e){if(t===1/0||t===-1/0||t!=t||t&&t>-1e3&&t<1e3||S.call(/e/,e))return e;var r=/[0-9](?=(?:[0-9]{3})+(?![0-9]))/g;if("number"==typeof t){var n=t<0?-j(-t):j(t);if(n!==t){var o=String(n),i=b.call(e,o.length+1);return v.call(o,r,"$&_")+"."+v.call(v.call(i,/([0-9]{3})/g,"$&_"),/_$/,"")}}return v.call(e,r,"$&_")}var $=r(2179),F=$.custom,_=H(F)?F:null,B={__proto__:null,double:'"',single:"'"},L={__proto__:null,double:/(["\\])/g,single:/(['\\])/g};function D(t,e,r){var n=r.quoteStyle||e,o=B[n];return o+t+o}function W(t){return v.call(String(t),/"/g,""")}function U(t){return!N||!("object"==typeof t&&(N in t||void 0!==t[N]))}function z(t){return"[object Array]"===X(t)&&U(t)}function V(t){return"[object RegExp]"===X(t)&&U(t)}function H(t){if(T)return t&&"object"==typeof t&&t instanceof Symbol;if("symbol"==typeof t)return!0;if(!t||"object"!=typeof t||!C)return!1;try{return C.call(t),!0}catch(t){}return!1}t.exports=function t(e,r,n,o){var s=r||{};if(q(s,"quoteStyle")&&!q(B,s.quoteStyle))throw new TypeError('option "quoteStyle" must be "single" or "double"');if(q(s,"maxStringLength")&&("number"==typeof s.maxStringLength?s.maxStringLength<0&&s.maxStringLength!==1/0:null!==s.maxStringLength))throw new TypeError('option "maxStringLength", if provided, must be a positive integer, Infinity, or `null`');var u=!q(s,"customInspect")||s.customInspect;if("boolean"!=typeof u&&"symbol"!==u)throw new TypeError("option \"customInspect\", if provided, must be `true`, `false`, or `'symbol'`");if(q(s,"indent")&&null!==s.indent&&"\t"!==s.indent&&!(parseInt(s.indent,10)===s.indent&&s.indent>0))throw new TypeError('option "indent" must be "\\t", an integer > 0, or `null`');if(q(s,"numericSeparator")&&"boolean"!=typeof s.numericSeparator)throw new TypeError('option "numericSeparator", if provided, must be `true` or `false`');var h=s.numericSeparator;if(void 0===e)return"undefined";if(null===e)return"null";if("boolean"==typeof e)return e?"true":"false";if("string"==typeof e)return Y(e,s);if("number"==typeof e){if(0===e)return 1/0/e>0?"0":"-0";var w=String(e);return h?k(e,w):w}if("bigint"==typeof e){var S=String(e)+"n";return h?k(e,S):S}var j=void 0===s.depth?5:s.depth;if(void 0===n&&(n=0),n>=j&&j>0&&"object"==typeof e)return z(e)?"[Array]":"[Object]";var P,F=function(t,e){var r;if("\t"===t.indent)r="\t";else{if(!("number"==typeof t.indent&&t.indent>0))return null;r=A.call(Array(t.indent+1)," ")}return{base:r,prev:A.call(Array(e+1),r)}}(s,n);if(void 0===o)o=[];else if(K(o,e)>=0)return"[Circular]";function L(e,r,i){if(r&&(o=O.call(o)).push(r),i){var a={depth:s.depth};return q(s,"quoteStyle")&&(a.quoteStyle=s.quoteStyle),t(e,a,n+1,o)}return t(e,s,n+1,o)}if("function"==typeof e&&!V(e)){var G=function(t){if(t.name)return t.name;var e=m.call(g.call(t),/^function\s*([\w$]+)/);return e?e[1]:null}(e),J=rt(e,L);return"[Function"+(G?": "+G:" (anonymous)")+"]"+(J.length>0?" { "+A.call(J,", ")+" }":"")}if(H(e)){var nt=T?v.call(String(e),/^(Symbol\(.*\))_[^)]*$/,"$1"):C.call(e);return"object"!=typeof e||T?nt:Q(nt)}if((P=e)&&"object"==typeof P&&("undefined"!=typeof HTMLElement&&P instanceof HTMLElement||"string"==typeof P.nodeName&&"function"==typeof P.getAttribute)){for(var ot="<"+x.call(String(e.nodeName)),it=e.attributes||[],at=0;at"}if(z(e)){if(0===e.length)return"[]";var st=rt(e,L);return F&&!function(t){for(var e=0;e=0)return!1;return!0}(st)?"["+et(st,F)+"]":"[ "+A.call(st,", ")+" ]"}if(function(t){return"[object Error]"===X(t)&&U(t)}(e)){var ut=rt(e,L);return"cause"in Error.prototype||!("cause"in e)||M.call(e,"cause")?0===ut.length?"["+String(e)+"]":"{ ["+String(e)+"] "+A.call(ut,", ")+" }":"{ ["+String(e)+"] "+A.call(E.call("[cause]: "+L(e.cause),ut),", ")+" }"}if("object"==typeof e&&u){if(_&&"function"==typeof e[_]&&$)return $(e,{depth:j-n});if("symbol"!==u&&"function"==typeof e.inspect)return e.inspect()}if(function(t){if(!i||!t||"object"!=typeof t)return!1;try{i.call(t);try{l.call(t)}catch(t){return!0}return t instanceof Map}catch(t){}return!1}(e)){var lt=[];return a&&a.call(e,function(t,r){lt.push(L(r,e,!0)+" => "+L(t,e))}),tt("Map",i.call(e),lt,F)}if(function(t){if(!l||!t||"object"!=typeof t)return!1;try{l.call(t);try{i.call(t)}catch(t){return!0}return t instanceof Set}catch(t){}return!1}(e)){var ct=[];return c&&c.call(e,function(t){ct.push(L(t,e))}),tt("Set",l.call(e),ct,F)}if(function(t){if(!f||!t||"object"!=typeof t)return!1;try{f.call(t,f);try{p.call(t,p)}catch(t){return!0}return t instanceof WeakMap}catch(t){}return!1}(e))return Z("WeakMap");if(function(t){if(!p||!t||"object"!=typeof t)return!1;try{p.call(t,p);try{f.call(t,f)}catch(t){return!0}return t instanceof WeakSet}catch(t){}return!1}(e))return Z("WeakSet");if(function(t){if(!d||!t||"object"!=typeof t)return!1;try{return d.call(t),!0}catch(t){}return!1}(e))return Z("WeakRef");if(function(t){return"[object Number]"===X(t)&&U(t)}(e))return Q(L(Number(e)));if(function(t){if(!t||"object"!=typeof t||!R)return!1;try{return R.call(t),!0}catch(t){}return!1}(e))return Q(L(R.call(e)));if(function(t){return"[object Boolean]"===X(t)&&U(t)}(e))return Q(y.call(e));if(function(t){return"[object String]"===X(t)&&U(t)}(e))return Q(L(String(e)));if("undefined"!=typeof window&&e===window)return"{ [object Window] }";if("undefined"!=typeof globalThis&&e===globalThis||"undefined"!=typeof globalThis&&e===globalThis)return"{ [object globalThis] }";if(!function(t){return"[object Date]"===X(t)&&U(t)}(e)&&!V(e)){var ft=rt(e,L),pt=I?I(e)===Object.prototype:e instanceof Object||e.constructor===Object,dt=e instanceof Object?"":"null prototype",yt=!pt&&N&&Object(e)===e&&N in e?b.call(X(e),8,-1):dt?"Object":"",ht=(pt||"function"!=typeof e.constructor?"":e.constructor.name?e.constructor.name+" ":"")+(yt||dt?"["+A.call(E.call([],yt||[],dt||[]),": ")+"] ":"");return 0===ft.length?ht+"{}":F?ht+"{"+et(ft,F)+"}":ht+"{ "+A.call(ft,", ")+" }"}return String(e)};var G=Object.prototype.hasOwnProperty||function(t){return t in this};function q(t,e){return G.call(t,e)}function X(t){return h.call(t)}function K(t,e){if(t.indexOf)return t.indexOf(e);for(var r=0,n=t.length;re.maxStringLength){var r=t.length-e.maxStringLength,n="... "+r+" more character"+(r>1?"s":"");return Y(b.call(t,0,e.maxStringLength),e)+n}var o=L[e.quoteStyle||"single"];return o.lastIndex=0,D(v.call(v.call(t,o,"\\$1"),/[\x00-\x1f]/g,J),"single",e)}function J(t){var e=t.charCodeAt(0),r={8:"b",9:"t",10:"n",12:"f",13:"r"}[e];return r?"\\"+r:"\\x"+(e<16?"0":"")+w.call(e.toString(16))}function Q(t){return"Object("+t+")"}function Z(t){return t+" { ? }"}function tt(t,e,r,n){return t+" ("+e+") {"+(n?et(r,n):A.call(r,", "))+"}"}function et(t,e){if(0===t.length)return"";var r="\n"+e.prev+e.base;return r+A.call(t,","+r)+"\n"+e.prev}function rt(t,e){var r=z(t),n=[];if(r){n.length=t.length;for(var o=0;o0&&!o.call(t,0))for(var h=0;h0)for(var g=0;g=0&&"[object Function]"===e.call(t.callee)),n}},3743(t,e,r){"use strict";var n=r(7843),o=r(7379),i=Object;t.exports=n(function(){if(null==this||this!==i(this))throw new o("RegExp.prototype.flags getter called on non-object");var t="";return this.hasIndices&&(t+="d"),this.global&&(t+="g"),this.ignoreCase&&(t+="i"),this.multiline&&(t+="m"),this.dotAll&&(t+="s"),this.unicode&&(t+="u"),this.unicodeSets&&(t+="v"),this.sticky&&(t+="y"),t},"get flags",!0)},1721(t,e,r){"use strict";var n=r(8189),o=r(7965),i=r(3743),a=r(4510),s=r(3980),u=o(a());n(u,{getPolyfill:a,implementation:i,shim:s}),t.exports=u},4510(t,e,r){"use strict";var n=r(3743),o=r(8189).supportsDescriptors,i=Object.getOwnPropertyDescriptor;t.exports=function(){if(o&&"gim"===/a/gim.flags){var t=i(RegExp.prototype,"flags");if(t&&"function"==typeof t.get&&"dotAll"in RegExp.prototype&&"hasIndices"in RegExp.prototype){var e="",r={};if(Object.defineProperty(r,"hasIndices",{get:function(){e+="d"}}),Object.defineProperty(r,"sticky",{get:function(){e+="y"}}),t.get.call(r),"dy"===e)return t.get}}return n}},3980(t,e,r){"use strict";var n=r(8189).supportsDescriptors,o=r(4510),i=r(3492),a=Object.defineProperty,s=r(9183),u=r(1611),l=/a/;t.exports=function(){if(!n||!u)throw new s("RegExp.prototype.flags requires a true ES5 environment that supports property descriptors");var t=o(),e=u(l),r=i(e,"flags");return r&&r.get===t||a(e,"flags",{configurable:!0,enumerable:!1,get:t}),t}},5537(t,e,r){"use strict";var n=r(5298),o=r(4587),i=n("RegExp.prototype.exec"),a=r(7379);t.exports=function(t){if(!o(t))throw new a("`regex` must be a RegExp");return function(e){return null!==i(t,e)}}},2644(t,e,r){"use strict";var n=r(703),o=r(7517),i=r(708)(),a=r(3492),s=r(7379),u=n("%Math.floor%");t.exports=function(t,e){if("function"!=typeof t)throw new s("`fn` is not a function");if("number"!=typeof e||e<0||e>4294967295||u(e)!==e)throw new s("`length` must be a positive 32-bit integer");var r=arguments.length>2&&!!arguments[2],n=!0,l=!0;if("length"in t&&a){var c=a(t,"length");c&&!c.configurable&&(n=!1),c&&!c.writable&&(l=!1)}return(n||l||!r)&&(i?o(t,"length",e,!0,!0):o(t,"length",e)),t}},7843(t,e,r){"use strict";var n=r(7517),o=r(708)(),i=r(3749).functionsHaveConfigurableNames(),a=r(7379);t.exports=function(t,e){if("function"!=typeof t)throw new a("`fn` is not a function");return arguments.length>2&&!!arguments[2]&&!i||(o?n(t,"name",e,!0,!0):n(t,"name",e)),t}},7797(t,e,r){"use strict";var n=r(4922),o=r(7379),i=function(t,e,r){for(var n,o=t;null!=(n=o.next);o=n)if(n.key===e)return o.next=n.next,r||(n.next=t.next,t.next=n),n};t.exports=function(){var t,e={assert:function(t){if(!e.has(t))throw new o("Side channel does not contain "+n(t))},delete:function(e){var r=function(t,e){if(t)return i(t,e,!0)}(t,e);return r&&t&&!t.next&&(t=void 0),!!r},get:function(e){return function(t,e){if(t){var r=i(t,e);return r&&r.value}}(t,e)},has:function(e){return function(t,e){return!!t&&!!i(t,e)}(t,e)},set:function(e,r){t||(t={next:void 0}),function(t,e,r){var n=i(t,e);n?n.value=r:t.next={key:e,next:t.next,value:r}}(t,e,r)}};return e}},1085(t,e,r){"use strict";var n=r(703),o=r(5298),i=r(4922),a=r(7379),s=n("%Map%",!0),u=o("Map.prototype.get",!0),l=o("Map.prototype.set",!0),c=o("Map.prototype.has",!0),f=o("Map.prototype.delete",!0),p=o("Map.prototype.size",!0);t.exports=!!s&&function(){var t,e={assert:function(t){if(!e.has(t))throw new a("Side channel does not contain "+i(t))},delete:function(e){if(t){var r=f(t,e);return 0===p(t)&&(t=void 0),r}return!1},get:function(e){if(t)return u(t,e)},has:function(e){return!!t&&c(t,e)},set:function(e,r){t||(t=new s),l(t,e,r)}};return e}},2468(t,e,r){"use strict";var n=r(703),o=r(5298),i=r(4922),a=r(1085),s=r(7379),u=n("%WeakMap%",!0),l=o("WeakMap.prototype.get",!0),c=o("WeakMap.prototype.set",!0),f=o("WeakMap.prototype.has",!0),p=o("WeakMap.prototype.delete",!0);t.exports=u?function(){var t,e,r={assert:function(t){if(!r.has(t))throw new s("Side channel does not contain "+i(t))},delete:function(r){if(u&&r&&("object"==typeof r||"function"==typeof r)){if(t)return p(t,r)}else if(a&&e)return e.delete(r);return!1},get:function(r){return u&&r&&("object"==typeof r||"function"==typeof r)&&t?l(t,r):e&&e.get(r)},has:function(r){return u&&r&&("object"==typeof r||"function"==typeof r)&&t?f(t,r):!!e&&e.has(r)},set:function(r,n){u&&r&&("object"==typeof r||"function"==typeof r)?(t||(t=new u),c(t,r,n)):a&&(e||(e=a()),e.set(r,n))}};return r}:a},6746(t,e,r){"use strict";var n=r(7379),o=r(4922),i=r(7797),a=r(1085),s=r(2468)||a||i;t.exports=function(){var t,e={assert:function(t){if(!e.has(t)){var r=t&&Object(t)===t?"the given object key":o(t);throw new n("Side channel does not contain "+r)}},delete:function(e){return!!t&&t.delete(e)},get:function(e){return t&&t.get(e)},has:function(e){return!!t&&t.has(e)},set:function(e,r){t||(t=s()),t.set(e,r)}};return e}},4290(t,e,r){"use strict";var n=r(6520),o=r(7630),i=r(4111),a=r(333),s=r(1076),u=r(7744),l=r(5298),c=r(7657)(),f=r(1721),p=r(703),d=r(7379),y=p("%RegExp%"),h=l("String.prototype.indexOf"),g=r(2570),m=function(t){var e=g();if(c&&"symbol"==typeof Symbol.matchAll){var r=i(t,Symbol.matchAll);return r===y.prototype[Symbol.matchAll]&&r!==e?e:r}if(a(t))return e};t.exports=function(t){var e=u(this);if(null!=t){if(a(t)){var r="flags"in t?o(t,"flags"):f(t);if(u(r),h(s(r),"g")<0)throw new d("matchAll requires a global regular expression")}var i=m(t);if(void 0!==i)return n(i,t,[e])}var l=s(e),c=new y(t,"g");return n(m(c),c,[l])}},6410(t,e,r){"use strict";var n=r(7965),o=r(8189),i=r(4290),a=r(4683),s=r(3197),u=n(i);o(u,{getPolyfill:a,implementation:i,shim:s}),t.exports=u},2570(t,e,r){"use strict";var n=r(7657)(),o=r(1930);t.exports=function(){return n&&"symbol"==typeof Symbol.matchAll&&"function"==typeof RegExp.prototype[Symbol.matchAll]?RegExp.prototype[Symbol.matchAll]:o}},4683(t,e,r){"use strict";var n=r(4290);t.exports=function(){if(String.prototype.matchAll)try{"".matchAll(RegExp.prototype)}catch(t){return String.prototype.matchAll}return n}},1930(t,e,r){"use strict";var n=r(3990),o=r(7630),i=r(5234),a=r(518),s=r(6117),u=r(1076),l=r(192),c=r(1721),f=r(7843),p=r(5298),d=r(703),y=r(7379),h=p("String.prototype.indexOf"),g=d("%RegExp%"),m="flags"in g.prototype,b=f(function(t){var e=this;if("Object"!==l(e))throw new y('"this" value must be an Object');var r=u(t),f=function(t,e){var r="flags"in e?o(e,"flags"):u(c(e));return{flags:r,matcher:new t(m&&"string"==typeof r?e:t===g?e.source:e,r)}}(a(e,g),e),p=f.flags,d=f.matcher,b=s(o(e,"lastIndex"));i(d,"lastIndex",b,!0);var v=h(p,"g")>-1,w=h(p,"u")>-1;return n(d,r,v,w)},"[Symbol.matchAll]",!0);t.exports=b},3197(t,e,r){"use strict";var n=r(8189),o=r(7657)(),i=r(3492),a=r(4683),s=r(2570),u=Object.defineProperty;t.exports=function(){var t=a();if(n(String.prototype,{matchAll:t},{matchAll:function(){return String.prototype.matchAll!==t}}),o){var e=Symbol.matchAll||(Symbol.for?Symbol.for("Symbol.matchAll"):Symbol("Symbol.matchAll"));if(n(Symbol,{matchAll:e},{matchAll:function(){return Symbol.matchAll!==e}}),u&&i){var r=i(Symbol,e);r&&!r.configurable||u(Symbol,e,{configurable:!1,enumerable:!1,value:e,writable:!1})}var l=s(),c={};c[e]=l;var f={};f[e]=function(){return RegExp.prototype[e]!==l},n(RegExp.prototype,c,f)}return t}},3952(t,e,r){"use strict";var n=r(7744),o=r(2501),i=r(5298),a=r(5537),s=i("String.prototype.replace"),u=i("String.prototype.charAt"),l=i("String.prototype.slice"),c=/^\s$/.test("᠎"),f=c?/^[\x09\x0A\x0B\x0C\x0D\x20\xA0\u1680\u180E\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000\u2028\u2029\uFEFF]+/:/^[\x09\x0A\x0B\x0C\x0D\x20\xA0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000\u2028\u2029\uFEFF]+/,p=a(c?/[\x09\x0A\x0B\x0C\x0D\x20\xA0\u1680\u180E\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000\u2028\u2029\uFEFF]$/:/[\x09\x0A\x0B\x0C\x0D\x20\xA0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000\u2028\u2029\uFEFF]$/);t.exports=function(){for(var t=s(o(n(this)),f,""),e=t.length;e>0&&p(u(t,e-1));)e-=1;return l(t,0,e)}},7724(t,e,r){"use strict";var n=r(7965),o=r(8189),i=r(7744),a=r(3952),s=r(8821),u=r(5795),l=n(s()),c=function(t){return i(t),l(t)};o(c,{getPolyfill:s,implementation:a,shim:u}),t.exports=c},8821(t,e,r){"use strict";var n=r(3952);t.exports=function(){return String.prototype.trim&&"​"==="​".trim()&&"᠎"==="᠎".trim()&&"_᠎"==="_᠎".trim()&&"᠎_"==="᠎_".trim()?String.prototype.trim:n}},5795(t,e,r){"use strict";var n=r(708)(),o=r(7517),i=r(8821);t.exports=function(){var t=i();return String.prototype.trim!==t&&(n?o(String.prototype,"trim",t,!0):o(String.prototype,"trim",t)),t}},2179(){},6917(t,e,r){"use strict";var n=r(6562),o=r(7379),i=r(1029),a=r(6241);t.exports=function(t,e,r){if("string"!=typeof t)throw new o("Assertion failed: `S` must be a String");if(!i(e)||e<0||e>a)throw new o("Assertion failed: `length` must be an integer >= 0 and <= 2**53");if("boolean"!=typeof r)throw new o("Assertion failed: `unicode` must be a Boolean");return r?e+1>=t.length?e+1:e+n(t,e)["[[CodeUnitCount]]"]:e+1}},6520(t,e,r){"use strict";var n=r(703),o=r(5298),i=r(7379),a=r(3443),s=n("%Reflect.apply%",!0)||o("Function.prototype.apply");t.exports=function(t,e){var r=arguments.length>2?arguments[2]:[];if(!a(r))throw new i("Assertion failed: optional `argumentsList`, if provided, must be a List");return s(t,e,r)}},6562(t,e,r){"use strict";var n=r(7379),o=r(5298),i=r(3283),a=r(8537),s=r(1300),u=o("String.prototype.charAt"),l=o("String.prototype.charCodeAt");t.exports=function(t,e){if("string"!=typeof t)throw new n("Assertion failed: `string` must be a String");var r=t.length;if(e<0||e>=r)throw new n("Assertion failed: `position` must be >= 0, and < the length of `string`");var o=l(t,e),c=u(t,e),f=i(o),p=a(o);if(!f&&!p)return{"[[CodePoint]]":c,"[[CodeUnitCount]]":1,"[[IsUnpairedSurrogate]]":!1};if(p||e+1===r)return{"[[CodePoint]]":c,"[[CodeUnitCount]]":1,"[[IsUnpairedSurrogate]]":!0};var d=l(t,e+1);return a(d)?{"[[CodePoint]]":s(o,d),"[[CodeUnitCount]]":2,"[[IsUnpairedSurrogate]]":!1}:{"[[CodePoint]]":c,"[[CodeUnitCount]]":1,"[[IsUnpairedSurrogate]]":!0}}},44(t,e,r){"use strict";var n=r(7379);t.exports=function(t,e){if("boolean"!=typeof e)throw new n("Assertion failed: Type(done) is not Boolean");return{value:t,done:e}}},3990(t,e,r){"use strict";var n=r(703),o=r(7657)(),i=r(7379),a=r(4938),s=n("%IteratorPrototype%",!0),u=r(6917),l=r(44),c=r(355),f=r(7630),p=r(2021),d=r(3936),y=r(5234),h=r(6117),g=r(1076),m=r(6561),b=r(3148),v=function(t,e,r,n){if("string"!=typeof e)throw new i("`S` must be a string");if("boolean"!=typeof r)throw new i("`global` must be a boolean");if("boolean"!=typeof n)throw new i("`fullUnicode` must be a boolean");m.set(this,"[[IteratingRegExp]]",t),m.set(this,"[[IteratedString]]",e),m.set(this,"[[Global]]",r),m.set(this,"[[Unicode]]",n),m.set(this,"[[Done]]",!1)};s&&(v.prototype=p(s)),c(v.prototype,"next",function(){var t=this;if(!a(t))throw new i("receiver must be an object");if(!(t instanceof v&&m.has(t,"[[IteratingRegExp]]")&&m.has(t,"[[IteratedString]]")&&m.has(t,"[[Global]]")&&m.has(t,"[[Unicode]]")&&m.has(t,"[[Done]]")))throw new i('"this" value must be a RegExpStringIterator instance');if(m.get(t,"[[Done]]"))return l(void 0,!0);var e=m.get(t,"[[IteratingRegExp]]"),r=m.get(t,"[[IteratedString]]"),n=m.get(t,"[[Global]]"),o=m.get(t,"[[Unicode]]"),s=d(e,r);if(null===s)return m.set(t,"[[Done]]",!0),l(void 0,!0);if(n){if(""===g(f(s,"0"))){var c=h(f(e,"lastIndex")),p=u(r,c,o);y(e,"lastIndex",p,!0)}return l(s,!1)}return m.set(t,"[[Done]]",!0),l(s,!1)},!1),o&&(b(v.prototype,"RegExp String Iterator"),Symbol.iterator&&"function"!=typeof v.prototype[Symbol.iterator])&&c(v.prototype,Symbol.iterator,function(){return this},!1),t.exports=function(t,e,r,n){return new v(t,e,r,n)}},355(t,e,r){"use strict";var n=r(7379),o=r(4938),i=r(9271),a=r(1181),s=r(5855);t.exports=function(t,e,r,u){if(!o(t))throw new n("Assertion failed: `homeObject` is not an Object");if(!s(e))throw new n("Assertion failed: `key` is not a Property Key or a Private Name");if("function"!=typeof r)throw new n("Assertion failed: `closure` is not a function");if("boolean"!=typeof u)throw new n("Assertion failed: `enumerable` is not a Boolean");if(!a(t))throw new n("Assertion failed: `homeObject` is not an ordinary, extensible object, with no non-configurable properties");i(t,e,{"[[Value]]":r,"[[Writable]]":!0,"[[Enumerable]]":u,"[[Configurable]]":!0})}},9271(t,e,r){"use strict";var n=r(7379),o=r(4938),i=r(6843),a=r(9999),s=r(5848),u=r(7817),l=r(5855),c=r(925),f=r(6309);t.exports=function(t,e,r){if(!o(t))throw new n("Assertion failed: Type(O) is not Object");if(!l(e))throw new n("Assertion failed: P is not a Property Key");var p=i(r)?r:f(r);if(!i(p))throw new n("Assertion failed: Desc is not a valid Property Descriptor");return a(u,c,s,t,e,p)}},5848(t,e,r){"use strict";var n=r(7379),o=r(6843),i=r(3003);t.exports=function(t){if(void 0!==t&&!o(t))throw new n("Assertion failed: `Desc` must be a Property Descriptor");return i(t)}},7630(t,e,r){"use strict";var n=r(7379),o=r(4922),i=r(5855),a=r(4938);t.exports=function(t,e){if(!a(t))throw new n("Assertion failed: Type(O) is not Object");if(!i(e))throw new n("Assertion failed: P is not a Property Key, got "+o(e));return t[e]}},4111(t,e,r){"use strict";var n=r(7379),o=r(7818),i=r(1816),a=r(5855),s=r(4922);t.exports=function(t,e){if(!a(e))throw new n("Assertion failed: P is not a Property Key");var r=o(t,e);if(null!=r){if(!i(r))throw new n(s(e)+" is not a function: "+s(r));return r}}},7818(t,e,r){"use strict";var n=r(7379),o=r(4922),i=r(5855);t.exports=function(t,e){if(!i(e))throw new n("Assertion failed: P is not a Property Key, got "+o(e));return t[e]}},3443(t,e,r){"use strict";t.exports=r(8622)},1816(t,e,r){"use strict";t.exports=r(2719)},3478(t,e,r){"use strict";var n=r(4334)("%Reflect.construct%",!0),o=r(9271);try{o({},"",{"[[Get]]":function(){}})}catch(t){o=null}if(o&&n){var i={},a={};o(a,"length",{"[[Get]]":function(){throw i},"[[Enumerable]]":!0}),t.exports=function(t){try{n(t,a)}catch(t){return t===i}}}else t.exports=function(t){return"function"==typeof t&&!!t.prototype}},7817(t,e,r){"use strict";var n=r(7379),o=r(9939),i=r(6843);t.exports=function(t){if(void 0===t)return!1;if(!i(t))throw new n("Assertion failed: `Desc` must be a Property Descriptor");return!(!o(t,"[[Value]]")&&!o(t,"[[Writable]]"))}},1181(t,e,r){"use strict";var n=r(703),o=n("%Object.preventExtensions%",!0),i=n("%Object.isExtensible%",!0),a=r(9258);t.exports=o?function(t){return!a(t)&&i(t)}:function(t){return!a(t)}},333(t,e,r){"use strict";var n=r(703)("%Symbol.match%",!0),o=r(4587),i=r(4938),a=r(4801);t.exports=function(t){if(!i(t))return!1;if(n){var e=t[n];if(void 0!==e)return a(e)}return o(t)}},2021(t,e,r){"use strict";var n=r(703)("%Object.create%",!0),o=r(7379),i=r(7388),a=r(4938),s=r(3443),u=r(5713),l=r(6561),c=r(6052)();t.exports=function(t){if(null!==t&&!a(t))throw new o("Assertion failed: `proto` must be null or an object");var e,r=arguments.length<2?[]:arguments[1];if(!s(r))throw new o("Assertion failed: `additionalInternalSlotsList` must be an Array");if(c)e={__proto__:t};else if(n)e=n(t);else{if(null===t)throw new i("native Object.create support is required to create null objects");var f=function(){};f.prototype=t,e=new f}return r.length>0&&u(r,function(t){l.set(e,t,void 0)}),e}},3936(t,e,r){"use strict";var n=r(7379),o=r(4938),i=r(5298)("RegExp.prototype.exec"),a=r(6520),s=r(7630),u=r(1816);t.exports=function(t,e){if(!o(t))throw new n("Assertion failed: `R` must be an Object");if("string"!=typeof e)throw new n("Assertion failed: `S` must be a String");var r=s(t,"exec");if(u(r)){var l=a(r,t,[e]);if(null===l||o(l))return l;throw new n('"exec" method must return `null` or an Object')}return i(t,e)}},925(t,e,r){"use strict";var n=r(5518);t.exports=function(t,e){return t===e?0!==t||1/t==1/e:n(t)&&n(e)}},5234(t,e,r){"use strict";var n=r(7379),o=r(4938),i=r(5855),a=r(925),s=function(){try{return delete[].length,!0}catch(t){return!1}}();t.exports=function(t,e,r,u){if(!o(t))throw new n("Assertion failed: `O` must be an Object");if(!i(e))throw new n("Assertion failed: `P` must be a Property Key");if("boolean"!=typeof u)throw new n("Assertion failed: `Throw` must be a Boolean");if(u){if(t[e]=r,s&&!a(t[e],r))throw new n("Attempted to assign to readonly property.");return!0}try{return t[e]=r,!s||a(t[e],r)}catch(t){return!1}}},518(t,e,r){"use strict";var n=r(703)("%Symbol.species%",!0),o=r(7379),i=r(4938),a=r(3478);t.exports=function(t,e){if(!i(t))throw new o("Assertion failed: Type(O) is not Object");var r=t.constructor;if(void 0===r)return e;if(!i(r))throw new o("O.constructor is not an Object");var s=n?r[n]:void 0;if(null==s)return e;if(a(s))return s;throw new o("no constructor found")}},9893(t,e,r){"use strict";var n=r(703),o=n("%RegExp%"),i=r(7379),a=n("%parseInt%"),s=r(5298),u=r(5537),l=s("String.prototype.slice"),c=u(/^0b[01]+$/i),f=u(/^0o[0-7]+$/i),p=u(/^[-+]0x[0-9a-f]+$/i),d=u(new o("["+["…","​","￾"].join("")+"]","g")),y=r(7724);t.exports=function t(e){if("string"!=typeof e)throw new i("Assertion failed: `argument` is not a String");if(c(e))return+a(l(e,2),2);if(f(e))return+a(l(e,2),8);if(d(e)||p(e))return NaN;var r=y(e);return r!==e?t(r):+e}},4801(t){"use strict";t.exports=function(t){return!!t}},7210(t,e,r){"use strict";var n=r(3312),o=r(6354),i=r(5518),a=r(1084);t.exports=function(t){var e=n(t);return i(e)||0===e?0:a(e)?o(e):e}},6117(t,e,r){"use strict";var n=r(6241),o=r(7210);t.exports=function(t){var e=o(t);return e<=0?0:e>n?n:e}},3312(t,e,r){"use strict";var n=r(703),o=r(7379),i=n("%Number%"),a=r(9258),s=r(3760),u=r(9893);t.exports=function(t){var e=a(t)?t:s(t,i);if("symbol"==typeof e)throw new o("Cannot convert a Symbol value to a number");if("bigint"==typeof e)throw new o("Conversion from 'BigInt' to 'number' is not allowed.");return"string"==typeof e?u(e):+e}},3760(t,e,r){"use strict";var n=r(2632);t.exports=function(t){return arguments.length>1?n(t,arguments[1]):n(t)}},6309(t,e,r){"use strict";var n=r(9939),o=r(7379),i=r(4938),a=r(1816),s=r(4801);t.exports=function(t){if(!i(t))throw new o("ToPropertyDescriptor requires an object");var e={};if(n(t,"enumerable")&&(e["[[Enumerable]]"]=s(t.enumerable)),n(t,"configurable")&&(e["[[Configurable]]"]=s(t.configurable)),n(t,"value")&&(e["[[Value]]"]=t.value),n(t,"writable")&&(e["[[Writable]]"]=s(t.writable)),n(t,"get")){var r=t.get;if(void 0!==r&&!a(r))throw new o("getter must be a function");e["[[Get]]"]=r}if(n(t,"set")){var u=t.set;if(void 0!==u&&!a(u))throw new o("setter must be a function");e["[[Set]]"]=u}if((n(e,"[[Get]]")||n(e,"[[Set]]"))&&(n(e,"[[Value]]")||n(e,"[[Writable]]")))throw new o("Invalid property descriptor. Cannot both specify accessors and a value or writable attribute");return e}},1076(t,e,r){"use strict";var n=r(703)("%String%"),o=r(7379);t.exports=function(t){if("symbol"==typeof t)throw new o("Cannot convert a Symbol value to a string");return n(t)}},192(t,e,r){"use strict";var n=r(3225);t.exports=function(t){return"symbol"==typeof t?"Symbol":"bigint"==typeof t?"BigInt":n(t)}},1300(t,e,r){"use strict";var n=r(703),o=r(7379),i=n("%String.fromCharCode%"),a=r(3283),s=r(8537);t.exports=function(t,e){if(!a(t)||!s(e))throw new o("Assertion failed: `lead` must be a leading surrogate char code, and `trail` must be a trailing surrogate char code");return i(t)+i(e)}},7060(t,e,r){"use strict";var n=r(2153);t.exports=function(t){return"bigint"==typeof t?t:n(t)}},6354(t,e,r){"use strict";var n=r(7060),o=r(7379);t.exports=function(t){if("number"!=typeof t&&"bigint"!=typeof t)throw new o("argument must be a Number or a BigInt");var e=t<0?-n(-t):n(t);return 0===e?0:e}},2501(t,e,r){"use strict";var n=r(703)("%String%"),o=r(7379);t.exports=function(t){if("symbol"==typeof t)throw new o("Cannot convert a Symbol value to a string");return n(t)}},3225(t,e,r){"use strict";var n=r(4938);t.exports=function(t){return null===t?"Null":void 0===t?"Undefined":n(t)?"Object":"number"==typeof t?"Number":"boolean"==typeof t?"Boolean":"string"==typeof t?"String":void 0}},4334(t,e,r){"use strict";t.exports=r(703)},9999(t,e,r){"use strict";var n=r(708),o=r(9173),i=n.hasArrayLengthDefineBug(),a=i&&r(8622),s=r(5298)("Object.prototype.propertyIsEnumerable");t.exports=function(t,e,r,n,u,l){if(!o){if(!t(l))return!1;if(!l["[[Configurable]]"]||!l["[[Writable]]"])return!1;if(u in n&&s(n,u)!==!!l["[[Enumerable]]"])return!1;var c=l["[[Value]]"];return n[u]=c,e(n[u],c)}return i&&"length"===u&&"[[Value]]"in l&&a(n)&&n.length!==l["[[Value]]"]?(n.length=l["[[Value]]"],n.length===l["[[Value]]"]):(o(n,u,r(l)),!0)}},8622(t,e,r){"use strict";var n=r(703)("%Array%"),o=!n.isArray&&r(5298)("Object.prototype.toString");t.exports=n.isArray||function(t){return"[object Array]"===o(t)}},5713(t){"use strict";t.exports=function(t,e){for(var r=0;r=55296&&t<=56319}},9258(t){"use strict";t.exports=function(t){return null===t||"function"!=typeof t&&"object"!=typeof t}},5855(t){"use strict";t.exports=function(t){return"string"==typeof t||"symbol"==typeof t}},8537(t){"use strict";t.exports=function(t){return"number"==typeof t&&t>=56320&&t<=57343}},6843(t,e,r){"use strict";var n=r(7379),o=r(9939),i={__proto__:null,"[[Configurable]]":!0,"[[Enumerable]]":!0,"[[Get]]":!0,"[[Set]]":!0,"[[Value]]":!0,"[[Writable]]":!0};t.exports=function(t){if(!t||"object"!=typeof t)return!1;for(var e in t)if(o(t,e)&&!i[e])return!1;var r=o(t,"[[Value]]")||o(t,"[[Writable]]"),a=o(t,"[[Get]]")||o(t,"[[Set]]");if(r&&a)throw new n("Property Descriptors may not be both accessor and data descriptors");return!0}}},e={};function r(n){var o=e[n];if(void 0!==o)return o.exports;var i=e[n]={exports:{}};return t[n](i,i.exports,r),i.exports}r.n=t=>{var e=t&&t.__esModule?()=>t.default:()=>t;return r.d(e,{a:e}),e},r.d=(t,e)=>{for(var n in e)r.o(e,n)&&!r.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:e[n]})},r.o=(t,e)=>Object.prototype.hasOwnProperty.call(t,e),(()=>{"use strict";var t=r(3618);function e(e,r,n){let o=0;const i=[];for(;-1!==o;)o=e.indexOf(r,o),-1!==o&&(i.push({start:o,end:o+r.length,errors:0}),o+=1);return i.length>0?i:(0,t.A)(e,r,n)}function n(t,r){return 0===r.length||0===t.length?0:1-e(t,r,r.length)[0].errors/r.length}function o(t){const e=document.createElement("div");return e.appendChild(t.cloneContents()),function(t){var e;for(const e of Array.from(t.querySelectorAll("br")))e.replaceWith(document.createTextNode(" "));return null!==(e=t.textContent)&&void 0!==e?e:""}(e)}function i(t,e){let r=0;for(const n of t){if(!(n{if(i=e===a.Forwards?r.nextNode():r.previousNode(),i){const t=i.textContent,r=e===a.Forwards?0:t.length;u=s(t,r,e)}};for(;i&&-1===u&&i!==o;)l();if(i&&u>=0)return{node:i,offset:u};throw new RangeError("No text nodes with non-whitespace text found in range")}function l(t,e){(null==e||e>t.length)&&(e=t.length);for(var r=0,n=Array(e);r1?e-1:0),n=1;no?(a.push({node:s,offset:o-l}),o=r.shift()):(u=i.nextNode(),l+=s.data.length);for(;void 0!==o&&s&&l===o;)a.push({node:s,offset:s.data.length}),o=r.shift();if(void 0!==o)throw new RangeError("Offset exceeds text length");return a}let d=function(t){return t[t.FORWARDS=1]="FORWARDS",t[t.BACKWARDS=2]="BACKWARDS",t}({});class y{constructor(t,e){if(e<0)throw new Error("Offset is invalid");this.element=t,this.offset=e}relativeTo(t){if(!t.contains(this.element))throw new Error("Parent is not an ancestor of current element");let e=this.element,r=this.offset;for(;e!==t;)r+=f(e),e=e.parentElement;return new y(e,r)}resolve(){let t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};try{return p(this.element,this.offset)[0]}catch(e){if(0===this.offset&&void 0!==t.direction){const r=document.createTreeWalker(this.element.getRootNode(),NodeFilter.SHOW_TEXT);r.currentNode=this.element;const n=t.direction===d.FORWARDS,o=n?r.nextNode():r.previousNode();if(!o)throw e;return{node:o,offset:n?0:o.data.length}}throw e}}static fromCharOffset(t,e){switch(t.nodeType){case Node.TEXT_NODE:return y.fromPoint(t,e);case Node.ELEMENT_NODE:return new y(t,e);default:throw new Error("Node is not an element or text node")}}static fromPoint(t,e){switch(t.nodeType){case Node.TEXT_NODE:{if(e<0||e>t.data.length)throw new Error("Text node offset is out of range");if(!t.parentElement)throw new Error("Text node has no parent");const r=f(t)+e;return new y(t.parentElement,r)}case Node.ELEMENT_NODE:{if(e<0||e>t.childNodes.length)throw new Error("Child node offset is out of range");let r=0;for(let n=0;n=0&&(e.setStart(t.startContainer,o.start),r=!0),o.end>0&&(e.setEnd(t.endContainer,o.end),n=!0),r&&n)return e;if(!r){const t=u(e,a.Forwards),r=t.node,n=t.offset;r&&n>=0&&e.setStart(r,n)}if(!n){const t=u(e,a.Backwards),r=t.node,n=t.offset;r&&n>0&&e.setEnd(r,n)}return e}(h.fromRange(t).toRange())}}class g{constructor(t,e,r){this.root=t,this.start=e,this.end=r}static fromRange(t,e){const r=h.fromRange(e).relativeTo(t);return new g(t,r.start.offset,r.end.offset)}static fromSelector(t,e){return new g(t,e.start,e.end)}toSelector(){return{type:"TextPositionSelector",start:this.start,end:this.end}}toRange(){return h.fromOffsets(this.root,this.start,this.end).toRange()}}class m{constructor(t,e){let r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};this.root=t,this.exact=e,this.context=r}static fromRange(t,e){var r;const n=null!==(r=t.textContent)&&void 0!==r?r:"",i=h.fromRange(e).relativeTo(t),a=i.start.offset,s=i.end.offset,u=o(e),l=o(h.fromOffsets(t,Math.max(0,a-32),a).toRange()),c=o(h.fromOffsets(t,s,Math.min(n.length,s+32)).toRange());return new m(t,u,{prefix:l,suffix:c})}static fromSelector(t,e){const r=e.prefix,n=e.suffix;return new m(t,e.exact,{prefix:r,suffix:n})}toSelector(){return{type:"TextQuoteSelector",exact:this.exact,prefix:this.context.prefix,suffix:this.context.suffix}}toRange(){let t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return this.toPositionAnchor(t).toRange()}toPositionAnchor(){let t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};const r=function(t){let e="";const r=[],n=t=>{var o;if(t.nodeType!==Node.TEXT_NODE){if(t.nodeType===Node.ELEMENT_NODE){if("BR"===t.tagName)return r.push(e.length),void(e+=" ");for(const e of Array.from(t.childNodes))n(e)}}else e+=null!==(o=t.textContent)&&void 0!==o?o:""};return n(t),{text:e,brPositionsInText:r}}(this.root),o=r.text,a=r.brPositionsInText,s=function(t,r){let o=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};if(0===r.length)return null;const i=Math.min(256,r.length/2),a=e(t,r,i);if(0===a.length)return null;const s=e=>{const i=1-e.errors/r.length,a=o.prefix?n(t.slice(Math.max(0,e.start-o.prefix.length),e.start),o.prefix):1,s=o.suffix?n(t.slice(e.end,e.end+o.suffix.length),o.suffix):1;let u=1;return"number"==typeof o.hint&&(u=1-Math.abs(e.start-o.hint)/t.length),(50*i+20*a+20*s+2*u)/92},u=a.map(t=>({start:t.start,end:t.end,score:s(t)}));return u.sort((t,e)=>e.score-t.score),u[0]}(o,this.exact,{...this.context,hint:t.hint});if(!s)throw new Error("Quote not found");return new g(this.root,i(a,s.start),i(a,s.end))}}var b,v=r(6410);function w(){if(!readium.link)return null;const t=readium.link.href;if(!t)return null;const e=function(){const t=window.getSelection();if(!t)return;if(t.isCollapsed)return;const e=t.toString();if(0===e.trim().replace(/\n/g," ").replace(/\s\s+/g," ").length)return;if(!t.anchorNode||!t.focusNode)return;const r=1===t.rangeCount?t.getRangeAt(0):function(t,e,r,n){const o=new Range;if(o.setStart(t,e),o.setEnd(r,n),!o.collapsed)return o;x(">>> createOrderedRange COLLAPSED ... RANGE REVERSE?");const i=new Range;if(i.setStart(r,n),i.setEnd(t,e),!i.collapsed)return x(">>> createOrderedRange RANGE REVERSE OK."),o;x(">>> createOrderedRange RANGE REVERSE ALSO COLLAPSED?!")}(t.anchorNode,t.anchorOffset,t.focusNode,t.focusOffset);if(!r||r.collapsed)return void x("$$$$$$$$$$$$$$$$$ CANNOT GET NON-COLLAPSED SELECTION RANGE?!");const n=document.body.textContent,o=h.fromRange(r).relativeTo(document.body),i=o.start.offset,a=o.end.offset;let s=n.slice(Math.max(0,i-200),i),u=s.search(/\P{L}\p{L}/gu);-1!==u&&(s=s.slice(u+1));let l=n.slice(a,Math.min(n.length,a+200)),c=Array.from(l.matchAll(/\p{L}\P{L}/gu)).pop();return void 0!==c&&c.index>1&&(l=l.slice(0,c.index+1)),{highlight:e,before:s,after:l}}();return e?{href:t,text:e,rect:function(){try{let t=window.getSelection();if(!t)return;return D(t.getRangeAt(0).getBoundingClientRect())}catch(t){return L(t),null}}()}:null}function x(){_.apply(null,arguments)}r.n(v)().shim(),window.addEventListener("error",function(t){webkit.messageHandlers.logError.postMessage({message:t.message,filename:t.filename,line:t.lineno})},!1),window.addEventListener("load",function(){var t;new ResizeObserver(()=>{t&&window.cancelAnimationFrame(t),t=window.requestAnimationFrame(function(){O=window.innerWidth,function(){const t="readium-virtual-page";var e=document.getElementById(t);if(R()||2!=parseInt(window.getComputedStyle(document.documentElement).getPropertyValue("column-count"))){var r;null===(r=e)||void 0===r||r.remove()}else{var n=document.scrollingElement.scrollWidth/window.innerWidth;Math.round(2*n)/2%1>.1&&(e?e.remove():((e=document.createElement("div")).setAttribute("id",t),e.style.breakBefore="column",e.innerHTML="​",document.body.appendChild(e)))}}(),function(){if(!R()){var t=I(window.scrollX+1);document.scrollingElement.scrollLeft=t}}(),j()})}).observe(document.body)},!1);var S,E,A=!1,O=0;function j(){if(readium.isFixedLayout)return;let t=document.scrollingElement;if(R()&&!P()){const e=window.scrollY,r=window.innerHeight,n=t.scrollHeight;b={first:e/n,last:(e+r)/n}}else{let e=window.scrollX;const r=window.innerWidth,n=t.scrollWidth;C()&&(e=Math.abs(e)),b={first:e/n,last:(e+r)/n}}0!==t.scrollWidth&&0!==t.scrollHeight&&(A||window.requestAnimationFrame(function(){var t;t=b,webkit.messageHandlers.progressionChanged.postMessage(t),A=!1}),A=!0)}function R(){return"readium-scroll-on"==document.documentElement.style.getPropertyValue("--USER__view").trim()}function P(){return window.getComputedStyle(document.documentElement).getPropertyValue("writing-mode").startsWith("vertical")}function C(){const t=window.getComputedStyle(document.documentElement);return"rtl"==t.getPropertyValue("direction")||"vertical-rl"==t.getPropertyValue("writing-mode")}function T(t,e){return R()?M({top:t.top+window.scrollY,animated:e}):M({left:I(t.left+window.scrollX),animated:e}),!0}function N(t,e){var r=window.scrollX,n=window.innerWidth,o=Math.abs(r-t)/n>.01;return o&&M({left:t,animated:e}),o}function M(){let t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},e=t.left,r=t.top,n=t.animated;document.scrollingElement.scrollTo({left:e,top:r,behavior:n?"smooth":"instant"})}function I(t){const e=t+(C()?-1:1);return e-e%O}function k(t){try{let n=t.locations,o=t.text;var e;if(o&&o.highlight)return n&&n.cssSelector&&(e=document.querySelector(n.cssSelector)),e||(e=document.body),new m(e,o.highlight,{prefix:o.before,suffix:o.after}).toRange();if(n){var r=null;if(!r&&n.cssSelector&&(r=document.querySelector(n.cssSelector)),!r&&n.fragments)for(const t of n.fragments)if(r=document.getElementById(t))break;if(r){let t=document.createRange();return t.setStartBefore(r),t.setEndAfter(r),t}}}catch(t){L(t)}return null}function $(t,e){null===e?F(t):document.documentElement.style.setProperty(t,e,"important")}function F(t){document.documentElement.style.removeProperty(t)}function _(){var t=Array.prototype.slice.call(arguments).join(" ");webkit.messageHandlers.log.postMessage(t)}function B(t){L(new Error(t))}function L(t){webkit.messageHandlers.logError.postMessage({message:t.message})}function D(t){let e=W({x:t.left,y:t.top});const r=t.width,n=t.height,o=e.x,i=e.y;return{width:r,height:n,left:o,top:i,right:o+r,bottom:i+n}}function W(t){if(!frameElement)return t;let e=frameElement.getBoundingClientRect();if(!e)return t;let r=window.top.document.documentElement;return{x:t.x+e.x+r.scrollLeft,y:t.y+e.y+r.scrollTop}}function U(t,e){let r=t.getClientRects();const n=[];for(const t of r)n.push({bottom:t.bottom,height:t.height,left:t.left,right:t.right,top:t.top,width:t.width});const o=q(function(t){const e=new Set(t);for(const r of t)if(r.width>1&&r.height>1){for(const n of t)if(r!==n&&e.has(n)&&H(n,r,1)){J(),e.delete(r);break}}else J(),e.delete(r);return Array.from(e)}(z(n,1,e)));for(let t=o.length-1;t>=0;t--){const e=o[t];if(!(e.width*e.height>4)){if(!(o.length>1)){J();break}J(),o.splice(t,1)}}return J((n.length,o.length)),o}function z(t,e,r){for(let n=0;nt!==i&&t!==a),o=V(i,a);return n.push(o),z(n,e,r)}}return t}function V(t,e){const r=Math.min(t.left,e.left),n=Math.max(t.right,e.right),o=Math.min(t.top,e.top),i=Math.max(t.bottom,e.bottom);return{bottom:i,height:i-o,left:r,right:n,top:o,width:n-r}}function H(t,e,r){return G(t,e.left,e.top,r)&&G(t,e.right,e.top,r)&&G(t,e.left,e.bottom,r)&&G(t,e.right,e.bottom,r)}function G(t,e,r,n){return(t.lefte||Y(t.right,e,n))&&(t.topr||Y(t.bottom,r,n))}function q(t){for(let e=0;et!==e);return Array.prototype.push.apply(a,r),q(a)}}else J()}return t}function X(t,e){const r=function(t,e){const r=Math.max(t.left,e.left),n=Math.min(t.right,e.right),o=Math.max(t.top,e.top),i=Math.min(t.bottom,e.bottom);return{bottom:i,height:Math.max(0,i-o),left:r,right:n,top:o,width:Math.max(0,n-r)}}(e,t);if(0===r.height||0===r.width)return[t];const n=[];{const e={bottom:t.bottom,height:0,left:t.left,right:r.left,top:t.top,width:0};e.width=e.right-e.left,e.height=e.bottom-e.top,0!==e.height&&0!==e.width&&n.push(e)}{const e={bottom:r.top,height:0,left:r.left,right:r.right,top:t.top,width:0};e.width=e.right-e.left,e.height=e.bottom-e.top,0!==e.height&&0!==e.width&&n.push(e)}{const e={bottom:t.bottom,height:0,left:r.left,right:r.right,top:r.bottom,width:0};e.width=e.right-e.left,e.height=e.bottom-e.top,0!==e.height&&0!==e.width&&n.push(e)}{const e={bottom:t.bottom,height:0,left:r.right,right:t.right,top:t.top,width:0};e.width=e.right-e.left,e.height=e.bottom-e.top,0!==e.height&&0!==e.width&&n.push(e)}return n}function K(t,e,r){return(t.left=0&&Y(t.left,e.right,r))&&(e.left=0&&Y(e.left,t.right,r))&&(t.top=0&&Y(t.top,e.bottom,r))&&(e.top=0&&Y(e.top,t.bottom,r))}function Y(t,e,r){return Math.abs(t-e)<=r}function J(){}window.addEventListener("scroll",j),document.addEventListener("selectionchange",(S=function(){webkit.messageHandlers.selectionChanged.postMessage(w())},function(){var t=this,e=arguments;clearTimeout(E),E=setTimeout(function(){S.apply(t,e),E=null},50)}));var Q,Z=[],tt=function(){return Z.some(function(t){return t.activeTargets.length>0})},et="ResizeObserver loop completed with undelivered notifications.";!function(t){t.BORDER_BOX="border-box",t.CONTENT_BOX="content-box",t.DEVICE_PIXEL_CONTENT_BOX="device-pixel-content-box"}(Q||(Q={}));var rt,nt=function(t){return Object.freeze(t)},ot=function(t,e){this.inlineSize=t,this.blockSize=e,nt(this)},it=function(){function t(t,e,r,n){return this.x=t,this.y=e,this.width=r,this.height=n,this.top=this.y,this.left=this.x,this.bottom=this.top+this.height,this.right=this.left+this.width,nt(this)}return t.prototype.toJSON=function(){var t=this;return{x:t.x,y:t.y,top:t.top,right:t.right,bottom:t.bottom,left:t.left,width:t.width,height:t.height}},t.fromRect=function(e){return new t(e.x,e.y,e.width,e.height)},t}(),at=function(t){return t instanceof SVGElement&&"getBBox"in t},st=function(t){if(at(t)){var e=t.getBBox(),r=e.width,n=e.height;return!r&&!n}var o=t,i=o.offsetWidth,a=o.offsetHeight;return!(i||a||t.getClientRects().length)},ut=function(t){var e;if(t instanceof Element)return!0;var r=null===(e=null==t?void 0:t.ownerDocument)||void 0===e?void 0:e.defaultView;return!!(r&&t instanceof r.Element)},lt="undefined"!=typeof window?window:{},ct=new WeakMap,ft=/auto|scroll/,pt=/^tb|vertical/,dt=/msie|trident/i.test(lt.navigator&<.navigator.userAgent),yt=function(t){return parseFloat(t||"0")},ht=function(t,e,r){return void 0===t&&(t=0),void 0===e&&(e=0),void 0===r&&(r=!1),new ot((r?e:t)||0,(r?t:e)||0)},gt=nt({devicePixelContentBoxSize:ht(),borderBoxSize:ht(),contentBoxSize:ht(),contentRect:new it(0,0,0,0)}),mt=function(t,e){if(void 0===e&&(e=!1),ct.has(t)&&!e)return ct.get(t);if(st(t))return ct.set(t,gt),gt;var r=getComputedStyle(t),n=at(t)&&t.ownerSVGElement&&t.getBBox(),o=!dt&&"border-box"===r.boxSizing,i=pt.test(r.writingMode||""),a=!n&&ft.test(r.overflowY||""),s=!n&&ft.test(r.overflowX||""),u=n?0:yt(r.paddingTop),l=n?0:yt(r.paddingRight),c=n?0:yt(r.paddingBottom),f=n?0:yt(r.paddingLeft),p=n?0:yt(r.borderTopWidth),d=n?0:yt(r.borderRightWidth),y=n?0:yt(r.borderBottomWidth),h=f+l,g=u+c,m=(n?0:yt(r.borderLeftWidth))+d,b=p+y,v=s?t.offsetHeight-b-t.clientHeight:0,w=a?t.offsetWidth-m-t.clientWidth:0,x=o?h+m:0,S=o?g+b:0,E=n?n.width:yt(r.width)-x-w,A=n?n.height:yt(r.height)-S-v,O=E+h+w+m,j=A+g+v+b,R=nt({devicePixelContentBoxSize:ht(Math.round(E*devicePixelRatio),Math.round(A*devicePixelRatio),i),borderBoxSize:ht(O,j,i),contentBoxSize:ht(E,A,i),contentRect:new it(f,u,E,A)});return ct.set(t,R),R},bt=function(t,e,r){var n=mt(t,r),o=n.borderBoxSize,i=n.contentBoxSize,a=n.devicePixelContentBoxSize;switch(e){case Q.DEVICE_PIXEL_CONTENT_BOX:return a;case Q.BORDER_BOX:return o;default:return i}},vt=function(t){var e=mt(t);this.target=t,this.contentRect=e.contentRect,this.borderBoxSize=nt([e.borderBoxSize]),this.contentBoxSize=nt([e.contentBoxSize]),this.devicePixelContentBoxSize=nt([e.devicePixelContentBoxSize])},wt=function(t){if(st(t))return 1/0;for(var e=0,r=t.parentNode;r;)e+=1,r=r.parentNode;return e},xt=function(){var t=1/0,e=[];Z.forEach(function(r){if(0!==r.activeTargets.length){var n=[];r.activeTargets.forEach(function(e){var r=new vt(e.target),o=wt(e.target);n.push(r),e.lastReportedSize=bt(e.target,e.observedBox),ot?e.activeTargets.push(r):e.skippedTargets.push(r))})})},Et=[],At=0,Ot={attributes:!0,characterData:!0,childList:!0,subtree:!0},jt=["resize","load","transitionend","animationend","animationstart","animationiteration","keyup","keydown","mouseup","mousedown","mouseover","mouseout","blur","focus"],Rt=function(t){return void 0===t&&(t=0),Date.now()+t},Pt=!1,Ct=function(){function t(){var t=this;this.stopped=!0,this.listener=function(){return t.schedule()}}return t.prototype.run=function(t){var e=this;if(void 0===t&&(t=250),!Pt){Pt=!0;var r,n=Rt(t);r=function(){var r=!1;try{r=function(){var t,e=0;for(St(e);tt();)e=xt(),St(e);return Z.some(function(t){return t.skippedTargets.length>0})&&("function"==typeof ErrorEvent?t=new ErrorEvent("error",{message:et}):((t=document.createEvent("Event")).initEvent("error",!1,!1),t.message=et),window.dispatchEvent(t)),e>0}()}finally{if(Pt=!1,t=n-Rt(),!At)return;r?e.run(1e3):t>0?e.run(t):e.start()}},function(t){if(!rt){var e=0,r=document.createTextNode("");new MutationObserver(function(){return Et.splice(0).forEach(function(t){return t()})}).observe(r,{characterData:!0}),rt=function(){r.textContent="".concat(e?e--:e++)}}Et.push(t),rt()}(function(){requestAnimationFrame(r)})}},t.prototype.schedule=function(){this.stop(),this.run()},t.prototype.observe=function(){var t=this,e=function(){return t.observer&&t.observer.observe(document.body,Ot)};document.body?e():lt.addEventListener("DOMContentLoaded",e)},t.prototype.start=function(){var t=this;this.stopped&&(this.stopped=!1,this.observer=new MutationObserver(this.listener),this.observe(),jt.forEach(function(e){return lt.addEventListener(e,t.listener,!0)}))},t.prototype.stop=function(){var t=this;this.stopped||(this.observer&&this.observer.disconnect(),jt.forEach(function(e){return lt.removeEventListener(e,t.listener,!0)}),this.stopped=!0)},t}(),Tt=new Ct,Nt=function(t){!At&&t>0&&Tt.start(),!(At+=t)&&Tt.stop()},Mt=function(){function t(t,e){this.target=t,this.observedBox=e||Q.CONTENT_BOX,this.lastReportedSize={inlineSize:0,blockSize:0}}return t.prototype.isActive=function(){var t,e=bt(this.target,this.observedBox,!0);return t=this.target,at(t)||function(t){switch(t.tagName){case"INPUT":if("image"!==t.type)break;case"VIDEO":case"AUDIO":case"EMBED":case"OBJECT":case"CANVAS":case"IFRAME":case"IMG":return!0}return!1}(t)||"inline"!==getComputedStyle(t).display||(this.lastReportedSize=e),this.lastReportedSize.inlineSize!==e.inlineSize||this.lastReportedSize.blockSize!==e.blockSize},t}(),It=function(t,e){this.activeTargets=[],this.skippedTargets=[],this.observationTargets=[],this.observer=t,this.callback=e},kt=new WeakMap,$t=function(t,e){for(var r=0;r=0&&(o&&Z.splice(Z.indexOf(r),1),r.observationTargets.splice(n,1),Nt(-1))},t.disconnect=function(t){var e=this,r=kt.get(t);r.observationTargets.slice().forEach(function(r){return e.unobserve(t,r.target)}),r.activeTargets.splice(0,r.activeTargets.length)},t}(),_t=function(){function t(t){if(0===arguments.length)throw new TypeError("Failed to construct 'ResizeObserver': 1 argument required, but only 0 present.");if("function"!=typeof t)throw new TypeError("Failed to construct 'ResizeObserver': The callback provided as parameter 1 is not a function.");Ft.connect(this,t)}return t.prototype.observe=function(t,e){if(0===arguments.length)throw new TypeError("Failed to execute 'observe' on 'ResizeObserver': 1 argument required, but only 0 present.");if(!ut(t))throw new TypeError("Failed to execute 'observe' on 'ResizeObserver': parameter 1 is not of type 'Element");Ft.observe(this,t,e)},t.prototype.unobserve=function(t){if(0===arguments.length)throw new TypeError("Failed to execute 'unobserve' on 'ResizeObserver': 1 argument required, but only 0 present.");if(!ut(t))throw new TypeError("Failed to execute 'unobserve' on 'ResizeObserver': parameter 1 is not of type 'Element");Ft.unobserve(this,t)},t.prototype.disconnect=function(){Ft.disconnect(this)},t.toString=function(){return"function ResizeObserver () { [polyfill code] }"},t}();function Bt(t,e){return function(t){if(Array.isArray(t))return t}(t)||function(t,e){var r=null==t?null:"undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(null!=r){var n,o,i,a,s=[],u=!0,l=!1;try{if(i=(r=r.call(t)).next,0===e){if(Object(r)!==r)return;u=!1}else for(;!(u=(n=i.call(r)).done)&&(s.push(n.value),s.length!==e);u=!0);}catch(t){l=!0,o=t}finally{try{if(!u&&null!=r.return&&(a=r.return(),Object(a)!==a))return}finally{if(l)throw o}}return s}}(t,e)||function(t,e){if(t){if("string"==typeof t)return Lt(t,e);var r={}.toString.call(t).slice(8,-1);return"Object"===r&&t.constructor&&(r=t.constructor.name),"Map"===r||"Set"===r?Array.from(t):"Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?Lt(t,e):void 0}}(t,e)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function Lt(t,e){(null==e||e>t.length)&&(e=t.length);for(var r=0,n=Array(e);r{e.width===t.clientWidth&&e.height===t.clientHeight||(e={width:t.clientWidth,height:t.clientHeight},Ut.forEach(function(t){t.requestLayout()}))}).observe(t)},!1);const Gt={id:"id",class:"class",tag:"tag",attribute:"attribute",nthchild:"nthchild",nthoftype:"nthoftype"};function qt(t="unknown problem",...e){console.warn(`CssSelectorGenerator: ${t}`,...e)}const Xt={selectors:[Gt.id,Gt.class,Gt.tag,Gt.attribute],includeTag:!1,whitelist:[],blacklist:[],combineWithinSelector:!0,combineBetweenSelectors:!0,root:null,maxCombinations:Number.POSITIVE_INFINITY,maxCandidates:Number.POSITIVE_INFINITY,useScope:!1,ignoreGeneratedClassNames:!1};function Kt(t){return!!t}function Yt(t){return t instanceof RegExp}function Jt(t){return["string","function"].includes(typeof t)||Yt(t)}function Qt(t){return Array.isArray(t)?t.filter(Jt):[]}function Zt(t){const e=[Node.DOCUMENT_NODE,Node.DOCUMENT_FRAGMENT_NODE,Node.ELEMENT_NODE];return function(t){return null!=t&&"object"==typeof t&&"nodeType"in t&&"number"==typeof t.nodeType}(t)&&e.includes(t.nodeType)}function te(t,e){if(Zt(t))return t.contains(e)||qt("element root mismatch","Provided root does not contain the element. This will most likely result in producing a fallback selector using element's real root node. If you plan to use the selector using provided root (e.g. `root.querySelector`), it will not work as intended."),t;const r=e.getRootNode({composed:!1});return Zt(r)?(r!==document&&qt("shadow root inferred","You did not provide a root and the element is a child of Shadow DOM. This will produce a selector using ShadowRoot as a root. If you plan to use the selector using document as a root (e.g. `document.querySelector`), it will not work as intended."),r):se(e)}function ee(t){return"number"==typeof t?t:Number.POSITIVE_INFINITY}function re(t=[]){const[e=[],...r]=t;return 0===r.length?e:r.reduce((t,e)=>t.filter(t=>e.includes(t)),e)}function ne(t){const e=t.map(t=>{if(Yt(t))return e=>t.test(e);if("function"==typeof t)return e=>{const r=t(e);return"boolean"!=typeof r?(qt("pattern matcher function invalid","Provided pattern matching function does not return boolean. It's result will be ignored.",t),!1):r};if("string"==typeof t){const e=new RegExp("^"+t.replace(/[|\\{}()[\]^$+?.]/g,"\\$&").replace(/\*/g,".+")+"$");return t=>e.test(t)}return qt("pattern matcher invalid","Pattern matching only accepts strings, regular expressions and/or functions. This item is invalid and will be ignored.",t),()=>!1});return t=>e.some(e=>e(t))}function oe(t,e,r){const n=Array.from(te(r,t[0]).querySelectorAll(e));return n.length===t.length&&t.every(t=>n.includes(t))}function ie(t,e){e=null!=e?e:se(t);const r=[];let n=t;for(;n&&n!==e;)Ht(n)&&r.push(n),n=n.parentNode;return r}function ae(t,e){return re(t.map(t=>ie(t,e)))}function se(t){return t.ownerDocument.querySelector(":root")}const ue=new RegExp(["^$","\\s"].join("|")),le=new RegExp(["^$"].join("|")),ce=[Gt.nthoftype,Gt.tag,Gt.id,Gt.class,Gt.attribute,Gt.nthchild],fe=ne(["class","id","ng-*"]);function pe({name:t}){return`[${t}]`}function de({name:t,value:e}){return`[${t}='${e}']`}function ye({nodeName:t,nodeValue:e}){return{name:Ce(t),value:Ce(null!=e?e:void 0)}}function he(t,e){const r=Array.from(t.attributes).filter(e=>function({nodeName:t,nodeValue:e},r){const n=r.tagName.toLowerCase();return!(["input","option"].includes(n)&&"value"===t||"src"===t&&(null==e?void 0:e.startsWith("data:"))||fe(t))}(e,t)).map(ye);return[...r.map(pe),...r.map(de)]}const ge=/^[a-z_-]{3,}$/i,me=/[bcdfghjklmnpqrstvwxyz]{4,}/i;function be(t,e){var r;const n=(null!==(r=t.getAttribute("class"))&&void 0!==r?r:"").trim().split(/\s+/).filter(t=>!le.test(t));let o=n;if(null==e?void 0:e.ignoreGeneratedClassNames){const t=ne(e.whitelist);o=n.filter(e=>{const r=`.${Ce(e)}`;return!!t(r)||function(t){if(!ge.test(t))return!1;if(t.includes("_")&&!t.includes("__"))return!1;if(/^(css|sc|jsx|emotion|makeStyles|MuiButton|MuiBox)-/i.test(t))return!1;const e=t.split(/--|__|[-]|(?<=[a-z])(?=[A-Z])/).filter(t=>t.length>0);if(0===e.length)return!1;if(1===e.length&&e[0].length<4)return!1;for(const t of e){if(t.length<=2)return!1;if(me.test(t))return!1}return!0}(e)})}return o.map(t=>`.${Ce(t)}`)}function ve(t,e){var r;const n=null!==(r=t.getAttribute("id"))&&void 0!==r?r:"",o=`#${Ce(n)}`,i=t.getRootNode({composed:!1});return!ue.test(n)&&oe([t],o,i)?[o]:[]}function we(t,e){const r=t.parentNode,n=r&&"children"in r?r.children:null;if(n)for(let e=0;exe(t)),[].concat(...n)))];var n;return 0===r.length||r.length>1?[]:[r[0]]}function Ee(t,e){const r=Se([t])[0],n=t.parentNode,o=n&&"children"in n?n:null;if(o){const e=Array.from(o.children).filter(t=>t.tagName.toLowerCase()===r),n=e.indexOf(t);if(n>-1)return[`${r}:nth-of-type(${String(n+1)})`]}return[]}function*Ae(t=[],{maxResults:e=Number.POSITIVE_INFINITY}={}){let r=0,n=je(1);for(;n.length<=t.length&&rt[e]);yield e,n=Oe(n,t.length-1)}}function Oe(t=[],e=0){const r=t.length;if(0===r)return[];const n=[...t];n[r-1]+=1;for(let t=r-1;t>=0;t--)if(n[t]>e){if(0===t)return je(r+1);n[t-1]++,n[t]=n[t-1]+1}return n[r-1]>e?je(r+1):n}function je(t=1){return Array.from(Array(t).keys())}const Re=":".charCodeAt(0).toString(16).toUpperCase(),Pe=/[ !"#$%&'()\[\]{|}<>*+,./;=?@^`~\\]/;function Ce(t=""){return CSS?CSS.escape(t):function(t=""){return t.split("").map(t=>":"===t?`\\${Re} `:Pe.test(t)?`\\${t}`:escape(t).replace(/%/g,"\\")).join("")}(t)}const Te={tag:Se,id:function(t,e){return 0===t.length||t.length>1?[]:ve(t[0])},class:function(t,e){return re(t.map(t=>be(t,e)))},attribute:function(t,e){return re(t.map(t=>he(t)))},nthchild:function(t,e){return re(t.map(t=>we(t)))},nthoftype:function(t,e){return re(t.map(t=>Ee(t)))}},Ne={tag:xe,id:ve,class:be,attribute:he,nthchild:we,nthoftype:Ee};function Me(t){return t.includes(Gt.tag)||t.includes(Gt.nthoftype)?[...t]:[...t,Gt.tag]}function*Ie(t,e){const r={};for(const n of t){const t=e[n];t&&t.length>0&&(r[n]=t)}for(const t of function*(t={}){const e=Object.entries(t);if(0===e.length)return;const r=[{index:e.length-1,partial:{}}];for(;r.length>0;){const t=r.pop();if(!t)break;const{index:n,partial:o}=t;if(n<0){yield o;continue}const[i,a]=e[n];for(let t=a.length-1;t>=0;t--)r.push({index:n-1,partial:Object.assign(Object.assign({},o),{[i]:a[t]})})}}(r))yield ke(t)}function ke(t={}){const e=[...ce];return t[Gt.tag]&&t[Gt.nthoftype]&&e.splice(e.indexOf(Gt.tag),1),e.map(e=>{return(n=t)[r=e]?n[r].join(""):"";var r,n}).join("")}function $e(t,e){return[...t.map(t=>e+" "+t),...t.map(t=>e+" > "+t)]}function*Fe(t,e,r="",n){const o=function*(t,e){const r=new Set,n=function(t,e){const{blacklist:r,whitelist:n,combineWithinSelector:o,maxCombinations:i}=e,a=ne(r),s=ne(n);return function(t){const{selectors:e,includeTag:r}=t,n=[...e];return r&&!n.includes("tag")&&n.push("tag"),n}(e).reduce((r,n)=>{const u=function(t,e,r){return(0,Te[e])(t,r)}(t,n,e),l=function(t=[],e,r){return t.filter(t=>r(t)||!e(t))}(u,a,s),c=function(t=[],e){return t.sort((t,r)=>{const n=e(t),o=e(r);return n&&!o?-1:!n&&o?1:0})}(l,s);return r[n]=o?Array.from(Ae(c,{maxResults:i})):c.map(t=>[t]),r},{})}(t,e);for(const t of function*(t,e){for(const r of function(t){const{selectors:e,combineBetweenSelectors:r,includeTag:n,maxCandidates:o}=t,i=r?function(t=[],{maxResults:e=Number.POSITIVE_INFINITY}={}){return Array.from(Ae(t,{maxResults:e}))}(e,{maxResults:o}):e.map(t=>[t]);return n?i.map(Me):i}(e))yield*Ie(r,t)}(n,e))r.has(t)||(r.add(t),yield t)}(t,n);for(const n of function*(t,e){if(""===e)yield*t;else for(const r of t)yield*$e([r],e)}(o,r))oe(t,n,e)&&(yield n)}function*_e(t,e,r="",n){if(0===t.length)return null;const o=[t.length>1?t:[],...ae(t,e).map(t=>[t])];for(const t of o)for(const o of Fe(t,e,r,n))yield{foundElements:t,selector:o}}function Be(t){return{value:t,include:!1}}function Le({selectors:t,operator:e}){let r=[...ce];t[Gt.tag]&&t[Gt.nthoftype]&&(r=r.filter(t=>t!==Gt.tag));let n="";return r.forEach(e=>{var r;(null!==(r=t[e])&&void 0!==r?r:[]).forEach(({value:t,include:e})=>{e&&(n+=t)})}),e+n}function De(t,e){return t.map(t=>function(t,e){const r=ie(t,e).reverse(),n=e instanceof ShadowRoot,o=r.map((t,e)=>{var r;const o=function(t,e,r=""){const n={};return e.forEach(e=>{Reflect.set(n,e,function(t,e){return Ne[e](t,void 0)}(t,e).map(Be))}),{element:t,operator:r,selectors:n}}(t,[Gt.nthchild],n&&0===e?"":" > ");return(null!==(r=o.selectors.nthchild)&&void 0!==r?r:[]).forEach(t=>{t.include=!0}),o});return[n?"":e?":scope":":root",...o.map(Le)].join("")}(t,e)).join(", ")}function We(t,e={}){const r=function*(t,e={}){var r;const n=function(t){(t instanceof NodeList||t instanceof HTMLCollection)&&(t=Array.from(t));const e=(Array.isArray(t)?t:[t]).filter(Ht);return[...new Set(e)]}(t),o=function(t,e={}){const r=Object.assign(Object.assign({},Xt),e);return{selectors:(n=r.selectors,Array.isArray(n)?n.filter(t=>{return e=Gt,r=t,Object.values(e).includes(r);var e,r}):[]),whitelist:Qt(r.whitelist),blacklist:Qt(r.blacklist),root:te(r.root,t),combineWithinSelector:Kt(r.combineWithinSelector),combineBetweenSelectors:Kt(r.combineBetweenSelectors),includeTag:Kt(r.includeTag),maxCombinations:ee(r.maxCombinations),maxCandidates:ee(r.maxCandidates),useScope:Kt(r.useScope),maxResults:ee(r.maxResults),ignoreGeneratedClassNames:Kt(r.ignoreGeneratedClassNames)};var n}(n[0],e),i=null!==(r=o.root)&&void 0!==r?r:se(n[0]);let a=0;for(const t of function*({elements:t,root:e,rootSelector:r="",options:n}){let o=e,i=r,a=!0;for(;a;){let r=!1;for(const a of _e(t,o,i,n)){const{foundElements:n,selector:s}=a;if(r=!0,!oe(t,s,e)){o=n[0],i=s;break}yield s}r||(a=!1)}}({elements:n,options:o,root:i,rootSelector:""}))if(yield t,a++,a>=o.maxResults)return;if(n.length>1){const{maxResults:t}=e,r=function(t,e){var r={};for(var n in t)Object.prototype.hasOwnProperty.call(t,n)&&e.indexOf(n)<0&&(r[n]=t[n]);if(null!=t&&"function"==typeof Object.getOwnPropertySymbols){var o=0;for(n=Object.getOwnPropertySymbols(t);oWe(t,r)).join(", "),a++,a>=o.maxResults)return}const s=void 0!==e.root;yield De(n,o.useScope||s?i:void 0)}(t,Object.assign(Object.assign({},e),{maxResults:1}));return r.next().value}function Ue(t){return null==t?null:-1!==["a","audio","button","canvas","details","input","label","option","select","submit","textarea","video"].indexOf(t.nodeName.toLowerCase())||t.hasAttribute("contenteditable")&&"false"!=t.getAttribute("contenteditable").toLowerCase()?t.outerHTML:t.parentElement?Ue(t.parentElement):null}function ze(t){for(var e=0;e0&&e.top0&&e.leftt.length>0).map(e=>t.ownerDocument.getElementById(e)).filter(t=>null!=t).map(t=>{var e,r;return(null===(e=t.getAttribute("aria-label"))||void 0===e?void 0:e.trim())||(null===(r=t.textContent)||void 0===r?void 0:r.replace(/\s+/g," ").trim())||""}).filter(t=>t.length>0).join(" ")||null}function qe(t,e){var r;const n=t.querySelector(`:scope > ${e}`);return(null==n||null===(r=n.textContent)||void 0===r?void 0:r.replace(/\s+/g," ").trim())||null}function Xe(t){var e,r;const n=null===(e=t.closest("figure"))||void 0===e?void 0:e.querySelector(":scope > figcaption");return!n||n.contains(t)?null:(null===(r=n.textContent)||void 0===r?void 0:r.replace(/\s+/g," ").trim())||null}let Ke=!1;function Ye(t){if(!getSelection().isCollapsed)return;let e=W({x:t.clientX,y:t.clientY}),r={defaultPrevented:t.defaultPrevented,x:e.x,y:e.y,targetElement:t.target.outerHTML,interactiveElement:Ue(t.target)};(function(t,e){let r=Vt(t);return!!r&&(webkit.messageHandlers.decorationActivated.postMessage({id:r.item.decoration.id,group:r.group,rect:D(r.item.range.getBoundingClientRect()),click:e}),!0)})(t,r)||webkit.messageHandlers.tap.postMessage(r)}function Je(t){er("down",t)}function Qe(t){er("up",t)}function Ze(t){er("move",t)}function tr(t){er("cancel",t)}function er(t,e){var r,n;Ke&&(t="cancel"),"move"!=t&&(r=Ue(e.target),n=function(t){var e,r;if(!t||!t.getBoundingClientRect)return null;let n=function(t){const e=["img","svg"];let r=t;for(;r&&r!==document.documentElement;){if(e.includes(r.tagName.toLowerCase()))return r;r=r.parentElement}return null}(t);if(!n)return null;let o=n.getBoundingClientRect(),i=W({x:o.left,y:o.top}),a=n.getAttribute("src")||n.getAttribute("href")||null,s=a?new URL(a,document.baseURI).href:null,u=s?null:n.outerHTML,l=function(t){var e,r,n;const o=t.tagName.toLowerCase(),i=(null===(e=t.getAttribute("title"))||void 0===e?void 0:e.trim())||null,a=null===(r=t.getAttribute("role"))||void 0===r?void 0:r.toLowerCase().split(/\s+/).find(t=>t.length>0),s=t.hasAttribute("aria-label")||t.hasAttribute("aria-labelledby")||t.hasAttribute("aria-describedby")||t.hasAttribute("aria-description");if("true"===(null===(n=t.getAttribute("aria-hidden"))||void 0===n?void 0:n.toLowerCase())||("presentation"===a||"none"===a)&&!s)return{name:null,description:null};let u=null,l=!1;var c;u=Ge(t,"aria-labelledby"),u||(u=(null===(c=t.getAttribute("aria-label"))||void 0===c?void 0:c.trim())||null),u||("img"===o?t.hasAttribute("alt")&&(u=t.getAttribute("alt").trim()||null,u||(l=!0)):"svg"===o&&(u=qe(t,"title")));let f=!1;u||l||!i||(u=i,f=!0),u||"img"!==o||t.hasAttribute("alt")||t.hasAttribute("title")||(u=function(t){const e=t.closest("figure"),r=null==e?void 0:e.querySelector(":scope > figcaption");if(!e||!r)return null;const n=t=>{var e;return null!==(e=null==t?void 0:t.replace(/\s+/g," ").trim())&&void 0!==e?e:""};if(n(e.textContent)!==n(r.textContent))return null;const o=e.querySelectorAll("img, svg, audio, video, object, iframe, embed");for(let e=0;e desc")?p=qe(t,"desc"):f||(p=i),{name:u,description:p}}(n);return{tag:n.tagName.toLowerCase(),html:u,src:s,resourceHref:null!==(e=null===(r=window.readium)||void 0===r||null===(r=r.link)||void 0===r?void 0:r.href)&&void 0!==e?e:null,frame:{x:i.x,y:i.y,width:o.width,height:o.height},accessibleName:l.name,accessibleDescription:l.description,caption:Xe(n),cssSelector:We(n)}}(e.target));let o=W({x:e.clientX,y:e.clientY}),i={phase:t,defaultPrevented:e.defaultPrevented,pointerId:e.pointerId,pointerType:e.pointerType,x:o.x,y:o.y,buttons:e.buttons,interactiveElement:r,targetElement:n,option:e.altKey,control:e.ctrlKey,shift:e.shiftKey,command:e.metaKey};null==Vt(e)&&webkit.messageHandlers.pointerEventReceived.postMessage(i)}function rr(t){return t.defaultPrevented||null!=Ue(document.activeElement)}function nr(t){t.stopPropagation(),t.preventDefault()}function or(t,e){e.repeat||webkit.messageHandlers.keyEventReceived.postMessage({phase:t,code:e.code,key:String.fromCharCode(e.keyCode),option:e.altKey,control:e.ctrlKey,shift:e.shiftKey,command:e.metaKey})}window.addEventListener("DOMContentLoaded",function(){document.addEventListener("click",Ye,!1),document.addEventListener("pointerdown",Je,!1),document.addEventListener("pointerup",Qe,!1),document.addEventListener("pointermove",Ze,!1),document.addEventListener("pointercancel",tr,!1),document.addEventListener("selectionchange",function(){Ke=!window.getSelection().isCollapsed})}),window.addEventListener("keydown",t=>{rr(t)||(nr(t),or("down",t))}),window.addEventListener("keyup",t=>{rr(t)||(nr(t),or("up",t))}),globalThis.readium={scrollToId:function(t,e){let r=document.getElementById(t);return!!r&&(T(r.getBoundingClientRect(),e),!0)},scrollToPosition:function(t,e,r){t<0||t>1?console.error(`Expected a valid progression in scrollToPosition, got ${t}`):R()?P()?M({left:-document.scrollingElement.scrollWidth*t,animated:r}):M({top:document.scrollingElement.scrollHeight*t,animated:r}):M({left:I(document.scrollingElement.scrollWidth*t*("rtl"==e?-1:1)),animated:r})},scrollToLocator:function(t,e){let r=k(t);return!!r&&function(t,e){return T(t.getBoundingClientRect(),e)}(r,e)},scrollLeft:function(t,e){var r="rtl"==t,n=document.scrollingElement.scrollWidth,o=window.innerWidth,i=window.scrollX-o,a=r?-(n-o):0;return N(Math.max(i,a),e)},scrollRight:function(t,e){var r="rtl"==t,n=document.scrollingElement.scrollWidth,o=window.innerWidth,i=window.scrollX+o,a=r?0:n-o;return N(Math.min(i,a),e)},setCSSProperties:function(t){for(const e in t)$(e,t[e])},setProperty:$,removeProperty:F,registerDecorationTemplates:function(t){var e="";for(const n of Object.entries(t)){var r=Bt(n,2);const t=r[0],o=r[1];Wt.set(t,o),o.stylesheet&&(e+=o.stylesheet+"\n")}if(e){let t=document.createElement("style");t.innerHTML=e,document.getElementsByTagName("head")[0].appendChild(t)}},getDecorations:function(t){var e=Ut.get(t);return e||(e=function(t,e){var r=[],n=0,o=null,i=!1;function a(e){let o=t+"-"+n++,i=k(e.locator);if(!i)return void _("Can't locate DOM range for decoration",e);let a={id:o,decoration:e,range:i};r.push(a),u(a)}function s(t){let e=r.findIndex(e=>e.decoration.id===t);if(-1===e)return;let n=r[e];r.splice(e,1),n.clickableElements=null,n.container&&(n.container.remove(),n.container=null)}function u(r){let n=(o||((o=document.createElement("div")).id=t,o.dataset.group=e,o.style.pointerEvents="none",requestAnimationFrame(function(){null!=o&&document.body.append(o)})),o),i=Wt.get(r.decoration.style);if(!i)return void B(`Unknown decoration style: ${r.decoration.style}`);let a=document.createElement("div");a.id=r.id,a.dataset.style=r.decoration.style,a.style.pointerEvents="none";const s=getComputedStyle(document.body).writingMode,u="vertical-rl"===s||"vertical-lr"===s,l=document.scrollingElement,c=l.scrollLeft,f=l.scrollTop,p=u?window.innerHeight:window.innerWidth,d=u?window.innerWidth:window.innerHeight,y=parseInt(getComputedStyle(document.documentElement).getPropertyValue("column-count"))||1,h=(u?d:p)/y;function g(t,e,r,n){t.style.position="absolute";const o="vertical-rl"===n;if(o||"vertical-lr"===n){if("wrap"===i.width)t.style.width=`${e.width}px`,t.style.height=`${e.height}px`,o?t.style.right=`${-e.right-c+l.clientWidth}px`:t.style.left=`${e.left+c}px`,t.style.top=`${e.top+f}px`;else if("viewport"===i.width){t.style.width=`${e.height}px`,t.style.height=`${p}px`;const r=Math.floor(e.top/p)*p;o?t.style.right=-e.right-c+"px":t.style.left=`${e.left+c}px`,t.style.top=`${r+f}px`}else if("bounds"===i.width)t.style.width=`${r.height}px`,t.style.height=`${p}px`,o?t.style.right=`${-r.right-c+l.clientWidth}px`:t.style.left=`${r.left+c}px`,t.style.top=`${r.top+f}px`;else if("page"===i.width){t.style.width=`${e.height}px`,t.style.height=`${h}px`;const r=Math.floor(e.top/h)*h;o?t.style.right=`${-e.right-c+l.clientWidth}px`:t.style.left=`${e.left+c}px`,t.style.top=`${r+f}px`}}else if("wrap"===i.width)t.style.width=`${e.width}px`,t.style.height=`${e.height}px`,t.style.left=`${e.left+c}px`,t.style.top=`${e.top+f}px`;else if("viewport"===i.width){t.style.width=`${p}px`,t.style.height=`${e.height}px`;const r=Math.floor(e.left/p)*p;t.style.left=`${r+c}px`,t.style.top=`${e.top+f}px`}else if("bounds"===i.width)t.style.width=`${r.width}px`,t.style.height=`${e.height}px`,t.style.left=`${r.left+c}px`,t.style.top=`${e.top+f}px`;else if("page"===i.width){t.style.width=`${h}px`,t.style.height=`${e.height}px`;const r=Math.floor(e.left/h)*h;t.style.left=`${r+c}px`,t.style.top=`${e.top+f}px`}}let m,b=r.range.getBoundingClientRect();try{let t=document.createElement("template");t.innerHTML=r.decoration.element.trim(),m=t.content.firstElementChild}catch(t){return void B(`Invalid decoration element "${r.decoration.element}": ${t.message}`)}if("boxes"===i.layout){const t=!s.startsWith("vertical"),e=(v=r.range.startContainer).nodeType===Node.ELEMENT_NODE?v:v.parentElement,n=getComputedStyle(e).writingMode,o=U(r.range,t).sort((t,e)=>t.top!==e.top?t.top-e.top:"vertical-rl"===n?e.left-t.left:t.left-e.left);for(let t of o){const e=m.cloneNode(!0);e.style.pointerEvents="none",e.dataset.writingMode=n,g(e,t,b,s),a.append(e)}}else if("bounds"===i.layout){const t=m.cloneNode(!0);t.style.pointerEvents="none",t.dataset.writingMode=s,g(t,b,b,s),a.append(t)}var v;n.append(a),r.container=a,r.clickableElements=Array.from(a.querySelectorAll("[data-activable='1']")),0===r.clickableElements.length&&(r.clickableElements=Array.from(a.children))}function l(){o&&(o.remove(),o=null)}return{add:a,remove:s,update:function(t){s(t.id),a(t)},clear:function(){l(),r.length=0},items:r,requestLayout:function(){l(),r.forEach(t=>u(t))},isActivable:function(){return i},setActivable:function(){i=!0}}}("r2-decoration-"+zt++,t),Ut.set(t,e)),e},findFirstVisibleLocator:function(){const t=ze(document.body);return{href:"#",type:"application/xhtml+xml",locations:{cssSelector:We(t)},text:{highlight:t.textContent}}}},window.readium.isFixedLayout=!0,webkit.messageHandlers.spreadLoadStarted.postMessage({})})()})(); //# sourceMappingURL=readium-fixed.js.map \ No newline at end of file diff --git a/Sources/Navigator/EPUB/Assets/Static/scripts/readium-reflowable.js b/Sources/Navigator/EPUB/Assets/Static/scripts/readium-reflowable.js index c538b75b1b..4d54a5a040 100644 --- a/Sources/Navigator/EPUB/Assets/Static/scripts/readium-reflowable.js +++ b/Sources/Navigator/EPUB/Assets/Static/scripts/readium-reflowable.js @@ -1,2 +1,2 @@ -(()=>{var t={3618(t,e){"use strict";function r(t){return t.split("").reverse().join("")}function n(t){return(t|-t)>>31&1}function o(t,e,r,o){var i=t.P[r],a=t.M[r],s=o>>>31,u=e[r]|s,l=u|a,c=(u&i)+i^i|u,f=a|~(c|i),p=i&c,d=n(f&t.lastRowMask[r])-n(p&t.lastRowMask[r]);return f<<=1,p<<=1,i=(p|=s)|~(l|(f|=n(o)-s)),a=f&l,t.P[r]=i,t.M[r]=a,d}function i(t,e,r){if(0===e.length)return[];r=Math.min(r,e.length);var n=[],i=32,a=Math.ceil(e.length/i)-1,s={P:new Uint32Array(a+1),M:new Uint32Array(a+1),lastRowMask:new Uint32Array(a+1)};s.lastRowMask.fill(1<<31),s.lastRowMask[a]=1<<(e.length-1)%i;for(var u=new Uint32Array(a+1),l=new Map,c=[],f=0;f<256;f++)c.push(u);for(var p=0;p=e.length||e.charCodeAt(m)===d&&(y[h]|=1<0&&v[b]>=r+i;)b-=1;b===a&&v[b]<=r&&(v[b]0?r:0,!0)},o?o(t.exports,"apply",{value:a}):t.exports.apply=a},5298(t,e,r){"use strict";var n=r(703),o=r(5312),i=o([n("%String.prototype.indexOf%")]);t.exports=function(t,e){var r=n(t,!!e);return"function"==typeof r&&i(t,".prototype.")>-1?o([r]):r}},7517(t,e,r){"use strict";var n=r(9173),o=r(7388),i=r(7379),a=r(3492);t.exports=function(t,e,r){if(!t||"object"!=typeof t&&"function"!=typeof t)throw new i("`obj` must be an object or a function`");if("string"!=typeof e&&"symbol"!=typeof e)throw new i("`property` must be a string or a symbol`");if(arguments.length>3&&"boolean"!=typeof arguments[3]&&null!==arguments[3])throw new i("`nonEnumerable`, if provided, must be a boolean or null");if(arguments.length>4&&"boolean"!=typeof arguments[4]&&null!==arguments[4])throw new i("`nonWritable`, if provided, must be a boolean or null");if(arguments.length>5&&"boolean"!=typeof arguments[5]&&null!==arguments[5])throw new i("`nonConfigurable`, if provided, must be a boolean or null");if(arguments.length>6&&"boolean"!=typeof arguments[6])throw new i("`loose`, if provided, must be a boolean");var s=arguments.length>3?arguments[3]:null,u=arguments.length>4?arguments[4]:null,l=arguments.length>5?arguments[5]:null,c=arguments.length>6&&arguments[6],f=!!a&&a(t,e);if(n)n(t,e,{configurable:null===l&&f?f.configurable:!l,enumerable:null===s&&f?f.enumerable:!s,value:r,writable:null===u&&f?f.writable:!u});else{if(!c&&(s||u||l))throw new o("This environment does not support defining a property as non-configurable, non-writable, or non-enumerable.");t[e]=r}}},8189(t,e,r){"use strict";var n=r(1748),o="function"==typeof Symbol&&"symbol"==typeof Symbol("foo"),i=Object.prototype.toString,a=Array.prototype.concat,s=r(7517),u=r(708)(),l=function(t,e,r,n){if(e in t)if(!0===n){if(t[e]===r)return}else if("function"!=typeof(o=n)||"[object Function]"!==i.call(o)||!n())return;var o;u?s(t,e,r,!0):s(t,e,r)},c=function(t,e){var r=arguments.length>2?arguments[2]:{},i=n(e);o&&(i=a.call(i,Object.getOwnPropertySymbols(e)));for(var s=0;s0&&arguments[1]||"Cannot call method on "+t);return t}},9253(t){"use strict";t.exports=Object},4938(t){"use strict";t.exports=function(t){return!!t&&("function"==typeof t||"object"==typeof t)}},3148(t,e,r){"use strict";var n=r(703)("%Object.defineProperty%",!0),o=r(6618)(),i=r(9939),a=r(7379),s=o?Symbol.toStringTag:null;t.exports=function(t,e){var r=arguments.length>2&&!!arguments[2]&&arguments[2].force,o=arguments.length>2&&!!arguments[2]&&arguments[2].nonConfigurable;if(void 0!==r&&"boolean"!=typeof r||void 0!==o&&"boolean"!=typeof o)throw new a("if provided, the `overrideIfSet` and `nonConfigurable` options must be booleans");!s||!r&&i(t,s)||(n?n(t,s,{configurable:!o,enumerable:!1,value:e,writable:!1}):t[s]=e)}},2632(t,e,r){"use strict";var n="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator,o=r(2719),i=r(5833),a=r(1718),s=r(7379),u=r(5465),l=r(7377);t.exports=function(t){if(u(t))return t;var e,r="default";if(arguments.length>1&&(arguments[1]===String?r="string":arguments[1]===Number&&(r="number")),n&&(Symbol.toPrimitive?e=l(t,Symbol.toPrimitive):a(t)&&(e=Symbol.prototype.valueOf)),void 0!==e){var c=e.call(t,r);if(u(c))return c;throw new s("unable to convert exotic object to primitive")}return"default"===r&&(i(t)||a(t))&&(r="string"),function(t,e){if(null==t)throw new s("Cannot call method on "+t);if("string"!=typeof e||"number"!==e&&"string"!==e)throw new s('hint must be "string" or "number"');var r,n,i,a="string"===e?["toString","valueOf"]:["valueOf","toString"];for(i=0;i1&&"boolean"!=typeof e)throw new c('"allowMissing" argument must be a boolean');if(null===z(/^%?[^%]*%?$/,t))throw new l("`%` may not be present anywhere but at the beginning and end of the intrinsic name");var r=function(t){var e=U(t,0,1),r=U(t,-1);if("%"===e&&"%"!==r)throw new l("invalid intrinsic syntax, expected closing `%`");if("%"===r&&"%"!==e)throw new l("invalid intrinsic syntax, expected opening `%`");var n=[];return W(t,H,function(t,e,r,o){n[n.length]=r?W(o,V,"$1"):e||t}),n}(t),n=r.length>0?r[0]:"",o=G("%"+n+"%",e),i=o.name,a=o.value,s=!1,u=o.alias;u&&(n=u[0],D(r,L([0,1],u)));for(var f=1,p=!0;f=r.length){var g=x(a,d);a=(p=!!g)&&"get"in g&&!("originalValue"in g.get)?g.get:a[d]}else p=B(a,d),a=a[d];p&&!s&&(I[i]=a)}}return a}},8819(t,e,r){"use strict";var n=r(9253);t.exports=n.getPrototypeOf||null},2517(t){"use strict";t.exports="undefined"!=typeof Reflect&&Reflect.getPrototypeOf||null},1611(t,e,r){"use strict";var n=r(2517),o=r(8819),i=r(1449);t.exports=n?function(t){return n(t)}:o?function(t){if(!t||"object"!=typeof t&&"function"!=typeof t)throw new TypeError("getProto: not an object");return o(t)}:i?function(t){return i(t)}:null},4656(t){"use strict";t.exports=Object.getOwnPropertyDescriptor},3492(t,e,r){"use strict";var n=r(4656);if(n)try{n([],"length")}catch(t){n=null}t.exports=n},708(t,e,r){"use strict";var n=r(9173),o=function(){return!!n};o.hasArrayLengthDefineBug=function(){if(!n)return null;try{return 1!==n([],"length",{value:1}).length}catch(t){return!0}},t.exports=o},6052(t){"use strict";var e={__proto__:null,foo:{}},r={__proto__:e}.foo===e.foo&&!(e instanceof Object);t.exports=function(){return r}},7657(t,e,r){"use strict";var n="undefined"!=typeof Symbol&&Symbol,o=r(8123);t.exports=function(){return"function"==typeof n&&"function"==typeof Symbol&&"symbol"==typeof n("foo")&&"symbol"==typeof Symbol("bar")&&o()}},8123(t){"use strict";t.exports=function(){if("function"!=typeof Symbol||"function"!=typeof Object.getOwnPropertySymbols)return!1;if("symbol"==typeof Symbol.iterator)return!0;var t={},e=Symbol("test"),r=Object(e);if("string"==typeof e)return!1;if("[object Symbol]"!==Object.prototype.toString.call(e))return!1;if("[object Symbol]"!==Object.prototype.toString.call(r))return!1;for(var n in t[e]=42,t)return!1;if("function"==typeof Object.keys&&0!==Object.keys(t).length)return!1;if("function"==typeof Object.getOwnPropertyNames&&0!==Object.getOwnPropertyNames(t).length)return!1;var o=Object.getOwnPropertySymbols(t);if(1!==o.length||o[0]!==e)return!1;if(!Object.prototype.propertyIsEnumerable.call(t,e))return!1;if("function"==typeof Object.getOwnPropertyDescriptor){var i=Object.getOwnPropertyDescriptor(t,e);if(42!==i.value||!0!==i.enumerable)return!1}return!0}},6618(t,e,r){"use strict";var n=r(8123);t.exports=function(){return n()&&!!Symbol.toStringTag}},9939(t,e,r){"use strict";var n=Function.prototype.call,o=Object.prototype.hasOwnProperty,i=r(3583);t.exports=i.call(n,o)},6561(t,e,r){"use strict";var n=r(9939),o=r(6746)(),i=r(7379),a={assert:function(t,e){if(!t||"object"!=typeof t&&"function"!=typeof t)throw new i("`O` is not an object");if("string"!=typeof e)throw new i("`slot` must be a string");if(o.assert(t),!a.has(t,e))throw new i("`"+e+"` is not present on `O`")},get:function(t,e){if(!t||"object"!=typeof t&&"function"!=typeof t)throw new i("`O` is not an object");if("string"!=typeof e)throw new i("`slot` must be a string");var r=o.get(t);return r&&r["$"+e]},has:function(t,e){if(!t||"object"!=typeof t&&"function"!=typeof t)throw new i("`O` is not an object");if("string"!=typeof e)throw new i("`slot` must be a string");var r=o.get(t);return!!r&&n(r,"$"+e)},set:function(t,e,r){if(!t||"object"!=typeof t&&"function"!=typeof t)throw new i("`O` is not an object");if("string"!=typeof e)throw new i("`slot` must be a string");var n=o.get(t);n||(n={},o.set(t,n)),n["$"+e]=r}};Object.freeze&&Object.freeze(a),t.exports=a},2719(t){"use strict";var e,r,n=Function.prototype.toString,o="object"==typeof Reflect&&null!==Reflect&&Reflect.apply;if("function"==typeof o&&"function"==typeof Object.defineProperty)try{e=Object.defineProperty({},"length",{get:function(){throw r}}),r={},o(function(){throw 42},null,e)}catch(t){t!==r&&(o=null)}else o=null;var i=/^\s*class\b/,a=function(t){try{var e=n.call(t);return i.test(e)}catch(t){return!1}},s=function(t){try{return!a(t)&&(n.call(t),!0)}catch(t){return!1}},u=Object.prototype.toString,l="function"==typeof Symbol&&!!Symbol.toStringTag,c=!(0 in[,]),f=function(){return!1};if("object"==typeof document){var p=document.all;u.call(p)===u.call(document.all)&&(f=function(t){if((c||!t)&&(void 0===t||"object"==typeof t))try{var e=u.call(t);return("[object HTMLAllCollection]"===e||"[object HTML document.all class]"===e||"[object HTMLCollection]"===e||"[object Object]"===e)&&null==t("")}catch(t){}return!1})}t.exports=o?function(t){if(f(t))return!0;if(!t)return!1;if("function"!=typeof t&&"object"!=typeof t)return!1;try{o(t,null,e)}catch(t){if(t!==r)return!1}return!a(t)&&s(t)}:function(t){if(f(t))return!0;if(!t)return!1;if("function"!=typeof t&&"object"!=typeof t)return!1;if(l)return s(t);if(a(t))return!1;var e=u.call(t);return!("[object Function]"!==e&&"[object GeneratorFunction]"!==e&&!/^\[object HTML/.test(e))&&s(t)}},5833(t,e,r){"use strict";var n=r(5298),o=n("Date.prototype.getDay"),i=n("Object.prototype.toString"),a=r(6618)();t.exports=function(t){return"object"==typeof t&&null!==t&&(a?function(t){try{return o(t),!0}catch(t){return!1}}(t):"[object Date]"===i(t))}},4587(t,e,r){"use strict";var n,o=r(5298),i=r(6618)(),a=r(9939),s=r(3492);if(i){var u=o("RegExp.prototype.exec"),l={},c=function(){throw l},f={toString:c,valueOf:c};"symbol"==typeof Symbol.toPrimitive&&(f[Symbol.toPrimitive]=c),n=function(t){if(!t||"object"!=typeof t)return!1;var e=s(t,"lastIndex");if(!e||!a(e,"value"))return!1;try{u(t,f)}catch(t){return t===l}}}else{var p=o("Object.prototype.toString");n=function(t){return!(!t||"object"!=typeof t&&"function"!=typeof t)&&"[object RegExp]"===p(t)}}t.exports=n},1718(t,e,r){"use strict";var n=r(5298),o=n("Object.prototype.toString"),i=r(7657)(),a=r(5537);if(i){var s=n("Symbol.prototype.toString"),u=a(/^Symbol\(.*\)$/);t.exports=function(t){if("symbol"==typeof t)return!0;if(!t||"object"!=typeof t||"[object Symbol]"!==o(t))return!1;try{return function(t){return"symbol"==typeof t.valueOf()&&u(s(t))}(t)}catch(t){return!1}}}else t.exports=function(t){return!1}},9895(t){"use strict";t.exports=Math.abs},6241(t){"use strict";t.exports=Number.MAX_SAFE_INTEGER||9007199254740991},2153(t){"use strict";t.exports=Math.floor},1084(t,e,r){"use strict";var n=r(5518);t.exports=function(t){return("number"==typeof t||"bigint"==typeof t)&&!n(t)&&t!==1/0&&t!==-1/0}},1029(t,e,r){"use strict";var n=r(9895),o=r(2153),i=r(5518),a=r(1084);t.exports=function(t){if("number"!=typeof t||i(t)||!a(t))return!1;var e=n(t);return o(e)===e}},5518(t){"use strict";t.exports=Number.isNaN||function(t){return t!=t}},457(t){"use strict";t.exports=Math.max},1179(t){"use strict";t.exports=Math.min},5985(t){"use strict";t.exports=Math.pow},8639(t){"use strict";t.exports=Math.round},5738(t,e,r){"use strict";var n=r(5518);t.exports=function(t){return n(t)||0===t?t:t<0?-1:1}},4922(t,e,r){var n="function"==typeof Map&&Map.prototype,o=Object.getOwnPropertyDescriptor&&n?Object.getOwnPropertyDescriptor(Map.prototype,"size"):null,i=n&&o&&"function"==typeof o.get?o.get:null,a=n&&Map.prototype.forEach,s="function"==typeof Set&&Set.prototype,u=Object.getOwnPropertyDescriptor&&s?Object.getOwnPropertyDescriptor(Set.prototype,"size"):null,l=s&&u&&"function"==typeof u.get?u.get:null,c=s&&Set.prototype.forEach,f="function"==typeof WeakMap&&WeakMap.prototype?WeakMap.prototype.has:null,p="function"==typeof WeakSet&&WeakSet.prototype?WeakSet.prototype.has:null,d="function"==typeof WeakRef&&WeakRef.prototype?WeakRef.prototype.deref:null,y=Boolean.prototype.valueOf,h=Object.prototype.toString,g=Function.prototype.toString,m=String.prototype.match,b=String.prototype.slice,v=String.prototype.replace,w=String.prototype.toUpperCase,x=String.prototype.toLowerCase,S=RegExp.prototype.test,E=Array.prototype.concat,A=Array.prototype.join,O=Array.prototype.slice,j=Math.floor,R="function"==typeof BigInt?BigInt.prototype.valueOf:null,P=Object.getOwnPropertySymbols,T="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?Symbol.prototype.toString:null,C="function"==typeof Symbol&&"object"==typeof Symbol.iterator,N="function"==typeof Symbol&&Symbol.toStringTag&&(Symbol.toStringTag,1)?Symbol.toStringTag:null,M=Object.prototype.propertyIsEnumerable,I=("function"==typeof Reflect?Reflect.getPrototypeOf:Object.getPrototypeOf)||([].__proto__===Array.prototype?function(t){return t.__proto__}:null);function k(t,e){if(t===1/0||t===-1/0||t!=t||t&&t>-1e3&&t<1e3||S.call(/e/,e))return e;var r=/[0-9](?=(?:[0-9]{3})+(?![0-9]))/g;if("number"==typeof t){var n=t<0?-j(-t):j(t);if(n!==t){var o=String(n),i=b.call(e,o.length+1);return v.call(o,r,"$&_")+"."+v.call(v.call(i,/([0-9]{3})/g,"$&_"),/_$/,"")}}return v.call(e,r,"$&_")}var $=r(2179),F=$.custom,_=V(F)?F:null,B={__proto__:null,double:'"',single:"'"},L={__proto__:null,double:/(["\\])/g,single:/(['\\])/g};function D(t,e,r){var n=r.quoteStyle||e,o=B[n];return o+t+o}function W(t){return v.call(String(t),/"/g,""")}function U(t){return!N||!("object"==typeof t&&(N in t||void 0!==t[N]))}function z(t){return"[object Array]"===X(t)&&U(t)}function H(t){return"[object RegExp]"===X(t)&&U(t)}function V(t){if(C)return t&&"object"==typeof t&&t instanceof Symbol;if("symbol"==typeof t)return!0;if(!t||"object"!=typeof t||!T)return!1;try{return T.call(t),!0}catch(t){}return!1}t.exports=function t(e,r,n,o){var s=r||{};if(q(s,"quoteStyle")&&!q(B,s.quoteStyle))throw new TypeError('option "quoteStyle" must be "single" or "double"');if(q(s,"maxStringLength")&&("number"==typeof s.maxStringLength?s.maxStringLength<0&&s.maxStringLength!==1/0:null!==s.maxStringLength))throw new TypeError('option "maxStringLength", if provided, must be a positive integer, Infinity, or `null`');var u=!q(s,"customInspect")||s.customInspect;if("boolean"!=typeof u&&"symbol"!==u)throw new TypeError("option \"customInspect\", if provided, must be `true`, `false`, or `'symbol'`");if(q(s,"indent")&&null!==s.indent&&"\t"!==s.indent&&!(parseInt(s.indent,10)===s.indent&&s.indent>0))throw new TypeError('option "indent" must be "\\t", an integer > 0, or `null`');if(q(s,"numericSeparator")&&"boolean"!=typeof s.numericSeparator)throw new TypeError('option "numericSeparator", if provided, must be `true` or `false`');var h=s.numericSeparator;if(void 0===e)return"undefined";if(null===e)return"null";if("boolean"==typeof e)return e?"true":"false";if("string"==typeof e)return Y(e,s);if("number"==typeof e){if(0===e)return 1/0/e>0?"0":"-0";var w=String(e);return h?k(e,w):w}if("bigint"==typeof e){var S=String(e)+"n";return h?k(e,S):S}var j=void 0===s.depth?5:s.depth;if(void 0===n&&(n=0),n>=j&&j>0&&"object"==typeof e)return z(e)?"[Array]":"[Object]";var P,F=function(t,e){var r;if("\t"===t.indent)r="\t";else{if(!("number"==typeof t.indent&&t.indent>0))return null;r=A.call(Array(t.indent+1)," ")}return{base:r,prev:A.call(Array(e+1),r)}}(s,n);if(void 0===o)o=[];else if(K(o,e)>=0)return"[Circular]";function L(e,r,i){if(r&&(o=O.call(o)).push(r),i){var a={depth:s.depth};return q(s,"quoteStyle")&&(a.quoteStyle=s.quoteStyle),t(e,a,n+1,o)}return t(e,s,n+1,o)}if("function"==typeof e&&!H(e)){var G=function(t){if(t.name)return t.name;var e=m.call(g.call(t),/^function\s*([\w$]+)/);return e?e[1]:null}(e),J=rt(e,L);return"[Function"+(G?": "+G:" (anonymous)")+"]"+(J.length>0?" { "+A.call(J,", ")+" }":"")}if(V(e)){var nt=C?v.call(String(e),/^(Symbol\(.*\))_[^)]*$/,"$1"):T.call(e);return"object"!=typeof e||C?nt:Q(nt)}if((P=e)&&"object"==typeof P&&("undefined"!=typeof HTMLElement&&P instanceof HTMLElement||"string"==typeof P.nodeName&&"function"==typeof P.getAttribute)){for(var ot="<"+x.call(String(e.nodeName)),it=e.attributes||[],at=0;at"}if(z(e)){if(0===e.length)return"[]";var st=rt(e,L);return F&&!function(t){for(var e=0;e=0)return!1;return!0}(st)?"["+et(st,F)+"]":"[ "+A.call(st,", ")+" ]"}if(function(t){return"[object Error]"===X(t)&&U(t)}(e)){var ut=rt(e,L);return"cause"in Error.prototype||!("cause"in e)||M.call(e,"cause")?0===ut.length?"["+String(e)+"]":"{ ["+String(e)+"] "+A.call(ut,", ")+" }":"{ ["+String(e)+"] "+A.call(E.call("[cause]: "+L(e.cause),ut),", ")+" }"}if("object"==typeof e&&u){if(_&&"function"==typeof e[_]&&$)return $(e,{depth:j-n});if("symbol"!==u&&"function"==typeof e.inspect)return e.inspect()}if(function(t){if(!i||!t||"object"!=typeof t)return!1;try{i.call(t);try{l.call(t)}catch(t){return!0}return t instanceof Map}catch(t){}return!1}(e)){var lt=[];return a&&a.call(e,function(t,r){lt.push(L(r,e,!0)+" => "+L(t,e))}),tt("Map",i.call(e),lt,F)}if(function(t){if(!l||!t||"object"!=typeof t)return!1;try{l.call(t);try{i.call(t)}catch(t){return!0}return t instanceof Set}catch(t){}return!1}(e)){var ct=[];return c&&c.call(e,function(t){ct.push(L(t,e))}),tt("Set",l.call(e),ct,F)}if(function(t){if(!f||!t||"object"!=typeof t)return!1;try{f.call(t,f);try{p.call(t,p)}catch(t){return!0}return t instanceof WeakMap}catch(t){}return!1}(e))return Z("WeakMap");if(function(t){if(!p||!t||"object"!=typeof t)return!1;try{p.call(t,p);try{f.call(t,f)}catch(t){return!0}return t instanceof WeakSet}catch(t){}return!1}(e))return Z("WeakSet");if(function(t){if(!d||!t||"object"!=typeof t)return!1;try{return d.call(t),!0}catch(t){}return!1}(e))return Z("WeakRef");if(function(t){return"[object Number]"===X(t)&&U(t)}(e))return Q(L(Number(e)));if(function(t){if(!t||"object"!=typeof t||!R)return!1;try{return R.call(t),!0}catch(t){}return!1}(e))return Q(L(R.call(e)));if(function(t){return"[object Boolean]"===X(t)&&U(t)}(e))return Q(y.call(e));if(function(t){return"[object String]"===X(t)&&U(t)}(e))return Q(L(String(e)));if("undefined"!=typeof window&&e===window)return"{ [object Window] }";if("undefined"!=typeof globalThis&&e===globalThis||"undefined"!=typeof globalThis&&e===globalThis)return"{ [object globalThis] }";if(!function(t){return"[object Date]"===X(t)&&U(t)}(e)&&!H(e)){var ft=rt(e,L),pt=I?I(e)===Object.prototype:e instanceof Object||e.constructor===Object,dt=e instanceof Object?"":"null prototype",yt=!pt&&N&&Object(e)===e&&N in e?b.call(X(e),8,-1):dt?"Object":"",ht=(pt||"function"!=typeof e.constructor?"":e.constructor.name?e.constructor.name+" ":"")+(yt||dt?"["+A.call(E.call([],yt||[],dt||[]),": ")+"] ":"");return 0===ft.length?ht+"{}":F?ht+"{"+et(ft,F)+"}":ht+"{ "+A.call(ft,", ")+" }"}return String(e)};var G=Object.prototype.hasOwnProperty||function(t){return t in this};function q(t,e){return G.call(t,e)}function X(t){return h.call(t)}function K(t,e){if(t.indexOf)return t.indexOf(e);for(var r=0,n=t.length;re.maxStringLength){var r=t.length-e.maxStringLength,n="... "+r+" more character"+(r>1?"s":"");return Y(b.call(t,0,e.maxStringLength),e)+n}var o=L[e.quoteStyle||"single"];return o.lastIndex=0,D(v.call(v.call(t,o,"\\$1"),/[\x00-\x1f]/g,J),"single",e)}function J(t){var e=t.charCodeAt(0),r={8:"b",9:"t",10:"n",12:"f",13:"r"}[e];return r?"\\"+r:"\\x"+(e<16?"0":"")+w.call(e.toString(16))}function Q(t){return"Object("+t+")"}function Z(t){return t+" { ? }"}function tt(t,e,r,n){return t+" ("+e+") {"+(n?et(r,n):A.call(r,", "))+"}"}function et(t,e){if(0===t.length)return"";var r="\n"+e.prev+e.base;return r+A.call(t,","+r)+"\n"+e.prev}function rt(t,e){var r=z(t),n=[];if(r){n.length=t.length;for(var o=0;o0&&!o.call(t,0))for(var h=0;h0)for(var g=0;g=0&&"[object Function]"===e.call(t.callee)),n}},3743(t,e,r){"use strict";var n=r(7843),o=r(7379),i=Object;t.exports=n(function(){if(null==this||this!==i(this))throw new o("RegExp.prototype.flags getter called on non-object");var t="";return this.hasIndices&&(t+="d"),this.global&&(t+="g"),this.ignoreCase&&(t+="i"),this.multiline&&(t+="m"),this.dotAll&&(t+="s"),this.unicode&&(t+="u"),this.unicodeSets&&(t+="v"),this.sticky&&(t+="y"),t},"get flags",!0)},1721(t,e,r){"use strict";var n=r(8189),o=r(7965),i=r(3743),a=r(4510),s=r(3980),u=o(a());n(u,{getPolyfill:a,implementation:i,shim:s}),t.exports=u},4510(t,e,r){"use strict";var n=r(3743),o=r(8189).supportsDescriptors,i=Object.getOwnPropertyDescriptor;t.exports=function(){if(o&&"gim"===/a/gim.flags){var t=i(RegExp.prototype,"flags");if(t&&"function"==typeof t.get&&"dotAll"in RegExp.prototype&&"hasIndices"in RegExp.prototype){var e="",r={};if(Object.defineProperty(r,"hasIndices",{get:function(){e+="d"}}),Object.defineProperty(r,"sticky",{get:function(){e+="y"}}),t.get.call(r),"dy"===e)return t.get}}return n}},3980(t,e,r){"use strict";var n=r(8189).supportsDescriptors,o=r(4510),i=r(3492),a=Object.defineProperty,s=r(9183),u=r(1611),l=/a/;t.exports=function(){if(!n||!u)throw new s("RegExp.prototype.flags requires a true ES5 environment that supports property descriptors");var t=o(),e=u(l),r=i(e,"flags");return r&&r.get===t||a(e,"flags",{configurable:!0,enumerable:!1,get:t}),t}},5537(t,e,r){"use strict";var n=r(5298),o=r(4587),i=n("RegExp.prototype.exec"),a=r(7379);t.exports=function(t){if(!o(t))throw new a("`regex` must be a RegExp");return function(e){return null!==i(t,e)}}},2644(t,e,r){"use strict";var n=r(703),o=r(7517),i=r(708)(),a=r(3492),s=r(7379),u=n("%Math.floor%");t.exports=function(t,e){if("function"!=typeof t)throw new s("`fn` is not a function");if("number"!=typeof e||e<0||e>4294967295||u(e)!==e)throw new s("`length` must be a positive 32-bit integer");var r=arguments.length>2&&!!arguments[2],n=!0,l=!0;if("length"in t&&a){var c=a(t,"length");c&&!c.configurable&&(n=!1),c&&!c.writable&&(l=!1)}return(n||l||!r)&&(i?o(t,"length",e,!0,!0):o(t,"length",e)),t}},7843(t,e,r){"use strict";var n=r(7517),o=r(708)(),i=r(3749).functionsHaveConfigurableNames(),a=r(7379);t.exports=function(t,e){if("function"!=typeof t)throw new a("`fn` is not a function");return arguments.length>2&&!!arguments[2]&&!i||(o?n(t,"name",e,!0,!0):n(t,"name",e)),t}},7797(t,e,r){"use strict";var n=r(4922),o=r(7379),i=function(t,e,r){for(var n,o=t;null!=(n=o.next);o=n)if(n.key===e)return o.next=n.next,r||(n.next=t.next,t.next=n),n};t.exports=function(){var t,e={assert:function(t){if(!e.has(t))throw new o("Side channel does not contain "+n(t))},delete:function(e){var r=function(t,e){if(t)return i(t,e,!0)}(t,e);return r&&t&&!t.next&&(t=void 0),!!r},get:function(e){return function(t,e){if(t){var r=i(t,e);return r&&r.value}}(t,e)},has:function(e){return function(t,e){return!!t&&!!i(t,e)}(t,e)},set:function(e,r){t||(t={next:void 0}),function(t,e,r){var n=i(t,e);n?n.value=r:t.next={key:e,next:t.next,value:r}}(t,e,r)}};return e}},1085(t,e,r){"use strict";var n=r(703),o=r(5298),i=r(4922),a=r(7379),s=n("%Map%",!0),u=o("Map.prototype.get",!0),l=o("Map.prototype.set",!0),c=o("Map.prototype.has",!0),f=o("Map.prototype.delete",!0),p=o("Map.prototype.size",!0);t.exports=!!s&&function(){var t,e={assert:function(t){if(!e.has(t))throw new a("Side channel does not contain "+i(t))},delete:function(e){if(t){var r=f(t,e);return 0===p(t)&&(t=void 0),r}return!1},get:function(e){if(t)return u(t,e)},has:function(e){return!!t&&c(t,e)},set:function(e,r){t||(t=new s),l(t,e,r)}};return e}},2468(t,e,r){"use strict";var n=r(703),o=r(5298),i=r(4922),a=r(1085),s=r(7379),u=n("%WeakMap%",!0),l=o("WeakMap.prototype.get",!0),c=o("WeakMap.prototype.set",!0),f=o("WeakMap.prototype.has",!0),p=o("WeakMap.prototype.delete",!0);t.exports=u?function(){var t,e,r={assert:function(t){if(!r.has(t))throw new s("Side channel does not contain "+i(t))},delete:function(r){if(u&&r&&("object"==typeof r||"function"==typeof r)){if(t)return p(t,r)}else if(a&&e)return e.delete(r);return!1},get:function(r){return u&&r&&("object"==typeof r||"function"==typeof r)&&t?l(t,r):e&&e.get(r)},has:function(r){return u&&r&&("object"==typeof r||"function"==typeof r)&&t?f(t,r):!!e&&e.has(r)},set:function(r,n){u&&r&&("object"==typeof r||"function"==typeof r)?(t||(t=new u),c(t,r,n)):a&&(e||(e=a()),e.set(r,n))}};return r}:a},6746(t,e,r){"use strict";var n=r(7379),o=r(4922),i=r(7797),a=r(1085),s=r(2468)||a||i;t.exports=function(){var t,e={assert:function(t){if(!e.has(t)){var r=t&&Object(t)===t?"the given object key":o(t);throw new n("Side channel does not contain "+r)}},delete:function(e){return!!t&&t.delete(e)},get:function(e){return t&&t.get(e)},has:function(e){return!!t&&t.has(e)},set:function(e,r){t||(t=s()),t.set(e,r)}};return e}},4290(t,e,r){"use strict";var n=r(6520),o=r(7630),i=r(4111),a=r(333),s=r(1076),u=r(5363),l=r(5298),c=r(7657)(),f=r(1721),p=r(703),d=r(7379),y=p("%RegExp%"),h=l("String.prototype.indexOf"),g=r(2570),m=function(t){var e=g();if(c&&"symbol"==typeof Symbol.matchAll){var r=i(t,Symbol.matchAll);return r===y.prototype[Symbol.matchAll]&&r!==e?e:r}if(a(t))return e};t.exports=function(t){var e=u(this);if(null!=t){if(a(t)){var r="flags"in t?o(t,"flags"):f(t);if(u(r),h(s(r),"g")<0)throw new d("matchAll requires a global regular expression")}var i=m(t);if(void 0!==i)return n(i,t,[e])}var l=s(e),c=new y(t,"g");return n(m(c),c,[l])}},6410(t,e,r){"use strict";var n=r(7965),o=r(8189),i=r(4290),a=r(4683),s=r(3197),u=n(i);o(u,{getPolyfill:a,implementation:i,shim:s}),t.exports=u},2570(t,e,r){"use strict";var n=r(7657)(),o=r(1930);t.exports=function(){return n&&"symbol"==typeof Symbol.matchAll&&"function"==typeof RegExp.prototype[Symbol.matchAll]?RegExp.prototype[Symbol.matchAll]:o}},4683(t,e,r){"use strict";var n=r(4290);t.exports=function(){if(String.prototype.matchAll)try{"".matchAll(RegExp.prototype)}catch(t){return String.prototype.matchAll}return n}},1930(t,e,r){"use strict";var n=r(3990),o=r(7630),i=r(5234),a=r(518),s=r(6117),u=r(1076),l=r(192),c=r(1721),f=r(7843),p=r(5298),d=r(703),y=r(7379),h=p("String.prototype.indexOf"),g=d("%RegExp%"),m="flags"in g.prototype,b=f(function(t){var e=this;if("Object"!==l(e))throw new y('"this" value must be an Object');var r=u(t),f=function(t,e){var r="flags"in e?o(e,"flags"):u(c(e));return{flags:r,matcher:new t(m&&"string"==typeof r?e:t===g?e.source:e,r)}}(a(e,g),e),p=f.flags,d=f.matcher,b=s(o(e,"lastIndex"));i(d,"lastIndex",b,!0);var v=h(p,"g")>-1,w=h(p,"u")>-1;return n(d,r,v,w)},"[Symbol.matchAll]",!0);t.exports=b},3197(t,e,r){"use strict";var n=r(8189),o=r(7657)(),i=r(3492),a=r(4683),s=r(2570),u=Object.defineProperty;t.exports=function(){var t=a();if(n(String.prototype,{matchAll:t},{matchAll:function(){return String.prototype.matchAll!==t}}),o){var e=Symbol.matchAll||(Symbol.for?Symbol.for("Symbol.matchAll"):Symbol("Symbol.matchAll"));if(n(Symbol,{matchAll:e},{matchAll:function(){return Symbol.matchAll!==e}}),u&&i){var r=i(Symbol,e);r&&!r.configurable||u(Symbol,e,{configurable:!1,enumerable:!1,value:e,writable:!1})}var l=s(),c={};c[e]=l;var f={};f[e]=function(){return RegExp.prototype[e]!==l},n(RegExp.prototype,c,f)}return t}},3952(t,e,r){"use strict";var n=r(5363),o=r(2501),i=r(5298),a=r(5537),s=i("String.prototype.replace"),u=i("String.prototype.charAt"),l=i("String.prototype.slice"),c=/^\s$/.test("᠎"),f=c?/^[\x09\x0A\x0B\x0C\x0D\x20\xA0\u1680\u180E\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000\u2028\u2029\uFEFF]+/:/^[\x09\x0A\x0B\x0C\x0D\x20\xA0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000\u2028\u2029\uFEFF]+/,p=a(c?/[\x09\x0A\x0B\x0C\x0D\x20\xA0\u1680\u180E\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000\u2028\u2029\uFEFF]$/:/[\x09\x0A\x0B\x0C\x0D\x20\xA0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000\u2028\u2029\uFEFF]$/);t.exports=function(){for(var t=s(o(n(this)),f,""),e=t.length;e>0&&p(u(t,e-1));)e-=1;return l(t,0,e)}},7724(t,e,r){"use strict";var n=r(7965),o=r(8189),i=r(5363),a=r(3952),s=r(8821),u=r(5795),l=n(s()),c=function(t){return i(t),l(t)};o(c,{getPolyfill:s,implementation:a,shim:u}),t.exports=c},8821(t,e,r){"use strict";var n=r(3952);t.exports=function(){return String.prototype.trim&&"​"==="​".trim()&&"᠎"==="᠎".trim()&&"_᠎"==="_᠎".trim()&&"᠎_"==="᠎_".trim()?String.prototype.trim:n}},5795(t,e,r){"use strict";var n=r(708)(),o=r(7517),i=r(8821);t.exports=function(){var t=i();return String.prototype.trim!==t&&(n?o(String.prototype,"trim",t,!0):o(String.prototype,"trim",t)),t}},2179(){},6917(t,e,r){"use strict";var n=r(6562),o=r(7379),i=r(1029),a=r(6241);t.exports=function(t,e,r){if("string"!=typeof t)throw new o("Assertion failed: `S` must be a String");if(!i(e)||e<0||e>a)throw new o("Assertion failed: `length` must be an integer >= 0 and <= 2**53");if("boolean"!=typeof r)throw new o("Assertion failed: `unicode` must be a Boolean");return r?e+1>=t.length?e+1:e+n(t,e)["[[CodeUnitCount]]"]:e+1}},6520(t,e,r){"use strict";var n=r(703),o=r(5298),i=r(7379),a=r(3443),s=n("%Reflect.apply%",!0)||o("Function.prototype.apply");t.exports=function(t,e){var r=arguments.length>2?arguments[2]:[];if(!a(r))throw new i("Assertion failed: optional `argumentsList`, if provided, must be a List");return s(t,e,r)}},6562(t,e,r){"use strict";var n=r(7379),o=r(5298),i=r(3283),a=r(8537),s=r(1300),u=o("String.prototype.charAt"),l=o("String.prototype.charCodeAt");t.exports=function(t,e){if("string"!=typeof t)throw new n("Assertion failed: `string` must be a String");var r=t.length;if(e<0||e>=r)throw new n("Assertion failed: `position` must be >= 0, and < the length of `string`");var o=l(t,e),c=u(t,e),f=i(o),p=a(o);if(!f&&!p)return{"[[CodePoint]]":c,"[[CodeUnitCount]]":1,"[[IsUnpairedSurrogate]]":!1};if(p||e+1===r)return{"[[CodePoint]]":c,"[[CodeUnitCount]]":1,"[[IsUnpairedSurrogate]]":!0};var d=l(t,e+1);return a(d)?{"[[CodePoint]]":s(o,d),"[[CodeUnitCount]]":2,"[[IsUnpairedSurrogate]]":!1}:{"[[CodePoint]]":c,"[[CodeUnitCount]]":1,"[[IsUnpairedSurrogate]]":!0}}},44(t,e,r){"use strict";var n=r(7379);t.exports=function(t,e){if("boolean"!=typeof e)throw new n("Assertion failed: Type(done) is not Boolean");return{value:t,done:e}}},3990(t,e,r){"use strict";var n=r(703),o=r(7657)(),i=r(7379),a=r(4938),s=n("%IteratorPrototype%",!0),u=r(6917),l=r(44),c=r(355),f=r(7630),p=r(2021),d=r(3936),y=r(5234),h=r(6117),g=r(1076),m=r(6561),b=r(3148),v=function(t,e,r,n){if("string"!=typeof e)throw new i("`S` must be a string");if("boolean"!=typeof r)throw new i("`global` must be a boolean");if("boolean"!=typeof n)throw new i("`fullUnicode` must be a boolean");m.set(this,"[[IteratingRegExp]]",t),m.set(this,"[[IteratedString]]",e),m.set(this,"[[Global]]",r),m.set(this,"[[Unicode]]",n),m.set(this,"[[Done]]",!1)};s&&(v.prototype=p(s)),c(v.prototype,"next",function(){var t=this;if(!a(t))throw new i("receiver must be an object");if(!(t instanceof v&&m.has(t,"[[IteratingRegExp]]")&&m.has(t,"[[IteratedString]]")&&m.has(t,"[[Global]]")&&m.has(t,"[[Unicode]]")&&m.has(t,"[[Done]]")))throw new i('"this" value must be a RegExpStringIterator instance');if(m.get(t,"[[Done]]"))return l(void 0,!0);var e=m.get(t,"[[IteratingRegExp]]"),r=m.get(t,"[[IteratedString]]"),n=m.get(t,"[[Global]]"),o=m.get(t,"[[Unicode]]"),s=d(e,r);if(null===s)return m.set(t,"[[Done]]",!0),l(void 0,!0);if(n){if(""===g(f(s,"0"))){var c=h(f(e,"lastIndex")),p=u(r,c,o);y(e,"lastIndex",p,!0)}return l(s,!1)}return m.set(t,"[[Done]]",!0),l(s,!1)},!1),o&&(b(v.prototype,"RegExp String Iterator"),Symbol.iterator&&"function"!=typeof v.prototype[Symbol.iterator])&&c(v.prototype,Symbol.iterator,function(){return this},!1),t.exports=function(t,e,r,n){return new v(t,e,r,n)}},355(t,e,r){"use strict";var n=r(7379),o=r(4938),i=r(9271),a=r(1181),s=r(5855);t.exports=function(t,e,r,u){if(!o(t))throw new n("Assertion failed: `homeObject` is not an Object");if(!s(e))throw new n("Assertion failed: `key` is not a Property Key or a Private Name");if("function"!=typeof r)throw new n("Assertion failed: `closure` is not a function");if("boolean"!=typeof u)throw new n("Assertion failed: `enumerable` is not a Boolean");if(!a(t))throw new n("Assertion failed: `homeObject` is not an ordinary, extensible object, with no non-configurable properties");i(t,e,{"[[Value]]":r,"[[Writable]]":!0,"[[Enumerable]]":u,"[[Configurable]]":!0})}},9271(t,e,r){"use strict";var n=r(7379),o=r(4938),i=r(6843),a=r(9999),s=r(5848),u=r(7817),l=r(5855),c=r(925),f=r(6309);t.exports=function(t,e,r){if(!o(t))throw new n("Assertion failed: Type(O) is not Object");if(!l(e))throw new n("Assertion failed: P is not a Property Key");var p=i(r)?r:f(r);if(!i(p))throw new n("Assertion failed: Desc is not a valid Property Descriptor");return a(u,c,s,t,e,p)}},5848(t,e,r){"use strict";var n=r(7379),o=r(6843),i=r(3003);t.exports=function(t){if(void 0!==t&&!o(t))throw new n("Assertion failed: `Desc` must be a Property Descriptor");return i(t)}},7630(t,e,r){"use strict";var n=r(7379),o=r(4922),i=r(5855),a=r(4938);t.exports=function(t,e){if(!a(t))throw new n("Assertion failed: Type(O) is not Object");if(!i(e))throw new n("Assertion failed: P is not a Property Key, got "+o(e));return t[e]}},4111(t,e,r){"use strict";var n=r(7379),o=r(7818),i=r(1816),a=r(5855),s=r(4922);t.exports=function(t,e){if(!a(e))throw new n("Assertion failed: P is not a Property Key");var r=o(t,e);if(null!=r){if(!i(r))throw new n(s(e)+" is not a function: "+s(r));return r}}},7818(t,e,r){"use strict";var n=r(7379),o=r(4922),i=r(5855);t.exports=function(t,e){if(!i(e))throw new n("Assertion failed: P is not a Property Key, got "+o(e));return t[e]}},3443(t,e,r){"use strict";t.exports=r(8622)},1816(t,e,r){"use strict";t.exports=r(2719)},3478(t,e,r){"use strict";var n=r(4334)("%Reflect.construct%",!0),o=r(9271);try{o({},"",{"[[Get]]":function(){}})}catch(t){o=null}if(o&&n){var i={},a={};o(a,"length",{"[[Get]]":function(){throw i},"[[Enumerable]]":!0}),t.exports=function(t){try{n(t,a)}catch(t){return t===i}}}else t.exports=function(t){return"function"==typeof t&&!!t.prototype}},7817(t,e,r){"use strict";var n=r(7379),o=r(9939),i=r(6843);t.exports=function(t){if(void 0===t)return!1;if(!i(t))throw new n("Assertion failed: `Desc` must be a Property Descriptor");return!(!o(t,"[[Value]]")&&!o(t,"[[Writable]]"))}},1181(t,e,r){"use strict";var n=r(703),o=n("%Object.preventExtensions%",!0),i=n("%Object.isExtensible%",!0),a=r(9258);t.exports=o?function(t){return!a(t)&&i(t)}:function(t){return!a(t)}},333(t,e,r){"use strict";var n=r(703)("%Symbol.match%",!0),o=r(4587),i=r(4938),a=r(4801);t.exports=function(t){if(!i(t))return!1;if(n){var e=t[n];if(void 0!==e)return a(e)}return o(t)}},2021(t,e,r){"use strict";var n=r(703)("%Object.create%",!0),o=r(7379),i=r(7388),a=r(4938),s=r(3443),u=r(5713),l=r(6561),c=r(6052)();t.exports=function(t){if(null!==t&&!a(t))throw new o("Assertion failed: `proto` must be null or an object");var e,r=arguments.length<2?[]:arguments[1];if(!s(r))throw new o("Assertion failed: `additionalInternalSlotsList` must be an Array");if(c)e={__proto__:t};else if(n)e=n(t);else{if(null===t)throw new i("native Object.create support is required to create null objects");var f=function(){};f.prototype=t,e=new f}return r.length>0&&u(r,function(t){l.set(e,t,void 0)}),e}},3936(t,e,r){"use strict";var n=r(7379),o=r(4938),i=r(5298)("RegExp.prototype.exec"),a=r(6520),s=r(7630),u=r(1816);t.exports=function(t,e){if(!o(t))throw new n("Assertion failed: `R` must be an Object");if("string"!=typeof e)throw new n("Assertion failed: `S` must be a String");var r=s(t,"exec");if(u(r)){var l=a(r,t,[e]);if(null===l||o(l))return l;throw new n('"exec" method must return `null` or an Object')}return i(t,e)}},925(t,e,r){"use strict";var n=r(5518);t.exports=function(t,e){return t===e?0!==t||1/t==1/e:n(t)&&n(e)}},5234(t,e,r){"use strict";var n=r(7379),o=r(4938),i=r(5855),a=r(925),s=function(){try{return delete[].length,!0}catch(t){return!1}}();t.exports=function(t,e,r,u){if(!o(t))throw new n("Assertion failed: `O` must be an Object");if(!i(e))throw new n("Assertion failed: `P` must be a Property Key");if("boolean"!=typeof u)throw new n("Assertion failed: `Throw` must be a Boolean");if(u){if(t[e]=r,s&&!a(t[e],r))throw new n("Attempted to assign to readonly property.");return!0}try{return t[e]=r,!s||a(t[e],r)}catch(t){return!1}}},518(t,e,r){"use strict";var n=r(703)("%Symbol.species%",!0),o=r(7379),i=r(4938),a=r(3478);t.exports=function(t,e){if(!i(t))throw new o("Assertion failed: Type(O) is not Object");var r=t.constructor;if(void 0===r)return e;if(!i(r))throw new o("O.constructor is not an Object");var s=n?r[n]:void 0;if(null==s)return e;if(a(s))return s;throw new o("no constructor found")}},9893(t,e,r){"use strict";var n=r(703),o=n("%RegExp%"),i=r(7379),a=n("%parseInt%"),s=r(5298),u=r(5537),l=s("String.prototype.slice"),c=u(/^0b[01]+$/i),f=u(/^0o[0-7]+$/i),p=u(/^[-+]0x[0-9a-f]+$/i),d=u(new o("["+["…","​","￾"].join("")+"]","g")),y=r(7724);t.exports=function t(e){if("string"!=typeof e)throw new i("Assertion failed: `argument` is not a String");if(c(e))return+a(l(e,2),2);if(f(e))return+a(l(e,2),8);if(d(e)||p(e))return NaN;var r=y(e);return r!==e?t(r):+e}},4801(t){"use strict";t.exports=function(t){return!!t}},7210(t,e,r){"use strict";var n=r(3312),o=r(6354),i=r(5518),a=r(1084);t.exports=function(t){var e=n(t);return i(e)||0===e?0:a(e)?o(e):e}},6117(t,e,r){"use strict";var n=r(6241),o=r(7210);t.exports=function(t){var e=o(t);return e<=0?0:e>n?n:e}},3312(t,e,r){"use strict";var n=r(703),o=r(7379),i=n("%Number%"),a=r(9258),s=r(3760),u=r(9893);t.exports=function(t){var e=a(t)?t:s(t,i);if("symbol"==typeof e)throw new o("Cannot convert a Symbol value to a number");if("bigint"==typeof e)throw new o("Conversion from 'BigInt' to 'number' is not allowed.");return"string"==typeof e?u(e):+e}},3760(t,e,r){"use strict";var n=r(2632);t.exports=function(t){return arguments.length>1?n(t,arguments[1]):n(t)}},6309(t,e,r){"use strict";var n=r(9939),o=r(7379),i=r(4938),a=r(1816),s=r(4801);t.exports=function(t){if(!i(t))throw new o("ToPropertyDescriptor requires an object");var e={};if(n(t,"enumerable")&&(e["[[Enumerable]]"]=s(t.enumerable)),n(t,"configurable")&&(e["[[Configurable]]"]=s(t.configurable)),n(t,"value")&&(e["[[Value]]"]=t.value),n(t,"writable")&&(e["[[Writable]]"]=s(t.writable)),n(t,"get")){var r=t.get;if(void 0!==r&&!a(r))throw new o("getter must be a function");e["[[Get]]"]=r}if(n(t,"set")){var u=t.set;if(void 0!==u&&!a(u))throw new o("setter must be a function");e["[[Set]]"]=u}if((n(e,"[[Get]]")||n(e,"[[Set]]"))&&(n(e,"[[Value]]")||n(e,"[[Writable]]")))throw new o("Invalid property descriptor. Cannot both specify accessors and a value or writable attribute");return e}},1076(t,e,r){"use strict";var n=r(703)("%String%"),o=r(7379);t.exports=function(t){if("symbol"==typeof t)throw new o("Cannot convert a Symbol value to a string");return n(t)}},192(t,e,r){"use strict";var n=r(3225);t.exports=function(t){return"symbol"==typeof t?"Symbol":"bigint"==typeof t?"BigInt":n(t)}},1300(t,e,r){"use strict";var n=r(703),o=r(7379),i=n("%String.fromCharCode%"),a=r(3283),s=r(8537);t.exports=function(t,e){if(!a(t)||!s(e))throw new o("Assertion failed: `lead` must be a leading surrogate char code, and `trail` must be a trailing surrogate char code");return i(t)+i(e)}},7060(t,e,r){"use strict";var n=r(2153);t.exports=function(t){return"bigint"==typeof t?t:n(t)}},6354(t,e,r){"use strict";var n=r(7060),o=r(7379);t.exports=function(t){if("number"!=typeof t&&"bigint"!=typeof t)throw new o("argument must be a Number or a BigInt");var e=t<0?-n(-t):n(t);return 0===e?0:e}},2501(t,e,r){"use strict";var n=r(703)("%String%"),o=r(7379);t.exports=function(t){if("symbol"==typeof t)throw new o("Cannot convert a Symbol value to a string");return n(t)}},3225(t,e,r){"use strict";var n=r(4938);t.exports=function(t){return null===t?"Null":void 0===t?"Undefined":n(t)?"Object":"number"==typeof t?"Number":"boolean"==typeof t?"Boolean":"string"==typeof t?"String":void 0}},4334(t,e,r){"use strict";t.exports=r(703)},9999(t,e,r){"use strict";var n=r(708),o=r(9173),i=n.hasArrayLengthDefineBug(),a=i&&r(8622),s=r(5298)("Object.prototype.propertyIsEnumerable");t.exports=function(t,e,r,n,u,l){if(!o){if(!t(l))return!1;if(!l["[[Configurable]]"]||!l["[[Writable]]"])return!1;if(u in n&&s(n,u)!==!!l["[[Enumerable]]"])return!1;var c=l["[[Value]]"];return n[u]=c,e(n[u],c)}return i&&"length"===u&&"[[Value]]"in l&&a(n)&&n.length!==l["[[Value]]"]?(n.length=l["[[Value]]"],n.length===l["[[Value]]"]):(o(n,u,r(l)),!0)}},8622(t,e,r){"use strict";var n=r(703)("%Array%"),o=!n.isArray&&r(5298)("Object.prototype.toString");t.exports=n.isArray||function(t){return"[object Array]"===o(t)}},5713(t){"use strict";t.exports=function(t,e){for(var r=0;r=55296&&t<=56319}},9258(t){"use strict";t.exports=function(t){return null===t||"function"!=typeof t&&"object"!=typeof t}},5855(t){"use strict";t.exports=function(t){return"string"==typeof t||"symbol"==typeof t}},8537(t){"use strict";t.exports=function(t){return"number"==typeof t&&t>=56320&&t<=57343}},6843(t,e,r){"use strict";var n=r(7379),o=r(9939),i={__proto__:null,"[[Configurable]]":!0,"[[Enumerable]]":!0,"[[Get]]":!0,"[[Set]]":!0,"[[Value]]":!0,"[[Writable]]":!0};t.exports=function(t){if(!t||"object"!=typeof t)return!1;for(var e in t)if(o(t,e)&&!i[e])return!1;var r=o(t,"[[Value]]")||o(t,"[[Writable]]"),a=o(t,"[[Get]]")||o(t,"[[Set]]");if(r&&a)throw new n("Property Descriptors may not be both accessor and data descriptors");return!0}}},e={};function r(n){var o=e[n];if(void 0!==o)return o.exports;var i=e[n]={exports:{}};return t[n](i,i.exports,r),i.exports}r.n=t=>{var e=t&&t.__esModule?()=>t.default:()=>t;return r.d(e,{a:e}),e},r.d=(t,e)=>{for(var n in e)r.o(e,n)&&!r.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:e[n]})},r.o=(t,e)=>Object.prototype.hasOwnProperty.call(t,e),(()=>{"use strict";var t=r(3618);function e(e,r,n){let o=0;const i=[];for(;-1!==o;)o=e.indexOf(r,o),-1!==o&&(i.push({start:o,end:o+r.length,errors:0}),o+=1);return i.length>0?i:(0,t.A)(e,r,n)}function n(t,r){return 0===r.length||0===t.length?0:1-e(t,r,r.length)[0].errors/r.length}function o(t){const e=document.createElement("div");return e.appendChild(t.cloneContents()),function(t){var e;for(const e of Array.from(t.querySelectorAll("br")))e.replaceWith(document.createTextNode(" "));return null!==(e=t.textContent)&&void 0!==e?e:""}(e)}function i(t,e){let r=0;for(const n of t){if(!(n{if(i=e===a.Forwards?r.nextNode():r.previousNode(),i){const t=i.textContent,r=e===a.Forwards?0:t.length;u=s(t,r,e)}};for(;i&&-1===u&&i!==o;)l();if(i&&u>=0)return{node:i,offset:u};throw new RangeError("No text nodes with non-whitespace text found in range")}function l(t,e){(null==e||e>t.length)&&(e=t.length);for(var r=0,n=Array(e);r1?e-1:0),n=1;no?(a.push({node:s,offset:o-l}),o=r.shift()):(u=i.nextNode(),l+=s.data.length);for(;void 0!==o&&s&&l===o;)a.push({node:s,offset:s.data.length}),o=r.shift();if(void 0!==o)throw new RangeError("Offset exceeds text length");return a}let d=function(t){return t[t.FORWARDS=1]="FORWARDS",t[t.BACKWARDS=2]="BACKWARDS",t}({});class y{constructor(t,e){if(e<0)throw new Error("Offset is invalid");this.element=t,this.offset=e}relativeTo(t){if(!t.contains(this.element))throw new Error("Parent is not an ancestor of current element");let e=this.element,r=this.offset;for(;e!==t;)r+=f(e),e=e.parentElement;return new y(e,r)}resolve(){let t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};try{return p(this.element,this.offset)[0]}catch(e){if(0===this.offset&&void 0!==t.direction){const r=document.createTreeWalker(this.element.getRootNode(),NodeFilter.SHOW_TEXT);r.currentNode=this.element;const n=t.direction===d.FORWARDS,o=n?r.nextNode():r.previousNode();if(!o)throw e;return{node:o,offset:n?0:o.data.length}}throw e}}static fromCharOffset(t,e){switch(t.nodeType){case Node.TEXT_NODE:return y.fromPoint(t,e);case Node.ELEMENT_NODE:return new y(t,e);default:throw new Error("Node is not an element or text node")}}static fromPoint(t,e){switch(t.nodeType){case Node.TEXT_NODE:{if(e<0||e>t.data.length)throw new Error("Text node offset is out of range");if(!t.parentElement)throw new Error("Text node has no parent");const r=f(t)+e;return new y(t.parentElement,r)}case Node.ELEMENT_NODE:{if(e<0||e>t.childNodes.length)throw new Error("Child node offset is out of range");let r=0;for(let n=0;n=0&&(e.setStart(t.startContainer,o.start),r=!0),o.end>0&&(e.setEnd(t.endContainer,o.end),n=!0),r&&n)return e;if(!r){const t=u(e,a.Forwards),r=t.node,n=t.offset;r&&n>=0&&e.setStart(r,n)}if(!n){const t=u(e,a.Backwards),r=t.node,n=t.offset;r&&n>0&&e.setEnd(r,n)}return e}(h.fromRange(t).toRange())}}class g{constructor(t,e,r){this.root=t,this.start=e,this.end=r}static fromRange(t,e){const r=h.fromRange(e).relativeTo(t);return new g(t,r.start.offset,r.end.offset)}static fromSelector(t,e){return new g(t,e.start,e.end)}toSelector(){return{type:"TextPositionSelector",start:this.start,end:this.end}}toRange(){return h.fromOffsets(this.root,this.start,this.end).toRange()}}class m{constructor(t,e){let r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};this.root=t,this.exact=e,this.context=r}static fromRange(t,e){var r;const n=null!==(r=t.textContent)&&void 0!==r?r:"",i=h.fromRange(e).relativeTo(t),a=i.start.offset,s=i.end.offset,u=o(e),l=o(h.fromOffsets(t,Math.max(0,a-32),a).toRange()),c=o(h.fromOffsets(t,s,Math.min(n.length,s+32)).toRange());return new m(t,u,{prefix:l,suffix:c})}static fromSelector(t,e){const r=e.prefix,n=e.suffix;return new m(t,e.exact,{prefix:r,suffix:n})}toSelector(){return{type:"TextQuoteSelector",exact:this.exact,prefix:this.context.prefix,suffix:this.context.suffix}}toRange(){let t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return this.toPositionAnchor(t).toRange()}toPositionAnchor(){let t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};const r=function(t){let e="";const r=[],n=t=>{var o;if(t.nodeType!==Node.TEXT_NODE){if(t.nodeType===Node.ELEMENT_NODE){if("BR"===t.tagName)return r.push(e.length),void(e+=" ");for(const e of Array.from(t.childNodes))n(e)}}else e+=null!==(o=t.textContent)&&void 0!==o?o:""};return n(t),{text:e,brPositionsInText:r}}(this.root),o=r.text,a=r.brPositionsInText,s=function(t,r){let o=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};if(0===r.length)return null;const i=Math.min(256,r.length/2),a=e(t,r,i);if(0===a.length)return null;const s=e=>{const i=1-e.errors/r.length,a=o.prefix?n(t.slice(Math.max(0,e.start-o.prefix.length),e.start),o.prefix):1,s=o.suffix?n(t.slice(e.end,e.end+o.suffix.length),o.suffix):1;let u=1;return"number"==typeof o.hint&&(u=1-Math.abs(e.start-o.hint)/t.length),(50*i+20*a+20*s+2*u)/92},u=a.map(t=>({start:t.start,end:t.end,score:s(t)}));return u.sort((t,e)=>e.score-t.score),u[0]}(o,this.exact,{...this.context,hint:t.hint});if(!s)throw new Error("Quote not found");return new g(this.root,i(a,s.start),i(a,s.end))}}var b,v=r(6410);function w(){if(!readium.link)return null;const t=readium.link.href;if(!t)return null;const e=function(){const t=window.getSelection();if(!t)return;if(t.isCollapsed)return;const e=t.toString();if(0===e.trim().replace(/\n/g," ").replace(/\s\s+/g," ").length)return;if(!t.anchorNode||!t.focusNode)return;const r=1===t.rangeCount?t.getRangeAt(0):function(t,e,r,n){const o=new Range;if(o.setStart(t,e),o.setEnd(r,n),!o.collapsed)return o;x(">>> createOrderedRange COLLAPSED ... RANGE REVERSE?");const i=new Range;if(i.setStart(r,n),i.setEnd(t,e),!i.collapsed)return x(">>> createOrderedRange RANGE REVERSE OK."),o;x(">>> createOrderedRange RANGE REVERSE ALSO COLLAPSED?!")}(t.anchorNode,t.anchorOffset,t.focusNode,t.focusOffset);if(!r||r.collapsed)return void x("$$$$$$$$$$$$$$$$$ CANNOT GET NON-COLLAPSED SELECTION RANGE?!");const n=document.body.textContent,o=h.fromRange(r).relativeTo(document.body),i=o.start.offset,a=o.end.offset;let s=n.slice(Math.max(0,i-200),i),u=s.search(/\P{L}\p{L}/gu);-1!==u&&(s=s.slice(u+1));let l=n.slice(a,Math.min(n.length,a+200)),c=Array.from(l.matchAll(/\p{L}\P{L}/gu)).pop();return void 0!==c&&c.index>1&&(l=l.slice(0,c.index+1)),{highlight:e,before:s,after:l}}();return e?{href:t,text:e,rect:function(){try{let t=window.getSelection();if(!t)return;return D(t.getRangeAt(0).getBoundingClientRect())}catch(t){return L(t),null}}()}:null}function x(){_.apply(null,arguments)}r.n(v)().shim(),window.addEventListener("error",function(t){webkit.messageHandlers.logError.postMessage({message:t.message,filename:t.filename,line:t.lineno})},!1),window.addEventListener("load",function(){var t;new ResizeObserver(()=>{t&&window.cancelAnimationFrame(t),t=window.requestAnimationFrame(function(){O=window.innerWidth,function(){const t="readium-virtual-page";var e=document.getElementById(t);if(R()||2!=parseInt(window.getComputedStyle(document.documentElement).getPropertyValue("column-count"))){var r;null===(r=e)||void 0===r||r.remove()}else{var n=document.scrollingElement.scrollWidth/window.innerWidth;Math.round(2*n)/2%1>.1&&(e?e.remove():((e=document.createElement("div")).setAttribute("id",t),e.style.breakBefore="column",e.innerHTML="​",document.body.appendChild(e)))}}(),function(){if(!R()){var t=I(window.scrollX+1);document.scrollingElement.scrollLeft=t}}(),j()})}).observe(document.body)},!1);var S,E,A=!1,O=0;function j(){if(readium.isFixedLayout)return;let t=document.scrollingElement;if(R()&&!P()){const e=window.scrollY,r=window.innerHeight,n=t.scrollHeight;b={first:e/n,last:(e+r)/n}}else{let e=window.scrollX;const r=window.innerWidth,n=t.scrollWidth;T()&&(e=Math.abs(e)),b={first:e/n,last:(e+r)/n}}0!==t.scrollWidth&&0!==t.scrollHeight&&(A||window.requestAnimationFrame(function(){var t;t=b,webkit.messageHandlers.progressionChanged.postMessage(t),A=!1}),A=!0)}function R(){return"readium-scroll-on"==document.documentElement.style.getPropertyValue("--USER__view").trim()}function P(){return window.getComputedStyle(document.documentElement).getPropertyValue("writing-mode").startsWith("vertical")}function T(){const t=window.getComputedStyle(document.documentElement);return"rtl"==t.getPropertyValue("direction")||"vertical-rl"==t.getPropertyValue("writing-mode")}function C(t,e){return R()?M({top:t.top+window.scrollY,animated:e}):M({left:I(t.left+window.scrollX),animated:e}),!0}function N(t,e){var r=window.scrollX,n=window.innerWidth,o=Math.abs(r-t)/n>.01;return o&&M({left:t,animated:e}),o}function M(){let t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},e=t.left,r=t.top,n=t.animated;document.scrollingElement.scrollTo({left:e,top:r,behavior:n?"smooth":"instant"})}function I(t){const e=t+(T()?-1:1);return e-e%O}function k(t){try{let n=t.locations,o=t.text;var e;if(o&&o.highlight)return n&&n.cssSelector&&(e=document.querySelector(n.cssSelector)),e||(e=document.body),new m(e,o.highlight,{prefix:o.before,suffix:o.after}).toRange();if(n){var r=null;if(!r&&n.cssSelector&&(r=document.querySelector(n.cssSelector)),!r&&n.fragments)for(const t of n.fragments)if(r=document.getElementById(t))break;if(r){let t=document.createRange();return t.setStartBefore(r),t.setEndAfter(r),t}}}catch(t){L(t)}return null}function $(t,e){null===e?F(t):document.documentElement.style.setProperty(t,e,"important")}function F(t){document.documentElement.style.removeProperty(t)}function _(){var t=Array.prototype.slice.call(arguments).join(" ");webkit.messageHandlers.log.postMessage(t)}function B(t){L(new Error(t))}function L(t){webkit.messageHandlers.logError.postMessage({message:t.message})}function D(t){let e=W({x:t.left,y:t.top});const r=t.width,n=t.height,o=e.x,i=e.y;return{width:r,height:n,left:o,top:i,right:o+r,bottom:i+n}}function W(t){if(!frameElement)return t;let e=frameElement.getBoundingClientRect();if(!e)return t;let r=window.top.document.documentElement;return{x:t.x+e.x+r.scrollLeft,y:t.y+e.y+r.scrollTop}}function U(t,e){let r=t.getClientRects();const n=[];for(const t of r)n.push({bottom:t.bottom,height:t.height,left:t.left,right:t.right,top:t.top,width:t.width});const o=q(function(t){const e=new Set(t);for(const r of t)if(r.width>1&&r.height>1){for(const n of t)if(r!==n&&e.has(n)&&V(n,r,1)){J(),e.delete(r);break}}else J(),e.delete(r);return Array.from(e)}(z(n,1,e)));for(let t=o.length-1;t>=0;t--){const e=o[t];if(!(e.width*e.height>4)){if(!(o.length>1)){J();break}J(),o.splice(t,1)}}return J((n.length,o.length)),o}function z(t,e,r){for(let n=0;nt!==i&&t!==a),o=H(i,a);return n.push(o),z(n,e,r)}}return t}function H(t,e){const r=Math.min(t.left,e.left),n=Math.max(t.right,e.right),o=Math.min(t.top,e.top),i=Math.max(t.bottom,e.bottom);return{bottom:i,height:i-o,left:r,right:n,top:o,width:n-r}}function V(t,e,r){return G(t,e.left,e.top,r)&&G(t,e.right,e.top,r)&&G(t,e.left,e.bottom,r)&&G(t,e.right,e.bottom,r)}function G(t,e,r,n){return(t.lefte||Y(t.right,e,n))&&(t.topr||Y(t.bottom,r,n))}function q(t){for(let e=0;et!==e);return Array.prototype.push.apply(a,r),q(a)}}else J()}return t}function X(t,e){const r=function(t,e){const r=Math.max(t.left,e.left),n=Math.min(t.right,e.right),o=Math.max(t.top,e.top),i=Math.min(t.bottom,e.bottom);return{bottom:i,height:Math.max(0,i-o),left:r,right:n,top:o,width:Math.max(0,n-r)}}(e,t);if(0===r.height||0===r.width)return[t];const n=[];{const e={bottom:t.bottom,height:0,left:t.left,right:r.left,top:t.top,width:0};e.width=e.right-e.left,e.height=e.bottom-e.top,0!==e.height&&0!==e.width&&n.push(e)}{const e={bottom:r.top,height:0,left:r.left,right:r.right,top:t.top,width:0};e.width=e.right-e.left,e.height=e.bottom-e.top,0!==e.height&&0!==e.width&&n.push(e)}{const e={bottom:t.bottom,height:0,left:r.left,right:r.right,top:r.bottom,width:0};e.width=e.right-e.left,e.height=e.bottom-e.top,0!==e.height&&0!==e.width&&n.push(e)}{const e={bottom:t.bottom,height:0,left:r.right,right:t.right,top:t.top,width:0};e.width=e.right-e.left,e.height=e.bottom-e.top,0!==e.height&&0!==e.width&&n.push(e)}return n}function K(t,e,r){return(t.left=0&&Y(t.left,e.right,r))&&(e.left=0&&Y(e.left,t.right,r))&&(t.top=0&&Y(t.top,e.bottom,r))&&(e.top=0&&Y(e.top,t.bottom,r))}function Y(t,e,r){return Math.abs(t-e)<=r}function J(){}window.addEventListener("scroll",j),document.addEventListener("selectionchange",(S=function(){webkit.messageHandlers.selectionChanged.postMessage(w())},function(){var t=this,e=arguments;clearTimeout(E),E=setTimeout(function(){S.apply(t,e),E=null},50)}));var Q,Z=[],tt=function(){return Z.some(function(t){return t.activeTargets.length>0})},et="ResizeObserver loop completed with undelivered notifications.";!function(t){t.BORDER_BOX="border-box",t.CONTENT_BOX="content-box",t.DEVICE_PIXEL_CONTENT_BOX="device-pixel-content-box"}(Q||(Q={}));var rt,nt=function(t){return Object.freeze(t)},ot=function(t,e){this.inlineSize=t,this.blockSize=e,nt(this)},it=function(){function t(t,e,r,n){return this.x=t,this.y=e,this.width=r,this.height=n,this.top=this.y,this.left=this.x,this.bottom=this.top+this.height,this.right=this.left+this.width,nt(this)}return t.prototype.toJSON=function(){var t=this;return{x:t.x,y:t.y,top:t.top,right:t.right,bottom:t.bottom,left:t.left,width:t.width,height:t.height}},t.fromRect=function(e){return new t(e.x,e.y,e.width,e.height)},t}(),at=function(t){return t instanceof SVGElement&&"getBBox"in t},st=function(t){if(at(t)){var e=t.getBBox(),r=e.width,n=e.height;return!r&&!n}var o=t,i=o.offsetWidth,a=o.offsetHeight;return!(i||a||t.getClientRects().length)},ut=function(t){var e;if(t instanceof Element)return!0;var r=null===(e=null==t?void 0:t.ownerDocument)||void 0===e?void 0:e.defaultView;return!!(r&&t instanceof r.Element)},lt="undefined"!=typeof window?window:{},ct=new WeakMap,ft=/auto|scroll/,pt=/^tb|vertical/,dt=/msie|trident/i.test(lt.navigator&<.navigator.userAgent),yt=function(t){return parseFloat(t||"0")},ht=function(t,e,r){return void 0===t&&(t=0),void 0===e&&(e=0),void 0===r&&(r=!1),new ot((r?e:t)||0,(r?t:e)||0)},gt=nt({devicePixelContentBoxSize:ht(),borderBoxSize:ht(),contentBoxSize:ht(),contentRect:new it(0,0,0,0)}),mt=function(t,e){if(void 0===e&&(e=!1),ct.has(t)&&!e)return ct.get(t);if(st(t))return ct.set(t,gt),gt;var r=getComputedStyle(t),n=at(t)&&t.ownerSVGElement&&t.getBBox(),o=!dt&&"border-box"===r.boxSizing,i=pt.test(r.writingMode||""),a=!n&&ft.test(r.overflowY||""),s=!n&&ft.test(r.overflowX||""),u=n?0:yt(r.paddingTop),l=n?0:yt(r.paddingRight),c=n?0:yt(r.paddingBottom),f=n?0:yt(r.paddingLeft),p=n?0:yt(r.borderTopWidth),d=n?0:yt(r.borderRightWidth),y=n?0:yt(r.borderBottomWidth),h=f+l,g=u+c,m=(n?0:yt(r.borderLeftWidth))+d,b=p+y,v=s?t.offsetHeight-b-t.clientHeight:0,w=a?t.offsetWidth-m-t.clientWidth:0,x=o?h+m:0,S=o?g+b:0,E=n?n.width:yt(r.width)-x-w,A=n?n.height:yt(r.height)-S-v,O=E+h+w+m,j=A+g+v+b,R=nt({devicePixelContentBoxSize:ht(Math.round(E*devicePixelRatio),Math.round(A*devicePixelRatio),i),borderBoxSize:ht(O,j,i),contentBoxSize:ht(E,A,i),contentRect:new it(f,u,E,A)});return ct.set(t,R),R},bt=function(t,e,r){var n=mt(t,r),o=n.borderBoxSize,i=n.contentBoxSize,a=n.devicePixelContentBoxSize;switch(e){case Q.DEVICE_PIXEL_CONTENT_BOX:return a;case Q.BORDER_BOX:return o;default:return i}},vt=function(t){var e=mt(t);this.target=t,this.contentRect=e.contentRect,this.borderBoxSize=nt([e.borderBoxSize]),this.contentBoxSize=nt([e.contentBoxSize]),this.devicePixelContentBoxSize=nt([e.devicePixelContentBoxSize])},wt=function(t){if(st(t))return 1/0;for(var e=0,r=t.parentNode;r;)e+=1,r=r.parentNode;return e},xt=function(){var t=1/0,e=[];Z.forEach(function(r){if(0!==r.activeTargets.length){var n=[];r.activeTargets.forEach(function(e){var r=new vt(e.target),o=wt(e.target);n.push(r),e.lastReportedSize=bt(e.target,e.observedBox),ot?e.activeTargets.push(r):e.skippedTargets.push(r))})})},Et=[],At=0,Ot={attributes:!0,characterData:!0,childList:!0,subtree:!0},jt=["resize","load","transitionend","animationend","animationstart","animationiteration","keyup","keydown","mouseup","mousedown","mouseover","mouseout","blur","focus"],Rt=function(t){return void 0===t&&(t=0),Date.now()+t},Pt=!1,Tt=function(){function t(){var t=this;this.stopped=!0,this.listener=function(){return t.schedule()}}return t.prototype.run=function(t){var e=this;if(void 0===t&&(t=250),!Pt){Pt=!0;var r,n=Rt(t);r=function(){var r=!1;try{r=function(){var t,e=0;for(St(e);tt();)e=xt(),St(e);return Z.some(function(t){return t.skippedTargets.length>0})&&("function"==typeof ErrorEvent?t=new ErrorEvent("error",{message:et}):((t=document.createEvent("Event")).initEvent("error",!1,!1),t.message=et),window.dispatchEvent(t)),e>0}()}finally{if(Pt=!1,t=n-Rt(),!At)return;r?e.run(1e3):t>0?e.run(t):e.start()}},function(t){if(!rt){var e=0,r=document.createTextNode("");new MutationObserver(function(){return Et.splice(0).forEach(function(t){return t()})}).observe(r,{characterData:!0}),rt=function(){r.textContent="".concat(e?e--:e++)}}Et.push(t),rt()}(function(){requestAnimationFrame(r)})}},t.prototype.schedule=function(){this.stop(),this.run()},t.prototype.observe=function(){var t=this,e=function(){return t.observer&&t.observer.observe(document.body,Ot)};document.body?e():lt.addEventListener("DOMContentLoaded",e)},t.prototype.start=function(){var t=this;this.stopped&&(this.stopped=!1,this.observer=new MutationObserver(this.listener),this.observe(),jt.forEach(function(e){return lt.addEventListener(e,t.listener,!0)}))},t.prototype.stop=function(){var t=this;this.stopped||(this.observer&&this.observer.disconnect(),jt.forEach(function(e){return lt.removeEventListener(e,t.listener,!0)}),this.stopped=!0)},t}(),Ct=new Tt,Nt=function(t){!At&&t>0&&Ct.start(),!(At+=t)&&Ct.stop()},Mt=function(){function t(t,e){this.target=t,this.observedBox=e||Q.CONTENT_BOX,this.lastReportedSize={inlineSize:0,blockSize:0}}return t.prototype.isActive=function(){var t,e=bt(this.target,this.observedBox,!0);return t=this.target,at(t)||function(t){switch(t.tagName){case"INPUT":if("image"!==t.type)break;case"VIDEO":case"AUDIO":case"EMBED":case"OBJECT":case"CANVAS":case"IFRAME":case"IMG":return!0}return!1}(t)||"inline"!==getComputedStyle(t).display||(this.lastReportedSize=e),this.lastReportedSize.inlineSize!==e.inlineSize||this.lastReportedSize.blockSize!==e.blockSize},t}(),It=function(t,e){this.activeTargets=[],this.skippedTargets=[],this.observationTargets=[],this.observer=t,this.callback=e},kt=new WeakMap,$t=function(t,e){for(var r=0;r=0&&(o&&Z.splice(Z.indexOf(r),1),r.observationTargets.splice(n,1),Nt(-1))},t.disconnect=function(t){var e=this,r=kt.get(t);r.observationTargets.slice().forEach(function(r){return e.unobserve(t,r.target)}),r.activeTargets.splice(0,r.activeTargets.length)},t}(),_t=function(){function t(t){if(0===arguments.length)throw new TypeError("Failed to construct 'ResizeObserver': 1 argument required, but only 0 present.");if("function"!=typeof t)throw new TypeError("Failed to construct 'ResizeObserver': The callback provided as parameter 1 is not a function.");Ft.connect(this,t)}return t.prototype.observe=function(t,e){if(0===arguments.length)throw new TypeError("Failed to execute 'observe' on 'ResizeObserver': 1 argument required, but only 0 present.");if(!ut(t))throw new TypeError("Failed to execute 'observe' on 'ResizeObserver': parameter 1 is not of type 'Element");Ft.observe(this,t,e)},t.prototype.unobserve=function(t){if(0===arguments.length)throw new TypeError("Failed to execute 'unobserve' on 'ResizeObserver': 1 argument required, but only 0 present.");if(!ut(t))throw new TypeError("Failed to execute 'unobserve' on 'ResizeObserver': parameter 1 is not of type 'Element");Ft.unobserve(this,t)},t.prototype.disconnect=function(){Ft.disconnect(this)},t.toString=function(){return"function ResizeObserver () { [polyfill code] }"},t}();function Bt(t,e){return function(t){if(Array.isArray(t))return t}(t)||function(t,e){var r=null==t?null:"undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(null!=r){var n,o,i,a,s=[],u=!0,l=!1;try{if(i=(r=r.call(t)).next,0===e){if(Object(r)!==r)return;u=!1}else for(;!(u=(n=i.call(r)).done)&&(s.push(n.value),s.length!==e);u=!0);}catch(t){l=!0,o=t}finally{try{if(!u&&null!=r.return&&(a=r.return(),Object(a)!==a))return}finally{if(l)throw o}}return s}}(t,e)||function(t,e){if(t){if("string"==typeof t)return Lt(t,e);var r={}.toString.call(t).slice(8,-1);return"Object"===r&&t.constructor&&(r=t.constructor.name),"Map"===r||"Set"===r?Array.from(t):"Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?Lt(t,e):void 0}}(t,e)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function Lt(t,e){(null==e||e>t.length)&&(e=t.length);for(var r=0,n=Array(e);r{e.width===t.clientWidth&&e.height===t.clientHeight||(e={width:t.clientWidth,height:t.clientHeight},Ut.forEach(function(t){t.requestLayout()}))}).observe(t)},!1);const Gt={id:"id",class:"class",tag:"tag",attribute:"attribute",nthchild:"nthchild",nthoftype:"nthoftype"};function qt(t="unknown problem",...e){console.warn(`CssSelectorGenerator: ${t}`,...e)}const Xt={selectors:[Gt.id,Gt.class,Gt.tag,Gt.attribute],includeTag:!1,whitelist:[],blacklist:[],combineWithinSelector:!0,combineBetweenSelectors:!0,root:null,maxCombinations:Number.POSITIVE_INFINITY,maxCandidates:Number.POSITIVE_INFINITY,useScope:!1,ignoreGeneratedClassNames:!1};function Kt(t){return!!t}function Yt(t){return t instanceof RegExp}function Jt(t){return["string","function"].includes(typeof t)||Yt(t)}function Qt(t){return Array.isArray(t)?t.filter(Jt):[]}function Zt(t){const e=[Node.DOCUMENT_NODE,Node.DOCUMENT_FRAGMENT_NODE,Node.ELEMENT_NODE];return function(t){return null!=t&&"object"==typeof t&&"nodeType"in t&&"number"==typeof t.nodeType}(t)&&e.includes(t.nodeType)}function te(t,e){if(Zt(t))return t.contains(e)||qt("element root mismatch","Provided root does not contain the element. This will most likely result in producing a fallback selector using element's real root node. If you plan to use the selector using provided root (e.g. `root.querySelector`), it will not work as intended."),t;const r=e.getRootNode({composed:!1});return Zt(r)?(r!==document&&qt("shadow root inferred","You did not provide a root and the element is a child of Shadow DOM. This will produce a selector using ShadowRoot as a root. If you plan to use the selector using document as a root (e.g. `document.querySelector`), it will not work as intended."),r):se(e)}function ee(t){return"number"==typeof t?t:Number.POSITIVE_INFINITY}function re(t=[]){const[e=[],...r]=t;return 0===r.length?e:r.reduce((t,e)=>t.filter(t=>e.includes(t)),e)}function ne(t){const e=t.map(t=>{if(Yt(t))return e=>t.test(e);if("function"==typeof t)return e=>{const r=t(e);return"boolean"!=typeof r?(qt("pattern matcher function invalid","Provided pattern matching function does not return boolean. It's result will be ignored.",t),!1):r};if("string"==typeof t){const e=new RegExp("^"+t.replace(/[|\\{}()[\]^$+?.]/g,"\\$&").replace(/\*/g,".+")+"$");return t=>e.test(t)}return qt("pattern matcher invalid","Pattern matching only accepts strings, regular expressions and/or functions. This item is invalid and will be ignored.",t),()=>!1});return t=>e.some(e=>e(t))}function oe(t,e,r){const n=Array.from(te(r,t[0]).querySelectorAll(e));return n.length===t.length&&t.every(t=>n.includes(t))}function ie(t,e){e=null!=e?e:se(t);const r=[];let n=t;for(;n&&n!==e;)Vt(n)&&r.push(n),n=n.parentNode;return r}function ae(t,e){return re(t.map(t=>ie(t,e)))}function se(t){return t.ownerDocument.querySelector(":root")}const ue=new RegExp(["^$","\\s"].join("|")),le=new RegExp(["^$"].join("|")),ce=[Gt.nthoftype,Gt.tag,Gt.id,Gt.class,Gt.attribute,Gt.nthchild],fe=ne(["class","id","ng-*"]);function pe({name:t}){return`[${t}]`}function de({name:t,value:e}){return`[${t}='${e}']`}function ye({nodeName:t,nodeValue:e}){return{name:Te(t),value:Te(null!=e?e:void 0)}}function he(t,e){const r=Array.from(t.attributes).filter(e=>function({nodeName:t,nodeValue:e},r){const n=r.tagName.toLowerCase();return!(["input","option"].includes(n)&&"value"===t||"src"===t&&(null==e?void 0:e.startsWith("data:"))||fe(t))}(e,t)).map(ye);return[...r.map(pe),...r.map(de)]}const ge=/^[a-z_-]{3,}$/i,me=/[bcdfghjklmnpqrstvwxyz]{4,}/i;function be(t,e){var r;const n=(null!==(r=t.getAttribute("class"))&&void 0!==r?r:"").trim().split(/\s+/).filter(t=>!le.test(t));let o=n;if(null==e?void 0:e.ignoreGeneratedClassNames){const t=ne(e.whitelist);o=n.filter(e=>{const r=`.${Te(e)}`;return!!t(r)||function(t){if(!ge.test(t))return!1;if(t.includes("_")&&!t.includes("__"))return!1;if(/^(css|sc|jsx|emotion|makeStyles|MuiButton|MuiBox)-/i.test(t))return!1;const e=t.split(/--|__|[-]|(?<=[a-z])(?=[A-Z])/).filter(t=>t.length>0);if(0===e.length)return!1;if(1===e.length&&e[0].length<4)return!1;for(const t of e){if(t.length<=2)return!1;if(me.test(t))return!1}return!0}(e)})}return o.map(t=>`.${Te(t)}`)}function ve(t,e){var r;const n=null!==(r=t.getAttribute("id"))&&void 0!==r?r:"",o=`#${Te(n)}`,i=t.getRootNode({composed:!1});return!ue.test(n)&&oe([t],o,i)?[o]:[]}function we(t,e){const r=t.parentNode,n=r&&"children"in r?r.children:null;if(n)for(let e=0;exe(t)),[].concat(...n)))];var n;return 0===r.length||r.length>1?[]:[r[0]]}function Ee(t,e){const r=Se([t])[0],n=t.parentNode,o=n&&"children"in n?n:null;if(o){const e=Array.from(o.children).filter(t=>t.tagName.toLowerCase()===r),n=e.indexOf(t);if(n>-1)return[`${r}:nth-of-type(${String(n+1)})`]}return[]}function*Ae(t=[],{maxResults:e=Number.POSITIVE_INFINITY}={}){let r=0,n=je(1);for(;n.length<=t.length&&rt[e]);yield e,n=Oe(n,t.length-1)}}function Oe(t=[],e=0){const r=t.length;if(0===r)return[];const n=[...t];n[r-1]+=1;for(let t=r-1;t>=0;t--)if(n[t]>e){if(0===t)return je(r+1);n[t-1]++,n[t]=n[t-1]+1}return n[r-1]>e?je(r+1):n}function je(t=1){return Array.from(Array(t).keys())}const Re=":".charCodeAt(0).toString(16).toUpperCase(),Pe=/[ !"#$%&'()\[\]{|}<>*+,./;=?@^`~\\]/;function Te(t=""){return CSS?CSS.escape(t):function(t=""){return t.split("").map(t=>":"===t?`\\${Re} `:Pe.test(t)?`\\${t}`:escape(t).replace(/%/g,"\\")).join("")}(t)}const Ce={tag:Se,id:function(t,e){return 0===t.length||t.length>1?[]:ve(t[0])},class:function(t,e){return re(t.map(t=>be(t,e)))},attribute:function(t,e){return re(t.map(t=>he(t)))},nthchild:function(t,e){return re(t.map(t=>we(t)))},nthoftype:function(t,e){return re(t.map(t=>Ee(t)))}},Ne={tag:xe,id:ve,class:be,attribute:he,nthchild:we,nthoftype:Ee};function Me(t){return t.includes(Gt.tag)||t.includes(Gt.nthoftype)?[...t]:[...t,Gt.tag]}function*Ie(t,e){const r={};for(const n of t){const t=e[n];t&&t.length>0&&(r[n]=t)}for(const t of function*(t={}){const e=Object.entries(t);if(0===e.length)return;const r=[{index:e.length-1,partial:{}}];for(;r.length>0;){const t=r.pop();if(!t)break;const{index:n,partial:o}=t;if(n<0){yield o;continue}const[i,a]=e[n];for(let t=a.length-1;t>=0;t--)r.push({index:n-1,partial:Object.assign(Object.assign({},o),{[i]:a[t]})})}}(r))yield ke(t)}function ke(t={}){const e=[...ce];return t[Gt.tag]&&t[Gt.nthoftype]&&e.splice(e.indexOf(Gt.tag),1),e.map(e=>{return(n=t)[r=e]?n[r].join(""):"";var r,n}).join("")}function $e(t,e){return[...t.map(t=>e+" "+t),...t.map(t=>e+" > "+t)]}function*Fe(t,e,r="",n){const o=function*(t,e){const r=new Set,n=function(t,e){const{blacklist:r,whitelist:n,combineWithinSelector:o,maxCombinations:i}=e,a=ne(r),s=ne(n);return function(t){const{selectors:e,includeTag:r}=t,n=[...e];return r&&!n.includes("tag")&&n.push("tag"),n}(e).reduce((r,n)=>{const u=function(t,e,r){return(0,Ce[e])(t,r)}(t,n,e),l=function(t=[],e,r){return t.filter(t=>r(t)||!e(t))}(u,a,s),c=function(t=[],e){return t.sort((t,r)=>{const n=e(t),o=e(r);return n&&!o?-1:!n&&o?1:0})}(l,s);return r[n]=o?Array.from(Ae(c,{maxResults:i})):c.map(t=>[t]),r},{})}(t,e);for(const t of function*(t,e){for(const r of function(t){const{selectors:e,combineBetweenSelectors:r,includeTag:n,maxCandidates:o}=t,i=r?function(t=[],{maxResults:e=Number.POSITIVE_INFINITY}={}){return Array.from(Ae(t,{maxResults:e}))}(e,{maxResults:o}):e.map(t=>[t]);return n?i.map(Me):i}(e))yield*Ie(r,t)}(n,e))r.has(t)||(r.add(t),yield t)}(t,n);for(const n of function*(t,e){if(""===e)yield*t;else for(const r of t)yield*$e([r],e)}(o,r))oe(t,n,e)&&(yield n)}function*_e(t,e,r="",n){if(0===t.length)return null;const o=[t.length>1?t:[],...ae(t,e).map(t=>[t])];for(const t of o)for(const o of Fe(t,e,r,n))yield{foundElements:t,selector:o}}function Be(t){return{value:t,include:!1}}function Le({selectors:t,operator:e}){let r=[...ce];t[Gt.tag]&&t[Gt.nthoftype]&&(r=r.filter(t=>t!==Gt.tag));let n="";return r.forEach(e=>{var r;(null!==(r=t[e])&&void 0!==r?r:[]).forEach(({value:t,include:e})=>{e&&(n+=t)})}),e+n}function De(t,e){return t.map(t=>function(t,e){const r=ie(t,e).reverse(),n=e instanceof ShadowRoot,o=r.map((t,e)=>{var r;const o=function(t,e,r=""){const n={};return e.forEach(e=>{Reflect.set(n,e,function(t,e){return Ne[e](t,void 0)}(t,e).map(Be))}),{element:t,operator:r,selectors:n}}(t,[Gt.nthchild],n&&0===e?"":" > ");return(null!==(r=o.selectors.nthchild)&&void 0!==r?r:[]).forEach(t=>{t.include=!0}),o});return[n?"":e?":scope":":root",...o.map(Le)].join("")}(t,e)).join(", ")}function We(t,e={}){const r=function*(t,e={}){var r;const n=function(t){(t instanceof NodeList||t instanceof HTMLCollection)&&(t=Array.from(t));const e=(Array.isArray(t)?t:[t]).filter(Vt);return[...new Set(e)]}(t),o=function(t,e={}){const r=Object.assign(Object.assign({},Xt),e);return{selectors:(n=r.selectors,Array.isArray(n)?n.filter(t=>{return e=Gt,r=t,Object.values(e).includes(r);var e,r}):[]),whitelist:Qt(r.whitelist),blacklist:Qt(r.blacklist),root:te(r.root,t),combineWithinSelector:Kt(r.combineWithinSelector),combineBetweenSelectors:Kt(r.combineBetweenSelectors),includeTag:Kt(r.includeTag),maxCombinations:ee(r.maxCombinations),maxCandidates:ee(r.maxCandidates),useScope:Kt(r.useScope),maxResults:ee(r.maxResults),ignoreGeneratedClassNames:Kt(r.ignoreGeneratedClassNames)};var n}(n[0],e),i=null!==(r=o.root)&&void 0!==r?r:se(n[0]);let a=0;for(const t of function*({elements:t,root:e,rootSelector:r="",options:n}){let o=e,i=r,a=!0;for(;a;){let r=!1;for(const a of _e(t,o,i,n)){const{foundElements:n,selector:s}=a;if(r=!0,!oe(t,s,e)){o=n[0],i=s;break}yield s}r||(a=!1)}}({elements:n,options:o,root:i,rootSelector:""}))if(yield t,a++,a>=o.maxResults)return;if(n.length>1){const{maxResults:t}=e,r=function(t,e){var r={};for(var n in t)Object.prototype.hasOwnProperty.call(t,n)&&e.indexOf(n)<0&&(r[n]=t[n]);if(null!=t&&"function"==typeof Object.getOwnPropertySymbols){var o=0;for(n=Object.getOwnPropertySymbols(t);oWe(t,r)).join(", "),a++,a>=o.maxResults)return}const s=void 0!==e.root;yield De(n,o.useScope||s?i:void 0)}(t,Object.assign(Object.assign({},e),{maxResults:1}));return r.next().value}function Ue(t){return null==t?null:-1!==["a","audio","button","canvas","details","input","label","option","select","submit","textarea","video"].indexOf(t.nodeName.toLowerCase())||t.hasAttribute("contenteditable")&&"false"!=t.getAttribute("contenteditable").toLowerCase()?t.outerHTML:t.parentElement?Ue(t.parentElement):null}function ze(t){for(var e=0;e0&&e.top0&&e.left title"))||void 0===r?void 0:r.textContent.trim();if(i)return i;const a=null===(n=t.querySelector(":scope > desc"))||void 0===n?void 0:n.textContent.trim();if(a)return a;const s=t.closest("figure");if(s){var u;const t=null===(u=s.querySelector("figcaption"))||void 0===u?void 0:u.textContent.trim();if(t)return t}return null}function tr(t){return t.defaultPrevented||null!=Ue(document.activeElement)}function er(t){t.stopPropagation(),t.preventDefault()}function rr(t,e){e.repeat||webkit.messageHandlers.keyEventReceived.postMessage({phase:t,code:e.code,key:String.fromCharCode(e.keyCode),option:e.altKey,control:e.ctrlKey,shift:e.shiftKey,command:e.metaKey})}window.addEventListener("DOMContentLoaded",function(){document.addEventListener("click",qe,!1),document.addEventListener("pointerdown",Xe,!1),document.addEventListener("pointerup",Ke,!1),document.addEventListener("pointermove",Ye,!1),document.addEventListener("pointercancel",Je,!1),document.addEventListener("selectionchange",function(){Ge=!window.getSelection().isCollapsed})}),window.addEventListener("keydown",t=>{tr(t)||(er(t),rr("down",t))}),window.addEventListener("keyup",t=>{tr(t)||(er(t),rr("up",t))}),globalThis.readium={scrollToId:function(t,e){let r=document.getElementById(t);return!!r&&(C(r.getBoundingClientRect(),e),!0)},scrollToPosition:function(t,e,r){t<0||t>1?console.error(`Expected a valid progression in scrollToPosition, got ${t}`):R()?P()?M({left:-document.scrollingElement.scrollWidth*t,animated:r}):M({top:document.scrollingElement.scrollHeight*t,animated:r}):M({left:I(document.scrollingElement.scrollWidth*t*("rtl"==e?-1:1)),animated:r})},scrollToLocator:function(t,e){let r=k(t);return!!r&&function(t,e){return C(t.getBoundingClientRect(),e)}(r,e)},scrollLeft:function(t,e){var r="rtl"==t,n=document.scrollingElement.scrollWidth,o=window.innerWidth,i=window.scrollX-o,a=r?-(n-o):0;return N(Math.max(i,a),e)},scrollRight:function(t,e){var r="rtl"==t,n=document.scrollingElement.scrollWidth,o=window.innerWidth,i=window.scrollX+o,a=r?0:n-o;return N(Math.min(i,a),e)},setCSSProperties:function(t){for(const e in t)$(e,t[e])},setProperty:$,removeProperty:F,registerDecorationTemplates:function(t){var e="";for(const n of Object.entries(t)){var r=Bt(n,2);const t=r[0],o=r[1];Wt.set(t,o),o.stylesheet&&(e+=o.stylesheet+"\n")}if(e){let t=document.createElement("style");t.innerHTML=e,document.getElementsByTagName("head")[0].appendChild(t)}},getDecorations:function(t){var e=Ut.get(t);return e||(e=function(t,e){var r=[],n=0,o=null,i=!1;function a(e){let o=t+"-"+n++,i=k(e.locator);if(!i)return void _("Can't locate DOM range for decoration",e);let a={id:o,decoration:e,range:i};r.push(a),u(a)}function s(t){let e=r.findIndex(e=>e.decoration.id===t);if(-1===e)return;let n=r[e];r.splice(e,1),n.clickableElements=null,n.container&&(n.container.remove(),n.container=null)}function u(r){let n=(o||((o=document.createElement("div")).id=t,o.dataset.group=e,o.style.pointerEvents="none",requestAnimationFrame(function(){null!=o&&document.body.append(o)})),o),i=Wt.get(r.decoration.style);if(!i)return void B(`Unknown decoration style: ${r.decoration.style}`);let a=document.createElement("div");a.id=r.id,a.dataset.style=r.decoration.style,a.style.pointerEvents="none";const s=getComputedStyle(document.body).writingMode,u="vertical-rl"===s||"vertical-lr"===s,l=document.scrollingElement,c=l.scrollLeft,f=l.scrollTop,p=u?window.innerHeight:window.innerWidth,d=u?window.innerWidth:window.innerHeight,y=parseInt(getComputedStyle(document.documentElement).getPropertyValue("column-count"))||1,h=(u?d:p)/y;function g(t,e,r,n){t.style.position="absolute";const o="vertical-rl"===n;if(o||"vertical-lr"===n){if("wrap"===i.width)t.style.width=`${e.width}px`,t.style.height=`${e.height}px`,o?t.style.right=`${-e.right-c+l.clientWidth}px`:t.style.left=`${e.left+c}px`,t.style.top=`${e.top+f}px`;else if("viewport"===i.width){t.style.width=`${e.height}px`,t.style.height=`${p}px`;const r=Math.floor(e.top/p)*p;o?t.style.right=-e.right-c+"px":t.style.left=`${e.left+c}px`,t.style.top=`${r+f}px`}else if("bounds"===i.width)t.style.width=`${r.height}px`,t.style.height=`${p}px`,o?t.style.right=`${-r.right-c+l.clientWidth}px`:t.style.left=`${r.left+c}px`,t.style.top=`${r.top+f}px`;else if("page"===i.width){t.style.width=`${e.height}px`,t.style.height=`${h}px`;const r=Math.floor(e.top/h)*h;o?t.style.right=`${-e.right-c+l.clientWidth}px`:t.style.left=`${e.left+c}px`,t.style.top=`${r+f}px`}}else if("wrap"===i.width)t.style.width=`${e.width}px`,t.style.height=`${e.height}px`,t.style.left=`${e.left+c}px`,t.style.top=`${e.top+f}px`;else if("viewport"===i.width){t.style.width=`${p}px`,t.style.height=`${e.height}px`;const r=Math.floor(e.left/p)*p;t.style.left=`${r+c}px`,t.style.top=`${e.top+f}px`}else if("bounds"===i.width)t.style.width=`${r.width}px`,t.style.height=`${e.height}px`,t.style.left=`${r.left+c}px`,t.style.top=`${e.top+f}px`;else if("page"===i.width){t.style.width=`${h}px`,t.style.height=`${e.height}px`;const r=Math.floor(e.left/h)*h;t.style.left=`${r+c}px`,t.style.top=`${e.top+f}px`}}let m,b=r.range.getBoundingClientRect();try{let t=document.createElement("template");t.innerHTML=r.decoration.element.trim(),m=t.content.firstElementChild}catch(t){return void B(`Invalid decoration element "${r.decoration.element}": ${t.message}`)}if("boxes"===i.layout){const t=!s.startsWith("vertical"),e=(v=r.range.startContainer).nodeType===Node.ELEMENT_NODE?v:v.parentElement,n=getComputedStyle(e).writingMode,o=U(r.range,t).sort((t,e)=>t.top!==e.top?t.top-e.top:"vertical-rl"===n?e.left-t.left:t.left-e.left);for(let t of o){const e=m.cloneNode(!0);e.style.pointerEvents="none",e.dataset.writingMode=n,g(e,t,b,s),a.append(e)}}else if("bounds"===i.layout){const t=m.cloneNode(!0);t.style.pointerEvents="none",t.dataset.writingMode=s,g(t,b,b,s),a.append(t)}var v;n.append(a),r.container=a,r.clickableElements=Array.from(a.querySelectorAll("[data-activable='1']")),0===r.clickableElements.length&&(r.clickableElements=Array.from(a.children))}function l(){o&&(o.remove(),o=null)}return{add:a,remove:s,update:function(t){s(t.id),a(t)},clear:function(){l(),r.length=0},items:r,requestLayout:function(){l(),r.forEach(t=>u(t))},isActivable:function(){return i},setActivable:function(){i=!0}}}("r2-decoration-"+zt++,t),Ut.set(t,e)),e},findFirstVisibleLocator:function(){const t=ze(document.body);return{href:"#",type:"application/xhtml+xml",locations:{cssSelector:We(t)},text:{highlight:t.textContent}}}},window.readium.isReflowable=!0,webkit.messageHandlers.spreadLoadStarted.postMessage({}),window.addEventListener("load",function(){window.requestAnimationFrame(function(){webkit.messageHandlers.spreadLoaded.postMessage({})});let t=document.createElement("meta");t.setAttribute("name","viewport"),t.setAttribute("content","width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, shrink-to-fit=no"),document.head.appendChild(t)})})()})(); +(()=>{var t={3618(t,e){"use strict";function r(t){return t.split("").reverse().join("")}function n(t){return(t|-t)>>31&1}function o(t,e,r,o){var i=t.P[r],a=t.M[r],s=o>>>31,u=e[r]|s,l=u|a,c=(u&i)+i^i|u,f=a|~(c|i),p=i&c,d=n(f&t.lastRowMask[r])-n(p&t.lastRowMask[r]);return f<<=1,p<<=1,i=(p|=s)|~(l|(f|=n(o)-s)),a=f&l,t.P[r]=i,t.M[r]=a,d}function i(t,e,r){if(0===e.length)return[];r=Math.min(r,e.length);var n=[],i=32,a=Math.ceil(e.length/i)-1,s={P:new Uint32Array(a+1),M:new Uint32Array(a+1),lastRowMask:new Uint32Array(a+1)};s.lastRowMask.fill(1<<31),s.lastRowMask[a]=1<<(e.length-1)%i;for(var u=new Uint32Array(a+1),l=new Map,c=[],f=0;f<256;f++)c.push(u);for(var p=0;p=e.length||e.charCodeAt(m)===d&&(y[h]|=1<0&&v[b]>=r+i;)b-=1;b===a&&v[b]<=r&&(v[b]0?r:0,!0)},o?o(t.exports,"apply",{value:a}):t.exports.apply=a},5298(t,e,r){"use strict";var n=r(703),o=r(5312),i=o([n("%String.prototype.indexOf%")]);t.exports=function(t,e){var r=n(t,!!e);return"function"==typeof r&&i(t,".prototype.")>-1?o([r]):r}},7517(t,e,r){"use strict";var n=r(9173),o=r(7388),i=r(7379),a=r(3492);t.exports=function(t,e,r){if(!t||"object"!=typeof t&&"function"!=typeof t)throw new i("`obj` must be an object or a function`");if("string"!=typeof e&&"symbol"!=typeof e)throw new i("`property` must be a string or a symbol`");if(arguments.length>3&&"boolean"!=typeof arguments[3]&&null!==arguments[3])throw new i("`nonEnumerable`, if provided, must be a boolean or null");if(arguments.length>4&&"boolean"!=typeof arguments[4]&&null!==arguments[4])throw new i("`nonWritable`, if provided, must be a boolean or null");if(arguments.length>5&&"boolean"!=typeof arguments[5]&&null!==arguments[5])throw new i("`nonConfigurable`, if provided, must be a boolean or null");if(arguments.length>6&&"boolean"!=typeof arguments[6])throw new i("`loose`, if provided, must be a boolean");var s=arguments.length>3?arguments[3]:null,u=arguments.length>4?arguments[4]:null,l=arguments.length>5?arguments[5]:null,c=arguments.length>6&&arguments[6],f=!!a&&a(t,e);if(n)n(t,e,{configurable:null===l&&f?f.configurable:!l,enumerable:null===s&&f?f.enumerable:!s,value:r,writable:null===u&&f?f.writable:!u});else{if(!c&&(s||u||l))throw new o("This environment does not support defining a property as non-configurable, non-writable, or non-enumerable.");t[e]=r}}},8189(t,e,r){"use strict";var n=r(1748),o="function"==typeof Symbol&&"symbol"==typeof Symbol("foo"),i=Object.prototype.toString,a=Array.prototype.concat,s=r(7517),u=r(708)(),l=function(t,e,r,n){if(e in t)if(!0===n){if(t[e]===r)return}else if("function"!=typeof(o=n)||"[object Function]"!==i.call(o)||!n())return;var o;u?s(t,e,r,!0):s(t,e,r)},c=function(t,e){var r=arguments.length>2?arguments[2]:{},i=n(e);o&&(i=a.call(i,Object.getOwnPropertySymbols(e)));for(var s=0;s0&&arguments[1]||"Cannot call method on "+t);return t}},9253(t){"use strict";t.exports=Object},4938(t){"use strict";t.exports=function(t){return!!t&&("function"==typeof t||"object"==typeof t)}},3148(t,e,r){"use strict";var n=r(703)("%Object.defineProperty%",!0),o=r(6618)(),i=r(9939),a=r(7379),s=o?Symbol.toStringTag:null;t.exports=function(t,e){var r=arguments.length>2&&!!arguments[2]&&arguments[2].force,o=arguments.length>2&&!!arguments[2]&&arguments[2].nonConfigurable;if(void 0!==r&&"boolean"!=typeof r||void 0!==o&&"boolean"!=typeof o)throw new a("if provided, the `overrideIfSet` and `nonConfigurable` options must be booleans");!s||!r&&i(t,s)||(n?n(t,s,{configurable:!o,enumerable:!1,value:e,writable:!1}):t[s]=e)}},2632(t,e,r){"use strict";var n="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator,o=r(2719),i=r(5833),a=r(1718),s=r(7379),u=r(5465),l=r(7377);t.exports=function(t){if(u(t))return t;var e,r="default";if(arguments.length>1&&(arguments[1]===String?r="string":arguments[1]===Number&&(r="number")),n&&(Symbol.toPrimitive?e=l(t,Symbol.toPrimitive):a(t)&&(e=Symbol.prototype.valueOf)),void 0!==e){var c=e.call(t,r);if(u(c))return c;throw new s("unable to convert exotic object to primitive")}return"default"===r&&(i(t)||a(t))&&(r="string"),function(t,e){if(null==t)throw new s("Cannot call method on "+t);if("string"!=typeof e||"number"!==e&&"string"!==e)throw new s('hint must be "string" or "number"');var r,n,i,a="string"===e?["toString","valueOf"]:["valueOf","toString"];for(i=0;i1&&"boolean"!=typeof e)throw new c('"allowMissing" argument must be a boolean');if(null===z(/^%?[^%]*%?$/,t))throw new l("`%` may not be present anywhere but at the beginning and end of the intrinsic name");var r=function(t){var e=U(t,0,1),r=U(t,-1);if("%"===e&&"%"!==r)throw new l("invalid intrinsic syntax, expected closing `%`");if("%"===r&&"%"!==e)throw new l("invalid intrinsic syntax, expected opening `%`");var n=[];return W(t,H,function(t,e,r,o){n[n.length]=r?W(o,V,"$1"):e||t}),n}(t),n=r.length>0?r[0]:"",o=G("%"+n+"%",e),i=o.name,a=o.value,s=!1,u=o.alias;u&&(n=u[0],D(r,L([0,1],u)));for(var f=1,p=!0;f=r.length){var g=x(a,d);a=(p=!!g)&&"get"in g&&!("originalValue"in g.get)?g.get:a[d]}else p=B(a,d),a=a[d];p&&!s&&(I[i]=a)}}return a}},8819(t,e,r){"use strict";var n=r(9253);t.exports=n.getPrototypeOf||null},2517(t){"use strict";t.exports="undefined"!=typeof Reflect&&Reflect.getPrototypeOf||null},1611(t,e,r){"use strict";var n=r(2517),o=r(8819),i=r(1449);t.exports=n?function(t){return n(t)}:o?function(t){if(!t||"object"!=typeof t&&"function"!=typeof t)throw new TypeError("getProto: not an object");return o(t)}:i?function(t){return i(t)}:null},4656(t){"use strict";t.exports=Object.getOwnPropertyDescriptor},3492(t,e,r){"use strict";var n=r(4656);if(n)try{n([],"length")}catch(t){n=null}t.exports=n},708(t,e,r){"use strict";var n=r(9173),o=function(){return!!n};o.hasArrayLengthDefineBug=function(){if(!n)return null;try{return 1!==n([],"length",{value:1}).length}catch(t){return!0}},t.exports=o},6052(t){"use strict";var e={__proto__:null,foo:{}},r={__proto__:e}.foo===e.foo&&!(e instanceof Object);t.exports=function(){return r}},7657(t,e,r){"use strict";var n="undefined"!=typeof Symbol&&Symbol,o=r(8123);t.exports=function(){return"function"==typeof n&&"function"==typeof Symbol&&"symbol"==typeof n("foo")&&"symbol"==typeof Symbol("bar")&&o()}},8123(t){"use strict";t.exports=function(){if("function"!=typeof Symbol||"function"!=typeof Object.getOwnPropertySymbols)return!1;if("symbol"==typeof Symbol.iterator)return!0;var t={},e=Symbol("test"),r=Object(e);if("string"==typeof e)return!1;if("[object Symbol]"!==Object.prototype.toString.call(e))return!1;if("[object Symbol]"!==Object.prototype.toString.call(r))return!1;for(var n in t[e]=42,t)return!1;if("function"==typeof Object.keys&&0!==Object.keys(t).length)return!1;if("function"==typeof Object.getOwnPropertyNames&&0!==Object.getOwnPropertyNames(t).length)return!1;var o=Object.getOwnPropertySymbols(t);if(1!==o.length||o[0]!==e)return!1;if(!Object.prototype.propertyIsEnumerable.call(t,e))return!1;if("function"==typeof Object.getOwnPropertyDescriptor){var i=Object.getOwnPropertyDescriptor(t,e);if(42!==i.value||!0!==i.enumerable)return!1}return!0}},6618(t,e,r){"use strict";var n=r(8123);t.exports=function(){return n()&&!!Symbol.toStringTag}},9939(t,e,r){"use strict";var n=Function.prototype.call,o=Object.prototype.hasOwnProperty,i=r(3583);t.exports=i.call(n,o)},6561(t,e,r){"use strict";var n=r(9939),o=r(6746)(),i=r(7379),a={assert:function(t,e){if(!t||"object"!=typeof t&&"function"!=typeof t)throw new i("`O` is not an object");if("string"!=typeof e)throw new i("`slot` must be a string");if(o.assert(t),!a.has(t,e))throw new i("`"+e+"` is not present on `O`")},get:function(t,e){if(!t||"object"!=typeof t&&"function"!=typeof t)throw new i("`O` is not an object");if("string"!=typeof e)throw new i("`slot` must be a string");var r=o.get(t);return r&&r["$"+e]},has:function(t,e){if(!t||"object"!=typeof t&&"function"!=typeof t)throw new i("`O` is not an object");if("string"!=typeof e)throw new i("`slot` must be a string");var r=o.get(t);return!!r&&n(r,"$"+e)},set:function(t,e,r){if(!t||"object"!=typeof t&&"function"!=typeof t)throw new i("`O` is not an object");if("string"!=typeof e)throw new i("`slot` must be a string");var n=o.get(t);n||(n={},o.set(t,n)),n["$"+e]=r}};Object.freeze&&Object.freeze(a),t.exports=a},2719(t){"use strict";var e,r,n=Function.prototype.toString,o="object"==typeof Reflect&&null!==Reflect&&Reflect.apply;if("function"==typeof o&&"function"==typeof Object.defineProperty)try{e=Object.defineProperty({},"length",{get:function(){throw r}}),r={},o(function(){throw 42},null,e)}catch(t){t!==r&&(o=null)}else o=null;var i=/^\s*class\b/,a=function(t){try{var e=n.call(t);return i.test(e)}catch(t){return!1}},s=function(t){try{return!a(t)&&(n.call(t),!0)}catch(t){return!1}},u=Object.prototype.toString,l="function"==typeof Symbol&&!!Symbol.toStringTag,c=!(0 in[,]),f=function(){return!1};if("object"==typeof document){var p=document.all;u.call(p)===u.call(document.all)&&(f=function(t){if((c||!t)&&(void 0===t||"object"==typeof t))try{var e=u.call(t);return("[object HTMLAllCollection]"===e||"[object HTML document.all class]"===e||"[object HTMLCollection]"===e||"[object Object]"===e)&&null==t("")}catch(t){}return!1})}t.exports=o?function(t){if(f(t))return!0;if(!t)return!1;if("function"!=typeof t&&"object"!=typeof t)return!1;try{o(t,null,e)}catch(t){if(t!==r)return!1}return!a(t)&&s(t)}:function(t){if(f(t))return!0;if(!t)return!1;if("function"!=typeof t&&"object"!=typeof t)return!1;if(l)return s(t);if(a(t))return!1;var e=u.call(t);return!("[object Function]"!==e&&"[object GeneratorFunction]"!==e&&!/^\[object HTML/.test(e))&&s(t)}},5833(t,e,r){"use strict";var n=r(5298),o=n("Date.prototype.getDay"),i=n("Object.prototype.toString"),a=r(6618)();t.exports=function(t){return"object"==typeof t&&null!==t&&(a?function(t){try{return o(t),!0}catch(t){return!1}}(t):"[object Date]"===i(t))}},4587(t,e,r){"use strict";var n,o=r(5298),i=r(6618)(),a=r(9939),s=r(3492);if(i){var u=o("RegExp.prototype.exec"),l={},c=function(){throw l},f={toString:c,valueOf:c};"symbol"==typeof Symbol.toPrimitive&&(f[Symbol.toPrimitive]=c),n=function(t){if(!t||"object"!=typeof t)return!1;var e=s(t,"lastIndex");if(!e||!a(e,"value"))return!1;try{u(t,f)}catch(t){return t===l}}}else{var p=o("Object.prototype.toString");n=function(t){return!(!t||"object"!=typeof t&&"function"!=typeof t)&&"[object RegExp]"===p(t)}}t.exports=n},1718(t,e,r){"use strict";var n=r(5298),o=n("Object.prototype.toString"),i=r(7657)(),a=r(5537);if(i){var s=n("Symbol.prototype.toString"),u=a(/^Symbol\(.*\)$/);t.exports=function(t){if("symbol"==typeof t)return!0;if(!t||"object"!=typeof t||"[object Symbol]"!==o(t))return!1;try{return function(t){return"symbol"==typeof t.valueOf()&&u(s(t))}(t)}catch(t){return!1}}}else t.exports=function(t){return!1}},9895(t){"use strict";t.exports=Math.abs},6241(t){"use strict";t.exports=Number.MAX_SAFE_INTEGER||9007199254740991},2153(t){"use strict";t.exports=Math.floor},1084(t,e,r){"use strict";var n=r(5518);t.exports=function(t){return("number"==typeof t||"bigint"==typeof t)&&!n(t)&&t!==1/0&&t!==-1/0}},1029(t,e,r){"use strict";var n=r(9895),o=r(2153),i=r(5518),a=r(1084);t.exports=function(t){if("number"!=typeof t||i(t)||!a(t))return!1;var e=n(t);return o(e)===e}},5518(t){"use strict";t.exports=Number.isNaN||function(t){return t!=t}},457(t){"use strict";t.exports=Math.max},1179(t){"use strict";t.exports=Math.min},5985(t){"use strict";t.exports=Math.pow},8639(t){"use strict";t.exports=Math.round},5738(t,e,r){"use strict";var n=r(5518);t.exports=function(t){return n(t)||0===t?t:t<0?-1:1}},4922(t,e,r){var n="function"==typeof Map&&Map.prototype,o=Object.getOwnPropertyDescriptor&&n?Object.getOwnPropertyDescriptor(Map.prototype,"size"):null,i=n&&o&&"function"==typeof o.get?o.get:null,a=n&&Map.prototype.forEach,s="function"==typeof Set&&Set.prototype,u=Object.getOwnPropertyDescriptor&&s?Object.getOwnPropertyDescriptor(Set.prototype,"size"):null,l=s&&u&&"function"==typeof u.get?u.get:null,c=s&&Set.prototype.forEach,f="function"==typeof WeakMap&&WeakMap.prototype?WeakMap.prototype.has:null,p="function"==typeof WeakSet&&WeakSet.prototype?WeakSet.prototype.has:null,d="function"==typeof WeakRef&&WeakRef.prototype?WeakRef.prototype.deref:null,y=Boolean.prototype.valueOf,h=Object.prototype.toString,g=Function.prototype.toString,m=String.prototype.match,b=String.prototype.slice,v=String.prototype.replace,w=String.prototype.toUpperCase,x=String.prototype.toLowerCase,S=RegExp.prototype.test,E=Array.prototype.concat,A=Array.prototype.join,O=Array.prototype.slice,j=Math.floor,R="function"==typeof BigInt?BigInt.prototype.valueOf:null,P=Object.getOwnPropertySymbols,C="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?Symbol.prototype.toString:null,T="function"==typeof Symbol&&"object"==typeof Symbol.iterator,N="function"==typeof Symbol&&Symbol.toStringTag&&(Symbol.toStringTag,1)?Symbol.toStringTag:null,M=Object.prototype.propertyIsEnumerable,I=("function"==typeof Reflect?Reflect.getPrototypeOf:Object.getPrototypeOf)||([].__proto__===Array.prototype?function(t){return t.__proto__}:null);function k(t,e){if(t===1/0||t===-1/0||t!=t||t&&t>-1e3&&t<1e3||S.call(/e/,e))return e;var r=/[0-9](?=(?:[0-9]{3})+(?![0-9]))/g;if("number"==typeof t){var n=t<0?-j(-t):j(t);if(n!==t){var o=String(n),i=b.call(e,o.length+1);return v.call(o,r,"$&_")+"."+v.call(v.call(i,/([0-9]{3})/g,"$&_"),/_$/,"")}}return v.call(e,r,"$&_")}var $=r(2179),F=$.custom,_=V(F)?F:null,B={__proto__:null,double:'"',single:"'"},L={__proto__:null,double:/(["\\])/g,single:/(['\\])/g};function D(t,e,r){var n=r.quoteStyle||e,o=B[n];return o+t+o}function W(t){return v.call(String(t),/"/g,""")}function U(t){return!N||!("object"==typeof t&&(N in t||void 0!==t[N]))}function z(t){return"[object Array]"===X(t)&&U(t)}function H(t){return"[object RegExp]"===X(t)&&U(t)}function V(t){if(T)return t&&"object"==typeof t&&t instanceof Symbol;if("symbol"==typeof t)return!0;if(!t||"object"!=typeof t||!C)return!1;try{return C.call(t),!0}catch(t){}return!1}t.exports=function t(e,r,n,o){var s=r||{};if(q(s,"quoteStyle")&&!q(B,s.quoteStyle))throw new TypeError('option "quoteStyle" must be "single" or "double"');if(q(s,"maxStringLength")&&("number"==typeof s.maxStringLength?s.maxStringLength<0&&s.maxStringLength!==1/0:null!==s.maxStringLength))throw new TypeError('option "maxStringLength", if provided, must be a positive integer, Infinity, or `null`');var u=!q(s,"customInspect")||s.customInspect;if("boolean"!=typeof u&&"symbol"!==u)throw new TypeError("option \"customInspect\", if provided, must be `true`, `false`, or `'symbol'`");if(q(s,"indent")&&null!==s.indent&&"\t"!==s.indent&&!(parseInt(s.indent,10)===s.indent&&s.indent>0))throw new TypeError('option "indent" must be "\\t", an integer > 0, or `null`');if(q(s,"numericSeparator")&&"boolean"!=typeof s.numericSeparator)throw new TypeError('option "numericSeparator", if provided, must be `true` or `false`');var h=s.numericSeparator;if(void 0===e)return"undefined";if(null===e)return"null";if("boolean"==typeof e)return e?"true":"false";if("string"==typeof e)return Y(e,s);if("number"==typeof e){if(0===e)return 1/0/e>0?"0":"-0";var w=String(e);return h?k(e,w):w}if("bigint"==typeof e){var S=String(e)+"n";return h?k(e,S):S}var j=void 0===s.depth?5:s.depth;if(void 0===n&&(n=0),n>=j&&j>0&&"object"==typeof e)return z(e)?"[Array]":"[Object]";var P,F=function(t,e){var r;if("\t"===t.indent)r="\t";else{if(!("number"==typeof t.indent&&t.indent>0))return null;r=A.call(Array(t.indent+1)," ")}return{base:r,prev:A.call(Array(e+1),r)}}(s,n);if(void 0===o)o=[];else if(K(o,e)>=0)return"[Circular]";function L(e,r,i){if(r&&(o=O.call(o)).push(r),i){var a={depth:s.depth};return q(s,"quoteStyle")&&(a.quoteStyle=s.quoteStyle),t(e,a,n+1,o)}return t(e,s,n+1,o)}if("function"==typeof e&&!H(e)){var G=function(t){if(t.name)return t.name;var e=m.call(g.call(t),/^function\s*([\w$]+)/);return e?e[1]:null}(e),J=rt(e,L);return"[Function"+(G?": "+G:" (anonymous)")+"]"+(J.length>0?" { "+A.call(J,", ")+" }":"")}if(V(e)){var nt=T?v.call(String(e),/^(Symbol\(.*\))_[^)]*$/,"$1"):C.call(e);return"object"!=typeof e||T?nt:Q(nt)}if((P=e)&&"object"==typeof P&&("undefined"!=typeof HTMLElement&&P instanceof HTMLElement||"string"==typeof P.nodeName&&"function"==typeof P.getAttribute)){for(var ot="<"+x.call(String(e.nodeName)),it=e.attributes||[],at=0;at"}if(z(e)){if(0===e.length)return"[]";var st=rt(e,L);return F&&!function(t){for(var e=0;e=0)return!1;return!0}(st)?"["+et(st,F)+"]":"[ "+A.call(st,", ")+" ]"}if(function(t){return"[object Error]"===X(t)&&U(t)}(e)){var ut=rt(e,L);return"cause"in Error.prototype||!("cause"in e)||M.call(e,"cause")?0===ut.length?"["+String(e)+"]":"{ ["+String(e)+"] "+A.call(ut,", ")+" }":"{ ["+String(e)+"] "+A.call(E.call("[cause]: "+L(e.cause),ut),", ")+" }"}if("object"==typeof e&&u){if(_&&"function"==typeof e[_]&&$)return $(e,{depth:j-n});if("symbol"!==u&&"function"==typeof e.inspect)return e.inspect()}if(function(t){if(!i||!t||"object"!=typeof t)return!1;try{i.call(t);try{l.call(t)}catch(t){return!0}return t instanceof Map}catch(t){}return!1}(e)){var lt=[];return a&&a.call(e,function(t,r){lt.push(L(r,e,!0)+" => "+L(t,e))}),tt("Map",i.call(e),lt,F)}if(function(t){if(!l||!t||"object"!=typeof t)return!1;try{l.call(t);try{i.call(t)}catch(t){return!0}return t instanceof Set}catch(t){}return!1}(e)){var ct=[];return c&&c.call(e,function(t){ct.push(L(t,e))}),tt("Set",l.call(e),ct,F)}if(function(t){if(!f||!t||"object"!=typeof t)return!1;try{f.call(t,f);try{p.call(t,p)}catch(t){return!0}return t instanceof WeakMap}catch(t){}return!1}(e))return Z("WeakMap");if(function(t){if(!p||!t||"object"!=typeof t)return!1;try{p.call(t,p);try{f.call(t,f)}catch(t){return!0}return t instanceof WeakSet}catch(t){}return!1}(e))return Z("WeakSet");if(function(t){if(!d||!t||"object"!=typeof t)return!1;try{return d.call(t),!0}catch(t){}return!1}(e))return Z("WeakRef");if(function(t){return"[object Number]"===X(t)&&U(t)}(e))return Q(L(Number(e)));if(function(t){if(!t||"object"!=typeof t||!R)return!1;try{return R.call(t),!0}catch(t){}return!1}(e))return Q(L(R.call(e)));if(function(t){return"[object Boolean]"===X(t)&&U(t)}(e))return Q(y.call(e));if(function(t){return"[object String]"===X(t)&&U(t)}(e))return Q(L(String(e)));if("undefined"!=typeof window&&e===window)return"{ [object Window] }";if("undefined"!=typeof globalThis&&e===globalThis||"undefined"!=typeof globalThis&&e===globalThis)return"{ [object globalThis] }";if(!function(t){return"[object Date]"===X(t)&&U(t)}(e)&&!H(e)){var ft=rt(e,L),pt=I?I(e)===Object.prototype:e instanceof Object||e.constructor===Object,dt=e instanceof Object?"":"null prototype",yt=!pt&&N&&Object(e)===e&&N in e?b.call(X(e),8,-1):dt?"Object":"",ht=(pt||"function"!=typeof e.constructor?"":e.constructor.name?e.constructor.name+" ":"")+(yt||dt?"["+A.call(E.call([],yt||[],dt||[]),": ")+"] ":"");return 0===ft.length?ht+"{}":F?ht+"{"+et(ft,F)+"}":ht+"{ "+A.call(ft,", ")+" }"}return String(e)};var G=Object.prototype.hasOwnProperty||function(t){return t in this};function q(t,e){return G.call(t,e)}function X(t){return h.call(t)}function K(t,e){if(t.indexOf)return t.indexOf(e);for(var r=0,n=t.length;re.maxStringLength){var r=t.length-e.maxStringLength,n="... "+r+" more character"+(r>1?"s":"");return Y(b.call(t,0,e.maxStringLength),e)+n}var o=L[e.quoteStyle||"single"];return o.lastIndex=0,D(v.call(v.call(t,o,"\\$1"),/[\x00-\x1f]/g,J),"single",e)}function J(t){var e=t.charCodeAt(0),r={8:"b",9:"t",10:"n",12:"f",13:"r"}[e];return r?"\\"+r:"\\x"+(e<16?"0":"")+w.call(e.toString(16))}function Q(t){return"Object("+t+")"}function Z(t){return t+" { ? }"}function tt(t,e,r,n){return t+" ("+e+") {"+(n?et(r,n):A.call(r,", "))+"}"}function et(t,e){if(0===t.length)return"";var r="\n"+e.prev+e.base;return r+A.call(t,","+r)+"\n"+e.prev}function rt(t,e){var r=z(t),n=[];if(r){n.length=t.length;for(var o=0;o0&&!o.call(t,0))for(var h=0;h0)for(var g=0;g=0&&"[object Function]"===e.call(t.callee)),n}},3743(t,e,r){"use strict";var n=r(7843),o=r(7379),i=Object;t.exports=n(function(){if(null==this||this!==i(this))throw new o("RegExp.prototype.flags getter called on non-object");var t="";return this.hasIndices&&(t+="d"),this.global&&(t+="g"),this.ignoreCase&&(t+="i"),this.multiline&&(t+="m"),this.dotAll&&(t+="s"),this.unicode&&(t+="u"),this.unicodeSets&&(t+="v"),this.sticky&&(t+="y"),t},"get flags",!0)},1721(t,e,r){"use strict";var n=r(8189),o=r(7965),i=r(3743),a=r(4510),s=r(3980),u=o(a());n(u,{getPolyfill:a,implementation:i,shim:s}),t.exports=u},4510(t,e,r){"use strict";var n=r(3743),o=r(8189).supportsDescriptors,i=Object.getOwnPropertyDescriptor;t.exports=function(){if(o&&"gim"===/a/gim.flags){var t=i(RegExp.prototype,"flags");if(t&&"function"==typeof t.get&&"dotAll"in RegExp.prototype&&"hasIndices"in RegExp.prototype){var e="",r={};if(Object.defineProperty(r,"hasIndices",{get:function(){e+="d"}}),Object.defineProperty(r,"sticky",{get:function(){e+="y"}}),t.get.call(r),"dy"===e)return t.get}}return n}},3980(t,e,r){"use strict";var n=r(8189).supportsDescriptors,o=r(4510),i=r(3492),a=Object.defineProperty,s=r(9183),u=r(1611),l=/a/;t.exports=function(){if(!n||!u)throw new s("RegExp.prototype.flags requires a true ES5 environment that supports property descriptors");var t=o(),e=u(l),r=i(e,"flags");return r&&r.get===t||a(e,"flags",{configurable:!0,enumerable:!1,get:t}),t}},5537(t,e,r){"use strict";var n=r(5298),o=r(4587),i=n("RegExp.prototype.exec"),a=r(7379);t.exports=function(t){if(!o(t))throw new a("`regex` must be a RegExp");return function(e){return null!==i(t,e)}}},2644(t,e,r){"use strict";var n=r(703),o=r(7517),i=r(708)(),a=r(3492),s=r(7379),u=n("%Math.floor%");t.exports=function(t,e){if("function"!=typeof t)throw new s("`fn` is not a function");if("number"!=typeof e||e<0||e>4294967295||u(e)!==e)throw new s("`length` must be a positive 32-bit integer");var r=arguments.length>2&&!!arguments[2],n=!0,l=!0;if("length"in t&&a){var c=a(t,"length");c&&!c.configurable&&(n=!1),c&&!c.writable&&(l=!1)}return(n||l||!r)&&(i?o(t,"length",e,!0,!0):o(t,"length",e)),t}},7843(t,e,r){"use strict";var n=r(7517),o=r(708)(),i=r(3749).functionsHaveConfigurableNames(),a=r(7379);t.exports=function(t,e){if("function"!=typeof t)throw new a("`fn` is not a function");return arguments.length>2&&!!arguments[2]&&!i||(o?n(t,"name",e,!0,!0):n(t,"name",e)),t}},7797(t,e,r){"use strict";var n=r(4922),o=r(7379),i=function(t,e,r){for(var n,o=t;null!=(n=o.next);o=n)if(n.key===e)return o.next=n.next,r||(n.next=t.next,t.next=n),n};t.exports=function(){var t,e={assert:function(t){if(!e.has(t))throw new o("Side channel does not contain "+n(t))},delete:function(e){var r=function(t,e){if(t)return i(t,e,!0)}(t,e);return r&&t&&!t.next&&(t=void 0),!!r},get:function(e){return function(t,e){if(t){var r=i(t,e);return r&&r.value}}(t,e)},has:function(e){return function(t,e){return!!t&&!!i(t,e)}(t,e)},set:function(e,r){t||(t={next:void 0}),function(t,e,r){var n=i(t,e);n?n.value=r:t.next={key:e,next:t.next,value:r}}(t,e,r)}};return e}},1085(t,e,r){"use strict";var n=r(703),o=r(5298),i=r(4922),a=r(7379),s=n("%Map%",!0),u=o("Map.prototype.get",!0),l=o("Map.prototype.set",!0),c=o("Map.prototype.has",!0),f=o("Map.prototype.delete",!0),p=o("Map.prototype.size",!0);t.exports=!!s&&function(){var t,e={assert:function(t){if(!e.has(t))throw new a("Side channel does not contain "+i(t))},delete:function(e){if(t){var r=f(t,e);return 0===p(t)&&(t=void 0),r}return!1},get:function(e){if(t)return u(t,e)},has:function(e){return!!t&&c(t,e)},set:function(e,r){t||(t=new s),l(t,e,r)}};return e}},2468(t,e,r){"use strict";var n=r(703),o=r(5298),i=r(4922),a=r(1085),s=r(7379),u=n("%WeakMap%",!0),l=o("WeakMap.prototype.get",!0),c=o("WeakMap.prototype.set",!0),f=o("WeakMap.prototype.has",!0),p=o("WeakMap.prototype.delete",!0);t.exports=u?function(){var t,e,r={assert:function(t){if(!r.has(t))throw new s("Side channel does not contain "+i(t))},delete:function(r){if(u&&r&&("object"==typeof r||"function"==typeof r)){if(t)return p(t,r)}else if(a&&e)return e.delete(r);return!1},get:function(r){return u&&r&&("object"==typeof r||"function"==typeof r)&&t?l(t,r):e&&e.get(r)},has:function(r){return u&&r&&("object"==typeof r||"function"==typeof r)&&t?f(t,r):!!e&&e.has(r)},set:function(r,n){u&&r&&("object"==typeof r||"function"==typeof r)?(t||(t=new u),c(t,r,n)):a&&(e||(e=a()),e.set(r,n))}};return r}:a},6746(t,e,r){"use strict";var n=r(7379),o=r(4922),i=r(7797),a=r(1085),s=r(2468)||a||i;t.exports=function(){var t,e={assert:function(t){if(!e.has(t)){var r=t&&Object(t)===t?"the given object key":o(t);throw new n("Side channel does not contain "+r)}},delete:function(e){return!!t&&t.delete(e)},get:function(e){return t&&t.get(e)},has:function(e){return!!t&&t.has(e)},set:function(e,r){t||(t=s()),t.set(e,r)}};return e}},4290(t,e,r){"use strict";var n=r(6520),o=r(7630),i=r(4111),a=r(333),s=r(1076),u=r(7744),l=r(5298),c=r(7657)(),f=r(1721),p=r(703),d=r(7379),y=p("%RegExp%"),h=l("String.prototype.indexOf"),g=r(2570),m=function(t){var e=g();if(c&&"symbol"==typeof Symbol.matchAll){var r=i(t,Symbol.matchAll);return r===y.prototype[Symbol.matchAll]&&r!==e?e:r}if(a(t))return e};t.exports=function(t){var e=u(this);if(null!=t){if(a(t)){var r="flags"in t?o(t,"flags"):f(t);if(u(r),h(s(r),"g")<0)throw new d("matchAll requires a global regular expression")}var i=m(t);if(void 0!==i)return n(i,t,[e])}var l=s(e),c=new y(t,"g");return n(m(c),c,[l])}},6410(t,e,r){"use strict";var n=r(7965),o=r(8189),i=r(4290),a=r(4683),s=r(3197),u=n(i);o(u,{getPolyfill:a,implementation:i,shim:s}),t.exports=u},2570(t,e,r){"use strict";var n=r(7657)(),o=r(1930);t.exports=function(){return n&&"symbol"==typeof Symbol.matchAll&&"function"==typeof RegExp.prototype[Symbol.matchAll]?RegExp.prototype[Symbol.matchAll]:o}},4683(t,e,r){"use strict";var n=r(4290);t.exports=function(){if(String.prototype.matchAll)try{"".matchAll(RegExp.prototype)}catch(t){return String.prototype.matchAll}return n}},1930(t,e,r){"use strict";var n=r(3990),o=r(7630),i=r(5234),a=r(518),s=r(6117),u=r(1076),l=r(192),c=r(1721),f=r(7843),p=r(5298),d=r(703),y=r(7379),h=p("String.prototype.indexOf"),g=d("%RegExp%"),m="flags"in g.prototype,b=f(function(t){var e=this;if("Object"!==l(e))throw new y('"this" value must be an Object');var r=u(t),f=function(t,e){var r="flags"in e?o(e,"flags"):u(c(e));return{flags:r,matcher:new t(m&&"string"==typeof r?e:t===g?e.source:e,r)}}(a(e,g),e),p=f.flags,d=f.matcher,b=s(o(e,"lastIndex"));i(d,"lastIndex",b,!0);var v=h(p,"g")>-1,w=h(p,"u")>-1;return n(d,r,v,w)},"[Symbol.matchAll]",!0);t.exports=b},3197(t,e,r){"use strict";var n=r(8189),o=r(7657)(),i=r(3492),a=r(4683),s=r(2570),u=Object.defineProperty;t.exports=function(){var t=a();if(n(String.prototype,{matchAll:t},{matchAll:function(){return String.prototype.matchAll!==t}}),o){var e=Symbol.matchAll||(Symbol.for?Symbol.for("Symbol.matchAll"):Symbol("Symbol.matchAll"));if(n(Symbol,{matchAll:e},{matchAll:function(){return Symbol.matchAll!==e}}),u&&i){var r=i(Symbol,e);r&&!r.configurable||u(Symbol,e,{configurable:!1,enumerable:!1,value:e,writable:!1})}var l=s(),c={};c[e]=l;var f={};f[e]=function(){return RegExp.prototype[e]!==l},n(RegExp.prototype,c,f)}return t}},3952(t,e,r){"use strict";var n=r(7744),o=r(2501),i=r(5298),a=r(5537),s=i("String.prototype.replace"),u=i("String.prototype.charAt"),l=i("String.prototype.slice"),c=/^\s$/.test("᠎"),f=c?/^[\x09\x0A\x0B\x0C\x0D\x20\xA0\u1680\u180E\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000\u2028\u2029\uFEFF]+/:/^[\x09\x0A\x0B\x0C\x0D\x20\xA0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000\u2028\u2029\uFEFF]+/,p=a(c?/[\x09\x0A\x0B\x0C\x0D\x20\xA0\u1680\u180E\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000\u2028\u2029\uFEFF]$/:/[\x09\x0A\x0B\x0C\x0D\x20\xA0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000\u2028\u2029\uFEFF]$/);t.exports=function(){for(var t=s(o(n(this)),f,""),e=t.length;e>0&&p(u(t,e-1));)e-=1;return l(t,0,e)}},7724(t,e,r){"use strict";var n=r(7965),o=r(8189),i=r(7744),a=r(3952),s=r(8821),u=r(5795),l=n(s()),c=function(t){return i(t),l(t)};o(c,{getPolyfill:s,implementation:a,shim:u}),t.exports=c},8821(t,e,r){"use strict";var n=r(3952);t.exports=function(){return String.prototype.trim&&"​"==="​".trim()&&"᠎"==="᠎".trim()&&"_᠎"==="_᠎".trim()&&"᠎_"==="᠎_".trim()?String.prototype.trim:n}},5795(t,e,r){"use strict";var n=r(708)(),o=r(7517),i=r(8821);t.exports=function(){var t=i();return String.prototype.trim!==t&&(n?o(String.prototype,"trim",t,!0):o(String.prototype,"trim",t)),t}},2179(){},6917(t,e,r){"use strict";var n=r(6562),o=r(7379),i=r(1029),a=r(6241);t.exports=function(t,e,r){if("string"!=typeof t)throw new o("Assertion failed: `S` must be a String");if(!i(e)||e<0||e>a)throw new o("Assertion failed: `length` must be an integer >= 0 and <= 2**53");if("boolean"!=typeof r)throw new o("Assertion failed: `unicode` must be a Boolean");return r?e+1>=t.length?e+1:e+n(t,e)["[[CodeUnitCount]]"]:e+1}},6520(t,e,r){"use strict";var n=r(703),o=r(5298),i=r(7379),a=r(3443),s=n("%Reflect.apply%",!0)||o("Function.prototype.apply");t.exports=function(t,e){var r=arguments.length>2?arguments[2]:[];if(!a(r))throw new i("Assertion failed: optional `argumentsList`, if provided, must be a List");return s(t,e,r)}},6562(t,e,r){"use strict";var n=r(7379),o=r(5298),i=r(3283),a=r(8537),s=r(1300),u=o("String.prototype.charAt"),l=o("String.prototype.charCodeAt");t.exports=function(t,e){if("string"!=typeof t)throw new n("Assertion failed: `string` must be a String");var r=t.length;if(e<0||e>=r)throw new n("Assertion failed: `position` must be >= 0, and < the length of `string`");var o=l(t,e),c=u(t,e),f=i(o),p=a(o);if(!f&&!p)return{"[[CodePoint]]":c,"[[CodeUnitCount]]":1,"[[IsUnpairedSurrogate]]":!1};if(p||e+1===r)return{"[[CodePoint]]":c,"[[CodeUnitCount]]":1,"[[IsUnpairedSurrogate]]":!0};var d=l(t,e+1);return a(d)?{"[[CodePoint]]":s(o,d),"[[CodeUnitCount]]":2,"[[IsUnpairedSurrogate]]":!1}:{"[[CodePoint]]":c,"[[CodeUnitCount]]":1,"[[IsUnpairedSurrogate]]":!0}}},44(t,e,r){"use strict";var n=r(7379);t.exports=function(t,e){if("boolean"!=typeof e)throw new n("Assertion failed: Type(done) is not Boolean");return{value:t,done:e}}},3990(t,e,r){"use strict";var n=r(703),o=r(7657)(),i=r(7379),a=r(4938),s=n("%IteratorPrototype%",!0),u=r(6917),l=r(44),c=r(355),f=r(7630),p=r(2021),d=r(3936),y=r(5234),h=r(6117),g=r(1076),m=r(6561),b=r(3148),v=function(t,e,r,n){if("string"!=typeof e)throw new i("`S` must be a string");if("boolean"!=typeof r)throw new i("`global` must be a boolean");if("boolean"!=typeof n)throw new i("`fullUnicode` must be a boolean");m.set(this,"[[IteratingRegExp]]",t),m.set(this,"[[IteratedString]]",e),m.set(this,"[[Global]]",r),m.set(this,"[[Unicode]]",n),m.set(this,"[[Done]]",!1)};s&&(v.prototype=p(s)),c(v.prototype,"next",function(){var t=this;if(!a(t))throw new i("receiver must be an object");if(!(t instanceof v&&m.has(t,"[[IteratingRegExp]]")&&m.has(t,"[[IteratedString]]")&&m.has(t,"[[Global]]")&&m.has(t,"[[Unicode]]")&&m.has(t,"[[Done]]")))throw new i('"this" value must be a RegExpStringIterator instance');if(m.get(t,"[[Done]]"))return l(void 0,!0);var e=m.get(t,"[[IteratingRegExp]]"),r=m.get(t,"[[IteratedString]]"),n=m.get(t,"[[Global]]"),o=m.get(t,"[[Unicode]]"),s=d(e,r);if(null===s)return m.set(t,"[[Done]]",!0),l(void 0,!0);if(n){if(""===g(f(s,"0"))){var c=h(f(e,"lastIndex")),p=u(r,c,o);y(e,"lastIndex",p,!0)}return l(s,!1)}return m.set(t,"[[Done]]",!0),l(s,!1)},!1),o&&(b(v.prototype,"RegExp String Iterator"),Symbol.iterator&&"function"!=typeof v.prototype[Symbol.iterator])&&c(v.prototype,Symbol.iterator,function(){return this},!1),t.exports=function(t,e,r,n){return new v(t,e,r,n)}},355(t,e,r){"use strict";var n=r(7379),o=r(4938),i=r(9271),a=r(1181),s=r(5855);t.exports=function(t,e,r,u){if(!o(t))throw new n("Assertion failed: `homeObject` is not an Object");if(!s(e))throw new n("Assertion failed: `key` is not a Property Key or a Private Name");if("function"!=typeof r)throw new n("Assertion failed: `closure` is not a function");if("boolean"!=typeof u)throw new n("Assertion failed: `enumerable` is not a Boolean");if(!a(t))throw new n("Assertion failed: `homeObject` is not an ordinary, extensible object, with no non-configurable properties");i(t,e,{"[[Value]]":r,"[[Writable]]":!0,"[[Enumerable]]":u,"[[Configurable]]":!0})}},9271(t,e,r){"use strict";var n=r(7379),o=r(4938),i=r(6843),a=r(9999),s=r(5848),u=r(7817),l=r(5855),c=r(925),f=r(6309);t.exports=function(t,e,r){if(!o(t))throw new n("Assertion failed: Type(O) is not Object");if(!l(e))throw new n("Assertion failed: P is not a Property Key");var p=i(r)?r:f(r);if(!i(p))throw new n("Assertion failed: Desc is not a valid Property Descriptor");return a(u,c,s,t,e,p)}},5848(t,e,r){"use strict";var n=r(7379),o=r(6843),i=r(3003);t.exports=function(t){if(void 0!==t&&!o(t))throw new n("Assertion failed: `Desc` must be a Property Descriptor");return i(t)}},7630(t,e,r){"use strict";var n=r(7379),o=r(4922),i=r(5855),a=r(4938);t.exports=function(t,e){if(!a(t))throw new n("Assertion failed: Type(O) is not Object");if(!i(e))throw new n("Assertion failed: P is not a Property Key, got "+o(e));return t[e]}},4111(t,e,r){"use strict";var n=r(7379),o=r(7818),i=r(1816),a=r(5855),s=r(4922);t.exports=function(t,e){if(!a(e))throw new n("Assertion failed: P is not a Property Key");var r=o(t,e);if(null!=r){if(!i(r))throw new n(s(e)+" is not a function: "+s(r));return r}}},7818(t,e,r){"use strict";var n=r(7379),o=r(4922),i=r(5855);t.exports=function(t,e){if(!i(e))throw new n("Assertion failed: P is not a Property Key, got "+o(e));return t[e]}},3443(t,e,r){"use strict";t.exports=r(8622)},1816(t,e,r){"use strict";t.exports=r(2719)},3478(t,e,r){"use strict";var n=r(4334)("%Reflect.construct%",!0),o=r(9271);try{o({},"",{"[[Get]]":function(){}})}catch(t){o=null}if(o&&n){var i={},a={};o(a,"length",{"[[Get]]":function(){throw i},"[[Enumerable]]":!0}),t.exports=function(t){try{n(t,a)}catch(t){return t===i}}}else t.exports=function(t){return"function"==typeof t&&!!t.prototype}},7817(t,e,r){"use strict";var n=r(7379),o=r(9939),i=r(6843);t.exports=function(t){if(void 0===t)return!1;if(!i(t))throw new n("Assertion failed: `Desc` must be a Property Descriptor");return!(!o(t,"[[Value]]")&&!o(t,"[[Writable]]"))}},1181(t,e,r){"use strict";var n=r(703),o=n("%Object.preventExtensions%",!0),i=n("%Object.isExtensible%",!0),a=r(9258);t.exports=o?function(t){return!a(t)&&i(t)}:function(t){return!a(t)}},333(t,e,r){"use strict";var n=r(703)("%Symbol.match%",!0),o=r(4587),i=r(4938),a=r(4801);t.exports=function(t){if(!i(t))return!1;if(n){var e=t[n];if(void 0!==e)return a(e)}return o(t)}},2021(t,e,r){"use strict";var n=r(703)("%Object.create%",!0),o=r(7379),i=r(7388),a=r(4938),s=r(3443),u=r(5713),l=r(6561),c=r(6052)();t.exports=function(t){if(null!==t&&!a(t))throw new o("Assertion failed: `proto` must be null or an object");var e,r=arguments.length<2?[]:arguments[1];if(!s(r))throw new o("Assertion failed: `additionalInternalSlotsList` must be an Array");if(c)e={__proto__:t};else if(n)e=n(t);else{if(null===t)throw new i("native Object.create support is required to create null objects");var f=function(){};f.prototype=t,e=new f}return r.length>0&&u(r,function(t){l.set(e,t,void 0)}),e}},3936(t,e,r){"use strict";var n=r(7379),o=r(4938),i=r(5298)("RegExp.prototype.exec"),a=r(6520),s=r(7630),u=r(1816);t.exports=function(t,e){if(!o(t))throw new n("Assertion failed: `R` must be an Object");if("string"!=typeof e)throw new n("Assertion failed: `S` must be a String");var r=s(t,"exec");if(u(r)){var l=a(r,t,[e]);if(null===l||o(l))return l;throw new n('"exec" method must return `null` or an Object')}return i(t,e)}},925(t,e,r){"use strict";var n=r(5518);t.exports=function(t,e){return t===e?0!==t||1/t==1/e:n(t)&&n(e)}},5234(t,e,r){"use strict";var n=r(7379),o=r(4938),i=r(5855),a=r(925),s=function(){try{return delete[].length,!0}catch(t){return!1}}();t.exports=function(t,e,r,u){if(!o(t))throw new n("Assertion failed: `O` must be an Object");if(!i(e))throw new n("Assertion failed: `P` must be a Property Key");if("boolean"!=typeof u)throw new n("Assertion failed: `Throw` must be a Boolean");if(u){if(t[e]=r,s&&!a(t[e],r))throw new n("Attempted to assign to readonly property.");return!0}try{return t[e]=r,!s||a(t[e],r)}catch(t){return!1}}},518(t,e,r){"use strict";var n=r(703)("%Symbol.species%",!0),o=r(7379),i=r(4938),a=r(3478);t.exports=function(t,e){if(!i(t))throw new o("Assertion failed: Type(O) is not Object");var r=t.constructor;if(void 0===r)return e;if(!i(r))throw new o("O.constructor is not an Object");var s=n?r[n]:void 0;if(null==s)return e;if(a(s))return s;throw new o("no constructor found")}},9893(t,e,r){"use strict";var n=r(703),o=n("%RegExp%"),i=r(7379),a=n("%parseInt%"),s=r(5298),u=r(5537),l=s("String.prototype.slice"),c=u(/^0b[01]+$/i),f=u(/^0o[0-7]+$/i),p=u(/^[-+]0x[0-9a-f]+$/i),d=u(new o("["+["…","​","￾"].join("")+"]","g")),y=r(7724);t.exports=function t(e){if("string"!=typeof e)throw new i("Assertion failed: `argument` is not a String");if(c(e))return+a(l(e,2),2);if(f(e))return+a(l(e,2),8);if(d(e)||p(e))return NaN;var r=y(e);return r!==e?t(r):+e}},4801(t){"use strict";t.exports=function(t){return!!t}},7210(t,e,r){"use strict";var n=r(3312),o=r(6354),i=r(5518),a=r(1084);t.exports=function(t){var e=n(t);return i(e)||0===e?0:a(e)?o(e):e}},6117(t,e,r){"use strict";var n=r(6241),o=r(7210);t.exports=function(t){var e=o(t);return e<=0?0:e>n?n:e}},3312(t,e,r){"use strict";var n=r(703),o=r(7379),i=n("%Number%"),a=r(9258),s=r(3760),u=r(9893);t.exports=function(t){var e=a(t)?t:s(t,i);if("symbol"==typeof e)throw new o("Cannot convert a Symbol value to a number");if("bigint"==typeof e)throw new o("Conversion from 'BigInt' to 'number' is not allowed.");return"string"==typeof e?u(e):+e}},3760(t,e,r){"use strict";var n=r(2632);t.exports=function(t){return arguments.length>1?n(t,arguments[1]):n(t)}},6309(t,e,r){"use strict";var n=r(9939),o=r(7379),i=r(4938),a=r(1816),s=r(4801);t.exports=function(t){if(!i(t))throw new o("ToPropertyDescriptor requires an object");var e={};if(n(t,"enumerable")&&(e["[[Enumerable]]"]=s(t.enumerable)),n(t,"configurable")&&(e["[[Configurable]]"]=s(t.configurable)),n(t,"value")&&(e["[[Value]]"]=t.value),n(t,"writable")&&(e["[[Writable]]"]=s(t.writable)),n(t,"get")){var r=t.get;if(void 0!==r&&!a(r))throw new o("getter must be a function");e["[[Get]]"]=r}if(n(t,"set")){var u=t.set;if(void 0!==u&&!a(u))throw new o("setter must be a function");e["[[Set]]"]=u}if((n(e,"[[Get]]")||n(e,"[[Set]]"))&&(n(e,"[[Value]]")||n(e,"[[Writable]]")))throw new o("Invalid property descriptor. Cannot both specify accessors and a value or writable attribute");return e}},1076(t,e,r){"use strict";var n=r(703)("%String%"),o=r(7379);t.exports=function(t){if("symbol"==typeof t)throw new o("Cannot convert a Symbol value to a string");return n(t)}},192(t,e,r){"use strict";var n=r(3225);t.exports=function(t){return"symbol"==typeof t?"Symbol":"bigint"==typeof t?"BigInt":n(t)}},1300(t,e,r){"use strict";var n=r(703),o=r(7379),i=n("%String.fromCharCode%"),a=r(3283),s=r(8537);t.exports=function(t,e){if(!a(t)||!s(e))throw new o("Assertion failed: `lead` must be a leading surrogate char code, and `trail` must be a trailing surrogate char code");return i(t)+i(e)}},7060(t,e,r){"use strict";var n=r(2153);t.exports=function(t){return"bigint"==typeof t?t:n(t)}},6354(t,e,r){"use strict";var n=r(7060),o=r(7379);t.exports=function(t){if("number"!=typeof t&&"bigint"!=typeof t)throw new o("argument must be a Number or a BigInt");var e=t<0?-n(-t):n(t);return 0===e?0:e}},2501(t,e,r){"use strict";var n=r(703)("%String%"),o=r(7379);t.exports=function(t){if("symbol"==typeof t)throw new o("Cannot convert a Symbol value to a string");return n(t)}},3225(t,e,r){"use strict";var n=r(4938);t.exports=function(t){return null===t?"Null":void 0===t?"Undefined":n(t)?"Object":"number"==typeof t?"Number":"boolean"==typeof t?"Boolean":"string"==typeof t?"String":void 0}},4334(t,e,r){"use strict";t.exports=r(703)},9999(t,e,r){"use strict";var n=r(708),o=r(9173),i=n.hasArrayLengthDefineBug(),a=i&&r(8622),s=r(5298)("Object.prototype.propertyIsEnumerable");t.exports=function(t,e,r,n,u,l){if(!o){if(!t(l))return!1;if(!l["[[Configurable]]"]||!l["[[Writable]]"])return!1;if(u in n&&s(n,u)!==!!l["[[Enumerable]]"])return!1;var c=l["[[Value]]"];return n[u]=c,e(n[u],c)}return i&&"length"===u&&"[[Value]]"in l&&a(n)&&n.length!==l["[[Value]]"]?(n.length=l["[[Value]]"],n.length===l["[[Value]]"]):(o(n,u,r(l)),!0)}},8622(t,e,r){"use strict";var n=r(703)("%Array%"),o=!n.isArray&&r(5298)("Object.prototype.toString");t.exports=n.isArray||function(t){return"[object Array]"===o(t)}},5713(t){"use strict";t.exports=function(t,e){for(var r=0;r=55296&&t<=56319}},9258(t){"use strict";t.exports=function(t){return null===t||"function"!=typeof t&&"object"!=typeof t}},5855(t){"use strict";t.exports=function(t){return"string"==typeof t||"symbol"==typeof t}},8537(t){"use strict";t.exports=function(t){return"number"==typeof t&&t>=56320&&t<=57343}},6843(t,e,r){"use strict";var n=r(7379),o=r(9939),i={__proto__:null,"[[Configurable]]":!0,"[[Enumerable]]":!0,"[[Get]]":!0,"[[Set]]":!0,"[[Value]]":!0,"[[Writable]]":!0};t.exports=function(t){if(!t||"object"!=typeof t)return!1;for(var e in t)if(o(t,e)&&!i[e])return!1;var r=o(t,"[[Value]]")||o(t,"[[Writable]]"),a=o(t,"[[Get]]")||o(t,"[[Set]]");if(r&&a)throw new n("Property Descriptors may not be both accessor and data descriptors");return!0}}},e={};function r(n){var o=e[n];if(void 0!==o)return o.exports;var i=e[n]={exports:{}};return t[n](i,i.exports,r),i.exports}r.n=t=>{var e=t&&t.__esModule?()=>t.default:()=>t;return r.d(e,{a:e}),e},r.d=(t,e)=>{for(var n in e)r.o(e,n)&&!r.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:e[n]})},r.o=(t,e)=>Object.prototype.hasOwnProperty.call(t,e),(()=>{"use strict";var t=r(3618);function e(e,r,n){let o=0;const i=[];for(;-1!==o;)o=e.indexOf(r,o),-1!==o&&(i.push({start:o,end:o+r.length,errors:0}),o+=1);return i.length>0?i:(0,t.A)(e,r,n)}function n(t,r){return 0===r.length||0===t.length?0:1-e(t,r,r.length)[0].errors/r.length}function o(t){const e=document.createElement("div");return e.appendChild(t.cloneContents()),function(t){var e;for(const e of Array.from(t.querySelectorAll("br")))e.replaceWith(document.createTextNode(" "));return null!==(e=t.textContent)&&void 0!==e?e:""}(e)}function i(t,e){let r=0;for(const n of t){if(!(n{if(i=e===a.Forwards?r.nextNode():r.previousNode(),i){const t=i.textContent,r=e===a.Forwards?0:t.length;u=s(t,r,e)}};for(;i&&-1===u&&i!==o;)l();if(i&&u>=0)return{node:i,offset:u};throw new RangeError("No text nodes with non-whitespace text found in range")}function l(t,e){(null==e||e>t.length)&&(e=t.length);for(var r=0,n=Array(e);r1?e-1:0),n=1;no?(a.push({node:s,offset:o-l}),o=r.shift()):(u=i.nextNode(),l+=s.data.length);for(;void 0!==o&&s&&l===o;)a.push({node:s,offset:s.data.length}),o=r.shift();if(void 0!==o)throw new RangeError("Offset exceeds text length");return a}let d=function(t){return t[t.FORWARDS=1]="FORWARDS",t[t.BACKWARDS=2]="BACKWARDS",t}({});class y{constructor(t,e){if(e<0)throw new Error("Offset is invalid");this.element=t,this.offset=e}relativeTo(t){if(!t.contains(this.element))throw new Error("Parent is not an ancestor of current element");let e=this.element,r=this.offset;for(;e!==t;)r+=f(e),e=e.parentElement;return new y(e,r)}resolve(){let t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};try{return p(this.element,this.offset)[0]}catch(e){if(0===this.offset&&void 0!==t.direction){const r=document.createTreeWalker(this.element.getRootNode(),NodeFilter.SHOW_TEXT);r.currentNode=this.element;const n=t.direction===d.FORWARDS,o=n?r.nextNode():r.previousNode();if(!o)throw e;return{node:o,offset:n?0:o.data.length}}throw e}}static fromCharOffset(t,e){switch(t.nodeType){case Node.TEXT_NODE:return y.fromPoint(t,e);case Node.ELEMENT_NODE:return new y(t,e);default:throw new Error("Node is not an element or text node")}}static fromPoint(t,e){switch(t.nodeType){case Node.TEXT_NODE:{if(e<0||e>t.data.length)throw new Error("Text node offset is out of range");if(!t.parentElement)throw new Error("Text node has no parent");const r=f(t)+e;return new y(t.parentElement,r)}case Node.ELEMENT_NODE:{if(e<0||e>t.childNodes.length)throw new Error("Child node offset is out of range");let r=0;for(let n=0;n=0&&(e.setStart(t.startContainer,o.start),r=!0),o.end>0&&(e.setEnd(t.endContainer,o.end),n=!0),r&&n)return e;if(!r){const t=u(e,a.Forwards),r=t.node,n=t.offset;r&&n>=0&&e.setStart(r,n)}if(!n){const t=u(e,a.Backwards),r=t.node,n=t.offset;r&&n>0&&e.setEnd(r,n)}return e}(h.fromRange(t).toRange())}}class g{constructor(t,e,r){this.root=t,this.start=e,this.end=r}static fromRange(t,e){const r=h.fromRange(e).relativeTo(t);return new g(t,r.start.offset,r.end.offset)}static fromSelector(t,e){return new g(t,e.start,e.end)}toSelector(){return{type:"TextPositionSelector",start:this.start,end:this.end}}toRange(){return h.fromOffsets(this.root,this.start,this.end).toRange()}}class m{constructor(t,e){let r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};this.root=t,this.exact=e,this.context=r}static fromRange(t,e){var r;const n=null!==(r=t.textContent)&&void 0!==r?r:"",i=h.fromRange(e).relativeTo(t),a=i.start.offset,s=i.end.offset,u=o(e),l=o(h.fromOffsets(t,Math.max(0,a-32),a).toRange()),c=o(h.fromOffsets(t,s,Math.min(n.length,s+32)).toRange());return new m(t,u,{prefix:l,suffix:c})}static fromSelector(t,e){const r=e.prefix,n=e.suffix;return new m(t,e.exact,{prefix:r,suffix:n})}toSelector(){return{type:"TextQuoteSelector",exact:this.exact,prefix:this.context.prefix,suffix:this.context.suffix}}toRange(){let t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return this.toPositionAnchor(t).toRange()}toPositionAnchor(){let t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};const r=function(t){let e="";const r=[],n=t=>{var o;if(t.nodeType!==Node.TEXT_NODE){if(t.nodeType===Node.ELEMENT_NODE){if("BR"===t.tagName)return r.push(e.length),void(e+=" ");for(const e of Array.from(t.childNodes))n(e)}}else e+=null!==(o=t.textContent)&&void 0!==o?o:""};return n(t),{text:e,brPositionsInText:r}}(this.root),o=r.text,a=r.brPositionsInText,s=function(t,r){let o=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};if(0===r.length)return null;const i=Math.min(256,r.length/2),a=e(t,r,i);if(0===a.length)return null;const s=e=>{const i=1-e.errors/r.length,a=o.prefix?n(t.slice(Math.max(0,e.start-o.prefix.length),e.start),o.prefix):1,s=o.suffix?n(t.slice(e.end,e.end+o.suffix.length),o.suffix):1;let u=1;return"number"==typeof o.hint&&(u=1-Math.abs(e.start-o.hint)/t.length),(50*i+20*a+20*s+2*u)/92},u=a.map(t=>({start:t.start,end:t.end,score:s(t)}));return u.sort((t,e)=>e.score-t.score),u[0]}(o,this.exact,{...this.context,hint:t.hint});if(!s)throw new Error("Quote not found");return new g(this.root,i(a,s.start),i(a,s.end))}}var b,v=r(6410);function w(){if(!readium.link)return null;const t=readium.link.href;if(!t)return null;const e=function(){const t=window.getSelection();if(!t)return;if(t.isCollapsed)return;const e=t.toString();if(0===e.trim().replace(/\n/g," ").replace(/\s\s+/g," ").length)return;if(!t.anchorNode||!t.focusNode)return;const r=1===t.rangeCount?t.getRangeAt(0):function(t,e,r,n){const o=new Range;if(o.setStart(t,e),o.setEnd(r,n),!o.collapsed)return o;x(">>> createOrderedRange COLLAPSED ... RANGE REVERSE?");const i=new Range;if(i.setStart(r,n),i.setEnd(t,e),!i.collapsed)return x(">>> createOrderedRange RANGE REVERSE OK."),o;x(">>> createOrderedRange RANGE REVERSE ALSO COLLAPSED?!")}(t.anchorNode,t.anchorOffset,t.focusNode,t.focusOffset);if(!r||r.collapsed)return void x("$$$$$$$$$$$$$$$$$ CANNOT GET NON-COLLAPSED SELECTION RANGE?!");const n=document.body.textContent,o=h.fromRange(r).relativeTo(document.body),i=o.start.offset,a=o.end.offset;let s=n.slice(Math.max(0,i-200),i),u=s.search(/\P{L}\p{L}/gu);-1!==u&&(s=s.slice(u+1));let l=n.slice(a,Math.min(n.length,a+200)),c=Array.from(l.matchAll(/\p{L}\P{L}/gu)).pop();return void 0!==c&&c.index>1&&(l=l.slice(0,c.index+1)),{highlight:e,before:s,after:l}}();return e?{href:t,text:e,rect:function(){try{let t=window.getSelection();if(!t)return;return D(t.getRangeAt(0).getBoundingClientRect())}catch(t){return L(t),null}}()}:null}function x(){_.apply(null,arguments)}r.n(v)().shim(),window.addEventListener("error",function(t){webkit.messageHandlers.logError.postMessage({message:t.message,filename:t.filename,line:t.lineno})},!1),window.addEventListener("load",function(){var t;new ResizeObserver(()=>{t&&window.cancelAnimationFrame(t),t=window.requestAnimationFrame(function(){O=window.innerWidth,function(){const t="readium-virtual-page";var e=document.getElementById(t);if(R()||2!=parseInt(window.getComputedStyle(document.documentElement).getPropertyValue("column-count"))){var r;null===(r=e)||void 0===r||r.remove()}else{var n=document.scrollingElement.scrollWidth/window.innerWidth;Math.round(2*n)/2%1>.1&&(e?e.remove():((e=document.createElement("div")).setAttribute("id",t),e.style.breakBefore="column",e.innerHTML="​",document.body.appendChild(e)))}}(),function(){if(!R()){var t=I(window.scrollX+1);document.scrollingElement.scrollLeft=t}}(),j()})}).observe(document.body)},!1);var S,E,A=!1,O=0;function j(){if(readium.isFixedLayout)return;let t=document.scrollingElement;if(R()&&!P()){const e=window.scrollY,r=window.innerHeight,n=t.scrollHeight;b={first:e/n,last:(e+r)/n}}else{let e=window.scrollX;const r=window.innerWidth,n=t.scrollWidth;C()&&(e=Math.abs(e)),b={first:e/n,last:(e+r)/n}}0!==t.scrollWidth&&0!==t.scrollHeight&&(A||window.requestAnimationFrame(function(){var t;t=b,webkit.messageHandlers.progressionChanged.postMessage(t),A=!1}),A=!0)}function R(){return"readium-scroll-on"==document.documentElement.style.getPropertyValue("--USER__view").trim()}function P(){return window.getComputedStyle(document.documentElement).getPropertyValue("writing-mode").startsWith("vertical")}function C(){const t=window.getComputedStyle(document.documentElement);return"rtl"==t.getPropertyValue("direction")||"vertical-rl"==t.getPropertyValue("writing-mode")}function T(t,e){return R()?M({top:t.top+window.scrollY,animated:e}):M({left:I(t.left+window.scrollX),animated:e}),!0}function N(t,e){var r=window.scrollX,n=window.innerWidth,o=Math.abs(r-t)/n>.01;return o&&M({left:t,animated:e}),o}function M(){let t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},e=t.left,r=t.top,n=t.animated;document.scrollingElement.scrollTo({left:e,top:r,behavior:n?"smooth":"instant"})}function I(t){const e=t+(C()?-1:1);return e-e%O}function k(t){try{let n=t.locations,o=t.text;var e;if(o&&o.highlight)return n&&n.cssSelector&&(e=document.querySelector(n.cssSelector)),e||(e=document.body),new m(e,o.highlight,{prefix:o.before,suffix:o.after}).toRange();if(n){var r=null;if(!r&&n.cssSelector&&(r=document.querySelector(n.cssSelector)),!r&&n.fragments)for(const t of n.fragments)if(r=document.getElementById(t))break;if(r){let t=document.createRange();return t.setStartBefore(r),t.setEndAfter(r),t}}}catch(t){L(t)}return null}function $(t,e){null===e?F(t):document.documentElement.style.setProperty(t,e,"important")}function F(t){document.documentElement.style.removeProperty(t)}function _(){var t=Array.prototype.slice.call(arguments).join(" ");webkit.messageHandlers.log.postMessage(t)}function B(t){L(new Error(t))}function L(t){webkit.messageHandlers.logError.postMessage({message:t.message})}function D(t){let e=W({x:t.left,y:t.top});const r=t.width,n=t.height,o=e.x,i=e.y;return{width:r,height:n,left:o,top:i,right:o+r,bottom:i+n}}function W(t){if(!frameElement)return t;let e=frameElement.getBoundingClientRect();if(!e)return t;let r=window.top.document.documentElement;return{x:t.x+e.x+r.scrollLeft,y:t.y+e.y+r.scrollTop}}function U(t,e){let r=t.getClientRects();const n=[];for(const t of r)n.push({bottom:t.bottom,height:t.height,left:t.left,right:t.right,top:t.top,width:t.width});const o=q(function(t){const e=new Set(t);for(const r of t)if(r.width>1&&r.height>1){for(const n of t)if(r!==n&&e.has(n)&&V(n,r,1)){J(),e.delete(r);break}}else J(),e.delete(r);return Array.from(e)}(z(n,1,e)));for(let t=o.length-1;t>=0;t--){const e=o[t];if(!(e.width*e.height>4)){if(!(o.length>1)){J();break}J(),o.splice(t,1)}}return J((n.length,o.length)),o}function z(t,e,r){for(let n=0;nt!==i&&t!==a),o=H(i,a);return n.push(o),z(n,e,r)}}return t}function H(t,e){const r=Math.min(t.left,e.left),n=Math.max(t.right,e.right),o=Math.min(t.top,e.top),i=Math.max(t.bottom,e.bottom);return{bottom:i,height:i-o,left:r,right:n,top:o,width:n-r}}function V(t,e,r){return G(t,e.left,e.top,r)&&G(t,e.right,e.top,r)&&G(t,e.left,e.bottom,r)&&G(t,e.right,e.bottom,r)}function G(t,e,r,n){return(t.lefte||Y(t.right,e,n))&&(t.topr||Y(t.bottom,r,n))}function q(t){for(let e=0;et!==e);return Array.prototype.push.apply(a,r),q(a)}}else J()}return t}function X(t,e){const r=function(t,e){const r=Math.max(t.left,e.left),n=Math.min(t.right,e.right),o=Math.max(t.top,e.top),i=Math.min(t.bottom,e.bottom);return{bottom:i,height:Math.max(0,i-o),left:r,right:n,top:o,width:Math.max(0,n-r)}}(e,t);if(0===r.height||0===r.width)return[t];const n=[];{const e={bottom:t.bottom,height:0,left:t.left,right:r.left,top:t.top,width:0};e.width=e.right-e.left,e.height=e.bottom-e.top,0!==e.height&&0!==e.width&&n.push(e)}{const e={bottom:r.top,height:0,left:r.left,right:r.right,top:t.top,width:0};e.width=e.right-e.left,e.height=e.bottom-e.top,0!==e.height&&0!==e.width&&n.push(e)}{const e={bottom:t.bottom,height:0,left:r.left,right:r.right,top:r.bottom,width:0};e.width=e.right-e.left,e.height=e.bottom-e.top,0!==e.height&&0!==e.width&&n.push(e)}{const e={bottom:t.bottom,height:0,left:r.right,right:t.right,top:t.top,width:0};e.width=e.right-e.left,e.height=e.bottom-e.top,0!==e.height&&0!==e.width&&n.push(e)}return n}function K(t,e,r){return(t.left=0&&Y(t.left,e.right,r))&&(e.left=0&&Y(e.left,t.right,r))&&(t.top=0&&Y(t.top,e.bottom,r))&&(e.top=0&&Y(e.top,t.bottom,r))}function Y(t,e,r){return Math.abs(t-e)<=r}function J(){}window.addEventListener("scroll",j),document.addEventListener("selectionchange",(S=function(){webkit.messageHandlers.selectionChanged.postMessage(w())},function(){var t=this,e=arguments;clearTimeout(E),E=setTimeout(function(){S.apply(t,e),E=null},50)}));var Q,Z=[],tt=function(){return Z.some(function(t){return t.activeTargets.length>0})},et="ResizeObserver loop completed with undelivered notifications.";!function(t){t.BORDER_BOX="border-box",t.CONTENT_BOX="content-box",t.DEVICE_PIXEL_CONTENT_BOX="device-pixel-content-box"}(Q||(Q={}));var rt,nt=function(t){return Object.freeze(t)},ot=function(t,e){this.inlineSize=t,this.blockSize=e,nt(this)},it=function(){function t(t,e,r,n){return this.x=t,this.y=e,this.width=r,this.height=n,this.top=this.y,this.left=this.x,this.bottom=this.top+this.height,this.right=this.left+this.width,nt(this)}return t.prototype.toJSON=function(){var t=this;return{x:t.x,y:t.y,top:t.top,right:t.right,bottom:t.bottom,left:t.left,width:t.width,height:t.height}},t.fromRect=function(e){return new t(e.x,e.y,e.width,e.height)},t}(),at=function(t){return t instanceof SVGElement&&"getBBox"in t},st=function(t){if(at(t)){var e=t.getBBox(),r=e.width,n=e.height;return!r&&!n}var o=t,i=o.offsetWidth,a=o.offsetHeight;return!(i||a||t.getClientRects().length)},ut=function(t){var e;if(t instanceof Element)return!0;var r=null===(e=null==t?void 0:t.ownerDocument)||void 0===e?void 0:e.defaultView;return!!(r&&t instanceof r.Element)},lt="undefined"!=typeof window?window:{},ct=new WeakMap,ft=/auto|scroll/,pt=/^tb|vertical/,dt=/msie|trident/i.test(lt.navigator&<.navigator.userAgent),yt=function(t){return parseFloat(t||"0")},ht=function(t,e,r){return void 0===t&&(t=0),void 0===e&&(e=0),void 0===r&&(r=!1),new ot((r?e:t)||0,(r?t:e)||0)},gt=nt({devicePixelContentBoxSize:ht(),borderBoxSize:ht(),contentBoxSize:ht(),contentRect:new it(0,0,0,0)}),mt=function(t,e){if(void 0===e&&(e=!1),ct.has(t)&&!e)return ct.get(t);if(st(t))return ct.set(t,gt),gt;var r=getComputedStyle(t),n=at(t)&&t.ownerSVGElement&&t.getBBox(),o=!dt&&"border-box"===r.boxSizing,i=pt.test(r.writingMode||""),a=!n&&ft.test(r.overflowY||""),s=!n&&ft.test(r.overflowX||""),u=n?0:yt(r.paddingTop),l=n?0:yt(r.paddingRight),c=n?0:yt(r.paddingBottom),f=n?0:yt(r.paddingLeft),p=n?0:yt(r.borderTopWidth),d=n?0:yt(r.borderRightWidth),y=n?0:yt(r.borderBottomWidth),h=f+l,g=u+c,m=(n?0:yt(r.borderLeftWidth))+d,b=p+y,v=s?t.offsetHeight-b-t.clientHeight:0,w=a?t.offsetWidth-m-t.clientWidth:0,x=o?h+m:0,S=o?g+b:0,E=n?n.width:yt(r.width)-x-w,A=n?n.height:yt(r.height)-S-v,O=E+h+w+m,j=A+g+v+b,R=nt({devicePixelContentBoxSize:ht(Math.round(E*devicePixelRatio),Math.round(A*devicePixelRatio),i),borderBoxSize:ht(O,j,i),contentBoxSize:ht(E,A,i),contentRect:new it(f,u,E,A)});return ct.set(t,R),R},bt=function(t,e,r){var n=mt(t,r),o=n.borderBoxSize,i=n.contentBoxSize,a=n.devicePixelContentBoxSize;switch(e){case Q.DEVICE_PIXEL_CONTENT_BOX:return a;case Q.BORDER_BOX:return o;default:return i}},vt=function(t){var e=mt(t);this.target=t,this.contentRect=e.contentRect,this.borderBoxSize=nt([e.borderBoxSize]),this.contentBoxSize=nt([e.contentBoxSize]),this.devicePixelContentBoxSize=nt([e.devicePixelContentBoxSize])},wt=function(t){if(st(t))return 1/0;for(var e=0,r=t.parentNode;r;)e+=1,r=r.parentNode;return e},xt=function(){var t=1/0,e=[];Z.forEach(function(r){if(0!==r.activeTargets.length){var n=[];r.activeTargets.forEach(function(e){var r=new vt(e.target),o=wt(e.target);n.push(r),e.lastReportedSize=bt(e.target,e.observedBox),ot?e.activeTargets.push(r):e.skippedTargets.push(r))})})},Et=[],At=0,Ot={attributes:!0,characterData:!0,childList:!0,subtree:!0},jt=["resize","load","transitionend","animationend","animationstart","animationiteration","keyup","keydown","mouseup","mousedown","mouseover","mouseout","blur","focus"],Rt=function(t){return void 0===t&&(t=0),Date.now()+t},Pt=!1,Ct=function(){function t(){var t=this;this.stopped=!0,this.listener=function(){return t.schedule()}}return t.prototype.run=function(t){var e=this;if(void 0===t&&(t=250),!Pt){Pt=!0;var r,n=Rt(t);r=function(){var r=!1;try{r=function(){var t,e=0;for(St(e);tt();)e=xt(),St(e);return Z.some(function(t){return t.skippedTargets.length>0})&&("function"==typeof ErrorEvent?t=new ErrorEvent("error",{message:et}):((t=document.createEvent("Event")).initEvent("error",!1,!1),t.message=et),window.dispatchEvent(t)),e>0}()}finally{if(Pt=!1,t=n-Rt(),!At)return;r?e.run(1e3):t>0?e.run(t):e.start()}},function(t){if(!rt){var e=0,r=document.createTextNode("");new MutationObserver(function(){return Et.splice(0).forEach(function(t){return t()})}).observe(r,{characterData:!0}),rt=function(){r.textContent="".concat(e?e--:e++)}}Et.push(t),rt()}(function(){requestAnimationFrame(r)})}},t.prototype.schedule=function(){this.stop(),this.run()},t.prototype.observe=function(){var t=this,e=function(){return t.observer&&t.observer.observe(document.body,Ot)};document.body?e():lt.addEventListener("DOMContentLoaded",e)},t.prototype.start=function(){var t=this;this.stopped&&(this.stopped=!1,this.observer=new MutationObserver(this.listener),this.observe(),jt.forEach(function(e){return lt.addEventListener(e,t.listener,!0)}))},t.prototype.stop=function(){var t=this;this.stopped||(this.observer&&this.observer.disconnect(),jt.forEach(function(e){return lt.removeEventListener(e,t.listener,!0)}),this.stopped=!0)},t}(),Tt=new Ct,Nt=function(t){!At&&t>0&&Tt.start(),!(At+=t)&&Tt.stop()},Mt=function(){function t(t,e){this.target=t,this.observedBox=e||Q.CONTENT_BOX,this.lastReportedSize={inlineSize:0,blockSize:0}}return t.prototype.isActive=function(){var t,e=bt(this.target,this.observedBox,!0);return t=this.target,at(t)||function(t){switch(t.tagName){case"INPUT":if("image"!==t.type)break;case"VIDEO":case"AUDIO":case"EMBED":case"OBJECT":case"CANVAS":case"IFRAME":case"IMG":return!0}return!1}(t)||"inline"!==getComputedStyle(t).display||(this.lastReportedSize=e),this.lastReportedSize.inlineSize!==e.inlineSize||this.lastReportedSize.blockSize!==e.blockSize},t}(),It=function(t,e){this.activeTargets=[],this.skippedTargets=[],this.observationTargets=[],this.observer=t,this.callback=e},kt=new WeakMap,$t=function(t,e){for(var r=0;r=0&&(o&&Z.splice(Z.indexOf(r),1),r.observationTargets.splice(n,1),Nt(-1))},t.disconnect=function(t){var e=this,r=kt.get(t);r.observationTargets.slice().forEach(function(r){return e.unobserve(t,r.target)}),r.activeTargets.splice(0,r.activeTargets.length)},t}(),_t=function(){function t(t){if(0===arguments.length)throw new TypeError("Failed to construct 'ResizeObserver': 1 argument required, but only 0 present.");if("function"!=typeof t)throw new TypeError("Failed to construct 'ResizeObserver': The callback provided as parameter 1 is not a function.");Ft.connect(this,t)}return t.prototype.observe=function(t,e){if(0===arguments.length)throw new TypeError("Failed to execute 'observe' on 'ResizeObserver': 1 argument required, but only 0 present.");if(!ut(t))throw new TypeError("Failed to execute 'observe' on 'ResizeObserver': parameter 1 is not of type 'Element");Ft.observe(this,t,e)},t.prototype.unobserve=function(t){if(0===arguments.length)throw new TypeError("Failed to execute 'unobserve' on 'ResizeObserver': 1 argument required, but only 0 present.");if(!ut(t))throw new TypeError("Failed to execute 'unobserve' on 'ResizeObserver': parameter 1 is not of type 'Element");Ft.unobserve(this,t)},t.prototype.disconnect=function(){Ft.disconnect(this)},t.toString=function(){return"function ResizeObserver () { [polyfill code] }"},t}();function Bt(t,e){return function(t){if(Array.isArray(t))return t}(t)||function(t,e){var r=null==t?null:"undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(null!=r){var n,o,i,a,s=[],u=!0,l=!1;try{if(i=(r=r.call(t)).next,0===e){if(Object(r)!==r)return;u=!1}else for(;!(u=(n=i.call(r)).done)&&(s.push(n.value),s.length!==e);u=!0);}catch(t){l=!0,o=t}finally{try{if(!u&&null!=r.return&&(a=r.return(),Object(a)!==a))return}finally{if(l)throw o}}return s}}(t,e)||function(t,e){if(t){if("string"==typeof t)return Lt(t,e);var r={}.toString.call(t).slice(8,-1);return"Object"===r&&t.constructor&&(r=t.constructor.name),"Map"===r||"Set"===r?Array.from(t):"Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?Lt(t,e):void 0}}(t,e)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function Lt(t,e){(null==e||e>t.length)&&(e=t.length);for(var r=0,n=Array(e);r{e.width===t.clientWidth&&e.height===t.clientHeight||(e={width:t.clientWidth,height:t.clientHeight},Ut.forEach(function(t){t.requestLayout()}))}).observe(t)},!1);const Gt={id:"id",class:"class",tag:"tag",attribute:"attribute",nthchild:"nthchild",nthoftype:"nthoftype"};function qt(t="unknown problem",...e){console.warn(`CssSelectorGenerator: ${t}`,...e)}const Xt={selectors:[Gt.id,Gt.class,Gt.tag,Gt.attribute],includeTag:!1,whitelist:[],blacklist:[],combineWithinSelector:!0,combineBetweenSelectors:!0,root:null,maxCombinations:Number.POSITIVE_INFINITY,maxCandidates:Number.POSITIVE_INFINITY,useScope:!1,ignoreGeneratedClassNames:!1};function Kt(t){return!!t}function Yt(t){return t instanceof RegExp}function Jt(t){return["string","function"].includes(typeof t)||Yt(t)}function Qt(t){return Array.isArray(t)?t.filter(Jt):[]}function Zt(t){const e=[Node.DOCUMENT_NODE,Node.DOCUMENT_FRAGMENT_NODE,Node.ELEMENT_NODE];return function(t){return null!=t&&"object"==typeof t&&"nodeType"in t&&"number"==typeof t.nodeType}(t)&&e.includes(t.nodeType)}function te(t,e){if(Zt(t))return t.contains(e)||qt("element root mismatch","Provided root does not contain the element. This will most likely result in producing a fallback selector using element's real root node. If you plan to use the selector using provided root (e.g. `root.querySelector`), it will not work as intended."),t;const r=e.getRootNode({composed:!1});return Zt(r)?(r!==document&&qt("shadow root inferred","You did not provide a root and the element is a child of Shadow DOM. This will produce a selector using ShadowRoot as a root. If you plan to use the selector using document as a root (e.g. `document.querySelector`), it will not work as intended."),r):se(e)}function ee(t){return"number"==typeof t?t:Number.POSITIVE_INFINITY}function re(t=[]){const[e=[],...r]=t;return 0===r.length?e:r.reduce((t,e)=>t.filter(t=>e.includes(t)),e)}function ne(t){const e=t.map(t=>{if(Yt(t))return e=>t.test(e);if("function"==typeof t)return e=>{const r=t(e);return"boolean"!=typeof r?(qt("pattern matcher function invalid","Provided pattern matching function does not return boolean. It's result will be ignored.",t),!1):r};if("string"==typeof t){const e=new RegExp("^"+t.replace(/[|\\{}()[\]^$+?.]/g,"\\$&").replace(/\*/g,".+")+"$");return t=>e.test(t)}return qt("pattern matcher invalid","Pattern matching only accepts strings, regular expressions and/or functions. This item is invalid and will be ignored.",t),()=>!1});return t=>e.some(e=>e(t))}function oe(t,e,r){const n=Array.from(te(r,t[0]).querySelectorAll(e));return n.length===t.length&&t.every(t=>n.includes(t))}function ie(t,e){e=null!=e?e:se(t);const r=[];let n=t;for(;n&&n!==e;)Vt(n)&&r.push(n),n=n.parentNode;return r}function ae(t,e){return re(t.map(t=>ie(t,e)))}function se(t){return t.ownerDocument.querySelector(":root")}const ue=new RegExp(["^$","\\s"].join("|")),le=new RegExp(["^$"].join("|")),ce=[Gt.nthoftype,Gt.tag,Gt.id,Gt.class,Gt.attribute,Gt.nthchild],fe=ne(["class","id","ng-*"]);function pe({name:t}){return`[${t}]`}function de({name:t,value:e}){return`[${t}='${e}']`}function ye({nodeName:t,nodeValue:e}){return{name:Ce(t),value:Ce(null!=e?e:void 0)}}function he(t,e){const r=Array.from(t.attributes).filter(e=>function({nodeName:t,nodeValue:e},r){const n=r.tagName.toLowerCase();return!(["input","option"].includes(n)&&"value"===t||"src"===t&&(null==e?void 0:e.startsWith("data:"))||fe(t))}(e,t)).map(ye);return[...r.map(pe),...r.map(de)]}const ge=/^[a-z_-]{3,}$/i,me=/[bcdfghjklmnpqrstvwxyz]{4,}/i;function be(t,e){var r;const n=(null!==(r=t.getAttribute("class"))&&void 0!==r?r:"").trim().split(/\s+/).filter(t=>!le.test(t));let o=n;if(null==e?void 0:e.ignoreGeneratedClassNames){const t=ne(e.whitelist);o=n.filter(e=>{const r=`.${Ce(e)}`;return!!t(r)||function(t){if(!ge.test(t))return!1;if(t.includes("_")&&!t.includes("__"))return!1;if(/^(css|sc|jsx|emotion|makeStyles|MuiButton|MuiBox)-/i.test(t))return!1;const e=t.split(/--|__|[-]|(?<=[a-z])(?=[A-Z])/).filter(t=>t.length>0);if(0===e.length)return!1;if(1===e.length&&e[0].length<4)return!1;for(const t of e){if(t.length<=2)return!1;if(me.test(t))return!1}return!0}(e)})}return o.map(t=>`.${Ce(t)}`)}function ve(t,e){var r;const n=null!==(r=t.getAttribute("id"))&&void 0!==r?r:"",o=`#${Ce(n)}`,i=t.getRootNode({composed:!1});return!ue.test(n)&&oe([t],o,i)?[o]:[]}function we(t,e){const r=t.parentNode,n=r&&"children"in r?r.children:null;if(n)for(let e=0;exe(t)),[].concat(...n)))];var n;return 0===r.length||r.length>1?[]:[r[0]]}function Ee(t,e){const r=Se([t])[0],n=t.parentNode,o=n&&"children"in n?n:null;if(o){const e=Array.from(o.children).filter(t=>t.tagName.toLowerCase()===r),n=e.indexOf(t);if(n>-1)return[`${r}:nth-of-type(${String(n+1)})`]}return[]}function*Ae(t=[],{maxResults:e=Number.POSITIVE_INFINITY}={}){let r=0,n=je(1);for(;n.length<=t.length&&rt[e]);yield e,n=Oe(n,t.length-1)}}function Oe(t=[],e=0){const r=t.length;if(0===r)return[];const n=[...t];n[r-1]+=1;for(let t=r-1;t>=0;t--)if(n[t]>e){if(0===t)return je(r+1);n[t-1]++,n[t]=n[t-1]+1}return n[r-1]>e?je(r+1):n}function je(t=1){return Array.from(Array(t).keys())}const Re=":".charCodeAt(0).toString(16).toUpperCase(),Pe=/[ !"#$%&'()\[\]{|}<>*+,./;=?@^`~\\]/;function Ce(t=""){return CSS?CSS.escape(t):function(t=""){return t.split("").map(t=>":"===t?`\\${Re} `:Pe.test(t)?`\\${t}`:escape(t).replace(/%/g,"\\")).join("")}(t)}const Te={tag:Se,id:function(t,e){return 0===t.length||t.length>1?[]:ve(t[0])},class:function(t,e){return re(t.map(t=>be(t,e)))},attribute:function(t,e){return re(t.map(t=>he(t)))},nthchild:function(t,e){return re(t.map(t=>we(t)))},nthoftype:function(t,e){return re(t.map(t=>Ee(t)))}},Ne={tag:xe,id:ve,class:be,attribute:he,nthchild:we,nthoftype:Ee};function Me(t){return t.includes(Gt.tag)||t.includes(Gt.nthoftype)?[...t]:[...t,Gt.tag]}function*Ie(t,e){const r={};for(const n of t){const t=e[n];t&&t.length>0&&(r[n]=t)}for(const t of function*(t={}){const e=Object.entries(t);if(0===e.length)return;const r=[{index:e.length-1,partial:{}}];for(;r.length>0;){const t=r.pop();if(!t)break;const{index:n,partial:o}=t;if(n<0){yield o;continue}const[i,a]=e[n];for(let t=a.length-1;t>=0;t--)r.push({index:n-1,partial:Object.assign(Object.assign({},o),{[i]:a[t]})})}}(r))yield ke(t)}function ke(t={}){const e=[...ce];return t[Gt.tag]&&t[Gt.nthoftype]&&e.splice(e.indexOf(Gt.tag),1),e.map(e=>{return(n=t)[r=e]?n[r].join(""):"";var r,n}).join("")}function $e(t,e){return[...t.map(t=>e+" "+t),...t.map(t=>e+" > "+t)]}function*Fe(t,e,r="",n){const o=function*(t,e){const r=new Set,n=function(t,e){const{blacklist:r,whitelist:n,combineWithinSelector:o,maxCombinations:i}=e,a=ne(r),s=ne(n);return function(t){const{selectors:e,includeTag:r}=t,n=[...e];return r&&!n.includes("tag")&&n.push("tag"),n}(e).reduce((r,n)=>{const u=function(t,e,r){return(0,Te[e])(t,r)}(t,n,e),l=function(t=[],e,r){return t.filter(t=>r(t)||!e(t))}(u,a,s),c=function(t=[],e){return t.sort((t,r)=>{const n=e(t),o=e(r);return n&&!o?-1:!n&&o?1:0})}(l,s);return r[n]=o?Array.from(Ae(c,{maxResults:i})):c.map(t=>[t]),r},{})}(t,e);for(const t of function*(t,e){for(const r of function(t){const{selectors:e,combineBetweenSelectors:r,includeTag:n,maxCandidates:o}=t,i=r?function(t=[],{maxResults:e=Number.POSITIVE_INFINITY}={}){return Array.from(Ae(t,{maxResults:e}))}(e,{maxResults:o}):e.map(t=>[t]);return n?i.map(Me):i}(e))yield*Ie(r,t)}(n,e))r.has(t)||(r.add(t),yield t)}(t,n);for(const n of function*(t,e){if(""===e)yield*t;else for(const r of t)yield*$e([r],e)}(o,r))oe(t,n,e)&&(yield n)}function*_e(t,e,r="",n){if(0===t.length)return null;const o=[t.length>1?t:[],...ae(t,e).map(t=>[t])];for(const t of o)for(const o of Fe(t,e,r,n))yield{foundElements:t,selector:o}}function Be(t){return{value:t,include:!1}}function Le({selectors:t,operator:e}){let r=[...ce];t[Gt.tag]&&t[Gt.nthoftype]&&(r=r.filter(t=>t!==Gt.tag));let n="";return r.forEach(e=>{var r;(null!==(r=t[e])&&void 0!==r?r:[]).forEach(({value:t,include:e})=>{e&&(n+=t)})}),e+n}function De(t,e){return t.map(t=>function(t,e){const r=ie(t,e).reverse(),n=e instanceof ShadowRoot,o=r.map((t,e)=>{var r;const o=function(t,e,r=""){const n={};return e.forEach(e=>{Reflect.set(n,e,function(t,e){return Ne[e](t,void 0)}(t,e).map(Be))}),{element:t,operator:r,selectors:n}}(t,[Gt.nthchild],n&&0===e?"":" > ");return(null!==(r=o.selectors.nthchild)&&void 0!==r?r:[]).forEach(t=>{t.include=!0}),o});return[n?"":e?":scope":":root",...o.map(Le)].join("")}(t,e)).join(", ")}function We(t,e={}){const r=function*(t,e={}){var r;const n=function(t){(t instanceof NodeList||t instanceof HTMLCollection)&&(t=Array.from(t));const e=(Array.isArray(t)?t:[t]).filter(Vt);return[...new Set(e)]}(t),o=function(t,e={}){const r=Object.assign(Object.assign({},Xt),e);return{selectors:(n=r.selectors,Array.isArray(n)?n.filter(t=>{return e=Gt,r=t,Object.values(e).includes(r);var e,r}):[]),whitelist:Qt(r.whitelist),blacklist:Qt(r.blacklist),root:te(r.root,t),combineWithinSelector:Kt(r.combineWithinSelector),combineBetweenSelectors:Kt(r.combineBetweenSelectors),includeTag:Kt(r.includeTag),maxCombinations:ee(r.maxCombinations),maxCandidates:ee(r.maxCandidates),useScope:Kt(r.useScope),maxResults:ee(r.maxResults),ignoreGeneratedClassNames:Kt(r.ignoreGeneratedClassNames)};var n}(n[0],e),i=null!==(r=o.root)&&void 0!==r?r:se(n[0]);let a=0;for(const t of function*({elements:t,root:e,rootSelector:r="",options:n}){let o=e,i=r,a=!0;for(;a;){let r=!1;for(const a of _e(t,o,i,n)){const{foundElements:n,selector:s}=a;if(r=!0,!oe(t,s,e)){o=n[0],i=s;break}yield s}r||(a=!1)}}({elements:n,options:o,root:i,rootSelector:""}))if(yield t,a++,a>=o.maxResults)return;if(n.length>1){const{maxResults:t}=e,r=function(t,e){var r={};for(var n in t)Object.prototype.hasOwnProperty.call(t,n)&&e.indexOf(n)<0&&(r[n]=t[n]);if(null!=t&&"function"==typeof Object.getOwnPropertySymbols){var o=0;for(n=Object.getOwnPropertySymbols(t);oWe(t,r)).join(", "),a++,a>=o.maxResults)return}const s=void 0!==e.root;yield De(n,o.useScope||s?i:void 0)}(t,Object.assign(Object.assign({},e),{maxResults:1}));return r.next().value}function Ue(t){return null==t?null:-1!==["a","audio","button","canvas","details","input","label","option","select","submit","textarea","video"].indexOf(t.nodeName.toLowerCase())||t.hasAttribute("contenteditable")&&"false"!=t.getAttribute("contenteditable").toLowerCase()?t.outerHTML:t.parentElement?Ue(t.parentElement):null}function ze(t){for(var e=0;e0&&e.top0&&e.leftt.length>0).map(e=>t.ownerDocument.getElementById(e)).filter(t=>null!=t).map(t=>{var e,r;return(null===(e=t.getAttribute("aria-label"))||void 0===e?void 0:e.trim())||(null===(r=t.textContent)||void 0===r?void 0:r.replace(/\s+/g," ").trim())||""}).filter(t=>t.length>0).join(" ")||null}function qe(t,e){var r;const n=t.querySelector(`:scope > ${e}`);return(null==n||null===(r=n.textContent)||void 0===r?void 0:r.replace(/\s+/g," ").trim())||null}function Xe(t){var e,r;const n=null===(e=t.closest("figure"))||void 0===e?void 0:e.querySelector(":scope > figcaption");return!n||n.contains(t)?null:(null===(r=n.textContent)||void 0===r?void 0:r.replace(/\s+/g," ").trim())||null}let Ke=!1;function Ye(t){if(!getSelection().isCollapsed)return;let e=W({x:t.clientX,y:t.clientY}),r={defaultPrevented:t.defaultPrevented,x:e.x,y:e.y,targetElement:t.target.outerHTML,interactiveElement:Ue(t.target)};(function(t,e){let r=Ht(t);return!!r&&(webkit.messageHandlers.decorationActivated.postMessage({id:r.item.decoration.id,group:r.group,rect:D(r.item.range.getBoundingClientRect()),click:e}),!0)})(t,r)||webkit.messageHandlers.tap.postMessage(r)}function Je(t){er("down",t)}function Qe(t){er("up",t)}function Ze(t){er("move",t)}function tr(t){er("cancel",t)}function er(t,e){var r,n;Ke&&(t="cancel"),"move"!=t&&(r=Ue(e.target),n=function(t){var e,r;if(!t||!t.getBoundingClientRect)return null;let n=function(t){const e=["img","svg"];let r=t;for(;r&&r!==document.documentElement;){if(e.includes(r.tagName.toLowerCase()))return r;r=r.parentElement}return null}(t);if(!n)return null;let o=n.getBoundingClientRect(),i=W({x:o.left,y:o.top}),a=n.getAttribute("src")||n.getAttribute("href")||null,s=a?new URL(a,document.baseURI).href:null,u=s?null:n.outerHTML,l=function(t){var e,r,n;const o=t.tagName.toLowerCase(),i=(null===(e=t.getAttribute("title"))||void 0===e?void 0:e.trim())||null,a=null===(r=t.getAttribute("role"))||void 0===r?void 0:r.toLowerCase().split(/\s+/).find(t=>t.length>0),s=t.hasAttribute("aria-label")||t.hasAttribute("aria-labelledby")||t.hasAttribute("aria-describedby")||t.hasAttribute("aria-description");if("true"===(null===(n=t.getAttribute("aria-hidden"))||void 0===n?void 0:n.toLowerCase())||("presentation"===a||"none"===a)&&!s)return{name:null,description:null};let u=null,l=!1;var c;u=Ge(t,"aria-labelledby"),u||(u=(null===(c=t.getAttribute("aria-label"))||void 0===c?void 0:c.trim())||null),u||("img"===o?t.hasAttribute("alt")&&(u=t.getAttribute("alt").trim()||null,u||(l=!0)):"svg"===o&&(u=qe(t,"title")));let f=!1;u||l||!i||(u=i,f=!0),u||"img"!==o||t.hasAttribute("alt")||t.hasAttribute("title")||(u=function(t){const e=t.closest("figure"),r=null==e?void 0:e.querySelector(":scope > figcaption");if(!e||!r)return null;const n=t=>{var e;return null!==(e=null==t?void 0:t.replace(/\s+/g," ").trim())&&void 0!==e?e:""};if(n(e.textContent)!==n(r.textContent))return null;const o=e.querySelectorAll("img, svg, audio, video, object, iframe, embed");for(let e=0;e desc")?p=qe(t,"desc"):f||(p=i),{name:u,description:p}}(n);return{tag:n.tagName.toLowerCase(),html:u,src:s,resourceHref:null!==(e=null===(r=window.readium)||void 0===r||null===(r=r.link)||void 0===r?void 0:r.href)&&void 0!==e?e:null,frame:{x:i.x,y:i.y,width:o.width,height:o.height},accessibleName:l.name,accessibleDescription:l.description,caption:Xe(n),cssSelector:We(n)}}(e.target));let o=W({x:e.clientX,y:e.clientY}),i={phase:t,defaultPrevented:e.defaultPrevented,pointerId:e.pointerId,pointerType:e.pointerType,x:o.x,y:o.y,buttons:e.buttons,interactiveElement:r,targetElement:n,option:e.altKey,control:e.ctrlKey,shift:e.shiftKey,command:e.metaKey};null==Ht(e)&&webkit.messageHandlers.pointerEventReceived.postMessage(i)}function rr(t){return t.defaultPrevented||null!=Ue(document.activeElement)}function nr(t){t.stopPropagation(),t.preventDefault()}function or(t,e){e.repeat||webkit.messageHandlers.keyEventReceived.postMessage({phase:t,code:e.code,key:String.fromCharCode(e.keyCode),option:e.altKey,control:e.ctrlKey,shift:e.shiftKey,command:e.metaKey})}window.addEventListener("DOMContentLoaded",function(){document.addEventListener("click",Ye,!1),document.addEventListener("pointerdown",Je,!1),document.addEventListener("pointerup",Qe,!1),document.addEventListener("pointermove",Ze,!1),document.addEventListener("pointercancel",tr,!1),document.addEventListener("selectionchange",function(){Ke=!window.getSelection().isCollapsed})}),window.addEventListener("keydown",t=>{rr(t)||(nr(t),or("down",t))}),window.addEventListener("keyup",t=>{rr(t)||(nr(t),or("up",t))}),globalThis.readium={scrollToId:function(t,e){let r=document.getElementById(t);return!!r&&(T(r.getBoundingClientRect(),e),!0)},scrollToPosition:function(t,e,r){t<0||t>1?console.error(`Expected a valid progression in scrollToPosition, got ${t}`):R()?P()?M({left:-document.scrollingElement.scrollWidth*t,animated:r}):M({top:document.scrollingElement.scrollHeight*t,animated:r}):M({left:I(document.scrollingElement.scrollWidth*t*("rtl"==e?-1:1)),animated:r})},scrollToLocator:function(t,e){let r=k(t);return!!r&&function(t,e){return T(t.getBoundingClientRect(),e)}(r,e)},scrollLeft:function(t,e){var r="rtl"==t,n=document.scrollingElement.scrollWidth,o=window.innerWidth,i=window.scrollX-o,a=r?-(n-o):0;return N(Math.max(i,a),e)},scrollRight:function(t,e){var r="rtl"==t,n=document.scrollingElement.scrollWidth,o=window.innerWidth,i=window.scrollX+o,a=r?0:n-o;return N(Math.min(i,a),e)},setCSSProperties:function(t){for(const e in t)$(e,t[e])},setProperty:$,removeProperty:F,registerDecorationTemplates:function(t){var e="";for(const n of Object.entries(t)){var r=Bt(n,2);const t=r[0],o=r[1];Wt.set(t,o),o.stylesheet&&(e+=o.stylesheet+"\n")}if(e){let t=document.createElement("style");t.innerHTML=e,document.getElementsByTagName("head")[0].appendChild(t)}},getDecorations:function(t){var e=Ut.get(t);return e||(e=function(t,e){var r=[],n=0,o=null,i=!1;function a(e){let o=t+"-"+n++,i=k(e.locator);if(!i)return void _("Can't locate DOM range for decoration",e);let a={id:o,decoration:e,range:i};r.push(a),u(a)}function s(t){let e=r.findIndex(e=>e.decoration.id===t);if(-1===e)return;let n=r[e];r.splice(e,1),n.clickableElements=null,n.container&&(n.container.remove(),n.container=null)}function u(r){let n=(o||((o=document.createElement("div")).id=t,o.dataset.group=e,o.style.pointerEvents="none",requestAnimationFrame(function(){null!=o&&document.body.append(o)})),o),i=Wt.get(r.decoration.style);if(!i)return void B(`Unknown decoration style: ${r.decoration.style}`);let a=document.createElement("div");a.id=r.id,a.dataset.style=r.decoration.style,a.style.pointerEvents="none";const s=getComputedStyle(document.body).writingMode,u="vertical-rl"===s||"vertical-lr"===s,l=document.scrollingElement,c=l.scrollLeft,f=l.scrollTop,p=u?window.innerHeight:window.innerWidth,d=u?window.innerWidth:window.innerHeight,y=parseInt(getComputedStyle(document.documentElement).getPropertyValue("column-count"))||1,h=(u?d:p)/y;function g(t,e,r,n){t.style.position="absolute";const o="vertical-rl"===n;if(o||"vertical-lr"===n){if("wrap"===i.width)t.style.width=`${e.width}px`,t.style.height=`${e.height}px`,o?t.style.right=`${-e.right-c+l.clientWidth}px`:t.style.left=`${e.left+c}px`,t.style.top=`${e.top+f}px`;else if("viewport"===i.width){t.style.width=`${e.height}px`,t.style.height=`${p}px`;const r=Math.floor(e.top/p)*p;o?t.style.right=-e.right-c+"px":t.style.left=`${e.left+c}px`,t.style.top=`${r+f}px`}else if("bounds"===i.width)t.style.width=`${r.height}px`,t.style.height=`${p}px`,o?t.style.right=`${-r.right-c+l.clientWidth}px`:t.style.left=`${r.left+c}px`,t.style.top=`${r.top+f}px`;else if("page"===i.width){t.style.width=`${e.height}px`,t.style.height=`${h}px`;const r=Math.floor(e.top/h)*h;o?t.style.right=`${-e.right-c+l.clientWidth}px`:t.style.left=`${e.left+c}px`,t.style.top=`${r+f}px`}}else if("wrap"===i.width)t.style.width=`${e.width}px`,t.style.height=`${e.height}px`,t.style.left=`${e.left+c}px`,t.style.top=`${e.top+f}px`;else if("viewport"===i.width){t.style.width=`${p}px`,t.style.height=`${e.height}px`;const r=Math.floor(e.left/p)*p;t.style.left=`${r+c}px`,t.style.top=`${e.top+f}px`}else if("bounds"===i.width)t.style.width=`${r.width}px`,t.style.height=`${e.height}px`,t.style.left=`${r.left+c}px`,t.style.top=`${e.top+f}px`;else if("page"===i.width){t.style.width=`${h}px`,t.style.height=`${e.height}px`;const r=Math.floor(e.left/h)*h;t.style.left=`${r+c}px`,t.style.top=`${e.top+f}px`}}let m,b=r.range.getBoundingClientRect();try{let t=document.createElement("template");t.innerHTML=r.decoration.element.trim(),m=t.content.firstElementChild}catch(t){return void B(`Invalid decoration element "${r.decoration.element}": ${t.message}`)}if("boxes"===i.layout){const t=!s.startsWith("vertical"),e=(v=r.range.startContainer).nodeType===Node.ELEMENT_NODE?v:v.parentElement,n=getComputedStyle(e).writingMode,o=U(r.range,t).sort((t,e)=>t.top!==e.top?t.top-e.top:"vertical-rl"===n?e.left-t.left:t.left-e.left);for(let t of o){const e=m.cloneNode(!0);e.style.pointerEvents="none",e.dataset.writingMode=n,g(e,t,b,s),a.append(e)}}else if("bounds"===i.layout){const t=m.cloneNode(!0);t.style.pointerEvents="none",t.dataset.writingMode=s,g(t,b,b,s),a.append(t)}var v;n.append(a),r.container=a,r.clickableElements=Array.from(a.querySelectorAll("[data-activable='1']")),0===r.clickableElements.length&&(r.clickableElements=Array.from(a.children))}function l(){o&&(o.remove(),o=null)}return{add:a,remove:s,update:function(t){s(t.id),a(t)},clear:function(){l(),r.length=0},items:r,requestLayout:function(){l(),r.forEach(t=>u(t))},isActivable:function(){return i},setActivable:function(){i=!0}}}("r2-decoration-"+zt++,t),Ut.set(t,e)),e},findFirstVisibleLocator:function(){const t=ze(document.body);return{href:"#",type:"application/xhtml+xml",locations:{cssSelector:We(t)},text:{highlight:t.textContent}}}},window.readium.isReflowable=!0,webkit.messageHandlers.spreadLoadStarted.postMessage({}),window.addEventListener("load",function(){window.requestAnimationFrame(function(){webkit.messageHandlers.spreadLoaded.postMessage({})});let t=document.createElement("meta");t.setAttribute("name","viewport"),t.setAttribute("content","width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, shrink-to-fit=no"),document.head.appendChild(t)})})()})(); //# sourceMappingURL=readium-reflowable.js.map \ No newline at end of file diff --git a/Sources/Navigator/EPUB/EPUBSpreadView.swift b/Sources/Navigator/EPUB/EPUBSpreadView.swift index 71bc8fd9c4..c40dd91652 100644 --- a/Sources/Navigator/EPUB/EPUBSpreadView.swift +++ b/Sources/Navigator/EPUB/EPUBSpreadView.swift @@ -319,8 +319,11 @@ class EPUBSpreadView: UIView, Loggable, PageView { } var attributes: [ContentAttribute] = [] - if let label = json["accessibilityLabel"] as? String, !label.isEmpty { - attributes.append(ContentAttribute(key: .accessibilityLabel, value: label)) + if let name = json["accessibleName"] as? String, !name.isEmpty { + attributes.append(ContentAttribute(key: .accessibleName, value: name)) + } + if let description = json["accessibleDescription"] as? String, !description.isEmpty { + attributes.append(ContentAttribute(key: .accessibleDescription, value: description)) } let caption = json["caption"] as? String diff --git a/Sources/Navigator/EPUB/Scripts/jest.config.cjs b/Sources/Navigator/EPUB/Scripts/jest.config.cjs new file mode 100644 index 0000000000..51350e983b --- /dev/null +++ b/Sources/Navigator/EPUB/Scripts/jest.config.cjs @@ -0,0 +1,7 @@ +module.exports = { + transform: { + "^.+\\.ts$": ["ts-jest", { tsconfig: "tsconfig.test.json" }], + }, + testMatch: ["**/*.test.ts"], + testEnvironment: "jsdom", +}; diff --git a/Sources/Navigator/EPUB/Scripts/package.json b/Sources/Navigator/EPUB/Scripts/package.json index f4b884799d..be87a47c44 100644 --- a/Sources/Navigator/EPUB/Scripts/package.json +++ b/Sources/Navigator/EPUB/Scripts/package.json @@ -8,8 +8,10 @@ "scripts": { "bundle": "webpack", "lint": "eslint src", - "checkformat": "prettier --check '**/*.js'", - "format": "prettier --list-different --write '**/*.js'" + "typecheck": "tsc --noEmit", + "test": "jest", + "checkformat": "prettier --check '**/*.{js,ts}'", + "format": "prettier --list-different --write '**/*.{js,ts}'" }, "browserslist": [ "iOS 13" @@ -18,9 +20,15 @@ "@babel/core": "^7.29.7", "@babel/preset-env": "^7.29.7", "@babel/preset-typescript": "^7.29.7", + "@types/jest": "^30.0.0", + "@types/node": "^26.1.2", "babel-loader": "^8.4.1", "eslint": "^7.32.0", + "jest": "^30.4.2", + "jest-environment-jsdom": "^30.4.1", "prettier": "2.3.1", + "ts-jest": "^29.4.12", + "typescript": "^5.9.3", "webpack": "^5.107.2", "webpack-cli": "^5.1.4" }, diff --git a/Sources/Navigator/EPUB/Scripts/pnpm-lock.yaml b/Sources/Navigator/EPUB/Scripts/pnpm-lock.yaml index 3fcbd9f68f..4127b06fd5 100644 --- a/Sources/Navigator/EPUB/Scripts/pnpm-lock.yaml +++ b/Sources/Navigator/EPUB/Scripts/pnpm-lock.yaml @@ -28,15 +28,33 @@ devDependencies: '@babel/preset-typescript': specifier: ^7.29.7 version: 7.29.7(@babel/core@7.29.7) + '@types/jest': + specifier: ^30.0.0 + version: 30.0.0 + '@types/node': + specifier: ^26.1.2 + version: 26.1.2 babel-loader: specifier: ^8.4.1 version: 8.4.1(@babel/core@7.29.7)(webpack@5.107.2) eslint: specifier: ^7.32.0 version: 7.32.0 + jest: + specifier: ^30.4.2 + version: 30.4.2(@types/node@26.1.2) + jest-environment-jsdom: + specifier: ^30.4.1 + version: 30.4.1 prettier: specifier: 2.3.1 version: 2.3.1 + ts-jest: + specifier: ^29.4.12 + version: 29.4.12(@babel/core@7.29.7)(jest@30.4.2)(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 webpack: specifier: ^5.107.2 version: 5.107.2(webpack-cli@5.1.4) @@ -46,6 +64,16 @@ devDependencies: packages: + /@asamuzakjp/css-color@3.2.0: + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5)(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5)(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + dev: true + /@babel/code-frame@7.12.11: resolution: {integrity: sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==} dependencies: @@ -386,6 +414,43 @@ packages: '@babel/core': 7.29.7 dev: true + /@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.7): + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + dev: true + + /@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.7): + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + dev: true + + /@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.7): + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + dev: true + + /@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.7): + resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + dev: true + /@babel/plugin-syntax-import-assertions@7.29.7(@babel/core@7.29.7): resolution: {integrity: sha512-/An1OCBN93thpBAGyfsK2pcf0jvju1SAtKkL2Ny++B5Sy6sqgzXDQH1cZxWbF96Wuk+bn41MDA9bLd4VVAw6rw==} engines: {node: '>=6.9.0'} @@ -406,6 +471,24 @@ packages: '@babel/helper-plugin-utils': 7.29.7 dev: true + /@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7): + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + dev: true + + /@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.7): + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + dev: true + /@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7): resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} engines: {node: '>=6.9.0'} @@ -416,6 +499,80 @@ packages: '@babel/helper-plugin-utils': 7.29.7 dev: true + /@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.7): + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + dev: true + + /@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7): + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + dev: true + + /@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.7): + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + dev: true + + /@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.7): + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + dev: true + + /@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.7): + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + dev: true + + /@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7): + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + dev: true + + /@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.7): + resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + dev: true + + /@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.7): + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + dev: true + /@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7): resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} engines: {node: '>=6.9.0'} @@ -1182,11 +1339,83 @@ packages: '@babel/helper-validator-identifier': 7.29.7 dev: true + /@bcoe/v8-coverage@0.2.3: + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + dev: true + + /@csstools/color-helpers@5.1.0: + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + dev: true + + /@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5)(@csstools/css-tokenizer@3.0.4): + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + dev: true + + /@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5)(@csstools/css-tokenizer@3.0.4): + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5)(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + dev: true + + /@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4): + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + dependencies: + '@csstools/css-tokenizer': 3.0.4 + dev: true + + /@csstools/css-tokenizer@3.0.4: + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + dev: true + /@discoveryjs/json-ext@0.5.7: resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} engines: {node: '>=10.0.0'} dev: true + /@emnapi/core@1.10.0: + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + requiresBuild: true + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + dev: true + optional: true + + /@emnapi/runtime@1.10.0: + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + requiresBuild: true + dependencies: + tslib: 2.8.1 + dev: true + optional: true + + /@emnapi/wasi-threads@1.2.1: + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + requiresBuild: true + dependencies: + tslib: 2.8.1 + dev: true + optional: true + /@eslint/eslintrc@0.4.3: resolution: {integrity: sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw==} engines: {node: ^10.12.0 || >=12.0.0} @@ -1221,138 +1450,740 @@ packages: deprecated: Use @eslint/object-schema instead dev: true - /@jridgewell/gen-mapping@0.3.13: - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + /@isaacs/cliui@8.0.2: + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 + string-width: 5.1.2 + string-width-cjs: /string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: /strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: /wrap-ansi@7.0.0 dev: true - /@jridgewell/remapping@2.3.5: - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + /@istanbuljs/load-nyc-config@1.1.0: + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.14.2 + resolve-from: 5.0.0 dev: true - /@jridgewell/resolve-uri@3.1.2: - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} + /@istanbuljs/schema@0.1.6: + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + engines: {node: '>=8'} dev: true - /@jridgewell/source-map@0.3.11: - resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + /@jest/console@30.4.1: + resolution: {integrity: sha512-v3bhyxUh9Hgmo5p6hAOXe14/R3ZxZDOsvHleh4B07z3m/x4/ngPUXEm9XwK4sF4u+f+P2ORb0Ge+MgpaqRMVDA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - dev: true - - /@jridgewell/sourcemap-codec@1.5.5: - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jest/types': 30.4.1 + '@types/node': 26.1.2 + chalk: 4.1.2 + jest-message-util: 30.4.1 + jest-util: 30.4.1 + slash: 3.0.0 dev: true - /@jridgewell/trace-mapping@0.3.31: - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + /@jest/core@30.4.2: + resolution: {integrity: sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@jest/console': 30.4.1 + '@jest/pattern': 30.4.0 + '@jest/reporters': 30.4.1 + '@jest/test-result': 30.4.1 + '@jest/transform': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 26.1.2 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 4.4.0 + exit-x: 0.2.2 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-changed-files: 30.4.1 + jest-config: 30.4.2(@types/node@26.1.2) + jest-haste-map: 30.4.1 + jest-message-util: 30.4.1 + jest-regex-util: 30.4.0 + jest-resolve: 30.4.1 + jest-resolve-dependencies: 30.4.2 + jest-runner: 30.4.2 + jest-runtime: 30.4.2 + jest-snapshot: 30.4.1 + jest-util: 30.4.1 + jest-validate: 30.4.1 + jest-watcher: 30.4.1 + pretty-format: 30.4.1 + slash: 3.0.0 + transitivePeerDependencies: + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node dev: true - /@juggle/resize-observer@3.4.0: - resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} - dev: false - - /@types/estree@1.0.9: - resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + /@jest/diff-sequences@30.4.0: + resolution: {integrity: sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} dev: true - /@types/json-schema@7.0.15: - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + /@jest/environment-jsdom-abstract@30.4.1(jsdom@26.1.0): + resolution: {integrity: sha512-dSlKrqug3siYNHVnjwIldShY12wAH3spwRltO/+8VOjg0X+xEq7vOs3DbBs4LRKsu7OH+NUb9kuZUNBF9Ho3TA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + jsdom: '*' + peerDependenciesMeta: + canvas: + optional: true + dependencies: + '@jest/environment': 30.4.1 + '@jest/fake-timers': 30.4.1 + '@jest/types': 30.4.1 + '@types/jsdom': 21.1.7 + '@types/node': 26.1.2 + jest-mock: 30.4.1 + jest-util: 30.4.1 + jsdom: 26.1.0 dev: true - /@types/node@25.9.3: - resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==} + /@jest/environment@30.4.1: + resolution: {integrity: sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} dependencies: - undici-types: 7.24.6 + '@jest/fake-timers': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 26.1.2 + jest-mock: 30.4.1 dev: true - /@webassemblyjs/ast@1.14.1: - resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} + /@jest/expect-utils@30.4.1: + resolution: {integrity: sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} dependencies: - '@webassemblyjs/helper-numbers': 1.13.2 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@jest/get-type': 30.1.0 dev: true - /@webassemblyjs/floating-point-hex-parser@1.13.2: - resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} + /@jest/expect@30.4.1: + resolution: {integrity: sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + dependencies: + expect: 30.4.1 + jest-snapshot: 30.4.1 + transitivePeerDependencies: + - supports-color dev: true - /@webassemblyjs/helper-api-error@1.13.2: - resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} + /@jest/fake-timers@30.4.1: + resolution: {integrity: sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + dependencies: + '@jest/types': 30.4.1 + '@sinonjs/fake-timers': 15.4.0 + '@types/node': 26.1.2 + jest-message-util: 30.4.1 + jest-mock: 30.4.1 + jest-util: 30.4.1 dev: true - /@webassemblyjs/helper-buffer@1.14.1: - resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} + /@jest/get-type@30.1.0: + resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} dev: true - /@webassemblyjs/helper-numbers@1.13.2: - resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} + /@jest/globals@30.4.1: + resolution: {integrity: sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} dependencies: - '@webassemblyjs/floating-point-hex-parser': 1.13.2 - '@webassemblyjs/helper-api-error': 1.13.2 - '@xtuc/long': 4.2.2 + '@jest/environment': 30.4.1 + '@jest/expect': 30.4.1 + '@jest/types': 30.4.1 + jest-mock: 30.4.1 + transitivePeerDependencies: + - supports-color dev: true - /@webassemblyjs/helper-wasm-bytecode@1.13.2: - resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} + /@jest/pattern@30.4.0: + resolution: {integrity: sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + dependencies: + '@types/node': 26.1.2 + jest-regex-util: 30.4.0 dev: true - /@webassemblyjs/helper-wasm-section@1.14.1: - resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} + /@jest/reporters@30.4.1: + resolution: {integrity: sha512-/SnkPCzEQpUaBH81kjdEdDdo2WZl5hxw+BmLDGWjRkm8o7XlhjwsU36cqwe5PGBE5WYpBvDzRSdXx9rbGuJtNA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-buffer': 1.14.1 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - '@webassemblyjs/wasm-gen': 1.14.1 + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 30.4.1 + '@jest/test-result': 30.4.1 + '@jest/transform': 30.4.1 + '@jest/types': 30.4.1 + '@jridgewell/trace-mapping': 0.3.31 + '@types/node': 26.1.2 + chalk: 4.1.2 + collect-v8-coverage: 1.0.3 + exit-x: 0.2.2 + glob: 10.5.0 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + jest-message-util: 30.4.1 + jest-util: 30.4.1 + jest-worker: 30.4.1 + slash: 3.0.0 + string-length: 4.0.2 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - supports-color dev: true - /@webassemblyjs/ieee754@1.13.2: - resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} + /@jest/schemas@30.4.1: + resolution: {integrity: sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} dependencies: - '@xtuc/ieee754': 1.2.0 + '@sinclair/typebox': 0.34.52 dev: true - /@webassemblyjs/leb128@1.13.2: - resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} + /@jest/snapshot-utils@30.4.1: + resolution: {integrity: sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} dependencies: - '@xtuc/long': 4.2.2 + '@jest/types': 30.4.1 + chalk: 4.1.2 + graceful-fs: 4.2.11 + natural-compare: 1.4.0 dev: true - /@webassemblyjs/utf8@1.13.2: - resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} + /@jest/source-map@30.0.1: + resolution: {integrity: sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + callsites: 3.1.0 + graceful-fs: 4.2.11 dev: true - /@webassemblyjs/wasm-edit@1.14.1: - resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} + /@jest/test-result@30.4.1: + resolution: {integrity: sha512-/ZG7pgEiOmmWkN9TplKbOu4id2N5lh7FHwRwlkgBVAzGdRH+OkkQ8wX/kIxg4zmd3ZQvAL1RwL2yWsvNYYECTw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-buffer': 1.14.1 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - '@webassemblyjs/helper-wasm-section': 1.14.1 - '@webassemblyjs/wasm-gen': 1.14.1 - '@webassemblyjs/wasm-opt': 1.14.1 - '@webassemblyjs/wasm-parser': 1.14.1 - '@webassemblyjs/wast-printer': 1.14.1 + '@jest/console': 30.4.1 + '@jest/types': 30.4.1 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.3 dev: true - /@webassemblyjs/wasm-gen@1.14.1: - resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} + /@jest/test-sequencer@30.4.1: + resolution: {integrity: sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - '@webassemblyjs/ieee754': 1.13.2 - '@webassemblyjs/leb128': 1.13.2 - '@webassemblyjs/utf8': 1.13.2 + '@jest/test-result': 30.4.1 + graceful-fs: 4.2.11 + jest-haste-map: 30.4.1 + slash: 3.0.0 + dev: true + + /@jest/transform@30.4.1: + resolution: {integrity: sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + dependencies: + '@babel/core': 7.29.7 + '@jest/types': 30.4.1 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 7.0.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.4.1 + jest-regex-util: 30.4.0 + jest-util: 30.4.1 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 5.0.1 + transitivePeerDependencies: + - supports-color + dev: true + + /@jest/types@30.4.1: + resolution: {integrity: sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + dependencies: + '@jest/pattern': 30.4.0 + '@jest/schemas': 30.4.1 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 26.1.2 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + dev: true + + /@jridgewell/gen-mapping@0.3.13: + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + dev: true + + /@jridgewell/remapping@2.3.5: + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + dev: true + + /@jridgewell/resolve-uri@3.1.2: + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + dev: true + + /@jridgewell/source-map@0.3.11: + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + dev: true + + /@jridgewell/sourcemap-codec@1.5.5: + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + dev: true + + /@jridgewell/trace-mapping@0.3.31: + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + dev: true + + /@juggle/resize-observer@3.4.0: + resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} + dev: false + + /@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): + resolution: {integrity: sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + requiresBuild: true + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.3 + dev: true + optional: true + + /@pkgjs/parseargs@0.11.0: + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + requiresBuild: true + dev: true + optional: true + + /@pkgr/core@0.3.6: + resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} + engines: {node: ^14.18.0 || >=16.0.0} + dev: true + + /@sinclair/typebox@0.34.52: + resolution: {integrity: sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==} + dev: true + + /@sinonjs/commons@3.0.1: + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + dependencies: + type-detect: 4.0.8 + dev: true + + /@sinonjs/fake-timers@15.4.0: + resolution: {integrity: sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==} + dependencies: + '@sinonjs/commons': 3.0.1 + dev: true + + /@tybys/wasm-util@0.10.3: + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + requiresBuild: true + dependencies: + tslib: 2.8.1 + dev: true + optional: true + + /@types/babel__core@7.20.5: + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + dev: true + + /@types/babel__generator@7.27.0: + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + dependencies: + '@babel/types': 7.29.7 + dev: true + + /@types/babel__template@7.4.4: + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + dev: true + + /@types/babel__traverse@7.28.0: + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + dependencies: + '@babel/types': 7.29.7 + dev: true + + /@types/estree@1.0.9: + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + dev: true + + /@types/istanbul-lib-coverage@2.0.6: + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + dev: true + + /@types/istanbul-lib-report@3.0.3: + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + dev: true + + /@types/istanbul-reports@3.0.4: + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + dependencies: + '@types/istanbul-lib-report': 3.0.3 + dev: true + + /@types/jest@30.0.0: + resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==} + dependencies: + expect: 30.4.1 + pretty-format: 30.4.1 + dev: true + + /@types/jsdom@21.1.7: + resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==} + dependencies: + '@types/node': 26.1.2 + '@types/tough-cookie': 4.0.5 + parse5: 7.3.0 + dev: true + + /@types/json-schema@7.0.15: + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + dev: true + + /@types/node@26.1.2: + resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} + dependencies: + undici-types: 8.3.0 + dev: true + + /@types/stack-utils@2.0.3: + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + dev: true + + /@types/tough-cookie@4.0.5: + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + dev: true + + /@types/yargs-parser@21.0.3: + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + dev: true + + /@types/yargs@17.0.35: + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + dependencies: + '@types/yargs-parser': 21.0.3 + dev: true + + /@ungap/structured-clone@1.3.3: + resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} + dev: true + + /@unrs/resolver-binding-android-arm-eabi@1.12.2: + resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@unrs/resolver-binding-android-arm64@1.12.2: + resolution: {integrity: sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@unrs/resolver-binding-darwin-arm64@1.12.2: + resolution: {integrity: sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@unrs/resolver-binding-darwin-x64@1.12.2: + resolution: {integrity: sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@unrs/resolver-binding-freebsd-x64@1.12.2: + resolution: {integrity: sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2: + resolution: {integrity: sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@unrs/resolver-binding-linux-arm-musleabihf@1.12.2: + resolution: {integrity: sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@unrs/resolver-binding-linux-arm64-gnu@1.12.2: + resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@unrs/resolver-binding-linux-arm64-musl@1.12.2: + resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@unrs/resolver-binding-linux-loong64-gnu@1.12.2: + resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} + cpu: [loong64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@unrs/resolver-binding-linux-loong64-musl@1.12.2: + resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} + cpu: [loong64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@unrs/resolver-binding-linux-ppc64-gnu@1.12.2: + resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@unrs/resolver-binding-linux-riscv64-gnu@1.12.2: + resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@unrs/resolver-binding-linux-riscv64-musl@1.12.2: + resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@unrs/resolver-binding-linux-s390x-gnu@1.12.2: + resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@unrs/resolver-binding-linux-x64-gnu@1.12.2: + resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@unrs/resolver-binding-linux-x64-musl@1.12.2: + resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@unrs/resolver-binding-openharmony-arm64@1.12.2: + resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} + cpu: [arm64] + os: [openharmony] + requiresBuild: true + dev: true + optional: true + + /@unrs/resolver-binding-wasm32-wasi@1.12.2: + resolution: {integrity: sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + requiresBuild: true + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + dev: true + optional: true + + /@unrs/resolver-binding-win32-arm64-msvc@1.12.2: + resolution: {integrity: sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@unrs/resolver-binding-win32-ia32-msvc@1.12.2: + resolution: {integrity: sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@unrs/resolver-binding-win32-x64-msvc@1.12.2: + resolution: {integrity: sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@webassemblyjs/ast@1.14.1: + resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} + dependencies: + '@webassemblyjs/helper-numbers': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + dev: true + + /@webassemblyjs/floating-point-hex-parser@1.13.2: + resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} + dev: true + + /@webassemblyjs/helper-api-error@1.13.2: + resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} + dev: true + + /@webassemblyjs/helper-buffer@1.14.1: + resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} + dev: true + + /@webassemblyjs/helper-numbers@1.13.2: + resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} + dependencies: + '@webassemblyjs/floating-point-hex-parser': 1.13.2 + '@webassemblyjs/helper-api-error': 1.13.2 + '@xtuc/long': 4.2.2 + dev: true + + /@webassemblyjs/helper-wasm-bytecode@1.13.2: + resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} + dev: true + + /@webassemblyjs/helper-wasm-section@1.14.1: + resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/wasm-gen': 1.14.1 + dev: true + + /@webassemblyjs/ieee754@1.13.2: + resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} + dependencies: + '@xtuc/ieee754': 1.2.0 + dev: true + + /@webassemblyjs/leb128@1.13.2: + resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} + dependencies: + '@xtuc/long': 4.2.2 + dev: true + + /@webassemblyjs/utf8@1.13.2: + resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} + dev: true + + /@webassemblyjs/wasm-edit@1.14.1: + resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/helper-wasm-section': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-opt': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + '@webassemblyjs/wast-printer': 1.14.1 + dev: true + + /@webassemblyjs/wasm-gen@1.14.1: + resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 dev: true /@webassemblyjs/wasm-opt@1.14.1: @@ -1456,6 +2287,11 @@ packages: hasBin: true dev: true + /agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + dev: true + /ajv-formats@2.1.1(ajv@8.20.0): resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -1507,11 +2343,23 @@ packages: engines: {node: '>=6'} dev: true + /ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + dependencies: + type-fest: 0.21.3 + dev: true + /ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} dev: true + /ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + dev: true + /ansi-styles@3.2.1: resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} engines: {node: '>=4'} @@ -1526,6 +2374,24 @@ packages: color-convert: 2.0.1 dev: true + /ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + dev: true + + /ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + dev: true + + /anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + dev: true + /approx-string-match@1.1.0: resolution: {integrity: sha512-j1yQB9XhfGWsvTfHEuNsR/SrUT4XQDkAc0PEjMifyi97931LmNQyLsO6HbuvZ3HeMx+3Dvk8m8XGkUF+8lCeqw==} dev: false @@ -1574,6 +2440,24 @@ packages: possible-typed-array-names: 1.1.0 dev: false + /babel-jest@30.4.1(@babel/core@7.29.7): + resolution: {integrity: sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@babel/core': ^7.11.0 || ^8.0.0-0 + dependencies: + '@babel/core': 7.29.7 + '@jest/transform': 30.4.1 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 7.0.1 + babel-preset-jest: 30.4.0(@babel/core@7.29.7) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + dev: true + /babel-loader@8.4.1(@babel/core@7.29.7)(webpack@5.107.2): resolution: {integrity: sha512-nXzRChX+Z1GoE6yWavBQg6jDslyFF3SDjl2paADuoQtQW10JqShJt62R6eJQ5m/pjJFDT8xgKIWSP85OY8eXeA==} engines: {node: '>= 8.9'} @@ -1589,6 +2473,26 @@ packages: webpack: 5.107.2(webpack-cli@5.1.4) dev: true + /babel-plugin-istanbul@7.0.1: + resolution: {integrity: sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==} + engines: {node: '>=12'} + dependencies: + '@babel/helper-plugin-utils': 7.29.7 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-instrument: 6.0.3 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + dev: true + + /babel-plugin-jest-hoist@30.4.0: + resolution: {integrity: sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + dependencies: + '@types/babel__core': 7.20.5 + dev: true + /babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.7): resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==} peerDependencies: @@ -1625,6 +2529,40 @@ packages: - supports-color dev: true + /babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.7): + resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} + peerDependencies: + '@babel/core': ^7.0.0 || ^8.0.0-0 + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.7) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.7) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.7) + '@babel/plugin-syntax-import-attributes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.7) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.7) + dev: true + + /babel-preset-jest@30.4.0(@babel/core@7.29.7): + resolution: {integrity: sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@babel/core': ^7.11.0 || ^8.0.0-beta.1 + dependencies: + '@babel/core': 7.29.7 + babel-plugin-jest-hoist: 30.4.0 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) + dev: true + /balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} dev: true @@ -1646,6 +2584,12 @@ packages: concat-map: 0.0.1 dev: true + /brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} + dependencies: + balanced-match: 1.0.2 + dev: true + /browserslist@4.28.2: resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -1658,6 +2602,19 @@ packages: update-browserslist-db: 1.2.3(browserslist@4.28.2) dev: true + /bs-logger@0.2.6: + resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} + engines: {node: '>= 6'} + dependencies: + fast-json-stable-stringify: 2.1.0 + dev: true + + /bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + dependencies: + node-int64: 0.4.0 + dev: true + /buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} dev: true @@ -1693,6 +2650,16 @@ packages: engines: {node: '>=6'} dev: true + /camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + dev: true + + /camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + dev: true + /caniuse-lite@1.0.30001799: resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} dev: true @@ -1714,11 +2681,34 @@ packages: supports-color: 7.2.0 dev: true + /char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + dev: true + /chrome-trace-event@1.0.4: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} dev: true + /ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + dev: true + + /cjs-module-lexer@2.2.0: + resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + dev: true + + /cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + dev: true + /clone-deep@4.0.1: resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==} engines: {node: '>=6'} @@ -1728,6 +2718,15 @@ packages: shallow-clone: 3.0.1 dev: true + /co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + dev: true + + /collect-v8-coverage@1.0.3: + resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} + dev: true + /color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} dependencies: @@ -1793,6 +2792,22 @@ packages: resolution: {integrity: sha512-VvPJovWN5NC6PdilCV/eq7ezPx8zfVcaGcOjKVQqVYvZ1ooicqB6PkklM9UfxjjkmO9fwQr80idYUoJEowrOPg==} dev: false + /cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + dev: true + + /data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + dev: true + /data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -1832,10 +2847,28 @@ packages: ms: 2.1.3 dev: true + /decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + dev: true + + /dedent@1.7.2: + resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + dev: true + /deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} dev: true + /deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + dev: true + /define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} @@ -1854,6 +2887,11 @@ packages: object-keys: 1.1.1 dev: false + /detect-newline@3.1.0: + resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} + engines: {node: '>=8'} + dev: true + /doctrine@3.0.0: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} @@ -1870,14 +2908,27 @@ packages: gopd: 1.2.0 dev: false + /eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + dev: true + /electron-to-chromium@1.5.373: resolution: {integrity: sha512-G2Hym8JIf/QreuseqkDibgH8Ci8KfJzqGDKdakbhSx9UltwRBH2cBLAWU/lBX0sCdv0TlhyxQyDCnSfxgMWsjA==} dev: true + /emittery@0.13.1: + resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} + engines: {node: '>=12'} + dev: true + /emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} dev: true + /emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + dev: true + /emojis-list@3.0.0: resolution: {integrity: sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==} engines: {node: '>= 4'} @@ -1899,12 +2950,23 @@ packages: strip-ansi: 6.0.1 dev: true + /entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + dev: true + /envinfo@7.21.0: resolution: {integrity: sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow==} engines: {node: '>=4'} hasBin: true dev: true + /error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + dependencies: + is-arrayish: 0.2.1 + dev: true + /es-abstract-get@1.0.0: resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} engines: {node: '>= 0.4'} @@ -2026,6 +3088,11 @@ packages: engines: {node: '>=0.8.0'} dev: true + /escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + dev: true + /escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -2155,6 +3222,38 @@ packages: engines: {node: '>=0.8.x'} dev: true + /execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + dev: true + + /exit-x@0.2.2: + resolution: {integrity: sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==} + engines: {node: '>= 0.8.0'} + dev: true + + /expect@30.4.1: + resolution: {integrity: sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + dependencies: + '@jest/expect-utils': 30.4.1 + '@jest/get-type': 30.1.0 + jest-matcher-utils: 30.4.1 + jest-message-util: 30.4.1 + jest-mock: 30.4.1 + jest-util: 30.4.1 + dev: true + /fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} dev: true @@ -2176,6 +3275,12 @@ packages: engines: {node: '>= 4.9.1'} dev: true + /fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + dependencies: + bser: 2.1.1 + dev: true + /file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -2225,10 +3330,26 @@ packages: is-callable: 1.2.7 dev: false + /foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + dev: true + /fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} dev: true + /fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + requiresBuild: true + dev: true + optional: true + /function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -2265,6 +3386,11 @@ packages: engines: {node: '>=6.9.0'} dev: true + /get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + dev: true + /get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -2281,6 +3407,11 @@ packages: math-intrinsics: 1.1.0 dev: false + /get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + dev: true + /get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -2289,6 +3420,11 @@ packages: es-object-atoms: 1.1.2 dev: false + /get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + dev: true + /get-symbol-description@1.1.0: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} @@ -2309,6 +3445,19 @@ packages: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} dev: true + /glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + dev: true + /glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -2345,6 +3494,19 @@ packages: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} dev: true + /handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} + engines: {node: '>=0.4.7'} + hasBin: true + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + dev: true + /has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -2391,6 +3553,49 @@ packages: dependencies: function-bind: 1.1.2 + /html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + dependencies: + whatwg-encoding: 3.1.1 + dev: true + + /html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + dev: true + + /http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + dev: true + + /https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + dev: true + + /human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + dev: true + + /iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + dependencies: + safer-buffer: 2.1.2 + dev: true + /ignore@4.0.6: resolution: {integrity: sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==} engines: {node: '>= 4'} @@ -2453,6 +3658,10 @@ packages: get-intrinsic: 1.3.0 dev: false + /is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + dev: true + /is-async-function@2.1.1: resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} engines: {node: '>= 0.4'} @@ -2532,6 +3741,11 @@ packages: engines: {node: '>=8'} dev: true + /is-generator-fn@2.1.0: + resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} + engines: {node: '>=6'} + dev: true + /is-generator-function@1.1.2: resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} engines: {node: '>= 0.4'} @@ -2575,6 +3789,10 @@ packages: isobject: 3.0.1 dev: true + /is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + dev: true + /is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -2597,6 +3815,11 @@ packages: call-bound: 1.0.4 dev: false + /is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + dev: true + /is-string@1.1.1: resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} engines: {node: '>= 0.4'} @@ -2654,15 +3877,500 @@ packages: engines: {node: '>=0.10.0'} dev: true + /istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + dev: true + + /istanbul-lib-instrument@6.0.3: + resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} + engines: {node: '>=10'} + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.7 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-coverage: 3.2.2 + semver: 7.8.4 + transitivePeerDependencies: + - supports-color + dev: true + + /istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + dev: true + + /istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + dev: true + + /istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + dev: true + + /jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + dev: true + + /jest-changed-files@30.4.1: + resolution: {integrity: sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + dependencies: + execa: 5.1.1 + jest-util: 30.4.1 + p-limit: 3.1.0 + dev: true + + /jest-circus@30.4.2: + resolution: {integrity: sha512-rvHH7VlY6LgbJXJTQ87GW62g1FntOtbhh0zT+v04kC+pgL6aBKyYINXxWukCpj3dcIBMw5/XUbtDS9dU9JTXeQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + dependencies: + '@jest/environment': 30.4.1 + '@jest/expect': 30.4.1 + '@jest/test-result': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 26.1.2 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.7.2 + is-generator-fn: 2.1.0 + jest-each: 30.4.1 + jest-matcher-utils: 30.4.1 + jest-message-util: 30.4.1 + jest-runtime: 30.4.2 + jest-snapshot: 30.4.1 + jest-util: 30.4.1 + p-limit: 3.1.0 + pretty-format: 30.4.1 + pure-rand: 7.0.1 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + dev: true + + /jest-cli@30.4.2(@types/node@26.1.2): + resolution: {integrity: sha512-jfA2ocvVHMXS2QijrJ0d31ektP+d/W0T5RpcTX2Pq+3sVqHlsXVCM2+FmwpL+bdY8OfHpIg9xMxLF17Zg0U49Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + dependencies: + '@jest/core': 30.4.2 + '@jest/test-result': 30.4.1 + '@jest/types': 30.4.1 + chalk: 4.1.2 + exit-x: 0.2.2 + import-local: 3.2.0 + jest-config: 30.4.2(@types/node@26.1.2) + jest-util: 30.4.1 + jest-validate: 30.4.1 + yargs: 17.7.3 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + dev: true + + /jest-config@30.4.2(@types/node@26.1.2): + resolution: {integrity: sha512-rNHAShJQqQwFNoL0hbf3BphSBOWnpOUAKvidLS/AjNVLPfoj5mSf4jQMfW3cYOs6hXeZC7nF7mDHaBnbxELOzg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@types/node': '*' + esbuild-register: '>=3.4.0' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + esbuild-register: + optional: true + ts-node: + optional: true + dependencies: + '@babel/core': 7.29.7 + '@jest/get-type': 30.1.0 + '@jest/pattern': 30.4.0 + '@jest/test-sequencer': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 26.1.2 + babel-jest: 30.4.1(@babel/core@7.29.7) + chalk: 4.1.2 + ci-info: 4.4.0 + deepmerge: 4.3.1 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-circus: 30.4.2 + jest-docblock: 30.4.0 + jest-environment-node: 30.4.1 + jest-regex-util: 30.4.0 + jest-resolve: 30.4.1 + jest-runner: 30.4.2 + jest-util: 30.4.1 + jest-validate: 30.4.1 + parse-json: 5.2.0 + pretty-format: 30.4.1 + slash: 3.0.0 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + dev: true + + /jest-diff@30.4.1: + resolution: {integrity: sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + dependencies: + '@jest/diff-sequences': 30.4.0 + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + pretty-format: 30.4.1 + dev: true + + /jest-docblock@30.4.0: + resolution: {integrity: sha512-ZPMabUZCx5MpbZ2eBYSvZ0J8fvo3dR9oM+eeUpb3aKNQFuS2tu3Duw1TNlMoP8k3WQgKGJuhcMFvwcVuq6T7oA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + dependencies: + detect-newline: 3.1.0 + dev: true + + /jest-each@30.4.1: + resolution: {integrity: sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + dependencies: + '@jest/get-type': 30.1.0 + '@jest/types': 30.4.1 + chalk: 4.1.2 + jest-util: 30.4.1 + pretty-format: 30.4.1 + dev: true + + /jest-environment-jsdom@30.4.1: + resolution: {integrity: sha512-o3nfaN4zej7qgk2X0j8Jhq/S9nAVKs2xK3QeQxeHVvpkEPxaA1yxDGydR+iVI7zPy7Cp62Aq2h3Ja46QvfWHGA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + dependencies: + '@jest/environment': 30.4.1 + '@jest/environment-jsdom-abstract': 30.4.1(jsdom@26.1.0) + jsdom: 26.1.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + dev: true + + /jest-environment-node@30.4.1: + resolution: {integrity: sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + dependencies: + '@jest/environment': 30.4.1 + '@jest/fake-timers': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 26.1.2 + jest-mock: 30.4.1 + jest-util: 30.4.1 + jest-validate: 30.4.1 + dev: true + + /jest-haste-map@30.4.1: + resolution: {integrity: sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + dependencies: + '@jest/types': 30.4.1 + '@types/node': 26.1.2 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 30.4.0 + jest-util: 30.4.1 + jest-worker: 30.4.1 + picomatch: 4.0.5 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + dev: true + + /jest-leak-detector@30.4.1: + resolution: {integrity: sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + dependencies: + '@jest/get-type': 30.1.0 + pretty-format: 30.4.1 + dev: true + + /jest-matcher-utils@30.4.1: + resolution: {integrity: sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + dependencies: + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + jest-diff: 30.4.1 + pretty-format: 30.4.1 + dev: true + + /jest-message-util@30.4.1: + resolution: {integrity: sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + dependencies: + '@babel/code-frame': 7.29.7 + '@jest/types': 30.4.1 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-util: 30.4.1 + picomatch: 4.0.5 + pretty-format: 30.4.1 + slash: 3.0.0 + stack-utils: 2.0.6 + dev: true + + /jest-mock@30.4.1: + resolution: {integrity: sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + dependencies: + '@jest/types': 30.4.1 + '@types/node': 26.1.2 + jest-util: 30.4.1 + dev: true + + /jest-pnp-resolver@1.2.3(jest-resolve@30.4.1): + resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} + engines: {node: '>=6'} + peerDependencies: + jest-resolve: '*' + peerDependenciesMeta: + jest-resolve: + optional: true + dependencies: + jest-resolve: 30.4.1 + dev: true + + /jest-regex-util@30.4.0: + resolution: {integrity: sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + dev: true + + /jest-resolve-dependencies@30.4.2: + resolution: {integrity: sha512-gDiVh1I+GxYzz9oXlyw+1wv6VOYX1WYxMOfjsA3iGKePV2oxmbHhwxfkALxNxYy1ciw6APWwkW2zZONwP97aEQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + dependencies: + jest-regex-util: 30.4.0 + jest-snapshot: 30.4.1 + transitivePeerDependencies: + - supports-color + dev: true + + /jest-resolve@30.4.1: + resolution: {integrity: sha512-Zry8Yq/yJcNAZ7dJ5F2heic8AheXvbFZ7XI5V+h28nrYZ7Qoyy4dItq8OodjnYD270mvX+ZudmrNV9cysqhW5Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 30.4.1 + jest-pnp-resolver: 1.2.3(jest-resolve@30.4.1) + jest-util: 30.4.1 + jest-validate: 30.4.1 + slash: 3.0.0 + unrs-resolver: 1.12.2 + dev: true + + /jest-runner@30.4.2: + resolution: {integrity: sha512-2dw0PslVYXxffXGpLo+Ejad+KcI1Qkjn7f4X4619gf21oCUmL+SPfjqIa/losUem3yEOvfNZe/F1HWUcNpODcg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + dependencies: + '@jest/console': 30.4.1 + '@jest/environment': 30.4.1 + '@jest/test-result': 30.4.1 + '@jest/transform': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 26.1.2 + chalk: 4.1.2 + emittery: 0.13.1 + exit-x: 0.2.2 + graceful-fs: 4.2.11 + jest-docblock: 30.4.0 + jest-environment-node: 30.4.1 + jest-haste-map: 30.4.1 + jest-leak-detector: 30.4.1 + jest-message-util: 30.4.1 + jest-resolve: 30.4.1 + jest-runtime: 30.4.2 + jest-util: 30.4.1 + jest-watcher: 30.4.1 + jest-worker: 30.4.1 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color + dev: true + + /jest-runtime@30.4.2: + resolution: {integrity: sha512-3/5e8iPz2k/VLqlr8DgTftYyLUv8Su3FkCAO2/Od81UsUTpSxOrS6O5x5KkoQwyUjmpYyDJKeyAvg2T2nvpNkQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + dependencies: + '@jest/environment': 30.4.1 + '@jest/fake-timers': 30.4.1 + '@jest/globals': 30.4.1 + '@jest/source-map': 30.0.1 + '@jest/test-result': 30.4.1 + '@jest/transform': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 26.1.2 + chalk: 4.1.2 + cjs-module-lexer: 2.2.0 + collect-v8-coverage: 1.0.3 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.4.1 + jest-message-util: 30.4.1 + jest-mock: 30.4.1 + jest-regex-util: 30.4.0 + jest-resolve: 30.4.1 + jest-snapshot: 30.4.1 + jest-util: 30.4.1 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + dev: true + + /jest-snapshot@30.4.1: + resolution: {integrity: sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + dependencies: + '@babel/core': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/types': 7.29.7 + '@jest/expect-utils': 30.4.1 + '@jest/get-type': 30.1.0 + '@jest/snapshot-utils': 30.4.1 + '@jest/transform': 30.4.1 + '@jest/types': 30.4.1 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) + chalk: 4.1.2 + expect: 30.4.1 + graceful-fs: 4.2.11 + jest-diff: 30.4.1 + jest-matcher-utils: 30.4.1 + jest-message-util: 30.4.1 + jest-util: 30.4.1 + pretty-format: 30.4.1 + semver: 7.8.4 + synckit: 0.11.13 + transitivePeerDependencies: + - supports-color + dev: true + + /jest-util@30.4.1: + resolution: {integrity: sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + dependencies: + '@jest/types': 30.4.1 + '@types/node': 26.1.2 + chalk: 4.1.2 + ci-info: 4.4.0 + graceful-fs: 4.2.11 + picomatch: 4.0.5 + dev: true + + /jest-validate@30.4.1: + resolution: {integrity: sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + dependencies: + '@jest/get-type': 30.1.0 + '@jest/types': 30.4.1 + camelcase: 6.3.0 + chalk: 4.1.2 + leven: 3.1.0 + pretty-format: 30.4.1 + dev: true + + /jest-watcher@30.4.1: + resolution: {integrity: sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + dependencies: + '@jest/test-result': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 26.1.2 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 30.4.1 + string-length: 4.0.2 + dev: true + /jest-worker@27.5.1: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} dependencies: - '@types/node': 25.9.3 + '@types/node': 26.1.2 + merge-stream: 2.0.0 + supports-color: 8.1.1 + dev: true + + /jest-worker@30.4.1: + resolution: {integrity: sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + dependencies: + '@types/node': 26.1.2 + '@ungap/structured-clone': 1.3.3 + jest-util: 30.4.1 merge-stream: 2.0.0 supports-color: 8.1.1 dev: true + /jest@30.4.2(@types/node@26.1.2): + resolution: {integrity: sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + dependencies: + '@jest/core': 30.4.2 + '@jest/types': 30.4.1 + import-local: 3.2.0 + jest-cli: 30.4.2(@types/node@26.1.2) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + dev: true + /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} dev: true @@ -2675,6 +4383,41 @@ packages: esprima: 4.0.1 dev: true + /jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.24 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.21.2 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + dev: true + /jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -2685,6 +4428,10 @@ packages: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} dev: true + /json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + dev: true + /json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} dev: true @@ -2714,6 +4461,11 @@ packages: engines: {node: '>=0.10.0'} dev: true + /leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + dev: true + /levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -2722,6 +4474,10 @@ packages: type-check: 0.4.0 dev: true + /lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + dev: true + /loader-runner@4.3.2: resolution: {integrity: sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==} engines: {node: '>=6.11.5'} @@ -2747,6 +4503,10 @@ packages: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} dev: true + /lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + dev: true + /lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} dev: true @@ -2755,6 +4515,10 @@ packages: resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} dev: true + /lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + dev: true + /lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} dependencies: @@ -2768,6 +4532,23 @@ packages: semver: 6.3.1 dev: true + /make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + dependencies: + semver: 7.8.4 + dev: true + + /make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + dev: true + + /makeerror@1.0.12: + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + dependencies: + tmpl: 1.0.5 + dev: true + /math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2782,16 +4563,43 @@ packages: engines: {node: '>= 0.6'} dev: true + /mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + dev: true + /minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} dependencies: brace-expansion: 1.1.15 dev: true + /minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + brace-expansion: 2.1.4 + dev: true + + /minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + dev: true + + /minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + dev: true + /ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} dev: true + /napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + dev: true + /natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} dev: true @@ -2800,11 +4608,31 @@ packages: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} dev: true + /node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + dev: true + /node-releases@2.0.47: resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} engines: {node: '>=18'} dev: true + /normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + dev: true + + /npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + dependencies: + path-key: 3.1.1 + dev: true + + /nwsapi@2.2.24: + resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} + dev: true + /object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -2833,6 +4661,13 @@ packages: wrappy: 1.0.2 dev: true + /onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + dependencies: + mimic-fn: 2.1.0 + dev: true + /optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -2861,6 +4696,13 @@ packages: p-try: 2.2.0 dev: true + /p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + dependencies: + yocto-queue: 0.1.0 + dev: true + /p-locate@4.1.0: resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} engines: {node: '>=8'} @@ -2873,6 +4715,10 @@ packages: engines: {node: '>=6'} dev: true + /package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + dev: true + /parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -2880,6 +4726,22 @@ packages: callsites: 3.1.0 dev: true + /parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + dependencies: + '@babel/code-frame': 7.29.7 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + dev: true + + /parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + dependencies: + entities: 6.0.1 + dev: true + /path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -2899,10 +4761,33 @@ packages: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} dev: true + /path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + dev: true + /picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} dev: true + /picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + dev: true + + /picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + dev: true + + /pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + dev: true + /pkg-dir@4.2.0: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} @@ -2926,6 +4811,16 @@ packages: hasBin: true dev: true + /pretty-format@30.4.1: + resolution: {integrity: sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + dependencies: + '@jest/schemas': 30.4.1 + ansi-styles: 5.2.0 + react-is-18: /react-is@18.3.1 + react-is-19: /react-is@19.2.8 + dev: true + /progress@2.0.3: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} @@ -2936,6 +4831,18 @@ packages: engines: {node: '>=6'} dev: true + /pure-rand@7.0.1: + resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} + dev: true + + /react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + dev: true + + /react-is@19.2.8: + resolution: {integrity: sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==} + dev: true + /rechoir@0.8.0: resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} engines: {node: '>= 10.13.0'} @@ -3008,6 +4915,11 @@ packages: jsesc: 3.1.0 dev: true + /require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + dev: true + /require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -3049,6 +4961,10 @@ packages: glob: 7.2.3 dev: true + /rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + dev: true + /safe-array-concat@1.1.4: resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} @@ -3077,6 +4993,17 @@ packages: is-regex: 1.2.1 dev: false + /safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + dev: true + + /saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + dependencies: + xmlchars: 2.2.0 + dev: true + /schema-utils@2.7.1: resolution: {integrity: sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==} engines: {node: '>= 8.9.0'} @@ -3107,6 +5034,12 @@ packages: hasBin: true dev: true + /semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + dev: true + /set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -3197,6 +5130,20 @@ packages: side-channel-weakmap: 1.0.2 dev: false + /signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + dev: true + + /signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + dev: true + + /slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + dev: true + /slice-ansi@4.0.0: resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} engines: {node: '>=10'} @@ -3206,6 +5153,13 @@ packages: is-fullwidth-code-point: 3.0.0 dev: true + /source-map-support@0.5.13: + resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + dev: true + /source-map-support@0.5.21: resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} dependencies: @@ -3222,6 +5176,13 @@ packages: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} dev: true + /stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + dependencies: + escape-string-regexp: 2.0.0 + dev: true + /stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -3230,6 +5191,14 @@ packages: internal-slot: 1.1.0 dev: false + /string-length@4.0.2: + resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} + engines: {node: '>=10'} + dependencies: + char-regex: 1.0.2 + strip-ansi: 6.0.1 + dev: true + /string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -3239,6 +5208,15 @@ packages: strip-ansi: 6.0.1 dev: true + /string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + dev: true + /string.prototype.matchall@4.0.12: resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} engines: {node: '>= 0.4'} @@ -3298,6 +5276,23 @@ packages: ansi-regex: 5.0.1 dev: true + /strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + dependencies: + ansi-regex: 6.2.2 + dev: true + + /strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + dev: true + + /strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + dev: true + /strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -3329,6 +5324,17 @@ packages: engines: {node: '>= 0.4'} dev: true + /symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + dev: true + + /synckit@0.11.13: + resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==} + engines: {node: ^14.18.0 || >=16.0.0} + dependencies: + '@pkgr/core': 0.3.6 + dev: true + /table@6.9.0: resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} engines: {node: '>=10.0.0'} @@ -3406,10 +5412,95 @@ packages: source-map-support: 0.5.21 dev: true + /test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + dependencies: + '@istanbuljs/schema': 0.1.6 + glob: 7.2.3 + minimatch: 3.1.5 + dev: true + /text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} dev: true + /tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + dev: true + + /tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + dependencies: + tldts-core: 6.1.86 + dev: true + + /tmpl@1.0.5: + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + dev: true + + /tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + dependencies: + tldts: 6.1.86 + dev: true + + /tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + dependencies: + punycode: 2.3.1 + dev: true + + /ts-jest@29.4.12(@babel/core@7.29.7)(jest@30.4.2)(typescript@5.9.3): + resolution: {integrity: sha512-Ov6ClY53Fflh6BGAnY2DlTq1hYDrTycz2PVTXBWFW2CU+9zrEqAp9fWdGXl42EXO5RLSFAcAZ2JFKbP+zBTFfw==} + engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@babel/core': '>=7.0.0-beta.0 <8' + '@jest/transform': ^29.0.0 || ^30.0.0 + '@jest/types': ^29.0.0 || ^30.0.0 + babel-jest: ^29.0.0 || ^30.0.0 + esbuild: '*' + jest: ^29.0.0 || ^30.0.0 + jest-util: ^29.0.0 || ^30.0.0 + typescript: '>=4.3 <7' + peerDependenciesMeta: + '@babel/core': + optional: true + '@jest/transform': + optional: true + '@jest/types': + optional: true + babel-jest: + optional: true + esbuild: + optional: true + jest-util: + optional: true + dependencies: + '@babel/core': 7.29.7 + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.9 + jest: 30.4.2(@types/node@26.1.2) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.8.5 + type-fest: 4.41.0 + typescript: 5.9.3 + yargs-parser: 21.1.1 + dev: true + + /tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + requiresBuild: true + dev: true + optional: true + /type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -3417,11 +5508,26 @@ packages: prelude-ls: 1.2.1 dev: true + /type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + dev: true + /type-fest@0.20.2: resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} engines: {node: '>=10'} dev: true + /type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + dev: true + + /type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + dev: true + /typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -3467,6 +5573,20 @@ packages: reflect.getprototypeof: 1.0.10 dev: false + /typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + dev: true + + /uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + requiresBuild: true + dev: true + optional: true + /unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -3477,8 +5597,8 @@ packages: which-boxed-primitive: 1.1.1 dev: false - /undici-types@7.24.6: - resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + /undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} dev: true /unicode-canonical-property-names-ecmascript@2.0.1: @@ -3504,6 +5624,36 @@ packages: engines: {node: '>=4'} dev: true + /unrs-resolver@1.12.2: + resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} + requiresBuild: true + dependencies: + napi-postinstall: 0.3.4 + optionalDependencies: + '@unrs/resolver-binding-android-arm-eabi': 1.12.2 + '@unrs/resolver-binding-android-arm64': 1.12.2 + '@unrs/resolver-binding-darwin-arm64': 1.12.2 + '@unrs/resolver-binding-darwin-x64': 1.12.2 + '@unrs/resolver-binding-freebsd-x64': 1.12.2 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.12.2 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.12.2 + '@unrs/resolver-binding-linux-arm64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-arm64-musl': 1.12.2 + '@unrs/resolver-binding-linux-loong64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-loong64-musl': 1.12.2 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-riscv64-musl': 1.12.2 + '@unrs/resolver-binding-linux-s390x-gnu': 1.12.2 + '@unrs/resolver-binding-linux-x64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-x64-musl': 1.12.2 + '@unrs/resolver-binding-openharmony-arm64': 1.12.2 + '@unrs/resolver-binding-wasm32-wasi': 1.12.2 + '@unrs/resolver-binding-win32-arm64-msvc': 1.12.2 + '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 + '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 + dev: true + /update-browserslist-db@1.2.3(browserslist@4.28.2): resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -3525,6 +5675,28 @@ packages: resolution: {integrity: sha512-ocyWc3bAHBB/guyqJQVI5o4BZkPhznPYUG2ea80Gond/BgNWpap8TOmLSeeQG7bnh2KMISxskdADG59j7zruhw==} dev: true + /v8-to-istanbul@9.3.0: + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.0 + dev: true + + /w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + dependencies: + xml-name-validator: 5.0.0 + dev: true + + /walker@1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + dependencies: + makeerror: 1.0.12 + dev: true + /watchpack@2.5.2: resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==} engines: {node: '>=10.13.0'} @@ -3532,6 +5704,11 @@ packages: graceful-fs: 4.2.11 dev: true + /webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + dev: true + /webpack-cli@5.1.4(webpack@5.107.2): resolution: {integrity: sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==} engines: {node: '>=14.15.0'} @@ -3628,6 +5805,27 @@ packages: - uglify-js dev: true + /whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + dependencies: + iconv-lite: 0.6.3 + dev: true + + /whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + dev: true + + /whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + dev: true + /which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -3698,10 +5896,90 @@ packages: engines: {node: '>=0.10.0'} dev: true + /wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + dev: true + + /wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + dev: true + + /wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + dev: true + /wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} dev: true + /write-file-atomic@5.0.1: + resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + dependencies: + imurmurhash: 0.1.4 + signal-exit: 4.1.0 + dev: true + + /ws@8.21.2: + resolution: {integrity: sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: true + + /xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + dev: true + + /xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + dev: true + + /y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + dev: true + /yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} dev: true + + /yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + dev: true + + /yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + dev: true + + /yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + dev: true diff --git a/Sources/Navigator/EPUB/Scripts/src/accname.ts b/Sources/Navigator/EPUB/Scripts/src/accname.ts new file mode 100644 index 0000000000..b868f2deb6 --- /dev/null +++ b/Sources/Navigator/EPUB/Scripts/src/accname.ts @@ -0,0 +1,264 @@ +// +// Copyright 2025 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. +// + +/** + * Accessible name and description of an HTML element, computed following a + * pragmatic subset of https://www.w3.org/TR/accname-1.2 + * + * This is the TypeScript counterpart of the Swift implementation in + * `Sources/Shared/Publication/Services/Content/Iterators/HTMLAccessibilityProperties.swift` + * — both MUST implement exactly the same subset. What keeps them in sync is + * the shared case manifest in `scripts/accname-sample/cases.toml`: it generates + * the fixtures both test suites run against, so a rule is stated once and + * asserted twice. Add cases there. + * + * Implemented: + * - Source precedence for the name: `aria-labelledby` → `aria-label` → + * host-language sources → `title`, matching the accname computation steps + * "LabelledBy", "AriaLabel", "Host Language Label" and "Tooltip": + * https://www.w3.org/TR/accname-1.2/#computation-steps + * - Element-level suppression: `aria-hidden="true"` (accname "Hidden Not + * Referenced" step: https://www.w3.org/TR/accname-1.2/#comp_hidden_not_referenced), + * or a presentational `role` not cancelled by a global ARIA attribute + * (WAI-ARIA "Presentational Roles Conflict Resolution": + * https://www.w3.org/TR/wai-aria-1.2/#conflict_resolution_presentation_none), + * yields no name and no description. + * - The description cascade (`aria-describedby` → `aria-description` → + * host-language sources → unused `title`) stops at the first PRESENT + * markup, even if it resolves to an empty description: + * https://www.w3.org/TR/accname-1.2/#mapping_additional_nd_description + * - HTML-AAM 4.1.10 rules for `img` + * (https://www.w3.org/TR/html-aam-1.0/#img-element-accessible-name-computation): + * an empty `alt` attribute marks a decorative image and blocks the `title` + * fallback (HTML-AAM overriding literal accname-1.2, whose "Tooltip" step, + * https://www.w3.org/TR/accname-1.2/#comp_tooltip, would still name the + * image from the tooltip; browsers follow HTML-AAM); a figcaption names an + * image which has no `alt`/`title` attribute and no sibling content. + * + * Deliberately skipped / divergences: + * - Full recursive traversal of `aria-labelledby`/`aria-describedby` targets + * (https://www.w3.org/TR/accname-1.2/#comp_labelledby); we approximate one + * level: each target contributes its own `aria-label` when present, else + * its text content. Nested images' `alt`, chained labelledby and embedded + * form-control values + * (https://www.w3.org/TR/accname-1.2/#comp_embedded_control) do not + * contribute. + * - Hidden-element rules beyond the element itself: hidden ancestors, and the + * exclusion of hidden nodes inside referenced targets (kept out of the DOM + * side for parity with the Swift implementation, which has no CSS + * knowledge). + * - Roles that prohibit naming other than `presentation`/`none` + * (https://www.w3.org/TR/wai-aria-1.2/#namefromprohibited); the + * presentational-role conflict rule is narrowed to the four ARIA attributes + * this helper reads (spec: any global ARIA attribute or focusable element); + * unknown role tokens are not validated (the first token wins). + * - CSS generated content (`::before`/`::after`) and name-from-content + * (https://www.w3.org/TR/accname-1.2/#comp_name_from_content). + * - An `aria-describedby` whose IDREFs all dangle still counts as "the first + * relevant markup found" and stops the description cascade (attribute + * presence = found). The spec doesn't spell this out and browsers vary; + * declared as a choice. + * - HTML-AAM's figcaption-as-name fallback approximates the "no other + * non-whitespace flow content descendants" condition: the figure's + * normalized text must equal the figcaption's, and the figure must contain + * no other embedded content. + * + * Reusability caveat: the ARIA-attribute sources apply to any element, but + * host-language sources are implemented only for `img` and `svg`, and + * name-from-content is not computed at all. The subset is exact for the + * current consumers (img, svg, audio, video — roles that don't allow name + * from content), but future element types have their own host-language + * sources (e.g. `
` → `
`, links/headings → content) that must + * be added per-tag before pointing the helper at them. + */ + +export interface AccessibilityProperties { + name: string | null; + description: string | null; +} + +/** + * Computes the accessible name and description of an element, following a + * pragmatic subset of https://www.w3.org/TR/accname-1.2 + */ +export function computeAccessibilityProperties( + element: Element +): AccessibilityProperties { + const tag = element.tagName.toLowerCase(); + const title = element.getAttribute("title")?.trim() || null; + + // Step 0: element-level suppression (accname "Initialization" and "Hidden + // Not Referenced" steps: https://www.w3.org/TR/accname-1.2/#computation-steps). + // `aria-hidden`, or a presentational role not cancelled by a global ARIA + // attribute, prohibit both name and description. ARIA token comparisons are + // case-insensitive; `role` is a token list with first-token-wins semantics. + const firstRole = element + .getAttribute("role") + ?.toLowerCase() + .split(/\s+/) + .find((token) => token.length > 0); + const hasGlobalARIAAttribute = + element.hasAttribute("aria-label") || + element.hasAttribute("aria-labelledby") || + element.hasAttribute("aria-describedby") || + element.hasAttribute("aria-description"); + if ( + element.getAttribute("aria-hidden")?.toLowerCase() === "true" || + ((firstRole === "presentation" || firstRole === "none") && + !hasGlobalARIAAttribute) + ) { + return { name: null, description: null }; + } + + let name: string | null = null; + let stopNameCascade = false; + + // 1. aria-labelledby + name = resolveIDReferences(element, "aria-labelledby"); + + // 2. aria-label + if (!name) { + name = element.getAttribute("aria-label")?.trim() || null; + } + + // 3. Host-language source + if (!name) { + if (tag === "img") { + if (element.hasAttribute("alt")) { + name = element.getAttribute("alt")!.trim() || null; + if (!name) { + // `alt=""` marks a decorative image: no fallback on `title`, per + // HTML-AAM 4.1.10. + stopNameCascade = true; + } + } + } else if (tag === "svg") { + name = firstDirectChildText(element, "title"); + } + } + + // 4. title attribute + let titleUsedAsName = false; + if (!name && !stopNameCascade && title) { + name = title; + titleUsedAsName = true; + } + + // 5. HTML-AAM 4.1.10 step 4: an img with no alt or title attribute, alone + // in a captioned figure, takes its name from the figcaption. + // https://www.w3.org/TR/html-aam-1.0/#img-element-accessible-name-computation + if ( + !name && + tag === "img" && + !element.hasAttribute("alt") && + !element.hasAttribute("title") + ) { + name = figureCaptionAsName(element); + } + + // The description cascade stops at the first PRESENT markup, even if it + // resolves to an empty description ("MUST NOT use any markup other than the + // first relevant markup found"). + let description: string | null = null; + if (element.hasAttribute("aria-describedby")) { + // 1. aria-describedby + description = resolveIDReferences(element, "aria-describedby"); + } else if (element.hasAttribute("aria-description")) { + // 2. aria-description + description = element.getAttribute("aria-description")!.trim() || null; + } else if (tag === "svg" && element.querySelector(":scope > desc")) { + // 3. Host-language source + description = firstDirectChildText(element, "desc"); + } else if (!titleUsedAsName) { + // 4. title attribute, if not already used as the name. + description = title; + } + + return { name, description }; +} + +/** + * Resolves a space-separated list of element IDs and concatenates the + * referenced elements' text alternatives (one-level approximation: each + * referenced element contributes its own `aria-label` when present, otherwise + * its text content). + */ +function resolveIDReferences( + element: Element, + attribute: string +): string | null { + const ids = element.getAttribute(attribute); + if (!ids) { + return null; + } + return ( + ids + .split(/\s+/) + .filter((id) => id.length > 0) + .map((id) => element.ownerDocument.getElementById(id) as Element | null) + .filter((el): el is Element => el != null) + .map( + (el) => + el.getAttribute("aria-label")?.trim() || + el.textContent?.replace(/\s+/g, " ").trim() || + "" + ) + .filter((text) => text.length > 0) + .join(" ") || null + ); +} + +function firstDirectChildText(element: Element, tag: string): string | null { + const child = element.querySelector(`:scope > ${tag}`); + return child?.textContent?.replace(/\s+/g, " ").trim() || null; +} + +/** + * Returns the text of the enclosing `
`'s direct `
` child, + * if any. Also used by gestures.js for the `caption` payload field. + * + * An element living inside the figcaption (a publisher logo, a footnote + * marker) is not captioned by the text wrapping it, so it gets no caption at + * all rather than falling back to an outer figure. + */ +export function findFigureCaption(element: Element): string | null { + const figcaption = element + .closest("figure") + ?.querySelector(":scope > figcaption"); + if (!figcaption || figcaption.contains(element)) { + return null; + } + return figcaption.textContent?.replace(/\s+/g, " ").trim() || null; +} + +/** + * HTML-AAM 4.1.10 step 4, approximated: the figcaption names the image only + * when the figure holds no other non-whitespace flow content — checked as + * "the figure's normalized text equals the figcaption's, and the figure + * contains no other embedded content". + * https://www.w3.org/TR/html-aam-1.0/#img-element-accessible-name-computation + */ +function figureCaptionAsName(element: Element): string | null { + const figure = element.closest("figure"); + const figcaption = figure?.querySelector(":scope > figcaption"); + if (!figure || !figcaption) { + return null; + } + const normalize = (text: string | null) => + text?.replace(/\s+/g, " ").trim() ?? ""; + if (normalize(figure.textContent) !== normalize(figcaption.textContent)) { + return null; + } + const embedded = figure.querySelectorAll( + "img, svg, audio, video, object, iframe, embed" + ); + for (let i = 0; i < embedded.length; i++) { + if (embedded[i] !== element) { + return null; + } + } + return normalize(figcaption.textContent) || null; +} diff --git a/Sources/Navigator/EPUB/Scripts/src/gestures.js b/Sources/Navigator/EPUB/Scripts/src/gestures.js index 855ebc42f6..9b6b10b3b9 100644 --- a/Sources/Navigator/EPUB/Scripts/src/gestures.js +++ b/Sources/Navigator/EPUB/Scripts/src/gestures.js @@ -8,6 +8,7 @@ import { findDecorationTarget, handleDecorationClickEvent } from "./decorator"; import { adjustPointToViewport } from "./rect"; import { findNearestInteractiveElement } from "./dom"; import { getCssSelector } from "css-selector-generator"; +import { computeAccessibilityProperties, findFigureCaption } from "./accname"; let isSelecting = false; @@ -121,8 +122,9 @@ function onPointerEvent(phase, event) { * * Returns an object with the element's bounding rectangle, tag name, source * URL, a CSS selector, the href of the document that contains the element, - * an accessibility label, and a caption. This information is used on the - * Swift side to build the appropriate `ContentElement`. + * the accessible name and description, and the caption from an enclosing + * figure's figcaption. This information is used on the Swift side to build + * the appropriate `ContentElement`. */ function extractTargetElement(element) { if (!element || !element.getBoundingClientRect) { @@ -153,6 +155,8 @@ function extractTargetElement(element) { // `html` is only needed for inline SVGs that have no resolvable `src`. let html = src ? null : imageElement.outerHTML; + let accessibility = computeAccessibilityProperties(imageElement); + return { tag: imageElement.tagName.toLowerCase(), html: html, @@ -164,51 +168,13 @@ function extractTargetElement(element) { width: rect.width, height: rect.height, }, - accessibilityLabel: imageElement.getAttribute("aria-label")?.trim() || null, - caption: extractCaption(imageElement), + accessibleName: accessibility.name, + accessibleDescription: accessibility.description, + caption: findFigureCaption(imageElement), cssSelector: getCssSelector(imageElement), }; } -/** - * Returns a human-readable caption for an image element by checking, in - * order: the `alt` attribute, the `title` attribute, the text content of the - * first SVG `` child, the text content of the first SVG `<desc>` - * child, and the text content of a `<figcaption>` inside a parent `<figure>`. - * Returns `null` when none of these are present. - * - * When `alt` is present — even as an empty string (decorative image) — no - * other source is consulted, so that an explicit `alt=""` suppresses fallback - * captions rather than incorrectly propagating them. - */ -function extractCaption(imageElement) { - if (imageElement.hasAttribute("alt")) { - const alt = imageElement.getAttribute("alt").trim(); - return alt || null; - } - - const title = imageElement.getAttribute("title")?.trim(); - if (title) return title; - - const svgTitle = imageElement - .querySelector(":scope > title") - ?.textContent.trim(); - if (svgTitle) return svgTitle; - - const svgDesc = imageElement - .querySelector(":scope > desc") - ?.textContent.trim(); - if (svgDesc) return svgDesc; - - const figure = imageElement.closest("figure"); - if (figure) { - const figcaption = figure.querySelector("figcaption")?.textContent.trim(); - if (figcaption) return figcaption; - } - - return null; -} - /** * Walks up the DOM tree from the given element to find the nearest image * element (img, svg). diff --git a/Sources/Navigator/EPUB/Scripts/test/accname-sample.test.ts b/Sources/Navigator/EPUB/Scripts/test/accname-sample.test.ts new file mode 100644 index 0000000000..5c52dff764 --- /dev/null +++ b/Sources/Navigator/EPUB/Scripts/test/accname-sample.test.ts @@ -0,0 +1,82 @@ +// +// Copyright 2026 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. +// + +/** + * Parity test for the accname sample publication. + * + * The fixtures under `test/fixtures/accname/` are generated from + * `/scripts/accname-sample/cases.toml`, which also drives the Swift end-to-end + * suite. + * + * Every subject element carries the expected name and description as + * `data-expected-*` attributes, so both harnesses assert against a single + * source of truth. + */ + +import * as fs from "fs"; +import * as path from "path"; +import { computeAccessibilityProperties } from "../src/accname"; + +const FIXTURES_DIR = path.join(__dirname, "fixtures/accname"); + +interface Case { + document: string; + id: string; + element: Element; + expectedName: string | null; + expectedDescription: string | null; +} + +/** Loads every non-skipped case of every generated fixture. */ +function loadCases(): Case[] { + const cases: Case[] = []; + for (const filename of fs.readdirSync(FIXTURES_DIR).sort()) { + if (!filename.endsWith(".xhtml")) { + continue; + } + const source = fs.readFileSync(path.join(FIXTURES_DIR, filename), "utf-8"); + const body = /<body[^>]*>([\s\S]*)<\/body>/.exec(source); + if (!body) { + throw new Error(`${filename}: no <body> element`); + } + + // Each fixture gets its own document so that IDREFs cannot resolve across + // files, matching what the Swift side sees when it parses one resource at + // a time. + const doc = document.implementation.createHTMLDocument(filename); + doc.body.innerHTML = body[1]; + + for (const element of Array.from( + doc.querySelectorAll("[data-case]:not([data-test-skipped])") + )) { + cases.push({ + document: filename, + id: element.getAttribute("data-case")!, + element, + expectedName: element.getAttribute("data-expected-name"), + expectedDescription: element.getAttribute("data-expected-description"), + }); + } + } + return cases; +} + +const CASES = loadCases(); + +describe("accname sample publication", () => { + test("the fixtures hold cases", () => { + expect(CASES.length).toBeGreaterThan(0); + }); + + test.each(CASES.map((c) => [`${c.document} · ${c.id}`, c] as const))( + "%s", + (_label, testCase) => { + const result = computeAccessibilityProperties(testCase.element); + expect(result.name).toBe(testCase.expectedName); + expect(result.description).toBe(testCase.expectedDescription); + } + ); +}); diff --git a/Sources/Navigator/EPUB/Scripts/test/figure-caption.test.ts b/Sources/Navigator/EPUB/Scripts/test/figure-caption.test.ts new file mode 100644 index 0000000000..741f8eec55 --- /dev/null +++ b/Sources/Navigator/EPUB/Scripts/test/figure-caption.test.ts @@ -0,0 +1,65 @@ +// +// Copyright 2025 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. +// + +/** + * Tests for `findFigureCaption` (`accname.ts`), which feeds the `caption` + * field of the pointer-event payload. + * + * The accessible name and description computation is covered by + * `accname-sample.test.ts`, which runs the shared case manifest in + * `scripts/accname-sample/cases.toml` — the same one the Swift suite in + * `Tests/SharedTests/Publication/Services/Content/Iterators/AccnameSampleTests.swift` + * asserts against. Add cases there, not here. + */ + +import { findFigureCaption } from "../src/accname"; + +describe("findFigureCaption", () => { + test("imageInFigureGetsCaptionFromFigcaption", () => { + document.body.innerHTML = ` + <figure><img src="a.jpg" alt="Alt text"/><figcaption>The caption</figcaption></figure> + `; + const element = document.body.querySelector("img")!; + expect(findFigureCaption(element)).toBe("The caption"); + }); + + test("nestedFigureUsesTheNearestFigcaption", () => { + document.body.innerHTML = ` + <figure> + <figure><img src="a.jpg" alt="Alt"/><figcaption>Inner</figcaption></figure> + <figcaption>Outer</figcaption> + </figure> + `; + const element = document.body.querySelector("img")!; + expect(findFigureCaption(element)).toBe("Inner"); + }); + + test("figcaptionNotFirstChildStillProvidesTheCaption", () => { + document.body.innerHTML = ` + <figure><p>intro</p><img src="a.jpg" alt="Alt"/><figcaption>Cap</figcaption></figure> + `; + const element = document.body.querySelector("img")!; + expect(findFigureCaption(element)).toBe("Cap"); + }); + + test("imageInsideTheFigcaptionIsNotCaptionedByIt", () => { + document.body.innerHTML = ` + <figure> + <img src="chart.png" alt="Revenue chart"/> + <figcaption>Source: <img src="logo.png" alt="ACME"/> annual report</figcaption> + </figure> + `; + const images = document.body.querySelectorAll("img"); + expect(findFigureCaption(images[0])).toBe("Source: annual report"); + expect(findFigureCaption(images[1])).toBeNull(); + }); + + test("elementOutsideAFigureHasNoCaption", () => { + document.body.innerHTML = `<img src="a.jpg" alt="Alt"/>`; + const element = document.body.querySelector("img")!; + expect(findFigureCaption(element)).toBeNull(); + }); +}); diff --git a/Sources/Navigator/EPUB/Scripts/test/fixtures/accname/image.xhtml b/Sources/Navigator/EPUB/Scripts/test/fixtures/accname/image.xhtml new file mode 100644 index 0000000000..f0821daebe --- /dev/null +++ b/Sources/Navigator/EPUB/Scripts/test/fixtures/accname/image.xhtml @@ -0,0 +1,465 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Generated by scripts/accname-sample/generate.py — do not edit. --> +<!DOCTYPE html> +<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="en" xml:lang="en"> +<head> + <meta charset="utf-8"/> + <title>Images + + + +

Images

+

The accessible name of an img is looked up in this order: aria-labelledby, aria-label, the alt attribute, the title attribute, and finally a figcaption. The description follows its own cascade and stops at the first attribute that is present, even when that attribute resolves to nothing.

+ +
+

aria-labelledby joins its targets and skips dangling ids

+
+
Name
Hello World
+
Description
none
+
+
+

Hello

+

World

+ Ignored +
+
+ +
+

aria-labelledby with only dangling ids falls through to aria-label

+
+
Name
Red apple
+
Description
none
+
+
+ +
+
+ +
+

A referenced element contributes its own aria-label

+
+
Name
Red apple
+
Description
none
+
+

The target's text content is only used when it carries no aria-label of its own.

+
+ ignore me + +
+
+ +
+

Referenced text is whitespace-normalised

+
+
Name
Hello beautiful world
+
Description
none
+
+
+

Hello + beautiful world

+ +
+
+ +
+

aria-label beats alt

+
+
Name
Red apple
+
Description
none
+
+
+ Ignored +
+
+ +
+

Name from alt

+
+
Name
Red apple
+
Description
none
+
+
+ Red apple +
+
+ +
+

A whitespace-only alt is decorative too

+
+
Name
none
+
Description
A tooltip
+
+

alt is trimmed before it is used, so alt=" " behaves exactly like alt="".

+
+    +
+
+ +
+

Empty alt blocks the title fallback

+
+
Name
none
+
Description
A tooltip
+
+

HTML-AAM 4.1.10 overrides accname 2.9, whose step 2.9 would name the image from the tooltip; browsers agree with us here. The tooltip still becomes the description.

+
+ +
+
+ +
+

Empty alt rescued by aria-label

+
+
Name
Red apple
+
Description
none
+
+
+ +
+
+ +
+

title names the image when nothing else does

+
+
Name
A tooltip
+
Description
none
+
+

The title is consumed by the name, so it does not also become the description.

+
+ +
+
+ +
+

No source at all yields no name

+
+
Name
none
+
Description
none
+
+
+ +
+
+ +
+

Description from aria-describedby

+
+
Name
Red apple
+
Description
A ripe apple on a white background.
+
+
+

A ripe apple on a white background.

+ Red apple +
+
+ +
+

A dangling aria-describedby still stops the cascade

+
+
Name
Red apple
+
Description
none
+
+

The attribute is present, so it counts as the first relevant markup found: the title is never consulted. The spec does not spell this out and browsers vary; this is a declared choice.

+
+ Red apple +
+
+ +
+

Description from aria-description

+
+
Name
Red apple
+
Description
A ripe apple on a white background.
+
+
+ Red apple +
+
+ +
+

aria-describedby beats aria-description

+
+
Name
Red apple
+
Description
From aria-describedby.
+
+
+

From aria-describedby.

+ Red apple +
+
+ +
+

title describes when the name came from alt

+
+
Name
Red apple
+
Description
A tooltip
+
+
+ Red apple +
+
+ +
+

aria-hidden="true" suppresses both

+
+
Name
none
+
Description
none
+
+
+ +
+
+ +
+

aria-hidden is compared case-insensitively

+
+
Name
none
+
Description
none
+
+
+ Red apple +
+
+ +
+

aria-hidden="false" does not suppress

+
+
Name
Red apple
+
Description
none
+
+
+ Red apple +
+
+ +
+

role="presentation" suppresses both

+
+
Name
none
+
Description
none
+
+
+ Red apple +
+
+ +
+

role="none" suppresses both

+
+
Name
none
+
Description
none
+
+
+ Red apple +
+
+ +
+

role is compared case-insensitively

+
+
Name
none
+
Description
none
+
+
+ Red apple +
+
+ +
+

aria-label cancels the presentational role

+
+
Name
Red apple
+
Description
none
+
+
+ +
+
+ +
+

aria-labelledby cancels the presentational role

+
+
Name
Red apple
+
Description
none
+
+
+

Red apple

+ +
+
+ +
+

aria-describedby cancels the presentational role

+
+
Name
Red apple
+
Description
A ripe apple.
+
+
+

A ripe apple.

+ Red apple +
+
+ +
+

aria-description cancels the presentational role

+
+
Name
Red apple
+
Description
A ripe apple.
+
+
+ Red apple +
+
+ +
+

role is a token list and the first token wins

+
+
Name
none
+
Description
none
+
+

presentation comes first, so the image is suppressed.

+
+ Red apple +
+
+ +
+

…and the same rule keeps a later presentation from applying

+
+
Name
Red apple
+
Description
none
+
+
+ Red apple +
+
+ +
+

figcaption names a lone image

+
+
Name
A red apple
+
Description
none
+
+
+
+ +
A red apple
+
+
+
+ +
+

figcaption is blocked by sibling flow content

+
+
Name
none
+
Description
none
+
+

The figure holds prose beyond the caption, so the caption is about more than the image.

+
+
+ +

Some prose sitting next to the image.

+
A red apple
+
+
+
+ +
+

figcaption is blocked by a second image

+
+
Name
none
+
Description
none
+
+
+
+ + Another apple +
A red apple
+
+
+
+ +
+

figcaption is blocked by an alt attribute

+
+
Name
none
+
Description
none
+
+

The rule only applies when the image has no alt attribute at all, not even an empty one.

+
+
+ +
A red apple
+
+
+
+ +
+

figcaption is blocked by a title attribute

+
+
Name
A tooltip
+
Description
none
+
+
+
+ +
A red apple
+
+
+
+ +
+

A nested image's alt does not reach the label

+
+
Name
Red apple
+
Description
none
+
+

The referenced span has no text of its own, so it contributes nothing and the cascade continues to alt.

+

Divergence: A browser recurses into the referenced element and computes “Yellow star”.

+
+ Yellow star + Red apple +
+
+ +
+

Chained aria-labelledby is not followed

+
+
Name
ignore me
+
Description
none
+
+

Divergence: A browser resolves the second hop and computes “Yellow star”.

+
+ ignore me + Yellow star + +
+
+ +
+

Hidden nodes inside a referenced target still contribute

+
+
Name
Visible hidden
+
Description
none
+
+

Excluding them needs CSS knowledge SwiftSoup does not have, so the DOM side matches the Swift side on purpose.

+

Divergence: A browser excludes the hidden span and computes “Visible”.

+
+

Visible

+ +
+
+ +
+

Unknown role tokens are not skipped

+
+
Name
Red apple
+
Description
none
+
+

Divergence: A browser discards the invalid token and honours presentation, computing no name.

+
+ Red apple +
+
+ + diff --git a/Sources/Navigator/EPUB/Scripts/test/fixtures/accname/media.xhtml b/Sources/Navigator/EPUB/Scripts/test/fixtures/accname/media.xhtml new file mode 100644 index 0000000000..097f226ceb --- /dev/null +++ b/Sources/Navigator/EPUB/Scripts/test/fixtures/accname/media.xhtml @@ -0,0 +1,116 @@ + + + + + + + Audio and video + + + +

Audio and video

+

audio and video have no host-language name source, so only ARIA attributes and title can name them. Their fallback content is never read.

+ +
+

aria-label names an audio

+
+
Name
One second of silence
+
Description
none
+
+
+ +
+
+ +
+

title names an audio

+
+
Name
One second of silence
+
Description
none
+
+
+ +
+
+ +
+

Fallback content never names an audio

+
+
Name
none
+
Description
none
+
+

The children of a media element are fallback content for readers without playback support, not a text alternative.

+
+ +
+
+ +
+

aria-describedby describes an audio

+
+
Name
Silent tone
+
Description
One second of digital silence.
+
+
+

One second of digital silence.

+ +
+
+ +
+

source children do not change the name

+
+
Name
Silent tone
+
Description
none
+
+
+ +
+
+ +
+

aria-hidden="true" suppresses an audio

+
+
Name
none
+
Description
none
+
+

role="presentation" is not valid on a media element, so aria-hidden is the only way to suppress one.

+
+ +
+
+ +
+

aria-label names a video

+
+
Name
A blue rectangle
+
Description
none
+
+
+ +
+
+ +
+

title describes a video named by aria-label

+
+
Name
A blue rectangle
+
Description
A tooltip
+
+
+ +
+
+ +
+

aria-hidden="true" suppresses a video

+
+
Name
none
+
Description
none
+
+
+ +
+
+ + diff --git a/Sources/Navigator/EPUB/Scripts/test/fixtures/accname/svg.xhtml b/Sources/Navigator/EPUB/Scripts/test/fixtures/accname/svg.xhtml new file mode 100644 index 0000000000..4d994f7215 --- /dev/null +++ b/Sources/Navigator/EPUB/Scripts/test/fixtures/accname/svg.xhtml @@ -0,0 +1,149 @@ + + + + + + + Inline SVG + + + +

Inline SVG

+

An inline svg takes its name from a direct title child and its description from a direct desc child. ARIA attributes still win over both, and an SVG referenced through an img element is an image like any other.

+ +
+

A direct title child names the SVG

+
+
Name
Blue circle
+
Description
none
+
+
+ Blue circle +
+
+ +
+

A direct desc child describes the SVG

+
+
Name
Blue circle
+
Description
A filled blue disc.
+
+
+ Blue circleA filled blue disc. +
+
+ +
+

desc without title describes but does not name

+
+
Name
none
+
Description
A filled blue disc.
+
+
+ A filled blue disc. +
+
+ +
+

A title nested in a g does not count

+
+
Name
none
+
Description
none
+
+

Only direct children of the svg element are host-language sources.

+
+ Blue circle +
+
+ +
+

A desc nested in a g does not count either

+
+
Name
Blue circle
+
Description
none
+
+
+ Blue circleIgnored +
+
+ +
+

aria-label beats the title child

+
+
Name
Red apple
+
Description
none
+
+
+ Blue circle +
+
+ +
+

aria-describedby beats the desc child

+
+
Name
Blue circle
+
Description
From aria-describedby.
+
+
+

From aria-describedby.

+ Blue circleIgnored +
+
+ +
+

The title child names, the title attribute describes

+
+
Name
Blue circle
+
Description
A tooltip
+
+
+ Blue circle +
+
+ +
+

The title attribute names an SVG with no title child

+
+
Name
A tooltip
+
Description
none
+
+
+ +
+
+ +
+

aria-hidden="true" suppresses the SVG

+
+
Name
none
+
Description
none
+
+
+ +
+
+ +
+

role="presentation" suppresses the SVG

+
+
Name
none
+
Description
none
+
+
+ Blue circle +
+
+ +
+

An SVG behind an img is an image

+
+
Name
Yellow star
+
Description
none
+
+

The title and desc inside the SVG file are invisible to the host document; only the img attributes are read.

+
+ Yellow star +
+
+ + diff --git a/Sources/Navigator/EPUB/Scripts/test/fixtures/accname/table.xhtml b/Sources/Navigator/EPUB/Scripts/test/fixtures/accname/table.xhtml new file mode 100644 index 0000000000..7146ac1996 --- /dev/null +++ b/Sources/Navigator/EPUB/Scripts/test/fixtures/accname/table.xhtml @@ -0,0 +1,143 @@ + + + + + + + Tables (not implemented) + + + +

Tables (not implemented)

+

Nothing in this section is implemented: table is not one of the elements the content iterator emits, and the accname helper has no host-language source for it. The cases below are written as if it were, and every one of them is flagged as skipped so neither test harness asserts it. Removing those flags is the acceptance test for a future implementation.

+ +
+

caption names the table

+
+
Name
Quarterly revenue
+
Description
none
+
+

Not implemented yet: neither test harness asserts this case.

+
+ + + + +
Quarterly revenue
QuarterRevenue
Q1120
+
+
+ +
+

aria-label beats the caption

+
+
Name
Revenue by quarter
+
Description
none
+
+

Not implemented yet: neither test harness asserts this case.

+
+ + + + +
Quarterly revenue
QuarterRevenue
Q1120
+
+
+ +
+

aria-labelledby beats the caption

+
+
Name
Revenue by quarter
+
Description
none
+
+

Not implemented yet: neither test harness asserts this case.

+
+

Revenue by quarter

+ + + + +
Quarterly revenue
QuarterRevenue
Q1120
+
+
+ +
+

title describes a table named by its caption

+
+
Name
Quarterly revenue
+
Description
A tooltip
+
+

Not implemented yet: neither test harness asserts this case.

+
+ + + + +
Quarterly revenue
QuarterRevenue
Q1120
+
+
+ +
+

aria-describedby describes the table

+
+
Name
Quarterly revenue
+
Description
Revenue in thousands of euros.
+
+

Not implemented yet: neither test harness asserts this case.

+
+

Revenue in thousands of euros.

+ + + + +
Quarterly revenue
QuarterRevenue
Q1120
+
+
+ +
+

aria-hidden="true" suppresses the table

+
+
Name
none
+
Description
none
+
+

Not implemented yet: neither test harness asserts this case.

+
+ + + + + +
+
+ +
+

role="presentation" suppresses the table

+
+
Name
none
+
Description
none
+
+

Not implemented yet: neither test harness asserts this case.

+
+ + + + + +
+
+ +
+

A table without a caption has no name

+
+
Name
none
+
Description
none
+
+

Not implemented yet: neither test harness asserts this case.

+
+ + + +
QuarterRevenue
Q1120
+
+
+ + diff --git a/Sources/Navigator/EPUB/Scripts/tsconfig.json b/Sources/Navigator/EPUB/Scripts/tsconfig.json new file mode 100644 index 0000000000..8f12d8ab2c --- /dev/null +++ b/Sources/Navigator/EPUB/Scripts/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "strict": true, + "noEmit": true, + "target": "es2019", + "lib": ["es2019", "dom"], + "moduleResolution": "bundler", + "allowJs": false + }, + "include": ["src/**/*.ts"], + "exclude": ["src/vendor"] +} diff --git a/Sources/Navigator/EPUB/Scripts/tsconfig.test.json b/Sources/Navigator/EPUB/Scripts/tsconfig.test.json new file mode 100644 index 0000000000..0a593cd2db --- /dev/null +++ b/Sources/Navigator/EPUB/Scripts/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "esModuleInterop": true + }, + "include": ["src/**/*.ts", "test"] +} diff --git a/Sources/Shared/Publication/Services/Content/Content.swift b/Sources/Shared/Publication/Services/Content/Content.swift index 19e4a9d120..b57d0a7516 100644 --- a/Sources/Shared/Publication/Services/Content/Content.swift +++ b/Sources/Shared/Publication/Services/Content/Content.swift @@ -86,7 +86,7 @@ public struct AnyEquatableContentElement: Equatable, ContentElement { /// An element which can be represented as human-readable text. /// -/// The default implementation returns the first accessibility label associated to the element. +/// The default implementation returns the accessible name of the element. public protocol TextualContentElement: ContentElement { /// Human-readable text representation for this element. var text: String? { get } @@ -94,7 +94,7 @@ public protocol TextualContentElement: ContentElement { public extension TextualContentElement { var text: String? { - accessibilityLabel + accessibleName } } @@ -104,40 +104,54 @@ public protocol EmbeddedContentElement: ContentElement { var embeddedLink: Link { get } } +/// An element which may carry a caption. +public protocol CaptionedContentElement: ContentElement { + /// Caption of the element, meant to be displayed alongside it. + /// + /// `nil` when the element has no caption, or when the caption is blank. + /// + /// May be equal to `accessibleName`, which is announced rather than + /// displayed. Prefer one or the other for display, rather than + /// concatenating them. + var caption: String? { get } +} + /// An audio clip. -public struct AudioContentElement: Hashable, EmbeddedContentElement, TextualContentElement { +public struct AudioContentElement: Hashable, EmbeddedContentElement, TextualContentElement, CaptionedContentElement { public var locator: Locator public var embeddedLink: Link + public var caption: String? public var attributes: [ContentAttribute] - public init(locator: Locator, embeddedLink: Link, attributes: [ContentAttribute] = []) { + public init(locator: Locator, embeddedLink: Link, caption: String? = nil, attributes: [ContentAttribute] = []) { self.locator = locator self.embeddedLink = embeddedLink + self.caption = caption self.attributes = attributes } } /// A video clip. -public struct VideoContentElement: Hashable, EmbeddedContentElement, TextualContentElement { +public struct VideoContentElement: Hashable, EmbeddedContentElement, TextualContentElement, CaptionedContentElement { public var locator: Locator public var embeddedLink: Link + public var caption: String? public var attributes: [ContentAttribute] - public init(locator: Locator, embeddedLink: Link, attributes: [ContentAttribute] = []) { + public init(locator: Locator, embeddedLink: Link, caption: String? = nil, attributes: [ContentAttribute] = []) { self.locator = locator self.embeddedLink = embeddedLink + self.caption = caption self.attributes = attributes } } /// An embedded image (bitmap or SVG). -public struct ImageContentElement: Hashable, EmbeddedContentElement, TextualContentElement { +public struct ImageContentElement: Hashable, EmbeddedContentElement, TextualContentElement, CaptionedContentElement { public var locator: Locator public var embeddedLink: Link - public var attributes: [ContentAttribute] - - /// Short piece of text associated with the image. public var caption: String? + public var attributes: [ContentAttribute] public init(locator: Locator, embeddedLink: Link, caption: String? = nil, attributes: [ContentAttribute] = []) { self.locator = locator @@ -145,35 +159,23 @@ public struct ImageContentElement: Hashable, EmbeddedContentElement, TextualCont self.caption = caption self.attributes = attributes } - - public var text: String? { - // The caption might be a better text description than the accessibility label, when available. - caption.takeIf { !$0.isEmpty } ?? accessibilityLabel - } } /// An inline SVG image. -public struct SVGContentElement: Hashable, TextualContentElement { +public struct SVGContentElement: Hashable, TextualContentElement, CaptionedContentElement { public var locator: Locator + public var caption: String? public var attributes: [ContentAttribute] /// Raw SVG contents. public var svg: String - /// Optional human-readable description of the image (e.g. from ``, - /// `<desc>`, `alt` or `title`). - public var caption: String? - public init(locator: Locator, svg: String, caption: String? = nil, attributes: [ContentAttribute] = []) { self.locator = locator self.svg = svg self.caption = caption self.attributes = attributes } - - public var text: String? { - caption.takeIf { !$0.isEmpty } ?? accessibilityLabel - } } /// A text element. @@ -234,8 +236,21 @@ public struct TextContentElement: Hashable, TextualContentElement { /// /// The `V` phantom type is there to perform static type checking when requesting an attribute. public struct ContentAttributeKey<V>: Hashable, Sendable { + @available(*, unavailable, renamed: "accessibleName") public static var accessibilityLabel: ContentAttributeKey<String> { - .init("accessibilityLabel") + fatalError() + } + + /// Accessible name of the element, computed following a subset of + /// https://www.w3.org/TR/accname-1.2 + public static var accessibleName: ContentAttributeKey<String> { + .init("accessibleName") + } + + /// Accessible description of the element, computed following a subset of + /// https://www.w3.org/TR/accname-1.2 + public static var accessibleDescription: ContentAttributeKey<String> { + .init("accessibleDescription") } public static var language: ContentAttributeKey<Language> { @@ -278,8 +293,17 @@ public extension ContentAttributesHolder { self[.language] } + @available(*, unavailable, renamed: "accessibleName") var accessibilityLabel: String? { - self[.accessibilityLabel] + fatalError() + } + + var accessibleName: String? { + self[.accessibleName] + } + + var accessibleDescription: String? { + self[.accessibleDescription] } /// Gets the first attribute with the given `key`. diff --git a/Sources/Shared/Publication/Services/Content/Iterators/HTMLAccessibilityProperties.swift b/Sources/Shared/Publication/Services/Content/Iterators/HTMLAccessibilityProperties.swift new file mode 100644 index 0000000000..7a3b46296a --- /dev/null +++ b/Sources/Shared/Publication/Services/Content/Iterators/HTMLAccessibilityProperties.swift @@ -0,0 +1,262 @@ +// +// Copyright 2026 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 SwiftSoup + +/// Accessible name and description of an HTML element, computed following a +/// pragmatic subset of https://www.w3.org/TR/accname-1.2 +/// +/// This is the Swift counterpart of the TypeScript implementation in +/// `Sources/Navigator/EPUB/Scripts/src/accname.ts` — both MUST implement +/// exactly the same subset. What keeps them in sync is the shared case +/// manifest in `scripts/accname-sample/cases.toml`: it generates the fixtures +/// both test suites run against, so a rule is stated once and asserted twice. +/// Add cases there. +/// +/// Implemented: +/// - Source precedence for the name: `aria-labelledby` → `aria-label` → +/// host-language sources → `title`, matching the accname computation steps +/// "LabelledBy", "AriaLabel", "Host Language Label" and "Tooltip": +/// https://www.w3.org/TR/accname-1.2/#computation-steps +/// - Element-level suppression: `aria-hidden="true"` (accname "Hidden Not +/// Referenced" step: https://www.w3.org/TR/accname-1.2/#comp_hidden_not_referenced), +/// or a presentational `role` not cancelled by a global ARIA attribute +/// (WAI-ARIA "Presentational Roles Conflict Resolution": +/// https://www.w3.org/TR/wai-aria-1.2/#conflict_resolution_presentation_none), +/// yields no name and no description. +/// - The description cascade (`aria-describedby` → `aria-description` → +/// host-language sources → unused `title`) stops at the first PRESENT +/// markup, even if it resolves to an empty description: +/// https://www.w3.org/TR/accname-1.2/#mapping_additional_nd_description +/// - HTML-AAM 4.1.10 rules for `img` +/// (https://www.w3.org/TR/html-aam-1.0/#img-element-accessible-name-computation): +/// an empty `alt` attribute marks a decorative image and blocks the `title` +/// fallback (HTML-AAM overriding literal accname-1.2, whose "Tooltip" step, +/// https://www.w3.org/TR/accname-1.2/#comp_tooltip, would still name the +/// image from the tooltip; browsers follow HTML-AAM); a figcaption names an +/// image which has no `alt`/`title` attribute and no sibling content. +/// +/// Deliberately skipped / divergences: +/// - Full recursive traversal of `aria-labelledby`/`aria-describedby` targets +/// (https://www.w3.org/TR/accname-1.2/#comp_labelledby); we approximate one +/// level: each target contributes its own `aria-label` when present, else +/// its text content. Nested images' `alt`, chained labelledby and embedded +/// form-control values +/// (https://www.w3.org/TR/accname-1.2/#comp_embedded_control) do not +/// contribute. +/// - Hidden-element rules beyond the element itself: hidden ancestors, and +/// the exclusion of hidden nodes inside referenced targets (requires CSS +/// knowledge SwiftSoup doesn't have; kept out of the DOM side too, for +/// parity). +/// - Roles that prohibit naming other than `presentation`/`none` +/// (https://www.w3.org/TR/wai-aria-1.2/#namefromprohibited); the +/// presentational-role conflict rule is narrowed to the four ARIA +/// attributes this helper reads (spec: any global ARIA attribute or +/// focusable element); unknown role tokens are not validated (the first +/// token wins). +/// - CSS generated content (`::before`/`::after`) and name-from-content +/// (https://www.w3.org/TR/accname-1.2/#comp_name_from_content). +/// - An `aria-describedby` whose IDREFs all dangle still counts as "the first +/// relevant markup found" and stops the description cascade (attribute +/// presence = found). The spec doesn't spell this out and browsers vary; +/// declared as a choice. +/// - HTML-AAM's figcaption-as-name fallback approximates the "no other +/// non-whitespace flow content descendants" condition: the figure's +/// normalized text must equal the figcaption's, and the figure must contain +/// no other embedded content. +/// +/// Reusability caveat: the ARIA-attribute sources apply to any element, but +/// host-language sources are implemented only for `img` and `svg`, and +/// name-from-content is not computed at all. The subset is exact for the +/// current consumers (img, svg, audio, video — roles that don't allow name +/// from content), but future element types have their own host-language +/// sources (e.g. `<table>` → `<caption>`, links/headings → content) that must +/// be added per-tag before pointing the helper at them. +struct HTMLAccessibilityProperties { + var name: String? + var description: String? + + /// The computed name/description as `ContentAttribute`s, ready to attach + /// to a `ContentElement`. + var contentAttributes: [ContentAttribute] { + var attributes: [ContentAttribute] = [] + if let name = name { + attributes.append(ContentAttribute(key: .accessibleName, value: name)) + } + if let description = description { + attributes.append(ContentAttribute(key: .accessibleDescription, value: description)) + } + return attributes + } +} + +extension SwiftSoup.Element { + /// Computes the accessible name and description of the receiver. + func accessibilityProperties() throws -> HTMLAccessibilityProperties { + let tag = tagNameNormal() + let title = try attr("title").trimmingCharacters(in: .whitespacesAndNewlines).orNilIfBlank() + + // Step 0: element-level suppression (accname "Initialization" and + // "Hidden Not Referenced" steps: + // https://www.w3.org/TR/accname-1.2/#computation-steps). + // `aria-hidden`, or a presentational role not cancelled by a global + // ARIA attribute, prohibit both name and description. ARIA token + // comparisons are case-insensitive; `role` is a token list with + // first-token-wins semantics. + let firstRole = try attr("role").lowercased() + .components(separatedBy: .whitespacesAndNewlines) + .first { !$0.isEmpty } + let hasGlobalARIAAttribute = hasAttr("aria-label") || hasAttr("aria-labelledby") + || hasAttr("aria-describedby") || hasAttr("aria-description") + if try attr("aria-hidden").lowercased() == "true" + || ((firstRole == "presentation" || firstRole == "none") && !hasGlobalARIAAttribute) + { + return HTMLAccessibilityProperties(name: nil, description: nil) + } + + var name: String? + var stopNameCascade = false + + // 1. aria-labelledby + name = try resolveIDReferences(attr("aria-labelledby")) + + // 2. aria-label + if name == nil { + name = try attr("aria-label").trimmingCharacters(in: .whitespacesAndNewlines).orNilIfEmpty() + } + + // 3. Host-language source + if name == nil { + switch tag { + case "img": + if hasAttr("alt") { + name = try attr("alt").trimmingCharacters(in: .whitespacesAndNewlines).orNilIfEmpty() + if name == nil { + // `alt=""` marks a decorative image: no fallback on + // `title`, per HTML-AAM 4.1.10. + stopNameCascade = true + } + } + case "svg": + name = try firstDirectChild(tag: "title")?.text().orNilIfBlank() + default: + break + } + } + + // 4. title attribute + var titleUsedAsName = false + if name == nil, !stopNameCascade, let title = title { + name = title + titleUsedAsName = true + } + + // 5. HTML-AAM 4.1.10 step 4: an img with no alt or title attribute, + // alone in a captioned figure, takes its name from the figcaption. + // https://www.w3.org/TR/html-aam-1.0/#img-element-accessible-name-computation + if name == nil, tag == "img", !hasAttr("alt"), !hasAttr("title") { + name = try figureCaptionAsName() + } + + // The description cascade stops at the first PRESENT markup, even if + // it resolves to an empty description ("MUST NOT use any markup other + // than the first relevant markup found"). + var description: String? + if hasAttr("aria-describedby") { + // 1. aria-describedby + description = try resolveIDReferences(attr("aria-describedby")) + } else if hasAttr("aria-description") { + // 2. aria-description + description = try attr("aria-description").trimmingCharacters(in: .whitespacesAndNewlines).orNilIfEmpty() + } else if tag == "svg", let desc = firstDirectChild(tag: "desc") { + // 3. Host-language source + description = try desc.text().orNilIfBlank() + } else if !titleUsedAsName { + // 4. title attribute, if not already used as the name + description = title + } + + return HTMLAccessibilityProperties(name: name, description: description) + } + + /// Resolves a space-separated list of element IDs against the document and + /// concatenates the referenced elements' text alternatives, per the + /// `aria-labelledby` and `aria-describedby` steps of the accessible name + /// computation. + /// + /// One-level approximation of the spec's recursive computation: each + /// referenced element contributes its own `aria-label` when present, + /// otherwise its text content. + private func resolveIDReferences(_ ids: String) throws -> String? { + guard let document = ownerDocument() else { + return nil + } + return try ids.components(separatedBy: .whitespacesAndNewlines) + .filter { !$0.isEmpty } + .compactMap { try document.getElementById($0)?.textAlternative() } + .joined(separator: " ") + .orNilIfBlank() + } + + /// The receiver's contribution when referenced by `aria-labelledby` or + /// `aria-describedby`: its `aria-label` when present, else its text content. + private func textAlternative() throws -> String? { + try attr("aria-label").trimmingCharacters(in: .whitespacesAndNewlines).orNilIfEmpty() + ?? text().orNilIfBlank() + } + + func firstDirectChild(tag: String) -> Element? { + children().first { $0.tagNameNormal() == tag } + } + + /// HTML-AAM 4.1.10 step 4, approximated: the figcaption names the image + /// only when the figure holds no other non-whitespace flow content — + /// checked as "the figure's normalized text equals the figcaption's, and + /// the figure contains no other embedded content". + /// https://www.w3.org/TR/html-aam-1.0/#img-element-accessible-name-computation + private func figureCaptionAsName() throws -> String? { + guard + let figure = enclosingFigure(), + let figcaption = figure.firstDirectChild(tag: "figcaption") + else { + return nil + } + guard + try figure.text() == figcaption.text(), + try figure.select("img, svg, audio, video, object, iframe, embed") + .allSatisfy({ $0 === self }) + else { + return nil + } + return try figcaption.text().orNilIfBlank() + } +} + +/// Shared with `HTMLResourceContentIterator` (which uses them for the +/// `caption` property). +extension SwiftSoup.Element { + /// Nearest ancestor `<figure>` element. + func enclosingFigure() -> Element? { + parents().first { $0.tagNameNormal() == "figure" } + } + + /// Returns the text of the enclosing `<figure>`'s direct `<figcaption>` + /// child, if any. + /// + /// An element living inside the figcaption (a publisher logo, a footnote + /// marker) is not captioned by the text wrapping it, so it gets no + /// caption at all rather than falling back to an outer figure. + func figureCaption() throws -> String? { + guard + let figcaption = enclosingFigure()?.firstDirectChild(tag: "figcaption"), + !parents().contains(where: { $0 === figcaption }) + else { + return nil + } + return try figcaption.text().orNilIfBlank() + } +} diff --git a/Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift b/Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift index 39a9c44094..94f86ed5c6 100644 --- a/Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift +++ b/Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift @@ -258,23 +258,18 @@ public actor HTMLResourceContentIterator: ContentIterator { if tag == "br" { flushText() - } else if tag == "img" { + } else if tag == "img", !isInsideSkippedElement { flushText() try node.srcRelativeToHREF(baseHREF).map { href in - var attributes: [ContentAttribute] = [] - if let alt = try node.attr("alt").orNilIfBlank() { - attributes.append(ContentAttribute(key: .accessibilityLabel, value: alt)) - } - - elements.append(ImageContentElement( + try elements.append(ImageContentElement( locator: elementLocator, embeddedLink: Link(href: href.string), - caption: nil, // TODO: Get the caption from figcaption - attributes: attributes + caption: node.figureCaption(), + attributes: node.accessibilityProperties().contentAttributes )) } - } else if tag == "audio" || tag == "video" { + } else if tag == "audio" || tag == "video", !isInsideSkippedElement { flushText() skippedAncestors.append(node) @@ -301,16 +296,29 @@ public actor HTMLResourceContentIterator: ContentIterator { }() if let link = link { + let caption = try node.figureCaption() + let attributes = try node.accessibilityProperties().contentAttributes switch tag { case "audio": - elements.append(AudioContentElement(locator: elementLocator, embeddedLink: link)) + elements.append(AudioContentElement(locator: elementLocator, embeddedLink: link, caption: caption, attributes: attributes)) case "video": - elements.append(VideoContentElement(locator: elementLocator, embeddedLink: link)) + elements.append(VideoContentElement(locator: elementLocator, embeddedLink: link, caption: caption, attributes: attributes)) default: break } } + } else if tag == "svg", !isInsideSkippedElement { + flushText() + skippedAncestors.append(node) + + try elements.append(SVGContentElement( + locator: elementLocator, + svg: node.outerHtml(), + caption: node.figureCaption(), + attributes: node.accessibilityProperties().contentAttributes + )) + } else if node.isBlock() { flushText() } @@ -599,6 +607,10 @@ private extension ContentElement { e.locator = update(e.locator) return e + case var e as SVGContentElement: + e.locator = update(e.locator) + return e + case var e as VideoContentElement: e.locator = update(e.locator) return e diff --git a/TestApp/Sources/Reader/Common/ImagePreview/ImagePreview.swift b/TestApp/Sources/Reader/Common/ImagePreview/ImagePreview.swift index f44310d76c..74650992bd 100644 --- a/TestApp/Sources/Reader/Common/ImagePreview/ImagePreview.swift +++ b/TestApp/Sources/Reader/Common/ImagePreview/ImagePreview.swift @@ -31,9 +31,15 @@ struct ImagePreview: View { } } - if let accessibilityLabel = image.accessibilityLabel { - LabeledContent("Accessibility Label") { - Text(accessibilityLabel) + if let accessibleName = image.accessibleName { + LabeledContent("Accessible Name") { + Text(accessibleName) + } + } + + if let accessibleDescription = image.accessibleDescription { + LabeledContent("Accessible Description") { + Text(accessibleDescription) } } } diff --git a/Tests/SharedTests/Fixtures/Publication/Services/Content/accname/image.xhtml b/Tests/SharedTests/Fixtures/Publication/Services/Content/accname/image.xhtml new file mode 100644 index 0000000000..f0821daebe --- /dev/null +++ b/Tests/SharedTests/Fixtures/Publication/Services/Content/accname/image.xhtml @@ -0,0 +1,465 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Generated by scripts/accname-sample/generate.py — do not edit. --> +<!DOCTYPE html> +<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="en" xml:lang="en"> +<head> + <meta charset="utf-8"/> + <title>Images + + + +

Images

+

The accessible name of an img is looked up in this order: aria-labelledby, aria-label, the alt attribute, the title attribute, and finally a figcaption. The description follows its own cascade and stops at the first attribute that is present, even when that attribute resolves to nothing.

+ +
+

aria-labelledby joins its targets and skips dangling ids

+
+
Name
Hello World
+
Description
none
+
+
+

Hello

+

World

+ Ignored +
+
+ +
+

aria-labelledby with only dangling ids falls through to aria-label

+
+
Name
Red apple
+
Description
none
+
+
+ +
+
+ +
+

A referenced element contributes its own aria-label

+
+
Name
Red apple
+
Description
none
+
+

The target's text content is only used when it carries no aria-label of its own.

+
+ ignore me + +
+
+ +
+

Referenced text is whitespace-normalised

+
+
Name
Hello beautiful world
+
Description
none
+
+
+

Hello + beautiful world

+ +
+
+ +
+

aria-label beats alt

+
+
Name
Red apple
+
Description
none
+
+
+ Ignored +
+
+ +
+

Name from alt

+
+
Name
Red apple
+
Description
none
+
+
+ Red apple +
+
+ +
+

A whitespace-only alt is decorative too

+
+
Name
none
+
Description
A tooltip
+
+

alt is trimmed before it is used, so alt=" " behaves exactly like alt="".

+
+    +
+
+ +
+

Empty alt blocks the title fallback

+
+
Name
none
+
Description
A tooltip
+
+

HTML-AAM 4.1.10 overrides accname 2.9, whose step 2.9 would name the image from the tooltip; browsers agree with us here. The tooltip still becomes the description.

+
+ +
+
+ +
+

Empty alt rescued by aria-label

+
+
Name
Red apple
+
Description
none
+
+
+ +
+
+ +
+

title names the image when nothing else does

+
+
Name
A tooltip
+
Description
none
+
+

The title is consumed by the name, so it does not also become the description.

+
+ +
+
+ +
+

No source at all yields no name

+
+
Name
none
+
Description
none
+
+
+ +
+
+ +
+

Description from aria-describedby

+
+
Name
Red apple
+
Description
A ripe apple on a white background.
+
+
+

A ripe apple on a white background.

+ Red apple +
+
+ +
+

A dangling aria-describedby still stops the cascade

+
+
Name
Red apple
+
Description
none
+
+

The attribute is present, so it counts as the first relevant markup found: the title is never consulted. The spec does not spell this out and browsers vary; this is a declared choice.

+
+ Red apple +
+
+ +
+

Description from aria-description

+
+
Name
Red apple
+
Description
A ripe apple on a white background.
+
+
+ Red apple +
+
+ +
+

aria-describedby beats aria-description

+
+
Name
Red apple
+
Description
From aria-describedby.
+
+
+

From aria-describedby.

+ Red apple +
+
+ +
+

title describes when the name came from alt

+
+
Name
Red apple
+
Description
A tooltip
+
+
+ Red apple +
+
+ +
+

aria-hidden="true" suppresses both

+
+
Name
none
+
Description
none
+
+
+ +
+
+ +
+

aria-hidden is compared case-insensitively

+
+
Name
none
+
Description
none
+
+
+ Red apple +
+
+ +
+

aria-hidden="false" does not suppress

+
+
Name
Red apple
+
Description
none
+
+
+ Red apple +
+
+ +
+

role="presentation" suppresses both

+
+
Name
none
+
Description
none
+
+
+ Red apple +
+
+ +
+

role="none" suppresses both

+
+
Name
none
+
Description
none
+
+
+ Red apple +
+
+ +
+

role is compared case-insensitively

+
+
Name
none
+
Description
none
+
+
+ Red apple +
+
+ +
+

aria-label cancels the presentational role

+
+
Name
Red apple
+
Description
none
+
+
+ +
+
+ +
+

aria-labelledby cancels the presentational role

+
+
Name
Red apple
+
Description
none
+
+
+

Red apple

+ +
+
+ +
+

aria-describedby cancels the presentational role

+
+
Name
Red apple
+
Description
A ripe apple.
+
+
+

A ripe apple.

+ Red apple +
+
+ +
+

aria-description cancels the presentational role

+
+
Name
Red apple
+
Description
A ripe apple.
+
+
+ Red apple +
+
+ +
+

role is a token list and the first token wins

+
+
Name
none
+
Description
none
+
+

presentation comes first, so the image is suppressed.

+
+ Red apple +
+
+ +
+

…and the same rule keeps a later presentation from applying

+
+
Name
Red apple
+
Description
none
+
+
+ Red apple +
+
+ +
+

figcaption names a lone image

+
+
Name
A red apple
+
Description
none
+
+
+
+ +
A red apple
+
+
+
+ +
+

figcaption is blocked by sibling flow content

+
+
Name
none
+
Description
none
+
+

The figure holds prose beyond the caption, so the caption is about more than the image.

+
+
+ +

Some prose sitting next to the image.

+
A red apple
+
+
+
+ +
+

figcaption is blocked by a second image

+
+
Name
none
+
Description
none
+
+
+
+ + Another apple +
A red apple
+
+
+
+ +
+

figcaption is blocked by an alt attribute

+
+
Name
none
+
Description
none
+
+

The rule only applies when the image has no alt attribute at all, not even an empty one.

+
+
+ +
A red apple
+
+
+
+ +
+

figcaption is blocked by a title attribute

+
+
Name
A tooltip
+
Description
none
+
+
+
+ +
A red apple
+
+
+
+ +
+

A nested image's alt does not reach the label

+
+
Name
Red apple
+
Description
none
+
+

The referenced span has no text of its own, so it contributes nothing and the cascade continues to alt.

+

Divergence: A browser recurses into the referenced element and computes “Yellow star”.

+
+ Yellow star + Red apple +
+
+ +
+

Chained aria-labelledby is not followed

+
+
Name
ignore me
+
Description
none
+
+

Divergence: A browser resolves the second hop and computes “Yellow star”.

+
+ ignore me + Yellow star + +
+
+ +
+

Hidden nodes inside a referenced target still contribute

+
+
Name
Visible hidden
+
Description
none
+
+

Excluding them needs CSS knowledge SwiftSoup does not have, so the DOM side matches the Swift side on purpose.

+

Divergence: A browser excludes the hidden span and computes “Visible”.

+
+

Visible

+ +
+
+ +
+

Unknown role tokens are not skipped

+
+
Name
Red apple
+
Description
none
+
+

Divergence: A browser discards the invalid token and honours presentation, computing no name.

+
+ Red apple +
+
+ + diff --git a/Tests/SharedTests/Fixtures/Publication/Services/Content/accname/media.xhtml b/Tests/SharedTests/Fixtures/Publication/Services/Content/accname/media.xhtml new file mode 100644 index 0000000000..097f226ceb --- /dev/null +++ b/Tests/SharedTests/Fixtures/Publication/Services/Content/accname/media.xhtml @@ -0,0 +1,116 @@ + + + + + + + Audio and video + + + +

Audio and video

+

audio and video have no host-language name source, so only ARIA attributes and title can name them. Their fallback content is never read.

+ +
+

aria-label names an audio

+
+
Name
One second of silence
+
Description
none
+
+
+ +
+
+ +
+

title names an audio

+
+
Name
One second of silence
+
Description
none
+
+
+ +
+
+ +
+

Fallback content never names an audio

+
+
Name
none
+
Description
none
+
+

The children of a media element are fallback content for readers without playback support, not a text alternative.

+
+ +
+
+ +
+

aria-describedby describes an audio

+
+
Name
Silent tone
+
Description
One second of digital silence.
+
+
+

One second of digital silence.

+ +
+
+ +
+

source children do not change the name

+
+
Name
Silent tone
+
Description
none
+
+
+ +
+
+ +
+

aria-hidden="true" suppresses an audio

+
+
Name
none
+
Description
none
+
+

role="presentation" is not valid on a media element, so aria-hidden is the only way to suppress one.

+
+ +
+
+ +
+

aria-label names a video

+
+
Name
A blue rectangle
+
Description
none
+
+
+ +
+
+ +
+

title describes a video named by aria-label

+
+
Name
A blue rectangle
+
Description
A tooltip
+
+
+ +
+
+ +
+

aria-hidden="true" suppresses a video

+
+
Name
none
+
Description
none
+
+
+ +
+
+ + diff --git a/Tests/SharedTests/Fixtures/Publication/Services/Content/accname/svg.xhtml b/Tests/SharedTests/Fixtures/Publication/Services/Content/accname/svg.xhtml new file mode 100644 index 0000000000..4d994f7215 --- /dev/null +++ b/Tests/SharedTests/Fixtures/Publication/Services/Content/accname/svg.xhtml @@ -0,0 +1,149 @@ + + + + + + + Inline SVG + + + +

Inline SVG

+

An inline svg takes its name from a direct title child and its description from a direct desc child. ARIA attributes still win over both, and an SVG referenced through an img element is an image like any other.

+ +
+

A direct title child names the SVG

+
+
Name
Blue circle
+
Description
none
+
+
+ Blue circle +
+
+ +
+

A direct desc child describes the SVG

+
+
Name
Blue circle
+
Description
A filled blue disc.
+
+
+ Blue circleA filled blue disc. +
+
+ +
+

desc without title describes but does not name

+
+
Name
none
+
Description
A filled blue disc.
+
+
+ A filled blue disc. +
+
+ +
+

A title nested in a g does not count

+
+
Name
none
+
Description
none
+
+

Only direct children of the svg element are host-language sources.

+
+ Blue circle +
+
+ +
+

A desc nested in a g does not count either

+
+
Name
Blue circle
+
Description
none
+
+
+ Blue circleIgnored +
+
+ +
+

aria-label beats the title child

+
+
Name
Red apple
+
Description
none
+
+
+ Blue circle +
+
+ +
+

aria-describedby beats the desc child

+
+
Name
Blue circle
+
Description
From aria-describedby.
+
+
+

From aria-describedby.

+ Blue circleIgnored +
+
+ +
+

The title child names, the title attribute describes

+
+
Name
Blue circle
+
Description
A tooltip
+
+
+ Blue circle +
+
+ +
+

The title attribute names an SVG with no title child

+
+
Name
A tooltip
+
Description
none
+
+
+ +
+
+ +
+

aria-hidden="true" suppresses the SVG

+
+
Name
none
+
Description
none
+
+
+ +
+
+ +
+

role="presentation" suppresses the SVG

+
+
Name
none
+
Description
none
+
+
+ Blue circle +
+
+ +
+

An SVG behind an img is an image

+
+
Name
Yellow star
+
Description
none
+
+

The title and desc inside the SVG file are invisible to the host document; only the img attributes are read.

+
+ Yellow star +
+
+ + diff --git a/Tests/SharedTests/Fixtures/Publication/Services/Content/accname/table.xhtml b/Tests/SharedTests/Fixtures/Publication/Services/Content/accname/table.xhtml new file mode 100644 index 0000000000..7146ac1996 --- /dev/null +++ b/Tests/SharedTests/Fixtures/Publication/Services/Content/accname/table.xhtml @@ -0,0 +1,143 @@ + + + + + + + Tables (not implemented) + + + +

Tables (not implemented)

+

Nothing in this section is implemented: table is not one of the elements the content iterator emits, and the accname helper has no host-language source for it. The cases below are written as if it were, and every one of them is flagged as skipped so neither test harness asserts it. Removing those flags is the acceptance test for a future implementation.

+ +
+

caption names the table

+
+
Name
Quarterly revenue
+
Description
none
+
+

Not implemented yet: neither test harness asserts this case.

+
+ + + + +
Quarterly revenue
QuarterRevenue
Q1120
+
+
+ +
+

aria-label beats the caption

+
+
Name
Revenue by quarter
+
Description
none
+
+

Not implemented yet: neither test harness asserts this case.

+
+ + + + +
Quarterly revenue
QuarterRevenue
Q1120
+
+
+ +
+

aria-labelledby beats the caption

+
+
Name
Revenue by quarter
+
Description
none
+
+

Not implemented yet: neither test harness asserts this case.

+
+

Revenue by quarter

+ + + + +
Quarterly revenue
QuarterRevenue
Q1120
+
+
+ +
+

title describes a table named by its caption

+
+
Name
Quarterly revenue
+
Description
A tooltip
+
+

Not implemented yet: neither test harness asserts this case.

+
+ + + + +
Quarterly revenue
QuarterRevenue
Q1120
+
+
+ +
+

aria-describedby describes the table

+
+
Name
Quarterly revenue
+
Description
Revenue in thousands of euros.
+
+

Not implemented yet: neither test harness asserts this case.

+
+

Revenue in thousands of euros.

+ + + + +
Quarterly revenue
QuarterRevenue
Q1120
+
+
+ +
+

aria-hidden="true" suppresses the table

+
+
Name
none
+
Description
none
+
+

Not implemented yet: neither test harness asserts this case.

+
+ + + + + +
+
+ +
+

role="presentation" suppresses the table

+
+
Name
none
+
Description
none
+
+

Not implemented yet: neither test harness asserts this case.

+
+ + + + + +
+
+ +
+

A table without a caption has no name

+
+
Name
none
+
Description
none
+
+

Not implemented yet: neither test harness asserts this case.

+
+ + + +
QuarterRevenue
Q1120
+
+
+ + diff --git a/Tests/SharedTests/Publication/Services/Content/Iterators/AccnameSampleTests.swift b/Tests/SharedTests/Publication/Services/Content/Iterators/AccnameSampleTests.swift new file mode 100644 index 0000000000..79913e888c --- /dev/null +++ b/Tests/SharedTests/Publication/Services/Content/Iterators/AccnameSampleTests.swift @@ -0,0 +1,149 @@ +// +// Copyright 2026 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 +@testable import ReadiumShared +import SwiftSoup +import Testing + +/// Tests for the accessible name and description computation +/// (`HTMLAccessibilityProperties`), through the HTML content iterator. +/// +/// The cases are read from the `accname` sample, generated from +/// `/scripts/accname-sample/cases.toml`. That manifest also drives the jest +/// parity suite in +/// `/Sources/Navigator/EPUB/Scripts/test/accname-sample.test.ts`, so the two +/// implementations are asserted against a single source of truth. +/// +/// Each case states its expected values in prose *and* as `data-expected-*` +/// attributes on the element under test. +/// `python3 scripts/accname-sample/generate.py` regenerates these fixtures, +/// along with an EPUB you can open in a reader to check the same cases by hand. +struct AccnameSampleTests { + @Test(arguments: AccnameSample.cases) + func computesTheExpectedProperties(testCase: AccnameSample.Case) async throws { + let computed = try await AccnameSample.computedProperties() + let actual = try #require( + computed[testCase.id], + "the content iterator did not emit an element for this case" + ) + #expect(actual.name == testCase.expectedName) + #expect(actual.description == testCase.expectedDescription) + } + + /// Catches subject elements the iterator silently drops, which would + /// otherwise make the per-case tests vacuous. + @Test func everyCaseIsReachedByTheIterator() async throws { + let computed = try await AccnameSample.computedProperties() + let missing = AccnameSample.cases + .map(\.id) + .filter { computed[$0] == nil } + #expect(missing.isEmpty, "cases missing from the iterator output: \(missing)") + } +} + +// MARK: - Sample + +/// Reads the `accname` sample once and exposes both the expected values (parsed +/// out of the generated markup) and the computed ones (collected from the +/// content iterator). +enum AccnameSample { + /// A single case of the sample, matched to an iterator element by id. + struct Case: Sendable, CustomStringConvertible { + /// Name of the generated document holding the case. + let document: String + /// Value of the `data-case` attribute, also the element's `id` minus + /// the `case-` prefix. + let id: String + let expectedName: String? + let expectedDescription: String? + + var description: String { + "\(document) · \(id)" + } + } + + struct Properties: Sendable { + let name: String? + let description: String? + } + + private static let fixtures = Fixtures(path: "Publication/Services/Content") + + /// The documents of the sample, discovered rather than listed, so that + /// adding a resource to the manifest needs no change here. + private static var documents: [String] { + get throws { + try FileManager.default + .contentsOfDirectory(atPath: fixtures.url(for: "accname").path) + .filter { $0.hasSuffix(".xhtml") } + .sorted() + } + } + + private static func markup(of document: String) throws -> String { + try String(contentsOf: fixtures.url(for: "accname/\(document)").url, encoding: .utf8) + } + + /// Every case the harness must assert, parsed out of the generated markup. + /// + /// Cases flagged `data-test-skipped` describe rules that are not + /// implemented yet and are excluded here, exactly as in the jest suite. + static let cases: [Case] = { + do { + return try documents.flatMap { (document: String) -> [Case] in + try SwiftSoup.parse(markup(of: document)) + .select("[data-case]:not([data-test-skipped])") + .map { element in + try Case( + document: document, + id: element.attr("data-case"), + expectedName: element.hasAttr("data-expected-name") + ? element.attr("data-expected-name") : nil, + expectedDescription: element.hasAttr("data-expected-description") + ? element.attr("data-expected-description") : nil + ) + } + } + } catch { + fatalError("Could not read the accname sample cases: \(error)") + } + }() + + /// Iterates every document once and keys the computed properties by case id. + /// + /// `CSSSelectorGenerator` returns `#` verbatim for an element carrying + /// an id, so every subject element lands under `#case-`. Decoy elements + /// inside a case have no id, get a positional selector, and are ignored. + private static let loading = Task<[String: Properties], Error> { + var properties: [String: Properties] = [:] + for document in try documents { + let iterator = try HTMLResourceContentIterator( + resource: DataResource(string: markup(of: document)), + totalProgressionRange: { nil }, + locator: Locator(href: document, mediaType: .xhtml) + ) + + while let element = try await iterator.next() { + guard + let selector = element.locator.locations["cssSelector"]?.string, + selector.hasPrefix("#case-") + else { + continue + } + properties[String(selector.dropFirst("#case-".count))] = Properties( + name: element.accessibleName, + description: element.accessibleDescription + ) + } + } + return properties + } + + static func computedProperties() async throws -> [String: Properties] { + try await loading.value + } +} diff --git a/Tests/SharedTests/Publication/Services/Content/Iterators/HTMLResourceContentIteratorTests.swift b/Tests/SharedTests/Publication/Services/Content/Iterators/HTMLResourceContentIteratorTests.swift index 2085886f7c..06268c5274 100644 --- a/Tests/SharedTests/Publication/Services/Content/Iterators/HTMLResourceContentIteratorTests.swift +++ b/Tests/SharedTests/Publication/Services/Content/Iterators/HTMLResourceContentIteratorTests.swift @@ -4,6 +4,7 @@ // available in the top-level LICENSE file of the project. // +import Foundation @testable import ReadiumShared import Testing @@ -174,7 +175,7 @@ struct HTMLResourceContentIteratorTests { locator: makeLocator(progression: 0.5, selector: "html > body > img:nth-child(2)"), embeddedLink: Link(href: "cover.jpg"), caption: nil, - attributes: [ContentAttribute(key: .accessibilityLabel, value: "Accessibility description")] + attributes: [ContentAttribute(key: .accessibleName, value: "Accessibility description")] ).equatable(), ] @@ -267,6 +268,184 @@ struct HTMLResourceContentIteratorTests { #expect(result == nil) } + struct Figures { + @Test func imageInFigureGetsCaptionFromFigcaption() async throws { + let elements = try await allElements(""" +
Alt text
The caption
+ """) + + #expect(elements.count == 2) + let image = try #require(elements[0] as? ImageContentElement) + #expect(image.caption == "The caption") + #expect(image.accessibleName == "Alt text") + #expect(image.text == "Alt text") + + // The figcaption is still emitted as a regular text element. + let text = try #require(elements[1] as? TextContentElement) + #expect(text.text == "The caption") + } + + @Test func nestedFigureUsesTheNearestFigcaption() async throws { + let elements = try await allElements(""" +
+
Alt
Inner
+
Outer
+
+ """) + + let image = try #require(elements.compactMap { $0 as? ImageContentElement }.first) + #expect(image.caption == "Inner") + } + + @Test func twoImagesInOneFigureShareTheCaption() async throws { + let elements = try await allElements(""" +
AB
Shared
+ """) + + let images = elements.compactMap { $0 as? ImageContentElement } + #expect(images.count == 2) + #expect(images.allSatisfy { $0.caption == "Shared" }) + } + + @Test func figcaptionNotFirstChildStillProvidesTheCaption() async throws { + let elements = try await allElements(""" +

intro

Alt
Cap
+ """) + + let image = try #require(elements.compactMap { $0 as? ImageContentElement }.first) + #expect(image.caption == "Cap") + } + + @Test func imageInsideTheFigcaptionIsNotCaptionedByIt() async throws { + let elements = try await allElements(""" +
+ Revenue chart +
Source: ACME annual report
+
+ """) + + let images = elements.compactMap { $0 as? ImageContentElement } + #expect(images.count == 2) + + let chart = try #require(images.first) + #expect(chart.caption == "Source: annual report") + + let logo = try #require(images.last) + #expect(logo.caption == nil) + } + + @Test func theNameStillComesFromAWrappingFigcaption() async throws { + // The guard above is deliberately not applied to the accessible + // name: HTML-AAM 4.1.10 names this image from the figcaption it + // lives inside, and we follow the spec there. + let elements = try await allElements(""" +
Logo:
+ """) + + let image = try #require(elements.compactMap { $0 as? ImageContentElement }.first) + #expect(image.caption == nil) + #expect(image.accessibleName == "Logo:") + } + + @Test func audioAndVideoInAFigureGetTheCaption() async throws { + let elements = try await allElements(""" +
A pure tone
+
A short clip
+ """) + + let audio = try #require(elements.compactMap { $0 as? AudioContentElement }.first) + #expect(audio.caption == "A pure tone") + #expect(audio.accessibleName == "Tone") + + let video = try #require(elements.compactMap { $0 as? VideoContentElement }.first) + #expect(video.caption == "A short clip") + #expect(video.accessibleName == "Clip") + } + } + + struct MediaAccessibilityAttributes { + @Test func audioElementExposesAccessibilityAttributes() async throws { + let elements = try await allElements(""" +