1use crate::backend::qobject::Sioul;
13use crate::backend::{QtThread, load_config, say, tr};
14use cxx_qt::Threading;
15use cxx_qt_lib::QString;
16use sioul_core::compose::Draft;
17use sioul_core::config::{Account, Config};
18use sioul_core::handed::{self, ArrivedFile, Handed, Incoming};
19use std::collections::BTreeMap;
20use std::pin::Pin;
21use std::sync::Mutex;
22
23static WINDOW: Mutex<Option<QtThread>> = Mutex::new(None);
25
26static COMING: Mutex<BTreeMap<String, (u32, Vec<ArrivedFile>)>> = Mutex::new(BTreeMap::new());
29
30pub(crate) fn started(mut sioul: Pin<&mut Sioul>) {
33 if let Ok(mut window) = WINDOW.lock() {
34 *window = Some(sioul.qt_thread());
35 }
36 take(sioul.as_mut());
37 std::thread::spawn(|| {
38 let held: Vec<std::path::PathBuf> = Draft::all().into_iter().flat_map(|d| d.attachments).collect();
40 Incoming::here().tidy(&held, std::time::SystemTime::now());
41 publish(true);
42 });
43}
44
45pub(crate) fn poke() {
48 let window = WINDOW.lock().ok().and_then(|w| w.clone());
49 if let Some(qt) = window {
50 let _ = qt.queue(take);
51 }
52}
53
54pub(crate) fn coming(id: &str) -> bool {
56 COMING.lock().is_ok_and(|coming| coming.contains_key(id))
57}
58
59pub(crate) fn attaching(id: &str) -> String {
61 let count = COMING.lock().ok().and_then(|coming| coming.get(id).map(|(files, _)| *files)).unwrap_or(0);
62 if count == 0 { String::new() } else { say("compose-attaching", &[("count", count.to_string())]) }
63}
64
65pub(crate) fn take(mut sioul: Pin<&mut Sioul>) {
71 activate_with_token();
72 let incoming = Incoming::here();
73 let me = std::process::id();
74 let mut changed = false;
75 for mark in incoming.marks() {
78 let arrived = match incoming.arrived(&mark.id) {
79 Some(arrived) => arrived,
80 None if mark.copier != me => incoming.salvage(&mark.id, mark.files),
81 None => continue,
82 };
83 let refused = COMING.lock().ok().and_then(|mut coming| coming.remove(&mark.draft)).map(|(_, refused)| refused).unwrap_or_default();
84 match Draft::by_id(&mark.draft) {
85 Some(mut draft) => {
86 let lost = handed::attach(&mut draft, &incoming.files_of(&mark.id), &arrived);
87 let mut attached = arrived.files.len().saturating_sub(lost.len());
88 let mut missing = refused;
89 missing.extend(lost);
90 if let Err(e) = draft.save() {
91 attached = 0;
92 missing.push(ArrivedFile { error: e, ..ArrivedFile::default() });
93 }
94 let (line, problem) = arrival_line(attached, &missing);
95 sioul.as_mut().draft_files_arrived(QString::from(&mark.draft), QString::from(&line), problem);
96 incoming.done(&mark.id);
97 }
98 None => incoming.drop_request(&mark.id),
100 }
101 changed = true;
102 }
103 for request in incoming.waiting() {
104 changed = true;
105 made(sioul.as_mut(), &incoming, &request, me);
106 }
107 if changed {
108 crate::mail::show_mail(&sioul.qt_thread(), &sioul.shared());
109 }
110}
111
112fn made(mut sioul: Pin<&mut Sioul>, incoming: &Incoming, request: &Handed, me: u32) {
114 let config = load_config();
115 let Some(mut draft) = handed::draft(request, &config) else {
116 sioul.as_mut().set_status(QString::from(&tr().text("handed-no-account", None)));
118 incoming.drop_request(&request.id);
119 return;
120 };
121 let refused: Vec<ArrivedFile> = request.refused.iter().map(|name| ArrivedFile { name: name.clone(), ..ArrivedFile::default() }).collect();
122 let arrived = if request.files == 0 {
124 Some(Default::default())
125 } else {
126 incoming.arrived(&request.id).or_else(|| (request.copier != me).then(|| incoming.salvage(&request.id, request.files)))
127 };
128 let mut missing = refused.clone();
129 let mut attached = 0;
130 if let Some(arrived) = &arrived {
131 let lost = handed::attach(&mut draft, &incoming.files_of(&request.id), arrived);
132 attached = arrived.files.len().saturating_sub(lost.len());
133 missing.extend(lost);
134 }
135 if let Err(e) = draft.save() {
136 sioul.as_mut().set_status(QString::from(&e));
137 return;
139 }
140 match arrived {
141 Some(_) => incoming.done(&request.id),
142 None => {
143 if let Ok(mut coming) = COMING.lock() {
144 coming.insert(draft.id.clone(), (request.files, refused));
145 }
146 if let Err(e) = incoming.mark(&request.id, &draft.id, request.copier, request.files) {
147 eprintln!("Sioul: {e}");
148 }
149 }
150 }
151 sioul.as_mut().compose_requested(QString::from(&draft.id));
152 if !coming(&draft.id) && (!missing.is_empty() || attached > 0) {
154 let (line, problem) = arrival_line(attached, &missing);
155 sioul.as_mut().draft_files_arrived(QString::from(&draft.id), QString::from(&line), problem);
156 }
157}
158
159fn arrival_line(attached: usize, missing: &[ArrivedFile]) -> (String, bool) {
162 if missing.is_empty() {
163 return (say("handed-attached", &[("count", attached.to_string())]), false);
164 }
165 let named: Vec<String> = missing
166 .iter()
167 .map(|file| {
168 let name = if file.name.is_empty() { tr().text("handed-a-file", None) } else { file.name.clone() };
169 let why = if file.stopped {
170 tr().text("handed-stopped", None)
171 } else if file.error.is_empty() {
172 tr().text("handed-refused", None)
173 } else {
174 file.error.clone()
175 };
176 format!("{name} ({why})")
177 })
178 .collect();
179 (say("handed-missing", &[("files", named.join(", ")), ("count", missing.len().to_string())]), true)
180}
181
182#[cfg_attr(not(target_os = "android"), allow(dead_code))]
186#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
187struct Target {
188 id: String,
190 short: String,
192 long: String,
194}
195
196#[cfg_attr(not(target_os = "android"), allow(dead_code))]
200fn targets(config: &Config, sent: &BTreeMap<String, usize>) -> Vec<Target> {
201 let mut accounts: Vec<(usize, &Account)> = config.accounts.iter().filter(|a| a.syncs()).enumerate().collect();
202 accounts.sort_by_key(|(order, account)| (std::cmp::Reverse(sent.get(&account.id).copied().unwrap_or(0)), account.priority, *order));
203 accounts
204 .into_iter()
205 .map(|(_, account)| {
206 let address = account.address.clone().filter(|a| !a.trim().is_empty()).unwrap_or_else(|| account.id.clone());
207 Target { id: account.id.clone(), long: say("share-write-from", &[("address", address.clone())]), short: address }
208 })
209 .collect()
210}
211
212#[cfg(target_os = "android")]
215fn sent(config: &Config) -> BTreeMap<String, usize> {
216 let count = |dir: std::path::PathBuf| std::fs::read_dir(dir).map(|entries| entries.count()).unwrap_or(0);
217 config
218 .accounts
219 .iter()
220 .filter(|a| a.syncs())
221 .map(|account| {
222 let root = account.maildir_path();
223 let folders = sioul_sync::mailbox::folders(&account.id);
224 let total: usize = folders.iter().filter(|f| f.role == sioul_core::folders::Role::Sent).map(|f| count(root.join(&f.local).join("cur")) + count(root.join(&f.local).join("new"))).sum();
225 (account.id.clone(), total)
226 })
227 .collect()
228}
229
230#[cfg(target_os = "android")]
231unsafe extern "C" {
233 fn sioul_android_mail_shortcuts(json: *const std::ffi::c_char);
236}
237
238pub(crate) fn publish(recount: bool) {
244 #[cfg(target_os = "android")]
245 {
246 static SENT: Mutex<BTreeMap<String, usize>> = Mutex::new(BTreeMap::new());
247 static GIVEN: Mutex<String> = Mutex::new(String::new());
248 let config = load_config();
249 let sent = if recount {
250 let counted = sent(&config);
251 if let Ok(mut kept) = SENT.lock() {
252 *kept = counted.clone();
253 }
254 counted
255 } else {
256 SENT.lock().map(|kept| kept.clone()).unwrap_or_default()
257 };
258 let json = serde_json::json!({ "accounts": targets(&config, &sent), "gone": tr().text("share-address-gone", None) }).to_string();
259 let Ok(mut given) = GIVEN.lock() else { return };
260 if *given == json {
261 return;
262 }
263 let Ok(text) = std::ffi::CString::new(json.clone()) else { return };
264 unsafe { sioul_android_mail_shortcuts(text.as_ptr()) };
266 *given = json;
267 }
268 let _ = recount;
269}
270
271#[cfg(target_os = "android")]
274#[unsafe(no_mangle)]
276pub extern "C" fn sioul_handed() {
277 let _ = std::panic::catch_unwind(poke);
278}
279
280pub(crate) fn addresses() -> Vec<String> {
285 std::env::args_os().skip(1).filter_map(|a| a.into_string().ok()).filter(|a| sioul_core::mailto::is_mailto(a)).collect()
286}
287
288pub(crate) fn hand(addresses: &[String]) {
290 let incoming = Incoming::here();
291 for address in addresses {
292 let request = Handed { mailto: address.clone(), ..Handed::new() };
293 if let Err(e) = incoming.hand(&request) {
294 eprintln!("Sioul: {e}");
295 }
296 }
297 poke();
298}
299
300#[cfg(all(unix, not(target_os = "android")))]
306fn socket() -> Option<std::path::PathBuf> {
307 let state = sioul_core::config::state_dir();
308 let hash = state.as_os_str().as_encoded_bytes().iter().fold(0xcbf2_9ce4_8422_2325_u64, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3));
310 let runtime = std::env::var_os("XDG_RUNTIME_DIR").filter(|v| !v.is_empty()).map(std::path::PathBuf::from);
311 let path = match (runtime, std::env::var("FLATPAK_ID").ok().filter(|id| !id.is_empty())) {
312 (Some(runtime), Some(app)) => runtime.join("app").join(app).join(format!("sioul-{hash:016x}.sock")),
313 (Some(runtime), None) => runtime.join(format!("sioul-{hash:016x}.sock")),
314 (None, _) => state.join("window.sock"),
315 };
316 (path.as_os_str().len() < 100).then_some(path)
318}
319
320#[cfg(all(unix, not(target_os = "android")))]
324pub(crate) fn hand_over(addresses: &[String]) -> bool {
325 !alone() && socket().is_some_and(|path| hand_over_at(&path, addresses, own_token()))
326}
327
328#[cfg(all(unix, not(target_os = "android")))]
332fn alone() -> bool {
333 std::env::var_os("SIOUL_GRAB").is_some() || std::env::var_os("SIOUL_DEMO").is_some()
334}
335
336#[cfg(not(all(unix, not(target_os = "android"))))]
337pub(crate) fn hand_over(_addresses: &[String]) -> bool {
338 false
339}
340
341#[cfg(all(unix, not(target_os = "android")))]
347fn own_token() -> Option<String> {
348 std::env::var("XDG_ACTIVATION_TOKEN").ok().and_then(|token| token_of(&token))
349}
350
351#[cfg(all(unix, not(target_os = "android")))]
354fn token_of(text: &str) -> Option<String> {
355 let token = text.trim();
356 (!token.is_empty() && token.len() <= 512 && token.bytes().all(|b| b.is_ascii_graphic())).then(|| token.to_string())
357}
358
359static TOKEN: Mutex<Option<String>> = Mutex::new(None);
362
363fn activate_with_token() {
367 let Some(token) = TOKEN.lock().ok().and_then(|mut token| token.take()) else { return };
368 #[cfg(all(unix, not(target_os = "android")))]
369 if let Ok(token) = std::ffi::CString::new(token) {
370 unsafe { crate::sioul_set_activation_token(token.as_ptr()) };
374 }
375 #[cfg(not(all(unix, not(target_os = "android"))))]
376 let _ = token;
377}
378
379#[cfg(all(unix, not(target_os = "android")))]
380fn hand_over_at(path: &std::path::Path, addresses: &[String], token: Option<String>) -> bool {
381 use std::io::{BufRead, BufReader, Write};
382 let Ok(mut stream) = std::os::unix::net::UnixStream::connect(path) else { return false };
383 let wait = Some(std::time::Duration::from_secs(5));
384 let _ = stream.set_read_timeout(wait);
385 let _ = stream.set_write_timeout(wait);
386 let asked = serde_json::json!({ "open": addresses, "activation": token }).to_string();
387 if stream.write_all(format!("{asked}\n").as_bytes()).is_err() {
388 return false;
389 }
390 let mut answer = String::new();
391 BufReader::new(stream).read_line(&mut answer).is_ok() && answer.trim() == "ok"
393}
394
395#[cfg(all(unix, not(target_os = "android")))]
399pub(crate) fn listen() {
400 if alone() {
401 return;
402 }
403 let Some(path) = socket() else { return };
404 let heard = |addresses: Vec<String>, token: Option<String>| {
405 if let (Some(token), Ok(mut waiting)) = (token, TOKEN.lock()) {
406 *waiting = Some(token);
407 }
408 if addresses.is_empty() {
409 let window = WINDOW.lock().ok().and_then(|w| w.clone());
410 if let Some(qt) = window {
411 let _ = qt.queue(|mut sioul| {
413 activate_with_token();
414 sioul.as_mut().reminder_opened(QString::default(), QString::default(), QString::default());
415 });
416 }
417 } else {
418 hand(&addresses);
419 }
420 };
421 match listen_at(&path, heard) {
422 Ok(()) => {
423 if let Ok(mut listening) = LISTENING.lock() {
424 *listening = Some(path);
425 }
426 }
427 Err(e) => eprintln!("Sioul: {}: {e}; another Sioul started now would open beside this one.", path.display()),
428 }
429}
430
431#[cfg(not(all(unix, not(target_os = "android"))))]
432pub(crate) fn listen() {}
433
434#[cfg(all(unix, not(target_os = "android")))]
436static LISTENING: Mutex<Option<std::path::PathBuf>> = Mutex::new(None);
437
438pub(crate) fn stop_listening() {
440 #[cfg(all(unix, not(target_os = "android")))]
441 if let Some(path) = LISTENING.lock().ok().and_then(|mut listening| listening.take()) {
442 let _ = std::fs::remove_file(path);
443 }
444}
445
446#[cfg(all(unix, not(target_os = "android")))]
447fn listen_at(path: &std::path::Path, heard: impl Fn(Vec<String>, Option<String>) + Send + 'static) -> std::io::Result<()> {
448 use std::io::{BufRead, BufReader, Read, Write};
449 use std::os::unix::net::{UnixListener, UnixStream};
450 if let Some(parent) = path.parent() {
451 std::fs::create_dir_all(parent)?;
452 }
453 let listener = match UnixListener::bind(path) {
454 Ok(listener) => listener,
455 Err(e) if e.kind() == std::io::ErrorKind::AddrInUse && UnixStream::connect(path).is_err() => {
457 std::fs::remove_file(path)?;
458 UnixListener::bind(path)?
459 }
460 Err(e) => return Err(e),
461 };
462 std::thread::spawn(move || {
463 for stream in listener.incoming().filter_map(Result::ok) {
464 let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(2)));
465 let mut line = String::new();
466 if BufReader::new((&stream).take(64 * 1024)).read_line(&mut line).is_err() {
468 continue;
469 }
470 let asked: serde_json::Value = serde_json::from_str(&line).unwrap_or_default();
471 let addresses: Vec<String> = asked["open"]
472 .as_array()
473 .map(|all| all.iter().filter_map(|a| a.as_str()).filter(|a| sioul_core::mailto::is_mailto(a)).map(str::to_string).collect())
474 .unwrap_or_default();
475 if asked.get("open").is_some() {
476 heard(addresses, asked["activation"].as_str().and_then(token_of));
477 let _ = (&stream).write_all(b"ok\n");
478 }
479 }
480 });
481 Ok(())
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487 use sioul_core::config::{Priority, Security};
488
489 #[test]
490 fn the_share_sheet_lists_the_most_used_addresses_first() {
491 let mut config = Config::default();
492 let work = Account::imap("work", "me@work.example", "imap.work.example", 993, Security::Tls, None);
493 let mut home = Account::imap("home", "me@home.example", "imap.home.example", 993, Security::Tls, None);
494 home.priority = Priority::Above;
495 let mut bare = Account::imap("old", "", "imap.old.example", 993, Security::Tls, None);
496 bare.address = None;
497 let calendars = Account::dav("calendars", "me@dav.example", "dav.example", None, None);
498 config.accounts = vec![work, home, bare, calendars];
499 let order = |sent: &[(&str, usize)]| targets(&config, &sent.iter().map(|(id, n)| (id.to_string(), *n)).collect()).into_iter().map(|t| t.short).collect::<Vec<_>>();
501 assert_eq!(order(&[]), ["me@home.example", "me@work.example", "old"]);
502 assert_eq!(order(&[("work", 12), ("home", 3)]), ["me@work.example", "me@home.example", "old"]);
504 assert_eq!(order(&[("old", 1)]), ["old", "me@home.example", "me@work.example"]);
505 let first = &targets(&config, &BTreeMap::new())[0];
506 assert_eq!(first.id, "home");
507 assert!(first.long.contains("me@home.example"), "{}", first.long);
508 }
509
510 #[test]
511 fn what_did_not_come_is_said() {
512 let (line, problem) = arrival_line(2, &[]);
513 assert!(!problem && line.contains('2'), "{line}");
514 let missing = [
515 ArrivedFile { name: "photo.jpg".into(), error: "Permission denied".into(), ..ArrivedFile::default() },
516 ArrivedFile { name: "id_rsa".into(), ..ArrivedFile::default() },
517 ArrivedFile { stopped: true, ..ArrivedFile::default() },
518 ];
519 let (line, problem) = arrival_line(1, &missing);
520 assert!(problem && line.contains("photo.jpg (Permission denied)") && line.contains("id_rsa ("), "{line}");
521 assert!(!line.contains("handed-"), "a message id left untranslated: {line}");
522 }
523
524 #[cfg(all(unix, not(target_os = "android")))]
525 #[test]
526 fn a_second_sioul_hands_its_link_to_the_first() {
527 let dir = std::env::temp_dir().join(format!("sioul-hand-over-{}", std::process::id()));
528 let _ = std::fs::remove_dir_all(&dir);
529 std::fs::create_dir_all(&dir).unwrap();
530 let path = dir.join("s.sock");
531 assert!(!hand_over_at(&path, &["mailto:a@example.org".into()], None));
533 drop(std::os::unix::net::UnixListener::bind(&path).unwrap());
535 let (send, heard) = std::sync::mpsc::channel();
536 listen_at(&path, move |addresses, token| send.send((addresses, token)).unwrap()).unwrap();
537 let next = || heard.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
538 assert!(hand_over_at(&path, &["mailto:a@example.org?subject=Hi".into()], None));
539 assert_eq!(next(), (vec!["mailto:a@example.org?subject=Hi".to_string()], None));
540 assert!(hand_over_at(&path, &[], None));
542 assert!(next().0.is_empty());
543 assert!(hand_over_at(&path, &["file:///etc/passwd".into(), "mailto:b@example.org".into()], None));
545 assert_eq!(next().0, ["mailto:b@example.org"]);
546 assert!(hand_over_at(&path, &["mailto:c@example.org".into()], Some("kwin-1234_abcd".into())));
549 assert_eq!(next(), (vec!["mailto:c@example.org".to_string()], Some("kwin-1234_abcd".to_string())));
550 assert!(hand_over_at(&path, &[], Some("two words\n".into())));
551 assert_eq!(next(), (Vec::new(), None));
552 std::fs::remove_dir_all(&dir).unwrap();
553 }
554}