Skip to main content

sioul_sync/
devices.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Each device says how it is, in the sharing folder (docs/database.md,
5//! "Devices"): when it last started, when it last closed cleanly, whether it
6//! is working now, when it last read the others and when it last wrote its
7//! own. The doses rely on it (docs/health.md, "Knowing"): a device that closed
8//! cleanly after its last export holds no answer the others have not read; one
9//! still working may.
10//!
11//! One file per device, `<folder>/devices/<id>.device`, written by that device
12//! only, sealed like the claims (bound to the device it names), its size
13//! changed at each writing (`share::pad_for`: some sync apps tell a change by
14//! its size alone), a few hundred bytes, never read past 64 KiB. The same,
15//! privately, in `$XDG_STATE_HOME/sioul/share/device.toml`, never shared,
16//! changed under a lock and read just before (`change`): two processes of
17//! Sioul (the window, a phone's background service) never write an older
18//! state back over the other's.
19//!
20//! Written into the folder only when the others need it (`change`): at each
21//! export while the device is in use (`working`: the doses need its exports
22//! fresh, `sioul_core::health::FRESH`), when anything else it says changes
23//! (its session, how far it wrote, whether it shares its doses, its build),
24//! when the folder's copy is not the one it wrote, and once an hour
25//! (`RESTATED`). A device put away whose exchanges find nothing new writes
26//! nothing there: a phone's background step every few minutes would
27//! otherwise rewrite it, and its sync app send it, each time.
28//!
29//! The identity is the sharing's own (`share::Here::id`, a UUID made once per
30//! machine), never the host name, which can change or be another's too.
31
32use serde::{Deserialize, Serialize};
33use std::path::{Path, PathBuf};
34
35/// A device's entry: what it says of itself, its times on its own clock (Unix seconds).
36#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
37pub struct Entry {
38    pub id: String,
39    /// To say where: a computer's host name, a phone's model.
40    #[serde(default)]
41    pub name: String,
42    /// `PHONE` or `COMPUTER`.
43    #[serde(default)]
44    pub kind: String,
45    /// The Sioul that wrote it: its version ("0.0.3") and the commit it was
46    /// built from (`sioul_core::build::COMMIT`); an older Sioul's entry says
47    /// its version alone, or neither, and reads all the same.
48    #[serde(default)]
49    pub version: String,
50    #[serde(default, skip_serializing_if = "String::is_empty")]
51    pub commit: String,
52    /// The format of what travels that its Sioul reads and writes
53    /// (`share::FORMAT`); 0 from an older Sioul, which reads format 1 only.
54    /// The sharing writes format 2 once every device says 2 (`share::format_holders`).
55    #[serde(default, skip_serializing_if = "is_zero")]
56    pub format: u32,
57    /// Its last start: Sioul opened; on a phone, Sioul back on the screen, or
58    /// one of its reminders handled while it was not.
59    #[serde(default)]
60    pub started: i64,
61    /// Its last clean close, after its last export; 0 never.
62    #[serde(default)]
63    pub closed: i64,
64    /// Up from its start to the last step of a clean close: a crash leaves it
65    /// up. Required: an entry without it is no entry, never one taken as closed.
66    pub working: bool,
67    /// When it last read the others.
68    #[serde(default)]
69    pub imported: i64,
70    /// When its files were last read for an export: everything captured there
71    /// before is in its records, up to `wrote`.
72    #[serde(default)]
73    pub exported: i64,
74    /// How far its records went then: round and number.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub wrote: Option<(u32, u64)>,
77    /// It shares the health part: the answers it captures travel.
78    #[serde(default)]
79    pub doses: bool,
80    /// A phone: whether Android lets Sioul hold other apps' notifications
81    /// there (notification access), as it last looked; None when unsaid (a
82    /// computer, an older Sioul). A computer's "What reaches you" counts a
83    /// phone's messages unless it says no (an older Sioul's, unsaid, as before).
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub notifications: Option<bool>,
86    /// It stopped sharing: it counts no more.
87    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
88    pub left: bool,
89    /// Its size changed at each writing (`share::pad_for`): read by nobody.
90    #[serde(default, skip_serializing_if = "String::is_empty")]
91    pub pad: String,
92}
93
94fn is_zero(n: &u32) -> bool {
95    *n == 0
96}
97
98pub const PHONE: &str = "phone";
99pub const COMPUTER: &str = "computer";
100
101impl Entry {
102    /// A session begins: Sioul opened, brought back, or a receiver at work.
103    pub fn start(&mut self, now: i64) {
104        self.started = now;
105        self.working = true;
106        self.left = false;
107    }
108
109    /// The last step of a clean session, after its last export: closed then
110    /// (never before its own start, whatever the clock did meanwhile).
111    pub fn close(&mut self, now: i64) {
112        self.closed = now.max(self.started);
113        self.working = false;
114    }
115
116    /// An exchange done (`share::Outcome`): the others read `now`, this
117    /// device's files read for its export at `looked` (milliseconds; 0 when it
118    /// did not run), its records up to `wrote`. Never its session.
119    pub fn exported(&mut self, looked_ms: i64, wrote: Option<(u32, u64)>, now: i64) {
120        if looked_ms <= 0 {
121            return;
122        }
123        self.imported = now;
124        self.exported = self.exported.max(looked_ms / 1000);
125        if wrote.is_some() {
126            self.wrote = wrote;
127        }
128    }
129
130    /// The build that wrote it, in words: "0.0.3 (eff8661abcde)"; its version
131    /// alone from an older Sioul; "" when unsaid.
132    pub fn build(&self) -> String {
133        sioul_core::build::described(&self.version, &self.commit)
134    }
135
136    /// What the doses read of it (`sioul_core::health::Said`).
137    pub fn said(&self) -> sioul_core::health::Said {
138        sioul_core::health::Said { started: self.started, closed: self.closed, working: self.working, exported: self.exported, imported: self.imported, doses: self.doses, left: self.left, phone: self.kind == PHONE }
139    }
140}
141
142fn folder_of(folder: &Path) -> PathBuf {
143    folder.join("devices")
144}
145
146fn file_of(folder: &Path, id: &str) -> PathBuf {
147    folder_of(folder).join(format!("{id}.device"))
148}
149
150fn bound(id: &str) -> String {
151    format!("device\u{1f}{id}")
152}
153
154/// This device's entry, sealed into its file in the folder, its size changed
155/// from the one there.
156pub fn publish(folder: &Path, key: &[u8; 32], entry: &Entry) -> Result<(), String> {
157    let path = file_of(folder, &entry.id);
158    let sealed = |pad: usize| serde_json::to_vec(&Entry { pad: ".".repeat(pad), ..entry.clone() }).map(|plain| crate::share::seal(key, &bound(&entry.id), &plain)).map_err(|e| e.to_string());
159    let pad = crate::share::pad_for(&path, |pad| sealed(pad).map_or(0, |s| s.len() as u64));
160    crate::share::write_atomically(&path, sealed(pad)?.as_bytes())
161}
162
163/// Every device's entry that reads, and the ids of the devices whose file
164/// does not (its seal not arrived, damaged, another key's, written by an
165/// older Sioul's sharing that knows no `working`): those are no entry, never
166/// one read as closed. Files of other names (a sync app's conflicted copies,
167/// half-written ones) are not read. Of an entry fetched from the server too
168/// (`remote::overlay`), the later of the two copies (`written`); the
169/// folder's on a tie.
170pub fn all(folder: &Path, key: &[u8; 32]) -> (Vec<Entry>, Vec<String>) {
171    let (mut entries, mut unread) = (Vec::new(), Vec::new());
172    let fetched = crate::remote::overlay(folder);
173    let ids = |dir: &Path| -> Vec<String> {
174        let listing = std::fs::read_dir(folder_of(dir)).into_iter().flatten().filter_map(Result::ok);
175        listing.filter_map(|e| e.file_name().to_str().and_then(|n| n.strip_suffix(".device")).map(str::to_string)).filter(|id| id.len() == 36 && uuid::Uuid::parse_str(id).is_ok()).collect()
176    };
177    let mut all: std::collections::BTreeSet<String> = ids(folder).into_iter().collect();
178    if let Some(fetched) = &fetched {
179        all.extend(ids(fetched));
180    }
181    let read = |dir: &Path, id: &str| crate::share::read_small(&file_of(dir, id)).and_then(|text| crate::share::open(key, &bound(id), text.trim())).and_then(|plain| serde_json::from_slice::<Entry>(&plain).ok()).filter(|entry| entry.id == id);
182    for id in all {
183        let entry = match (read(folder, &id), fetched.as_deref().and_then(|f| read(f, &id))) {
184            (Some(here), Some(fetched)) => Some(if written(&fetched) > written(&here) { fetched } else { here }),
185            (here, fetched) => here.or(fetched),
186        };
187        match entry {
188            Some(entry) => entries.push(entry),
189            None => unread.push(id),
190        }
191    }
192    entries.sort_by(|a, b| a.id.cmp(&b.id));
193    unread.sort();
194    (entries, unread)
195}
196
197/// When an entry was written, as far as its times tell: each writing sets
198/// one of them to its device's clock then (a start, a close, an exchange), and
199/// none goes back.
200pub(crate) fn written(entry: &Entry) -> (i64, i64, i64, i64, i64) {
201    (entry.started.max(entry.closed).max(entry.imported), entry.closed, entry.started, entry.imported, entry.exported)
202}
203
204/// Where this device keeps its own entry, never shared.
205pub fn own_path(state: &Path) -> PathBuf {
206    state.join("share").join("device.toml")
207}
208
209/// This device's own entry as kept here; a new one when none reads.
210pub fn own(path: &Path) -> Entry {
211    std::fs::read_to_string(path).ok().and_then(|text| toml::from_str(&text).ok()).unwrap_or_default()
212}
213
214/// Whether this device is in use, as its own entry says (`Entry::working`):
215/// the entry kept beside the sharing's memory (`<state>/share/`, `own_path`).
216pub(crate) fn in_use(memory: &Path) -> bool {
217    own(&memory.with_file_name("device.toml")).working
218}
219
220/// How long a device not in use leaves its entry in the folder as it is, at
221/// most, while only the times of its exchanges change (seconds): restated
222/// then, so that the others still hear of it (Settings ▸ Your folder and
223/// sharing, the texts' "last shared", `sioul_core::health::SILENT_DAYS`). The
224/// doses never wait for it: a device put away is known by its close and how
225/// far it wrote, both written at once.
226pub const RESTATED: i64 = 60 * 60;
227
228/// What this device last wrote into the folder, kept beside its own entry and
229/// never shared (`published_path`): what it said then, its exchanges' times
230/// left out (`steady`), the file as written (its size and time), where, under
231/// which key (`sealed_with`: the folder sealed again, it is written again at
232/// once), and when.
233#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
234struct Published {
235    said: String,
236    folder: String,
237    #[serde(default)]
238    key: String,
239    size: u64,
240    modified: u64,
241    at: i64,
242}
243
244/// Which key an entry was sealed with, as a mark that says nothing of the key
245/// itself (a hash of it under a name of its own).
246fn sealed_with(key: &[u8; 32]) -> String {
247    use sha2::Digest;
248    let digest = sha2::Sha256::new().chain_update(b"sioul: the key a device entry was sealed with\x1f").chain_update(key).finalize();
249    digest.iter().take(8).map(|b| format!("{b:02x}")).collect()
250}
251
252fn published_path(own: &Path) -> PathBuf {
253    own.with_file_name("device-published.toml")
254}
255
256/// What an entry says but for its exchanges' times, as a hash: what the
257/// others must hear at once when it changes.
258pub(crate) fn steady(entry: &Entry) -> String {
259    let steady = Entry { imported: 0, exported: 0, pad: String::new(), ..entry.clone() };
260    serde_json::to_string(&steady).map(|text| crate::share::hash(&text)).unwrap_or_default()
261}
262
263/// The size and time of the folder's copy of an entry, when there is one.
264fn stamp_of(path: &Path) -> Option<(u64, u64)> {
265    std::fs::metadata(path).ok().map(|meta| (meta.len(), crate::share::modified_ns(&meta)))
266}
267
268/// This device's own entry changed by `change`, under its lock, read just
269/// before: written here, then, sharing on (`vault`: the folder and its key),
270/// into the folder when the others need it (see the module's words).
271/// Returns it as written here.
272pub fn change(path: &Path, vault: Option<(&Path, &[u8; 32])>, change: impl FnOnce(&mut Entry)) -> Result<Entry, String> {
273    change_at(path, vault, jiff::Timestamp::now().as_second(), change)
274}
275
276/// `change`, at `now` (Unix seconds, this device's clock): when the folder's copy was last restated.
277pub(crate) fn change_at(path: &Path, vault: Option<(&Path, &[u8; 32])>, now: i64, change: impl FnOnce(&mut Entry)) -> Result<Entry, String> {
278    sioul_core::filelock::with_lock(path, || {
279        let mut entry = own(path);
280        change(&mut entry);
281        entry.pad.clear();
282        let text = toml::to_string(&entry).map_err(|e| e.to_string())?;
283        crate::share::write_atomically(path, format!("# This device, as it tells your other devices (docs/database.md, \"Devices\").\n{text}").as_bytes())?;
284        if let Some((folder, key)) = vault
285            && !entry.id.is_empty()
286        {
287            let file = file_of(folder, &entry.id);
288            let said = steady(&entry);
289            let there = folder.display().to_string();
290            let last: Option<Published> = std::fs::read_to_string(published_path(path)).ok().and_then(|t| toml::from_str(&t).ok());
291            // As the others last read it: the same words, the folder's copy the one written, within the hour.
292            let mark = sealed_with(key);
293            let current = last.is_some_and(|last| last.said == said && last.folder == there && last.key == mark && stamp_of(&file) == Some((last.size, last.modified)) && (0..RESTATED).contains(&(now - last.at)));
294            if entry.working || !current {
295                publish(folder, key, &entry)?;
296                let (size, modified) = stamp_of(&file).unwrap_or_default();
297                let published = Published { said, folder: there, key: mark, size, modified, at: now };
298                // Lost, the entry is only written again at the next change: nothing is said wrongly.
299                let _ = toml::to_string(&published).map_err(|e| e.to_string()).and_then(|text| crate::share::write_atomically(&published_path(path), text.as_bytes()));
300            }
301        }
302        Ok(entry)
303    })
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use crate::share::{Roots, Sharing, Store};
310    use sioul_core::config::Config;
311    use sioul_core::doses::{Answer, Answered, DoseRecords, SKIPPED, TAKEN};
312    use sioul_core::health::{Doubt, HealthState, Peer, doubts_now};
313
314    fn scratch(name: &str) -> PathBuf {
315        let dir = std::env::temp_dir().join(format!("sioul-devices-{name}-{}", std::process::id()));
316        let _ = std::fs::remove_dir_all(&dir);
317        std::fs::create_dir_all(&dir).unwrap();
318        dir
319    }
320
321    const KEY: [u8; 32] = [7u8; 32];
322
323    #[test]
324    fn a_device_says_how_it_is() {
325        let dir = scratch("says");
326        let (folder, state) = (dir.join("shared"), dir.join("state"));
327        let id = uuid::Uuid::new_v4().to_string();
328        let own = own_path(&state);
329        let vault = Some((folder.as_path(), &KEY));
330        // Started: up, said in the folder, sealed (nothing of it readable there).
331        change(&own, vault, |e| {
332            e.id = id.clone();
333            e.kind = PHONE.into();
334            e.doses = true;
335            e.start(1_000);
336        })
337        .unwrap();
338        let raw = std::fs::read_to_string(file_of(&folder, &id)).unwrap();
339        assert!(!raw.contains("working") && !raw.contains(&id), "sealed");
340        let size = raw.len();
341        let (entries, unread) = all(&folder, &KEY);
342        assert!(unread.is_empty());
343        assert_eq!((entries[0].started, entries[0].working, entries[0].said().phone), (1_000, true, true));
344        // An exchange: what it read and wrote, never its session.
345        change(&own, vault, |e| e.exported(1_060_000, Some((1, 4)), 1_061)).unwrap();
346        // Closed after its last export: down.
347        change(&own, vault, |e| e.close(1_120)).unwrap();
348        let (entries, _) = all(&folder, &KEY);
349        assert_eq!((entries[0].exported, entries[0].wrote, entries[0].closed, entries[0].working), (1_060, Some((1, 4)), 1_120, false));
350        assert_ne!(std::fs::metadata(file_of(&folder, &id)).unwrap().len() as usize, size, "its size changes at each writing");
351        // Kept here too, never shared.
352        assert_eq!(super::own(&own).closed, 1_120);
353        // Another key reads nothing: no entry, said apart; never taken as closed.
354        let (entries, unread) = all(&folder, &[1u8; 32]);
355        assert!(entries.is_empty() && unread == [id.clone()]);
356        // A file of another name (a conflicted copy) is not read; one sealed for another device is no entry.
357        std::fs::copy(file_of(&folder, &id), folder_of(&folder).join(format!("{id} (conflicted copy).device"))).unwrap();
358        let other = uuid::Uuid::new_v4().to_string();
359        std::fs::copy(file_of(&folder, &id), file_of(&folder, &other)).unwrap();
360        let (entries, unread) = all(&folder, &KEY);
361        assert_eq!((entries.len(), unread), (1, vec![other]));
362        // An entry without `working` (written by something else): no entry, never closed.
363        let without = serde_json::json!({ "id": id, "started": 1_000, "closed": 2_000 }).to_string();
364        std::fs::write(file_of(&folder, &id), crate::share::seal(&KEY, &bound(&id), without.as_bytes())).unwrap();
365        assert!(all(&folder, &KEY).0.is_empty());
366        let _ = std::fs::remove_dir_all(&dir);
367    }
368
369    /// Each entry says the build that wrote it (docs/database.md, "Devices");
370    /// one written before the field came, by an older Sioul, reads all the same.
371    #[test]
372    fn an_entry_says_its_build_and_an_older_one_still_reads() {
373        let dir = scratch("build");
374        let (folder, state) = (dir.join("shared"), dir.join("state"));
375        let id = uuid::Uuid::new_v4().to_string();
376        let vault = Some((folder.as_path(), &KEY));
377        change(&own_path(&state), vault, |e| {
378            e.id = id.clone();
379            e.version = sioul_core::build::VERSION.into();
380            e.commit = sioul_core::build::COMMIT.into();
381            e.start(1_000);
382        })
383        .unwrap();
384        let (entries, _) = all(&folder, &KEY);
385        assert_eq!((entries[0].version.as_str(), entries[0].commit.as_str()), (sioul_core::build::VERSION, sioul_core::build::COMMIT));
386        assert_eq!(entries[0].build(), sioul_core::build::DESCRIBED);
387        assert!(!std::fs::read_to_string(file_of(&folder, &id)).unwrap().contains(sioul_core::build::COMMIT), "sealed: the server never learns it");
388        // An older Sioul's entry: its version alone, no commit; one older still, neither.
389        for (plain, said) in [(serde_json::json!({ "id": id, "version": "0.0.2", "started": 1_000, "working": true }), "0.0.2"), (serde_json::json!({ "id": id, "started": 1_000, "working": true }), "")] {
390            std::fs::write(file_of(&folder, &id), crate::share::seal(&KEY, &bound(&id), plain.to_string().as_bytes())).unwrap();
391            let (entries, unread) = all(&folder, &KEY);
392            assert!(unread.is_empty() && entries[0].working && entries[0].commit.is_empty(), "{entries:?}");
393            assert_eq!(entries[0].build(), said);
394        }
395        let _ = std::fs::remove_dir_all(&dir);
396    }
397
398    /// A phone says whether Android lets Sioul hold other apps' notifications;
399    /// a change that does not look again keeps what it said; an older Sioul
400    /// says nothing, read as unsaid.
401    #[test]
402    fn a_phone_says_whether_it_holds_notifications() {
403        let dir = scratch("notifications");
404        let (folder, state) = (dir.join("shared"), dir.join("state"));
405        let id = uuid::Uuid::new_v4().to_string();
406        let vault = Some((folder.as_path(), &KEY));
407        change(&own_path(&state), vault, |e| {
408            e.id = id.clone();
409            e.kind = PHONE.into();
410            e.notifications = Some(true);
411            e.start(1_000);
412        })
413        .unwrap();
414        change(&own_path(&state), vault, |e| e.close(1_100)).unwrap();
415        assert_eq!(all(&folder, &KEY).0[0].notifications, Some(true), "kept by a change that did not look");
416        change(&own_path(&state), vault, |e| e.notifications = Some(false)).unwrap();
417        assert_eq!(all(&folder, &KEY).0[0].notifications, Some(false));
418        let older = serde_json::json!({ "id": id, "kind": "phone", "started": 1_000, "working": true });
419        std::fs::write(file_of(&folder, &id), crate::share::seal(&KEY, &bound(&id), older.to_string().as_bytes())).unwrap();
420        assert_eq!(all(&folder, &KEY).0[0].notifications, None, "an older Sioul's: unsaid");
421        let _ = std::fs::remove_dir_all(&dir);
422    }
423
424    /// A device put away (a phone's background step every few minutes) whose
425    /// exchanges find nothing new leaves its entry in the folder as it is:
426    /// what the others need goes there at once (its session, how far it
427    /// wrote, whether it shares its doses), a copy there that is not the one
428    /// it wrote is written again, and an hour on it is restated. In use, every
429    /// export goes there, as the doses' knowing needs (`FRESH`).
430    #[test]
431    fn a_device_put_away_writes_its_entry_only_when_it_says_something_new() {
432        let dir = scratch("put-away");
433        let (folder, state) = (dir.join("shared"), dir.join("state"));
434        let own = own_path(&state);
435        let vault = Some((folder.as_path(), &KEY));
436        let id = uuid::Uuid::new_v4().to_string();
437        let file = file_of(&folder, &id);
438        let bytes = || std::fs::read(&file).unwrap();
439        let exported = |at: i64, wrote: (u32, u64)| change_at(&own, vault, at, |e| e.exported(at * 1000, Some(wrote), at)).unwrap();
440        let t = 1_800_000_000;
441        change_at(&own, vault, t, |e| {
442            e.id = id.clone();
443            e.kind = PHONE.into();
444            e.doses = true;
445            e.start(t);
446        })
447        .unwrap();
448        exported(t, (1, 4));
449        change_at(&own, vault, t + 5, |e| e.close(t + 5)).unwrap();
450        let closed = bytes();
451        // Exchanges that wrote nothing: kept here, nothing written there.
452        for n in 1..=5 {
453            exported(t + 5 + n * 120, (1, 4));
454        }
455        assert_eq!(bytes(), closed, "nothing new: the folder's copy as it was");
456        assert_eq!(super::own(&own).exported, t + 605, "kept here");
457        let seen = &all(&folder, &KEY).0[0];
458        assert_eq!((seen.exported, seen.wrote, seen.working), (t, Some((1, 4)), false));
459        // How far it wrote changed: there at once, with its time.
460        exported(t + 700, (2, 1));
461        let seen = all(&folder, &KEY).0[0].clone();
462        assert_eq!((seen.exported, seen.wrote, seen.working), (t + 700, Some((2, 1)), false));
463        // Within the hour, nothing new: as it is; an hour on: restated.
464        let wrote = bytes();
465        exported(t + 700 + RESTATED - 1, (2, 1));
466        assert_eq!(bytes(), wrote);
467        exported(t + 700 + RESTATED, (2, 1));
468        assert_eq!(all(&folder, &KEY).0[0].exported, t + 700 + RESTATED, "restated after an hour");
469        // An older copy put back by a sync app: written again at the next export.
470        std::fs::write(&file, &closed).unwrap();
471        exported(t + 800 + RESTATED, (2, 1));
472        assert_eq!(all(&folder, &KEY).0[0].wrote, Some((2, 1)), "the copy put back is replaced");
473        // Its doses no longer shared: there at once.
474        change_at(&own, vault, t + 900 + RESTATED, |e| e.doses = false).unwrap();
475        assert!(!all(&folder, &KEY).0[0].doses);
476        // The folder sealed again (another passphrase, the same place): written under the new key at once.
477        let other = [9u8; 32];
478        exported(t + 950 + RESTATED, (2, 1));
479        change_at(&own, Some((folder.as_path(), &other)), t + 960 + RESTATED, |e| e.exported((t + 960 + RESTATED) * 1000, Some((2, 1)), t + 960 + RESTATED)).unwrap();
480        assert_eq!(all(&folder, &other).0.len(), 1, "readable under the new key at once");
481        // In use: every export, each with its time.
482        change_at(&own, vault, t + 1_000 + RESTATED, |e| e.start(t + 1_000 + RESTATED)).unwrap();
483        for n in 1..=3 {
484            let at = t + 1_000 + RESTATED + n * 60;
485            exported(at, (2, 1));
486            let seen = &all(&folder, &KEY).0[0];
487            assert_eq!((seen.exported, seen.working), (at, true), "in use: export {n} there at once");
488        }
489        // Another folder (sharing moved): written there at once.
490        let elsewhere = dir.join("elsewhere");
491        change_at(&own, Some((elsewhere.as_path(), &KEY)), t + 2_000 + RESTATED, |e| e.close(t + 2_000 + RESTATED)).unwrap();
492        exported(t + 2_100 + RESTATED, (2, 1));
493        let moved = dir.join("moved");
494        change_at(&own, Some((moved.as_path(), &KEY)), t + 2_200 + RESTATED, |e| e.exported((t + 2_200 + RESTATED) * 1000, Some((2, 1)), t + 2_200 + RESTATED)).unwrap();
495        assert_eq!(all(&moved, &KEY).0.len(), 1, "the new folder has it at once");
496        let _ = std::fs::remove_dir_all(&dir);
497    }
498
499    #[test]
500    fn two_processes_never_undo_each_others_session() {
501        let dir = scratch("processes");
502        let (folder, state) = (dir.join("shared"), dir.join("state"));
503        let own = own_path(&state);
504        let vault = Some((folder.as_path(), &KEY));
505        let id = uuid::Uuid::new_v4().to_string();
506        change(&own, vault, |e| {
507            e.id = id.clone();
508            e.start(1_000);
509        })
510        .unwrap();
511        // The window's process puts Sioul away (down); a background service then exports: still down.
512        change(&own, vault, |e| e.close(1_100)).unwrap();
513        change(&own, vault, |e| e.exported(1_200_000, Some((1, 9)), 1_201)).unwrap();
514        let (entries, _) = all(&folder, &KEY);
515        assert_eq!((entries[0].working, entries[0].closed, entries[0].exported), (false, 1_100, 1_200));
516        // Brought back (up); the service exports again: still up.
517        change(&own, vault, |e| e.start(1_300)).unwrap();
518        change(&own, vault, |e| e.exported(1_310_000, Some((1, 9)), 1_311)).unwrap();
519        assert!(all(&folder, &KEY).0[0].working);
520        // An exchange that did not run (busy) changes nothing.
521        change(&own, vault, |e| e.exported(0, None, 1_400)).unwrap();
522        assert_eq!(all(&folder, &KEY).0[0].exported, 1_310);
523        let _ = std::fs::remove_dir_all(&dir);
524    }
525
526    // ------------------------------------------------ two devices, one folder
527
528    /// A device of its own, in a scratch folder: its folders, its id, its memory.
529    struct Device {
530        roots: Roots,
531        id: String,
532        memory: PathBuf,
533        kind: &'static str,
534    }
535
536    impl Device {
537        fn new(base: &Path, name: &str, kind: &'static str) -> Device {
538            let root = base.join(name);
539            let roots = Roots { config: root.join("config"), data: root.join("data"), state: root.join("state") };
540            for dir in [&roots.config, &roots.data, &roots.state] {
541                std::fs::create_dir_all(dir).unwrap();
542            }
543            let memory = roots.state.join("share").join("memory.json");
544            Device { roots, id: uuid::Uuid::new_v4().to_string(), memory, kind }
545        }
546
547        fn stores(&self) -> Vec<Store> {
548            crate::share::stores(&Config::default(), &self.roots)
549        }
550
551        /// An exchange as `crate::share::exchange` makes it, then its entry
552        /// says so: what it read and wrote (`exported`).
553        fn exchange(&self, folder: &Path, now: i64) -> crate::share::Outcome {
554            let outcome = crate::share::exchange(&Sharing { folder, computer: &self.id, key: &KEY, memory: &self.memory, files: false, hurry: None }, &self.stores(), now * 1000).unwrap();
555            // The exchange's own clock read the files; a test's clock is `now`.
556            change(&own_path(&self.roots.state), Some((folder, &KEY)), |e| e.exported(now * 1000, outcome.wrote, now)).unwrap();
557            outcome
558        }
559
560        fn session(&self, folder: &Path, f: impl FnOnce(&mut Entry)) {
561            let kind = self.kind;
562            let id = self.id.clone();
563            change(&own_path(&self.roots.state), Some((folder, &KEY)), |e| {
564                e.id = id;
565                e.kind = kind.into();
566                e.doses = true;
567                f(e);
568            })
569            .unwrap();
570        }
571
572        fn records(&self) -> PathBuf {
573            self.roots.state.join(sioul_core::doses::FILE)
574        }
575
576        fn marks(&self) -> PathBuf {
577            self.roots.state.join("health-state.toml")
578        }
579
580        /// The person answers here, as the window or a receiver records it: the
581        /// answer in the records, the mark beside it for an older Sioul.
582        fn answer(&self, key: &str, state: &str, at: i64) {
583            DoseRecords::update(&self.records(), at, |r| r.answer(key, &self.id, Answer { state: state.into(), at, noted: at, name: self.kind.into(), kind: self.kind.into() })).unwrap();
584            HealthState::update(&self.marks(), at, |s| {
585                if state == TAKEN {
586                    s.taken.insert(key.to_string(), at);
587                } else {
588                    s.not_taken.insert(key.to_string(), at);
589                }
590            })
591            .unwrap();
592        }
593
594        /// What this device knows of `other`, as the window's `know` builds it:
595        /// its entry, and whether everything it wrote up to it is read here.
596        fn sees(&self, folder: &Path, other: &Device, now: i64) -> Peer {
597            let entry = all(folder, &KEY).0.into_iter().find(|e| e.id == other.id).expect("its entry");
598            let heard = crate::share::heard(&self.memory, &self.id);
599            Peer { id: other.id.clone(), name: entry.name.clone(), said: Some(entry.said()), complete: entry.wrote.is_none_or(|wrote| heard.complete(&other.id, wrote)), seen: now, heard: entry.exported, ..Peer::default() }
600        }
601
602        fn answered(&self, key: &str) -> Answered {
603            sioul_core::doses::answered(key, &HealthState::read(&self.marks()).unwrap(), &DoseRecords::read(&self.records()).unwrap())
604        }
605    }
606
607    const DUE: i64 = 1_800_000_000;
608    const DOSE: &str = "levo@1800000000";
609
610    /// Two devices sharing one folder, each joined.
611    fn pair(name: &str) -> (PathBuf, PathBuf, Device, Device) {
612        let base = scratch(name);
613        let folder = base.join("shared");
614        std::fs::create_dir_all(&folder).unwrap();
615        let desk = Device::new(&base, "desk", COMPUTER);
616        let phone = Device::new(&base, "phone", PHONE);
617        desk.session(&folder, |e| e.start(DUE - 7_200));
618        phone.session(&folder, |e| e.start(DUE - 7_200));
619        desk.exchange(&folder, DUE - 7_100);
620        phone.exchange(&folder, DUE - 7_090);
621        desk.exchange(&folder, DUE - 7_080);
622        (base, folder, desk, phone)
623    }
624
625    #[test]
626    fn the_phone_marks_taken_in_the_background_and_the_desktop_shows_taken() {
627        let (base, folder, desk, phone) = pair("background");
628        // The phone put away at 07:00: its last export, then down.
629        phone.exchange(&folder, DUE - 3_600);
630        phone.session(&folder, |e| e.close(DUE - 3_595));
631        desk.exchange(&folder, DUE - 3_500);
632        // 08:00, before anything: the phone closed cleanly before the dose, read in full: known not taken.
633        let at_dose = desk.sees(&folder, &phone, DUE + 30);
634        assert!(doubts_now(DUE, DUE + 30, None, &[at_dose]).is_empty());
635        assert_eq!(desk.answered(DOSE), Answered::Open);
636        // 08:05, "Taken" pressed on the phone's notification, Sioul in the background:
637        // the receiver raises its session, records, exports, then lowers it.
638        phone.session(&folder, |e| e.start(DUE + 300));
639        phone.answer(DOSE, TAKEN, DUE + 300);
640        phone.exchange(&folder, DUE + 302);
641        phone.session(&folder, |e| e.close(DUE + 303));
642        // The desktop reads the folder: taken, on the phone, at 08:05; nothing in doubt.
643        desk.exchange(&folder, DUE + 360);
644        match desk.answered(DOSE) {
645            Answered::Taken(given) => assert_eq!((given.device.as_str(), given.at), (phone.id.as_str(), DUE + 300)),
646            other => panic!("{other:?}"),
647        }
648        assert!(doubts_now(DUE, DUE + 360, None, &[desk.sees(&folder, &phone, DUE + 360)]).is_empty());
649        // The old mark came too, for an older Sioul.
650        assert!(HealthState::read(&desk.marks()).unwrap().taken.contains_key(DOSE));
651        let _ = std::fs::remove_dir_all(&base);
652    }
653
654    /// The phone put away, its background step exchanging every few minutes
655    /// and finding nothing new: its entry in the folder stays as it was, and
656    /// the desktop knows a dose due since as surely as before (closed after its
657    /// last export, everything it wrote read), hours later too. "Taken"
658    /// pressed on its reminder in the background goes at once, as before:
659    /// raised, recorded, exported, lowered, each in the folder.
660    #[test]
661    fn the_phone_put_away_and_quiet_is_known_without_rewriting_its_entry() {
662        let (base, folder, desk, phone) = pair("quiet-phone");
663        phone.exchange(&folder, DUE - 3_600);
664        phone.session(&folder, |e| e.close(DUE - 3_595));
665        let entry = std::fs::read(file_of(&folder, &phone.id)).unwrap();
666        for n in 0..12 {
667            phone.exchange(&folder, DUE - 3_500 + n * 300);
668            desk.exchange(&folder, DUE - 3_490 + n * 300);
669        }
670        assert_eq!(std::fs::read(file_of(&folder, &phone.id)).unwrap(), entry, "nothing new: its entry as it was");
671        for at in [DUE + 30, DUE + 3_600, DUE + 6 * 3_600] {
672            assert!(doubts_now(DUE, at, None, &[desk.sees(&folder, &phone, at)]).is_empty(), "known at {at}");
673        }
674        // Raised by a reminder: in the folder at once; the desk doubts until its export is read.
675        phone.session(&folder, |e| e.start(DUE + 300));
676        assert!(matches!(doubts_now(DUE, DUE + 310, None, &[desk.sees(&folder, &phone, DUE + 310)]).as_slice(), [Doubt::Working { .. }]));
677        phone.answer(DOSE, TAKEN, DUE + 300);
678        phone.exchange(&folder, DUE + 302);
679        phone.session(&folder, |e| e.close(DUE + 303));
680        desk.exchange(&folder, DUE + 360);
681        assert!(matches!(desk.answered(DOSE), Answered::Taken(_)));
682        assert!(doubts_now(DUE, DUE + 360, None, &[desk.sees(&folder, &phone, DUE + 360)]).is_empty());
683        let _ = std::fs::remove_dir_all(&base);
684    }
685
686    /// What the desktop knows of the phone with `seen` as `health::know` keeps
687    /// it (when the phone's entry was last seen changing: its session, its
688    /// export), not the moment it looks (review of battery part B, F7). Put
689    /// away and quiet for hours, its entry unchanged: `seen` hours old, the
690    /// dose known all the same (a closed device's knowing never reads it). In
691    /// use, exporting every minute: known after the dose while it goes on;
692    /// stopped without closing (a crash), its entry still: the doubt said once
693    /// `FRESH` has passed.
694    #[test]
695    fn a_dose_is_known_with_seen_as_the_desktop_keeps_it() {
696        let (base, folder, desk, phone) = pair("seen-kept");
697        let mut watched: Option<((i64, i64, bool, i64, bool), i64)> = None;
698        let mut sees = |at: i64| -> Peer {
699            let entry = all(&folder, &KEY).0.into_iter().find(|e| e.id == phone.id).expect("its entry");
700            let state = (entry.started, entry.closed, entry.working, entry.exported, entry.left);
701            if watched.is_none_or(|(last, _)| last != state) {
702                watched = Some((state, at));
703            }
704            Peer { seen: watched.map_or(at, |(_, seen)| seen), ..desk.sees(&folder, &phone, at) }
705        };
706        phone.exchange(&folder, DUE - 3_600);
707        phone.session(&folder, |e| e.close(DUE - 3_595));
708        desk.exchange(&folder, DUE - 3_590);
709        let closed_seen = sees(DUE - 3_590).seen;
710        for n in 0..12 {
711            phone.exchange(&folder, DUE - 3_500 + n * 300);
712            desk.exchange(&folder, DUE - 3_490 + n * 300);
713            assert_eq!(sees(DUE - 3_490 + n * 300).seen, closed_seen, "put away and quiet: its entry not seen changing");
714        }
715        for at in [DUE + 30, DUE + 3_600, DUE + 6 * 3_600] {
716            let peer = sees(at);
717            assert!(at - peer.seen > sioul_core::health::FRESH);
718            assert!(doubts_now(DUE, at, None, &[peer]).is_empty(), "closed, its seen {} s old: known at {at}", at - closed_seen);
719        }
720        // In use from DUE + 400, exporting every minute: seen follows, the dose known after it.
721        let later = DUE + 7 * 3_600;
722        phone.session(&folder, |e| e.start(later));
723        for n in 1..=5 {
724            phone.exchange(&folder, later + n * 60);
725            desk.exchange(&folder, later + n * 60 + 5);
726            assert!(doubts_now(later, later + n * 60 + 10, None, &[sees(later + n * 60 + 10)]).is_empty() || n * 60 < sioul_core::health::SKEW, "in use, exporting: known at minute {n}");
727        }
728        // Stopped without closing (a crash): its entry still, the doubt said once FRESH has passed.
729        let stopped = later + 5 * 60 + 10 + sioul_core::health::FRESH + 60;
730        desk.exchange(&folder, stopped - 5);
731        assert!(matches!(doubts_now(later, stopped, None, &[sees(stopped)]).as_slice(), [Doubt::Working { .. }]), "a phone in use gone quiet: the doubt said");
732        let _ = std::fs::remove_dir_all(&base);
733    }
734
735    #[test]
736    fn the_phone_raised_but_not_exported_is_uncertain_and_its_records_behind_its_entry_too() {
737        let (base, folder, desk, phone) = pair("raised");
738        // The phone's receiver raised its session and recorded, then was stopped before its export.
739        phone.session(&folder, |e| e.start(DUE + 300));
740        phone.answer(DOSE, TAKEN, DUE + 300);
741        desk.exchange(&folder, DUE + 400);
742        assert_eq!(desk.answered(DOSE), Answered::Open, "its answer not exported");
743        let doubts = doubts_now(DUE, DUE + 400, None, &[desk.sees(&folder, &phone, DUE + 400)]);
744        assert!(matches!(doubts.as_slice(), [Doubt::Working { device, .. }] if device.id == phone.id), "{doubts:?}");
745        // Its export done, its entry in the folder, but its new records only half arrived here (the
746        // sync brought its entry first): what it last shared is coming, not known.
747        let rounds = || -> Vec<PathBuf> { std::fs::read_dir(&folder).unwrap().filter_map(Result::ok).map(|e| e.path()).filter(|p| p.file_name().unwrap().to_string_lossy().starts_with(&phone.id) && p.extension().is_some_and(|x| x == "jsonl")).collect() };
748        let before: Vec<(PathBuf, usize)> = rounds().into_iter().map(|p| (p.clone(), std::fs::read(&p).unwrap().len())).collect();
749        phone.exchange(&folder, DUE + 500);
750        phone.session(&folder, |e| e.close(DUE + 501));
751        let held: Vec<(PathBuf, Vec<u8>)> = rounds().into_iter().map(|p| (p.clone(), std::fs::read(&p).unwrap())).collect();
752        for (path, bytes) in &held {
753            let had = before.iter().find(|(p, _)| p == path).map_or(0, |(_, size)| *size);
754            std::fs::write(path, &bytes[..had + (bytes.len() - had) / 2]).unwrap();
755        }
756        desk.exchange(&folder, DUE + 510);
757        let behind = desk.sees(&folder, &phone, DUE + 520);
758        assert!(!behind.complete);
759        assert!(matches!(doubts_now(DUE, DUE + 520, None, &[behind]).as_slice(), [Doubt::Coming { .. }]));
760        for (path, bytes) in &held {
761            std::fs::write(path, bytes).unwrap();
762        }
763        desk.exchange(&folder, DUE + 560);
764        assert!(matches!(desk.answered(DOSE), Answered::Taken(_)));
765        assert!(doubts_now(DUE, DUE + 560, None, &[desk.sees(&folder, &phone, DUE + 560)]).is_empty());
766        let _ = std::fs::remove_dir_all(&base);
767    }
768
769    #[test]
770    fn the_same_dose_answered_on_both_devices_keeps_both() {
771        let (base, folder, desk, phone) = pair("both");
772        // Taken on the phone at 08:02 (its sync slow), skipped on the desktop at 08:10.
773        phone.answer(DOSE, TAKEN, DUE + 120);
774        desk.answer(DOSE, SKIPPED, DUE + 600);
775        phone.exchange(&folder, DUE + 700);
776        desk.exchange(&folder, DUE + 710);
777        phone.exchange(&folder, DUE + 720);
778        // Both answers kept on both devices, both said; none picked.
779        for device in [&desk, &phone] {
780            match device.answered(DOSE) {
781                Answered::Differ(given) => {
782                    let said: Vec<(String, sioul_core::doses::State)> = given.iter().map(|g| (g.device.clone(), g.state.clone())).collect();
783                    assert_eq!(said, [(phone.id.clone(), sioul_core::doses::State::Taken), (desk.id.clone(), sioul_core::doses::State::Skipped)]);
784                }
785                other => panic!("{other:?}"),
786            }
787        }
788        // You choose on the desktop, seeing both: "taken". Settled everywhere, the phone's answer kept.
789        DoseRecords::update(&desk.records(), DUE + 800, |r| r.choose(DOSE, &desk.id, Answer { state: TAKEN.into(), at: DUE + 120, noted: DUE + 800, name: "desk".into(), kind: COMPUTER.into() })).unwrap();
790        // The marks beside, for an older Sioul, as the window settles them.
791        HealthState::update(&desk.marks(), DUE + 800, |s| {
792            s.not_taken.remove(DOSE);
793            s.taken.insert(DOSE.into(), DUE + 120);
794        })
795        .unwrap();
796        desk.exchange(&folder, DUE + 810);
797        phone.exchange(&folder, DUE + 820);
798        assert!(matches!(phone.answered(DOSE), Answered::Taken(ref g) if g.device == phone.id));
799        let _ = std::fs::remove_dir_all(&base);
800    }
801
802    #[test]
803    fn a_choice_or_a_take_back_on_one_device_settles_both() {
804        let (base, folder, desk, phone) = pair("settle");
805        // Taken on the phone, skipped on the desktop: both said on both devices.
806        phone.answer(DOSE, TAKEN, DUE + 120);
807        desk.answer(DOSE, SKIPPED, DUE + 600);
808        phone.exchange(&folder, DUE + 700);
809        desk.exchange(&folder, DUE + 710);
810        phone.exchange(&folder, DUE + 720);
811        assert!(matches!(phone.answered(DOSE), Answered::Differ(_)));
812        // You choose "not taken" on the desktop: the phone's "taken" goes, on purpose, on both devices.
813        DoseRecords::update(&desk.records(), DUE + 800, |r| r.choose(DOSE, &desk.id, Answer { state: SKIPPED.into(), at: DUE + 800, noted: DUE + 800, name: "desk".into(), kind: COMPUTER.into() })).unwrap();
814        HealthState::update(&desk.marks(), DUE + 800, |s| {
815            s.taken.remove(DOSE);
816            s.not_taken.insert(DOSE.into(), DUE + 800);
817        })
818        .unwrap();
819        desk.exchange(&folder, DUE + 810);
820        phone.exchange(&folder, DUE + 820);
821        desk.exchange(&folder, DUE + 830);
822        for device in [&desk, &phone] {
823            let answered = device.answered(DOSE);
824            assert!(matches!(answered, Answered::Skipped(ref g) if g.device == desk.id), "{answered:?}");
825        }
826        // Another dose taken on the phone, taken back on the desktop: open again on both, never taken on one.
827        let iron = "iron@1800000000";
828        phone.answer(iron, TAKEN, DUE + 900);
829        phone.exchange(&folder, DUE + 910);
830        desk.exchange(&folder, DUE + 920);
831        assert!(matches!(desk.answered(iron), Answered::Taken(_)));
832        DoseRecords::update(&desk.records(), DUE + 1_000, |r| r.take_back(iron)).unwrap();
833        HealthState::update(&desk.marks(), DUE + 1_000, |s| {
834            s.taken.remove(iron);
835        })
836        .unwrap();
837        desk.exchange(&folder, DUE + 1_010);
838        phone.exchange(&folder, DUE + 1_020);
839        desk.exchange(&folder, DUE + 1_030);
840        for device in [&desk, &phone] {
841            assert_eq!(device.answered(iron), Answered::Open);
842        }
843        let _ = std::fs::remove_dir_all(&base);
844    }
845
846    #[test]
847    fn a_canary_opened_on_two_devices_merges_into_one_record() {
848        let (base, folder, desk, phone) = pair("canary");
849        DoseRecords::update(&desk.records(), DUE + 30, |r| r.open(DOSE, &desk.id, DUE + 30)).unwrap();
850        DoseRecords::update(&phone.records(), DUE + 20, |r| r.open(DOSE, &phone.id, DUE + 20)).unwrap();
851        desk.exchange(&folder, DUE + 60);
852        phone.exchange(&folder, DUE + 70);
853        desk.exchange(&folder, DUE + 80);
854        for device in [&desk, &phone] {
855            let records = DoseRecords::read(&device.records()).unwrap();
856            assert_eq!(records.dose.len(), 1);
857            assert_eq!(records.dose[DOSE].opened.len(), 2, "both openings kept");
858            assert_eq!(records.opened_first(DOSE), Some((phone.id.clone(), DUE + 20)));
859            assert_eq!(device.answered(DOSE), Answered::Open, "not taken yet: the canary");
860        }
861        // An answer on one device lifts it on both; nothing taken out by the merge.
862        phone.answer(DOSE, TAKEN, DUE + 90);
863        phone.exchange(&folder, DUE + 100);
864        desk.exchange(&folder, DUE + 110);
865        assert!(matches!(desk.answered(DOSE), Answered::Taken(_)));
866        assert_eq!(DoseRecords::read(&desk.records()).unwrap().dose[DOSE].opened.len(), 2);
867        let _ = std::fs::remove_dir_all(&base);
868    }
869
870    #[test]
871    fn an_older_sioul_takes_nothing_out_and_its_marks_still_count() {
872        let base = scratch("older");
873        let folder = base.join("shared");
874        std::fs::create_dir_all(&folder).unwrap();
875        let desk = Device::new(&base, "desk", COMPUTER);
876        let old = Device::new(&base, "old", PHONE);
877        // The older Sioul: no records file among its stores, no entry in the registry.
878        let old_stores = || old.stores().into_iter().filter(|s| s.name != "state/health-doses.toml").collect::<Vec<_>>();
879        let old_exchange = |now: i64| crate::share::exchange(&Sharing { folder: &folder, computer: &old.id, key: &KEY, memory: &old.memory, files: false, hurry: None }, &old_stores(), now * 1000).unwrap();
880        desk.session(&folder, |e| e.start(DUE - 3_600));
881        desk.exchange(&folder, DUE - 3_500);
882        old_exchange(DUE - 3_400);
883        // The desktop opens and answers in its records; the older one exchanges again and again.
884        DoseRecords::update(&desk.records(), DUE + 30, |r| r.open(DOSE, &desk.id, DUE + 30)).unwrap();
885        desk.answer("iron@1800000000", SKIPPED, DUE + 60);
886        desk.exchange(&folder, DUE + 90);
887        for n in 0..3 {
888            old_exchange(DUE + 100 + n);
889        }
890        desk.exchange(&folder, DUE + 200);
891        let records = DoseRecords::read(&desk.records()).unwrap();
892        assert!(records.dose.contains_key(DOSE) && records.dose.contains_key("iron@1800000000"), "nothing taken out by the older one");
893        // Its own marks (health-state.toml) still read as answers on the desktop.
894        HealthState::update(&old.marks(), DUE + 300, |s| {
895            s.taken.insert(DOSE.into(), DUE + 300);
896        })
897        .unwrap();
898        old_exchange(DUE + 310);
899        desk.exchange(&folder, DUE + 320);
900        assert!(matches!(desk.answered(DOSE), Answered::Taken(ref g) if g.device.is_empty()));
901        // It wrote no entry: the desktop has none, and judges it by its claims, as before (never closed).
902        assert!(all(&folder, &KEY).0.iter().all(|e| e.id != old.id));
903        let _ = std::fs::remove_dir_all(&base);
904    }
905}