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? 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 "". /// 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) } }