Files
readest/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs
T
Huang Xin 17749f7cc7 feat(send): mobile URL clipping via native-bridge plugin (#4252)
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>
2026-05-21 16:00:53 +02:00

289 lines
10 KiB
Rust

use serde::de::DeserializeOwned;
use std::collections::HashMap;
use tauri::{plugin::PluginApi, AppHandle, Runtime};
use crate::models::*;
pub fn init<R: Runtime, C: DeserializeOwned>(
app: &AppHandle<R>,
_api: PluginApi<R, C>,
) -> crate::Result<NativeBridge<R>> {
// keyring v4 split the library into `keyring-core` plus a
// per-platform credential-store crate. The default store is a
// process-wide global that must be installed before the first
// `Entry::new` call. `set_default_store` is idempotent — calling
// it again on plugin re-init just replaces the previous handle.
// We log and swallow errors so a misconfigured keychain doesn't
// block plugin init; downstream calls then fail with NoDefaultStore
// and the TS layer falls back to the ephemeral store.
install_default_keyring_store();
Ok(NativeBridge(app.clone()))
}
#[cfg(target_os = "macos")]
fn install_default_keyring_store() {
match apple_native_keyring_store::keychain::Store::new() {
Ok(store) => keyring_core::set_default_store(store),
Err(err) => eprintln!("[native-bridge] keychain store init failed: {err}"),
}
}
#[cfg(target_os = "windows")]
fn install_default_keyring_store() {
match windows_native_keyring_store::Store::new() {
Ok(store) => keyring_core::set_default_store(store),
Err(err) => eprintln!("[native-bridge] credential manager init failed: {err}"),
}
}
#[cfg(target_os = "linux")]
fn install_default_keyring_store() {
match dbus_secret_service_keyring_store::Store::new() {
Ok(store) => keyring_core::set_default_store(store),
Err(err) => eprintln!("[native-bridge] secret service init failed: {err}"),
}
}
/// Access to the native-bridge APIs.
pub struct NativeBridge<R: Runtime>(AppHandle<R>);
impl<R: Runtime> NativeBridge<R> {
pub fn auth_with_safari(&self, _payload: AuthRequest) -> crate::Result<AuthResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn auth_with_custom_tab(&self, _payload: AuthRequest) -> crate::Result<AuthResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn copy_uri_to_path(&self, _payload: CopyURIRequest) -> crate::Result<CopyURIResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn use_background_audio(&self, _payload: UseBackgroundAudioRequest) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn install_package(
&self,
_payload: InstallPackageRequest,
) -> crate::Result<InstallPackageResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn set_system_ui_visibility(
&self,
_payload: SetSystemUIVisibilityRequest,
) -> crate::Result<SetSystemUIVisibilityResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn get_status_bar_height(&self) -> crate::Result<GetStatusBarHeightResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn get_sys_fonts_list(&self) -> crate::Result<GetSysFontsListResponse> {
let font_collection = font_enumeration::Collection::new().unwrap();
let mut fonts = HashMap::new();
for font in font_collection.all() {
if cfg!(target_os = "windows") {
// FIXME: temporarily disable font name with style for windows
fonts.insert(font.family_name.clone(), font.family_name.clone());
} else {
fonts.insert(font.font_name.clone(), font.family_name.clone());
}
}
Ok(GetSysFontsListResponse { fonts, error: None })
}
pub fn intercept_keys(&self, _payload: InterceptKeysRequest) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn lock_screen_orientation(
&self,
_payload: LockScreenOrientationRequest,
) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn iap_is_available(&self) -> crate::Result<IAPIsAvailableResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn iap_initialize(
&self,
_payload: IAPInitializeRequest,
) -> crate::Result<IAPInitializeResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn iap_fetch_products(
&self,
_payload: IAPFetchProductsRequest,
) -> crate::Result<IAPFetchProductsResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn iap_purchase_product(
&self,
_payload: IAPPurchaseProductRequest,
) -> crate::Result<IAPPurchaseProductResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn iap_restore_purchases(&self) -> crate::Result<IAPRestorePurchasesResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn get_system_color_scheme(&self) -> crate::Result<GetSystemColorSchemeResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn get_safe_area_insets(&self) -> crate::Result<GetSafeAreaInsetsResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn get_screen_brightness(&self) -> crate::Result<GetScreenBrightnessResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn set_screen_brightness(
&self,
_payload: SetScreenBrightnessRequest,
) -> crate::Result<SetScreenBrightnessResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn get_external_sdcard_path(&self) -> crate::Result<GetExternalSDCardPathResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn open_external_url(
&self,
_payload: OpenExternalUrlRequest,
) -> crate::Result<OpenExternalUrlResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
/// Desktop has no mobile-style "system dictionary intent" surface;
/// macOS's HUD is invoked through a separate top-level Tauri
/// command (`show_lookup_popover` in `src/macos/system_dictionary.rs`),
/// and Linux/Windows have no native target. Return
/// UnsupportedPlatformError here so the TS layer doesn't
/// accidentally dispatch through the mobile plugin on desktop.
pub fn show_lookup_popover(
&self,
_payload: ShowLookupPopoverRequest,
) -> crate::Result<ShowLookupPopoverResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn select_directory(&self) -> crate::Result<SelectDirectoryResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn get_storefront_region_code(&self) -> crate::Result<GetStorefrontRegionCodeResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn request_manage_storage_permission(
&self,
) -> crate::Result<RequestManageStoragePermissionResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
// ── Sync passphrase keychain ────────────────────────────────────────
//
// Uses `keyring-core` v1 with a platform-specific credential store
// installed in `init()` above:
// * macOS → Security framework Keychain (apple-native-keyring-store)
// * Windows → Credential Manager (windows-native-keyring-store)
// * Linux → Secret Service (dbus-secret-service-keyring-store)
//
// `service` and `user` form the keychain item identity. Service is
// the bundle id; user is a stable string ("default") so multiple
// Readest installs on the same machine could coexist with distinct
// user values if ever needed.
pub fn set_sync_passphrase(
&self,
payload: SetSyncPassphraseRequest,
) -> crate::Result<SyncPassphraseResponse> {
match keyring_entry().and_then(|e| e.set_password(&payload.passphrase)) {
Ok(()) => Ok(SyncPassphraseResponse {
success: true,
error: None,
}),
Err(err) => Ok(SyncPassphraseResponse {
success: false,
error: Some(err.to_string()),
}),
}
}
pub fn get_sync_passphrase(&self) -> crate::Result<GetSyncPassphraseResponse> {
match keyring_entry().and_then(|e| e.get_password()) {
Ok(passphrase) => Ok(GetSyncPassphraseResponse {
passphrase: Some(passphrase),
error: None,
}),
Err(keyring_core::Error::NoEntry) => Ok(GetSyncPassphraseResponse {
passphrase: None,
error: None,
}),
Err(err) => Ok(GetSyncPassphraseResponse {
passphrase: None,
error: Some(err.to_string()),
}),
}
}
pub fn clear_sync_passphrase(&self) -> crate::Result<SyncPassphraseResponse> {
match keyring_entry().and_then(|e| e.delete_credential()) {
Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(SyncPassphraseResponse {
success: true,
error: None,
}),
Err(err) => Ok(SyncPassphraseResponse {
success: false,
error: Some(err.to_string()),
}),
}
}
pub fn is_sync_keychain_available(&self) -> crate::Result<SyncKeychainAvailableResponse> {
// Best-effort probe: open an entry handle. Surface the error
// string instead of throwing so the TS layer can fall back
// to the ephemeral store gracefully.
match keyring_entry() {
Ok(_) => Ok(SyncKeychainAvailableResponse {
available: true,
error: None,
}),
Err(err) => Ok(SyncKeychainAvailableResponse {
available: false,
error: Some(err.to_string()),
}),
}
}
/// Desktop has its own URL-clip path (`src/clip_url.rs` spawns a
/// hidden `WebviewWindow` and listens on `127.0.0.1`). The plugin
/// branch is mobile-only — if anyone calls into it from desktop,
/// surface that mistake instead of silently returning empty HTML.
pub fn clip_url(&self, _payload: ClipUrlRequest) -> crate::Result<ClipUrlResponse> {
Err(crate::Error::NativeBridgeError(
"clip_url plugin is mobile-only; desktop callers should invoke the top-level command"
.to_string(),
))
}
}
const KEYRING_SERVICE: &str = "Readest Safe Storage";
const KEYRING_USER: &str = "default";
fn keyring_entry() -> std::result::Result<keyring_core::Entry, keyring_core::Error> {
keyring_core::Entry::new(KEYRING_SERVICE, KEYRING_USER)
}