Skip to main content

tauri_plugin_pulsify/
lib.rs

1//! Tauri 2 plugin persisting Pulsify documents in a local SQLite database.
2//!
3//! Register it with `.plugin(tauri_plugin_pulsify::init())` and connect documents
4//! with `createTauriConnector()` from `@pulsify/tauri`.
5use pulsify_sqlite::{Hub, SqliteStore};
6use serde::Serialize;
7use std::path::PathBuf;
8use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
9use tauri::ipc::{Channel, InvokeBody, Request, Response};
10use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
11use tauri::webview::PageLoadEvent;
12use tauri::{Manager, RunEvent, Runtime, State, Webview, WindowEvent};
13
14pub use pulsify_sqlite;
15
16/// Header carrying the session ID of a raw `send` invocation.
17pub const SESSION_HEADER: &str = "pulsify-session";
18
19pub struct ChannelSink(Channel<Response>);
20impl pulsify_sqlite::Sink for ChannelSink {
21    fn deliver(&self, _session: u64, frame: Vec<u8>) -> bool {
22        self.0.send(Response::new(frame)).is_ok()
23    }
24}
25
26/// Managed state: the single SQLite writer and every open session.
27///
28/// The database opens on first use, so an unavailable file fails `open` with
29/// `CONNECTION_FAILED` (retried by the webview) instead of the app's startup.
30#[derive(Clone)]
31pub struct Pulsify(Arc<State_>);
32struct State_ {
33    path: PathBuf,
34    hub: Mutex<Option<Hub<ChannelSink>>>,
35}
36impl Pulsify {
37    fn new(path: PathBuf) -> Self {
38        Self(Arc::new(State_ {
39            path,
40            hub: Mutex::new(None),
41        }))
42    }
43    /// Runs `work` with exclusive access to the store, e.g. for backups or maintenance.
44    pub fn with_store<T>(
45        &self,
46        work: impl FnOnce(&mut SqliteStore) -> T,
47    ) -> Result<T, CommandError> {
48        self.with_hub(|hub| Ok(work(hub.store())))
49    }
50    fn lock(&self) -> MutexGuard<'_, Option<Hub<ChannelSink>>> {
51        self.0.hub.lock().unwrap_or_else(PoisonError::into_inner)
52    }
53    fn with_hub<T>(
54        &self,
55        work: impl FnOnce(&mut Hub<ChannelSink>) -> pulsify_sqlite::Result<T>,
56    ) -> Result<T, CommandError> {
57        let mut hub = self.lock();
58        if hub.is_none() {
59            let unavailable = |error: &dyn std::fmt::Display| {
60                CommandError::new("CONNECTION_FAILED", error.to_string())
61            };
62            if let Some(parent) = self.0.path.parent() {
63                std::fs::create_dir_all(parent).map_err(|error| unavailable(&error))?;
64            }
65            let store = SqliteStore::open(&self.0.path).map_err(|error| unavailable(&error))?;
66            *hub = Some(Hub::new(store));
67        }
68        work(hub.as_mut().expect("opened above")).map_err(CommandError::from)
69    }
70    async fn run<T: Send + 'static>(
71        &self,
72        work: impl FnOnce(&mut Hub<ChannelSink>) -> pulsify_sqlite::Result<T> + Send + 'static,
73    ) -> Result<T, CommandError> {
74        let state = self.clone();
75        tauri::async_runtime::spawn_blocking(move || state.with_hub(work))
76            .await
77            .map_err(|error| CommandError::new("CONNECTION_FAILED", error.to_string()))?
78    }
79    fn close_owner(&self, owner: &str) {
80        if let Some(hub) = self.lock().as_mut() {
81            hub.close_owner(owner);
82        }
83    }
84}
85
86/// Serialized to the webview as `{ code, message }`, with a Pulsify error code.
87#[derive(Debug, Serialize)]
88pub struct CommandError {
89    code: String,
90    message: String,
91}
92impl CommandError {
93    fn new(code: &str, message: impl Into<String>) -> Self {
94        Self {
95            code: code.to_owned(),
96            message: message.into(),
97        }
98    }
99}
100impl From<pulsify_sqlite::Error> for CommandError {
101    fn from(error: pulsify_sqlite::Error) -> Self {
102        Self::new(error.code, error.message)
103    }
104}
105
106#[tauri::command]
107async fn open<R: Runtime>(
108    webview: Webview<R>,
109    state: State<'_, Pulsify>,
110    doc_id: String,
111    channel: Channel<Response>,
112) -> Result<u64, CommandError> {
113    let owner = webview.label().to_owned();
114    state
115        .run(move |hub| hub.open(&doc_id, &owner, ChannelSink(channel)))
116        .await
117}
118
119#[tauri::command]
120async fn send(state: State<'_, Pulsify>, request: Request<'_>) -> Result<(), CommandError> {
121    let session = request
122        .headers()
123        .get(SESSION_HEADER)
124        .and_then(|value| value.to_str().ok()?.parse::<u64>().ok())
125        .ok_or_else(|| CommandError::new("INVALID_MESSAGE", "Missing session header"))?;
126    let bytes = match request.body() {
127        InvokeBody::Raw(bytes) => Some(bytes.clone()),
128        // IPC without a custom protocol serializes bytes as a JSON array.
129        InvokeBody::Json(serde_json::Value::Array(values)) => values
130            .iter()
131            .map(|value| u8::try_from(value.as_u64()?).ok())
132            .collect(),
133        InvokeBody::Json(_) => None,
134    }
135    .ok_or_else(|| CommandError::new("INVALID_MESSAGE", "Expected a binary frame"))?;
136    state.run(move |hub| hub.send(session, &bytes)).await
137}
138
139#[tauri::command]
140async fn close(state: State<'_, Pulsify>, session_id: u64) -> Result<(), CommandError> {
141    state
142        .run(move |hub| {
143            hub.close(session_id);
144            Ok(())
145        })
146        .await
147}
148
149#[derive(Debug, Default)]
150pub struct Builder {
151    path: Option<PathBuf>,
152}
153impl Builder {
154    pub fn new() -> Self {
155        Self::default()
156    }
157    /// The database file. Relative paths resolve against the app data directory;
158    /// defaults to `pulsify.sqlite` there.
159    pub fn path(mut self, path: impl Into<PathBuf>) -> Self {
160        self.path = Some(path.into());
161        self
162    }
163    pub fn build<R: Runtime>(self) -> TauriPlugin<R> {
164        PluginBuilder::new("pulsify")
165            .invoke_handler(tauri::generate_handler![open, send, close])
166            .setup(move |app, _api| {
167                let path = self.path.unwrap_or_else(|| "pulsify.sqlite".into());
168                let path = if path.is_absolute() {
169                    path
170                } else {
171                    app.path().app_data_dir()?.join(path)
172                };
173                app.manage(Pulsify::new(path));
174                Ok(())
175            })
176            // A reloaded or closed webview loses its channels; drop its sessions.
177            .on_page_load(|webview, payload| {
178                if payload.event() == PageLoadEvent::Started {
179                    if let Some(state) = webview.try_state::<Pulsify>() {
180                        state.close_owner(webview.label());
181                    }
182                }
183            })
184            .on_event(|app, event| {
185                if let RunEvent::WindowEvent {
186                    label,
187                    event: WindowEvent::Destroyed,
188                    ..
189                } = event
190                {
191                    if let Some(state) = app.try_state::<Pulsify>() {
192                        state.close_owner(label);
193                    }
194                }
195            })
196            .build()
197    }
198}
199
200/// The plugin with its default database, `pulsify.sqlite` in the app data directory.
201pub fn init<R: Runtime>() -> TauriPlugin<R> {
202    Builder::new().build()
203}