tauri_plugin_pulsify/
lib.rs1use 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
16pub 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#[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 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#[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 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 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 .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
200pub fn init<R: Runtime>() -> TauriPlugin<R> {
202 Builder::new().build()
203}