Files
readest/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift
T
Huang Xin 324bb8a366 feat(reader): add e-ink screen refresh page-turner action (#4687) (#4822)
Add a bindable "Refresh Page" action to Settings > Behavior > Page Turner
that triggers a deep e-ink full refresh (GC16) to clear screen ghosting,
gated to e-ink mode on Android.

It reuses the existing hardware page-turner key-binding machinery: a new
'refresh' slot in HardwarePageTurnerSettings, shown only when isAndroidApp
and the e-ink view setting is on. Pressing the bound key calls a new native
bridge command instead of paginating.

The native side is device-agnostic: EinkRefreshController probes each vendor
mechanism via reflection and stops at the first that works, covering Onyx
BOOX (Qualcomm View.refreshScreen), Tolino/Nook (NTX postInvalidateDelayed)
and Boyue-style Rockchip (requestEpdMode) without bundling any vendor SDK.
A success:false result is a soft no-op on non-e-ink hardware. iOS gets a stub.

Verified on an Onyx BOOX Leaf5: the Onyx path fires and performs a visible
full GC16 refresh.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:18:11 +02:00

1830 lines
66 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
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
@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")
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()
}
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)!
authSession = ASWebAuthenticationSession(url: authUrl, callbackURLScheme: "readest") {
[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 < 0.0 {
// Revert to system brightness - iOS doesn't have a direct "system brightness" setting
// We will restore the brightness that was set before the app modified it
return invoke.resolve(["success": true])
}
if brightness > 1.0 {
return invoke.reject("Brightness must be between 0.0 and 1.0")
}
DispatchQueue.main.async {
UIScreen.main.brightness = CGFloat(brightness)
}
invoke.resolve(["success": true])
}
@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])
}
}
/// 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
}
@_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)
}
}