Files
readest/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift
T
Huang Xin 712d564e9d feat(sync): encrypted OPDS credentials + Tauri keychain (PR 4c + 4d) (#4090)
* feat(sync): encrypt opds_catalog credentials end-to-end (TS path)

Wires encrypted-credential sync for opds_catalog via the CryptoSession
shipped in PR 4a (#4084) plus a new publish/pull crypto middleware.
TS-only — native still uses ephemeral storage (re-enter passphrase per
launch); PR 4d wires the OS keychain.

- ReplicaAdapter gains optional `encryptedFields: readonly string[]`.
  Adapters stay sync; the middleware handles the crypto round trip.
- replicaCryptoMiddleware.ts: encryptPackedFields drops the named
  fields from the push when the session is locked (no plaintext
  leak); decryptRowFields drops them on pull failure (local
  plaintext preserved by the store merge).
- replicaPublish / replicaPullAndApply invoke the middleware.
- OPDS adapter declares encryptedFields = [username, password] and
  now pack/unpack them as plaintext.
- passphraseGate.ts: ensurePassphraseUnlocked coalesces concurrent
  calls, prompts via the registered prompter with kind=setup|unlock,
  throws NO_PASSPHRASE on cancel.
- PassphrasePromptModal mounted at the Providers root; registers
  itself as the gate prompter.
- CryptoSession.forget() wipes server-side envelopes + salts.
- Migration 010 + replica_keys_forget RPC; DELETE
  /api/sync/replica-keys + client wrapper.
- SyncPassphraseSection on the user page: status / Set / Unlock /
  Lock / Forgot.
- CatalogManager pre-save: ensurePassphraseUnlocked when credentials
  are present; user cancel saves locally without sync.

Plan updated: PR 4 split documented as 4a/4b/4c/4d.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sync): persist sync passphrase via OS keychain (Tauri)

Replaces the EphemeralPassphraseStore stub on native with real
OS-keychain storage so users don't re-enter their sync passphrase
every launch. Web stays on the in-memory ephemeral store by design.

Native bridge plugin gains 4 commands wired across all platforms:

- Rust desktop (`keyring` crate): macOS Keychain on apple-native,
  Windows Credential Manager on windows-native, Linux libsecret/
  Secret Service on sync-secret-service. Per-target features so each
  platform compiles only the backend it needs.
- iOS Swift: Security framework Keychain (kSecClassGenericPassword,
  SecItemAdd / Copy / Delete).
- Android Kotlin: androidx.security EncryptedSharedPreferences
  (AndroidKeystore-derived AES-GCM master key,
  AES256_SIV / AES256_GCM key/value encryption).

TS layer:

- TauriPassphraseStore wraps the bridge calls. set is fail-loud
  (surfaces keychain rejection); get is fail-soft (returns null on
  any error so the gate prompts).
- createPassphraseStore returns ephemeral synchronously;
  upgradeToKeychainIfAvailable swaps the singleton to
  TauriPassphraseStore on Tauri after probing the bridge. CryptoSession
  resolves the store via createPassphraseStore() each touch so the
  swap is transparent.
- CryptoSession.tryRestoreFromStore: silent unlock at boot. Stale-
  entry recovery clears the store when the account has no salt
  server-side. unlock/setup persist; forget also clears the store.
- Providers boot effect: upgrade keychain → silent restore.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sync): make encrypted-credential pull actually decrypt + UX polish

PR 4c shipped the encrypt path but the pull side silently dropped
ciphers when locked, the modal was busy with double-rings, and web
re-prompted on every page refresh. This rolls up the post-test
fixes + UX polish:

Pull-side decrypt:
- decryptRowFields takes an `onLocked` callback the orchestrator
  wires to the passphrase gate; encountering a cipher field with a
  locked session now triggers the lazy-prompt path instead of
  dropping the field.
- replicaPullAndApply re-applies the unpacked row for metadata-only
  kinds even when a local copy exists, so the now-decrypted creds
  reach the store (the binary-kind skip-if-local optimization
  doesn't apply).
- Cipher fingerprint comparison: capture the row's `cipher.c` for
  each encrypted field, compare against the local record's
  lastSeenCipher. Same → skip prompt + decrypt entirely. Different
  (rotation / value change on another device) → prompt to
  re-decrypt. Fingerprint persists via OPDSCatalog.lastSeenCipher.

Web persistence:
- SessionStoragePassphraseStore: passphrase survives page refresh
  within the same tab, dies on tab close. Replaces
  EphemeralPassphraseStore as the default on web. Avoids
  localStorage / IndexedDB to keep the tab-scoped trust boundary.

UI:
- Renamed PassphrasePromptModal → PassphrasePrompt; modernized: filled
  input style with single subtle focus border, btn-primary +
  btn-ghost replaced with leaner custom buttons. eink-bordered +
  btn-primary classes give the dialog correct e-paper rendering.
- globals.css: suppress redundant outline/box-shadow on focused text
  inputs / textareas (the element's own border is the focus
  indicator).
- AGENTS.md: documents the e-ink convention (`eink-bordered`,
  `btn-primary` for inverted CTAs, etc.) so future widgets ship with
  e-paper support.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 13:22:49 +02:00

1085 lines
36 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AVFoundation
import AuthenticationServices
import CoreText
import MediaPlayer
import ObjectiveC
import StoreKit
import SwiftRs
import Tauri
import UIKit
import 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?
}
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 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?
@objc public override func load(webview: WKWebView) {
self.webView = webview
logger.log("NativeBridgePlugin loaded")
webViewLifecycleManager = WebViewLifecycleManager()
webViewLifecycleManager?.startMonitoring(webView: webview)
logger.log("NativeBridgePlugin: WebView lifecycle monitoring activated")
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")
}
}
@objc func appWillEnterForeground() {
logger.log("NativeBridgePlugin: App will enter foreground")
webViewLifecycleManager?.handleAppWillEnterForeground()
}
@objc func appDidBecomeActive() {
if volumeKeyHandler != nil {
activateVolumeKeyInterception()
}
}
@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"
}
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)
}
}
@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
}
}
}
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) {
let userInterfaceStyle = UITraitCollection.current.userInterfaceStyle
let colorScheme = (userInterfaceStyle == .dark) ? "dark" : "light"
invoke.resolve(["colorScheme": colorScheme])
}
@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)"])
}
}
}
class SyncPassphraseSetArgs: Decodable {
let passphrase: String
}
@_cdecl("init_plugin_native_bridge")
func initPlugin() -> Plugin {
return NativeBridgePlugin()
}
@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)
}
}