7e78f80e14
* feat(sync): add Google Drive file-sync provider core Second FileSyncProvider for the merged provider-agnostic file-sync engine, behind the provider seam. This is the CI-testable core only: no settings UI and no platform OAuth runners yet (those land in later phases). - GoogleDriveProvider over the Drive v3 REST API: id-addressed path resolution with a per-instance id cache, create-then-name uploads, real idempotent ensureDir, files.list pagination, Retry-After-aware 429/5xx backoff, per-path folder-creation locks with deterministic duplicate collapse, stale-id eviction, and FileSyncError mapping (403 split into rate-limit vs permission). - DI OAuth layer: pkce, parseRedirect (redirect-target + CSRF state), reverseDnsRedirect, tokenStore (iOS client, no secret), oauthFlow. - PersistedDriveAuth with single-flight token refresh; keychain-backed token store with no ephemeral fallback for the refresh token; account label via about.get. - providerRegistry (backend kind to provider) and buildGoogleDriveProvider assembly. - Shared transport-agnostic provider semantic contract, run against both WebDAV and Drive. - Keyed secure-KV bridge contract (set/get/clear_secure_item); the native keychain implementation lands with the desktop OAuth slice that first exercises it. Adapted from ratatabananana-bit/Readest-google-drive-mod-patcher (AGPL-3.0) with the author's explicit permission. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): multi-provider file-sync settings + sync-state foundation PR2 foundation for a second file-sync backend (Google Drive). The behaviour-sensitive reader-hook and Sync-now form generalization land in PR3 alongside OAuth, where Drive actually connects and the multi-provider paths can be exercised and live-verified (and the extracted form gets its second consumer, avoiding a single-use abstraction). - GoogleDriveSettings type (mirrors WebDAVSettings minus URL/credentials/ rootPath, plus accountLabel) wired into SystemSettings, with DEFAULT_GOOGLE_DRIVE_SETTINGS in the defaults. - googleDrive.deviceId + googleDrive.lastSyncedAt added to the backup blacklist so device-local sync identity / cursors never restore onto another device. Covered by the existing backup-settings test. - Generalize webdavSyncStore into fileSyncStore: per-backend progress keyed by provider kind, plus a global library-sync mutex (beginSync returns false when another backend already holds the lock) since every backend's syncLibrary mutates the same local library. Migrate WebDAVForm and IntegrationsPanel to the keyed API; WebDAV behaviour is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(native-bridge): add keyed secure key-value store commands A generic, keyed secret store over the same OS keychain backends as the sync passphrase (set/get/clear_secure_item), so secrets that aren't the single sync passphrase get the same XSS-free cross-launch persistence without each needing its own native command. The Google Drive OAuth token store (PR1's KeychainTokenPersistence) is the first consumer; a future cloud provider's refresh token reuses it. - Desktop (macOS/Windows/Linux): keyring-core, keyed by the item key as the entry account under the existing "Readest Safe Storage" service. - Android: EncryptedSharedPreferences (a dedicated readest_secure_items_v1 file, the item key as the pref key). - iOS: Security framework Keychain (kSecClassGenericPassword, dedicated service, the item key as kSecAttrAccount). Registered in the plugin invoke handler + build COMMANDS + default permission set (autogenerated permission files regenerated; the passphrase entries are preserved). The TS bridge wrappers shipped in PR1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): desktop Google Drive OAuth runner + connect flow The desktop half of Drive sign-in: open consent in the system browser, capture the reverse-DNS redirect the OS routes back, and exchange the code for tokens. - oauthDesktop.ts: runDesktopDeepLinkOAuth wires the DI OAuth flow to the desktop mechanics (open default browser, capture via single-instance / onOpenUrl, cold-browser fallback after a grace period, hard deadline). Fully headless-unit-tested via injected deps. - spawn_fresh_browser.rs (+ registration, Windows-only winreg dep): the cold browser the runner falls back to when the user's already-running browser snapshotted protocol associations before the scheme was registered (a Windows-specific failure). Resolves the default browser from the registry and spawns it cold with an isolated --user-data-dir; a no-op on macOS/Linux where the default-browser open already routes the redirect. Pure helpers unit-tested. - connectGoogleDrive.ts: run the platform OAuth runner, persist the token (fail-loud — Drive is not reported connected if the refresh token does not save), and resolve the account label via about.get (best-effort). OAuth runner adapted from ratatabananana-bit/Readest-google-drive-mod-patcher (AGPL-3.0) with the author's permission. Scheme registration + the ingress redirect filter + the Drive connect UI land in the following commits; live desktop verification follows once the official Google client id is provisioned. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): filter Google OAuth redirects out of the deep-link ingress The reverse-DNS OAuth redirect (com.googleusercontent.apps.<id>:/oauthredirect) is delivered through the same single-instance / onOpenUrl channels as book-file deep links. Without a filter the book-import consumer would treat the redirect URL as a file path to open. Drop it at the ingress source (useAppUrlIngress) before the app-incoming-url broadcast, so no consumer ever sees it; the Drive sign-in runner still captures it via its own listeners. isGoogleOAuthRedirectUrl matches the scheme prefix (not a specific client id), so it stays correct regardless of which client is baked into the build. Note: registering the scheme in tauri.conf.json (so the OS routes it back to the app) needs the official Google client id, which is a provisioning prerequisite. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): bake the official Google Drive OAuth client id + redirect scheme Provisioned the Readest Google Cloud OAuth client (iOS application type, no secret, drive.file scope). Bake the client id as the default in getGoogleClientId (overridable via NEXT_PUBLIC_GOOGLE_CLIENT_ID for forkers, who must also regenerate the manifest schemes) and register the derived reverse-DNS redirect scheme com.googleusercontent.apps.<id> in tauri.conf.json (desktop + mobile deep-link) so the OS routes the OAuth redirect back to the app. The client id is a public client identifier, not a secret. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): Google Drive connect UI + shared FileSyncForm Make Drive usable from Settings, and extract the now-two-consumer sync controls. - FileSyncForm: the provider-agnostic sync controls (sub-toggles, conflict strategy, manual "Sync now" with progress + result toast), parameterised by backend kind and building the provider through the registry. Extracted from WebDAVForm now that a second consumer exists. WebDAVForm keeps its URL/credentials connect panel + browse pane and renders FileSyncForm for the sync section; behaviour is unchanged (WebDAV "Sync now" goes through the same provider via the registry). - GoogleDriveForm: an OAuth connect panel (Connect -> runGoogleDriveConnect -> store token in keychain -> "Connected as <email>"; Disconnect) + FileSyncForm. - googleDriveConnect.ts: assemble the env client id + keychain + desktop runner into connectGoogleDrive/disconnectGoogleDrive for the UI. - IntegrationsPanel: a "Google Drive" row + sub-page, shown only on desktop (mobile OAuth runners land in later phases). Reader-side auto-sync (generalizing useWebDAVSync) is a follow-up; manual "Sync now" already exercises the full Drive stack. Full suite 6412 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(settings): unified Third-party Cloud Sync section (exclusive provider) Group WebDAV + Google Drive into a new "Third-party Cloud Sync" section and make them mutually exclusive — only one cloud provider syncs the library at a time. - New unified "Cloud Sync" sub-page (CloudSyncForm): a provider picker (radio, the AIPanel mutually-exclusive pattern) on top, the shared FileSyncForm sync options below for whichever provider is active. Google Drive is offered only on desktop; on mobile the page is WebDAV only and the picker is hidden. - withActiveCloudProvider helper: enabling one provider disables the other in one save. Both panels' connect/activate paths use it. Unit-tested. - WebDAVForm / GoogleDriveForm refactored into embeddable panels (the unified page owns the header). Drive gains a "configured but inactive" state so switching back re-activates it without a fresh sign-in; explicit Disconnect clears the keychain token. - IntegrationsPanel: remove the two separate WebDAV / Google Drive rows from "Reading Sync" (now KOReader Sync / Readwise / Hardcover only); add the Third-party Cloud Sync section with one Cloud Sync row (status = active provider). Old webdav/gdrive deep-links route to the unified page. Also removes the temporary Drive concurrency probe (the upload already runs at the intended concurrency 4; the probe confirmed it). Full suite 6416 green; lint + format clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reader): auto-sync the active cloud provider while reading Generalize the reader sync hook from useWebDAVSync to useFileSync so the active third-party cloud provider (WebDAV OR Google Drive) syncs per-book while reading — pull-on-open, debounced push on progress/booknote changes, cover/file upload — not just via the manual "Sync now" in settings. Since the providers are mutually exclusive, the hook drives exactly the one enabled backend, built through the provider registry. The build is async (the Google Drive provider probes the OS keychain), so the engine lives in state and the pull-on-open waits for it; switching providers mid-session resets the per-book locks. The engine is keyed on connection-relevant settings so a lastSyncedAt write doesn't re-probe the keychain. deviceId / lastSyncedAt now write the active provider's settings slice; the auth-failed toast is provider-neutral; the per-book events are renamed *-file-sync. WebDAV reader-sync behaviour is unchanged. Full suite 6416 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(settings): surface cloud providers in the section with inline switch Show WebDAV + Google Drive as separate rows in the Third-party Cloud Sync section (instead of one "Cloud Sync" row), so both providers are visible and the active one can be switched right there. - CloudProviderRow: a trailing radio makes a provider the single active sync target inline (enabled only when it's already configured — WebDAV creds / a Drive token); the row body / chevron opens its config sub-page (connect, sync options, disconnect). Status reads Active / Configured / Not connected, with a Syncing… indicator. - Each provider drills into its own sub-page again (WebDAV / Google Drive), rendering the embeddable panel under a SubPageHeader; the brief unified CloudSyncForm picker page is removed (its old deep-link maps to Google Drive). - Switching stays exclusive via withActiveCloudProvider; an inline switch trusts the stored credentials/token (no re-validate / re-OAuth). Full suite 6416 green; lint + format clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): gate third-party cloud sync behind a premium plan WebDAV + Google Drive sync is now a premium feature: available on any paid plan (Plus, Pro, or Lifetime), not on free. - isCloudSyncInPlan(plan) helper (mirrors isEmailInPlan; plus/pro/purchase). - IntegrationsPanel: free users see the Third-party Cloud Sync section with an upgrade row ("Available on Plus, Pro, or Lifetime") that opens the plans page instead of the provider rows; the cloud-sync deep-links are gated too (waiting for the plan to load before deciding). - useFileSync: the reader's auto-sync only runs on a paid plan, so a downgraded user's sync stops even if a provider's enabled flag lingers. Full suite 6418 green; lint + format clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sync): escape backslashes in Drive query literals (CodeQL) escapeDriveLiteral escaped single quotes but not the backslash escape character, so a file name containing a backslash (or ending in one) could break out of the single-quoted Drive `files.list` query literal and malform the query. Escape backslashes first, then single quotes, so the backslashes added for the quotes are not doubled. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1823 lines
66 KiB
Swift
1823 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)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
}
|