diff --git a/Cargo.lock b/Cargo.lock
index 25a16c04..ccf94eb2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -7543,8 +7543,6 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7"
dependencies = [
"base64 0.21.7",
"serde",
@@ -8265,9 +8263,13 @@ name = "tauri-plugin-native-bridge"
version = "0.1.0"
dependencies = [
"apple-native-keyring-store",
+ "base64 0.22.1",
+ "block",
+ "cocoa",
"dbus-secret-service-keyring-store",
"font-enumeration",
"keyring-core",
+ "objc",
"schemars 0.8.22",
"serde",
"serde_json",
diff --git a/Cargo.toml b/Cargo.toml
index 2ca3c71f..1bb082c7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -40,3 +40,7 @@ rust-version = "1.77.2"
[patch.crates-io]
tauri = { path = "packages/tauri/crates/tauri" }
tauri-plugin-fs = { path = "packages/tauri-plugins/plugins/fs" }
+# Xcode 26.2 (Swift 6.2) broke upstream swift-rs 1.0.7's per-swiftc target
+# override; the vendored copy cross-compiles via `--triple`/`--sdk` instead.
+# Upstream is unmaintained (last release 2024). See packages/swift-rs.
+swift-rs = { path = "packages/swift-rs" }
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/Cargo.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/Cargo.toml
index 6e52db8b..a09ff61b 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/Cargo.toml
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/Cargo.toml
@@ -37,6 +37,16 @@ keyring-core = "1"
[target.'cfg(target_os = "macos")'.dependencies]
apple-native-keyring-store = { version = "1", features = ["keychain"] }
+# WKWebView snapshot for the mesh page-curl texture (#555) — same
+# dynamic-ObjC stack the main app's `src/macos/` modules use.
+objc = "0.2.7"
+cocoa = "0.25"
+block = "0.1.6"
+
+[target.'cfg(any(target_os = "ios", target_os = "android"))'.dependencies]
+# The Swift/Kotlin plugin boundary is JSON-only, so webview snapshots
+# arrive base64-encoded and are decoded back to bytes in mobile.rs.
+base64 = "0.22"
[target.'cfg(target_os = "windows")'.dependencies]
windows-native-keyring-store = "1"
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt
index 86f5b6dc..d7f2aed7 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt
@@ -19,7 +19,13 @@ import android.view.KeyEvent
import android.view.WindowInsets
import android.view.WindowManager
import android.view.WindowInsetsController
+import android.graphics.Bitmap
import android.graphics.Color
+import android.graphics.Rect
+import android.os.Handler
+import android.os.Looper
+import android.util.Base64
+import android.view.PixelCopy
import android.webkit.WebView
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
@@ -123,6 +129,14 @@ class UpdateReadingWidgetBookArgs {
var coverPath: String = ""
}
+@InvokeArg
+class CaptureWebviewRegionArgs {
+ var x: Float = 0f
+ var y: Float = 0f
+ var width: Float = 0f
+ var height: Float = 0f
+}
+
@InvokeArg
class UpdateReadingWidgetTtsArgs {
var active: Boolean = false
@@ -177,6 +191,7 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
private val implementation = NativeBridge()
private var redirectScheme = "readest"
private var redirectHost = "auth-callback"
+ private var webViewRef: WebView? = null
private val billingManager by lazy {
BillingManager(activity)
}
@@ -202,6 +217,7 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
override fun load(webView: WebView) {
instance = this
+ webViewRef = webView
super.load(webView)
handleIntent(activity.intent)
}
@@ -1511,6 +1527,77 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
invoke.resolve(ret)
}
}
+
+ /**
+ * Snapshot a region of the webview for the mesh page-curl texture
+ * (readest#555). The rect arrives in CSS pixels of the JS viewport;
+ * scaling by the display density (devicePixelRatio) maps it to window
+ * pixels. PixelCopy reads back from the window surface, which includes
+ * the hardware-accelerated WebView that View.draw would miss. Resolved
+ * as base64 because the plugin boundary is JSON-only; the Rust side
+ * decodes back to bytes.
+ *
+ * The result is JPEG, not PNG: a full-screen 3x PNG took ~1.5s to
+ * encode per page turn on a Xiaomi 13, which read as the curl not
+ * working at all. The page is opaque so JPEG loses nothing visible,
+ * and the destination bitmap is capped at 2x CSS pixels — PixelCopy
+ * scales into a smaller bitmap for free and the moving page stays
+ * visually sharp.
+ */
+ @Command
+ fun capture_webview_region(invoke: Invoke) {
+ val args = invoke.parseArgs(CaptureWebviewRegionArgs::class.java)
+ val webView = webViewRef
+ val window = activity.window
+ if (webView == null || window == null) {
+ invoke.reject("WebView not available")
+ return
+ }
+ activity.runOnUiThread {
+ val density = webView.resources.displayMetrics.density
+ val location = IntArray(2)
+ webView.getLocationInWindow(location)
+ val left = location[0] + (args.x * density).toInt()
+ val top = location[1] + (args.y * density).toInt()
+ val width = (args.width * density).toInt()
+ val height = (args.height * density).toInt()
+ if (width <= 0 || height <= 0) {
+ invoke.reject("Empty capture region")
+ return@runOnUiThread
+ }
+ val captureScale = minOf(density, 2f)
+ val destWidth = (args.width * captureScale).toInt()
+ val destHeight = (args.height * captureScale).toInt()
+ val bitmap = Bitmap.createBitmap(destWidth, destHeight, Bitmap.Config.ARGB_8888)
+ try {
+ PixelCopy.request(
+ window,
+ Rect(left, top, left + width, top + height),
+ bitmap,
+ { result ->
+ if (result == PixelCopy.SUCCESS) {
+ // Encode off the main thread; ~100ms of work for
+ // a full-screen 2x JPEG.
+ pluginScope.launch {
+ val data = withContext(Dispatchers.IO) {
+ val out = ByteArrayOutputStream()
+ bitmap.compress(Bitmap.CompressFormat.JPEG, 85, out)
+ Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
+ }
+ invoke.resolve(JSObject().put("data", data))
+ }
+ } else {
+ invoke.reject("PixelCopy failed: $result")
+ }
+ },
+ Handler(Looper.getMainLooper())
+ )
+ } catch (e: IllegalArgumentException) {
+ // Thrown when the rect falls outside the window bounds.
+ invoke.reject("Capture region out of bounds: ${e.message}")
+ }
+ }
+ }
}
@app.tauri.annotation.InvokeArg
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/build.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/build.rs
index 87f5434b..e11547b1 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/build.rs
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/build.rs
@@ -39,6 +39,7 @@ const COMMANDS: &[&str] = &[
"clear_secure_item",
"refresh_eink_screen",
"update_reading_widget",
+ "capture_webview_region",
];
fn main() {
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Package.swift b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Package.swift
index 11aec42c..f9ea75ac 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Package.swift
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Package.swift
@@ -7,7 +7,11 @@ let package = Package(
name: "tauri-plugin-native-bridge",
platforms: [
.macOS(.v10_13),
- .iOS(.v14),
+ // Matches the app's IPHONEOS_DEPLOYMENT_TARGET (15.0); StoreKit's
+ // Storefront is used unguarded and needs iOS 15. SPM takes the
+ // deployment floor from this stanza, not from the build triple.
+ // (String form: `.v15` needs swift-tools 5.5, this manifest is 5.3.)
+ .iOS("15.0"),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift
index c5c1e2e4..daa5c98d 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift
@@ -1558,6 +1558,47 @@ class NativeBridgePlugin: Plugin {
invoke.resolve()
}
}
+
+ /// Snapshot a region of the webview for the mesh page-curl texture
+ /// (#555). The rect is in CSS pixels of the JS viewport (== points of
+ /// the WKWebView). Like Android, the snapshot is capped at 2x CSS
+ /// pixels (Pro iPhones render at 3x) and encoded as JPEG — the page is
+ /// opaque and JPEG encodes several times faster than PNG, keeping the
+ /// dead time between the tap and the first curl frame short. Resolved
+ /// as base64 because the plugin boundary is JSON-only; the Rust side
+ /// decodes back to bytes.
+ @objc public func capture_webview_region(_ invoke: Invoke) {
+ guard let args = try? invoke.parseArgs(CaptureWebviewRegionArgs.self) else {
+ return invoke.reject("Failed to parse arguments")
+ }
+ DispatchQueue.main.async { [weak self] in
+ guard let webView = self?.webView else {
+ return invoke.reject("WebView not available")
+ }
+ let config = WKSnapshotConfiguration()
+ config.rect = CGRect(x: args.x, y: args.y, width: args.width, height: args.height)
+ // snapshotWidth is in points and the produced image is snapshotWidth
+ // x screen-scale pixels wide, so width x (2 / scale) points yields a
+ // 2x-CSS-pixel bitmap on 3x screens and native size elsewhere.
+ let scale = webView.window?.screen.scale ?? UIScreen.main.scale
+ if scale > 2 {
+ config.snapshotWidth = NSNumber(value: args.width * 2.0 / scale)
+ }
+ webView.takeSnapshot(with: config) { image, error in
+ guard let image = image else {
+ return invoke.reject(error?.localizedDescription ?? "Snapshot failed")
+ }
+ // Encode and base64 off the main thread; the completion arrives on
+ // main and a full-screen encode is fast but not free.
+ DispatchQueue.global(qos: .userInteractive).async {
+ guard let data = image.jpegData(compressionQuality: 0.85) else {
+ return invoke.reject("JPEG encoding failed")
+ }
+ invoke.resolve(["data": data.base64EncodedString()])
+ }
+ }
+ }
+ }
}
/// Persistent store for security-scoped folder bookmarks.
@@ -1800,6 +1841,13 @@ struct UpdateReadingWidgetRequestArgs: Decodable {
let emptyTitle: String
}
+struct CaptureWebviewRegionArgs: Decodable {
+ let x: Double
+ let y: Double
+ let width: Double
+ let height: Double
+}
+
@_cdecl("init_plugin_native_bridge")
func initPlugin() -> Plugin {
return NativeBridgePlugin()
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/capture_webview_region.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/capture_webview_region.toml
new file mode 100644
index 00000000..f4898e00
--- /dev/null
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/capture_webview_region.toml
@@ -0,0 +1,13 @@
+# Automatically generated - DO NOT EDIT!
+
+"$schema" = "../../schemas/schema.json"
+
+[[permission]]
+identifier = "allow-capture-webview-region"
+description = "Enables the capture_webview_region command without any pre-configured scope."
+commands.allow = ["capture_webview_region"]
+
+[[permission]]
+identifier = "deny-capture-webview-region"
+description = "Denies the capture_webview_region command without any pre-configured scope."
+commands.deny = ["capture_webview_region"]
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/reference.md b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/reference.md
index b042e7d1..7417a109 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/reference.md
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/reference.md
@@ -47,6 +47,7 @@ Default permissions for the plugin
- `allow-clear-secure-item`
- `allow-refresh-eink-screen`
- `allow-update-reading-widget`
+- `allow-capture-webview-region`
## Permission Table
@@ -112,6 +113,32 @@ Denies the auth_with_safari command without any pre-configured scope.
|
+`native-bridge:allow-capture-webview-region`
+
+ |
+
+
+Enables the capture_webview_region command without any pre-configured scope.
+
+ |
+
+
+
+|
+
+`native-bridge:deny-capture-webview-region`
+
+ |
+
+
+Denies the capture_webview_region command without any pre-configured scope.
+
+ |
+
+
+
+|
+
`native-bridge:allow-check-permissions`
|
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/default.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/default.toml
index 6d1d6b4f..95f634a9 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/default.toml
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/default.toml
@@ -44,4 +44,5 @@ permissions = [
"allow-clear-secure-item",
"allow-refresh-eink-screen",
"allow-update-reading-widget",
+ "allow-capture-webview-region",
]
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/schemas/schema.json b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/schemas/schema.json
index 7a7b0678..93b1db17 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/schemas/schema.json
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/schemas/schema.json
@@ -318,6 +318,18 @@
"const": "deny-auth-with-safari",
"markdownDescription": "Denies the auth_with_safari command without any pre-configured scope."
},
+ {
+ "description": "Enables the capture_webview_region command without any pre-configured scope.",
+ "type": "string",
+ "const": "allow-capture-webview-region",
+ "markdownDescription": "Enables the capture_webview_region command without any pre-configured scope."
+ },
+ {
+ "description": "Denies the capture_webview_region command without any pre-configured scope.",
+ "type": "string",
+ "const": "deny-capture-webview-region",
+ "markdownDescription": "Denies the capture_webview_region command without any pre-configured scope."
+ },
{
"description": "Enables the check-permissions command without any pre-configured scope.",
"type": "string",
@@ -847,10 +859,10 @@
"markdownDescription": "Denies the use_background_audio command without any pre-configured scope."
},
{
- "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-save-image-to-gallery`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-show-lookup-popover`\n- `allow-get-lookup-dictionary`\n- `allow-clear-lookup-dictionary`\n- `allow-select-directory`\n- `allow-get-storefront-region-code`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`\n- `allow-set-sync-passphrase`\n- `allow-get-sync-passphrase`\n- `allow-clear-sync-passphrase`\n- `allow-is-sync-keychain-available`\n- `allow-set-secure-item`\n- `allow-get-secure-item`\n- `allow-clear-secure-item`\n- `allow-refresh-eink-screen`\n- `allow-update-reading-widget`",
+ "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-save-image-to-gallery`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-show-lookup-popover`\n- `allow-get-lookup-dictionary`\n- `allow-clear-lookup-dictionary`\n- `allow-select-directory`\n- `allow-get-storefront-region-code`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`\n- `allow-set-sync-passphrase`\n- `allow-get-sync-passphrase`\n- `allow-clear-sync-passphrase`\n- `allow-is-sync-keychain-available`\n- `allow-set-secure-item`\n- `allow-get-secure-item`\n- `allow-clear-secure-item`\n- `allow-refresh-eink-screen`\n- `allow-update-reading-widget`\n- `allow-capture-webview-region`",
"type": "string",
"const": "default",
- "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-save-image-to-gallery`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-show-lookup-popover`\n- `allow-get-lookup-dictionary`\n- `allow-clear-lookup-dictionary`\n- `allow-select-directory`\n- `allow-get-storefront-region-code`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`\n- `allow-set-sync-passphrase`\n- `allow-get-sync-passphrase`\n- `allow-clear-sync-passphrase`\n- `allow-is-sync-keychain-available`\n- `allow-set-secure-item`\n- `allow-get-secure-item`\n- `allow-clear-secure-item`\n- `allow-refresh-eink-screen`\n- `allow-update-reading-widget`"
+ "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-save-image-to-gallery`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-show-lookup-popover`\n- `allow-get-lookup-dictionary`\n- `allow-clear-lookup-dictionary`\n- `allow-select-directory`\n- `allow-get-storefront-region-code`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`\n- `allow-set-sync-passphrase`\n- `allow-get-sync-passphrase`\n- `allow-clear-sync-passphrase`\n- `allow-is-sync-keychain-available`\n- `allow-set-secure-item`\n- `allow-get-secure-item`\n- `allow-clear-secure-item`\n- `allow-refresh-eink-screen`\n- `allow-update-reading-widget`\n- `allow-capture-webview-region`"
}
]
}
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/commands.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/commands.rs
index dcda99d4..3909c206 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/commands.rs
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/commands.rs
@@ -288,3 +288,19 @@ pub(crate) async fn update_reading_widget(
) -> Result<()> {
app.native_bridge().update_reading_widget(payload)
}
+
+/// Snapshot a region of the calling webview and return it as binary PNG
+/// (`tauri::ipc::Response`, no JSON encoding) for the mesh page-curl
+/// texture (#555). Platforms without a capture implementation reject,
+/// which the JS side treats as "fall back to the CSS curl".
+#[command]
+pub(crate) async fn capture_webview_region(
+ app: AppHandle,
+ window: tauri::WebviewWindow,
+ payload: CaptureWebviewRegionRequest,
+) -> Result {
+ let png = app
+ .native_bridge()
+ .capture_webview_region(&window, payload)?;
+ Ok(tauri::ipc::Response::new(png))
+}
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs
index ff7449ee..bbc95c86 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs
@@ -352,13 +352,31 @@ impl NativeBridge {
Err(crate::Error::UnsupportedPlatformError)
}
- pub fn update_reading_widget(
- &self,
- _payload: UpdateReadingWidgetRequest,
- ) -> crate::Result<()> {
+ pub fn update_reading_widget(&self, _payload: UpdateReadingWidgetRequest) -> crate::Result<()> {
// Home-screen widgets are mobile-only; desktop is a no-op.
Ok(())
}
+
+ /// Snapshot a region of `window`'s webview as PNG bytes for the mesh
+ /// page-curl texture (#555). macOS only so far; Windows
+ /// (`ICoreWebView2::CapturePreview`) and Linux
+ /// (`webkit_web_view_get_snapshot`) reject until implemented, and the
+ /// JS side falls back to the CSS curl.
+ pub fn capture_webview_region(
+ &self,
+ window: &tauri::WebviewWindow,
+ payload: CaptureWebviewRegionRequest,
+ ) -> crate::Result> {
+ #[cfg(target_os = "macos")]
+ {
+ crate::platform::macos::capture_webview_region(window, payload)
+ }
+ #[cfg(not(target_os = "macos"))]
+ {
+ let _ = (window, payload);
+ Err(crate::Error::UnsupportedPlatformError)
+ }
+ }
}
const KEYRING_SERVICE: &str = "Readest Safe Storage";
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/lib.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/lib.rs
index 90c250d1..54899bdf 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/lib.rs
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/lib.rs
@@ -90,6 +90,7 @@ pub fn init() -> TauriPlugin {
commands::clear_secure_item,
commands::refresh_eink_screen,
commands::update_reading_widget,
+ commands::capture_webview_region,
])
.setup(|app, api| {
#[cfg(mobile)]
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs
index 4ba5d6a9..9c7874b5 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs
@@ -361,3 +361,24 @@ impl NativeBridge {
.map_err(Into::into)
}
}
+
+impl NativeBridge {
+ /// Snapshot a region of the webview as PNG bytes for the mesh
+ /// page-curl texture (#555). The Swift (WKWebView takeSnapshot) or
+ /// Kotlin (PixelCopy) side resolves JSON, so the image arrives
+ /// base64-encoded and is decoded here; the JS-facing command then
+ /// returns it binary.
+ pub fn capture_webview_region(
+ &self,
+ _window: &tauri::WebviewWindow,
+ payload: CaptureWebviewRegionRequest,
+ ) -> crate::Result> {
+ use base64::Engine as _;
+ let response: CaptureWebviewRegionResponse = self
+ .0
+ .run_mobile_plugin("capture_webview_region", payload)?;
+ base64::engine::general_purpose::STANDARD
+ .decode(response.data)
+ .map_err(|e| crate::Error::NativeBridgeError(format!("invalid base64 PNG: {e}")))
+ }
+}
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs
index 2bab7153..9dd678df 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs
@@ -432,3 +432,24 @@ pub struct UpdateReadingWidgetRequest {
#[serde(default)]
pub tts: Option,
}
+
+/// Region of the webview to snapshot for the mesh page-curl (#555),
+/// in CSS pixels of the webview viewport (origin top-left). The native
+/// side applies the screen scale factor.
+#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct CaptureWebviewRegionRequest {
+ pub x: f64,
+ pub y: f64,
+ pub width: f64,
+ pub height: f64,
+}
+
+/// Mobile-side response: Swift/Kotlin can only resolve JSON, so the
+/// PNG crosses the plugin boundary base64-encoded; `mobile.rs` decodes
+/// it back to bytes so the JS-facing command stays binary.
+#[derive(Debug, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct CaptureWebviewRegionResponse {
+ pub data: String,
+}
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/platform/macos.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/platform/macos.rs
index 8b137891..93318259 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/platform/macos.rs
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/platform/macos.rs
@@ -1 +1,129 @@
+//! WKWebView region snapshot for the mesh page-curl (#555).
+//!
+//! `WKWebView takeSnapshotWithConfiguration:completionHandler:` renders
+//! the current page (annotations and all) into an NSImage without
+//! flushing or disturbing the live view. The rect is in the web view's
+//! own coordinate space, which for a standard Tauri window matches the
+//! JS viewport's CSS pixels; the snapshot itself comes back at the
+//! screen's backing scale, so callers get a Retina-resolution PNG.
+use std::sync::mpsc;
+use std::sync::Mutex;
+use std::time::Duration;
+
+use block::ConcreteBlock;
+use cocoa::base::{id, nil};
+use cocoa::foundation::{NSPoint, NSRect, NSSize};
+use objc::{class, msg_send, sel, sel_impl};
+use tauri::Runtime;
+
+use crate::models::CaptureWebviewRegionRequest;
+
+/// `NSBitmapImageFileTypePNG`
+const NS_BITMAP_IMAGE_FILE_TYPE_PNG: u64 = 4;
+
+/// How long to wait for WebKit before giving up. Snapshots normally
+/// complete within a frame or two; a timeout means the JS side should
+/// fall back to the CSS curl rather than stall the page turn.
+const SNAPSHOT_TIMEOUT: Duration = Duration::from_millis(500);
+
+pub fn capture_webview_region(
+ window: &tauri::WebviewWindow,
+ payload: CaptureWebviewRegionRequest,
+) -> crate::Result> {
+ let (tx, rx) = mpsc::channel::, String>>();
+ window
+ .with_webview(move |webview| unsafe {
+ // Runs on the main thread; `inner()` is the WKWebView.
+ take_snapshot_png(webview.inner() as id, payload, tx);
+ })
+ .map_err(|e| crate::Error::NativeBridgeError(e.to_string()))?;
+ match rx.recv_timeout(SNAPSHOT_TIMEOUT) {
+ Ok(Ok(png)) => Ok(png),
+ Ok(Err(err)) => Err(crate::Error::NativeBridgeError(err)),
+ Err(_) => Err(crate::Error::NativeBridgeError(
+ "webview snapshot timed out".into(),
+ )),
+ }
+}
+
+/// SAFETY: must run on the main thread; `webview` must be a live WKWebView.
+unsafe fn take_snapshot_png(
+ webview: id,
+ payload: CaptureWebviewRegionRequest,
+ tx: mpsc::Sender, String>>,
+) {
+ if webview.is_null() {
+ let _ = tx.send(Err("webview handle is null".into()));
+ return;
+ }
+ let config: id = msg_send![class!(WKSnapshotConfiguration), new];
+ let rect = NSRect::new(
+ NSPoint::new(payload.x, payload.y),
+ NSSize::new(payload.width, payload.height),
+ );
+ let _: () = msg_send![config, setRect: rect];
+
+ // `ConcreteBlock` requires `Fn`, but the sender must move out on the
+ // (single) completion call — park it in a Mutex