Files
readest/apps/readest-app/src/utils/bridge.ts
T
Huang Xin 75f1fafe9f feat(reader): slide and page curl turn animations (#555) (#4940)
* feat(reader): slide and page curl turn animations (#555)

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

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

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

Fixes #555

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:49:57 +02:00

421 lines
12 KiB
TypeScript

import { invoke, Channel } from '@tauri-apps/api/core';
export interface CopyURIRequest {
uri: string;
dst: string;
}
export interface CopyURIResponse {
success: boolean;
error?: string;
}
export interface SaveImageToGalleryRequest {
srcPath: string;
fileName: string;
mimeType: string;
albumName?: string;
}
export interface SaveImageToGalleryResponse {
success: boolean;
uri?: string;
error?: string;
}
export interface UseBackgroundAudioRequest {
enabled: boolean;
}
export interface InstallPackageRequest {
path: string;
}
export interface InstallPackageResponse {
success: boolean;
error?: string;
}
export interface SetSystemUIVisibilityRequest {
visible: boolean;
darkMode: boolean;
}
export interface SetSystemUIVisibilityResponse {
success: boolean;
error?: string;
}
export interface GetStatusBarHeightResponse {
height: number;
error?: string;
}
export interface GetSystemFontsListResponse {
fonts: Record<string, string>; // { fontName: fontFamily }
error?: string;
}
export interface InterceptKeysRequest {
volumeKeys?: boolean;
backKey?: boolean;
/** Intercept media keys (next/previous/play-pause) for the hardware page turner. */
pageTurnerKeys?: boolean;
/** Forward every key press to JS so the settings UI can capture a binding. */
learnMode?: boolean;
}
export interface LockScreenRequest {
orientation: 'portrait' | 'landscape' | 'auto';
}
export interface GetSystemColorSchemeResponse {
colorScheme: 'light' | 'dark';
error?: string;
}
export interface GetSafeAreaInsetsResponse {
top: number;
right: number;
bottom: number;
left: number;
error?: string;
}
interface GetScreenBrightnessResponse {
brightness: number; // 0.0 to 1.0
error?: string;
}
interface SetScreenBrightnessRequest {
brightness: number; // 0.0 to 1.0
}
interface SetScreenBrightnessResponse {
success: boolean;
error?: string;
}
interface GetExternalSDCardPathResponse {
path: string | null;
error?: string;
}
interface SelectDirectoryResponse {
cancelled?: boolean;
uri?: string;
path?: string;
error?: string;
}
export interface GetStorefrontRegionCodeResponse {
regionCode?: string;
error?: string;
}
export interface RefreshEinkScreenResponse {
success: boolean;
error?: string;
}
export async function copyURIToPath(request: CopyURIRequest): Promise<CopyURIResponse> {
const result = await invoke<CopyURIResponse>('plugin:native-bridge|copy_uri_to_path', {
payload: request,
});
return result;
}
export async function saveImageToGallery(
request: SaveImageToGalleryRequest,
): Promise<SaveImageToGalleryResponse> {
return await invoke<SaveImageToGalleryResponse>('plugin:native-bridge|save_image_to_gallery', {
payload: request,
});
}
export async function invokeUseBackgroundAudio(request: UseBackgroundAudioRequest): Promise<void> {
await invoke('plugin:native-bridge|use_background_audio', {
payload: request,
});
}
export async function installPackage(
request: InstallPackageRequest,
): Promise<InstallPackageResponse> {
const result = await invoke<InstallPackageResponse>('plugin:native-bridge|install_package', {
payload: request,
});
return result;
}
export async function setSystemUIVisibility(
request: SetSystemUIVisibilityRequest,
): Promise<SetSystemUIVisibilityResponse> {
const result = await invoke<SetSystemUIVisibilityResponse>(
'plugin:native-bridge|set_system_ui_visibility',
{
payload: request,
},
);
return result;
}
export async function getStatusBarHeight(): Promise<GetStatusBarHeightResponse> {
const result = await invoke<GetStatusBarHeightResponse>(
'plugin:native-bridge|get_status_bar_height',
);
return result;
}
let cachedSysFontsResult: GetSystemFontsListResponse | null = null;
export async function getSysFontsList(): Promise<GetSystemFontsListResponse> {
if (cachedSysFontsResult) {
return cachedSysFontsResult;
}
const result = await invoke<GetSystemFontsListResponse>(
'plugin:native-bridge|get_sys_fonts_list',
);
cachedSysFontsResult = result;
return result;
}
export async function interceptKeys(request: InterceptKeysRequest): Promise<void> {
await invoke('plugin:native-bridge|intercept_keys', {
payload: request,
});
}
export async function lockScreenOrientation(request: LockScreenRequest): Promise<void> {
await invoke('plugin:native-bridge|lock_screen_orientation', {
payload: request,
});
}
export async function getSystemColorScheme(): Promise<GetSystemColorSchemeResponse> {
const result = await invoke<GetSystemColorSchemeResponse>(
'plugin:native-bridge|get_system_color_scheme',
);
return result;
}
export async function getSafeAreaInsets(): Promise<GetSafeAreaInsetsResponse> {
const result = await invoke<GetSafeAreaInsetsResponse>(
'plugin:native-bridge|get_safe_area_insets',
);
return result;
}
export async function getScreenBrightness(): Promise<GetScreenBrightnessResponse> {
const result = await invoke<GetScreenBrightnessResponse>(
'plugin:native-bridge|get_screen_brightness',
);
return result;
}
export async function setScreenBrightness(
request: SetScreenBrightnessRequest,
): Promise<SetScreenBrightnessResponse> {
const result = await invoke<SetScreenBrightnessResponse>(
'plugin:native-bridge|set_screen_brightness',
{
payload: request,
},
);
return result;
}
export async function getExternalSDCardPath(): Promise<GetExternalSDCardPathResponse> {
const result = await invoke<GetExternalSDCardPathResponse>(
'plugin:native-bridge|get_external_sdcard_path',
);
return result;
}
export async function selectDirectory(): Promise<SelectDirectoryResponse> {
const result = await invoke<SelectDirectoryResponse>('plugin:native-bridge|select_directory');
return result;
}
export async function getStorefrontRegionCode(): Promise<GetStorefrontRegionCodeResponse> {
const result = await invoke<GetStorefrontRegionCodeResponse>(
'plugin:native-bridge|get_storefront_region_code',
);
return result;
}
/**
* Trigger a deep e-ink full screen refresh (GC / GC16 waveform) to clear
* ghosting. Android-only; the native side probes several vendor mechanisms
* via reflection and returns `success: false` on devices with no e-ink
* controller. Other platforms reject with an unsupported-platform error.
*/
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
// launches via the OS keychain (macOS Keychain, Windows Credential
// Manager, Linux libsecret, iOS Keychain, Android EncryptedSharedPrefs).
export interface SetSyncPassphraseRequest {
passphrase: string;
}
export interface SyncPassphraseResponse {
success: boolean;
error?: string;
}
export interface GetSyncPassphraseResponse {
passphrase?: string;
error?: string;
}
export interface SyncKeychainAvailableResponse {
available: boolean;
error?: string;
}
export async function setSyncPassphrase(
request: SetSyncPassphraseRequest,
): Promise<SyncPassphraseResponse> {
return invoke<SyncPassphraseResponse>('plugin:native-bridge|set_sync_passphrase', {
payload: request,
});
}
export async function getSyncPassphrase(): Promise<GetSyncPassphraseResponse> {
return invoke<GetSyncPassphraseResponse>('plugin:native-bridge|get_sync_passphrase');
}
export async function clearSyncPassphrase(): Promise<SyncPassphraseResponse> {
return invoke<SyncPassphraseResponse>('plugin:native-bridge|clear_sync_passphrase');
}
export async function isSyncKeychainAvailable(): Promise<SyncKeychainAvailableResponse> {
return invoke<SyncKeychainAvailableResponse>('plugin:native-bridge|is_sync_keychain_available');
}
// ── Keyed secure key-value store ─────────────────────────────────────────
// Tauri-only. A generic, keyed secret store over the same OS keychain backends
// as the sync passphrase above, so secrets that aren't the single sync
// passphrase (the Google Drive OAuth token set, and any future cloud
// provider's refresh token) get the same XSS-free cross-launch persistence
// without each needing its own native command. Availability is the same probe
// as `is_sync_keychain_available`.
export interface SetSecureItemRequest {
key: string;
value: string;
}
export interface GetSecureItemRequest {
key: string;
}
export interface SecureItemResponse {
success: boolean;
error?: string;
}
export interface GetSecureItemResponse {
value?: string;
error?: string;
}
export async function setSecureItem(request: SetSecureItemRequest): Promise<SecureItemResponse> {
return invoke<SecureItemResponse>('plugin:native-bridge|set_secure_item', { payload: request });
}
export async function getSecureItem(request: GetSecureItemRequest): Promise<GetSecureItemResponse> {
return invoke<GetSecureItemResponse>('plugin:native-bridge|get_secure_item', {
payload: request,
});
}
export async function clearSecureItem(request: GetSecureItemRequest): Promise<SecureItemResponse> {
return invoke<SecureItemResponse>('plugin:native-bridge|clear_secure_item', { payload: request });
}
// ── Reading widget ────────────────────────────────────────────────────────
export interface ReadingWidgetBookPayload {
hash: string;
title: string;
author: string;
percent: number;
coverPath: string;
}
export interface ReadingWidgetTts {
active: boolean;
playing: boolean;
}
export interface UpdateReadingWidgetRequest {
books: ReadingWidgetBookPayload[];
sectionTitle: string;
emptyTitle: string;
tts?: ReadingWidgetTts;
}
export async function updateReadingWidget(request: UpdateReadingWidgetRequest): Promise<void> {
await invoke('plugin:native-bridge|update_reading_widget', { payload: request });
}
// ── Nightly updater (main-app commands, no native-bridge prefix) ─────────
// `verify_update_signature` gates the custom install flows (portable /
// AppImage / Android); `install_nightly_update` drives the Tauri updater for
// the platform keys it natively installs (macOS / Windows-NSIS).
export async function verifyUpdateSignature(
path: string,
signature: string,
pubKey: string,
): Promise<boolean> {
return invoke<boolean>('verify_update_signature', { path, signature, pubKey });
}
export interface NightlyProgress {
event: 'progress' | 'finished';
downloaded: number;
contentLength: number;
}
export async function installNightlyUpdate(
endpoint: string,
onProgress?: (p: NightlyProgress) => void,
): Promise<void> {
const channel = new Channel<NightlyProgress>();
if (onProgress) channel.onmessage = onProgress;
await invoke<void>('install_nightly_update', { endpoint, channel });
}