Skip to main content

sioul_app/
outside.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Writing from other applications (docs/client.md, "Writing from other
5//! applications"; docs/android.md, "Sharing"). Files and text shared to
6//! Sioul, one of your addresses chosen in Android's share sheet, a `mailto:`
7//! link: each request (`sioul_core::handed`) becomes a draft whose window
8//! opens. On a phone each address that can send is a direct-share target
9//! (`publish`); on a computer `sioul-app mailto:…` is handed to the Sioul
10//! already open (`hand_over`). Nothing is sent until you press Send.
11
12use 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
23/// The window, once it runs: what comes while it is open goes to it at once.
24static WINDOW: Mutex<Option<QtThread>> = Mutex::new(None);
25
26/// Drafts whose files are still being copied: how many, and those already
27/// known not to come (refused), said with the rest when they arrive.
28static COMING: Mutex<BTreeMap<String, (u32, Vec<ArrivedFile>)>> = Mutex::new(BTreeMap::new());
29
30/// The window has started: what waits is taken, what comes later goes to it.
31/// On the window's thread, from `start`.
32pub(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        // Copies nobody holds any more, from a Sioul stopped while copying.
39        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
45/// Something was handed (Android's ShareActivity, a second `sioul-app`):
46/// taken now when the window runs, else when it starts. Any thread.
47pub(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
54/// Whether draft `id` still waits for files being copied for it.
55pub(crate) fn coming(id: &str) -> bool {
56    COMING.lock().is_ok_and(|coming| coming.contains_key(id))
57}
58
59/// "Attaching 2 files…" while draft `id` waits for them, else "".
60pub(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
65/// The requests waiting (`drafts/incoming/`): files arrived for drafts made
66/// before are attached, each new request made a draft whose window opens. On
67/// the window's thread, so that it never crosses the writing window's own
68/// saves: at the start, when the window comes back, at its minute, and when
69/// something is handed.
70pub(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    // Files come for drafts already open: all copied, or their copy stopped
76    // with the Sioul that made it (another process).
77    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            // Sent or discarded meanwhile: its copies go too.
99            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
112/// One request made a draft, its window opened.
113fn 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        // No address that sends yet: said, and what was copied for it goes.
117        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    // The files: none asked, all there already, or their copy stopped with another Sioul.
123    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        // Read again at the next showing, unless it can never be saved: the copies stay meanwhile.
138        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    // What did not come is said in the writing window, now that it is open.
153    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
159/// What the writing window says once the files came: "The 2 files are
160/// attached.", or those not attached and why.
161fn 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// ------------------------------------------------------------------ Android
183
184/// One direct-share target: an address that can send, as Android shows it.
185#[cfg_attr(not(target_os = "android"), allow(dead_code))]
186#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
187struct Target {
188    /// The account's id; the shortcut's is "mail:" and it.
189    id: String,
190    /// Under its icon in the share sheet: the address.
191    short: String,
192    /// At a long press on Sioul's icon: "Write from you@example.org".
193    long: String,
194}
195
196/// The direct-share targets: each account that sends, the most used first
197/// (`sent`: messages in its Sent folders here), then by priority, then in
198/// the file's order.
199#[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/// How many messages each account's Sent folders hold here: what "most
213/// used" means for the share sheet. Read from the folders' listings only.
214#[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")]
231// SAFETY: declared as android/main.cpp defines them: extern "C", the same types.
232unsafe extern "C" {
233    /// Sioul's addresses that send, as Android's direct-share targets
234    /// (android/main.cpp, MailShortcuts.java): JSON {accounts, gone}.
235    fn sioul_android_mail_shortcuts(json: *const std::ffi::c_char);
236}
237
238/// Android: the direct-share targets brought in step with the accounts, when
239/// they changed (added, renamed, removed, their order). `recount` counts the
240/// Sent folders again (at the start, when the window comes back); otherwise
241/// the last counts rank them. Only while the window is shown: Android limits
242/// how often an app in the background may change them. Nothing elsewhere.
243pub(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        // SAFETY: a zero-terminated text, valid for the call; any thread.
265        unsafe { sioul_android_mail_shortcuts(text.as_ptr()) };
266        *given = json;
267    }
268    let _ = recount;
269}
270
271/// Android's ShareActivity: a request written, or the files copied for one
272/// (android/main.cpp). Any thread.
273#[cfg(target_os = "android")]
274// SAFETY: no other symbol of the program has this name.
275#[unsafe(no_mangle)]
276pub extern "C" fn sioul_handed() {
277    let _ = std::panic::catch_unwind(poke);
278}
279
280// ------------------------------------------------------------------ a computer
281
282/// The program's arguments that are `mailto:` addresses (the desktop file's
283/// `%u`: a mail link clicked while Sioul is the mail program).
284pub(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
288/// `addresses` handed to the window: taken at its start, or at once.
289pub(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/// Where the Sioul of this profile listens for another one started after it:
301/// in the session's runtime folder (Flatpak's own for its applications), its
302/// name made from the state folder's, so that a test or a demo profile never
303/// reaches yours; else in the state folder itself. None where it would not
304/// fit a socket's name.
305#[cfg(all(unix, not(target_os = "android")))]
306fn socket() -> Option<std::path::PathBuf> {
307    let state = sioul_core::config::state_dir();
308    // FNV-1a: the same name from one build to the next.
309    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    // A socket's name holds 107 bytes on Linux, 103 on macOS.
317    (path.as_os_str().len() < 100).then_some(path)
318}
319
320/// Another Sioul of this profile is open: `addresses` handed to it (with
321/// none, its window brought forward), and true: this one has nothing more to
322/// do. False when none answers: this one opens, and listens (`listen`).
323#[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/// The window's pictures and the demo (SIOUL_GRAB, SIOUL_DEMO) run beside
329/// each other on one made-up profile: each stays its own, neither handing
330/// over nor listening.
331#[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/// The token a desktop gives the program it starts, on Wayland, so that the
342/// window it opens may come to the front (xdg-activation-v1: the launcher's
343/// `XDG_ACTIVATION_TOKEN`). A second Sioul hands it on with its addresses:
344/// without it, KWin keeps the first Sioul's draft behind the window you
345/// clicked in (focus stealing prevention).
346#[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/// A token as it may be handed on: short, printable, without spaces (a
352/// compositor's tokens are; anything else is not one).
353#[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
359/// The token handed by the last Sioul started after this one, waiting for
360/// the window's thread (`activate_with_token`).
361static TOKEN: Mutex<Option<String>> = Mutex::new(None);
362
363/// On the window's thread, before a window opens or comes forward: the
364/// token handed on, given to Qt, which hands it to the compositor at the
365/// next activation (`requestActivate`) and forgets it.
366fn 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        // SAFETY: `token` is a C string alive for the call, which copies it;
371        // on the window's thread, where Qt reads the variable (qputenv takes
372        // Qt's lock on the environment).
373        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    // An open Sioul that does not answer (frozen): this one opens all the same.
392    BufReader::new(stream).read_line(&mut answer).is_ok() && answer.trim() == "ok"
393}
394
395/// The first Sioul of this profile listens for those started after it: their
396/// `mailto:` addresses become drafts here, a plain start brings this window
397/// forward. A socket left by a Sioul that ended is replaced.
398#[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                // A reminder's "Open" with nothing to open: the window brought forward.
412                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/// The socket this Sioul listens on, if it does.
435#[cfg(all(unix, not(target_os = "android")))]
436static LISTENING: Mutex<Option<std::path::PathBuf>> = Mutex::new(None);
437
438/// Sioul ends: its socket goes with it, so that the next start does not knock there.
439pub(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        // Left by a Sioul that ended: nobody answers there.
456        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            // One line of JSON, never more than 64 KB.
467            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        // Nothing sent from here yet: by priority, then the file's order; no address, its id.
500        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        // The most used first.
503        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        // None open: this one opens.
532        assert!(!hand_over_at(&path, &["mailto:a@example.org".into()], None));
533        // A socket left by a Sioul that ended is replaced.
534        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        // A plain start: nothing to write, the window brought forward.
541        assert!(hand_over_at(&path, &[], None));
542        assert!(next().0.is_empty());
543        // Only mailto: addresses are taken from another process.
544        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        // The desktop's activation token goes with the link, so that its
547        // draft may come to the front; what is not a token stays behind.
548        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}