forked from akai/readest
7da41a65ad
Add a resizable home-screen widget on iOS and Android showing recent
in-progress books with cover, reading progress, and tap-to-open.
- One responsive widget: Android resizable 1x1 to 4x3 (one book per
column, up to 3); iOS Small/Medium/Large families. Covers are cropped,
rounded, with a percent badge and a progress bar (baked into the bitmap
on Android, SwiftUI overlays on iOS).
- TTS controls (previous, play-pause, next) appear in 2+ row sizes when
TTS is active, wired to the existing media session. Reading progress
stays live during background TTS via a fraction computed from the baked
offline locations.
- Publishes a snapshot plus downsized cover thumbnails to the iOS App
Group and Android SharedPreferences through a new update_reading_widget
native-bridge command; refresh is debounced and driven by library and
progress changes, TTS, and app backgrounding.
- Tapping a cover opens readest://book/{hash}, switching the reader in
place when one is already open.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
376 lines
14 KiB
Rust
376 lines
14 KiB
Rust
use serde::de::DeserializeOwned;
|
|
use std::collections::HashMap;
|
|
use tauri::{plugin::PluginApi, AppHandle, Runtime};
|
|
|
|
use crate::models::*;
|
|
|
|
pub fn init<R: Runtime, C: DeserializeOwned>(
|
|
app: &AppHandle<R>,
|
|
_api: PluginApi<R, C>,
|
|
) -> crate::Result<NativeBridge<R>> {
|
|
// keyring v4 split the library into `keyring-core` plus a
|
|
// per-platform credential-store crate. The default store is a
|
|
// process-wide global that must be installed before the first
|
|
// `Entry::new` call. `set_default_store` is idempotent — calling
|
|
// it again on plugin re-init just replaces the previous handle.
|
|
// We log and swallow errors so a misconfigured keychain doesn't
|
|
// block plugin init; downstream calls then fail with NoDefaultStore
|
|
// and the TS layer falls back to the ephemeral store.
|
|
install_default_keyring_store();
|
|
Ok(NativeBridge(app.clone()))
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn install_default_keyring_store() {
|
|
match apple_native_keyring_store::keychain::Store::new() {
|
|
Ok(store) => keyring_core::set_default_store(store),
|
|
Err(err) => eprintln!("[native-bridge] keychain store init failed: {err}"),
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn install_default_keyring_store() {
|
|
match windows_native_keyring_store::Store::new() {
|
|
Ok(store) => keyring_core::set_default_store(store),
|
|
Err(err) => eprintln!("[native-bridge] credential manager init failed: {err}"),
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn install_default_keyring_store() {
|
|
match dbus_secret_service_keyring_store::Store::new() {
|
|
Ok(store) => keyring_core::set_default_store(store),
|
|
Err(err) => eprintln!("[native-bridge] secret service init failed: {err}"),
|
|
}
|
|
}
|
|
|
|
/// Access to the native-bridge APIs.
|
|
pub struct NativeBridge<R: Runtime>(AppHandle<R>);
|
|
|
|
impl<R: Runtime> NativeBridge<R> {
|
|
pub fn auth_with_safari(&self, _payload: AuthRequest) -> crate::Result<AuthResponse> {
|
|
Err(crate::Error::UnsupportedPlatformError)
|
|
}
|
|
|
|
pub fn auth_with_custom_tab(&self, _payload: AuthRequest) -> crate::Result<AuthResponse> {
|
|
Err(crate::Error::UnsupportedPlatformError)
|
|
}
|
|
|
|
pub fn copy_uri_to_path(&self, _payload: CopyURIRequest) -> crate::Result<CopyURIResponse> {
|
|
Err(crate::Error::UnsupportedPlatformError)
|
|
}
|
|
|
|
pub fn save_image_to_gallery(
|
|
&self,
|
|
_payload: SaveImageToGalleryRequest,
|
|
) -> crate::Result<SaveImageToGalleryResponse> {
|
|
Err(crate::Error::UnsupportedPlatformError)
|
|
}
|
|
|
|
pub fn use_background_audio(&self, _payload: UseBackgroundAudioRequest) -> crate::Result<()> {
|
|
Err(crate::Error::UnsupportedPlatformError)
|
|
}
|
|
|
|
pub fn install_package(
|
|
&self,
|
|
_payload: InstallPackageRequest,
|
|
) -> crate::Result<InstallPackageResponse> {
|
|
Err(crate::Error::UnsupportedPlatformError)
|
|
}
|
|
|
|
pub fn set_system_ui_visibility(
|
|
&self,
|
|
_payload: SetSystemUIVisibilityRequest,
|
|
) -> crate::Result<SetSystemUIVisibilityResponse> {
|
|
Err(crate::Error::UnsupportedPlatformError)
|
|
}
|
|
|
|
pub fn get_status_bar_height(&self) -> crate::Result<GetStatusBarHeightResponse> {
|
|
Err(crate::Error::UnsupportedPlatformError)
|
|
}
|
|
|
|
pub fn get_sys_fonts_list(&self) -> crate::Result<GetSysFontsListResponse> {
|
|
let font_collection = font_enumeration::Collection::new().unwrap();
|
|
let mut fonts = HashMap::new();
|
|
for font in font_collection.all() {
|
|
if cfg!(target_os = "windows") {
|
|
// FIXME: temporarily disable font name with style for windows
|
|
fonts.insert(font.family_name.clone(), font.family_name.clone());
|
|
} else {
|
|
fonts.insert(font.font_name.clone(), font.family_name.clone());
|
|
}
|
|
}
|
|
Ok(GetSysFontsListResponse { fonts, error: None })
|
|
}
|
|
|
|
pub fn intercept_keys(&self, _payload: InterceptKeysRequest) -> crate::Result<()> {
|
|
Err(crate::Error::UnsupportedPlatformError)
|
|
}
|
|
|
|
pub fn lock_screen_orientation(
|
|
&self,
|
|
_payload: LockScreenOrientationRequest,
|
|
) -> crate::Result<()> {
|
|
Err(crate::Error::UnsupportedPlatformError)
|
|
}
|
|
|
|
pub fn iap_is_available(&self) -> crate::Result<IAPIsAvailableResponse> {
|
|
Err(crate::Error::UnsupportedPlatformError)
|
|
}
|
|
|
|
pub fn iap_initialize(
|
|
&self,
|
|
_payload: IAPInitializeRequest,
|
|
) -> crate::Result<IAPInitializeResponse> {
|
|
Err(crate::Error::UnsupportedPlatformError)
|
|
}
|
|
|
|
pub fn iap_fetch_products(
|
|
&self,
|
|
_payload: IAPFetchProductsRequest,
|
|
) -> crate::Result<IAPFetchProductsResponse> {
|
|
Err(crate::Error::UnsupportedPlatformError)
|
|
}
|
|
|
|
pub fn iap_purchase_product(
|
|
&self,
|
|
_payload: IAPPurchaseProductRequest,
|
|
) -> crate::Result<IAPPurchaseProductResponse> {
|
|
Err(crate::Error::UnsupportedPlatformError)
|
|
}
|
|
|
|
pub fn iap_restore_purchases(&self) -> crate::Result<IAPRestorePurchasesResponse> {
|
|
Err(crate::Error::UnsupportedPlatformError)
|
|
}
|
|
|
|
pub fn get_system_color_scheme(&self) -> crate::Result<GetSystemColorSchemeResponse> {
|
|
Err(crate::Error::UnsupportedPlatformError)
|
|
}
|
|
|
|
pub fn get_safe_area_insets(&self) -> crate::Result<GetSafeAreaInsetsResponse> {
|
|
Err(crate::Error::UnsupportedPlatformError)
|
|
}
|
|
|
|
pub fn get_screen_brightness(&self) -> crate::Result<GetScreenBrightnessResponse> {
|
|
Err(crate::Error::UnsupportedPlatformError)
|
|
}
|
|
|
|
pub fn set_screen_brightness(
|
|
&self,
|
|
_payload: SetScreenBrightnessRequest,
|
|
) -> crate::Result<SetScreenBrightnessResponse> {
|
|
Err(crate::Error::UnsupportedPlatformError)
|
|
}
|
|
|
|
pub fn get_external_sdcard_path(&self) -> crate::Result<GetExternalSDCardPathResponse> {
|
|
Err(crate::Error::UnsupportedPlatformError)
|
|
}
|
|
|
|
pub fn open_external_url(
|
|
&self,
|
|
_payload: OpenExternalUrlRequest,
|
|
) -> crate::Result<OpenExternalUrlResponse> {
|
|
Err(crate::Error::UnsupportedPlatformError)
|
|
}
|
|
|
|
/// Desktop has no mobile-style "system dictionary intent" surface;
|
|
/// macOS's HUD is invoked through a separate top-level Tauri
|
|
/// command (`show_lookup_popover` in `src/macos/system_dictionary.rs`),
|
|
/// and Linux/Windows have no native target. Return
|
|
/// UnsupportedPlatformError here so the TS layer doesn't
|
|
/// accidentally dispatch through the mobile plugin on desktop.
|
|
pub fn show_lookup_popover(
|
|
&self,
|
|
_payload: ShowLookupPopoverRequest,
|
|
) -> crate::Result<ShowLookupPopoverResponse> {
|
|
Err(crate::Error::UnsupportedPlatformError)
|
|
}
|
|
|
|
pub fn select_directory(&self) -> crate::Result<SelectDirectoryResponse> {
|
|
Err(crate::Error::UnsupportedPlatformError)
|
|
}
|
|
|
|
pub fn get_storefront_region_code(&self) -> crate::Result<GetStorefrontRegionCodeResponse> {
|
|
Err(crate::Error::UnsupportedPlatformError)
|
|
}
|
|
|
|
pub fn request_manage_storage_permission(
|
|
&self,
|
|
) -> crate::Result<RequestManageStoragePermissionResponse> {
|
|
Err(crate::Error::UnsupportedPlatformError)
|
|
}
|
|
|
|
// ── Sync passphrase keychain ────────────────────────────────────────
|
|
//
|
|
// Uses `keyring-core` v1 with a platform-specific credential store
|
|
// installed in `init()` above:
|
|
// * macOS → Security framework Keychain (apple-native-keyring-store)
|
|
// * Windows → Credential Manager (windows-native-keyring-store)
|
|
// * Linux → Secret Service (dbus-secret-service-keyring-store)
|
|
//
|
|
// `service` and `user` form the keychain item identity. Service is
|
|
// the bundle id; user is a stable string ("default") so multiple
|
|
// Readest installs on the same machine could coexist with distinct
|
|
// user values if ever needed.
|
|
|
|
pub fn set_sync_passphrase(
|
|
&self,
|
|
payload: SetSyncPassphraseRequest,
|
|
) -> crate::Result<SyncPassphraseResponse> {
|
|
match keyring_entry().and_then(|e| e.set_password(&payload.passphrase)) {
|
|
Ok(()) => Ok(SyncPassphraseResponse {
|
|
success: true,
|
|
error: None,
|
|
}),
|
|
Err(err) => Ok(SyncPassphraseResponse {
|
|
success: false,
|
|
error: Some(err.to_string()),
|
|
}),
|
|
}
|
|
}
|
|
|
|
pub fn get_sync_passphrase(&self) -> crate::Result<GetSyncPassphraseResponse> {
|
|
match keyring_entry().and_then(|e| e.get_password()) {
|
|
Ok(passphrase) => Ok(GetSyncPassphraseResponse {
|
|
passphrase: Some(passphrase),
|
|
error: None,
|
|
}),
|
|
Err(keyring_core::Error::NoEntry) => Ok(GetSyncPassphraseResponse {
|
|
passphrase: None,
|
|
error: None,
|
|
}),
|
|
Err(err) => Ok(GetSyncPassphraseResponse {
|
|
passphrase: None,
|
|
error: Some(err.to_string()),
|
|
}),
|
|
}
|
|
}
|
|
|
|
pub fn clear_sync_passphrase(&self) -> crate::Result<SyncPassphraseResponse> {
|
|
match keyring_entry().and_then(|e| e.delete_credential()) {
|
|
Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(SyncPassphraseResponse {
|
|
success: true,
|
|
error: None,
|
|
}),
|
|
Err(err) => Ok(SyncPassphraseResponse {
|
|
success: false,
|
|
error: Some(err.to_string()),
|
|
}),
|
|
}
|
|
}
|
|
|
|
pub fn is_sync_keychain_available(&self) -> crate::Result<SyncKeychainAvailableResponse> {
|
|
// Best-effort probe: open an entry handle. Surface the error
|
|
// string instead of throwing so the TS layer can fall back
|
|
// to the ephemeral store gracefully.
|
|
match keyring_entry() {
|
|
Ok(_) => Ok(SyncKeychainAvailableResponse {
|
|
available: true,
|
|
error: None,
|
|
}),
|
|
Err(err) => Ok(SyncKeychainAvailableResponse {
|
|
available: false,
|
|
error: Some(err.to_string()),
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Desktop has its own URL-clip path (`src/clip_url.rs` spawns a
|
|
/// hidden `WebviewWindow` and listens on `127.0.0.1`). The plugin
|
|
/// branch is mobile-only — if anyone calls into it from desktop,
|
|
/// surface that mistake instead of silently returning empty HTML.
|
|
pub fn clip_url(&self, _payload: ClipUrlRequest) -> crate::Result<ClipUrlResponse> {
|
|
Err(crate::Error::NativeBridgeError(
|
|
"clip_url plugin is mobile-only; desktop callers should invoke the top-level command"
|
|
.to_string(),
|
|
))
|
|
}
|
|
|
|
// ── Keyed secure key-value store ────────────────────────────────────
|
|
//
|
|
// Same keychain backends + fail-loud/fail-soft contract as the sync
|
|
// passphrase above, but each item gets its own keychain entry keyed by
|
|
// `key` (the item's `user`/account), so many independent secrets (the
|
|
// Drive token set, future provider tokens) coexist under one service
|
|
// without colliding with the passphrase entry (user "default").
|
|
|
|
pub fn set_secure_item(
|
|
&self,
|
|
payload: SetSecureItemRequest,
|
|
) -> crate::Result<SecureItemResponse> {
|
|
match keyring_entry_for(&payload.key).and_then(|e| e.set_password(&payload.value)) {
|
|
Ok(()) => Ok(SecureItemResponse {
|
|
success: true,
|
|
error: None,
|
|
}),
|
|
Err(err) => Ok(SecureItemResponse {
|
|
success: false,
|
|
error: Some(err.to_string()),
|
|
}),
|
|
}
|
|
}
|
|
|
|
pub fn get_secure_item(
|
|
&self,
|
|
payload: GetSecureItemRequest,
|
|
) -> crate::Result<GetSecureItemResponse> {
|
|
match keyring_entry_for(&payload.key).and_then(|e| e.get_password()) {
|
|
Ok(value) => Ok(GetSecureItemResponse {
|
|
value: Some(value),
|
|
error: None,
|
|
}),
|
|
Err(keyring_core::Error::NoEntry) => Ok(GetSecureItemResponse {
|
|
value: None,
|
|
error: None,
|
|
}),
|
|
Err(err) => Ok(GetSecureItemResponse {
|
|
value: None,
|
|
error: Some(err.to_string()),
|
|
}),
|
|
}
|
|
}
|
|
|
|
pub fn clear_secure_item(
|
|
&self,
|
|
payload: GetSecureItemRequest,
|
|
) -> crate::Result<SecureItemResponse> {
|
|
match keyring_entry_for(&payload.key).and_then(|e| e.delete_credential()) {
|
|
Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(SecureItemResponse {
|
|
success: true,
|
|
error: None,
|
|
}),
|
|
Err(err) => Ok(SecureItemResponse {
|
|
success: false,
|
|
error: Some(err.to_string()),
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// E-ink panels exist only on the mobile (Android) side. Desktop has no
|
|
/// e-ink controller, so this is unsupported here.
|
|
pub fn refresh_eink_screen(&self) -> crate::Result<RefreshEinkScreenResponse> {
|
|
Err(crate::Error::UnsupportedPlatformError)
|
|
}
|
|
|
|
pub fn update_reading_widget(
|
|
&self,
|
|
_payload: UpdateReadingWidgetRequest,
|
|
) -> crate::Result<()> {
|
|
// Home-screen widgets are mobile-only; desktop is a no-op.
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
const KEYRING_SERVICE: &str = "Readest Safe Storage";
|
|
const KEYRING_USER: &str = "default";
|
|
|
|
fn keyring_entry() -> std::result::Result<keyring_core::Entry, keyring_core::Error> {
|
|
keyring_core::Entry::new(KEYRING_SERVICE, KEYRING_USER)
|
|
}
|
|
|
|
/// Keychain entry for a keyed secure item — same service as the passphrase,
|
|
/// with the caller's `key` as the per-item account so each secret is distinct.
|
|
fn keyring_entry_for(key: &str) -> std::result::Result<keyring_core::Entry, keyring_core::Error> {
|
|
keyring_core::Entry::new(KEYRING_SERVICE, key)
|
|
}
|