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>
340 lines
9.7 KiB
Rust
340 lines
9.7 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AuthRequest {
|
|
pub auth_url: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AuthResponse {
|
|
pub redirect_url: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct CopyURIRequest {
|
|
pub uri: String,
|
|
pub dst: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct CopyURIResponse {
|
|
pub success: bool,
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct UseBackgroundAudioRequest {
|
|
pub enabled: bool,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct InstallPackageRequest {
|
|
pub path: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct InstallPackageResponse {
|
|
pub success: bool,
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SetSystemUIVisibilityRequest {
|
|
pub visible: bool,
|
|
pub dark_mode: bool,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SetSystemUIVisibilityResponse {
|
|
pub success: bool,
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct GetStatusBarHeightResponse {
|
|
pub height: u32,
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct GetSysFontsListResponse {
|
|
pub fonts: HashMap<String, String>,
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct InterceptKeysRequest {
|
|
pub volume_keys: Option<bool>,
|
|
pub back_key: Option<bool>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct LockScreenOrientationRequest {
|
|
pub orientation: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Product {
|
|
pub id: String,
|
|
pub title: String,
|
|
pub description: String,
|
|
pub price: String,
|
|
pub price_currency_code: Option<String>,
|
|
pub price_amount_micros: i64,
|
|
pub product_type: String, // "consumable", "non_consumable", or "subscription"
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Purchase {
|
|
pub platform: String, // "ios" or "android"
|
|
pub package_name: Option<String>,
|
|
pub product_id: String,
|
|
pub transaction_id: Option<String>,
|
|
pub original_transaction_id: Option<String>,
|
|
pub order_id: Option<String>,
|
|
pub purchase_token: Option<String>,
|
|
pub purchase_date: String,
|
|
pub purchase_state: String, // "purchased", "pending", "cancelled", "restored"
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct IAPIsAvailableResponse {
|
|
pub available: bool,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct IAPInitializeRequest {
|
|
pub public_key: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct IAPInitializeResponse {
|
|
pub success: bool,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct IAPFetchProductsRequest {
|
|
pub product_ids: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct IAPFetchProductsResponse {
|
|
pub products: Vec<Product>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct IAPPurchaseProductRequest {
|
|
pub product_id: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct IAPPurchaseProductResponse {
|
|
pub purchase: Option<Purchase>,
|
|
pub cancelled_purchase: Option<Purchase>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct IAPRestorePurchasesResponse {
|
|
pub purchases: Vec<Purchase>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct GetSystemColorSchemeResponse {
|
|
pub color_scheme: String, // "light" or "dark"
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct GetSafeAreaInsetsResponse {
|
|
pub top: f64,
|
|
pub bottom: f64,
|
|
pub left: f64,
|
|
pub right: f64,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct GetScreenBrightnessResponse {
|
|
pub brightness: f64,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SetScreenBrightnessRequest {
|
|
pub brightness: f64, // 0.0 to 1.0
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SetScreenBrightnessResponse {
|
|
pub success: bool,
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct GetExternalSDCardPathResponse {
|
|
pub path: Option<String>,
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct RequestManageStoragePermissionResponse {
|
|
pub manage_storage: String, // "granted", "denied", or "prompt"
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct OpenExternalUrlRequest {
|
|
pub url: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct OpenExternalUrlResponse {
|
|
pub success: bool,
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
/// Hand a word off to the platform's native dictionary surface.
|
|
///
|
|
/// On iOS this presents `UIReferenceLibraryViewController` modally
|
|
/// (the same UI Apple uses for `Look Up` in UIKit text views). On
|
|
/// Android it dispatches `ACTION_PROCESS_TEXT` so any installed
|
|
/// dictionary app (ColorDict, GoldenDict, 欧路, etc.) can handle the
|
|
/// word; we don't bind to a specific package so users can stick with
|
|
/// their preferred dictionary. Desktop platforms return
|
|
/// `UnsupportedPlatformError` — macOS goes through a separate native
|
|
/// command in `src/macos/system_dictionary.rs` that uses the AppKit
|
|
/// HUD surface, which doesn't exist on iOS/Android.
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ShowLookupPopoverRequest {
|
|
pub word: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ShowLookupPopoverResponse {
|
|
pub success: bool,
|
|
/// `unavailable` is set on Android when no app responded to the
|
|
/// `ACTION_PROCESS_TEXT` intent (i.e. the user has no dictionary
|
|
/// installed). The TS layer can surface a "no dictionary app"
|
|
/// hint without us having to push a localized string from
|
|
/// native code.
|
|
pub unavailable: Option<bool>,
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SelectDirectoryResponse {
|
|
pub cancelled: Option<bool>,
|
|
pub uri: Option<String>,
|
|
pub path: Option<String>,
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct GetStorefrontRegionCodeResponse {
|
|
pub region_code: Option<String>,
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
// ── Sync passphrase keychain ────────────────────────────────────────────
|
|
//
|
|
// Persist the sync passphrase across app launches via the OS keychain
|
|
// so native users don't re-enter it every session. The replica-sync
|
|
// CryptoSession (TS side) reads/writes via these commands; web users
|
|
// keep using the in-memory ephemeral store.
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SetSyncPassphraseRequest {
|
|
pub passphrase: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SyncPassphraseResponse {
|
|
pub success: bool,
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct GetSyncPassphraseResponse {
|
|
/// Present iff a passphrase is stored. Absent (and `error: None`)
|
|
/// means "no entry on this device" — caller should prompt.
|
|
pub passphrase: Option<String>,
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
/// Args for the mobile URL-clip flow. Mirrors the public `ClipOptions`
|
|
/// struct in `clip_url.rs` so the JS caller can pass the same payload
|
|
/// to both desktop and mobile without branching. The native side honors
|
|
/// `background`/`foreground` for the overlay backdrop and `windowTitle`/
|
|
/// `overlayTitle`/`loadingStatus`/`capturingStatus`/`savedTitle` for the
|
|
/// chrome and progress labels.
|
|
#[derive(Debug, Default, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ClipUrlRequest {
|
|
pub url: String,
|
|
#[serde(default)]
|
|
pub window_title: Option<String>,
|
|
#[serde(default)]
|
|
pub overlay_title: Option<String>,
|
|
#[serde(default)]
|
|
pub loading_status: Option<String>,
|
|
#[serde(default)]
|
|
pub capturing_status: Option<String>,
|
|
#[serde(default)]
|
|
pub saved_title: Option<String>,
|
|
#[serde(default)]
|
|
pub background: Option<String>,
|
|
#[serde(default)]
|
|
pub foreground: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Default, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ClipUrlResponse {
|
|
/// Rendered `document.documentElement.outerHTML` captured from the
|
|
/// hidden WKWebView / WebView once load+settle completed.
|
|
pub html: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SyncKeychainAvailableResponse {
|
|
pub available: bool,
|
|
pub error: Option<String>,
|
|
}
|