forked from akai/readest
* 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:
Generated
+4
-2
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
+87
@@ -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.
|
||||
|
||||
+48
@@ -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()
|
||||
|
||||
+13
@@ -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"]
|
||||
+27
@@ -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",
|
||||
]
|
||||
|
||||
+14
-2
@@ -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()
|
||||
}
|
||||
|
||||
+18
@@ -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,
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
-1
Submodule packages/foliate-js updated: 8485e93213...c50a7b164c
@@ -0,0 +1 @@
|
||||
example/* linguist-vendored
|
||||
+38
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
.build/
|
||||
target/
|
||||
.swiftpm/
|
||||
.idea/
|
||||
.DS_Store
|
||||
icon.txt
|
||||
**/Cargo.lock
|
||||
@@ -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 = []
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,483 @@
|
||||
# swift-rs
|
||||
|
||||

|
||||

|
||||
|
||||
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.
|
||||
@@ -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
|
||||
}};
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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)
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
@@ -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::*;
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
"#;
|
||||
Reference in New Issue
Block a user