import AVFoundation import AuthenticationServices import CoreText import MediaPlayer import ObjectiveC 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? 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 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 NativeBridgePlugin: Plugin { private var webView: WKWebView? private var authSession: ASWebAuthenticationSession? private var isOrientationLocked = false private var currentOrientationMask: UIInterfaceOrientationMask = .all private var orientationObserver: NSObjectProtocol? @objc public override func load(webview: WKWebView) { self.webView = webview logger.log("NativeBridgePlugin loaded") NotificationCenter.default.addObserver( self, selector: #selector(appDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(appDidEnterBackground), name: UIApplication.didEnterBackgroundNotification, object: nil ) } @objc func appDidBecomeActive() { if volumeKeyHandler != nil { activateVolumeKeyInterception() } if orientationObserver != nil { self.setupOrientationObserver() } } @objc func appDidEnterBackground() { if let handler = volumeKeyHandler, handler.isIntercepting { handler.stopInterception() } } 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 { 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.isIdleTimerDisabled = !visible 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.isOrientationLocked = true self.currentOrientationMask = .portrait self.forceOrientation(.portrait) self.setupOrientationObserver() case "landscape": self.isOrientationLocked = true self.currentOrientationMask = .landscape self.forceOrientation(.landscapeRight) self.setupOrientationObserver() case "auto": self.isOrientationLocked = false self.currentOrientationMask = .all default: invoke.reject("Invalid orientation mode") return } invoke.resolve() } } private func forceOrientation(_ orientation: UIInterfaceOrientation) { if #available(iOS 16.0, *) { if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene { let orientationMask: UIInterfaceOrientationMask = orientation.isPortrait ? .portrait : .landscape for window in windowScene.windows { if let rootVC = window.rootViewController { rootVC.setNeedsUpdateOfSupportedInterfaceOrientations() } } windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: orientationMask)) { error in print("Orientation update error: \(error.localizedDescription)") } } } else { UIDevice.current.setValue(orientation.rawValue, forKey: "orientation") UIViewController.attemptRotationToDeviceOrientation() } } private func setupOrientationObserver() { orientationObserver = NotificationCenter.default.addObserver( forName: UIDevice.orientationDidChangeNotification, object: nil, queue: .main ) { [weak self] _ in guard let self = self, self.isOrientationLocked else { return } if self.currentOrientationMask == .portrait { self.forceOrientation(.portrait) } else if self.currentOrientationMask == .landscape { self.forceOrientation(.landscapeRight) } } } } @_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() } }