forked from akai/readest
17749f7cc7
iOS and Android now run the same Web-URL clip flow as desktop. Paste
an article URL, the native side opens a full-screen WKWebView /
WebView with the same Chrome UA + fingerprint mask + "Saving to
Readest" overlay as the desktop hidden window, waits for load +
settle, captures `document.documentElement.outerHTML` via the
platform's `evaluateJavaScript`, and returns it through the existing
`convertToEpub` pipeline.
JS surface stays `invoke('clip_url', { url, options })` — no changes
in `library/page.tsx` or `send/page.tsx`. The platform branch lives
entirely in `clip_url.rs`.
Why not Tauri's `Window::add_child`
`add_child` is gated `#[cfg(any(test, all(desktop, feature =
"unstable")))]` in tauri 2.10. No public API for attaching a second
webview to the main window on mobile, so the clip flow can't be a
`#[cfg(mobile)]` branch of the existing `WebviewWindowBuilder` shape
— it needs native code. Extend `tauri-plugin-native-bridge` rather
than create a separate plugin: the Swift / Kotlin scaffolding +
Tauri IPC are already there.
Layout
- `src-tauri/src/clip_url.rs` — desktop branch unchanged; new
`#[cfg(mobile)]` `clip_url` command routes through
`app.native_bridge().clip_url(request)`. Shared `ClipOptions`
struct exposes its fields `pub` so the mobile branch can map into
the plugin's `ClipUrlRequest`.
- `plugins/tauri-plugin-native-bridge/src/models.rs` — `ClipUrlRequest`
+ `ClipUrlResponse` mirroring `ClipOptions` field-for-field so the
payload travels untouched from JS through to Swift/Kotlin.
- `plugins/tauri-plugin-native-bridge/src/{desktop,mobile}.rs` — desktop
returns an error (desktop has its own path); mobile dispatches via
`run_mobile_plugin("clip_url", payload)`.
- `ios/Sources/ClipUrlController.swift` — `UIViewController` hosting
`WKWebView` with the loading overlay drawn as native UIKit views
(not an injected user script, so the page's own hydration can't
wipe the spinner). 30 s hard timeout + 3 s settle window after
`didFinish`, same as desktop. Fingerprint mask injected as
`WKUserScript` at `.atDocumentStart`.
- `android/src/main/java/ClipUrlController.kt` — full-screen Dialog
hosting a `WebView`, mirrors the iOS controller's behaviour. JSON-
decodes the `evaluateJavascript` callback (raw return value is a
JSON-encoded string).
- `NativeBridgePlugin.{swift,kt}` — new `clip_url` method that parses
args via `invoke.parseArgs`, presents the controller, resolves the
invoke with `{ html }` on success or `invoke.reject` on failure.
Same rejection vocabulary as desktop (`"Invalid URL"`, `"Page took
too long to load"`, etc.) so the calling JS doesn't need a
platform branch.
- `build.rs` — adds `clip_url` to the plugin's `COMMANDS` array.
Notes
- The Swift overlay reserves the iOS safe-area-edge-to-edge so notch /
Dynamic Island devices don't see the underlying app peek through
during the brief capture window.
- The Android overlay's spinner tint follows the foreground theme
colour at 85 % alpha — same idea as the iOS controller.
- `WKWebView`'s JS keeps running while the controller is presented;
no off-screen / `isHidden` trick that would let iOS throttle the
page mid-capture.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
304 lines
8.8 KiB
Rust
304 lines
8.8 KiB
Rust
use serde::de::DeserializeOwned;
|
|
use tauri::{
|
|
plugin::{PluginApi, PluginHandle},
|
|
AppHandle, Runtime,
|
|
};
|
|
|
|
use crate::models::*;
|
|
|
|
#[cfg(target_os = "ios")]
|
|
tauri::ios_plugin_binding!(init_plugin_native_bridge);
|
|
|
|
// initializes the Kotlin or Swift plugin classes
|
|
pub fn init<R: Runtime, C: DeserializeOwned>(
|
|
_app: &AppHandle<R>,
|
|
api: PluginApi<R, C>,
|
|
) -> crate::Result<NativeBridge<R>> {
|
|
#[cfg(target_os = "android")]
|
|
let handle = api.register_android_plugin("com.readest.native_bridge", "NativeBridgePlugin")?;
|
|
#[cfg(target_os = "ios")]
|
|
let handle = api.register_ios_plugin(init_plugin_native_bridge)?;
|
|
Ok(NativeBridge(handle))
|
|
}
|
|
|
|
/// Access to the native-bridge APIs.
|
|
pub struct NativeBridge<R: Runtime>(PluginHandle<R>);
|
|
|
|
impl<R: Runtime> NativeBridge<R> {
|
|
pub fn auth_with_safari(&self, payload: AuthRequest) -> crate::Result<AuthResponse> {
|
|
self.0
|
|
.run_mobile_plugin("auth_with_safari", payload)
|
|
.map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
impl<R: Runtime> NativeBridge<R> {
|
|
pub fn auth_with_custom_tab(&self, payload: AuthRequest) -> crate::Result<AuthResponse> {
|
|
self.0
|
|
.run_mobile_plugin("auth_with_custom_tab", payload)
|
|
.map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
impl<R: Runtime> NativeBridge<R> {
|
|
pub fn copy_uri_to_path(&self, payload: CopyURIRequest) -> crate::Result<CopyURIResponse> {
|
|
self.0
|
|
.run_mobile_plugin("copy_uri_to_path", payload)
|
|
.map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
impl<R: Runtime> NativeBridge<R> {
|
|
pub fn use_background_audio(&self, payload: UseBackgroundAudioRequest) -> crate::Result<()> {
|
|
self.0
|
|
.run_mobile_plugin("use_background_audio", payload)
|
|
.map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
impl<R: Runtime> NativeBridge<R> {
|
|
pub fn install_package(
|
|
&self,
|
|
payload: InstallPackageRequest,
|
|
) -> crate::Result<InstallPackageResponse> {
|
|
self.0
|
|
.run_mobile_plugin("install_package", payload)
|
|
.map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
impl<R: Runtime> NativeBridge<R> {
|
|
pub fn set_system_ui_visibility(
|
|
&self,
|
|
payload: SetSystemUIVisibilityRequest,
|
|
) -> crate::Result<SetSystemUIVisibilityResponse> {
|
|
self.0
|
|
.run_mobile_plugin("set_system_ui_visibility", payload)
|
|
.map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
impl<R: Runtime> NativeBridge<R> {
|
|
pub fn get_status_bar_height(&self) -> crate::Result<GetStatusBarHeightResponse> {
|
|
self.0
|
|
.run_mobile_plugin("get_status_bar_height", ())
|
|
.map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
impl<R: Runtime> NativeBridge<R> {
|
|
pub fn get_sys_fonts_list(&self) -> crate::Result<GetSysFontsListResponse> {
|
|
self.0
|
|
.run_mobile_plugin("get_sys_fonts_list", ())
|
|
.map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
impl<R: Runtime> NativeBridge<R> {
|
|
pub fn intercept_keys(&self, payload: InterceptKeysRequest) -> crate::Result<()> {
|
|
self.0
|
|
.run_mobile_plugin("intercept_keys", payload)
|
|
.map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
impl<R: Runtime> NativeBridge<R> {
|
|
pub fn lock_screen_orientation(
|
|
&self,
|
|
payload: LockScreenOrientationRequest,
|
|
) -> crate::Result<()> {
|
|
self.0
|
|
.run_mobile_plugin("lock_screen_orientation", payload)
|
|
.map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
impl<R: Runtime> NativeBridge<R> {
|
|
pub fn iap_is_available(&self) -> crate::Result<IAPIsAvailableResponse> {
|
|
self.0
|
|
.run_mobile_plugin("iap_is_available", ())
|
|
.map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
impl<R: Runtime> NativeBridge<R> {
|
|
pub fn iap_initialize(
|
|
&self,
|
|
payload: IAPInitializeRequest,
|
|
) -> crate::Result<IAPInitializeResponse> {
|
|
self.0
|
|
.run_mobile_plugin("iap_initialize", payload)
|
|
.map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
impl<R: Runtime> NativeBridge<R> {
|
|
pub fn iap_fetch_products(
|
|
&self,
|
|
payload: IAPFetchProductsRequest,
|
|
) -> crate::Result<IAPFetchProductsResponse> {
|
|
self.0
|
|
.run_mobile_plugin("iap_fetch_products", payload)
|
|
.map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
impl<R: Runtime> NativeBridge<R> {
|
|
pub fn iap_purchase_product(
|
|
&self,
|
|
payload: IAPPurchaseProductRequest,
|
|
) -> crate::Result<IAPPurchaseProductResponse> {
|
|
self.0
|
|
.run_mobile_plugin("iap_purchase_product", payload)
|
|
.map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
impl<R: Runtime> NativeBridge<R> {
|
|
pub fn iap_restore_purchases(&self) -> crate::Result<IAPRestorePurchasesResponse> {
|
|
self.0
|
|
.run_mobile_plugin("iap_restore_purchases", ())
|
|
.map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
impl<R: Runtime> NativeBridge<R> {
|
|
pub fn get_system_color_scheme(&self) -> crate::Result<GetSystemColorSchemeResponse> {
|
|
self.0
|
|
.run_mobile_plugin("get_system_color_scheme", ())
|
|
.map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
impl<R: Runtime> NativeBridge<R> {
|
|
pub fn get_safe_area_insets(&self) -> crate::Result<GetSafeAreaInsetsResponse> {
|
|
self.0
|
|
.run_mobile_plugin("get_safe_area_insets", ())
|
|
.map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
impl<R: Runtime> NativeBridge<R> {
|
|
pub fn get_screen_brightness(&self) -> crate::Result<GetScreenBrightnessResponse> {
|
|
self.0
|
|
.run_mobile_plugin("get_screen_brightness", ())
|
|
.map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
impl<R: Runtime> NativeBridge<R> {
|
|
pub fn set_screen_brightness(
|
|
&self,
|
|
payload: SetScreenBrightnessRequest,
|
|
) -> crate::Result<SetScreenBrightnessResponse> {
|
|
self.0
|
|
.run_mobile_plugin("set_screen_brightness", payload)
|
|
.map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
impl<R: Runtime> NativeBridge<R> {
|
|
pub fn get_external_sdcard_path(&self) -> crate::Result<GetExternalSDCardPathResponse> {
|
|
self.0
|
|
.run_mobile_plugin("get_external_sdcard_path", ())
|
|
.map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
impl<R: Runtime> NativeBridge<R> {
|
|
pub fn open_external_url(
|
|
&self,
|
|
payload: OpenExternalUrlRequest,
|
|
) -> crate::Result<OpenExternalUrlResponse> {
|
|
self.0
|
|
.run_mobile_plugin("open_external_url", payload)
|
|
.map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
impl<R: Runtime> NativeBridge<R> {
|
|
pub fn show_lookup_popover(
|
|
&self,
|
|
payload: ShowLookupPopoverRequest,
|
|
) -> crate::Result<ShowLookupPopoverResponse> {
|
|
self.0
|
|
.run_mobile_plugin("show_lookup_popover", payload)
|
|
.map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
impl<R: Runtime> NativeBridge<R> {
|
|
pub fn select_directory(&self) -> crate::Result<SelectDirectoryResponse> {
|
|
self.0
|
|
.run_mobile_plugin("select_directory", ())
|
|
.map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
impl<R: Runtime> NativeBridge<R> {
|
|
pub fn get_storefront_region_code(&self) -> crate::Result<GetStorefrontRegionCodeResponse> {
|
|
self.0
|
|
.run_mobile_plugin("get_storefront_region_code", ())
|
|
.map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
impl<R: Runtime> NativeBridge<R> {
|
|
pub fn request_manage_storage_permission(
|
|
&self,
|
|
) -> crate::Result<RequestManageStoragePermissionResponse> {
|
|
self.0
|
|
.run_mobile_plugin("request_manage_storage_permission", ())
|
|
.map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
impl<R: Runtime> NativeBridge<R> {
|
|
pub fn set_sync_passphrase(
|
|
&self,
|
|
payload: SetSyncPassphraseRequest,
|
|
) -> crate::Result<SyncPassphraseResponse> {
|
|
self.0
|
|
.run_mobile_plugin("set_sync_passphrase", payload)
|
|
.map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
impl<R: Runtime> NativeBridge<R> {
|
|
pub fn get_sync_passphrase(&self) -> crate::Result<GetSyncPassphraseResponse> {
|
|
self.0
|
|
.run_mobile_plugin("get_sync_passphrase", ())
|
|
.map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
impl<R: Runtime> NativeBridge<R> {
|
|
pub fn clear_sync_passphrase(&self) -> crate::Result<SyncPassphraseResponse> {
|
|
self.0
|
|
.run_mobile_plugin("clear_sync_passphrase", ())
|
|
.map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
impl<R: Runtime> NativeBridge<R> {
|
|
pub fn is_sync_keychain_available(&self) -> crate::Result<SyncKeychainAvailableResponse> {
|
|
self.0
|
|
.run_mobile_plugin("is_sync_keychain_available", ())
|
|
.map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
impl<R: Runtime> NativeBridge<R> {
|
|
/// Open a full-screen `WKWebView` / `WebView` over the main app,
|
|
/// navigate to `payload.url` with a real Chrome UA, wait for load
|
|
/// + settle, then return `document.documentElement.outerHTML`. The
|
|
/// overlay UX (loading spinner, theme-matched backdrop, localized
|
|
/// labels) is driven by the same fields the desktop `clip_url`
|
|
/// command consumes — see `clip_url.rs` for the canonical struct.
|
|
pub fn clip_url(&self, payload: ClipUrlRequest) -> crate::Result<ClipUrlResponse> {
|
|
self.0
|
|
.run_mobile_plugin("clip_url", payload)
|
|
.map_err(Into::into)
|
|
}
|
|
}
|