feat(reader): slide and page curl turn animations (#555) (#4940)

* feat(reader): slide and page curl turn animations (#555)

Add an Animation Style setting (Push, Slide, Page Curl) next to the
Paging Animation switch. Slide moves the turning page over the still
previous or next page like the Apple Books slide; Page Curl folds it
open in 3D so the page underneath is partially visible as it turns.
Both styles track the finger: the page follows a horizontal drag and
commits past halfway or on a flick, or settles back. The page header
and footer stay in place while the page turns.

The styles layer a View Transitions snapshot of the outgoing page over
the live, stationary incoming page, since the pages of one section live
in a single iframe and can never be on screen twice. They work for all
writing modes including vertical-rl, and on engines without the View
Transitions API (older WebViews) the paginator falls back to the
existing push animation, so all platforms keep working page turns.

The paginator changes live in the foliate-js submodule; this bumps the
pointer, wires viewSettings.pageTurnStyle to the renderer turn-style
attribute, and adds browser tests covering slide layering, curl,
vertical-rl, finger tracking with commit and revert, and the push
fallback.

Fixes #555

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(reader): add WebGL page curl renderer for mesh turn animations (#555)

Grid mesh deformed around a cylinder: content past the fold wraps over
and lands mirrored on top with a whitened page back, transparent where
the page has curled away. Corner grabs start as a steep diagonal pinch
that straightens as the turn completes so the whole page clears by the
end. Groundwork for the Tauri mesh curl; capture and orchestration land
separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(native-bridge): capture webview region as PNG on macOS and iOS (#555)

New capture_webview_region plugin command returns a binary PNG snapshot
of the calling webview (tauri::ipc::Response, no JSON overhead) for the
mesh page-curl texture. macOS goes through WKWebView
takeSnapshotWithConfiguration via with_webview on the main thread with
a 500ms timeout; iOS snapshots in Swift and hands the PNG across the
JSON-only plugin boundary base64-encoded, decoded back to bytes in
mobile.rs. Windows, Linux, and Android reject for now so the JS side
falls back to the CSS curl.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(reader): drive the mesh page curl on Tauri platforms (#555)

Wire the WebGL curl renderer and the native webview capture into page
turns. A MeshCurlTurn controller runs the pipeline per turn: snapshot
the content box, overlay the captured page drawn flat, turn the live
view instantly underneath (the paginator's animated paths all gate on
the animated attribute), then curl the capture away. Backward turns
mirror the fold to the spine edge, matching the layered VT curl's
old-page-recedes choreography.

useMeshPageCurl wraps the view's prev/next so taps, keys, and wheel
turns all curl, and registers a touch interceptor (between the reading
ruler and the fixed-layout swipe) that scrubs the curl from the finger,
committing past halfway or on a flick and otherwise un-curling and
turning back under the overlay. The paginator stays out of the way via
no-swipe while the mesh is active; if the native capture ever fails the
session falls back to the paginator's CSS arc-fold curl and the shared
applyPageTurnAttributes helper restores turn-style.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(build): restore iOS builds on Xcode 26.2 with a vendored swift-rs

Swift 6.2's driver no longer honors swift-rs 1.0.7's cross-compilation
style (swift build --arch <host> with per-swiftc -target overrides and
an inherited SDKROOT): plugin sources compile against the wrong
platform's Swift overlays and fail with baffling errors like type
'Bundle' has no member 'main' and extra argument 'privacy' in call.
Upstream swift-rs is unmaintained, so vendor it under packages/swift-rs
via a crates-io patch and build with SPM's first-class --triple/--sdk
flags instead, dropping the leaked SDKROOT so the host-targeted
manifest compile stays clean. Artifacts land in the unversioned-triple
directory now, so the link search path follows.

With --triple, SPM enforces the deployment floor declared in
Package.swift (the old override bypassed it): bump native-bridge to
iOS 15.0, matching the app's deployment target, since StoreKit's
Storefront is used unguarded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(native-bridge): capture webview region on Android via PixelCopy (#555)

Implements the Android side of capture_webview_region so the mesh page
curl works there too. The Kotlin command scales the CSS-pixel rect by
the display density, offsets it by the webview's window position, and
reads the pixels back from the window surface with PixelCopy (API 26+,
the app's minSdk), which includes the hardware-accelerated WebView that
View.draw would miss. PNG encoding runs off the main thread and the
result crosses the JSON plugin boundary base64-encoded, decoded back to
bytes in mobile.rs like iOS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reader): right the upside-down page curl on iOS (#555)

The renderer oriented its texture with UNPACK_FLIP_Y_WEBGL, which WebKit
ignores for ImageBitmap uploads: on iOS the captured page rendered
upside down, and the mirrored page back read as rotated 180 degrees
instead of the ink-through-paper horizontal mirror Apple Books shows.
Upload unflipped and sample page coordinates directly so no pixel-store
flag is involved.

The page texture in the browser test was only horizontally asymmetric,
which is how the flip slipped through; it now uses four quadrants fed
through the production PNG-blob-to-ImageBitmap path and pins the
vertical orientation. Verified red/green by running the suite on
Playwright WebKit, which reproduces the iOS behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reader): curl the whole page including header, footer, and margins (#555)

The mesh curl captured only the margin-inset content box, leaving the
running header, footer, and page margins static while just the text
column turned. A physical page turn takes the whole sheet with it, as
Apple Books does, so the capture and overlay now span the full reader
cell. The overlay mounts above the in-cell header (z-10) and footer, so
the static copies never show through the turning page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reader): gate layered View Transition turns and slide from a capture instead (#555)

iOS 18 WebKit ships document.startViewTransition but crashes the WebContent
process when a page-turn transition snapshots the reader, so the mere
presence of the API is not enough for the layered slide/curl turns. Require
nested view-transition groups (Chrome/WebView 140+) as the marker of a
mature engine before setting turn-style on the renderer.

Engines that fail the check no longer lose the slide on Tauri: the mesh
curl's capture pipeline generalizes to CapturedPageTurn and now also drives
a flat slide overlay (capture the outgoing page, turn instantly underneath,
translate the captured page out toward the spine, mirrored for backward
turns), clipped to the content box with an edge shadow like the VT slide.
On the web, engines without full support fall back to push and the
Slide/Page Curl options are hidden from the Animation Style select; a
synced slide/curl setting from another device reads as Push there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reader): make the Android page curl start instantly (#555)

The Android capture encoded a full-density PNG: 1080x2400 on a 3x
Xiaomi 13 took ~1.5s per turn, so the page sat frozen long enough to
read as the curl not working at all. Encode JPEG instead (the page is
opaque) and cap the destination bitmap at 2x CSS pixels - PixelCopy
scales into a smaller bitmap for free and the moving page stays sharp.
Measured on device over CDP: the capture invoke drops from 1550ms to
34ms and the curl overlay mounts 132ms after the tap.

The JS side stops hardcoding an image/png blob type and lets the
decoder sniff the platform's actual format.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(reader): encode iOS page-curl captures as capped JPEG (#555)

Apply the Android speedup to iOS: encode the snapshot as JPEG (the
page is opaque) off the main thread, and cap it at 2x CSS pixels via
WKSnapshotConfiguration.snapshotWidth on 3x screens, cutting both the
encode time and the base64 payload crossing the JSON plugin boundary.
The JS side already sniffs the image format from the bytes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-07-06 03:49:57 +09:00
committed by GitHub
parent 9321c2cd39
commit 75f1fafe9f
55 changed files with 4171 additions and 23 deletions
Generated
+4 -2
View File
@@ -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",
+4
View File
@@ -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" }
@@ -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"
@@ -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
@@ -39,6 +39,7 @@ const COMMANDS: &[&str] = &[
"clear_secure_item",
"refresh_eink_screen",
"update_reading_widget",
"capture_webview_region",
];
fn main() {
@@ -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.
@@ -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()
@@ -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"]
@@ -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.
<tr>
<td>
`native-bridge:allow-capture-webview-region`
</td>
<td>
Enables the capture_webview_region command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-capture-webview-region`
</td>
<td>
Denies the capture_webview_region command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-check-permissions`
</td>
@@ -44,4 +44,5 @@ permissions = [
"allow-clear-secure-item",
"allow-refresh-eink-screen",
"allow-update-reading-widget",
"allow-capture-webview-region",
]
@@ -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`"
}
]
}
@@ -288,3 +288,19 @@ pub(crate) async fn update_reading_widget<R: Runtime>(
) -> 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<R: Runtime>(
app: AppHandle<R>,
window: tauri::WebviewWindow<R>,
payload: CaptureWebviewRegionRequest,
) -> Result<tauri::ipc::Response> {
let png = app
.native_bridge()
.capture_webview_region(&window, payload)?;
Ok(tauri::ipc::Response::new(png))
}
@@ -352,13 +352,31 @@ impl<R: Runtime> NativeBridge<R> {
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<R>,
payload: CaptureWebviewRegionRequest,
) -> crate::Result<Vec<u8>> {
#[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";
@@ -90,6 +90,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
commands::clear_secure_item,
commands::refresh_eink_screen,
commands::update_reading_widget,
commands::capture_webview_region,
])
.setup(|app, api| {
#[cfg(mobile)]
@@ -361,3 +361,24 @@ impl<R: Runtime> NativeBridge<R> {
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
/// 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<R>,
payload: CaptureWebviewRegionRequest,
) -> crate::Result<Vec<u8>> {
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}")))
}
}
@@ -432,3 +432,24 @@ pub struct UpdateReadingWidgetRequest {
#[serde(default)]
pub tts: Option<ReadingWidgetTts>,
}
/// 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,
}
@@ -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<R: Runtime>(
window: &tauri::WebviewWindow<R>,
payload: CaptureWebviewRegionRequest,
) -> crate::Result<Vec<u8>> {
let (tx, rx) = mpsc::channel::<Result<Vec<u8>, 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<Result<Vec<u8>, 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<Option<_>>.
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<Vec<u8>, 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()
}
@@ -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=");
}
@@ -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<void>((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<string, string> = {};
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;
}
});
});
@@ -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);
});
});
@@ -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<ArrayBuffer> => {
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<Blob>((resolve) => canvas.toBlob((b) => resolve(b!), 'image/png'));
return blob.arrayBuffer();
};
describe('CapturedPageTurn (browser)', () => {
let host: HTMLDivElement;
let capture: ReturnType<typeof vi.fn<CapturedTurnHost['capture']>>;
let navigate: ReturnType<typeof vi.fn<CapturedTurnHost['navigate']>>;
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<CapturedTurnHost['capture']>().mockResolvedValue(png);
navigate = vi.fn<CapturedTurnHost['navigate']>().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();
});
});
@@ -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<ImageBitmap> => {
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<Blob>((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);
});
});
@@ -142,6 +142,10 @@ const BookCellInner: React.FC<BookCellProps> = ({
return (
<div
id={`gridcell-${bookKey}`}
// Layered page turns (slide/curl) snapshot this element, so the page
// header and footer rendered as siblings of the viewer turn with the
// page in both layers.
data-view-transition-root=''
className={clsx(
'relative h-full w-full overflow-hidden',
appServiceHasRoundedWindow && 'rounded-window',
@@ -14,6 +14,7 @@ import { useSettingsStore } from '@/store/settingsStore';
import { useCustomFontStore } from '@/store/customFontStore';
import { useParallelViewStore } from '@/store/parallelViewStore';
import { useMouseEvent, useTouchEvent, useOpenMediaEvent } from '../hooks/useIframeEvents';
import { useCapturedTurn, applyPageTurnAttributes } from '../hooks/useCapturedTurn';
import { useBrightnessGesture } from '../hooks/useBrightnessGesture';
import BrightnessOverlay from './BrightnessOverlay';
import { usePagination, viewPagination } from '../hooks/usePagination';
@@ -607,6 +608,8 @@ const FoliateViewer: React.FC<{
useOpenMediaEvent(bookKey, handleImagePress, handleTablePress);
useCapturedTurn(bookKey, viewRef);
useFoliateEvents(viewRef.current, {
onLoad: docLoadHandler,
onStabilized: stabilizedHandler,
@@ -702,6 +705,7 @@ const FoliateViewer: React.FC<{
} else {
view.renderer.removeAttribute('animated');
}
applyPageTurnAttributes(view, viewSettings, bookDoc.rendition?.layout === 'pre-paginated');
// iOS WebKit composites large/persistent page layers without the Android
// high-DPR Blink freeze, so opt this renderer into the GPU-accelerated
// page-turn path (persistent compositor layers + no main-thread
@@ -710,11 +714,6 @@ const FoliateViewer: React.FC<{
if (appService?.isIOSApp) {
view.renderer.setAttribute('gpu-composite', '');
}
if (viewSettings.disableSwipe) {
view.renderer.setAttribute('no-swipe', '');
} else {
view.renderer.removeAttribute('no-swipe');
}
if (appService?.isAndroidApp) {
if (eink) {
view.renderer.setAttribute('eink', '');
@@ -0,0 +1,253 @@
import { useEffect, useRef } from 'react';
import { FoliateView } from '@/types/view';
import { ViewSettings } from '@/types/book';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { captureWebviewRegion } from '@/utils/bridge';
import { isTauriAppPlatform } from '@/services/environment';
import { CapturedPageTurn, CapturedTurnStyle } from '../utils/capturedTurn';
import { useTouchInterceptor } from './useTouchInterceptor';
// Once the native snapshot fails (older webview, capture bug), stop trying
// for the rest of the session: the renderer's own `turn-style` animations
// take over where the engine supports them, push everywhere else.
let captureBroken = false;
/**
* Whether the engine runs the paginator's layered View Transition turns
* reliably. iOS 18 WebKit ships `document.startViewTransition` but crashes
* the WebContent process when a turn transition snapshots the reader, so
* the presence of the API is not enough; nested view-transition groups
* (Chrome/WebView 140+) mark the mature engines where the layered turns
* are known to work.
*/
export const supportsViewTransitionTurns = (): boolean =>
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<FoliateView | null>) => {
const { getViewSettings } = useReaderStore();
const { getBookData } = useBookDataStore();
const controllerRef = useRef<CapturedPageTurn | null>(null);
const dragRef = useRef<DragState | null>(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<void>;
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));
};
@@ -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<ArrayBuffer>;
/** Instant (animation-less) page turn of the live view. */
navigate: (forward: boolean) => Promise<void>;
}
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<unknown> = 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<boolean> {
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<boolean> {
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<ActiveTurn | null> {
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<void> {
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();
}
}
@@ -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<SettingsPanelPanelProp> = ({ 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<SettingsPanelPanelProp> = ({ 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<SettingsPanelPanelProp> = ({ 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<SettingsPanelPanelProp> = ({ 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<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
checked={animated}
onChange={() => setAnimated(!animated)}
/>
<SettingsRow label={_('Animation Style')} data-setting-id='settings.control.pageTurnStyle'>
<SettingsSelect
// A synced slide/curl setting from another device still reads as
// push here when this engine cannot animate it.
value={
turnStyleOptions.some((opt) => opt.value === pageTurnStyle) ? pageTurnStyle : 'push'
}
onChange={(e) => setPageTurnStyle(e.target.value as PageTurnStyle)}
ariaLabel={_('Animation Style')}
options={turnStyleOptions}
disabled={!animated}
/>
</SettingsRow>
</BoxedList>
<BoxedList title={_('Device')} data-setting-id='settings.control.device'>
@@ -374,6 +374,7 @@ export const DEFAULT_VIEW_CONFIG: ViewConfig = {
progressInfoMode: 'all',
animated: false,
pageTurnStyle: 'push',
isEink: false,
isColorEink: false,
+4
View File
@@ -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;
+24
View File
@@ -255,6 +255,30 @@ export async function refreshEinkScreen(): Promise<RefreshEinkScreenResponse> {
return await invoke<RefreshEinkScreenResponse>('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<ArrayBuffer> {
return await invoke<ArrayBuffer>('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
+259
View File
@@ -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<string, WebGLUniformLocation | null> = {};
/** 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;
}
}
+62
View File
@@ -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;
}
}
+1
View File
@@ -0,0 +1 @@
example/* linguist-vendored
+38
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
.build/
target/
.swiftpm/
.idea/
.DS_Store
icon.txt
**/Cargo.lock
+73
View File
@@ -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 = []
+201
View File
@@ -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.
+19
View File
@@ -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.
+483
View File
@@ -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 <b>all</b> 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
}
}
```
<sub><sup>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<T>`.
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<SquareNumberResult>);
```
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<T>` type!
```rust
use swift_rs::{swift, Bool, SRString};
swift!(optional_string(return_nil: Bool) -> Option<SRString>)
```
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<T>`) 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<T>` 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<Int>
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<Int>
}
// Since IntArray extends NSObject in its Swift implementation,
// it must be wrapped in SRObject on the Rust side
swift!(fn get_numbers() -> SRObject<IntArray>);
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<usize>`,
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<SRArray<Int>>);
```
**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<T>` 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<T>`, 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<T> since
// SRObjectArray<T> does it automatically
swift!(fn get_tuples() -> SRObjectArray<IntTuple>);
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<T>` you like, just remember to follow the 3 rules!
## Bonuses
### SRData
A wrapper type for `SRArray<T>` 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.
+26
View File
@@ -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
}};
}
+330
View File
@@ -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<String>,
runtime_library_import_paths: Vec<String>,
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<SwiftPackage>,
macos_min_version: String,
ios_min_version: Option<String>,
}
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<Path>) -> 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 <host>` 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");
}
+90
View File
@@ -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<ValueArity>> {
// v.into_iter().fold(
// BTreeMap::<_, Vec<ValueArity>>::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::<Vec<_>>();
// 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::<Vec<_>>();
// 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)
// }
// }
// });
// }
+20
View File
@@ -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::*;
+101
View File
@@ -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<T::Shape>);
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<Self::Shape>;
/// 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<Self>
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
}
};
}
+75
View File
@@ -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<T>, SRArray<T>, 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()
}
}
+55
View File
@@ -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<T: SwiftObject> SwiftRet for Option<T> {
unsafe fn retain(&self) {
if let Some(v) = self {
v.retain()
}
}
}
impl<T: SwiftObject> SwiftRet for T {
unsafe fn retain(&self) {
(*self).retain()
}
}
+20
View File
@@ -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();
}
}
+110
View File
@@ -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<T> = SRObject<SRArray<SRObject<T>>>;
#[doc(hidden)]
#[repr(C)]
pub struct SRArrayImpl<T> {
data: NonNull<T>,
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<SRArray<Int>>);
///
/// 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<Int>
/// }
///
/// swift!(fn get_array_struct() -> SRObject<ArrayStruct>);
///
/// 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<T>(SRObject<SRArrayImpl<T>>);
impl<T> SRArray<T> {
pub fn as_slice(&self) -> &[T] {
self.0.as_slice()
}
}
impl<T> SwiftObject for SRArray<T> {
type Shape = SRArrayImpl<T>;
fn get_object(&self) -> &SRObject<Self::Shape> {
&self.0
}
}
impl<T> Deref for SRArray<T> {
type Target = [T];
fn deref(&self) -> &Self::Target {
self.0.as_slice()
}
}
impl<T> SRArrayImpl<T> {
pub fn as_slice(&self) -> &[T] {
unsafe { std::slice::from_raw_parts(self.data.as_ref(), self.length) }
}
}
#[cfg(feature = "serde")]
impl<T> serde::Serialize for SRArray<T>
where
T: serde::Serialize,
{
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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()
}
}
+74
View File
@@ -0,0 +1,74 @@
use crate::{
swift::{self, SwiftObject},
Int,
};
use super::{array::SRArray, SRObject};
use std::ops::Deref;
type Data = SRArray<u8>;
/// 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<Data>);
impl SRData {
pub fn as_slice(&self) -> &[u8] {
self
}
pub fn to_vec(&self) -> Vec<u8> {
self.as_slice().to_vec()
}
}
impl SwiftObject for SRData {
type Shape = Data;
fn get_object(&self) -> &SRObject<Self::Shape> {
&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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_bytes(self)
}
}
+11
View File
@@ -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::*;
+75
View File
@@ -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<T> {
_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<CustomObject>);
///
/// 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<T>(pub(crate) NonNull<SRObjectImpl<T>>);
impl<T> SwiftObject for SRObject<T> {
type Shape = T;
fn get_object(&self) -> &SRObject<Self::Shape> {
self
}
}
impl<T> Deref for SRObject<T> {
type Target = T;
fn deref(&self) -> &T {
unsafe { &self.0.as_ref().data }
}
}
impl<T> AsRef<T> for SRObject<T> {
fn as_ref(&self) -> &T {
self
}
}
impl<T> Drop for SRObject<T> {
fn drop(&mut self) {
unsafe { swift::release_object(self.0.as_ref() as *const _ as *const c_void) }
}
}
#[cfg(feature = "serde")]
impl<T> serde::Serialize for SRObject<T>
where
T: serde::Serialize,
{
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
self.deref().serialize(serializer)
}
}
+34
View File
@@ -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;
+84
View File
@@ -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 = <SRData as SwiftObject>::Shape;
fn get_object(&self) -> &SRObject<Self::Shape> {
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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.as_str())
}
}
#[cfg(feature = "serde")]
impl<'a> serde::Deserialize<'a> for SRString {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'a>,
{
let string = String::deserialize(deserializer)?;
Ok(SRString::from(string.as_str()))
}
}
+150
View File
@@ -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<Complex>);
#[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#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict><key>com.apple.security.get-task-allow</key><true/></dict>
</plist>
"#;