75f1fafe9f
* feat(reader): slide and page curl turn animations (#555) Add an Animation Style setting (Push, Slide, Page Curl) next to the Paging Animation switch. Slide moves the turning page over the still previous or next page like the Apple Books slide; Page Curl folds it open in 3D so the page underneath is partially visible as it turns. Both styles track the finger: the page follows a horizontal drag and commits past halfway or on a flick, or settles back. The page header and footer stay in place while the page turns. The styles layer a View Transitions snapshot of the outgoing page over the live, stationary incoming page, since the pages of one section live in a single iframe and can never be on screen twice. They work for all writing modes including vertical-rl, and on engines without the View Transitions API (older WebViews) the paginator falls back to the existing push animation, so all platforms keep working page turns. The paginator changes live in the foliate-js submodule; this bumps the pointer, wires viewSettings.pageTurnStyle to the renderer turn-style attribute, and adds browser tests covering slide layering, curl, vertical-rl, finger tracking with commit and revert, and the push fallback. Fixes #555 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reader): add WebGL page curl renderer for mesh turn animations (#555) Grid mesh deformed around a cylinder: content past the fold wraps over and lands mirrored on top with a whitened page back, transparent where the page has curled away. Corner grabs start as a steep diagonal pinch that straightens as the turn completes so the whole page clears by the end. Groundwork for the Tauri mesh curl; capture and orchestration land separately. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(native-bridge): capture webview region as PNG on macOS and iOS (#555) New capture_webview_region plugin command returns a binary PNG snapshot of the calling webview (tauri::ipc::Response, no JSON overhead) for the mesh page-curl texture. macOS goes through WKWebView takeSnapshotWithConfiguration via with_webview on the main thread with a 500ms timeout; iOS snapshots in Swift and hands the PNG across the JSON-only plugin boundary base64-encoded, decoded back to bytes in mobile.rs. Windows, Linux, and Android reject for now so the JS side falls back to the CSS curl. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reader): drive the mesh page curl on Tauri platforms (#555) Wire the WebGL curl renderer and the native webview capture into page turns. A MeshCurlTurn controller runs the pipeline per turn: snapshot the content box, overlay the captured page drawn flat, turn the live view instantly underneath (the paginator's animated paths all gate on the animated attribute), then curl the capture away. Backward turns mirror the fold to the spine edge, matching the layered VT curl's old-page-recedes choreography. useMeshPageCurl wraps the view's prev/next so taps, keys, and wheel turns all curl, and registers a touch interceptor (between the reading ruler and the fixed-layout swipe) that scrubs the curl from the finger, committing past halfway or on a flick and otherwise un-curling and turning back under the overlay. The paginator stays out of the way via no-swipe while the mesh is active; if the native capture ever fails the session falls back to the paginator's CSS arc-fold curl and the shared applyPageTurnAttributes helper restores turn-style. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(build): restore iOS builds on Xcode 26.2 with a vendored swift-rs Swift 6.2's driver no longer honors swift-rs 1.0.7's cross-compilation style (swift build --arch <host> with per-swiftc -target overrides and an inherited SDKROOT): plugin sources compile against the wrong platform's Swift overlays and fail with baffling errors like type 'Bundle' has no member 'main' and extra argument 'privacy' in call. Upstream swift-rs is unmaintained, so vendor it under packages/swift-rs via a crates-io patch and build with SPM's first-class --triple/--sdk flags instead, dropping the leaked SDKROOT so the host-targeted manifest compile stays clean. Artifacts land in the unversioned-triple directory now, so the link search path follows. With --triple, SPM enforces the deployment floor declared in Package.swift (the old override bypassed it): bump native-bridge to iOS 15.0, matching the app's deployment target, since StoreKit's Storefront is used unguarded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(native-bridge): capture webview region on Android via PixelCopy (#555) Implements the Android side of capture_webview_region so the mesh page curl works there too. The Kotlin command scales the CSS-pixel rect by the display density, offsets it by the webview's window position, and reads the pixels back from the window surface with PixelCopy (API 26+, the app's minSdk), which includes the hardware-accelerated WebView that View.draw would miss. PNG encoding runs off the main thread and the result crosses the JSON plugin boundary base64-encoded, decoded back to bytes in mobile.rs like iOS. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reader): right the upside-down page curl on iOS (#555) The renderer oriented its texture with UNPACK_FLIP_Y_WEBGL, which WebKit ignores for ImageBitmap uploads: on iOS the captured page rendered upside down, and the mirrored page back read as rotated 180 degrees instead of the ink-through-paper horizontal mirror Apple Books shows. Upload unflipped and sample page coordinates directly so no pixel-store flag is involved. The page texture in the browser test was only horizontally asymmetric, which is how the flip slipped through; it now uses four quadrants fed through the production PNG-blob-to-ImageBitmap path and pins the vertical orientation. Verified red/green by running the suite on Playwright WebKit, which reproduces the iOS behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reader): curl the whole page including header, footer, and margins (#555) The mesh curl captured only the margin-inset content box, leaving the running header, footer, and page margins static while just the text column turned. A physical page turn takes the whole sheet with it, as Apple Books does, so the capture and overlay now span the full reader cell. The overlay mounts above the in-cell header (z-10) and footer, so the static copies never show through the turning page. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reader): gate layered View Transition turns and slide from a capture instead (#555) iOS 18 WebKit ships document.startViewTransition but crashes the WebContent process when a page-turn transition snapshots the reader, so the mere presence of the API is not enough for the layered slide/curl turns. Require nested view-transition groups (Chrome/WebView 140+) as the marker of a mature engine before setting turn-style on the renderer. Engines that fail the check no longer lose the slide on Tauri: the mesh curl's capture pipeline generalizes to CapturedPageTurn and now also drives a flat slide overlay (capture the outgoing page, turn instantly underneath, translate the captured page out toward the spine, mirrored for backward turns), clipped to the content box with an edge shadow like the VT slide. On the web, engines without full support fall back to push and the Slide/Page Curl options are hidden from the Animation Style select; a synced slide/curl setting from another device reads as Push there. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reader): make the Android page curl start instantly (#555) The Android capture encoded a full-density PNG: 1080x2400 on a 3x Xiaomi 13 took ~1.5s per turn, so the page sat frozen long enough to read as the curl not working at all. Encode JPEG instead (the page is opaque) and cap the destination bitmap at 2x CSS pixels - PixelCopy scales into a smaller bitmap for free and the moving page stays sharp. Measured on device over CDP: the capture invoke drops from 1550ms to 34ms and the curl overlay mounts 132ms after the tap. The JS side stops hardcoding an image/png blob type and lets the decoder sniff the platform's actual format. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * perf(reader): encode iOS page-curl captures as capped JPEG (#555) Apply the Android speedup to iOS: encode the snapshot as JPEG (the page is opaque) off the main thread, and cap it at 2x CSS pixels via WKSnapshotConfiguration.snapshotWidth on 3x screens, cutting both the encode time and the base64 payload crossing the JSON plugin boundary. The JS side already sniffs the image format from the bytes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1952 lines
72 KiB
Swift
1952 lines
72 KiB
Swift
import AVFoundation
|
||
import AuthenticationServices
|
||
import CoreText
|
||
import MediaPlayer
|
||
import ObjectiveC
|
||
import StoreKit
|
||
import SwiftRs
|
||
import Tauri
|
||
import UIKit
|
||
import UniformTypeIdentifiers
|
||
import WebKit
|
||
import os
|
||
|
||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "NativeBridge")
|
||
|
||
func getLocalizedDisplayName(familyName: String) -> String? {
|
||
let fontDescriptor = CTFontDescriptorCreateWithAttributes(
|
||
[
|
||
kCTFontFamilyNameAttribute: familyName
|
||
] as CFDictionary)
|
||
|
||
let font = CTFontCreateWithFontDescriptor(fontDescriptor, 0.0, nil)
|
||
|
||
var actualLanguage: Unmanaged<CFString>?
|
||
if let localizedName = CTFontCopyLocalizedName(font, kCTFontFamilyNameKey, &actualLanguage) {
|
||
return localizedName as String
|
||
}
|
||
return nil
|
||
}
|
||
|
||
class SafariAuthRequestArgs: Decodable {
|
||
let authUrl: String
|
||
// ASWebAuthenticationSession callback scheme. Defaults to "readest" (the
|
||
// Supabase login); the Google Drive flow passes its reverse-DNS scheme so the
|
||
// session intercepts that redirect instead.
|
||
let callbackScheme: String?
|
||
}
|
||
|
||
class UseBackgroundAudioRequestArgs: Decodable {
|
||
let enabled: Bool
|
||
}
|
||
|
||
class SetSystemUIVisibilityRequestArgs: Decodable {
|
||
let visible: Bool
|
||
let darkMode: Bool
|
||
}
|
||
|
||
class InterceptKeysRequestArgs: Decodable {
|
||
let backKey: Bool?
|
||
let volumeKeys: Bool?
|
||
let pageTurnerKeys: Bool?
|
||
let learnMode: Bool?
|
||
}
|
||
|
||
class LockScreenOrientationRequestArgs: Decodable {
|
||
let orientation: String?
|
||
}
|
||
|
||
class SetScreenBrightnessRequestArgs: Decodable {
|
||
let brightness: Float?
|
||
}
|
||
|
||
class CopyUriToPathRequestArgs: Decodable {
|
||
let uri: String?
|
||
let dst: String?
|
||
}
|
||
|
||
struct InitializeRequest: Decodable {
|
||
let publicKey: String?
|
||
}
|
||
|
||
struct FetchProductsRequest: Decodable {
|
||
let productIds: [String]
|
||
}
|
||
|
||
struct PurchaseProductRequest: Decodable {
|
||
let productId: String
|
||
}
|
||
|
||
struct ProductData: Codable {
|
||
let id: String
|
||
let title: String
|
||
let description: String
|
||
let price: String
|
||
let priceCurrencyCode: String?
|
||
let priceAmountMicros: Int64
|
||
let productType: String
|
||
}
|
||
|
||
struct PurchaseData: Codable {
|
||
let productId: String
|
||
let transactionId: String
|
||
let originalTransactionId: String
|
||
let purchaseDate: String
|
||
let purchaseState: String
|
||
let platform: String
|
||
}
|
||
|
||
class VolumeKeyHandler: NSObject {
|
||
private var audioSession: AVAudioSession?
|
||
private var originalVolume: Float = 0.0
|
||
private var referenceVolume: Float = 0.5
|
||
private var previousVolume: Float = 0.5
|
||
private var volumeView: MPVolumeView?
|
||
private(set) var isIntercepting = false
|
||
private var webView: WKWebView?
|
||
private var volumeSlider: UISlider?
|
||
|
||
func startInterception(webView: WKWebView) {
|
||
if isIntercepting {
|
||
stopInterception()
|
||
}
|
||
|
||
logger.log("Starting volume key interception")
|
||
self.webView = webView
|
||
isIntercepting = true
|
||
|
||
audioSession = AVAudioSession.sharedInstance()
|
||
do {
|
||
try audioSession?.setCategory(.playback, mode: .default, options: [.mixWithOthers])
|
||
try audioSession?.setActive(true)
|
||
} catch {
|
||
logger.error("Failed to activate audio session: \(error)")
|
||
}
|
||
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||
guard let self = self else { return }
|
||
self.originalVolume = self.audioSession?.outputVolume ?? 0.1
|
||
if self.originalVolume > 0.9 {
|
||
self.referenceVolume = 0.9
|
||
} else if self.originalVolume < 0.1 {
|
||
self.referenceVolume = 0.1
|
||
} else {
|
||
self.referenceVolume = self.originalVolume
|
||
}
|
||
logger.log("Reference volume set to \(self.referenceVolume)")
|
||
self.previousVolume = self.referenceVolume
|
||
self.setSessionVolume(self.referenceVolume)
|
||
self.setupHiddenVolumeView()
|
||
self.audioSession?.addObserver(
|
||
self, forKeyPath: "outputVolume", options: [.new], context: nil)
|
||
}
|
||
|
||
audioSession?.addObserver(self, forKeyPath: "outputVolume", options: [.new], context: nil)
|
||
}
|
||
|
||
func stopInterception() {
|
||
if !isIntercepting {
|
||
return
|
||
}
|
||
|
||
logger.log("Stopping volume key interception")
|
||
isIntercepting = false
|
||
audioSession?.removeObserver(self, forKeyPath: "outputVolume")
|
||
DispatchQueue.main.async { [weak self] in
|
||
self?.setSessionVolume(self?.originalVolume ?? 0.1)
|
||
self?.volumeView?.removeFromSuperview()
|
||
self?.volumeView = nil
|
||
self?.volumeSlider = nil
|
||
}
|
||
}
|
||
|
||
private func setSessionVolume(_ volume: Float) {
|
||
DispatchQueue.main.async { [weak self] in
|
||
self?.volumeSlider?.value = volume
|
||
}
|
||
}
|
||
|
||
private func setupHiddenVolumeView() {
|
||
assert(Thread.isMainThread, "setupHiddenVolumeView must be called on main thread")
|
||
let frame = CGRect(x: -1000, y: -1000, width: 1, height: 1)
|
||
volumeView = MPVolumeView(frame: frame)
|
||
volumeSlider = volumeView?.subviews.first(where: { $0 is UISlider }) as? UISlider
|
||
if let window = UIApplication.shared.windows.first {
|
||
window.addSubview(volumeView!)
|
||
}
|
||
}
|
||
|
||
override func observeValue(
|
||
forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?,
|
||
context: UnsafeMutableRawPointer?
|
||
) {
|
||
if keyPath == "outputVolume", let audioSession = self.audioSession, isIntercepting {
|
||
let currentVolume = audioSession.outputVolume
|
||
if currentVolume > self.previousVolume {
|
||
DispatchQueue.main.async { [weak self] in
|
||
self?.webView?.evaluateJavaScript(
|
||
"window.onNativeKeyDown('VolumeUp');", completionHandler: nil)
|
||
}
|
||
} else if currentVolume < self.previousVolume {
|
||
DispatchQueue.main.async { [weak self] in
|
||
self?.webView?.evaluateJavaScript(
|
||
"window.onNativeKeyDown('VolumeDown');", completionHandler: nil)
|
||
}
|
||
}
|
||
self.previousVolume = currentVolume
|
||
self.setSessionVolume(self.referenceVolume)
|
||
}
|
||
}
|
||
}
|
||
|
||
class MediaKeyHandler {
|
||
private weak var webView: WKWebView?
|
||
private var registered = false
|
||
private let commandCenter = MPRemoteCommandCenter.shared()
|
||
|
||
func start(webView: WKWebView) {
|
||
self.webView = webView
|
||
if registered { return }
|
||
registered = true
|
||
commandCenter.nextTrackCommand.isEnabled = true
|
||
commandCenter.previousTrackCommand.isEnabled = true
|
||
commandCenter.nextTrackCommand.addTarget { [weak self] _ in
|
||
self?.forward("MediaNext")
|
||
return .success
|
||
}
|
||
commandCenter.previousTrackCommand.addTarget { [weak self] _ in
|
||
self?.forward("MediaPrevious")
|
||
return .success
|
||
}
|
||
logger.log("MediaKeyHandler: started")
|
||
}
|
||
|
||
func stop() {
|
||
if !registered { return }
|
||
registered = false
|
||
commandCenter.nextTrackCommand.removeTarget(nil)
|
||
commandCenter.previousTrackCommand.removeTarget(nil)
|
||
logger.log("MediaKeyHandler: stopped")
|
||
}
|
||
|
||
private func forward(_ name: String) {
|
||
DispatchQueue.main.async { [weak self] in
|
||
self?.webView?.evaluateJavaScript(
|
||
"try { window.onNativeKeyDown('\(name)', 0); } catch (_) {}", completionHandler: nil)
|
||
}
|
||
}
|
||
}
|
||
|
||
class WebViewLifecycleManager: NSObject {
|
||
private weak var webView: WKWebView?
|
||
private var originalNavigationDelegate: WKNavigationDelegate?
|
||
private var isMonitoring = false
|
||
private var lastBackgroundTime: Date?
|
||
private var backgroundTimeThreshold: TimeInterval = 180.0
|
||
|
||
func startMonitoring(webView: WKWebView) {
|
||
self.webView = webView
|
||
originalNavigationDelegate = webView.navigationDelegate
|
||
webView.navigationDelegate = self
|
||
isMonitoring = true
|
||
logger.log("WebViewLifecycleManager: Started monitoring WebView")
|
||
}
|
||
|
||
func stopMonitoring() {
|
||
isMonitoring = false
|
||
if let original = originalNavigationDelegate {
|
||
webView?.navigationDelegate = original
|
||
}
|
||
|
||
logger.log("WebViewLifecycleManager: Stopped monitoring WebView")
|
||
}
|
||
|
||
func handleAppWillEnterForeground() {
|
||
guard isMonitoring, let webView = webView else {
|
||
logger.warning(
|
||
"WebViewLifecycleManager: Cannot handle foreground - not monitoring or webView is nil")
|
||
return
|
||
}
|
||
|
||
logger.log("WebViewLifecycleManager: App entering foreground")
|
||
|
||
var timeInBackground: TimeInterval = 0
|
||
if let backgroundTime = lastBackgroundTime {
|
||
timeInBackground = Date().timeIntervalSince(backgroundTime)
|
||
logger.log("WebViewLifecycleManager: Time in background: \(timeInBackground)s")
|
||
}
|
||
|
||
// If app was backgrounded for more than threshold, check WebView health
|
||
if timeInBackground > backgroundTimeThreshold {
|
||
logger.log(
|
||
"WebViewLifecycleManager: App was backgrounded for \(timeInBackground)s, checking WebView health..."
|
||
)
|
||
checkAndRecoverWebView(webView, reason: "long_background")
|
||
} else {
|
||
// Still do a quick check after a small delay
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
||
self?.quickHealthCheck(webView)
|
||
}
|
||
}
|
||
|
||
lastBackgroundTime = nil
|
||
}
|
||
|
||
func handleAppWillResignActive() {
|
||
logger.log("WebViewLifecycleManager: App will resign active")
|
||
guard let webView = webView else { return }
|
||
webView.evaluateJavaScript("window.location.href") { result, error in
|
||
if let error = error {
|
||
logger.error("WebViewLifecycleManager: Failed to capture URL on background: \(error)")
|
||
return
|
||
}
|
||
|
||
if let urlString = result as? String {
|
||
if urlString.hasPrefix("http") || urlString.hasPrefix("tauri") {
|
||
UserDefaults.standard.set(urlString, forKey: "tauri_last_valid_url")
|
||
logger.log("WebViewLifecycleManager: Saved valid URL")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func handleAppDidEnterBackground() {
|
||
lastBackgroundTime = Date()
|
||
}
|
||
|
||
private func quickHealthCheck(_ webView: WKWebView) {
|
||
logger.log("WebViewLifecycleManager: Performing quick health check")
|
||
|
||
webView.evaluateJavaScript("window.location.href") { [weak self] result, error in
|
||
if let error = error {
|
||
logger.error("WebViewLifecycleManager: Quick health check failed: \(error)")
|
||
self?.checkAndRecoverWebView(webView, reason: "health_check_failed")
|
||
} else if let urlString = result as? String {
|
||
if urlString.contains("about:blank") || urlString.isEmpty {
|
||
logger.warning("WebViewLifecycleManager: WebView showing about:blank!")
|
||
self?.recoverWebView(webView, reason: "about_blank")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func checkAndRecoverWebView(_ webView: WKWebView, reason: String) {
|
||
logger.log("WebViewLifecycleManager: Checking WebView health (reason: \(reason))")
|
||
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||
webView.evaluateJavaScript("window.location.href") { result, error in
|
||
if let error = error {
|
||
logger.error("WebViewLifecycleManager: Error checking WebView URL: \(error)")
|
||
self?.recoverWebView(webView, reason: "js_error_\(reason)")
|
||
} else if let urlString = result as? String {
|
||
logger.log("WebViewLifecycleManager: Current URL after \(reason): \(urlString)")
|
||
if urlString.contains("about:blank") || urlString.isEmpty {
|
||
logger.warning("WebViewLifecycleManager: Detected blank WebView after \(reason)")
|
||
self?.recoverWebView(webView, reason: reason)
|
||
} else {
|
||
logger.log("WebViewLifecycleManager: WebView appears healthy")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func recoverWebView(_ webView: WKWebView, reason: String) {
|
||
logger.log("WebViewLifecycleManager: Recovering WebView (reason: \(reason))")
|
||
|
||
if let lastURL = UserDefaults.standard.string(forKey: "tauri_last_valid_url"),
|
||
let url = URL(string: lastURL)
|
||
{
|
||
logger.log("WebViewLifecycleManager: Reloading from saved URL: \(lastURL)")
|
||
webView.load(URLRequest(url: url))
|
||
} else {
|
||
logger.log("WebViewLifecycleManager: No saved URL, performing standard reload")
|
||
webView.reload()
|
||
}
|
||
}
|
||
}
|
||
|
||
extension WebViewLifecycleManager: WKNavigationDelegate {
|
||
|
||
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
||
logger.error("WebViewLifecycleManager: WebContent process TERMINATED!️")
|
||
recoverWebView(webView, reason: "process_terminated")
|
||
|
||
if let original = originalNavigationDelegate,
|
||
original.responds(to: #selector(webViewWebContentProcessDidTerminate(_:)))
|
||
{
|
||
original.webViewWebContentProcessDidTerminate?(webView)
|
||
}
|
||
}
|
||
|
||
// Save successful navigation URLs
|
||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||
if let url = webView.url {
|
||
let urlString = url.absoluteString
|
||
|
||
if urlString.hasPrefix("http") || urlString.hasPrefix("tauri") {
|
||
UserDefaults.standard.set(urlString, forKey: "tauri_last_valid_url")
|
||
logger.log("WebViewLifecycleManager: Saved valid URL")
|
||
}
|
||
}
|
||
|
||
if let original = originalNavigationDelegate,
|
||
original.responds(to: #selector(webView(_:didFinish:)))
|
||
{
|
||
original.webView?(webView, didFinish: navigation)
|
||
}
|
||
}
|
||
|
||
// Proxy other important navigation delegate methods
|
||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||
logger.error("WebViewLifecycleManager: Navigation failed: \(error)")
|
||
|
||
if let original = originalNavigationDelegate,
|
||
original.responds(to: #selector(webView(_:didFail:withError:)))
|
||
{
|
||
original.webView?(webView, didFail: navigation, withError: error)
|
||
}
|
||
}
|
||
|
||
func webView(
|
||
_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!,
|
||
withError error: Error
|
||
) {
|
||
logger.error("WebViewLifecycleManager: Provisional navigation failed: \(error)")
|
||
|
||
if let original = originalNavigationDelegate,
|
||
original.responds(to: #selector(webView(_:didFailProvisionalNavigation:withError:)))
|
||
{
|
||
original.webView?(webView, didFailProvisionalNavigation: navigation, withError: error)
|
||
}
|
||
}
|
||
|
||
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||
if let original = originalNavigationDelegate,
|
||
original.responds(to: #selector(webView(_:didStartProvisionalNavigation:)))
|
||
{
|
||
original.webView?(webView, didStartProvisionalNavigation: navigation)
|
||
}
|
||
}
|
||
|
||
func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
|
||
if let original = originalNavigationDelegate,
|
||
original.responds(to: #selector(webView(_:didCommit:)))
|
||
{
|
||
original.webView?(webView, didCommit: navigation)
|
||
}
|
||
}
|
||
|
||
func webView(
|
||
_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction,
|
||
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
|
||
) {
|
||
if let original = originalNavigationDelegate {
|
||
original.webView?(
|
||
webView, decidePolicyFor: navigationAction, decisionHandler: decisionHandler)
|
||
} else {
|
||
decisionHandler(.allow)
|
||
}
|
||
}
|
||
|
||
func webView(
|
||
_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse,
|
||
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void
|
||
) {
|
||
if let original = originalNavigationDelegate {
|
||
original.webView?(
|
||
webView, decidePolicyFor: navigationResponse, decisionHandler: decisionHandler)
|
||
} else {
|
||
decisionHandler(.allow)
|
||
}
|
||
}
|
||
}
|
||
|
||
class NativeBridgePlugin: Plugin {
|
||
private var webView: WKWebView?
|
||
private var authSession: ASWebAuthenticationSession?
|
||
private var currentOrientationMask: UIInterfaceOrientationMask = .all
|
||
private var originalDelegate: UIApplicationDelegate?
|
||
private var webViewLifecycleManager: WebViewLifecycleManager?
|
||
private var traitChangeRegistered = false
|
||
|
||
// Screen-brightness management. `UIScreen.main.brightness` is a *global*
|
||
// device setting, not a per-window one: once the app writes to it, iOS
|
||
// suppresses ambient auto-brightness and the override survives backgrounding,
|
||
// leaving the system stuck at the app's level until the user nudges it
|
||
// manually (issue #4885). We remember the value that was there before the
|
||
// first override so we can hand it back whenever the app leaves the
|
||
// foreground, and re-assert the app's value when it returns.
|
||
private var appDesiredBrightness: CGFloat?
|
||
private var systemBrightnessBeforeOverride: CGFloat?
|
||
|
||
@objc public override func load(webview: WKWebView) {
|
||
self.webView = webview
|
||
logger.log("NativeBridgePlugin loaded")
|
||
|
||
// Suppress the iOS system text-selection edit menu so it never
|
||
// covers Readest's annotation toolbar. See ContextMenuSuppressor.
|
||
ContextMenuSuppressor.installIfNeeded()
|
||
|
||
// Register a WKScriptMessageHandler so JS can signal when its
|
||
// share-extension hook has mounted. On `{type: 'ready'}` we run a
|
||
// sync immediately, which is the cold-start path (app launched
|
||
// because the Share Extension just woke it up, JS may not have been
|
||
// listening when the first `appDidBecomeActive` fired).
|
||
webview.configuration.userContentController.add(
|
||
ShareBridgeMessageHandler(owner: self), name: "readestShareBridge")
|
||
|
||
webViewLifecycleManager = WebViewLifecycleManager()
|
||
webViewLifecycleManager?.startMonitoring(webView: webview)
|
||
logger.log("NativeBridgePlugin: WebView lifecycle monitoring activated")
|
||
|
||
// The WKWebView never fires the `prefers-color-scheme` media query
|
||
// `change` event while the app stays foregrounded, so observe the
|
||
// native appearance and push changes to JS instead. Registration is
|
||
// deferred because the window scene may not be connected yet.
|
||
DispatchQueue.main.async { [weak self] in
|
||
self?.registerTraitChangeObserverIfNeeded()
|
||
}
|
||
|
||
NotificationCenter.default.addObserver(
|
||
self,
|
||
selector: #selector(appDidBecomeActive),
|
||
name: UIApplication.didBecomeActiveNotification,
|
||
object: nil
|
||
)
|
||
|
||
NotificationCenter.default.addObserver(
|
||
self,
|
||
selector: #selector(appDidEnterBackground),
|
||
name: UIApplication.didEnterBackgroundNotification,
|
||
object: nil
|
||
)
|
||
|
||
NotificationCenter.default.addObserver(
|
||
self,
|
||
selector: #selector(appWillEnterForeground),
|
||
name: UIApplication.willEnterForegroundNotification,
|
||
object: nil
|
||
)
|
||
|
||
if let app = UIApplication.value(forKey: "sharedApplication") as? UIApplication {
|
||
self.originalDelegate = app.delegate
|
||
app.delegate = self
|
||
} else {
|
||
Logger.error("NativeBridgePlugin: Failed to get shared application")
|
||
}
|
||
|
||
// Re-acquire security-scoped access for every external library
|
||
// folder the user registered in a previous session. Done here in
|
||
// `load` so the WebView can issue fs reads against those paths as
|
||
// soon as the JS layer comes up. See `FolderBookmarkStore`
|
||
// for the why and how.
|
||
FolderBookmarkStore.restoreAllPersisted()
|
||
}
|
||
|
||
@objc func appWillEnterForeground() {
|
||
logger.log("NativeBridgePlugin: App will enter foreground")
|
||
// Re-assert the app's brightness that was released on background (#4885).
|
||
if let desired = appDesiredBrightness {
|
||
UIScreen.main.brightness = desired
|
||
}
|
||
webViewLifecycleManager?.handleAppWillEnterForeground()
|
||
}
|
||
|
||
@objc func appDidBecomeActive() {
|
||
if volumeKeyHandler != nil {
|
||
activateVolumeKeyInterception()
|
||
}
|
||
registerTraitChangeObserverIfNeeded()
|
||
// Fallback for iOS < 17 (no `registerForTraitChanges`): re-check the
|
||
// appearance whenever the app becomes active, e.g. after toggling
|
||
// dark mode from Control Center.
|
||
notifyColorSchemeChange()
|
||
syncShareExtensionState()
|
||
}
|
||
|
||
/// JS-initiated entry point. The share-extension JS hook calls
|
||
/// `window.webkit.messageHandlers.readestShareBridge.postMessage({type:'ready'})`
|
||
/// once mounted, which routes here so the cold-start drain happens
|
||
/// even when the JS hook wasn't listening at app launch.
|
||
@objc func syncShareExtensionStateFromJS() {
|
||
syncShareExtensionState()
|
||
}
|
||
|
||
/// Bridge between the Readest Share Extension (separate process) and
|
||
/// the host app's JS, via the App Group container at
|
||
/// `group.com.bilingify.readest`. Two directions on every activation:
|
||
///
|
||
/// 1. Groups (host → extension). Read the current library group list
|
||
/// from JS (`window.__readestGetGroups`) and persist it so the
|
||
/// extension's picker shows up-to-date options next time the user
|
||
/// shares. If the JS function isn't installed yet (cold start, hook
|
||
/// hasn't mounted), no-op — the next activation will refresh.
|
||
///
|
||
/// 2. Pending saves (extension → host). Drain any queued saves the
|
||
/// extension wrote, hand them to JS
|
||
/// (`window.__readestOnShareExtensionPending`), then clear the
|
||
/// queue only if JS confirmed receipt. If JS isn't ready yet,
|
||
/// leave the queue intact — the next activation (or the JS hook
|
||
/// on its own mount) will pick them up.
|
||
private func syncShareExtensionState() {
|
||
DispatchQueue.main.async { [weak self] in
|
||
guard let self = self, let webView = self.webView else { return }
|
||
// Pull groups + the user-locale "Default" label from JS → App Group.
|
||
webView.evaluateJavaScript(
|
||
"(window.__readestGetGroups && window.__readestGetGroups()) || null"
|
||
) { result, _ in
|
||
guard let payload = result as? [String: Any] else { return }
|
||
if let array = payload["groups"] as? [[String: Any]] {
|
||
let groups: [AppGroupBridge.LibraryGroup] = array.compactMap { item in
|
||
guard let id = item["id"] as? String, let name = item["name"] as? String else {
|
||
return nil
|
||
}
|
||
return AppGroupBridge.LibraryGroup(id: id, name: name)
|
||
}
|
||
AppGroupBridge.writeGroups(groups)
|
||
}
|
||
if let defaultName = payload["defaultGroupName"] as? String, !defaultName.isEmpty {
|
||
AppGroupBridge.writeDefaultGroupName(defaultName)
|
||
}
|
||
}
|
||
// Push pending saves → JS, clear queue iff JS confirmed.
|
||
let saves = AppGroupBridge.readPendingSaves()
|
||
guard !saves.isEmpty else { return }
|
||
let payload: [[String: Any?]] = saves.map { save in
|
||
[
|
||
"url": save.url,
|
||
"groupId": save.groupId,
|
||
"groupName": save.groupName,
|
||
"addedAt": save.addedAt,
|
||
]
|
||
}
|
||
guard let json = try? JSONSerialization.data(withJSONObject: payload, options: []),
|
||
let jsonString = String(data: json, encoding: .utf8)
|
||
else { return }
|
||
let script =
|
||
"(window.__readestOnShareExtensionPending && window.__readestOnShareExtensionPending(\(jsonString))) === true"
|
||
webView.evaluateJavaScript(script) { result, _ in
|
||
if let acknowledged = result as? Bool, acknowledged {
|
||
AppGroupBridge.clearPendingSaves()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Resolves the foreground window scene. Its trait collection reflects
|
||
// the real system appearance and is unaffected by the per-window
|
||
// `overrideUserInterfaceStyle` that `set_system_ui_visibility` applies.
|
||
private func foregroundWindowScene() -> UIWindowScene? {
|
||
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
|
||
return scenes.first { $0.activationState == .foregroundActive } ?? scenes.first
|
||
}
|
||
|
||
private func systemColorScheme() -> String {
|
||
let userInterfaceStyle =
|
||
foregroundWindowScene()?.traitCollection.userInterfaceStyle
|
||
?? UITraitCollection.current.userInterfaceStyle
|
||
return (userInterfaceStyle == .dark) ? "dark" : "light"
|
||
}
|
||
|
||
private func registerTraitChangeObserverIfNeeded() {
|
||
guard !traitChangeRegistered, #available(iOS 17.0, *) else { return }
|
||
guard let windowScene = foregroundWindowScene() else { return }
|
||
traitChangeRegistered = true
|
||
MainActor.assumeIsolated {
|
||
windowScene.registerForTraitChanges([UITraitUserInterfaceStyle.self]) {
|
||
[weak self] (_: UIWindowScene, _: UITraitCollection) in
|
||
self?.notifyColorSchemeChange()
|
||
}
|
||
}
|
||
}
|
||
|
||
private func notifyColorSchemeChange() {
|
||
DispatchQueue.main.async { [weak self] in
|
||
guard let self = self, let webView = self.webView else { return }
|
||
let colorScheme = self.systemColorScheme()
|
||
webView.evaluateJavaScript(
|
||
"try { window.onNativeColorSchemeChange && window.onNativeColorSchemeChange('\(colorScheme)'); } catch (_) {}",
|
||
completionHandler: nil)
|
||
}
|
||
}
|
||
|
||
@objc func appDidEnterBackground() {
|
||
logger.log("NativeBridgePlugin: App did enter background")
|
||
if let handler = volumeKeyHandler, handler.isIntercepting {
|
||
handler.stopInterception()
|
||
}
|
||
// Hand screen brightness back to iOS so ambient auto-brightness resumes
|
||
// while backgrounded; the override is re-applied on foreground (#4885).
|
||
if appDesiredBrightness != nil, let original = systemBrightnessBeforeOverride {
|
||
UIScreen.main.brightness = original
|
||
}
|
||
webViewLifecycleManager?.handleAppDidEnterBackground()
|
||
}
|
||
|
||
func activateVolumeKeyInterception() {
|
||
if volumeKeyHandler == nil {
|
||
volumeKeyHandler = VolumeKeyHandler()
|
||
}
|
||
|
||
if let webView = self.webView {
|
||
volumeKeyHandler?.stopInterception()
|
||
volumeKeyHandler?.startInterception(webView: webView)
|
||
} else {
|
||
logger.warning("Cannot activate volume key interception: webView is nil")
|
||
}
|
||
}
|
||
|
||
deinit {
|
||
webViewLifecycleManager?.stopMonitoring()
|
||
webViewLifecycleManager = nil
|
||
|
||
NotificationCenter.default.removeObserver(self)
|
||
}
|
||
|
||
private struct AssociatedKeys {
|
||
static var volumeKeyHandler = "volumeKeyHandler"
|
||
static var interceptingVolumeKeys = "interceptingVolumeKeys"
|
||
static var mediaKeyHandler = "mediaKeyHandler"
|
||
static var mediaKeyState = "mediaKeyState"
|
||
}
|
||
|
||
private var volumeKeyHandler: VolumeKeyHandler? {
|
||
get {
|
||
return objc_getAssociatedObject(self, &AssociatedKeys.volumeKeyHandler) as? VolumeKeyHandler
|
||
}
|
||
set {
|
||
objc_setAssociatedObject(
|
||
self, &AssociatedKeys.volumeKeyHandler, newValue, .OBJC_ASSOCIATION_RETAIN)
|
||
}
|
||
}
|
||
|
||
private var interceptingVolumeKeys: Bool {
|
||
get {
|
||
return objc_getAssociatedObject(self, &AssociatedKeys.interceptingVolumeKeys) as? Bool
|
||
?? false
|
||
}
|
||
set {
|
||
objc_setAssociatedObject(
|
||
self, &AssociatedKeys.interceptingVolumeKeys, newValue, .OBJC_ASSOCIATION_RETAIN)
|
||
}
|
||
}
|
||
|
||
private var mediaKeyHandler: MediaKeyHandler? {
|
||
get {
|
||
return objc_getAssociatedObject(self, &AssociatedKeys.mediaKeyHandler) as? MediaKeyHandler
|
||
}
|
||
set {
|
||
objc_setAssociatedObject(
|
||
self, &AssociatedKeys.mediaKeyHandler, newValue, .OBJC_ASSOCIATION_RETAIN)
|
||
}
|
||
}
|
||
|
||
// Bit 0 = pageTurnerKeys interception, bit 1 = learn mode.
|
||
private var mediaKeyState: Int {
|
||
get {
|
||
return objc_getAssociatedObject(self, &AssociatedKeys.mediaKeyState) as? Int ?? 0
|
||
}
|
||
set {
|
||
objc_setAssociatedObject(
|
||
self, &AssociatedKeys.mediaKeyState, newValue, .OBJC_ASSOCIATION_RETAIN)
|
||
}
|
||
}
|
||
|
||
private func updateMediaKeyHandler() {
|
||
let shouldRun = mediaKeyState != 0
|
||
if shouldRun {
|
||
if mediaKeyHandler == nil {
|
||
mediaKeyHandler = MediaKeyHandler()
|
||
}
|
||
if let webView = self.webView {
|
||
mediaKeyHandler?.start(webView: webView)
|
||
} else {
|
||
logger.warning("Cannot start media key handler: webView is nil")
|
||
}
|
||
} else {
|
||
mediaKeyHandler?.stop()
|
||
mediaKeyHandler = nil
|
||
}
|
||
}
|
||
|
||
@objc public func use_background_audio(_ invoke: Invoke) {
|
||
do {
|
||
let args = try invoke.parseArgs(UseBackgroundAudioRequestArgs.self)
|
||
let enabled = args.enabled
|
||
let session = AVAudioSession.sharedInstance()
|
||
if enabled {
|
||
try session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
|
||
try session.setActive(true)
|
||
logger.log("AVAudioSession activated")
|
||
} else {
|
||
try session.setActive(false)
|
||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
||
logger.log("AVAudioSession deactivated")
|
||
}
|
||
invoke.resolve()
|
||
} catch {
|
||
logger.error("Failed to set up audio session: \(error)")
|
||
}
|
||
}
|
||
|
||
@objc public func auth_with_safari(_ invoke: Invoke) throws {
|
||
let args = try invoke.parseArgs(SafariAuthRequestArgs.self)
|
||
let authUrl = URL(string: args.authUrl)!
|
||
let callbackScheme = args.callbackScheme ?? "readest"
|
||
|
||
authSession = ASWebAuthenticationSession(url: authUrl, callbackURLScheme: callbackScheme) {
|
||
[weak self] callbackURL, error in
|
||
guard let strongSelf = self else { return }
|
||
|
||
if let error = error {
|
||
logger.error("Auth session error: \(error.localizedDescription)")
|
||
invoke.reject(error.localizedDescription)
|
||
return
|
||
}
|
||
|
||
if let callbackURL = callbackURL {
|
||
strongSelf.authSession?.cancel()
|
||
strongSelf.authSession = nil
|
||
invoke.resolve(["redirectUrl": callbackURL.absoluteString])
|
||
}
|
||
}
|
||
|
||
if #available(iOS 13.0, *) {
|
||
authSession?.presentationContextProvider = self
|
||
}
|
||
|
||
let started = authSession?.start() ?? false
|
||
logger.log("Auth session start result: \(started)")
|
||
}
|
||
|
||
@objc public func set_system_ui_visibility(_ invoke: Invoke) throws {
|
||
let args = try invoke.parseArgs(SetSystemUIVisibilityRequestArgs.self)
|
||
let visible = args.visible
|
||
let darkMode = args.darkMode
|
||
|
||
DispatchQueue.main.async {
|
||
UIApplication.shared.setStatusBarHidden(!visible, with: .none)
|
||
|
||
let windows = UIApplication.shared.connectedScenes
|
||
.compactMap { $0 as? UIWindowScene }
|
||
.flatMap { $0.windows }
|
||
|
||
let keyWindow = windows.first(where: { $0.isKeyWindow }) ?? windows.first
|
||
if let keyWindow = keyWindow {
|
||
keyWindow.overrideUserInterfaceStyle = darkMode ? .dark : .light
|
||
keyWindow.layoutIfNeeded()
|
||
} else {
|
||
logger.error("No key window found")
|
||
}
|
||
}
|
||
invoke.resolve(["success": true])
|
||
}
|
||
|
||
@objc public func get_sys_fonts_list(_ invoke: Invoke) throws {
|
||
var fontDict: [String: String] = [:]
|
||
|
||
for family in UIFont.familyNames.sorted() {
|
||
if let localized = getLocalizedDisplayName(familyName: family) {
|
||
fontDict[family] = localized
|
||
} else {
|
||
let fontNames = UIFont.fontNames(forFamilyName: family)
|
||
if fontNames.isEmpty {
|
||
fontDict[family] = family
|
||
} else {
|
||
for fontName in fontNames {
|
||
fontDict[fontName] = family
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
invoke.resolve(["fonts": fontDict])
|
||
}
|
||
|
||
@objc public func intercept_keys(_ invoke: Invoke) {
|
||
do {
|
||
let args = try invoke.parseArgs(InterceptKeysRequestArgs.self)
|
||
|
||
if let volumeKeys = args.volumeKeys {
|
||
if volumeKeys {
|
||
self.activateVolumeKeyInterception()
|
||
} else {
|
||
self.volumeKeyHandler?.stopInterception()
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||
self?.volumeKeyHandler = nil
|
||
}
|
||
}
|
||
}
|
||
|
||
if let pageTurnerKeys = args.pageTurnerKeys {
|
||
mediaKeyState = pageTurnerKeys ? (mediaKeyState | 1) : (mediaKeyState & ~1)
|
||
}
|
||
if let learnMode = args.learnMode {
|
||
mediaKeyState = learnMode ? (mediaKeyState | 2) : (mediaKeyState & ~2)
|
||
}
|
||
if args.pageTurnerKeys != nil || args.learnMode != nil {
|
||
DispatchQueue.main.async { [weak self] in
|
||
self?.updateMediaKeyHandler()
|
||
}
|
||
}
|
||
|
||
invoke.resolve()
|
||
} catch {
|
||
invoke.reject(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
@objc public func lock_screen_orientation(_ invoke: Invoke) throws {
|
||
guard let args = try? invoke.parseArgs(LockScreenOrientationRequestArgs.self) else {
|
||
return invoke.reject("Invalid arguments")
|
||
}
|
||
|
||
DispatchQueue.main.async {
|
||
UIDevice.current.beginGeneratingDeviceOrientationNotifications()
|
||
let orientation = args.orientation ?? "auto"
|
||
switch orientation.lowercased() {
|
||
case "portrait":
|
||
self.changeOrientation(.portrait)
|
||
case "landscape":
|
||
self.changeOrientation(.landscape)
|
||
case "auto":
|
||
self.changeOrientation(.all)
|
||
default:
|
||
invoke.reject("Invalid orientation mode")
|
||
return
|
||
}
|
||
|
||
invoke.resolve()
|
||
}
|
||
}
|
||
|
||
private func changeOrientation(_ orientationMask: UIInterfaceOrientationMask) {
|
||
self.currentOrientationMask = orientationMask
|
||
if #available(iOS 16.0, *) {
|
||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
|
||
for window in windowScene.windows {
|
||
if let rootVC = window.rootViewController {
|
||
rootVC.setNeedsUpdateOfSupportedInterfaceOrientations()
|
||
}
|
||
}
|
||
if orientationMask == .all {
|
||
windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: .all)) { error in
|
||
logger.error("Orientation update error: \(error.localizedDescription)")
|
||
DispatchQueue.main.async {
|
||
UIViewController.attemptRotationToDeviceOrientation()
|
||
}
|
||
}
|
||
} else {
|
||
windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: orientationMask)) { error in
|
||
logger.error("Orientation update error: \(error.localizedDescription)")
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
if orientationMask == .all {
|
||
UIViewController.attemptRotationToDeviceOrientation()
|
||
} else {
|
||
let specificOrientation: UIInterfaceOrientation
|
||
if orientationMask.contains(.portrait) {
|
||
specificOrientation = .portrait
|
||
} else if orientationMask.contains(.landscape) {
|
||
let currentOrientation = UIDevice.current.orientation
|
||
if currentOrientation == .landscapeLeft {
|
||
specificOrientation = .landscapeRight
|
||
} else if currentOrientation == .landscapeRight {
|
||
specificOrientation = .landscapeLeft
|
||
} else {
|
||
specificOrientation = .landscapeRight
|
||
}
|
||
} else {
|
||
specificOrientation = .portrait
|
||
}
|
||
UIDevice.current.setValue(specificOrientation.rawValue, forKey: "orientation")
|
||
UIViewController.attemptRotationToDeviceOrientation()
|
||
}
|
||
}
|
||
}
|
||
|
||
@objc public func iap_is_available(_ invoke: Invoke) {
|
||
invoke.resolve(["available": true])
|
||
}
|
||
|
||
@objc public func iap_initialize(_ invoke: Invoke) {
|
||
StoreKitManager.shared.initialize()
|
||
invoke.resolve(["success": true])
|
||
}
|
||
|
||
@objc public func iap_fetch_products(_ invoke: Invoke) {
|
||
do {
|
||
let args = try invoke.parseArgs(FetchProductsRequest.self)
|
||
|
||
StoreKitManager.shared.fetchProducts(productIds: args.productIds) { products in
|
||
let productsData: [ProductData] = products.map { product in
|
||
return ProductData(
|
||
id: product.productIdentifier,
|
||
title: product.localizedTitle,
|
||
description: product.localizedDescription,
|
||
price: product.price.stringValue,
|
||
priceCurrencyCode: product.priceLocale.currencyCode,
|
||
priceAmountMicros: Int64(product.price.doubleValue * 1_000_000),
|
||
productType: product.productIdentifier.contains("monthly")
|
||
|| product.productIdentifier.contains("yearly") ? "subscription" : "consumable"
|
||
)
|
||
}
|
||
invoke.resolve(["products": productsData])
|
||
}
|
||
} catch {
|
||
invoke.reject("Failed to parse fetch products arguments: \(error.localizedDescription)")
|
||
}
|
||
}
|
||
|
||
@objc public func iap_purchase_product(_ invoke: Invoke) {
|
||
do {
|
||
let args = try invoke.parseArgs(PurchaseProductRequest.self)
|
||
|
||
StoreKitManager.shared.fetchProducts(productIds: [args.productId]) { products in
|
||
guard let product = products.first else {
|
||
invoke.reject("Product not found")
|
||
return
|
||
}
|
||
|
||
StoreKitManager.shared.purchase(product: product) { result in
|
||
switch result {
|
||
case .success(let txn):
|
||
let purchase = PurchaseData(
|
||
productId: txn.payment.productIdentifier,
|
||
transactionId: txn.transactionIdentifier ?? "",
|
||
originalTransactionId: txn.original?.transactionIdentifier ?? txn
|
||
.transactionIdentifier ?? "",
|
||
purchaseDate: ISO8601DateFormatter().string(from: txn.transactionDate ?? Date()),
|
||
purchaseState: "purchased",
|
||
platform: "ios"
|
||
)
|
||
invoke.resolve(["purchase": purchase])
|
||
case .failure(let error):
|
||
invoke.reject("Purchase failed: \(error.localizedDescription)")
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
invoke.reject("Failed to parse purchase arguments: \(error.localizedDescription)")
|
||
}
|
||
}
|
||
|
||
@objc public func iap_restore_purchases(_ invoke: Invoke) {
|
||
StoreKitManager.shared.restorePurchases { transactions in
|
||
let restored = transactions.map { txn -> PurchaseData in
|
||
return PurchaseData(
|
||
productId: txn.payment.productIdentifier,
|
||
transactionId: txn.transactionIdentifier ?? "",
|
||
originalTransactionId: txn.original?.transactionIdentifier ?? txn.transactionIdentifier
|
||
?? "",
|
||
purchaseDate: ISO8601DateFormatter().string(from: txn.transactionDate ?? Date()),
|
||
purchaseState: "restored",
|
||
platform: "ios"
|
||
)
|
||
}
|
||
invoke.resolve(["purchases": restored])
|
||
}
|
||
}
|
||
|
||
@objc public func get_system_color_scheme(_ invoke: Invoke) {
|
||
DispatchQueue.main.async { [weak self] in
|
||
invoke.resolve(["colorScheme": self?.systemColorScheme() ?? "light"])
|
||
}
|
||
}
|
||
|
||
@objc public func get_screen_brightness(_ invoke: Invoke) {
|
||
let brightness = UIScreen.main.brightness
|
||
invoke.resolve(["brightness": brightness])
|
||
}
|
||
|
||
@objc public func set_screen_brightness(_ invoke: Invoke) {
|
||
guard let args = try? invoke.parseArgs(SetScreenBrightnessRequestArgs.self) else {
|
||
return invoke.reject("Failed to parse arguments")
|
||
}
|
||
|
||
let brightness = args.brightness ?? 0.5
|
||
|
||
if brightness > 1.0 {
|
||
return invoke.reject("Brightness must be between 0.0 and 1.0")
|
||
}
|
||
|
||
DispatchQueue.main.async { [weak self] in
|
||
guard let self = self else { return }
|
||
if brightness < 0.0 {
|
||
// A negative value means "release control back to the system", mirroring
|
||
// Android's BRIGHTNESS_OVERRIDE_NONE. Restore the pre-override brightness
|
||
// so iOS resumes ambient auto-brightness.
|
||
self.releaseBrightnessControl()
|
||
} else {
|
||
if self.systemBrightnessBeforeOverride == nil {
|
||
self.systemBrightnessBeforeOverride = UIScreen.main.brightness
|
||
}
|
||
self.appDesiredBrightness = CGFloat(brightness)
|
||
UIScreen.main.brightness = CGFloat(brightness)
|
||
}
|
||
}
|
||
invoke.resolve(["success": true])
|
||
}
|
||
|
||
/// Restore the brightness captured before the app first overrode it so iOS
|
||
/// resumes ambient auto-brightness, then forget our managed state. Must run
|
||
/// on the main thread.
|
||
private func releaseBrightnessControl() {
|
||
if let original = systemBrightnessBeforeOverride {
|
||
UIScreen.main.brightness = original
|
||
}
|
||
appDesiredBrightness = nil
|
||
systemBrightnessBeforeOverride = nil
|
||
}
|
||
|
||
@objc public func copy_uri_to_path(_ invoke: Invoke) {
|
||
guard let args = try? invoke.parseArgs(CopyUriToPathRequestArgs.self) else {
|
||
return invoke.reject("Failed to parse arguments")
|
||
}
|
||
|
||
guard let uriString = args.uri, let dstPath = args.dst else {
|
||
return invoke.reject("URI and destination path must be provided")
|
||
}
|
||
|
||
let uri: URL
|
||
if uriString.hasPrefix("file://") {
|
||
let path = String(uriString.dropFirst("file://".count))
|
||
guard let decodedPath = path.removingPercentEncoding else {
|
||
return invoke.reject("Invalid URI encoding")
|
||
}
|
||
uri = URL(fileURLWithPath: decodedPath)
|
||
} else {
|
||
guard let parsed = URL(string: uriString) else {
|
||
return invoke.reject("Invalid URI")
|
||
}
|
||
uri = parsed
|
||
}
|
||
|
||
let fileManager = FileManager.default
|
||
let dstURL = URL(fileURLWithPath: dstPath)
|
||
|
||
do {
|
||
let didStartAccessing = uri.startAccessingSecurityScopedResource()
|
||
defer {
|
||
if didStartAccessing {
|
||
uri.stopAccessingSecurityScopedResource()
|
||
}
|
||
}
|
||
|
||
var shouldCopy = false
|
||
|
||
if fileManager.fileExists(atPath: dstURL.path) {
|
||
let srcAttributes = try fileManager.attributesOfItem(atPath: uri.path)
|
||
let dstAttributes = try fileManager.attributesOfItem(atPath: dstURL.path)
|
||
|
||
let srcModDate = srcAttributes[.modificationDate] as? Date ?? Date.distantPast
|
||
let dstModDate = dstAttributes[.modificationDate] as? Date ?? Date.distantPast
|
||
|
||
if srcModDate > dstModDate {
|
||
try fileManager.removeItem(at: dstURL)
|
||
shouldCopy = true
|
||
} else {
|
||
shouldCopy = false
|
||
}
|
||
} else {
|
||
shouldCopy = true
|
||
}
|
||
|
||
if shouldCopy {
|
||
try fileManager.copyItem(at: uri, to: dstURL)
|
||
}
|
||
|
||
invoke.resolve(["success": true])
|
||
} catch {
|
||
invoke.reject("Failed to copy file: \(error.localizedDescription)")
|
||
}
|
||
}
|
||
|
||
@objc public func get_storefront_region_code(_ invoke: Invoke) {
|
||
Task {
|
||
if let storefront = await Storefront.current {
|
||
invoke.resolve(["regionCode": storefront.countryCode])
|
||
} else {
|
||
invoke.reject("Failed to get region code")
|
||
}
|
||
}
|
||
}
|
||
|
||
@objc public func get_safe_area_insets(_ invoke: Invoke) {
|
||
DispatchQueue.main.async {
|
||
if let window = UIApplication.shared.windows.first {
|
||
let insets = window.safeAreaInsets
|
||
invoke.resolve([
|
||
"top": insets.top,
|
||
"left": insets.left,
|
||
"bottom": insets.bottom,
|
||
"right": insets.right
|
||
])
|
||
} else {
|
||
invoke.resolve([
|
||
"error": "No window found",
|
||
"top": 0,
|
||
"left": 0,
|
||
"bottom": 0,
|
||
"right": 0
|
||
])
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Sync passphrase keychain ──────────────────────────────────────────
|
||
// Backed by the iOS Security framework Keychain. The TS-side
|
||
// CryptoSession reads/writes via these commands so the user's sync
|
||
// passphrase persists across app launches.
|
||
|
||
private static let syncKeychainService = "com.bilingify.readest.sync-passphrase"
|
||
private static let syncKeychainAccount = "default"
|
||
|
||
private func syncKeychainBaseQuery() -> [String: Any] {
|
||
return [
|
||
kSecClass as String: kSecClassGenericPassword,
|
||
kSecAttrService as String: NativeBridgePlugin.syncKeychainService,
|
||
kSecAttrAccount as String: NativeBridgePlugin.syncKeychainAccount
|
||
]
|
||
}
|
||
|
||
@objc public func set_sync_passphrase(_ invoke: Invoke) {
|
||
do {
|
||
let args = try invoke.parseArgs(SyncPassphraseSetArgs.self)
|
||
guard let data = args.passphrase.data(using: .utf8) else {
|
||
invoke.resolve(["success": false, "error": "encoding"])
|
||
return
|
||
}
|
||
var query = syncKeychainBaseQuery()
|
||
query[kSecValueData as String] = data
|
||
// Replace any existing entry. Delete-then-add keeps the
|
||
// accessibility class consistent across SDK versions.
|
||
SecItemDelete(query as CFDictionary)
|
||
let status = SecItemAdd(query as CFDictionary, nil)
|
||
if status == errSecSuccess {
|
||
invoke.resolve(["success": true])
|
||
} else {
|
||
invoke.resolve(["success": false, "error": "OSStatus \(status)"])
|
||
}
|
||
} catch {
|
||
invoke.resolve(["success": false, "error": "\(error)"])
|
||
}
|
||
}
|
||
|
||
@objc public func get_sync_passphrase(_ invoke: Invoke) {
|
||
var query = syncKeychainBaseQuery()
|
||
query[kSecReturnData as String] = true
|
||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||
var item: CFTypeRef?
|
||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||
if status == errSecSuccess, let data = item as? Data, let s = String(data: data, encoding: .utf8) {
|
||
invoke.resolve(["passphrase": s])
|
||
} else if status == errSecItemNotFound {
|
||
// No entry: empty response. The TS layer treats this as "prompt".
|
||
invoke.resolve([:])
|
||
} else {
|
||
invoke.resolve(["error": "OSStatus \(status)"])
|
||
}
|
||
}
|
||
|
||
@objc public func clear_sync_passphrase(_ invoke: Invoke) {
|
||
let status = SecItemDelete(syncKeychainBaseQuery() as CFDictionary)
|
||
if status == errSecSuccess || status == errSecItemNotFound {
|
||
invoke.resolve(["success": true])
|
||
} else {
|
||
invoke.resolve(["success": false, "error": "OSStatus \(status)"])
|
||
}
|
||
}
|
||
|
||
@objc public func is_sync_keychain_available(_ invoke: Invoke) {
|
||
// The Keychain is always available on iOS; report true and let the
|
||
// TS layer trust it. We probe SecItemCopyMatching anyway so a
|
||
// future sandbox restriction surfaces an explicit error.
|
||
var query = syncKeychainBaseQuery()
|
||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||
let status = SecItemCopyMatching(query as CFDictionary, nil)
|
||
if status == errSecSuccess || status == errSecItemNotFound {
|
||
invoke.resolve(["available": true])
|
||
} else {
|
||
invoke.resolve(["available": false, "error": "OSStatus \(status)"])
|
||
}
|
||
}
|
||
|
||
// ── Keyed secure key-value store ──────────────────────────────────
|
||
// Same Keychain backing as the sync passphrase, but a generic keyed
|
||
// store: one service, the caller's `key` as the account, so secrets
|
||
// like the Google Drive token set persist the same way.
|
||
|
||
private static let secureItemsService = "com.bilingify.readest.secure-items"
|
||
|
||
private func secureItemBaseQuery(_ key: String) -> [String: Any] {
|
||
return [
|
||
kSecClass as String: kSecClassGenericPassword,
|
||
kSecAttrService as String: NativeBridgePlugin.secureItemsService,
|
||
kSecAttrAccount as String: key
|
||
]
|
||
}
|
||
|
||
@objc public func set_secure_item(_ invoke: Invoke) {
|
||
do {
|
||
let args = try invoke.parseArgs(SecureItemSetArgs.self)
|
||
guard let data = args.value.data(using: .utf8) else {
|
||
invoke.resolve(["success": false, "error": "encoding"])
|
||
return
|
||
}
|
||
var query = secureItemBaseQuery(args.key)
|
||
query[kSecValueData as String] = data
|
||
// Replace any existing entry. Delete-then-add keeps the
|
||
// accessibility class consistent across SDK versions.
|
||
SecItemDelete(query as CFDictionary)
|
||
let status = SecItemAdd(query as CFDictionary, nil)
|
||
if status == errSecSuccess {
|
||
invoke.resolve(["success": true])
|
||
} else {
|
||
invoke.resolve(["success": false, "error": "OSStatus \(status)"])
|
||
}
|
||
} catch {
|
||
invoke.resolve(["success": false, "error": "\(error)"])
|
||
}
|
||
}
|
||
|
||
@objc public func get_secure_item(_ invoke: Invoke) {
|
||
do {
|
||
let args = try invoke.parseArgs(SecureItemGetArgs.self)
|
||
var query = secureItemBaseQuery(args.key)
|
||
query[kSecReturnData as String] = true
|
||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||
var item: CFTypeRef?
|
||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||
if status == errSecSuccess, let data = item as? Data, let s = String(data: data, encoding: .utf8) {
|
||
invoke.resolve(["value": s])
|
||
} else if status == errSecItemNotFound {
|
||
// No entry: empty response. The TS layer treats this as "not stored".
|
||
invoke.resolve([:])
|
||
} else {
|
||
invoke.resolve(["error": "OSStatus \(status)"])
|
||
}
|
||
} catch {
|
||
invoke.resolve(["error": "\(error)"])
|
||
}
|
||
}
|
||
|
||
@objc public func clear_secure_item(_ invoke: Invoke) {
|
||
do {
|
||
let args = try invoke.parseArgs(SecureItemGetArgs.self)
|
||
let status = SecItemDelete(secureItemBaseQuery(args.key) as CFDictionary)
|
||
if status == errSecSuccess || status == errSecItemNotFound {
|
||
invoke.resolve(["success": true])
|
||
} else {
|
||
invoke.resolve(["success": false, "error": "OSStatus \(status)"])
|
||
}
|
||
} catch {
|
||
invoke.resolve(["success": false, "error": "\(error)"])
|
||
}
|
||
}
|
||
|
||
@objc public func show_lookup_popover(_ invoke: Invoke) {
|
||
// Bridge for the system-dictionary "Look Up" surface on iOS.
|
||
// We use `UIReferenceLibraryViewController`, which is the same
|
||
// view UIKit presents for the Look Up callout in editable text
|
||
// views. Two notes:
|
||
//
|
||
// * The controller refuses to render and `dictionaryHasDefinitionForTerm`
|
||
// returns false for empty strings, so guard for that explicitly
|
||
// to keep the rejection path consistent with the Rust models.
|
||
// * Presentation must happen on the main thread, against a
|
||
// view controller that's actually in the active window
|
||
// hierarchy. We reach the foreground scene (matching how
|
||
// other commands here pick a `keyWindow`) and walk down to
|
||
// the topmost presented controller so the lookup view sits
|
||
// above any modal sheet (settings, notebook, etc.) the user
|
||
// might have open when they tap "dictionary".
|
||
guard let args = try? invoke.parseArgs(ShowLookupPopoverArgs.self) else {
|
||
return invoke.reject("Failed to parse arguments")
|
||
}
|
||
let trimmed = args.word.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
if trimmed.isEmpty {
|
||
return invoke.reject("empty word")
|
||
}
|
||
|
||
DispatchQueue.main.async {
|
||
let windows = UIApplication.shared.connectedScenes
|
||
.compactMap { $0 as? UIWindowScene }
|
||
.flatMap { $0.windows }
|
||
let keyWindow = windows.first(where: { $0.isKeyWindow }) ?? windows.first
|
||
guard let rootVC = keyWindow?.rootViewController else {
|
||
invoke.reject("no root view controller")
|
||
return
|
||
}
|
||
// Drill past any modally-presented stack so the dictionary
|
||
// view appears on top of (not behind) settings dialogs etc.
|
||
var presenter: UIViewController = rootVC
|
||
while let next = presenter.presentedViewController {
|
||
presenter = next
|
||
}
|
||
|
||
// `UIReferenceLibraryViewController` itself doesn't expose
|
||
// dictionary availability, but the static
|
||
// `dictionaryHasDefinitionForTerm:` does. We don't surface
|
||
// "no definition" as an error — Apple's view shows its own
|
||
// "No definition found" / download UI in that case, which is
|
||
// the expected UX. Logging it is enough for diagnostics.
|
||
let hasDefinition = UIReferenceLibraryViewController.dictionaryHasDefinition(forTerm: trimmed)
|
||
if !hasDefinition {
|
||
logger.log("[show_lookup_popover] no built-in dictionary entry for '\(trimmed, privacy: .private)'")
|
||
}
|
||
|
||
let dictVC = UIReferenceLibraryViewController(term: trimmed)
|
||
// Constrain the lookup to the lower half of the screen so the
|
||
// book content the user just selected from stays visible above
|
||
// it — the full-screen default feels heavyweight for a quick
|
||
// dictionary glance. On iOS 15+ we use a half-detent sheet
|
||
// presentation: medium height by default, but the user can
|
||
// drag-to-expand to full if they want more room. iPad keeps
|
||
// the form sheet (a native floating panel) since half-screen
|
||
// sheets look out of place on tablet-class screens.
|
||
if UIDevice.current.userInterfaceIdiom == .pad {
|
||
dictVC.modalPresentationStyle = .formSheet
|
||
} else if #available(iOS 15.0, *) {
|
||
dictVC.modalPresentationStyle = .pageSheet
|
||
if let sheet = dictVC.sheetPresentationController {
|
||
sheet.detents = [.medium(), .large()]
|
||
// Default to medium (lower half). The system handles drag
|
||
// to expand to large; we don't need to track changes.
|
||
sheet.selectedDetentIdentifier = .medium
|
||
// Keep the grabber visible so users discover they can
|
||
// expand or drag down to dismiss.
|
||
sheet.prefersGrabberVisible = true
|
||
// Round only the top corners (the standard iOS sheet look).
|
||
sheet.preferredCornerRadius = 16
|
||
}
|
||
} else {
|
||
// iOS 14 and earlier — sheets/detents API doesn't exist; fall
|
||
// back to a centered form sheet so it at least doesn't take
|
||
// the full screen.
|
||
dictVC.modalPresentationStyle = .formSheet
|
||
}
|
||
presenter.present(dictVC, animated: true) {
|
||
invoke.resolve(["success": true])
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Open a hidden-but-visible WKWebView at `url`, capture
|
||
/// `document.documentElement.outerHTML` after the page settles, and
|
||
/// resolve with `{ html }`. Implements the mobile half of the
|
||
/// `clip_url` command — see `clip_url.rs` for the desktop half and
|
||
/// `ClipUrlController.swift` for the actual lifecycle.
|
||
@objc public func clip_url(_ invoke: Invoke) {
|
||
let args: ClipUrlArgs
|
||
do {
|
||
args = try invoke.parseArgs(ClipUrlArgs.self)
|
||
} catch {
|
||
invoke.reject(error.localizedDescription)
|
||
return
|
||
}
|
||
DispatchQueue.main.async {
|
||
// Find the topmost view controller to present from. The Tauri
|
||
// root WKWebView lives in the app's key window — present over
|
||
// whatever's currently on top of it (e.g., the library page,
|
||
// a settings sheet) so the user keeps their place after the
|
||
// controller dismisses.
|
||
guard let presenter = topmostViewController() else {
|
||
invoke.reject("Could not find a view controller to present from")
|
||
return
|
||
}
|
||
|
||
let controller = ClipUrlController(args: args) { result in
|
||
switch result {
|
||
case .success(let html):
|
||
invoke.resolve(["html": html])
|
||
case .failure(let err):
|
||
invoke.reject(err.message)
|
||
}
|
||
}
|
||
presenter.present(controller, animated: true)
|
||
}
|
||
}
|
||
|
||
/// Hold a strong reference to the active folder picker delegate so
|
||
/// it survives until the user dismisses the picker. `present`
|
||
/// only retains the controller; the delegate is `weak` from
|
||
/// `UIDocumentPickerViewController`'s side, so without our retain
|
||
/// it would deallocate immediately and the callbacks would never
|
||
/// fire.
|
||
private var folderPickerDelegate: FolderPickerDelegate?
|
||
|
||
/// Bridge for the native-bridge `select_directory` command on iOS.
|
||
/// Mirrors the Android implementation (ACTION_OPEN_DOCUMENT_TREE)
|
||
/// but uses `UIDocumentPickerViewController(forOpeningContentTypes:
|
||
/// [.folder])` — the same picker the system uses for "Files" → choose
|
||
/// folder. Users can navigate into On My iPhone / iCloud Drive /
|
||
/// any third-party file provider and pick a directory there.
|
||
///
|
||
/// Caveats around iOS sandbox model (callers should be aware):
|
||
/// * Resolving the returned path on a SUBSEQUENT app launch
|
||
/// requires a security-scoped bookmark — for now we hold the
|
||
/// security-scoped resource open for the lifetime of the app
|
||
/// process so the current session works. v2 should persist a
|
||
/// bookmark and resolve it on launch; until then a relaunch
|
||
/// after picking a non-Documents folder may need the user to
|
||
/// re-pick.
|
||
/// * The returned `path` is `url.path` — a normal POSIX path.
|
||
/// Combined with the resource being open, plain Foundation /
|
||
/// stdlib fs reads work against it.
|
||
@objc public func select_directory(_ invoke: Invoke) {
|
||
DispatchQueue.main.async { [weak self] in
|
||
guard let self = self else {
|
||
invoke.reject("plugin deallocated")
|
||
return
|
||
}
|
||
guard let presenter = topmostViewController() else {
|
||
invoke.reject("Could not find a view controller to present from")
|
||
return
|
||
}
|
||
|
||
// `forOpeningContentTypes: [.folder]` tells the picker to surface
|
||
// folders as the selectable entries (you can't pick individual
|
||
// files in this mode). `asCopy: false` keeps it a reference into
|
||
// the original location instead of copying into our sandbox.
|
||
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.folder], asCopy: false)
|
||
picker.allowsMultipleSelection = false
|
||
|
||
let delegate = FolderPickerDelegate(invoke: invoke) { [weak self] in
|
||
// Drop our strong reference once the picker resolves so the
|
||
// delegate (and thus the closure capture chain) can deallocate.
|
||
// The security-scoped URL resource stays open via
|
||
// `FolderBookmarkStore.persist`, which retains the URL
|
||
// for the rest of the process and writes a bookmark to
|
||
// UserDefaults so the next launch can re-acquire access
|
||
// without prompting the user again.
|
||
self?.folderPickerDelegate = nil
|
||
}
|
||
self.folderPickerDelegate = delegate
|
||
picker.delegate = delegate
|
||
|
||
presenter.present(picker, animated: true)
|
||
}
|
||
}
|
||
|
||
// iOS devices have no e-ink panel; the "Refresh Page" page-turner action is
|
||
// gated to e-ink Android in the UI, so this is only ever reached defensively.
|
||
// Resolve as a soft no-op rather than rejecting.
|
||
@objc public func refresh_eink_screen(_ invoke: Invoke) {
|
||
invoke.resolve(["success": false])
|
||
}
|
||
|
||
@objc public func update_reading_widget(_ invoke: Invoke) {
|
||
guard let args = try? invoke.parseArgs(UpdateReadingWidgetRequestArgs.self) else {
|
||
return invoke.reject("Failed to parse arguments")
|
||
}
|
||
DispatchQueue.global(qos: .utility).async {
|
||
for book in args.books {
|
||
ReadingWidgetWriter.writeThumbnail(hash: book.hash, sourcePath: book.coverPath)
|
||
}
|
||
let snapshot = ReadingWidgetWriter.Snapshot(
|
||
books: args.books.map {
|
||
.init(hash: $0.hash, title: $0.title, author: $0.author, percent: $0.percent)
|
||
},
|
||
sectionTitle: args.sectionTitle,
|
||
emptyTitle: args.emptyTitle
|
||
)
|
||
ReadingWidgetWriter.write(snapshot: snapshot)
|
||
invoke.resolve()
|
||
}
|
||
}
|
||
|
||
/// Snapshot a region of the webview for the mesh page-curl texture
|
||
/// (#555). The rect is in CSS pixels of the JS viewport (== points of
|
||
/// the WKWebView). Like Android, the snapshot is capped at 2x CSS
|
||
/// pixels (Pro iPhones render at 3x) and encoded as JPEG — the page is
|
||
/// opaque and JPEG encodes several times faster than PNG, keeping the
|
||
/// dead time between the tap and the first curl frame short. Resolved
|
||
/// as base64 because the plugin boundary is JSON-only; the Rust side
|
||
/// decodes back to bytes.
|
||
@objc public func capture_webview_region(_ invoke: Invoke) {
|
||
guard let args = try? invoke.parseArgs(CaptureWebviewRegionArgs.self) else {
|
||
return invoke.reject("Failed to parse arguments")
|
||
}
|
||
DispatchQueue.main.async { [weak self] in
|
||
guard let webView = self?.webView else {
|
||
return invoke.reject("WebView not available")
|
||
}
|
||
let config = WKSnapshotConfiguration()
|
||
config.rect = CGRect(x: args.x, y: args.y, width: args.width, height: args.height)
|
||
// snapshotWidth is in points and the produced image is snapshotWidth
|
||
// x screen-scale pixels wide, so width x (2 / scale) points yields a
|
||
// 2x-CSS-pixel bitmap on 3x screens and native size elsewhere.
|
||
let scale = webView.window?.screen.scale ?? UIScreen.main.scale
|
||
if scale > 2 {
|
||
config.snapshotWidth = NSNumber(value: args.width * 2.0 / scale)
|
||
}
|
||
webView.takeSnapshot(with: config) { image, error in
|
||
guard let image = image else {
|
||
return invoke.reject(error?.localizedDescription ?? "Snapshot failed")
|
||
}
|
||
// Encode and base64 off the main thread; the completion arrives on
|
||
// main and a full-screen encode is fast but not free.
|
||
DispatchQueue.global(qos: .userInteractive).async {
|
||
guard let data = image.jpegData(compressionQuality: 0.85) else {
|
||
return invoke.reject("JPEG encoding failed")
|
||
}
|
||
invoke.resolve(["data": data.base64EncodedString()])
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Persistent store for security-scoped folder bookmarks.
|
||
///
|
||
/// `UIDocumentPickerViewController(forOpeningContentTypes: [.folder])`
|
||
/// hands us a security-scoped URL whose access right is granted only to
|
||
/// the running process. To read the same folder on the *next* launch
|
||
/// (without making the user re-pick it every time) we have to:
|
||
///
|
||
/// 1. Right after the pick, call `URL.bookmarkData(.withSecurityScope)`
|
||
/// and stash the bytes in `UserDefaults` keyed by the POSIX path.
|
||
/// 2. On every app launch, walk every persisted bookmark, resolve it
|
||
/// back into a URL, and call `startAccessingSecurityScopedResource()`.
|
||
/// Hold the URL alive for the entire process so subsequent
|
||
/// Foundation / POSIX reads against `url.path` succeed.
|
||
///
|
||
/// The TS-visible `path` is unchanged across launches, so the rest of
|
||
/// the app doesn't need to know about bookmarks — they're an iOS
|
||
/// sandbox plumbing detail that lives entirely on the Swift side.
|
||
///
|
||
/// `isStale` recovery: when the source moves (user renamed, system
|
||
/// migrated) `URL(resolvingBookmarkData:..., bookmarkDataIsStale:)`
|
||
/// sets `isStale=true` and the new URL still works. We re-encode and
|
||
/// rewrite. If the bookmark can't be resolved at all (file deleted,
|
||
/// provider gone), we drop the entry — the user will need to re-pick.
|
||
enum FolderBookmarkStore {
|
||
/// UserDefaults key prefix; the actual key is "<prefix><path>".
|
||
/// Using the path verbatim keeps debugging straightforward (you can
|
||
/// see the registered folders in `defaults read`), and UserDefaults
|
||
/// keys can contain any valid string.
|
||
private static let keyPrefix = "readest.folderBookmark."
|
||
|
||
/// Every URL we've successfully `startAccessingSecurityScopedResource`-ed
|
||
/// during this process, indexed by the POSIX path the JS layer uses.
|
||
/// Held forever (until process exit) — calling `stop` would kill any
|
||
/// subsequent fs read against the path.
|
||
private static var liveAccess: [String: URL] = [:]
|
||
|
||
/// Persist a bookmark for `url` so it can be resolved on next launch,
|
||
/// and pre-warm `liveAccess` for the current session.
|
||
static func persist(url: URL) {
|
||
let path = url.path
|
||
do {
|
||
let data = try url.bookmarkData(
|
||
options: .minimalBookmark,
|
||
includingResourceValuesForKeys: nil,
|
||
relativeTo: nil)
|
||
UserDefaults.standard.set(data, forKey: keyPrefix + path)
|
||
liveAccess[path] = url
|
||
logger.log(
|
||
"FolderBookmarkStore: persisted bookmark for \(path, privacy: .public)")
|
||
} catch {
|
||
Logger.error(
|
||
"FolderBookmarkStore: failed to encode bookmark for \(path): \(error)")
|
||
}
|
||
}
|
||
|
||
/// Resolve every stored bookmark and start accessing its security-
|
||
/// scoped resource. Called once from `NativeBridgePlugin.load(webview:)`
|
||
/// so all previously-registered folders are reachable before
|
||
/// the WebView begins issuing fs reads.
|
||
static func restoreAllPersisted() {
|
||
let defaults = UserDefaults.standard
|
||
let allKeys = defaults.dictionaryRepresentation().keys.filter {
|
||
$0.hasPrefix(keyPrefix)
|
||
}
|
||
var restored = 0
|
||
var dropped = 0
|
||
for key in allKeys {
|
||
guard let data = defaults.data(forKey: key) else { continue }
|
||
let storedPath = String(key.dropFirst(keyPrefix.count))
|
||
var isStale = false
|
||
do {
|
||
let url = try URL(
|
||
resolvingBookmarkData: data,
|
||
options: [],
|
||
relativeTo: nil,
|
||
bookmarkDataIsStale: &isStale)
|
||
let started = url.startAccessingSecurityScopedResource()
|
||
if started {
|
||
liveAccess[url.path] = url
|
||
restored += 1
|
||
} else {
|
||
// The OS denied access (e.g. file provider revoked). Don't
|
||
// delete the entry — the user may reauthorize the provider
|
||
// in Settings; we'll pick it up on the next launch.
|
||
Logger.error(
|
||
"FolderBookmarkStore: startAccessing denied for \(storedPath)")
|
||
}
|
||
if isStale {
|
||
// Re-encode against the resolved URL so the next launch
|
||
// doesn't have to walk the stale path again.
|
||
if let fresh = try? url.bookmarkData(
|
||
options: .minimalBookmark,
|
||
includingResourceValuesForKeys: nil,
|
||
relativeTo: nil)
|
||
{
|
||
defaults.set(fresh, forKey: key)
|
||
}
|
||
}
|
||
} catch {
|
||
// Bookmark is unrecoverable — folder gone, provider uninstalled,
|
||
// disk reformatted. Drop it from UserDefaults so the next launch
|
||
// doesn't re-attempt it.
|
||
defaults.removeObject(forKey: key)
|
||
dropped += 1
|
||
Logger.error(
|
||
"FolderBookmarkStore: dropped unresolvable bookmark for \(storedPath): \(error)")
|
||
}
|
||
}
|
||
logger.log(
|
||
"FolderBookmarkStore: restored \(restored, privacy: .public) folder(s), dropped \(dropped, privacy: .public)"
|
||
)
|
||
}
|
||
|
||
/// Stop accessing and forget the bookmark for `path`. Used when the
|
||
/// JS layer removes a folder from `settings.externalLibraryFolders`.
|
||
/// Currently unused — the v1 UI doesn't expose folder removal — but
|
||
/// kept here so the cleanup contract is obvious from one place.
|
||
static func forget(path: String) {
|
||
if let url = liveAccess.removeValue(forKey: path) {
|
||
url.stopAccessingSecurityScopedResource()
|
||
}
|
||
UserDefaults.standard.removeObject(forKey: keyPrefix + path)
|
||
}
|
||
}
|
||
|
||
/// Delegate for `select_directory`'s `UIDocumentPickerViewController`.
|
||
/// We split this out into a dedicated class (rather than making
|
||
/// `NativeBridgePlugin` itself the delegate) so multiple concurrent
|
||
/// picker invocations would each hold their own state — currently we
|
||
/// only allow one at a time, but the wiring is cleaner this way and
|
||
/// `Invoke` is easier to capture by reference.
|
||
///
|
||
/// Lifecycle:
|
||
/// 1. Plugin retains us strongly via `folderPickerDelegate`.
|
||
/// 2. UIKit holds us as the picker's `delegate` (weak).
|
||
/// 3. When the user picks or cancels, we call back into `invoke`
|
||
/// and then run `onComplete`, which clears the plugin's strong
|
||
/// reference. We deallocate at that point.
|
||
/// 4. The chosen URL retains its security-scoped access for the
|
||
/// remainder of the process via `FolderBookmarkStore`,
|
||
/// and is also persisted to UserDefaults so the next launch can
|
||
/// re-acquire access without prompting the user again.
|
||
private final class FolderPickerDelegate: NSObject, UIDocumentPickerDelegate {
|
||
private let invoke: Invoke
|
||
private let onComplete: () -> Void
|
||
|
||
init(invoke: Invoke, onComplete: @escaping () -> Void) {
|
||
self.invoke = invoke
|
||
self.onComplete = onComplete
|
||
}
|
||
|
||
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
||
defer { onComplete() }
|
||
guard let url = urls.first else {
|
||
invoke.resolve(["cancelled": true])
|
||
return
|
||
}
|
||
|
||
// `startAccessingSecurityScopedResource` is required for any URL
|
||
// returned by the picker that lives outside our sandbox (which is
|
||
// the only interesting case — anything inside our sandbox we
|
||
// already have access to without it). Returns false when no scope
|
||
// is needed or the request was denied; in either case we still
|
||
// try to use the URL because (a) sandbox-internal paths work
|
||
// either way, (b) for denied requests `path` access will surface
|
||
// a normal "permission denied" later, which is a clearer error
|
||
// than failing here pre-emptively.
|
||
let started = url.startAccessingSecurityScopedResource()
|
||
if started {
|
||
// Persist a security-scoped bookmark so the same path is
|
||
// reachable on the next launch, without forcing the user to
|
||
// re-pick it. The store also retains the URL so the resource
|
||
// stays accessible for the remainder of this process.
|
||
FolderBookmarkStore.persist(url: url)
|
||
}
|
||
|
||
let result: [String: Any] = [
|
||
"cancelled": false,
|
||
"uri": url.absoluteString,
|
||
"path": url.path,
|
||
]
|
||
invoke.resolve(result)
|
||
}
|
||
|
||
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
|
||
defer { onComplete() }
|
||
invoke.resolve(["cancelled": true])
|
||
}
|
||
}
|
||
|
||
/// Find the visible top-of-stack `UIViewController` so the clip flow
|
||
/// can present over whatever the user is looking at. Walks
|
||
/// presentedViewController chains and unwraps standard container
|
||
/// types (UINavigationController, UITabBarController).
|
||
private func topmostViewController() -> UIViewController? {
|
||
let scene =
|
||
UIApplication.shared.connectedScenes
|
||
.compactMap { $0 as? UIWindowScene }
|
||
.first { $0.activationState == .foregroundActive }
|
||
?? UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
|
||
let window = scene?.windows.first(where: \.isKeyWindow) ?? scene?.windows.first
|
||
var top = window?.rootViewController
|
||
while let presented = top?.presentedViewController {
|
||
top = presented
|
||
}
|
||
if let nav = top as? UINavigationController { top = nav.visibleViewController ?? nav }
|
||
if let tab = top as? UITabBarController { top = tab.selectedViewController ?? tab }
|
||
return top
|
||
}
|
||
|
||
class SyncPassphraseSetArgs: Decodable {
|
||
let passphrase: String
|
||
}
|
||
|
||
class SecureItemSetArgs: Decodable {
|
||
let key: String
|
||
let value: String
|
||
}
|
||
|
||
class SecureItemGetArgs: Decodable {
|
||
let key: String
|
||
}
|
||
|
||
class ShowLookupPopoverArgs: Decodable {
|
||
let word: String
|
||
}
|
||
|
||
struct UpdateReadingWidgetBookArgs: Decodable {
|
||
let hash: String
|
||
let title: String
|
||
let author: String
|
||
let percent: Int
|
||
let coverPath: String
|
||
}
|
||
struct UpdateReadingWidgetRequestArgs: Decodable {
|
||
let books: [UpdateReadingWidgetBookArgs]
|
||
let sectionTitle: String
|
||
let emptyTitle: String
|
||
}
|
||
|
||
struct CaptureWebviewRegionArgs: Decodable {
|
||
let x: Double
|
||
let y: Double
|
||
let width: Double
|
||
let height: Double
|
||
}
|
||
|
||
@_cdecl("init_plugin_native_bridge")
|
||
func initPlugin() -> Plugin {
|
||
return NativeBridgePlugin()
|
||
}
|
||
|
||
/// JS → Swift bridge for the share-extension state. Lives in its own
|
||
/// class because WKScriptMessageHandler conformance on NativeBridgePlugin
|
||
/// would require routing every message type through the plugin's
|
||
/// existing @objc method surface, which is noisier than a dedicated
|
||
/// handler. We weakly retain the plugin so the WKWebView's
|
||
/// WKUserContentController holding the handler doesn't extend the
|
||
/// plugin's lifetime.
|
||
private final class ShareBridgeMessageHandler: NSObject, WKScriptMessageHandler {
|
||
private weak var owner: NativeBridgePlugin?
|
||
init(owner: NativeBridgePlugin) { self.owner = owner }
|
||
func userContentController(
|
||
_ userContentController: WKUserContentController,
|
||
didReceive message: WKScriptMessage
|
||
) {
|
||
guard message.name == "readestShareBridge" else { return }
|
||
guard let body = message.body as? [String: Any], let type = body["type"] as? String else {
|
||
return
|
||
}
|
||
if type == "ready" {
|
||
owner?.syncShareExtensionStateFromJS()
|
||
}
|
||
}
|
||
}
|
||
|
||
@available(iOS 13.0, *)
|
||
extension NativeBridgePlugin: ASWebAuthenticationPresentationContextProviding {
|
||
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
||
return UIApplication.shared.windows.first ?? UIWindow()
|
||
}
|
||
}
|
||
|
||
extension NativeBridgePlugin: UIApplicationDelegate {
|
||
public func application(
|
||
_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?
|
||
) -> UIInterfaceOrientationMask {
|
||
return self.currentOrientationMask
|
||
}
|
||
|
||
/*
|
||
Proxy all application delegate methods to the original delegate:
|
||
sel!(application:didFinishLaunchingWithOptions:),
|
||
sel!(application:openURL:options:),
|
||
sel!(application:continue:restorationHandler:),
|
||
sel!(applicationDidBecomeActive:),
|
||
sel!(applicationWillResignActive:),
|
||
sel!(applicationWillEnterForeground:),
|
||
sel!(applicationDidEnterBackground:),
|
||
sel!(applicationWillTerminate:),
|
||
*/
|
||
|
||
public func application(
|
||
_ application: UIApplication,
|
||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||
) -> Bool {
|
||
self.originalDelegate?.application?(application, didFinishLaunchingWithOptions: launchOptions)
|
||
?? false
|
||
}
|
||
|
||
public func application(
|
||
_ application: UIApplication, open url: URL,
|
||
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
|
||
) -> Bool {
|
||
self.originalDelegate?.application?(application, open: url, options: options) ?? false
|
||
}
|
||
|
||
public func application(
|
||
_ application: UIApplication, continue continueUserActivity: NSUserActivity,
|
||
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
|
||
) -> Bool {
|
||
self.originalDelegate?.application?(
|
||
application, continue: continueUserActivity, restorationHandler: restorationHandler) ?? false
|
||
}
|
||
|
||
public func applicationDidBecomeActive(_ application: UIApplication) {
|
||
self.originalDelegate?.applicationDidBecomeActive?(application)
|
||
}
|
||
|
||
public func applicationWillResignActive(_ application: UIApplication) {
|
||
webViewLifecycleManager?.handleAppWillResignActive()
|
||
self.originalDelegate?.applicationWillResignActive?(application)
|
||
}
|
||
|
||
public func applicationWillEnterForeground(_ application: UIApplication) {
|
||
webViewLifecycleManager?.handleAppWillEnterForeground()
|
||
self.originalDelegate?.applicationWillEnterForeground?(application)
|
||
}
|
||
|
||
public func applicationDidEnterBackground(_ application: UIApplication) {
|
||
webViewLifecycleManager?.handleAppDidEnterBackground()
|
||
self.originalDelegate?.applicationDidEnterBackground?(application)
|
||
}
|
||
|
||
public func applicationWillTerminate(_ application: UIApplication) {
|
||
self.originalDelegate?.applicationWillTerminate?(application)
|
||
}
|
||
}
|