forked from akai/readest
324bb8a366
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>
1830 lines
66 KiB
Swift
1830 lines
66 KiB
Swift
import AVFoundation
|
||
import AuthenticationServices
|
||
import CoreText
|
||
import MediaPlayer
|
||
import ObjectiveC
|
||
import StoreKit
|
||
import SwiftRs
|
||
import Tauri
|
||
import UIKit
|
||
import UniformTypeIdentifiers
|
||
import WebKit
|
||
import os
|
||
|
||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "NativeBridge")
|
||
|
||
func getLocalizedDisplayName(familyName: String) -> String? {
|
||
let fontDescriptor = CTFontDescriptorCreateWithAttributes(
|
||
[
|
||
kCTFontFamilyNameAttribute: familyName
|
||
] as CFDictionary)
|
||
|
||
let font = CTFontCreateWithFontDescriptor(fontDescriptor, 0.0, nil)
|
||
|
||
var actualLanguage: Unmanaged<CFString>?
|
||
if let localizedName = CTFontCopyLocalizedName(font, kCTFontFamilyNameKey, &actualLanguage) {
|
||
return localizedName as String
|
||
}
|
||
return nil
|
||
}
|
||
|
||
class SafariAuthRequestArgs: Decodable {
|
||
let authUrl: String
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|