Skip to content

Commit 9ebbfb2

Browse files
feat(http): persist cookies on disk (#1978)
* enhance(http): persist cookies on disk closes tauri-apps/tauri#11518 * clippy * inline reqwest_cookie_store to fix clippy * clippy * Update .changes/persist-cookies.md * Update plugins/http/src/reqwest_cookie_store.rs * update example * fallback to empty store if failed to load * fix example * persist cookies immediately * clone * lint * .cookies filename * prevent race condition --------- Co-authored-by: Lucas Nogueira <[email protected]>
1 parent 4bbcdbd commit 9ebbfb2

File tree

7 files changed

+217
-4
lines changed

7 files changed

+217
-4
lines changed

.changes/persist-cookies.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"http": "patch"
3+
"http-js": "patch"
4+
---
5+
6+
Persist cookies to disk and load it on next app start.
7+

Cargo.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/api/src-tauri/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ tauri-build = { workspace = true, features = ["codegen", "isolation"] }
1818
serde_json = { workspace = true }
1919
serde = { workspace = true }
2020
tiny_http = "0.12"
21+
time = "0.3"
2122
log = { workspace = true }
2223
tauri-plugin-log = { path = "../../../plugins/log", version = "2.3.1" }
2324
tauri-plugin-fs = { path = "../../../plugins/fs", version = "2.2.0", features = [
@@ -27,6 +28,7 @@ tauri-plugin-clipboard-manager = { path = "../../../plugins/clipboard-manager",
2728
tauri-plugin-dialog = { path = "../../../plugins/dialog", version = "2.2.0" }
2829
tauri-plugin-http = { path = "../../../plugins/http", features = [
2930
"multipart",
31+
"cookies",
3032
], version = "2.4.2" }
3133
tauri-plugin-notification = { path = "../../../plugins/notification", version = "2.2.2", features = [
3234
"windows7-compat",

examples/api/src-tauri/src/lib.rs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,28 @@ pub fn run() {
102102
if let Ok(mut request) = server.recv() {
103103
let mut body = Vec::new();
104104
let _ = request.as_reader().read_to_end(&mut body);
105+
let mut headers = request.headers().to_vec();
106+
107+
if !headers.iter().any(|header| header.field == tiny_http::HeaderField::from_bytes(b"Cookie").unwrap()) {
108+
let expires = time::OffsetDateTime::now_utc() + time::Duration::days(1);
109+
// RFC 1123 format
110+
let format = time::macros::format_description!(
111+
"[weekday repr:short], [day] [month repr:short] [year] [hour]:[minute]:[second] GMT"
112+
);
113+
let expires_str = expires.format(format).unwrap();
114+
headers.push(
115+
tiny_http::Header::from_bytes(
116+
&b"Set-Cookie"[..],
117+
format!("session-token=test-value; Secure; Path=/; Expires={expires_str}")
118+
.as_bytes(),
119+
)
120+
.unwrap(),
121+
);
122+
}
123+
105124
let response = tiny_http::Response::new(
106125
tiny_http::StatusCode(200),
107-
request.headers().to_vec(),
126+
headers,
108127
std::io::Cursor::new(body),
109128
request.body_length(),
110129
None,

plugins/http/Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ http = "1"
4141
reqwest = { version = "0.12", default-features = false }
4242
url = { workspace = true }
4343
data-url = "0.3"
44+
cookie_store = { version = "0.21.1", optional = true, features = ["serde"] }
45+
bytes = { version = "1.9", optional = true }
4446
tracing = { workspace = true, optional = true }
4547

4648
[features]
@@ -62,7 +64,7 @@ rustls-tls-manual-roots = ["reqwest/rustls-tls-manual-roots"]
6264
rustls-tls-webpki-roots = ["reqwest/rustls-tls-webpki-roots"]
6365
rustls-tls-native-roots = ["reqwest/rustls-tls-native-roots"]
6466
blocking = ["reqwest/blocking"]
65-
cookies = ["reqwest/cookies"]
67+
cookies = ["reqwest/cookies", "dep:cookie_store", "dep:bytes"]
6668
gzip = ["reqwest/gzip"]
6769
brotli = ["reqwest/brotli"]
6870
deflate = ["reqwest/deflate"]

plugins/http/src/lib.rs

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,25 +14,72 @@ pub use error::{Error, Result};
1414

1515
mod commands;
1616
mod error;
17+
#[cfg(feature = "cookies")]
18+
mod reqwest_cookie_store;
1719
mod scope;
1820

21+
#[cfg(feature = "cookies")]
22+
const COOKIES_FILENAME: &str = ".cookies";
23+
1924
pub(crate) struct Http {
2025
#[cfg(feature = "cookies")]
21-
cookies_jar: std::sync::Arc<reqwest::cookie::Jar>,
26+
cookies_jar: std::sync::Arc<crate::reqwest_cookie_store::CookieStoreMutex>,
2227
}
2328

2429
pub fn init<R: Runtime>() -> TauriPlugin<R> {
2530
Builder::<R>::new("http")
2631
.setup(|app, _| {
32+
#[cfg(feature = "cookies")]
33+
let cookies_jar = {
34+
use crate::reqwest_cookie_store::*;
35+
use std::fs::File;
36+
use std::io::BufReader;
37+
38+
let cache_dir = app.path().app_cache_dir()?;
39+
std::fs::create_dir_all(&cache_dir)?;
40+
41+
let path = cache_dir.join(COOKIES_FILENAME);
42+
let file = File::options()
43+
.create(true)
44+
.append(true)
45+
.read(true)
46+
.open(&path)?;
47+
48+
let reader = BufReader::new(file);
49+
CookieStoreMutex::load(path.clone(), reader).unwrap_or_else(|_e| {
50+
#[cfg(feature = "tracing")]
51+
tracing::warn!(
52+
"failed to load cookie store: {_e}, falling back to empty store"
53+
);
54+
CookieStoreMutex::new(path, Default::default())
55+
})
56+
};
57+
2758
let state = Http {
2859
#[cfg(feature = "cookies")]
29-
cookies_jar: std::sync::Arc::new(reqwest::cookie::Jar::default()),
60+
cookies_jar: std::sync::Arc::new(cookies_jar),
3061
};
3162

3263
app.manage(state);
3364

3465
Ok(())
3566
})
67+
.on_event(|app, event| {
68+
#[cfg(feature = "cookies")]
69+
if let tauri::RunEvent::Exit = event {
70+
let state = app.state::<Http>();
71+
72+
match state.cookies_jar.request_save() {
73+
Ok(rx) => {
74+
let _ = rx.recv();
75+
}
76+
Err(_e) => {
77+
#[cfg(feature = "tracing")]
78+
tracing::error!("failed to save cookie jar: {_e}");
79+
}
80+
}
81+
}
82+
})
3683
.invoke_handler(tauri::generate_handler![
3784
commands::fetch,
3885
commands::fetch_cancel,
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
2+
// SPDX-License-Identifier: Apache-2.0
3+
// SPDX-License-Identifier: MIT
4+
5+
// taken from https://github.com/pfernie/reqwest_cookie_store/blob/2ec4afabcd55e24d3afe3f0626ee6dc97bed938d/src/lib.rs
6+
7+
use std::{
8+
path::PathBuf,
9+
sync::{mpsc::Receiver, Mutex},
10+
};
11+
12+
use cookie_store::{CookieStore, RawCookie, RawCookieParseError};
13+
use reqwest::header::HeaderValue;
14+
15+
fn set_cookies(
16+
cookie_store: &mut CookieStore,
17+
cookie_headers: &mut dyn Iterator<Item = &HeaderValue>,
18+
url: &url::Url,
19+
) {
20+
let cookies = cookie_headers.filter_map(|val| {
21+
std::str::from_utf8(val.as_bytes())
22+
.map_err(RawCookieParseError::from)
23+
.and_then(RawCookie::parse)
24+
.map(|c| c.into_owned())
25+
.ok()
26+
});
27+
cookie_store.store_response_cookies(cookies, url);
28+
}
29+
30+
fn cookies(cookie_store: &CookieStore, url: &url::Url) -> Option<HeaderValue> {
31+
let s = cookie_store
32+
.get_request_values(url)
33+
.map(|(name, value)| format!("{}={}", name, value))
34+
.collect::<Vec<_>>()
35+
.join("; ");
36+
37+
if s.is_empty() {
38+
return None;
39+
}
40+
41+
HeaderValue::from_maybe_shared(bytes::Bytes::from(s)).ok()
42+
}
43+
44+
/// A [`cookie_store::CookieStore`] wrapped internally by a [`std::sync::Mutex`], suitable for use in
45+
/// async/concurrent contexts.
46+
#[derive(Debug)]
47+
pub struct CookieStoreMutex {
48+
pub path: PathBuf,
49+
store: Mutex<CookieStore>,
50+
save_task: Mutex<Option<CancellableTask>>,
51+
}
52+
53+
impl CookieStoreMutex {
54+
/// Create a new [`CookieStoreMutex`] from an existing [`cookie_store::CookieStore`].
55+
pub fn new(path: PathBuf, cookie_store: CookieStore) -> CookieStoreMutex {
56+
CookieStoreMutex {
57+
path,
58+
store: Mutex::new(cookie_store),
59+
save_task: Default::default(),
60+
}
61+
}
62+
63+
pub fn load<R: std::io::BufRead>(
64+
path: PathBuf,
65+
reader: R,
66+
) -> cookie_store::Result<CookieStoreMutex> {
67+
cookie_store::serde::load(reader, |c| serde_json::from_str(c))
68+
.map(|store| CookieStoreMutex::new(path, store))
69+
}
70+
71+
fn cookies_to_str(&self) -> Result<String, serde_json::Error> {
72+
let mut cookies = Vec::new();
73+
for cookie in self
74+
.store
75+
.lock()
76+
.expect("poisoned cookie jar mutex")
77+
.iter_unexpired()
78+
{
79+
if cookie.is_persistent() {
80+
cookies.push(cookie.clone());
81+
}
82+
}
83+
serde_json::to_string(&cookies)
84+
}
85+
86+
pub fn request_save(&self) -> cookie_store::Result<Receiver<()>> {
87+
let cookie_str = self.cookies_to_str()?;
88+
let path = self.path.clone();
89+
let (tx, rx) = std::sync::mpsc::channel();
90+
let task = tauri::async_runtime::spawn(async move {
91+
match tokio::fs::write(&path, &cookie_str).await {
92+
Ok(()) => {
93+
let _ = tx.send(());
94+
}
95+
Err(_e) => {
96+
#[cfg(feature = "tracing")]
97+
tracing::error!("failed to save cookie jar: {_e}");
98+
}
99+
}
100+
});
101+
self.save_task
102+
.lock()
103+
.unwrap()
104+
.replace(CancellableTask(task));
105+
Ok(rx)
106+
}
107+
}
108+
109+
impl reqwest::cookie::CookieStore for CookieStoreMutex {
110+
fn set_cookies(&self, cookie_headers: &mut dyn Iterator<Item = &HeaderValue>, url: &url::Url) {
111+
set_cookies(&mut self.store.lock().unwrap(), cookie_headers, url);
112+
113+
// try to persist cookies immediately asynchronously
114+
if let Err(_e) = self.request_save() {
115+
#[cfg(feature = "tracing")]
116+
tracing::error!("failed to save cookie jar: {_e}");
117+
}
118+
}
119+
120+
fn cookies(&self, url: &url::Url) -> Option<HeaderValue> {
121+
let store = self.store.lock().unwrap();
122+
cookies(&store, url)
123+
}
124+
}
125+
126+
#[derive(Debug)]
127+
struct CancellableTask(tauri::async_runtime::JoinHandle<()>);
128+
129+
impl Drop for CancellableTask {
130+
fn drop(&mut self) {
131+
self.0.abort();
132+
}
133+
}

0 commit comments

Comments
 (0)