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>. + let tx_cell = Mutex::new(Some(tx)); + let block = ConcreteBlock::new(move |image: id, error: id| { + let Some(tx) = tx_cell.lock().ok().and_then(|mut guard| guard.take()) else { + return; + }; + let _ = tx.send(unsafe { png_from_snapshot(image, error) }); + }); + // WebKit copies the handler it stores, so our reference can drop + // when this closure returns. + let block = block.copy(); + let _: () = + msg_send![webview, takeSnapshotWithConfiguration: config completionHandler: &*block]; + let _: () = msg_send![config, release]; +} + +/// SAFETY: main thread, called from the snapshot completion handler. +unsafe fn png_from_snapshot(image: id, error: id) -> Result, String> { + if image == nil { + return Err(describe_nserror(error)); + } + // NSImage → NSBitmapImageRep → PNG. TIFFRepresentation is an extra + // copy but avoids dropping to CoreGraphics for a once-per-turn call. + let tiff: id = msg_send![image, TIFFRepresentation]; + if tiff == nil { + return Err("snapshot has no TIFF representation".into()); + } + let rep: id = msg_send![class!(NSBitmapImageRep), imageRepWithData: tiff]; + if rep == nil { + return Err("snapshot TIFF not decodable".into()); + } + let props: id = msg_send![class!(NSDictionary), dictionary]; + let png: id = + msg_send![rep, representationUsingType: NS_BITMAP_IMAGE_FILE_TYPE_PNG properties: props]; + if png == nil { + return Err("PNG encoding failed".into()); + } + let len: usize = msg_send![png, length]; + let bytes: *const u8 = msg_send![png, bytes]; + if bytes.is_null() || len == 0 { + return Err("PNG encoding produced no data".into()); + } + Ok(std::slice::from_raw_parts(bytes, len).to_vec()) +} + +/// SAFETY: `error` is an NSError or nil. +unsafe fn describe_nserror(error: id) -> String { + if error == nil { + return "snapshot returned no image".into(); + } + let desc: id = msg_send![error, localizedDescription]; + if desc == nil { + return "snapshot failed".into(); + } + let utf8: *const std::os::raw::c_char = msg_send![desc, UTF8String]; + if utf8.is_null() { + return "snapshot failed".into(); + } + std::ffi::CStr::from_ptr(utf8) + .to_string_lossy() + .into_owned() +} diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/tests/capture_webview_region.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/tests/capture_webview_region.rs new file mode 100644 index 00000000..d3991623 --- /dev/null +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/tests/capture_webview_region.rs @@ -0,0 +1,18 @@ +use tauri_plugin_native_bridge::{CaptureWebviewRegionRequest, CaptureWebviewRegionResponse}; + +#[test] +fn deserializes_camel_case_payload() { + let json = r#"{"x": 0, "y": 44.5, "width": 402, "height": 700.25}"#; + let req: CaptureWebviewRegionRequest = serde_json::from_str(json).unwrap(); + assert_eq!(req.x, 0.0); + assert_eq!(req.y, 44.5); + assert_eq!(req.width, 402.0); + assert_eq!(req.height, 700.25); +} + +#[test] +fn deserializes_mobile_base64_response() { + let json = r#"{"data": "iVBORw0KGgo="}"#; + let res: CaptureWebviewRegionResponse = serde_json::from_str(json).unwrap(); + assert_eq!(res.data, "iVBORw0KGgo="); +} diff --git a/apps/readest-app/src/__tests__/document/paginator-turn-styles.browser.test.ts b/apps/readest-app/src/__tests__/document/paginator-turn-styles.browser.test.ts new file mode 100644 index 00000000..a46c39ef --- /dev/null +++ b/apps/readest-app/src/__tests__/document/paginator-turn-styles.browser.test.ts @@ -0,0 +1,302 @@ +import { describe, it, expect, beforeAll, afterEach } from 'vitest'; +import { DocumentLoader } from '@/libs/document'; +import type { BookDoc } from '@/libs/document'; +import type { Renderer } from '@/types/view'; + +// Tests for readest#555: Apple Books style page-turn animations. The `slide` +// and `curl` turn styles layer a View Transitions snapshot of the outgoing +// page over the live incoming page, so the page underneath stays still while +// the top page slides away or curls open. When the View Transitions API is +// unavailable the paginator falls back to the existing push animation. + +const LTR_EPUB_URL = new URL('../fixtures/data/sample-alice.epub', import.meta.url).href; +const VERTICAL_EPUB_URL = new URL('../fixtures/data/sample-vertical-rl.epub', import.meta.url).href; + +let ltrBook: BookDoc; +let verticalBook: BookDoc; + +const loadEPUB = async (url: string, name: string) => { + const resp = await fetch(url); + const buffer = await resp.arrayBuffer(); + const file = new File([buffer], name, { type: 'application/epub+zip' }); + const loader = new DocumentLoader(file); + const { book } = await loader.open(); + return book; +}; + +const waitForStabilized = (el: HTMLElement, timeout = 10000) => + new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('stabilized timeout')), timeout); + el.addEventListener( + 'stabilized', + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + }); + +const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +describe('Page turn styles (browser)', () => { + let paginator: Renderer; + + beforeAll(async () => { + ltrBook = await loadEPUB(LTR_EPUB_URL, 'sample-alice.epub'); + verticalBook = await loadEPUB(VERTICAL_EPUB_URL, 'sample-vertical-rl.epub'); + await import('foliate-js/paginator.js'); + }, 30000); + + const createPaginator = () => { + const el = document.createElement('foliate-paginator') as Renderer; + Object.assign(el.style, { + width: '800px', + height: '600px', + position: 'absolute', + left: '0', + top: '0', + }); + document.body.appendChild(el); + return el; + }; + + const setup = async (book: BookDoc, style: string, index = 3) => { + paginator = createPaginator(); + paginator.setAttribute('animated', ''); + paginator.setAttribute('turn-style', style); + paginator.open(book); + const stabilized = waitForStabilized(paginator); + await paginator.goTo({ index }); + await stabilized; + }; + + afterEach(async () => { + if (paginator) { + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + try { + paginator.destroy(); + } catch { + /* iframe body may already be torn down */ + } + paginator.remove(); + } + // A transition may still be running; let it finish before the next test. + await wait(600); + }); + + /** + * Sample the live view-transition animations mid-turn. Animation objects on + * the ::view-transition pseudos only exist while a transition is actually + * running (unlike getComputedStyle, which reports matched rules even + * without an active transition), and a layer styled `animation: none` has + * no entry at all — proving it sits still. + */ + const sampleTransition = async (timeout = 600) => { + const t0 = performance.now(); + while (performance.now() - t0 < timeout) { + const animations = document + .getAnimations() + .filter((a) => + (a.effect as KeyframeEffect | null)?.pseudoElement?.includes('(foliate-turn)'), + ); + if (animations.length) { + const byPseudo: Record = {}; + for (const a of animations) { + const pseudo = (a.effect as KeyframeEffect).pseudoElement!; + byPseudo[pseudo.replace('(foliate-turn)', '')] = (a as CSSAnimation).animationName; + } + return { + oldAnim: byPseudo['::view-transition-old'] ?? 'none', + newAnim: byPseudo['::view-transition-new'] ?? 'none', + }; + } + await new Promise((r) => requestAnimationFrame(r)); + } + return null; + }; + + it('slide keeps the incoming page still while the outgoing page slides away', async () => { + await setup(ltrBook, 'slide'); + const size = paginator.size; + const before = paginator.containerPosition; + + const turn = paginator.next(); + const sampled = await sampleTransition(); + expect(sampled).not.toBeNull(); + // Forward: the outgoing snapshot animates out; the incoming page has no + // motion of its own (it sits still underneath). + expect(sampled!.oldAnim).toContain('foliate-turn-slide-out'); + expect(sampled!.newAnim).toBe('none'); + await turn; + // The live content jumped to the destination under the snapshot. + expect(paginator.containerPosition).toBe(before + size); + + const back = paginator.prev(); + const sampledBack = await sampleTransition(); + expect(sampledBack).not.toBeNull(); + // Backward: the incoming snapshot slides in over the still outgoing page. + expect(sampledBack!.newAnim).toContain('foliate-turn-slide-in'); + expect(sampledBack!.oldAnim).toBe('none'); + await back; + expect(paginator.containerPosition).toBe(before); + }); + + it('curl folds the outgoing page open over the incoming page', async () => { + await setup(ltrBook, 'curl'); + const before = paginator.containerPosition; + const size = paginator.size; + + const turn = paginator.next(); + const sampled = await sampleTransition(); + expect(sampled).not.toBeNull(); + // Forward: the outgoing page folds away (an animated clip edge sweeps + // toward the spine); the incoming page sits still underneath. + expect(sampled!.oldAnim).toContain('foliate-turn-curl-fold'); + expect(sampled!.newAnim).toBe('none'); + // The fold visibly travels: the animated gradient stop re-rasterizes the + // mask, so the computed mask image changes over time. + const maskOf = () => + getComputedStyle(document.documentElement, '::view-transition-old(foliate-turn)').maskImage; + const maskA = maskOf(); + await wait(120); + const maskB = maskOf(); + expect(maskA).toContain('radial-gradient'); + expect(maskB).not.toBe(maskA); + await turn; + expect(paginator.containerPosition).toBe(before + size); + + const back = paginator.prev(); + const sampledBack = await sampleTransition(); + expect(sampledBack).not.toBeNull(); + // Backward: the outgoing page recedes from the spine side (Chrome does + // not paint masks on the live new layer), revealing the previous page. + expect(sampledBack!.oldAnim).toContain('foliate-turn-curl-fold'); + expect(sampledBack!.newAnim).toBe('none'); + await back; + expect(paginator.containerPosition).toBe(before); + }); + + it('works for vertical-rl books where pages stack along the scroll axis', async () => { + await setup(verticalBook, 'slide', 0); + const size = paginator.size; + const before = paginator.containerPosition; + + const turn = paginator.next(); + const sampled = await sampleTransition(); + expect(sampled).not.toBeNull(); + expect(sampled!.oldAnim).toContain('foliate-turn-slide-out'); + await turn; + expect(paginator.containerPosition).toBe(before + size); + }); + + const makeTouch = (x: number, y: number) => + new Touch({ identifier: 1, target: paginator, screenX: x, screenY: y, clientX: x, clientY: y }); + + const fireTouch = (type: string, x: number, y: number) => + paginator.dispatchEvent( + new TouchEvent(type, { + bubbles: true, + cancelable: true, + touches: type === 'touchend' ? [] : [makeTouch(x, y)], + changedTouches: [makeTouch(x, y)], + }), + ); + + /** The scrubbed turn's paused animations, keyed for inspection. */ + const scrubbedAnimations = () => + document + .getAnimations() + .filter((a) => + (a.effect as KeyframeEffect | null)?.pseudoElement?.includes('(foliate-turn)'), + ); + + it('tracks the finger: the paused snapshot follows the drag and commits on release', async () => { + await setup(ltrBook, 'slide'); + const page = paginator.page; + + // ltr: finger moves LEFT to go forward. + let x = 700; + fireTouch('touchstart', x, 300); + for (let i = 0; i < 6; i++) { + x -= 30; + fireTouch('touchmove', x, 300); + await wait(16); + } + // Mid-drag: the transition exists, is paused, and its progress tracks the + // finger (~180px of an 800px-wide page). + const anims = scrubbedAnimations(); + expect(anims.length).toBeGreaterThan(0); + expect(anims.every((a) => a.playState === 'paused')).toBe(true); + const timeA = Number(anims[0]!.currentTime); + expect(timeA).toBeGreaterThan(0); + x -= 60; + fireTouch('touchmove', x, 300); + await wait(30); + const timeB = Number(anims[0]!.currentTime); + expect(timeB).toBeGreaterThan(timeA); + + fireTouch('touchend', x, 300); + const t0 = performance.now(); + while (paginator.page !== page + 1 && performance.now() - t0 < 2000) await wait(50); + expect(paginator.page).toBe(page + 1); + }); + + it('tracks the finger: a mostly-returned drag reverses without turning', async () => { + await setup(ltrBook, 'slide'); + const page = paginator.page; + const before = paginator.containerPosition; + + let x = 700; + fireTouch('touchstart', x, 300); + for (let i = 0; i < 6; i++) { + x -= 30; + fireTouch('touchmove', x, 300); + await wait(16); + } + expect(scrubbedAnimations().length).toBeGreaterThan(0); + // Finger returns, rests, lifts: cancel. + for (let i = 0; i < 5; i++) { + x += 30; + fireTouch('touchmove', x, 300); + await wait(16); + } + await wait(150); + fireTouch('touchend', x, 300); + await wait(700); + expect(paginator.page).toBe(page); + expect(paginator.containerPosition).toBe(before); + expect(scrubbedAnimations().length).toBe(0); + }); + + it('falls back to the push animation when view transitions are unavailable', async () => { + const original = document.startViewTransition; + // @ts-expect-error simulate an engine without the View Transitions API + document.startViewTransition = undefined; + try { + await setup(ltrBook, 'slide'); + const container = paginator.shadowRoot!.getElementById('container')!; + const before = paginator.containerPosition; + const size = paginator.size; + + const turn = paginator.next(); + // The push fallback animates the strip with per-view transforms. + let sawTransform = false; + const t0 = performance.now(); + while (performance.now() - t0 < 500) { + const child = container.children[0] as HTMLElement | undefined; + const transform = child && getComputedStyle(child).transform; + if (transform && transform !== 'none') { + sawTransform = true; + break; + } + await new Promise((r) => requestAnimationFrame(r)); + } + expect(sawTransform).toBe(true); + await turn; + expect(paginator.containerPosition).toBe(before + size); + } finally { + document.startViewTransition = original; + } + }); +}); diff --git a/apps/readest-app/src/__tests__/hooks/useCapturedTurn.test.ts b/apps/readest-app/src/__tests__/hooks/useCapturedTurn.test.ts new file mode 100644 index 00000000..1056fcc2 --- /dev/null +++ b/apps/readest-app/src/__tests__/hooks/useCapturedTurn.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { + applyPageTurnAttributes, + getCapturedTurnStyle, + supportsViewTransitionTurns, +} from '@/app/reader/hooks/useCapturedTurn'; +import type { FoliateView } from '@/types/view'; +import type { ViewSettings } from '@/types/book'; + +// The DOM lib types startViewTransition as always present; go through a +// loose shape so the stub can also remove it. +type VTDocument = { startViewTransition?: () => void }; + +// iOS 18 WebKit has startViewTransition but crashes the WebContent process on +// the layered turns (#555); engines with nested view-transition groups +// (Chrome/WebView 140+) are the ones known to run them reliably. +const stubEngine = ({ + startViewTransition, + nestedGroups, +}: { + startViewTransition: boolean; + nestedGroups: boolean; +}) => { + const doc = document as unknown as VTDocument; + if (startViewTransition) doc.startViewTransition = () => {}; + else delete doc.startViewTransition; + vi.stubGlobal('CSS', { + supports: (property: string, value: string) => + nestedGroups && property === 'view-transition-group' && value === 'nearest', + }); +}; + +const makeView = () => { + const renderer = document.createElement('foliate-paginator'); + return { view: { renderer } as unknown as FoliateView, renderer }; +}; + +const settings = (pageTurnStyle: ViewSettings['pageTurnStyle']) => + ({ + pageTurnStyle, + animated: true, + scrolled: false, + disableSwipe: false, + isEink: false, + }) as ViewSettings; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + delete (document as unknown as VTDocument).startViewTransition; +}); + +describe('supportsViewTransitionTurns', () => { + it('reports no support without startViewTransition', () => { + stubEngine({ startViewTransition: false, nestedGroups: true }); + expect(supportsViewTransitionTurns()).toBe(false); + }); + + it('reports no support when nested view-transition groups are missing (iOS 18 WebKit)', () => { + stubEngine({ startViewTransition: true, nestedGroups: false }); + expect(supportsViewTransitionTurns()).toBe(false); + }); + + it('reports support on engines with nested view-transition groups', () => { + stubEngine({ startViewTransition: true, nestedGroups: true }); + expect(supportsViewTransitionTurns()).toBe(true); + }); +}); + +describe('getCapturedTurnStyle', () => { + it('captures the slide on Tauri when the engine cannot layer View Transitions', () => { + vi.stubEnv('NEXT_PUBLIC_APP_PLATFORM', 'tauri'); + stubEngine({ startViewTransition: true, nestedGroups: false }); + expect(getCapturedTurnStyle(settings('slide'), false)).toBe('slide'); + }); + + it('leaves the slide to View Transitions on fully supporting engines', () => { + vi.stubEnv('NEXT_PUBLIC_APP_PLATFORM', 'tauri'); + stubEngine({ startViewTransition: true, nestedGroups: true }); + expect(getCapturedTurnStyle(settings('slide'), false)).toBeNull(); + }); + + it('never captures outside Tauri platforms', () => { + vi.stubEnv('NEXT_PUBLIC_APP_PLATFORM', 'web'); + stubEngine({ startViewTransition: true, nestedGroups: false }); + expect(getCapturedTurnStyle(settings('slide'), false)).toBeNull(); + expect(getCapturedTurnStyle(settings('curl'), false)).toBeNull(); + }); +}); + +describe('applyPageTurnAttributes', () => { + it('keeps the View Transition slide on fully supporting engines', () => { + vi.stubEnv('NEXT_PUBLIC_APP_PLATFORM', 'web'); + stubEngine({ startViewTransition: true, nestedGroups: true }); + const { view, renderer } = makeView(); + applyPageTurnAttributes(view, settings('slide'), false); + expect(renderer.getAttribute('turn-style')).toBe('slide'); + }); + + it('falls back to push on web engines without full View Transitions support', () => { + vi.stubEnv('NEXT_PUBLIC_APP_PLATFORM', 'web'); + stubEngine({ startViewTransition: true, nestedGroups: false }); + const { view, renderer } = makeView(); + renderer.setAttribute('turn-style', 'slide'); + applyPageTurnAttributes(view, settings('slide'), false); + expect(renderer.hasAttribute('turn-style')).toBe(false); + }); + + it('hands the slide to the capture pipeline on Tauri without full support', () => { + vi.stubEnv('NEXT_PUBLIC_APP_PLATFORM', 'tauri'); + stubEngine({ startViewTransition: true, nestedGroups: false }); + const { view, renderer } = makeView(); + applyPageTurnAttributes(view, settings('slide'), false); + // The app slides the captured page itself: the paginator must not run + // its own View Transition nor its swipe tracking. + expect(renderer.hasAttribute('turn-style')).toBe(false); + expect(renderer.hasAttribute('no-swipe')).toBe(true); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/captured-turn.browser.test.ts b/apps/readest-app/src/__tests__/utils/captured-turn.browser.test.ts new file mode 100644 index 00000000..01763c32 --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/captured-turn.browser.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { CapturedPageTurn, CapturedTurnHost } from '@/app/reader/utils/capturedTurn'; + +// Choreography tests for the captured page-turn controller (readest#555): +// capture the page → overlay the captured bitmap → instantly navigate the +// live view underneath → animate (or scrub) the turn → dispose. Pixel-level +// curl geometry is covered by page-curl.browser.test.ts; these tests assert +// the orchestration contract against a fake host. + +const W = 320; +const H = 240; + +const makePngBuffer = async (): Promise => { + const canvas = document.createElement('canvas'); + canvas.width = W; + canvas.height = H; + const ctx = canvas.getContext('2d')!; + ctx.fillStyle = 'rgb(200, 60, 60)'; + ctx.fillRect(0, 0, W, H); + const blob = await new Promise((resolve) => canvas.toBlob((b) => resolve(b!), 'image/png')); + return blob.arrayBuffer(); +}; + +describe('CapturedPageTurn (browser)', () => { + let host: HTMLDivElement; + let capture: ReturnType>; + let navigate: ReturnType>; + let controller: CapturedPageTurn; + + const contentRect = () => new DOMRect(10, 20, W, H); + + beforeEach(async () => { + host = document.createElement('div'); + Object.assign(host.style, { + position: 'absolute', + left: '0', + top: '0', + width: '400px', + height: '300px', + }); + document.body.appendChild(host); + const png = await makePngBuffer(); + capture = vi.fn().mockResolvedValue(png); + navigate = vi.fn().mockResolvedValue(undefined); + const hostApi: CapturedTurnHost = { + getHostElement: () => host, + getContentRect: contentRect, + capture, + navigate, + }; + controller = new CapturedPageTurn(hostApi, { duration: 40 }); + }); + + afterEach(() => { + controller.dispose(); + host.remove(); + }); + + it('captures the content rect, navigates once, and disposes after a turn', async () => { + const ok = await controller.turn(true, false); + expect(ok).toBe(true); + expect(capture).toHaveBeenCalledWith({ x: 10, y: 20, width: W, height: H }); + expect(navigate).toHaveBeenCalledTimes(1); + expect(navigate).toHaveBeenCalledWith(true); + // Overlay fully cleaned up. + expect(host.querySelector('canvas')).toBeNull(); + }); + + it('mounts the overlay canvas over the content box while animating', async () => { + // Slow animation so the overlay is reliably observable mid-turn. + const slow = new CapturedPageTurn( + { getHostElement: () => host, getContentRect: contentRect, capture, navigate }, + { duration: 5000 }, + ); + const turned = slow.turn(true, false); + // Wait until the async capture+navigate steps have mounted the overlay. + await vi.waitFor(() => { + expect(host.querySelector('canvas')).not.toBeNull(); + }); + const overlay = host.querySelector('canvas')!.parentElement!; + expect(overlay.style.left).toBe('10px'); + expect(overlay.style.top).toBe('20px'); + slow.dispose(); + await turned; + expect(host.querySelector('canvas')).toBeNull(); + }); + + it('slides the captured page toward the spine on a forward LTR turn', async () => { + const slow = new CapturedPageTurn( + { getHostElement: () => host, getContentRect: contentRect, capture, navigate }, + { duration: 5000 }, + ); + const turned = slow.turn(true, false, 'slide'); + await vi.waitFor(() => { + expect(host.querySelector('canvas')).not.toBeNull(); + }); + const canvas = host.querySelector('canvas')!; + // The overlay clips the exiting page to the content box like the VT slide. + expect(canvas.parentElement!.style.overflow).toBe('hidden'); + await vi.waitFor(() => { + const shift = new DOMMatrixReadOnly(getComputedStyle(canvas).transform).e; + expect(shift).toBeLessThan(0); + }); + slow.dispose(); + await turned; + expect(host.querySelector('canvas')).toBeNull(); + }); + + it('slides backward turns out over the outer edge (mirrored)', async () => { + const slow = new CapturedPageTurn( + { getHostElement: () => host, getContentRect: contentRect, capture, navigate }, + { duration: 5000 }, + ); + const turned = slow.turn(false, false, 'slide'); + await vi.waitFor(() => { + expect(host.querySelector('canvas')).not.toBeNull(); + }); + const canvas = host.querySelector('canvas')!; + await vi.waitFor(() => { + const shift = new DOMMatrixReadOnly(getComputedStyle(canvas).transform).e; + expect(shift).toBeGreaterThan(0); + }); + slow.dispose(); + await turned; + }); + + it('propagates capture failures without navigating or leaving an overlay', async () => { + capture.mockRejectedValueOnce(new Error('no capture')); + await expect(controller.turn(true, false)).rejects.toThrow('no capture'); + expect(navigate).not.toHaveBeenCalled(); + expect(host.querySelector('canvas')).toBeNull(); + }); + + it('interrupts an in-flight turn when a new one starts', async () => { + const first = controller.turn(true, false); + await vi.waitFor(() => expect(navigate).toHaveBeenCalledTimes(1)); + const second = controller.turn(true, false); + await Promise.all([first, second]); + expect(navigate).toHaveBeenCalledTimes(2); + expect(host.querySelector('canvas')).toBeNull(); + }); + + it('scrubs a drag and navigates back when cancelled', async () => { + const began = await controller.beginDrag(true, false); + expect(began).toBe(true); + expect(navigate).toHaveBeenNthCalledWith(1, true); + expect(host.querySelector('canvas')).not.toBeNull(); + + controller.moveDrag(0.3, 0.5); + await controller.endDrag(false); + // Cancel: back to flat, then instantly turn back under the overlay. + expect(navigate).toHaveBeenNthCalledWith(2, false); + expect(host.querySelector('canvas')).toBeNull(); + }); + + it('scrubs a slide drag and cleans up on commit', async () => { + const began = await controller.beginDrag(true, false, 'slide'); + expect(began).toBe(true); + const canvas = host.querySelector('canvas')!; + controller.moveDrag(0.5, 0.5); + expect(new DOMMatrixReadOnly(getComputedStyle(canvas).transform).e).toBeCloseTo(-W / 2, 0); + await controller.endDrag(true); + expect(navigate).toHaveBeenCalledTimes(1); + expect(host.querySelector('canvas')).toBeNull(); + }); + + it('commits a drag without a second navigation', async () => { + await controller.beginDrag(true, false); + controller.moveDrag(0.7, 0.5); + await controller.endDrag(true); + expect(navigate).toHaveBeenCalledTimes(1); + expect(host.querySelector('canvas')).toBeNull(); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/page-curl.browser.test.ts b/apps/readest-app/src/__tests__/utils/page-curl.browser.test.ts new file mode 100644 index 00000000..8633cecb --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/page-curl.browser.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, afterEach, beforeEach } from 'vitest'; +import { PageCurlRenderer } from '@/utils/pageCurl'; + +// Tests for the WebGL page-curl renderer (readest#555 mesh curl groundwork). +// A synthetic four-quadrant page texture makes the deformation checkable per +// pixel — green/blue across the fold axis, red/yellow rows to pin the +// vertical orientation. The texture reaches the renderer the same way +// production does: PNG blob → createImageBitmap. WebKit ignores +// UNPACK_FLIP_Y_WEBGL for ImageBitmap uploads, so the renderer must not +// depend on it — the orientation assertions catch that (upside-down curl +// on iOS, readest#555). + +const W = 400; +const H = 300; + +const makePageBitmap = async (): Promise => { + const canvas = document.createElement('canvas'); + canvas.width = W; + canvas.height = H; + const ctx = canvas.getContext('2d')!; + // Top row: green | blue. Bottom row: red | yellow. + ctx.fillStyle = 'rgb(0, 160, 0)'; + ctx.fillRect(0, 0, W / 2, H / 2); + ctx.fillStyle = 'rgb(0, 0, 160)'; + ctx.fillRect(W / 2, 0, W / 2, H / 2); + ctx.fillStyle = 'rgb(160, 0, 0)'; + ctx.fillRect(0, H / 2, W / 2, H / 2); + ctx.fillStyle = 'rgb(160, 160, 0)'; + ctx.fillRect(W / 2, H / 2, W / 2, H / 2); + const blob = await new Promise((resolve) => canvas.toBlob((b) => resolve(b!), 'image/png')); + return createImageBitmap(blob); +}; + +describe('PageCurlRenderer (browser)', () => { + let renderer: PageCurlRenderer; + let host: HTMLDivElement; + + beforeEach(async () => { + host = document.createElement('div'); + Object.assign(host.style, { + position: 'absolute', + left: '0', + top: '0', + width: `${W}px`, + height: `${H}px`, + }); + document.body.appendChild(host); + renderer = new PageCurlRenderer(); + renderer.attach(host, W, H, 1); + renderer.setTexture(await makePageBitmap()); + }); + + afterEach(() => { + renderer?.dispose(); + host?.remove(); + }); + + it('covers the page exactly and upright at progress 0', () => { + renderer.render(0); + const topLeft = renderer.readPixel(40, 75); + const topRight = renderer.readPixel(W - 20, 75); + expect(topLeft[3]).toBe(255); + expect(topLeft[1]).toBeGreaterThan(100); // green + expect(topRight[3]).toBe(255); + expect(topRight[2]).toBeGreaterThan(100); // blue + // Vertical orientation: the bottom half must show the bottom of the + // page (red), not the top — an upside-down texture swaps these. + const bottomLeft = renderer.readPixel(40, H - 75); + expect(bottomLeft[3]).toBe(255); + expect(bottomLeft[0]).toBeGreaterThan(100); // red + expect(bottomLeft[1]).toBeLessThan(100); + }); + + it('curls the outer half away, folding its whitened back over the spine side', () => { + renderer.render(0.45, { x: 1, y: 0.5 }); + + // The outer (right) region has curled away: transparent, the live page + // beneath would show through. + const outer = renderer.readPixel(W - 60, 75); + expect(outer[3]).toBe(0); + + // The wrapped-over part lands near the spine ON TOP, showing the page + // back: whitened blue (the mirrored outer-half content). A straight + // fold mirrors horizontally only — the top row stays on top, so this + // is whitened BLUE (not whitened yellow from the bottom row). + const back = renderer.readPixel(100, 75); + expect(back[3]).toBe(255); + expect(back[0]).toBeGreaterThan(140); // whitened + expect(back[2]).toBeGreaterThan(180); // blue tint preserved + expect(back[1]).toBeLessThan(back[2]); // not yellow: rows did not flip + + // The far spine edge still shows the flat front (green). + const front = renderer.readPixel(12, 75); + expect(front[3]).toBe(255); + expect(front[1]).toBeGreaterThan(100); + expect(front[0]).toBeLessThan(120); + }); + + it('fully clears the page at progress 1', () => { + renderer.render(1, { x: 1, y: 0.5 }); + for (const x of [20, W / 2, W - 20]) { + expect(renderer.readPixel(x, 150)[3]).toBe(0); + } + }); + + it('tilts the fold for corner grabs', () => { + renderer.render(0.4, { x: 1, y: 1 }); + // A bottom-corner grab folds diagonally: at the same x, the bottom is + // curled away while the top is still flat. + const top = renderer.readPixel(W - 110, 20); + const bottom = renderer.readPixel(W - 110, H - 20); + expect(top[3]).toBe(255); + expect(bottom[3]).toBe(0); + }); + + it('mirrors the direction for rtl pages', () => { + renderer.render(0.45, { x: 0, y: 0.5 }, true); + // rtl grabs the LEFT edge: the left region curls away, the right stays. + const left = renderer.readPixel(60, 75); + const right = renderer.readPixel(W - 12, 75); + expect(left[3]).toBe(0); + expect(right[3]).toBe(255); + }); +}); diff --git a/apps/readest-app/src/app/reader/components/BooksGrid.tsx b/apps/readest-app/src/app/reader/components/BooksGrid.tsx index d2f40fa8..2d93bee4 100644 --- a/apps/readest-app/src/app/reader/components/BooksGrid.tsx +++ b/apps/readest-app/src/app/reader/components/BooksGrid.tsx @@ -142,6 +142,10 @@ const BookCellInner: React.FC = ({ return (
+ typeof document !== 'undefined' && + 'startViewTransition' in document && + typeof CSS !== 'undefined' && + typeof CSS.supports === 'function' && + CSS.supports('view-transition-group', 'nearest'); + +/** + * The turn style the captured-page pipeline should drive for this view, or + * null when the paginator's own turns apply. The pipeline needs a native + * webview snapshot (Tauri only) and only makes sense for animated, + * paginated, reflowable books. The curl always turns from a capture (a + * flat snapshot cannot mesh-bend); the slide prefers the View Transitions + * version and falls back to the captured slide on engines without full + * support. + */ +export const getCapturedTurnStyle = ( + viewSettings: ViewSettings, + isFixedLayout: boolean, +): CapturedTurnStyle | null => { + if (!isTauriAppPlatform() || captureBroken) return null; + if (!viewSettings.animated || viewSettings.scrolled || viewSettings.isEink || isFixedLayout) { + return null; + } + if (viewSettings.pageTurnStyle === 'curl') return 'curl'; + if (viewSettings.pageTurnStyle === 'slide' && !supportsViewTransitionTurns()) return 'slide'; + return null; +}; + +/** + * Single source of truth for the page-turn renderer attributes. When a + * captured turn is active the paginator must stay out of the way: no + * `turn-style` (the app animates the captured page itself) and `no-swipe` + * (the touch interceptor scrubs the turn instead of the paginator's + * finger-tracked View Transition). The layered `turn-style` values are + * withheld from engines without full View Transitions support — iOS 18 + * WebKit crashes on them — leaving those on push. + */ +export const applyPageTurnAttributes = ( + view: FoliateView, + viewSettings: ViewSettings, + isFixedLayout: boolean, +) => { + const captured = getCapturedTurnStyle(viewSettings, isFixedLayout); + const style = viewSettings.pageTurnStyle; + if (style && style !== 'push' && !captured && supportsViewTransitionTurns()) { + view.renderer.setAttribute('turn-style', style); + } else { + view.renderer.removeAttribute('turn-style'); + } + if (viewSettings.disableSwipe || captured) { + view.renderer.setAttribute('no-swipe', ''); + } else { + view.renderer.removeAttribute('no-swipe'); + } +}; + +interface DragState { + forward: boolean; + width: number; + height: number; +} + +/** + * Drives the captured page turns (readest#555) on Tauri platforms: wraps + * the view's `prev`/`next` so programmatic turns (taps, keys, wheel) run + * the capture→overlay→instant-turn→animate pipeline, and registers a touch + * interceptor that scrubs the turn from the finger. Falls back to the + * paginator's own animations when the native capture is unavailable. + */ +export const useCapturedTurn = (bookKey: string, viewRef: React.RefObject) => { + const { getViewSettings } = useReaderStore(); + const { getBookData } = useBookDataStore(); + const controllerRef = useRef(null); + const dragRef = useRef(null); + const view = viewRef.current; + + const isFixedLayout = () => !!getBookData(bookKey)?.isFixedLayout; + + const markCaptureBroken = (error: unknown) => { + if (captureBroken) return; + captureBroken = true; + console.warn('Captured page turn unavailable, falling back:', error); + const currentView = viewRef.current; + const viewSettings = getViewSettings(bookKey); + if (currentView && viewSettings) { + applyPageTurnAttributes(currentView, viewSettings, isFixedLayout()); + } + }; + + useEffect(() => { + if (!view || !isTauriAppPlatform()) return; + + // The foliate implementation returns the turn's promise even though the + // published type is void; navigate() awaits it so the overlay only starts + // animating once the instant jump underneath has landed. + type TurnFn = (distance?: number) => void | Promise; + const originals: { prev: TurnFn; next: TurnFn } = { + prev: view.prev.bind(view), + next: view.next.bind(view), + }; + const controller = new CapturedPageTurn({ + getHostElement: () => document.getElementById(`gridcell-${bookKey}`), + // The whole reader cell turns — running header, footer, and page + // margins ride the turning page like a physical sheet (and like + // Apple Books), so the capture spans the full cell, not just the + // text content box. + getContentRect: () => + document.getElementById(`gridcell-${bookKey}`)?.getBoundingClientRect() ?? null, + capture: captureWebviewRegion, + navigate: async (forward: boolean) => { + // The paginator's animated paths (push slide and the layered VT + // turns) all gate on the `animated` attribute; dropping it makes + // the underlying turn an instant jump hidden by the overlay. + const renderer = view.renderer; + const hadAnimated = renderer.hasAttribute('animated'); + renderer.removeAttribute('animated'); + try { + await (forward ? originals.next() : originals.prev()); + } finally { + if (hadAnimated) renderer.setAttribute('animated', ''); + } + }, + }); + controllerRef.current = controller; + + const capturedTurn = async (forward: boolean, distance?: number) => { + const viewSettings = getViewSettings(bookKey); + const boundary = forward ? view.renderer.atEnd : view.renderer.atStart; + const style = + viewSettings && distance === undefined && !boundary + ? getCapturedTurnStyle(viewSettings, isFixedLayout()) + : null; + if (!viewSettings || !style) { + return forward ? originals.next(distance) : originals.prev(distance); + } + try { + await controller.turn(forward, viewSettings.rtl, style); + } catch (error) { + markCaptureBroken(error); + return forward ? originals.next() : originals.prev(); + } + }; + view.prev = (distance?: number) => void capturedTurn(false, distance); + view.next = (distance?: number) => void capturedTurn(true, distance); + + return () => { + view.prev = originals.prev; + view.next = originals.next; + controller.dispose(); + controllerRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [view, bookKey]); + + useTouchInterceptor( + `captured-turn-${bookKey}`, + (bk, detail) => { + if (bk !== bookKey) return false; + const currentView = viewRef.current; + const controller = controllerRef.current; + if (!currentView || !controller) return false; + + if (detail.phase === 'start') { + dragRef.current = null; + return false; + } + + const viewSettings = getViewSettings(bookKey); + if (detail.phase === 'move') { + let state = dragRef.current; + if (!state) { + if (!viewSettings || viewSettings.disableSwipe) return false; + const style = getCapturedTurnStyle(viewSettings, isFixedLayout()); + if (!style) return false; + // Horizontal intent only; leave vertical swipes and taps alone. + const { deltaX, deltaY } = detail; + if (Math.abs(deltaX) < 15 || Math.abs(deltaX) <= Math.abs(deltaY)) return false; + const forward = viewSettings.rtl ? deltaX > 0 : deltaX < 0; + if (forward ? currentView.renderer.atEnd : currentView.renderer.atStart) return false; + const rect = document.getElementById(`gridcell-${bookKey}`)?.getBoundingClientRect(); + state = { + forward, + width: rect?.width || window.innerWidth, + height: rect?.height || window.innerHeight, + }; + dragRef.current = state; + controller + .beginDrag(forward, viewSettings.rtl, style) + .then((ok) => { + if (!ok) dragRef.current = null; + }) + .catch((error) => { + dragRef.current = null; + markCaptureBroken(error); + }); + return true; + } + controller.moveDrag( + dragProgress(state, detail.deltaX, viewSettings?.rtl ?? false), + // The fold tilts as the finger strays vertically, curling corners + // like a real page pinch. + Math.max(0.05, Math.min(0.95, 0.5 + detail.deltaY / state.height)), + ); + return true; + } + + // phase === 'end' + const state = dragRef.current; + if (!state) return false; + dragRef.current = null; + const progress = dragProgress(state, detail.deltaX, viewSettings?.rtl ?? false); + const signed = progress * state.width; + const velocity = signed / (detail.deltaT || 1); + // Same carousel rule as the paginator: a flick along the turn commits + // regardless of distance; otherwise commit past halfway. + const commit = velocity > 0.3 ? true : progress > 0.5; + controller.endDrag(commit).catch(() => {}); + return true; + }, + // Above the fixed-layout swipe-flip (0), below the reading ruler (10). + 5, + ); +}; + +const dragProgress = (state: DragState, deltaX: number, rtl: boolean) => { + const along = rtl ? -deltaX : deltaX; + const signed = state.forward ? -along : along; + return Math.max(0, Math.min(1, signed / state.width)); +}; diff --git a/apps/readest-app/src/app/reader/utils/capturedTurn.ts b/apps/readest-app/src/app/reader/utils/capturedTurn.ts new file mode 100644 index 00000000..2b04e932 --- /dev/null +++ b/apps/readest-app/src/app/reader/utils/capturedTurn.ts @@ -0,0 +1,269 @@ +import { CurlGrab, PageCurlRenderer } from '@/utils/pageCurl'; +import { PageSlideRenderer } from '@/utils/pageSlide'; + +/** + * Captured page-turn orchestration (readest#555, Tauri platforms). + * + * A page turn cannot move the live page as a layer — the page is a slice of + * one big multi-column iframe. Instead the platform webview captures the + * outgoing page as a bitmap, the live view turns instantly underneath, and + * an overlay animates the captured bitmap over the (already turned) live + * page: + * + * capture content box → mount overlay drawing the flat capture → + * navigate instantly under it → animate/scrub the turn → dispose. + * + * Two overlay renderers share the pipeline: the WebGL mesh curl, and the + * flat slide for engines where the View Transitions slide is unavailable + * (iOS 18 WebKit crashes on it; older engines lack the API). + * + * Backward turns run the same pipeline mirrored: the current page curls or + * slides away from the spine edge, revealing the previous page underneath — + * the same "old page recedes" choreography the View Transitions turns use. + * + * The controller only orchestrates DOM + rendering; the host callbacks + * supply the platform pieces (native capture, instant navigation, + * geometry), which keeps it independent of stores and testable in a plain + * browser. + */ +export interface CapturedTurnHost { + /** Element the overlay mounts into (the reader grid cell). */ + getHostElement: () => HTMLElement | null; + /** + * Rect of the page to capture and turn, in viewport CSS px. The whole + * reader cell — running header, footer, and margins included — turns + * like a physical sheet, matching Apple Books. + */ + getContentRect: () => DOMRect | null; + /** Native webview snapshot of `rect`, as PNG bytes. */ + capture: (rect: { x: number; y: number; width: number; height: number }) => Promise; + /** Instant (animation-less) page turn of the live view. */ + navigate: (forward: boolean) => Promise; +} + +export type CapturedTurnStyle = 'curl' | 'slide'; + +/** What the overlay draws each frame; PageCurlRenderer and PageSlideRenderer. */ +interface TurnRenderer { + attach(container: HTMLElement, width: number, height: number): void; + setTexture(source: ImageBitmap): void; + render(progress: number, grab: CurlGrab, rtl: boolean): void; + dispose(): void; +} + +interface ActiveTurn { + overlay: HTMLElement; + renderer: TurnRenderer; + forward: boolean; + /** Renderer-space mirror flag (spine side of the turn), not book direction. */ + rendererRtl: boolean; + progress: number; + grabY: number; + raf: number; + /** Resolves when the play-out animation finishes or is interrupted. */ + finish: (() => void) | null; +} + +const easeInOutQuad = (t: number) => (t < 0.5 ? 2 * t * t : 1 - (1 - t) * (1 - t) * 2); + +export class CapturedPageTurn { + #host: CapturedTurnHost; + #duration: number; + #active: ActiveTurn | null = null; + /** Serializes turns: a new turn interrupts and awaits the previous one. */ + #pending: Promise = Promise.resolve(); + + constructor(host: CapturedTurnHost, options: { duration?: number } = {}) { + this.#host = host; + this.#duration = options.duration ?? 450; + } + + get active(): boolean { + return this.#active !== null; + } + + /** + * Programmatic page turn: animates all the way through. Resolves true + * when the captured turn ran; rejects if the platform capture failed (the + * caller should mark the capture unavailable and fall back). `rtl` is the + * book's page progression direction. + */ + async turn(forward: boolean, rtl: boolean, style: CapturedTurnStyle = 'curl'): Promise { + const run = this.#pending.then(async () => { + this.#finishActive(); + const active = await this.#setUp(forward, rtl, style); + if (!active) return false; + await this.#playTo(active, 1); + this.#disposeActive(); + return true; + }); + // Keep the chain alive after failures so later turns still run. + this.#pending = run.catch(() => {}); + return run; + } + + /** + * Finger-tracked turn: captures, navigates instantly under the overlay, + * and leaves the turn at progress 0 for `moveDrag` to scrub. Resolves + * false when the turn could not start (no host element/rect). + */ + async beginDrag( + forward: boolean, + rtl: boolean, + style: CapturedTurnStyle = 'curl', + ): Promise { + const run = this.#pending.then(async () => { + this.#finishActive(); + const active = await this.#setUp(forward, rtl, style); + if (!active) return false; + active.renderer.render(active.progress, this.#grab(active), active.rendererRtl); + return true; + }); + this.#pending = run.catch(() => {}); + return run; + } + + /** Scrub the turn from the finger. Safe to call while beginDrag is pending. */ + moveDrag(progress: number, grabY: number) { + const active = this.#active; + if (!active) return; + active.progress = Math.min(1, Math.max(0, progress)); + active.grabY = grabY; + active.renderer.render(active.progress, this.#grab(active), active.rendererRtl); + } + + /** + * Release the drag: play out to the end (commit) or animate back flat and + * instantly turn the live view back (cancel) — the overlay shows the old + * page flat while the view underneath returns, so no wrong page ever + * flashes. + */ + async endDrag(commit: boolean) { + const active = this.#active; + if (!active) return; + if (commit) { + await this.#playTo(active, 1); + } else { + await this.#playTo(active, 0); + if (this.#active === active) await this.#host.navigate(!active.forward); + } + this.#disposeActive(); + } + + dispose() { + this.#finishActive(); + } + + async #setUp( + forward: boolean, + rtl: boolean, + style: CapturedTurnStyle, + ): Promise { + const hostElement = this.#host.getHostElement(); + const rect = this.#host.getContentRect(); + if (!hostElement || !rect || rect.width <= 0 || rect.height <= 0) return null; + + const image = await this.#host.capture({ + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + }); + // No mime: the platforms return different formats (PNG on iOS/macOS, + // JPEG on Android where PNG encoding took ~1.5s per turn) and the + // decoder sniffs the actual format from the bytes. + const bitmap = await createImageBitmap(new Blob([image])); + + // Position the overlay at the content box within the host element. + const hostRect = hostElement.getBoundingClientRect(); + const overlay = document.createElement('div'); + Object.assign(overlay.style, { + position: 'absolute', + left: `${rect.left - hostRect.left}px`, + top: `${rect.top - hostRect.top}px`, + width: `${rect.width}px`, + height: `${rect.height}px`, + pointerEvents: 'none', + zIndex: '50', + }); + hostElement.appendChild(overlay); + + const renderer: TurnRenderer = + style === 'slide' ? new PageSlideRenderer() : new PageCurlRenderer(); + try { + renderer.attach(overlay, rect.width, rect.height); + renderer.setTexture(bitmap); + } catch (error) { + renderer.dispose(); + overlay.remove(); + throw error; + } finally { + bitmap.close(); + } + + const active: ActiveTurn = { + overlay, + renderer, + forward, + // Forward: the page moves out from its outer edge toward the spine + // (left for LTR books). Backward: the mirror image — it recedes over + // the outer edge, revealing the previous page. + rendererRtl: forward ? rtl : !rtl, + progress: 0, + grabY: 0.5, + raf: 0, + finish: null, + }; + this.#active = active; + + // First frame draws the captured page exactly covering the content box, + // hiding the instant page swap happening underneath. + renderer.render(0, this.#grab(active), active.rendererRtl); + await this.#host.navigate(forward); + return active; + } + + #grab(active: ActiveTurn) { + return { x: active.rendererRtl ? 0 : 1, y: active.grabY }; + } + + /** Animate the active turn from its current progress to `target`. */ + #playTo(active: ActiveTurn, target: number): Promise { + return new Promise((resolve) => { + const from = active.progress; + const span = target - from; + if (span === 0) return resolve(); + const duration = Math.max(1, this.#duration * Math.abs(span)); + const start = performance.now(); + active.finish = resolve; + const step = (now: number) => { + if (this.#active !== active) return resolve(); + const t = Math.min(1, (now - start) / duration); + active.progress = from + span * easeInOutQuad(t); + active.renderer.render(active.progress, this.#grab(active), active.rendererRtl); + if (t < 1) { + active.raf = requestAnimationFrame(step); + } else { + active.finish = null; + resolve(); + } + }; + active.raf = requestAnimationFrame(step); + }); + } + + /** Tear down the current overlay, resolving any in-flight animation. */ + #finishActive() { + const active = this.#active; + if (!active) return; + this.#active = null; + cancelAnimationFrame(active.raf); + active.finish?.(); + active.renderer.dispose(); + active.overlay.remove(); + } + + #disposeActive() { + this.#finishActive(); + } +} diff --git a/apps/readest-app/src/components/settings/ControlPanel.tsx b/apps/readest-app/src/components/settings/ControlPanel.tsx index 88cccc88..c3534abe 100644 --- a/apps/readest-app/src/components/settings/ControlPanel.tsx +++ b/apps/readest-app/src/components/settings/ControlPanel.tsx @@ -9,8 +9,14 @@ import { useEinkMode } from '@/hooks/useEinkMode'; import { getStyles } from '@/utils/style'; import { getMaxInlineSize } from '@/utils/config'; import { saveSysSettings, saveViewSettings } from '@/helpers/settings'; +import { PageTurnStyle } from '@/types/book'; import { SettingsPanelPanelProp } from './SettingsDialog'; import { annotationToolQuickActions } from '@/app/reader/components/annotator/AnnotationTools'; +import { + applyPageTurnAttributes, + supportsViewTransitionTurns, +} from '@/app/reader/hooks/useCapturedTurn'; +import { isTauriAppPlatform } from '@/services/environment'; import { BoxedList, NavigationRow, @@ -56,6 +62,7 @@ const ControlPanel: React.FC = ({ bookKey, onRegisterRes const [copyToNotebook, setCopyToNotebook] = useState(viewSettings.copyToNotebook); const [showToolbarCustomizer, setShowToolbarCustomizer] = useState(false); const [animated, setAnimated] = useState(viewSettings.animated); + const [pageTurnStyle, setPageTurnStyle] = useState(viewSettings.pageTurnStyle || 'push'); const [isEink, setIsEink] = useState(viewSettings.isEink); const [isColorEink, setIsColorEink] = useState(viewSettings.isColorEink); const [autoScreenBrightness, setAutoScreenBrightness] = useState(settings.autoScreenBrightness); @@ -72,6 +79,19 @@ const ControlPanel: React.FC = ({ bookKey, onRegisterRes const pageTurnerResetRef = useRef<() => void>(() => {}); const canShare = canShareText(appService); + // The layered styles need an engine with full View Transitions support or + // the Tauri captured-turn fallback; engines like iOS 18 WebKit crash on + // the VT turns, so on the web they only get Push (readest#555). + const turnStyleOptions = [ + { value: 'push', label: _('Push') }, + ...(supportsViewTransitionTurns() || isTauriAppPlatform() + ? [ + { value: 'slide', label: _('Slide') }, + { value: 'curl', label: _('Page Curl') }, + ] + : []), + ]; + const handleReset = () => { resetToDefaults({ scrolled: setScrolledMode, @@ -157,16 +177,20 @@ const ControlPanel: React.FC = ({ bookKey, onRegisterRes // eslint-disable-next-line react-hooks/exhaustive-deps }, [isDisableClick]); + // The renderer reads `turn-style`/`no-swipe` at touchmove/touchend time, so + // settings changes have to push the attributes through immediately rather + // than waiting for the next recreateViewer pass. + const applyTurnAttributes = () => { + const view = getView(bookKey); + const freshSettings = getViewSettings(bookKey); + if (view && freshSettings) { + applyPageTurnAttributes(view, freshSettings, !!bookData?.isFixedLayout); + } + }; + useEffect(() => { saveViewSettings(envConfig, bookKey, 'disableSwipe', isDisableSwipe, false, false); - // The renderer reads `no-swipe` at touchmove/touchend time, so we have to - // push the attribute through immediately rather than waiting for the next - // recreateViewer pass. - if (isDisableSwipe) { - getView(bookKey)?.renderer.setAttribute('no-swipe', ''); - } else { - getView(bookKey)?.renderer.removeAttribute('no-swipe'); - } + applyTurnAttributes(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [isDisableSwipe]); @@ -192,9 +216,17 @@ const ControlPanel: React.FC = ({ bookKey, onRegisterRes } else { getView(bookKey)?.renderer.removeAttribute('animated'); } + // Mesh-curl eligibility depends on `animated`. + applyTurnAttributes(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [animated]); + useEffect(() => { + saveViewSettings(envConfig, bookKey, 'pageTurnStyle', pageTurnStyle, false, false); + applyTurnAttributes(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pageTurnStyle]); + useEffect(() => { saveViewSettings(envConfig, bookKey, 'isEink', isEink); if (isEink) { @@ -426,6 +458,19 @@ const ControlPanel: React.FC = ({ bookKey, onRegisterRes checked={animated} onChange={() => setAnimated(!animated)} /> + + opt.value === pageTurnStyle) ? pageTurnStyle : 'push' + } + onChange={(e) => setPageTurnStyle(e.target.value as PageTurnStyle)} + ariaLabel={_('Animation Style')} + options={turnStyleOptions} + disabled={!animated} + /> + diff --git a/apps/readest-app/src/services/constants.ts b/apps/readest-app/src/services/constants.ts index 9e1995a7..deb613b8 100644 --- a/apps/readest-app/src/services/constants.ts +++ b/apps/readest-app/src/services/constants.ts @@ -374,6 +374,7 @@ export const DEFAULT_VIEW_CONFIG: ViewConfig = { progressInfoMode: 'all', animated: false, + pageTurnStyle: 'push', isEink: false, isColorEink: false, diff --git a/apps/readest-app/src/types/book.ts b/apps/readest-app/src/types/book.ts index db59f200..a69396ac 100644 --- a/apps/readest-app/src/types/book.ts +++ b/apps/readest-app/src/types/book.ts @@ -272,6 +272,9 @@ export interface BookLanguage { } export type ProgressBarMode = 'remaining' | 'progress' | 'battery' | 'time' | 'all' | 'none'; +// 'push' slides the whole strip; 'slide' and 'curl' layer the outgoing page +// over the still incoming page (Apple Books style, needs View Transitions). +export type PageTurnStyle = 'push' | 'slide' | 'curl'; export interface ViewConfig { sideBarTab: string; uiLanguage: string; @@ -297,6 +300,7 @@ export interface ViewConfig { progressInfoMode: ProgressBarMode; animated: boolean; + pageTurnStyle: PageTurnStyle; isEink: boolean; isColorEink: boolean; diff --git a/apps/readest-app/src/utils/bridge.ts b/apps/readest-app/src/utils/bridge.ts index c77e72fc..a6c0bbcd 100644 --- a/apps/readest-app/src/utils/bridge.ts +++ b/apps/readest-app/src/utils/bridge.ts @@ -255,6 +255,30 @@ export async function refreshEinkScreen(): Promise { return await invoke('plugin:native-bridge|refresh_eink_screen'); } +/** Webview region to snapshot, in CSS pixels of the viewport (origin top-left). */ +export interface CaptureWebviewRegionRequest { + x: number; + y: number; + width: number; + height: number; +} + +/** + * Capture a region of the running webview as compressed image bytes for + * the mesh page-curl texture (#555): PNG on macOS, JPEG on iOS/Android + * (phone-CPU PNG encoding took ~1.5s per turn). The snapshot is taken at + * screen scale, capped at 2x CSS pixels on mobile. Rejects on platforms + * without a native capture implementation (web, Windows/Linux so far) — + * callers fall back to the CSS curl. + */ +export async function captureWebviewRegion( + request: CaptureWebviewRegionRequest, +): Promise { + return await invoke('plugin:native-bridge|capture_webview_region', { + payload: request, + }); +} + // ── Sync passphrase keychain ──────────────────────────────────────────── // Tauri-only. Wired into the TauriPassphraseStore (src/libs/crypto/ // passphrase.ts) so the user's sync passphrase persists across app diff --git a/apps/readest-app/src/utils/pageCurl.ts b/apps/readest-app/src/utils/pageCurl.ts new file mode 100644 index 00000000..d61c4129 --- /dev/null +++ b/apps/readest-app/src/utils/pageCurl.ts @@ -0,0 +1,259 @@ +/** + * WebGL page-curl renderer (readest#555, mesh curl for Tauri apps). + * + * Draws a captured page bitmap as a grid mesh deformed around a cylinder — + * the classic page curl: content before the fold stays flat, content past the + * fold wraps over the cylinder and comes out mirrored on top, showing the + * whitened back of the page. The canvas is transparent wherever the page has + * curled away, so the live (already turned) page shows through underneath. + * + * The renderer knows nothing about capture or gestures: callers provide an + * ImageBitmap of the outgoing page and drive `render(progress, grab)`. + */ + +const VERTEX_SHADER = ` +attribute vec2 aPos; // page coords in [0,1]x[0,1] +uniform vec2 uPage; // page size in px +uniform vec2 uFold; // a point on the fold line, page px +uniform vec2 uDir; // fold normal (unit): points toward the curled side +uniform float uRadius; // cylinder radius, px +varying vec2 vUv; +varying float vLift; // 0 flat .. 1 on top of the cylinder + +const float PI = 3.141592653589793; + +void main() { + vec2 p = aPos * uPage; + float s = dot(p - uFold, uDir); + float z = 0.0; + if (s > 0.0) { + if (s < PI * uRadius) { + float wrapped = uRadius * sin(s / uRadius); + z = uRadius * (1.0 - cos(s / uRadius)); + p -= uDir * (s - wrapped); + } else { + // Past the half turn: lies flat on top, mirrored about the fold. + p -= uDir * (2.0 * s - PI * uRadius); + z = 2.0 * uRadius; + } + } + // Texture row 0 is the top of the captured page and aPos.y = 0 is the top + // of the page, so page coordinates are texture coordinates as-is. Do NOT + // rely on UNPACK_FLIP_Y_WEBGL to reconcile them: WebKit ignores it for + // ImageBitmap uploads, which turned the curl upside down on iOS. + vUv = aPos; + vLift = clamp(z / (2.0 * uRadius + 1.0e-4), 0.0, 1.0); + vec2 clip = (p / uPage) * 2.0 - 1.0; + // Lifted parts draw on top of flat parts. + gl_Position = vec4(clip.x, -clip.y, -vLift * 0.5, 1.0); +} +`; + +const FRAGMENT_SHADER = ` +precision mediump float; +uniform sampler2D uTex; +varying vec2 vUv; +varying float vLift; + +void main() { + vec4 c = texture2D(uTex, vUv); + if (gl_FrontFacing) { + // Slight contact shading as the page lifts. + c.rgb *= 1.0 - 0.18 * vLift; + } else { + // The back of the page: the mirrored content bleeding through paper. + c.rgb = mix(c.rgb, vec3(1.0), 0.72); + c.rgb *= 1.0 - 0.08 * vLift; + } + gl_FragColor = vec4(c.rgb, c.a); +} +`; + +const GRID = 64; + +export interface CurlGrab { + /** Normalized grab point on the page, 0..1 in both axes. */ + x: number; + y: number; +} + +export class PageCurlRenderer { + private canvas: HTMLCanvasElement | null = null; + private gl: WebGLRenderingContext | null = null; + private indexCount = 0; + private width = 0; + private height = 0; + private uniforms: Record = {}; + + /** Mount the overlay canvas covering `rect` (CSS px) inside `container`. */ + attach(container: HTMLElement, width: number, height: number, dpr = window.devicePixelRatio) { + this.width = width; + this.height = height; + const canvas = document.createElement('canvas'); + canvas.width = Math.round(width * dpr); + canvas.height = Math.round(height * dpr); + Object.assign(canvas.style, { + position: 'absolute', + inset: '0', + width: `${width}px`, + height: `${height}px`, + pointerEvents: 'none', + zIndex: '50', + }); + container.appendChild(canvas); + this.canvas = canvas; + + // preserveDrawingBuffer keeps readPixels valid after the browser + // composites (readbacks otherwise silently return zeros past an await); + // one short-lived overlay per turn, so the extra buffer copy is cheap. + const gl = canvas.getContext('webgl', { + alpha: true, + premultipliedAlpha: true, + preserveDrawingBuffer: true, + }); + if (!gl) { + this.dispose(); + throw new Error('WebGL unavailable'); + } + this.gl = gl; + + const compile = (type: number, src: string) => { + const shader = gl.createShader(type)!; + gl.shaderSource(shader, src); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + throw new Error(`shader: ${gl.getShaderInfoLog(shader)}`); + } + return shader; + }; + const program = gl.createProgram()!; + gl.attachShader(program, compile(gl.VERTEX_SHADER, VERTEX_SHADER)); + gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAGMENT_SHADER)); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + throw new Error(`program: ${gl.getProgramInfoLog(program)}`); + } + // biome-ignore lint/correctness/useHookAtTopLevel: WebGL API, not a React hook + gl.useProgram(program); + + // Grid mesh of GRID x GRID quads over the unit page. + const verts: number[] = []; + for (let y = 0; y <= GRID; y++) { + for (let x = 0; x <= GRID; x++) { + verts.push(x / GRID, y / GRID); + } + } + const indices: number[] = []; + const at = (x: number, y: number) => y * (GRID + 1) + x; + for (let y = 0; y < GRID; y++) { + for (let x = 0; x < GRID; x++) { + indices.push(at(x, y), at(x + 1, y), at(x, y + 1)); + indices.push(at(x + 1, y), at(x + 1, y + 1), at(x, y + 1)); + } + } + this.indexCount = indices.length; + + const vbo = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, vbo); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(verts), gl.STATIC_DRAW); + const aPos = gl.getAttribLocation(program, 'aPos'); + gl.enableVertexAttribArray(aPos); + gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0); + + const ibo = gl.createBuffer(); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo); + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices), gl.STATIC_DRAW); + + for (const name of ['uPage', 'uFold', 'uDir', 'uRadius', 'uTex']) { + this.uniforms[name] = gl.getUniformLocation(program, name); + } + gl.uniform2f(this.uniforms['uPage']!, width, height); + + gl.viewport(0, 0, canvas.width, canvas.height); + gl.enable(gl.DEPTH_TEST); + gl.disable(gl.CULL_FACE); + // The vertex shader flips Y into clip space, mirroring triangle winding: + // the grid's quads come out clockwise, so declare CW as front-facing or + // gl_FrontFacing (front page vs whitened back) is inverted. + gl.frontFace(gl.CW); + gl.enable(gl.BLEND); + gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA); + } + + /** Upload the captured page (drawn at progress 0 it exactly covers). */ + setTexture(source: TexImageSource) { + const gl = this.gl; + if (!gl) return; + const tex = gl.createTexture(); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, tex); + // Upload unflipped; the vertex shader samples page coordinates directly. + // (WebKit ignores UNPACK_FLIP_Y_WEBGL for ImageBitmap sources, so any + // orientation scheme built on it breaks on iOS.) + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.uniform1i(this.uniforms['uTex']!, 0); + } + + /** + * Draw the curl at `progress` (0 = flat, 1 = fully turned). `grab` picks + * where the reader lifted the page: y near 1 curls from the bottom corner + * (a diagonal fold that straightens as the turn completes), y near 0.5 + * folds straight. `rtl` mirrors the direction: with rtl the page is + * grabbed at its left edge. + */ + render(progress: number, grab: CurlGrab = { x: 1, y: 0.5 }, rtl = false) { + const gl = this.gl; + if (!gl) return; + const { width: w, height: h } = this; + + // Fold normal: mostly horizontal, tilted by how far the grab sits from + // the vertical middle. The tilt decays with progress — a corner grab + // starts as a steep diagonal pinch at that corner and flattens out, so + // the far side of the page stays flat early in the turn yet the whole + // page still clears by the end. + const tilt = (grab.y - 0.5) * 1.8 * (1 - progress); + const dx = rtl ? -1 : 1; + const len = Math.hypot(1, tilt); + const dir: [number, number] = [dx / len, tilt / len]; + + // The cylinder tightens slightly as the page lifts off. + const radius = Math.max(24, 0.16 * w * (1 - 0.4 * progress)); + // The fold sweeps from the grabbed edge along the grab row; by progress 1 + // (tilt 0) it must cross the page plus the final half-circumference so + // the spine-side column has fully wrapped off. + const endRadius = Math.max(24, 0.16 * w * 0.6); + const travel = w + Math.PI * endRadius; + const start: [number, number] = [rtl ? 0 : w, grab.y * h]; + const foldX = start[0] - dir[0] * travel * progress; + const foldY = start[1] - dir[1] * travel * progress; + + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); + gl.uniform2f(this.uniforms['uFold']!, foldX, foldY); + gl.uniform2f(this.uniforms['uDir']!, dir[0], dir[1]); + gl.uniform1f(this.uniforms['uRadius']!, radius); + gl.drawElements(gl.TRIANGLES, this.indexCount, gl.UNSIGNED_SHORT, 0); + } + + /** Read back a pixel (device px, origin top-left) — used by tests. */ + readPixel(x: number, y: number): [number, number, number, number] { + const gl = this.gl; + const canvas = this.canvas; + if (!gl || !canvas) return [0, 0, 0, 0]; + const data = new Uint8Array(4); + gl.readPixels(x, canvas.height - 1 - y, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, data); + return [data[0]!, data[1]!, data[2]!, data[3]!]; + } + + dispose() { + this.gl?.getExtension('WEBGL_lose_context')?.loseContext(); + this.canvas?.remove(); + this.canvas = null; + this.gl = null; + } +} diff --git a/apps/readest-app/src/utils/pageSlide.ts b/apps/readest-app/src/utils/pageSlide.ts new file mode 100644 index 00000000..8ffc29c8 --- /dev/null +++ b/apps/readest-app/src/utils/pageSlide.ts @@ -0,0 +1,62 @@ +import { CurlGrab } from '@/utils/pageCurl'; + +/** + * Slide renderer for the captured page-turn pipeline (readest#555). Draws + * the captured outgoing page on a plain 2D canvas and translates it + * horizontally out of the content box — the flat sibling of the WebGL + * `PageCurlRenderer`, used where the View Transitions slide is unavailable + * (iOS 18 WebKit crashes on it; older engines lack the API). + * + * Mirrors the paginator's VT slide choreography: the moving page exits + * toward the spine side on forward turns with a soft edge shadow, clipped + * to the content box so the margins stay still. + */ +export class PageSlideRenderer { + private canvas: HTMLCanvasElement | null = null; + private width = 0; + + /** Mount the overlay canvas covering `rect` (CSS px) inside `container`. */ + attach(container: HTMLElement, width: number, height: number, dpr = window.devicePixelRatio) { + this.width = width; + // The page slides past the container edge; clip it like the VT version + // clips its transition group to the content box. + container.style.overflow = 'hidden'; + const canvas = document.createElement('canvas'); + canvas.width = Math.round(width * dpr); + canvas.height = Math.round(height * dpr); + Object.assign(canvas.style, { + position: 'absolute', + inset: '0', + width: `${width}px`, + height: `${height}px`, + pointerEvents: 'none', + boxShadow: '0 0 24px rgba(0, 0, 0, 0.35)', + }); + container.appendChild(canvas); + this.canvas = canvas; + } + + /** Draw the captured page (at progress 0 it exactly covers). */ + setTexture(source: CanvasImageSource) { + const canvas = this.canvas; + const ctx = canvas?.getContext('2d'); + if (!canvas || !ctx) return; + ctx.drawImage(source, 0, 0, canvas.width, canvas.height); + } + + /** + * Slide the page at `progress` (0 = flat, 1 = fully out). `rtl` is the + * renderer-space mirror flag shared with the curl: false exits left (the + * spine side of a forward LTR turn), true exits right. + */ + render(progress: number, _grab: CurlGrab = { x: 1, y: 0.5 }, rtl = false) { + if (!this.canvas) return; + const shift = (rtl ? 1 : -1) * progress * this.width; + this.canvas.style.transform = `translateX(${shift}px)`; + } + + dispose() { + this.canvas?.remove(); + this.canvas = null; + } +} diff --git a/packages/foliate-js b/packages/foliate-js index 8485e932..c50a7b16 160000 --- a/packages/foliate-js +++ b/packages/foliate-js @@ -1 +1 @@ -Subproject commit 8485e93213100490329a83376288fc9b9a1aa361 +Subproject commit c50a7b164c5d1b49fc1521e86be41abf45224ebd diff --git a/packages/swift-rs/.gitattributes b/packages/swift-rs/.gitattributes new file mode 100644 index 00000000..0bad51cb --- /dev/null +++ b/packages/swift-rs/.gitattributes @@ -0,0 +1 @@ +example/* linguist-vendored diff --git a/packages/swift-rs/.github/workflows/main.yaml b/packages/swift-rs/.github/workflows/main.yaml new file mode 100644 index 00000000..3ce71192 --- /dev/null +++ b/packages/swift-rs/.github/workflows/main.yaml @@ -0,0 +1,38 @@ +name: Build + +on: + push: + branches: [ master ] + pull_request: + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + build: + name: Build + runs-on: macos-latest + strategy: + matrix: + rust: [stable, beta] + steps: + - uses: actions/checkout@v3 + name: Checkout + - name: Install specific rust version + run: | + rustup install ${{ matrix.rust }} --profile minimal + rustup component add --toolchain ${{ matrix.rust }} rustfmt clippy + - name: Setup cache + uses: Swatinem/rust-cache@v2 + - name: Test example + working-directory: example + run: cargo +${{ matrix.rust }} run + - name: Run Tests + env: + TEST_SWIFT_RS: "true" + run: cargo +${{ matrix.rust }} test --features build + - name: Check Code Formatting + run: cargo +${{ matrix.rust }} fmt --all -- --check + - name: Lints + run: cargo +${{ matrix.rust }} clippy -- -D warnings diff --git a/packages/swift-rs/.gitignore b/packages/swift-rs/.gitignore new file mode 100644 index 00000000..4c1a6f0e --- /dev/null +++ b/packages/swift-rs/.gitignore @@ -0,0 +1,7 @@ +.build/ +target/ +.swiftpm/ +.idea/ +.DS_Store +icon.txt +**/Cargo.lock diff --git a/packages/swift-rs/Cargo.toml b/packages/swift-rs/Cargo.toml new file mode 100644 index 00000000..e82f9d1c --- /dev/null +++ b/packages/swift-rs/Cargo.toml @@ -0,0 +1,73 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2021" +name = "swift-rs" +version = "1.0.7" +authors = ["The swift-rs contributors"] +build = "src-rs/test-build.rs" +exclude = [ + "/src-swift", + "*.swift", +] +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "Call Swift from Rust with ease!" +readme = "README.md" +license = "MIT OR Apache-2.0" +repository = "https://github.com/Brendonovich/swift-rs" + +[package.metadata."docs.rs"] +all-features = true +rustdoc-args = [ + "--cfg", + "docsrs", +] + +[lib] +name = "swift_rs" +path = "src-rs/lib.rs" + +[[test]] +name = "test_bindings" +path = "tests/test_bindings.rs" + +[dependencies.base64] +version = "0.21.0" + +[dependencies.serde] +version = "1.0" +features = ["derive"] +optional = true + +[dependencies.serde_json] +version = "1.0" +optional = true + +[dev-dependencies.serial_test] +version = "0.10" + +[build-dependencies.serde] +version = "1.0" +features = ["derive"] + +[build-dependencies.serde_json] +version = "1.0" + +[features] +build = [ + "serde", + "serde_json", +] +default = [] diff --git a/packages/swift-rs/LICENSE-APACHE b/packages/swift-rs/LICENSE-APACHE new file mode 100644 index 00000000..6e7334b3 --- /dev/null +++ b/packages/swift-rs/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 The swift-rs developers + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/swift-rs/LICENSE-MIT b/packages/swift-rs/LICENSE-MIT new file mode 100644 index 00000000..dd897495 --- /dev/null +++ b/packages/swift-rs/LICENSE-MIT @@ -0,0 +1,19 @@ +Copyright (c) 2023 The swift-rs Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/swift-rs/README.md b/packages/swift-rs/README.md new file mode 100644 index 00000000..2e83a2da --- /dev/null +++ b/packages/swift-rs/README.md @@ -0,0 +1,483 @@ +# swift-rs + +![Crates.io](https://img.shields.io/crates/v/swift-rs?color=blue&style=flat-square) +![docs.rs](https://img.shields.io/docsrs/swift-rs?color=blue&style=flat-square) + +Call Swift functions from Rust with ease! + +## Setup + +Add `swift-rs` to your project's `dependencies` and `build-dependencies`: + +```toml +[dependencies] +swift-rs = "1.0.5" + +[build-dependencies] +swift-rs = { version = "1.0.5", features = ["build"] } +``` + +Next, some setup work must be done: + +1. Ensure your swift code is organized into a Swift Package. +This can be done in XCode by selecting File -> New -> Project -> Multiplatform -> Swift Package and importing your existing code. +2. Add `SwiftRs` as a dependency to your Swift package and make the build type `.static`. +```swift +let package = Package( + dependencies: [ + .package(url: "https://github.com/Brendonovich/swift-rs", from: "1.0.5") + ], + products: [ + .library( + type: .static, + ), + ], + targets: [ + .target( + // Must specify swift-rs as a dependency of your target + dependencies: [ + .product( + name: "SwiftRs", + package: "swift-rs" + ) + ], + ) + ] +) +``` +3. Create a `build.rs` file in your project's root folder, if you don't have one already. +4. Use `SwiftLinker` in your `build.rs` file to link both the Swift runtime and your Swift package. +The package name should be the same as is specified in your `Package.swift` file, +and the path should point to your Swift project's root folder relative to your crate's root folder. + +```rust +use swift_rs::SwiftLinker; + +fn build() { + // swift-rs has a minimum of macOS 10.13 + // Ensure the same minimum supported macOS version is specified as in your `Package.swift` file. + SwiftLinker::new("10.13") + // Only if you are also targetting iOS + // Ensure the same minimum supported iOS version is specified as in your `Package.swift` file + .with_ios("11") + .with_package(PACKAGE_NAME, PACKAGE_PATH) + .link(); + + // Other build steps +} +``` + +With those steps completed, you should be ready to start using Swift code from Rust! + +If you experience the error `dyld[16008]: Library not loaded: @rpath/libswiftCore.dylib` +when using `swift-rs` with [Tauri](https://tauri.app) ensure you have set your +[Tauri minimum system version](https://tauri.app/v1/guides/building/macos#setting-a-minimum-system-version) +to `10.15` or higher in your `tauri.config.json`. + +## Calling basic functions + +To allow calling a Swift function from Rust, it must follow some rules: + +1. It must be global +2. It must be annotated with `@_cdecl`, so that it is callable from C +3. It must only use types that can be represented in Objective-C, +so only classes that derive `NSObject`, as well as scalars such as Int and Bool. +This excludes strings, arrays, generics (though all of these can be sent with workarounds) +and structs (which are strictly forbidden). + +For this example we will use a function that simply squares a number: + +```swift +public func squareNumber(number: Int) -> Int { + return number * number +} +``` + +So far, this function meets requirements 1 and 3: it is global and public, and only uses the Int type, which is Objective-C compatible. +However, it is not annotated with `@_cdecl`. +To fix this, we must call `@_cdecl` before the function's declaration and specify the name that the function is exposed to Rust with as its only argument. +To keep with Rust's naming conventions, we will export this function in snake case as `square_number`. + +```swift +@_cdecl("square_number") +public func squareNumber(number: Int) -> Int { + return number * number +} +``` + +Now that `squareNumber` is properly exposed to Rust, we can start interfacing with it. +This can be done using the `swift!` macro, with the `Int` type helping to provide a similar function signature: + +```rust +use swift_rs::swift; + +swift!(fn square_number(number: Int) -> Int); +``` + +Lastly, you can call the function from regular Rust functions. +Note that all calls to a Swift function are unsafe, +and require wrapping in an `unsafe {}` block or `unsafe fn`. + +```rust +fn main() { + let input: Int = 4; + let output = unsafe { square_number(input) }; + + println!("Input: {}, Squared: {}", input, output); + // Prints "Input: 4, Squared: 16" +} +``` + +Check [the documentation](TODO) for all available helper types. + +## Returning objects from Swift + +Let's say that we want our `squareNumber` function to return not only the result, but also the original input. +A standard way to do this in Swift would be with a struct: + +```swift +struct SquareNumberResult { + var input: Int + var output: Int +} +``` + +We are not allowed to do this, though, since structs cannot be represented in Objective-C. +Instead, we must use a class that extends `NSObject`: + +```swift +class SquareNumberResult: NSObject { + var input: Int + var output: Int + + init(_ input: Int, _ output: Int) { + self.input = input; + self.output = output + } +} +``` + +Yes, this class could contain the squaring logic too, but that is irrelevant for this example + +An instance of this class can then be returned from `squareNumber`: + +```swift +@_cdecl("square_number") +public func squareNumber(input: Int) -> SquareNumberResult { + let output = input * input + return SquareNumberResult(input, output) +} +``` + +As you can see, returning an `NSObject` from Swift isn't too difficult. +The same can't be said for the Rust implementation, though. +`squareNumber` doesn't actually return a struct containing `input` and `output`, +but instead a pointer to a `SquareNumberResult` stored somewhere in memory. +Additionally, this value contains more data than just `input` and `output`: +Since it is an `NSObject`, it contains extra data that must be accounted for when using it in Rust. + +This may sound daunting, but it's not actually a problem thanks to `SRObject`. +This type manages the pointer internally, and takes a generic argument for a struct that we can access the data through. +Let's see how we'd implement `SquareNumberResult` in Rust: + +```rust +use swift_rs::{swift, Int, SRObject}; + +// Any struct that is used in a C function must be annotated +// with this, and since our Swift function is exposed as a +// C function with @_cdecl, this is necessary here +#[repr(C)] +// Struct matches the class declaration in Swift +struct SquareNumberResult { + input: Int, + output: Int +} + +// SRObject abstracts away the underlying pointer and will automatically deref to +// &SquareNumberResult through the Deref trait +swift!(fn square_number(input: Int) -> SRObject); +``` + +Then, using the new return value is just like using `SquareNumberResult` directly: + +```rust +fn main() { + let input = 4; + let result = unsafe { square_number(input) }; + + let result_input = result.input; // 4 + let result_output = result.output; // 16 +} +``` + +Creating objects in Rust and then passing them to Swift is not supported. + +## Optionals + +`swift-rs` also supports Swift's `nil` type, but only for functions that return optional `NSObject`s. +Functions returning optional primitives cannot be represented in Objective C, and thus are not supported. + +Let's say we have a function returning an optional `SRString`: + +```swift +@_cdecl("optional_string") +func optionalString(returnNil: Bool) -> SRString? { + if (returnNil) return nil + else return SRString("lorem ipsum") +} +``` + +Thanks to Rust's [null pointer optimisation](https://doc.rust-lang.org/std/option/index.html#representation), +the optional nature of `SRString?` can be represented by wrapping `SRString` in Rust's `Option` type! + +```rust +use swift_rs::{swift, Bool, SRString}; + +swift!(optional_string(return_nil: Bool) -> Option) +``` + +Null pointers are actually the reason why a function that returns an optional primitive cannot be represented in C. +If this were to be supported, how could a `nil` be differentiated from a number? It can't! + +## Complex types + +So far we have only looked at using primitive types and structs/classes, +but this leaves out some of the most important data structures: arrays (`SRArray`) and strings (`SRString`). +These types must be treated with caution, however, and are not as flexible as their native Swift & Rust counterparts. + +### Strings + +Strings can be passed between Rust and Swift through `SRString`, which can be created from native strings in either language. + +**As an argument** + +```swift +import SwiftRs + +@_cdecl("swift_print") +public func swiftPrint(value: SRString) { + // .to_string() converts the SRString to a Swift String + print(value.to_string()) +} +``` + +```rust +use swift_rs::{swift, SRString, SwiftRef}; + +swift!(fn swift_print(value: &SRString)); + +fn main() { + // SRString can be created by simply calling .into() on any string reference. + // This will allocate memory in Swift and copy the string + let value: SRString = "lorem ipsum".into(); + + unsafe { swift_print(&value) }; // Will print "lorem ipsum" to the console +} +``` + +**As a return value** + +```swift +import SwiftRs + +@_cdecl("get_string") +public func getString() -> SRString { + let value = "lorem ipsum" + + // SRString can be created from a regular String + return SRString(value) +} +``` + +```rust +use swift_rs::{swift, SRString}; + +swift!(fn get_string() -> SRString); + +fn main() { + let value_srstring = unsafe { get_string() }; + + // SRString can be converted to an &str using as_str()... + let value_str: &str = value_srstring.as_str(); + // or though the Deref trait + let value_str: &str = &*value_srstring; + + // SRString also implements Display + println!("{}", value_srstring); // Will print "lorem ipsum" to the console +} +``` + +### Arrays + +**Primitive Arrays** + +Representing arrays properly is tricky, since we cannot use generics as Swift arguments or return values according to rule 3. +Instead, `swift-rs` provides a generic `SRArray` that can be embedded inside another class that extends `NSObject` that is not generic, +but is restricted to a single element type. + +```swift +import SwiftRs + +// Argument/Return values can contain generic types, but cannot be generic themselves. +// This includes extending generic types. +class IntArray: NSObject { + var data: SRArray + + init(_ data: [Int]) { + self.data = SRArray(data) + } +} + +@_cdecl("get_numbers") +public func getNumbers() -> IntArray { + let numbers = [1, 2, 3, 4] + + return IntArray(numbers) +} +``` + +```rust +use swift_rs::{Int, SRArray, SRObject}; + +#[repr(C)] +struct IntArray { + data: SRArray +} + +// Since IntArray extends NSObject in its Swift implementation, +// it must be wrapped in SRObject on the Rust side +swift!(fn get_numbers() -> SRObject); + +fn main() { + let numbers = unsafe { get_numbers() }; + + // SRArray can be accessed as a slice via as_slice + let numbers_slice: &[Int] = numbers.data.as_slice(); + + assert_eq!(numbers_slice, &[1, 2, 3, 4]); +} +``` + +To simplify things on the rust side, we can actually do away with the `IntArray` struct. +Since `IntArray` only has one field, its memory layout is identical to that of `SRArray`, +so our Rust implementation can be simplified at the cost of equivalence with our Swift code: + +```rust +// We still need to wrap the array in SRObject since +// the wrapper class in Swift is an NSObject +swift!(fn get_numbers() -> SRObject>); +``` + +**NSObject Arrays** + +What if we want to return an `NSObject` array? There are two options on the Swift side: + +1. Continue using `SRArray` and a custom wrapper type, or +2. Use `SRObjectArray`, a wrapper type provided by `swift-rs` that accepts any `NSObject` as its elements. +This can be easier than continuing to create wrapper types, but sacrifices some type safety. + +There is also `SRObjectArray` for Rust, which is compatible with any single-element Swift wrapper type (and of course `SRObjectArray` in Swift), +and automatically wraps its elements in `SRObject`, so there's very little reason to not use it unless you _really_ like custom wrapper types. + +Using `SRObjectArray` in both Swift and Rust with a basic custom class/struct can be done like this: + +```swift +import SwiftRs + +class IntTuple: NSObject { + var item1: Int + var item2: Int + + init(_ item1: Int, _ item2: Int) { + self.item1 = item1 + self.item2 = item2 + } +} + +@_cdecl("get_tuples") +public func getTuples() -> SRObjectArray { + let tuple1 = IntTuple(0,1), + tuple2 = IntTuple(2,3), + tuple3 = IntTuple(4,5) + + let tupleArray: [IntTuple] = [ + tuple1, + tuple2, + tuple3 + ] + + // Type safety is only lost when the Swift array is converted to an SRObjectArray + return SRObjectArray(tupleArray) +} +``` + +```rust +use swift_rs::{swift, Int, SRObjectArray}; + +#[repr(C)] +struct IntTuple { + item1: Int, + item2: Int +} + +// No need to wrap IntTuple in SRObject since +// SRObjectArray does it automatically +swift!(fn get_tuples() -> SRObjectArray); + +fn main() { + let tuples = unsafe { get_tuples() }; + + for tuple in tuples.as_slice() { + // Will print each tuple's contents to the console + println!("Item 1: {}, Item 2: {}", tuple.item1, tuple.item2); + } +} +``` + +Complex types can contain whatever combination of primitives and `SRObject` you like, just remember to follow the 3 rules! + +## Bonuses + +### SRData + +A wrapper type for `SRArray` designed for storing `u8`s - essentially just a byte buffer. + +### Tighter Memory Control with `autoreleasepool!` + +If you've come to Swift from an Objective-C background, you likely know the utility of `@autoreleasepool` blocks. +`swift-rs` has your back on this too, just wrap your block of code with a `autoreleasepool!`, and that block of code now executes with its own autorelease pool! + +```rust +use swift_rs::autoreleasepool; + +for _ in 0..10000 { + autoreleasepool!({ + // do some memory intensive thing here + }); +} +``` + +## Limitations + +Currently, the only types that can be created from Rust are number types, boolean, `SRString`, and `SRData`. +This is because those types are easy to allocate memory for, either on the stack or on the heap via calling out to swift, +whereas other types are not. This may be implemented in the future, though. + +Mutating values across Swift and Rust is not currently an aim for this library, it is purely for providing arguments and returning values. +Besides, this would go against Rust's programming model, potentially allowing for multiple shared references to a value instead of interior mutability via something like a Mutex. + +## License + +Licensed under either of + + * Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) + * MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) + +at your option. + +### Contribution + +Unless you explicitly state otherwise, any contribution intentionally +submitted for inclusion in the work by you, as defined in the Apache-2.0 +license, shall be dual licensed as above, without any additional terms or +conditions. diff --git a/packages/swift-rs/src-rs/autorelease.rs b/packages/swift-rs/src-rs/autorelease.rs new file mode 100644 index 00000000..bb1007d7 --- /dev/null +++ b/packages/swift-rs/src-rs/autorelease.rs @@ -0,0 +1,26 @@ +/// Run code with its own autorelease pool. Semantically, this is identical +/// to [`@autoreleasepool`](https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/MemoryMgmt/Articles/mmAutoreleasePools.html) +/// in Objective-C +/// +/// +/// ```no_run +/// use swift_rs::autoreleasepool; +/// +/// autoreleasepool!({ +/// // do something memory intensive stuff +/// }) +/// ``` +#[macro_export] +macro_rules! autoreleasepool { + ( $expr:expr ) => {{ + extern "C" { + fn objc_autoreleasePoolPush() -> *mut std::ffi::c_void; + fn objc_autoreleasePoolPop(context: *mut std::ffi::c_void); + } + + let pool = unsafe { objc_autoreleasePoolPush() }; + let r = { $expr }; + unsafe { objc_autoreleasePoolPop(pool) }; + r + }}; +} diff --git a/packages/swift-rs/src-rs/build.rs b/packages/swift-rs/src-rs/build.rs new file mode 100644 index 00000000..3f3bb9db --- /dev/null +++ b/packages/swift-rs/src-rs/build.rs @@ -0,0 +1,330 @@ +#![allow(dead_code)] +use std::{env, fmt::Display, path::Path, path::PathBuf, process::Command}; + +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SwiftTarget { + triple: String, + unversioned_triple: String, + module_triple: String, + //pub swift_runtime_compatibility_version: String, + #[serde(rename = "librariesRequireRPath")] + libraries_require_rpath: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SwiftPaths { + runtime_library_paths: Vec, + runtime_library_import_paths: Vec, + runtime_resource_path: String, +} + +#[derive(Deserialize)] +struct SwiftEnv { + target: SwiftTarget, + paths: SwiftPaths, +} + +impl SwiftEnv { + fn new(minimum_macos_version: &str, minimum_ios_version: Option<&str>) -> Self { + let rust_target = RustTarget::from_env(); + let target = rust_target.swift_target_triple(minimum_macos_version, minimum_ios_version); + + let swift_target_info_str = Command::new("swift") + .args(["-target", &target, "-print-target-info"]) + .output() + .unwrap() + .stdout; + + serde_json::from_slice(&swift_target_info_str).unwrap() + } +} + +#[allow(clippy::upper_case_acronyms)] +enum RustTargetOS { + MacOS, + IOS, +} + +impl RustTargetOS { + fn from_env() -> Self { + match env::var("CARGO_CFG_TARGET_OS").unwrap().as_str() { + "macos" => RustTargetOS::MacOS, + "ios" => RustTargetOS::IOS, + _ => panic!("unexpected target operating system"), + } + } + + fn to_swift(&self) -> &'static str { + match self { + Self::MacOS => "macosx", + Self::IOS => "ios", + } + } +} + +impl Display for RustTargetOS { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MacOS => write!(f, "macos"), + Self::IOS => write!(f, "ios"), + } + } +} + +#[allow(clippy::upper_case_acronyms)] +enum SwiftSDK { + MacOS, + IOS, + IOSSimulator, +} + +impl SwiftSDK { + fn from_os(os: &RustTargetOS) -> Self { + let target = env::var("TARGET").unwrap(); + let simulator = target.ends_with("ios-sim") + || (target.starts_with("x86_64") && target.ends_with("ios")); + + match os { + RustTargetOS::MacOS => Self::MacOS, + RustTargetOS::IOS if simulator => Self::IOSSimulator, + RustTargetOS::IOS => Self::IOS, + } + } + + fn clang_lib_extension(&self) -> &'static str { + match self { + Self::MacOS => "osx", + Self::IOS => "ios", + Self::IOSSimulator => "iossim", + } + } +} + +impl Display for SwiftSDK { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MacOS => write!(f, "macosx"), + Self::IOSSimulator => write!(f, "iphonesimulator"), + Self::IOS => write!(f, "iphoneos"), + } + } +} + +struct RustTarget { + arch: String, + os: RustTargetOS, + sdk: SwiftSDK, +} + +impl RustTarget { + fn from_env() -> Self { + let arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap(); + let os = RustTargetOS::from_env(); + let sdk = SwiftSDK::from_os(&os); + + Self { arch, os, sdk } + } + + fn swift_target_triple( + &self, + minimum_macos_version: &str, + minimum_ios_version: Option<&str>, + ) -> String { + let unversioned = self.unversioned_swift_target_triple(); + format!( + "{unversioned}{}{}", + match (&self.os, minimum_ios_version) { + (RustTargetOS::MacOS, _) => minimum_macos_version, + (RustTargetOS::IOS, Some(version)) => version, + _ => "", + }, + // simulator suffix + matches!(self.sdk, SwiftSDK::IOSSimulator) + .then(|| "-simulator".to_string()) + .unwrap_or_default() + ) + } + + fn unversioned_swift_target_triple(&self) -> String { + format!( + "{}-apple-{}", + match self.arch.as_str() { + "aarch64" => "arm64", + a => a, + }, + self.os.to_swift(), + ) + } +} + +struct SwiftPackage { + name: String, + path: PathBuf, +} + +/// Builder for linking the Swift runtime and custom packages. +#[cfg(feature = "build")] +pub struct SwiftLinker { + packages: Vec, + macos_min_version: String, + ios_min_version: Option, +} + +impl SwiftLinker { + /// Creates a new [`SwiftLinker`] with a minimum macOS verison. + /// + /// Minimum macOS version must be at least 10.13. + pub fn new(macos_min_version: &str) -> Self { + Self { + packages: vec![], + macos_min_version: macos_min_version.to_string(), + ios_min_version: None, + } + } + + /// Instructs the [`SwiftLinker`] to also compile for iOS + /// using the specified minimum iOS version. + /// + /// Minimum iOS version must be at least 11. + pub fn with_ios(mut self, min_version: &str) -> Self { + self.ios_min_version = Some(min_version.to_string()); + self + } + + /// Adds a package to be linked against. + /// `name` should match the `name` field in your `Package.swift`, + /// and `path` should point to the root of your Swift package relative + /// to your crate's root. + pub fn with_package(mut self, name: &str, path: impl AsRef) -> Self { + self.packages.extend([SwiftPackage { + name: name.to_string(), + path: path.as_ref().into(), + }]); + + self + } + + /// Links the Swift runtime, then builds and links the provided packages. + /// This does not (yet) automatically rebuild your Swift files when they are modified, + /// you'll need to modify/save your `build.rs` file for that. + pub fn link(self) { + let swift_env = SwiftEnv::new(&self.macos_min_version, self.ios_min_version.as_deref()); + + #[allow(clippy::uninlined_format_args)] + for path in swift_env.paths.runtime_library_paths { + println!("cargo:rustc-link-search=native={path}"); + } + + let debug = env::var("DEBUG").unwrap() == "true"; + let configuration = if debug { "debug" } else { "release" }; + let rust_target = RustTarget::from_env(); + + link_clang_rt(&rust_target); + + for package in self.packages { + let package_path = + Path::new(&env::var("CARGO_MANIFEST_DIR").unwrap()).join(&package.path); + let out_path = Path::new(&env::var("OUT_DIR").unwrap()) + .join("swift-rs") + .join(&package.name); + + let sdk_path_output = Command::new("xcrun") + .args(["--sdk", &rust_target.sdk.to_string(), "--show-sdk-path"]) + .output() + .unwrap(); + if !sdk_path_output.status.success() { + panic!( + "Failed to get SDK path with `xcrun --sdk {} --show-sdk-path`", + rust_target.sdk + ); + } + + let sdk_path = String::from_utf8_lossy(&sdk_path_output.stdout); + + let mut command = Command::new("swift"); + command.current_dir(&package.path); + + let swift_target_triple = rust_target + .swift_target_triple(&self.macos_min_version, self.ios_min_version.as_deref()); + + // READEST PATCH (Xcode 26.2 / Swift 6.2): the upstream invocation + // built with `--arch ` and overrode the target per-swiftc via + // `-Xswiftc -target`. Swift 6.2's driver no longer honors that mix: + // sources compile against the wrong platform's Swift overlays, so + // overlay-provided APIs (`Bundle.main`, os.Logger's `privacy:` + // interpolation) vanish with baffling errors. Use SPM's first-class + // cross-compilation flags instead: `--triple` + `--sdk`. + command + // Build the package (duh) + .arg("build") + // Cross-compile for the target platform; SPM forwards the + // triple to swiftc and clang itself. + .args(["--triple", &swift_target_triple]) + // SDK for the target platform. + .args(["--sdk", sdk_path.trim()]) + // Release/Debug configuration + .args(["-c", configuration]) + // Where the artifacts will be generated to + .args(["--build-path", &out_path.display().to_string()]); + + // Xcode's script phases export SDKROOT for the *product* platform + // (e.g. iphoneos), which leaks into SPM's manifest compilation — + // the manifest must always build for the host — and breaks it with + // "using sysroot for 'iPhoneOS' but targeting 'MacOSX'". The + // explicit `--sdk` above covers the target sources. + command.env_remove("SDKROOT"); + + if !command.status().unwrap().success() { + panic!("Failed to compile swift package {}", package.name); + } + + // With `--triple`, swift build writes artifacts into a directory + // named after the unversioned triple (plus the simulator suffix), + // e.g. `arm64-apple-ios/release` or `arm64-apple-ios-simulator/release`. + let triple_dir = format!( + "{}{}", + rust_target.unversioned_swift_target_triple(), + matches!(rust_target.sdk, SwiftSDK::IOSSimulator) + .then(|| "-simulator".to_string()) + .unwrap_or_default() + ); + let search_path = out_path.join(triple_dir).join(configuration); + + println!("cargo:rerun-if-changed={}", package_path.display()); + println!("cargo:rustc-link-search=native={}", search_path.display()); + println!("cargo:rustc-link-lib=static={}", package.name); + } + } +} + +fn link_clang_rt(rust_target: &RustTarget) { + println!( + "cargo:rustc-link-lib=clang_rt.{}", + rust_target.sdk.clang_lib_extension() + ); + println!("cargo:rustc-link-search={}", clang_link_search_path()); +} + +fn clang_link_search_path() -> String { + let output = std::process::Command::new( + std::env::var("SWIFT_RS_CLANG").unwrap_or_else(|_| "/usr/bin/clang".to_string()), + ) + .arg("--print-search-dirs") + .output() + .unwrap(); + if !output.status.success() { + panic!("Can't get search paths from clang"); + } + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + if line.contains("libraries: =") { + let path = line.split('=').nth(1).unwrap(); + return format!("{}/lib/darwin", path); + } + } + panic!("clang is missing search paths"); +} diff --git a/packages/swift-rs/src-rs/dark_magic.rs b/packages/swift-rs/src-rs/dark_magic.rs new file mode 100644 index 00000000..7cb8c36a --- /dev/null +++ b/packages/swift-rs/src-rs/dark_magic.rs @@ -0,0 +1,90 @@ +/// This retain-balancing algorithm is cool but likely isn't required. +/// I'm keeping it around in case it's necessary one day. + +// #[derive(Clone, Copy, Debug)] +// enum ValueArity { +// Reference, +// Value, +// } + +// pub unsafe fn balance_ptrs(args: Vec<(*const c_void, bool)>, ret: Vec<(*const c_void, bool)>) { +// fn collect_references( +// v: Vec<(*const c_void, bool)>, +// ) -> BTreeMap<*const c_void, Vec> { +// v.into_iter().fold( +// BTreeMap::<_, Vec>::new(), +// |mut map, (ptr, is_ref)| { +// map.entry(ptr).or_default().push(if is_ref { +// ValueArity::Reference +// } else { +// ValueArity::Value +// }); +// map +// }, +// ) +// } + +// let mut args = collect_references(args); +// let mut ret = collect_references(ret); + +// let both_counts = args +// .clone() +// .into_iter() +// .flat_map(|(arg, values)| { +// ret.remove(&arg).map(|ret| { +// args.remove(&arg); + +// let ret_values = ret +// .iter() +// .filter(|v| matches!(v, ValueArity::Value)) +// .count() as isize; + +// let arg_references = values +// .iter() +// .filter(|v| matches!(v, ValueArity::Reference)) +// .count() as isize; + +// let ref_in_value_out_retains = min(ret_values, arg_references); + +// (arg, ref_in_value_out_retains) +// }) +// }) +// .collect::>(); + +// let arg_counts = args.into_iter().map(|(ptr, values)| { +// let count = values +// .into_iter() +// .filter(|v| matches!(v, ValueArity::Value)) +// .count() as isize; +// (ptr, count) +// }); + +// let ret_counts = ret +// .into_iter() +// .map(|(ptr, values)| { +// let count = values +// .into_iter() +// .filter(|v| matches!(v, ValueArity::Value)) +// .count() as isize; +// (ptr, count) +// }) +// .collect::>(); + +// both_counts +// .into_iter() +// .chain(arg_counts) +// .chain(ret_counts) +// .for_each(|(ptr, count)| match count { +// 0 => {} +// n if n > 0 => { +// for _ in 0..n { +// retain_object(ptr) +// } +// } +// n => { +// for _ in n..0 { +// release_object(ptr) +// } +// } +// }); +// } diff --git a/packages/swift-rs/src-rs/lib.rs b/packages/swift-rs/src-rs/lib.rs new file mode 100644 index 00000000..3933189d --- /dev/null +++ b/packages/swift-rs/src-rs/lib.rs @@ -0,0 +1,20 @@ +//! Call Swift functions from Rust with ease! +#![cfg_attr(docsrs, feature(doc_cfg))] + +mod autorelease; +mod swift; +mod swift_arg; +mod swift_ret; +mod types; + +pub use autorelease::*; +pub use swift::*; +pub use swift_arg::*; +pub use swift_ret::*; +pub use types::*; + +#[cfg(feature = "build")] +#[cfg_attr(docsrs, doc(cfg(feature = "build")))] +mod build; +#[cfg(feature = "build")] +pub use build::*; diff --git a/packages/swift-rs/src-rs/swift.rs b/packages/swift-rs/src-rs/swift.rs new file mode 100644 index 00000000..0b4f3706 --- /dev/null +++ b/packages/swift-rs/src-rs/swift.rs @@ -0,0 +1,101 @@ +use std::ffi::c_void; + +use crate::*; + +/// Reference to an `NSObject` for internal use by [`swift!`]. +#[must_use = "A Ref MUST be sent over to the Swift side"] +#[repr(transparent)] +pub struct SwiftRef<'a, T: SwiftObject>(&'a SRObjectImpl); + +impl<'a, T: SwiftObject> SwiftRef<'a, T> { + pub(crate) unsafe fn retain(&self) { + retain_object(self.0 as *const _ as *const c_void) + } +} + +/// A type that is represented as an `NSObject` in Swift. +pub trait SwiftObject { + type Shape; + + /// Gets a reference to the `SRObject` at the root of a `SwiftObject` + fn get_object(&self) -> &SRObject; + + /// Creates a [`SwiftRef`] for an object which can be used when calling a Swift function. + /// This function should never be called manually, + /// instead you should rely on the [`swift!`] macro to call it for you. + /// + /// # Safety + /// This function converts the [`NonNull`](std::ptr::NonNull) + /// inside an [`SRObject`] into a reference, + /// implicitly assuming that the pointer is still valid. + /// The inner pointer is private, + /// and the returned [`SwiftRef`] is bound to the lifetime of the original [`SRObject`], + /// so if you use `swift-rs` as normal this function should be safe. + unsafe fn swift_ref(&self) -> SwiftRef + where + Self: Sized, + { + SwiftRef(self.get_object().0.as_ref()) + } + + /// Adds a retain to an object. + /// + /// # Safety + /// Just don't call this, let [`swift!`] handle it for you. + unsafe fn retain(&self) + where + Self: Sized, + { + self.swift_ref().retain() + } +} + +swift!(pub(crate) fn retain_object(obj: *const c_void)); +swift!(pub(crate) fn release_object(obj: *const c_void)); +swift!(pub(crate) fn data_from_bytes(data: *const u8, size: Int) -> SRData); +swift!(pub(crate) fn string_from_bytes(data: *const u8, size: Int) -> SRString); + +/// Declares a function defined in a swift library. +/// As long as this macro is used, retain counts of arguments +/// and return values will be correct. +/// +/// Use this macro as if the contents were going directly +/// into an `extern "C"` block. +/// +/// ``` +/// use swift_rs::*; +/// +/// swift!(fn echo(string: &SRString) -> SRString); +/// +/// let string: SRString = "test".into(); +/// let result = unsafe { echo(&string) }; +/// +/// assert_eq!(result.as_str(), string.as_str()) +/// ``` +/// +/// # Details +/// +/// Internally this macro creates a wrapping function around an `extern "C"` block +/// that represents the actual Swift function. This is done in order to restrict the types +/// that can be used as arguments and return types, and to ensure that retain counts of returned +/// values are appropriately balanced. +#[macro_export] +macro_rules! swift { + ($vis:vis fn $name:ident $(<$($lt:lifetime),+>)? ($($arg:ident: $arg_ty:ty),*) $(-> $ret:ty)?) => { + $vis unsafe fn $name $(<$($lt),*>)? ($($arg: $arg_ty),*) $(-> $ret)? { + extern "C" { + fn $name $(<$($lt),*>)? ($($arg: <$arg_ty as $crate::SwiftArg>::ArgType),*) $(-> $ret)?; + } + + let res = { + $(let $arg = $crate::SwiftArg::as_arg(&$arg);)* + + $name($($arg),*) + }; + + $crate::SwiftRet::retain(&res); + + res + } + }; +} diff --git a/packages/swift-rs/src-rs/swift_arg.rs b/packages/swift-rs/src-rs/swift_arg.rs new file mode 100644 index 00000000..689650e2 --- /dev/null +++ b/packages/swift-rs/src-rs/swift_arg.rs @@ -0,0 +1,75 @@ +use std::ffi::c_void; + +use crate::{swift::SwiftObject, *}; + +/// Identifies a type as being a valid argument in a Swift function. +pub trait SwiftArg<'a> { + type ArgType; + + /// Creates a swift-compatible version of the argument. + /// For primitives this just returns `self`, + /// but for [`SwiftObject`] types it wraps them in [`SwiftRef`]. + /// + /// This function is called within the [`swift!`] macro. + /// + /// # Safety + /// + /// Creating a [`SwiftRef`] is inherently unsafe, + /// but is reliable if using the [`swift!`] macro, + /// so it is not advised to call this function manually. + unsafe fn as_arg(&'a self) -> Self::ArgType; +} + +macro_rules! primitive_impl { + ($($t:ty),+) => { + $(impl<'a> SwiftArg<'a> for $t { + type ArgType = $t; + + unsafe fn as_arg(&'a self) -> Self::ArgType { + *self + } + })+ + }; +} + +primitive_impl!( + Bool, + Int, + Int8, + Int16, + Int32, + Int64, + UInt, + UInt8, + UInt16, + UInt32, + UInt64, + Float32, + Float64, + *const c_void, + *mut c_void, + *const u8, + () +); + +macro_rules! ref_impl { + ($($t:ident $(<$($gen:ident),+>)?),+) => { + $(impl<'a $($(, $gen: 'a),+)?> SwiftArg<'a> for $t$(<$($gen),+>)? { + type ArgType = SwiftRef<'a, $t$(<$($gen),+>)?>; + + unsafe fn as_arg(&'a self) -> Self::ArgType { + self.swift_ref() + } + })+ + }; +} + +ref_impl!(SRObject, SRArray, SRData, SRString); + +impl<'a, T: SwiftArg<'a>> SwiftArg<'a> for &T { + type ArgType = T::ArgType; + + unsafe fn as_arg(&'a self) -> Self::ArgType { + (*self).as_arg() + } +} diff --git a/packages/swift-rs/src-rs/swift_ret.rs b/packages/swift-rs/src-rs/swift_ret.rs new file mode 100644 index 00000000..d853d126 --- /dev/null +++ b/packages/swift-rs/src-rs/swift_ret.rs @@ -0,0 +1,55 @@ +use crate::{swift::SwiftObject, *}; +use std::ffi::c_void; + +/// Identifies a type as being a valid return type from a Swift function. +/// For types that are objects which need extra retains, +/// the [`retain`](SwiftRet::retain) function will be re-implemented. +pub trait SwiftRet { + /// Adds a retain to the value if possible + /// + /// # Safety + /// Just don't use this. + /// Let [`swift!`] handle it. + unsafe fn retain(&self) {} +} + +macro_rules! primitive_impl { + ($($t:ty),+) => { + $(impl SwiftRet for $t { + })+ + }; +} + +primitive_impl!( + Bool, + Int, + Int8, + Int16, + Int32, + Int64, + UInt, + UInt8, + UInt16, + UInt32, + UInt64, + Float32, + Float64, + *const c_void, + *mut c_void, + *const u8, + () +); + +impl SwiftRet for Option { + unsafe fn retain(&self) { + if let Some(v) = self { + v.retain() + } + } +} + +impl SwiftRet for T { + unsafe fn retain(&self) { + (*self).retain() + } +} diff --git a/packages/swift-rs/src-rs/test-build.rs b/packages/swift-rs/src-rs/test-build.rs new file mode 100644 index 00000000..da43c635 --- /dev/null +++ b/packages/swift-rs/src-rs/test-build.rs @@ -0,0 +1,20 @@ +//! Build script for swift-rs that is a no-op for normal builds, but can be enabled +//! to include test swift library based on env var `TEST_SWIFT_RS=true` with the +//! `build` feature being enabled. + +#[cfg(feature = "build")] +mod build; + +fn main() { + println!("cargo:rerun-if-env-changed=TEST_SWIFT_RS"); + + #[cfg(feature = "build")] + if std::env::var("TEST_SWIFT_RS").unwrap_or_else(|_| "false".into()) == "true" { + use build::SwiftLinker; + + SwiftLinker::new("10.15") + .with_ios("11") + .with_package("test-swift", "tests/swift-pkg") + .link(); + } +} diff --git a/packages/swift-rs/src-rs/types/array.rs b/packages/swift-rs/src-rs/types/array.rs new file mode 100644 index 00000000..fc69069b --- /dev/null +++ b/packages/swift-rs/src-rs/types/array.rs @@ -0,0 +1,110 @@ +use std::{ops::Deref, ptr::NonNull}; + +use crate::swift::SwiftObject; + +use super::SRObject; + +/// Wrapper of [`SRArray`] exclusively for arrays of objects. +/// Equivalent to `SRObjectArray` in Swift. +// SRArray is wrapped in SRObject since the Swift implementation extends NSObject +pub type SRObjectArray = SRObject>>; + +#[doc(hidden)] +#[repr(C)] +pub struct SRArrayImpl { + data: NonNull, + length: usize, +} + +/// General array type for objects and scalars. +/// +/// ## Returning Directly +/// +/// When returning an `SRArray` from a Swift function, +/// you will need to wrap it in an `NSObject` class since +/// Swift doesn't permit returning generic types from `@_cdecl` functions. +/// To account for the wrapping `NSObject`, the array must be wrapped +/// in `SRObject` on the Rust side. +/// +/// ```rust +/// use swift_rs::{swift, SRArray, SRObject, Int}; +/// +/// swift!(fn get_int_array() -> SRObject>); +/// +/// let array = unsafe { get_int_array() }; +/// +/// assert_eq!(array.as_slice(), &[1, 2, 3]) +/// ``` +/// [_corresponding Swift code_](https://github.com/Brendonovich/swift-rs/blob/07269e511f1afb71e2fcfa89ca5d7338bceb20e8/tests/swift-pkg/doctests.swift#L19) +/// +/// ## Returning in a Struct fIeld +/// +/// When returning an `SRArray` from a custom struct that is itself an `NSObject`, +/// the above work is already done for you. +/// Assuming your custom struct is already wrapped in `SRObject` in Rust, +/// `SRArray` will work normally. +/// +/// ```rust +/// use swift_rs::{swift, SRArray, SRObject, Int}; +/// +/// #[repr(C)] +/// struct ArrayStruct { +/// array: SRArray +/// } +/// +/// swift!(fn get_array_struct() -> SRObject); +/// +/// let data = unsafe { get_array_struct() }; +/// +/// assert_eq!(data.array.as_slice(), &[4, 5, 6]); +/// ``` +/// [_corresponding Swift code_](https://github.com/Brendonovich/swift-rs/blob/07269e511f1afb71e2fcfa89ca5d7338bceb20e8/tests/swift-pkg/doctests.swift#L32) +#[repr(transparent)] +pub struct SRArray(SRObject>); + +impl SRArray { + pub fn as_slice(&self) -> &[T] { + self.0.as_slice() + } +} + +impl SwiftObject for SRArray { + type Shape = SRArrayImpl; + + fn get_object(&self) -> &SRObject { + &self.0 + } +} + +impl Deref for SRArray { + type Target = [T]; + + fn deref(&self) -> &Self::Target { + self.0.as_slice() + } +} + +impl SRArrayImpl { + pub fn as_slice(&self) -> &[T] { + unsafe { std::slice::from_raw_parts(self.data.as_ref(), self.length) } + } +} + +#[cfg(feature = "serde")] +impl serde::Serialize for SRArray +where + T: serde::Serialize, +{ + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeSeq; + + let mut seq = serializer.serialize_seq(Some(self.len()))?; + for item in self.iter() { + seq.serialize_element(item)?; + } + seq.end() + } +} diff --git a/packages/swift-rs/src-rs/types/data.rs b/packages/swift-rs/src-rs/types/data.rs new file mode 100644 index 00000000..addcca00 --- /dev/null +++ b/packages/swift-rs/src-rs/types/data.rs @@ -0,0 +1,74 @@ +use crate::{ + swift::{self, SwiftObject}, + Int, +}; + +use super::{array::SRArray, SRObject}; + +use std::ops::Deref; + +type Data = SRArray; + +/// Convenience type for working with byte buffers, +/// analagous to `SRData` in Swift. +/// +/// ```rust +/// use swift_rs::{swift, SRData}; +/// +/// swift!(fn get_data() -> SRData); +/// +/// let data = unsafe { get_data() }; +/// +/// assert_eq!(data.as_ref(), &[1, 2, 3]) +/// ``` +/// [_corresponding Swift code_](https://github.com/Brendonovich/swift-rs/blob/07269e511f1afb71e2fcfa89ca5d7338bceb20e8/tests/swift-pkg/doctests.swift#L68) +#[repr(transparent)] +pub struct SRData(SRObject); + +impl SRData { + pub fn as_slice(&self) -> &[u8] { + self + } + + pub fn to_vec(&self) -> Vec { + self.as_slice().to_vec() + } +} + +impl SwiftObject for SRData { + type Shape = Data; + + fn get_object(&self) -> &SRObject { + &self.0 + } +} + +impl Deref for SRData { + type Target = [u8]; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl AsRef<[u8]> for SRData { + fn as_ref(&self) -> &[u8] { + self + } +} + +impl From<&[u8]> for SRData { + fn from(value: &[u8]) -> Self { + unsafe { swift::data_from_bytes(value.as_ptr(), value.len() as Int) } + } +} + +#[cfg(feature = "serde")] +impl serde::Serialize for SRData { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_bytes(self) + } +} diff --git a/packages/swift-rs/src-rs/types/mod.rs b/packages/swift-rs/src-rs/types/mod.rs new file mode 100644 index 00000000..90d9465d --- /dev/null +++ b/packages/swift-rs/src-rs/types/mod.rs @@ -0,0 +1,11 @@ +mod array; +mod data; +mod object; +mod scalars; +mod string; + +pub use array::*; +pub use data::*; +pub use object::*; +pub use scalars::*; +pub use string::*; diff --git a/packages/swift-rs/src-rs/types/object.rs b/packages/swift-rs/src-rs/types/object.rs new file mode 100644 index 00000000..49748a79 --- /dev/null +++ b/packages/swift-rs/src-rs/types/object.rs @@ -0,0 +1,75 @@ +use crate::swift::{self, SwiftObject}; +use std::{ffi::c_void, ops::Deref, ptr::NonNull}; + +#[doc(hidden)] +#[repr(C)] +pub struct SRObjectImpl { + _nsobject_offset: u8, + data: T, +} + +/// Wrapper for arbitrary `NSObject` types. +/// +/// When returning an `NSObject`, its Rust type must be wrapped in `SRObject`. +/// The type must also be annotated with `#[repr(C)]` to ensure its memory layout +/// is identical to its Swift counterpart's. +/// +/// ```rust +/// use swift_rs::{swift, SRObject, Int, Bool}; +/// +/// #[repr(C)] +/// struct CustomObject { +/// a: Int, +/// b: Bool +/// } +/// +/// swift!(fn get_custom_object() -> SRObject); +/// +/// let value = unsafe { get_custom_object() }; +/// +/// let reference: &CustomObject = value.as_ref(); +/// ``` +/// [_corresponding Swift code_](https://github.com/Brendonovich/swift-rs/blob/07269e511f1afb71e2fcfa89ca5d7338bceb20e8/tests/swift-pkg/doctests.swift#L49) +#[repr(transparent)] +pub struct SRObject(pub(crate) NonNull>); + +impl SwiftObject for SRObject { + type Shape = T; + + fn get_object(&self) -> &SRObject { + self + } +} + +impl Deref for SRObject { + type Target = T; + + fn deref(&self) -> &T { + unsafe { &self.0.as_ref().data } + } +} + +impl AsRef for SRObject { + fn as_ref(&self) -> &T { + self + } +} + +impl Drop for SRObject { + fn drop(&mut self) { + unsafe { swift::release_object(self.0.as_ref() as *const _ as *const c_void) } + } +} + +#[cfg(feature = "serde")] +impl serde::Serialize for SRObject +where + T: serde::Serialize, +{ + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.deref().serialize(serializer) + } +} diff --git a/packages/swift-rs/src-rs/types/scalars.rs b/packages/swift-rs/src-rs/types/scalars.rs new file mode 100644 index 00000000..226f3bd9 --- /dev/null +++ b/packages/swift-rs/src-rs/types/scalars.rs @@ -0,0 +1,34 @@ +/// Swift's [`Bool`](https://developer.apple.com/documentation/swift/bool) type +pub type Bool = bool; + +/// Swift's [`Int`](https://developer.apple.com/documentation/swift/int) type +pub type Int = isize; +/// Swift's [`Int8`](https://developer.apple.com/documentation/swift/int8) type +pub type Int8 = i8; +/// Swift's [`Int16`](https://developer.apple.com/documentation/swift/int16) type +pub type Int16 = i16; +/// Swift's [`Int32`](https://developer.apple.com/documentation/swift/int32) type +pub type Int32 = i32; +/// Swift's [`Int64`](https://developer.apple.com/documentation/swift/int64) type +pub type Int64 = i64; + +/// Swift's [`UInt`](https://developer.apple.com/documentation/swift/uint) type +pub type UInt = usize; +/// Swift's [`UInt8`](https://developer.apple.com/documentation/swift/uint8) type +pub type UInt8 = u8; +/// Swift's [`UInt16`](https://developer.apple.com/documentation/swift/uint16) type +pub type UInt16 = u16; +/// Swift's [`UInt32`](https://developer.apple.com/documentation/swift/uint32) type +pub type UInt32 = u32; +/// Swift's [`UInt64`](https://developer.apple.com/documentation/swift/uint64) type +pub type UInt64 = u64; + +/// Swift's [`Float`](https://developer.apple.com/documentation/swift/float) type +pub type Float = f32; +/// Swift's [`Double`](https://developer.apple.com/documentation/swift/double) type +pub type Double = f64; + +/// Swift's [`Float32`](https://developer.apple.com/documentation/swift/float32) type +pub type Float32 = f32; +/// Swift's [`Float64`](https://developer.apple.com/documentation/swift/float64) type +pub type Float64 = f64; diff --git a/packages/swift-rs/src-rs/types/string.rs b/packages/swift-rs/src-rs/types/string.rs new file mode 100644 index 00000000..3f6f86fd --- /dev/null +++ b/packages/swift-rs/src-rs/types/string.rs @@ -0,0 +1,84 @@ +use std::{ + fmt::{Display, Error, Formatter}, + ops::Deref, +}; + +use crate::{ + swift::{self, SwiftObject}, + Int, SRData, SRObject, +}; + +/// String type that can be shared between Swift and Rust. +/// +/// ```rust +/// use swift_rs::{swift, SRString}; +/// +/// swift!(fn get_greeting(name: &SRString) -> SRString); +/// +/// let greeting = unsafe { get_greeting(&"Brendan".into()) }; +/// +/// assert_eq!(greeting.as_str(), "Hello Brendan!"); +/// ``` +/// [_corresponding Swift code_](https://github.com/Brendonovich/swift-rs/blob/07269e511f1afb71e2fcfa89ca5d7338bceb20e8/tests/swift-pkg/doctests.swift#L56) +#[repr(transparent)] +pub struct SRString(SRData); + +impl SRString { + pub fn as_str(&self) -> &str { + unsafe { std::str::from_utf8_unchecked(&self.0) } + } +} + +impl SwiftObject for SRString { + type Shape = ::Shape; + + fn get_object(&self) -> &SRObject { + self.0.get_object() + } +} + +impl Deref for SRString { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.as_str() + } +} + +impl AsRef<[u8]> for SRString { + fn as_ref(&self) -> &[u8] { + self.0.as_ref() + } +} + +impl From<&str> for SRString { + fn from(string: &str) -> Self { + unsafe { swift::string_from_bytes(string.as_ptr(), string.len() as Int) } + } +} + +impl Display for SRString { + fn fmt(&self, f: &mut Formatter) -> Result<(), Error> { + self.as_str().fmt(f) + } +} + +#[cfg(feature = "serde")] +impl serde::Serialize for SRString { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} +#[cfg(feature = "serde")] +impl<'a> serde::Deserialize<'a> for SRString { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'a>, + { + let string = String::deserialize(deserializer)?; + Ok(SRString::from(string.as_str())) + } +} diff --git a/packages/swift-rs/tests/test_bindings.rs b/packages/swift-rs/tests/test_bindings.rs new file mode 100644 index 00000000..35f9b4c2 --- /dev/null +++ b/packages/swift-rs/tests/test_bindings.rs @@ -0,0 +1,150 @@ +//! Test for swift-rs bindings +//! +//! Needs to be run with the env var `TEST_SWIFT_RS=true`, to allow for +//! the test swift code to be linked. + +use serial_test::serial; +use std::{env, process::Command}; +use swift_rs::*; + +macro_rules! test_with_leaks { + ( $op:expr ) => {{ + let leaks_env_var = "TEST_RUNNING_UNDER_LEAKS"; + if env::var(leaks_env_var).unwrap_or_else(|_| "false".into()) == "true" { + let _ = $op(); + } else { + // we run $op directly in the current process first, as leaks will not give + // us the exit code of $op, but only if memory leaks happened or not + $op(); + + // and now we run the above codepath under leaks monitoring + let exe = env::current_exe().unwrap(); + + // codesign the binary first, so that leaks can be run + let debug_plist = exe.parent().unwrap().join("debug.plist"); + let plist_path = &debug_plist.to_string_lossy(); + std::fs::write(&debug_plist, DEBUG_PLIST_XML.as_bytes()).unwrap(); + let status = Command::new("codesign") + .args([ + "-s", + "-", + "-v", + "-f", + "--entitlements", + plist_path, + &exe.to_string_lossy(), + ]) + .status() + .expect("cmd failure"); + assert!(status.success(), "failed to codesign"); + + // run leaks command to detect memory leaks + let status = Command::new("leaks") + .args(["-atExit", "--", &exe.to_string_lossy(), "--nocapture"]) + .env(leaks_env_var, "true") + .status() + .expect("cmd failure"); + assert!(status.success(), "leaks detected in memory pressure test"); + } + }}; +} + +swift!(fn echo(string: &SRString) -> SRString); + +#[test] +#[serial] +fn test_reflection() { + test_with_leaks!(|| { + // create memory pressure + let name: SRString = "Brendan".into(); + for _ in 0..10_000 { + let reflected = unsafe { echo(&name) }; + assert_eq!(name.as_str(), reflected.as_str()); + } + }); +} + +swift!(fn get_greeting(name: &SRString) -> SRString); + +#[test] +#[serial] +fn test_string() { + test_with_leaks!(|| { + let name: SRString = "Brendan".into(); + let greeting = unsafe { get_greeting(&name) }; + assert_eq!(greeting.as_str(), "Hello Brendan!"); + }); +} + +#[test] +#[serial] +fn test_memory_pressure() { + test_with_leaks!(|| { + // create memory pressure + let name: SRString = "Brendan".into(); + for _ in 0..10_000 { + let greeting = unsafe { get_greeting(&name) }; + assert_eq!(greeting.as_str(), "Hello Brendan!"); + } + }); +} + +#[test] +#[serial] +fn test_autoreleasepool() { + test_with_leaks!(|| { + // create memory pressure + let name: SRString = "Brendan".into(); + for _ in 0..10_000 { + autoreleasepool!({ + let greeting = unsafe { get_greeting(&name) }; + assert_eq!(greeting.as_str(), "Hello Brendan!"); + }); + } + }); +} + +#[repr(C)] +struct Complex { + a: SRString, + b: Int, + c: Bool, +} + +swift!(fn complex_data() -> SRObjectArray); + +#[test] +#[serial] +fn test_complex() { + test_with_leaks!(|| { + let mut v = vec![]; + + for _ in 0..10_000 { + let data = unsafe { complex_data() }; + assert_eq!(data[0].a.as_str(), "Brendan"); + v.push(data); + } + }); +} + +swift!(fn echo_data(data: &SRData) -> SRData); + +#[test] +#[serial] +fn test_data() { + test_with_leaks!(|| { + let str: &str = "hello"; + let bytes = str.as_bytes(); + for _ in 0..10_000 { + let data = unsafe { echo_data(&bytes.into()) }; + assert_eq!(data.as_slice(), bytes); + } + }); +} + +const DEBUG_PLIST_XML: &str = r#" + + + com.apple.security.get-task-allow + +"#;