Skip to main content

sioul_app/
sites.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Sites kept in Sioul, for the window: the list, their notifications (shown
5//! at once for a site in real time, else kept for the Porch), the site a
6//! message announces, and logins filled from Bitwarden.
7//!
8//! Bitwarden is read here, by Sioul itself (`sioul_sync::bitwarden`): nothing
9//! to install, on every system. Your master password is asked when a login is
10//! wanted, used to open the vault and dropped; the logins stay in memory until
11//! the window closes or the vault is locked.
12
13use crate::backend::{QtThread, Shared, json, load_config, say, tr};
14use serde::Serialize;
15use sioul_core::sites::{self, Notice, Notices, Site};
16use sioul_sync::bitwarden;
17use std::sync::{Arc, Mutex};
18
19#[derive(Serialize)]
20struct SiteRow {
21    #[serde(flatten)]
22    site: Site,
23    /// It notified something not seen yet: a small dot, never a number.
24    news: bool,
25    /// What it is for fits the hours now; else it waits, folded under the others.
26    in_view: bool,
27    /// Its own icon, kept on this computer (a file's address); "" until fetched.
28    icon: String,
29}
30
31#[derive(Serialize)]
32struct PresetRow {
33    name: String,
34    url: String,
35    host: String,
36    /// One of `sites::TYPES`.
37    kind: String,
38    /// Its categories, in your words: "Banque", "Santé".
39    categories: Vec<String>,
40    area: String,
41    about: String,
42    announced_by: Vec<String>,
43    calls: Option<bool>,
44    /// Already in your sites (the same address).
45    kept: bool,
46}
47
48/// What the Add dialog offers: the countries (and their states or provinces),
49/// the types and categories to filter by, the country shown (`country` when
50/// given, else your language's region), and its sites with everyone's. Words
51/// only: no request goes out.
52pub(crate) fn presets(country: &str, region: &str) -> String {
53    let presets = sioul_core::presets::load();
54    let language = tr().text("qt-locale", None);
55    let lang = language.split(['_', '-']).next().unwrap_or("en").to_string();
56    let country = if country.is_empty() {
57        // "fr_CA" → Canada; the region is chosen by you.
58        let guess = language.split(['_', '-']).nth(1).unwrap_or("").to_uppercase();
59        if presets.countries.contains_key(&guess) { guess } else { String::new() }
60    } else if country == "-" {
61        String::new()
62    } else {
63        country.to_string()
64    };
65    let kept: Vec<String> = sites::sites(&load_config()).iter().map(|s| sites::place_of(&s.url)).collect();
66    let rows: Vec<PresetRow> = presets
67        .offered(&country, region)
68        .into_iter()
69        .map(|(p, _)| PresetRow {
70            host: sites::host_of(&p.url),
71            kept: kept.contains(&sites::place_of(&p.url)),
72            area: presets.area_of(p).id(),
73            about: sioul_core::presets::words(&p.about, &lang),
74            categories: presets.tag_words(p, &lang),
75            name: p.name.clone(),
76            url: p.url.clone(),
77            kind: sites::type_of(&p.kind).to_string(),
78            announced_by: p.announced_by.clone(),
79            calls: p.calls,
80        })
81        .collect();
82    let countries: Vec<serde_json::Value> = presets
83        .countries
84        .iter()
85        .map(|(code, c)| {
86            let regions: Vec<serde_json::Value> = c.regions.iter().map(|(r, region)| serde_json::json!({ "code": r, "name": sioul_core::presets::words(&region.name, &lang) })).collect();
87            serde_json::json!({ "code": code, "name": sioul_core::presets::words(&c.name, &lang), "regions": regions })
88        })
89        .collect();
90    // The categories the shown sites come with, in your words, by name.
91    let mut categories: Vec<String> = rows.iter().flat_map(|r| r.categories.iter().cloned()).collect();
92    categories.sort_by_key(|c| c.to_lowercase());
93    categories.dedup();
94    serde_json::json!({ "country": country, "region": region, "countries": countries, "types": sites::TYPES, "categories": categories, "sites": rows }).to_string()
95}
96
97/// The usual sites as a menu: each country (and everyone's), its groups
98/// (offices, banks, energy…) and its states or provinces apart, each with its
99/// own groups: `{"countries": [{"name", "groups": [{"name", "sites": [row]}],
100/// "regions_name", "regions": [{"name", "groups"}]}]}`.
101pub(crate) fn presets_tree() -> String {
102    let presets = sioul_core::presets::load();
103    let language = tr().text("qt-locale", None);
104    let lang = language.split(['_', '-']).next().unwrap_or("en").to_string();
105    let kept: Vec<String> = sites::sites(&load_config()).iter().map(|s| sites::place_of(&s.url)).collect();
106    let row = |p: &sioul_core::presets::Preset| PresetRow {
107        host: sites::host_of(&p.url),
108        kept: kept.contains(&sites::place_of(&p.url)),
109        area: presets.area_of(p).id(),
110        about: sioul_core::presets::words(&p.about, &lang),
111        categories: presets.tag_words(p, &lang),
112        name: p.name.clone(),
113        url: p.url.clone(),
114        kind: sites::type_of(&p.kind).to_string(),
115        announced_by: p.announced_by.clone(),
116        calls: p.calls,
117    };
118    // A personal space goes by its first category (offices, banks, energy…); the rest by type.
119    let group_of = |p: &sioul_core::presets::Preset| -> String {
120        let kind = sites::type_of(&p.kind);
121        if kind == "mailbox" {
122            presets.tag_words(p, &lang).into_iter().next().unwrap_or_else(|| tr().text("site-kind-mailbox", None))
123        } else {
124            tr().text(&format!("site-kind-{kind}"), None)
125        }
126    };
127    let groups = |list: &[sioul_core::presets::Preset]| -> Vec<serde_json::Value> {
128        let mut by: Vec<(String, Vec<PresetRow>)> = Vec::new();
129        for p in list {
130            let name = group_of(p);
131            match by.iter_mut().find(|(n, _)| *n == name) {
132                Some((_, rows)) => rows.push(row(p)),
133                None => by.push((name, vec![row(p)])),
134            }
135        }
136        by.into_iter().map(|(name, mut rows)| {
137            rows.sort_by_key(|r| sioul_core::text::fold(&r.name));
138            serde_json::json!({ "name": name, "sites": rows })
139        }).collect()
140    };
141    let mut countries: Vec<serde_json::Value> = presets
142        .countries
143        .values()
144        .map(|c| {
145            let mut regions: Vec<serde_json::Value> = c.regions.values().map(|r| serde_json::json!({ "name": sioul_core::presets::words(&r.name, &lang), "groups": groups(&r.sites) })).collect();
146            regions.sort_by_key(|r| sioul_core::text::fold(r["name"].as_str().unwrap_or("")));
147            let regions_name = if c.regions_name.is_empty() { tr().text("site-presets-by-region", None) } else { sioul_core::presets::words(&c.regions_name, &lang) };
148            serde_json::json!({ "name": sioul_core::presets::words(&c.name, &lang), "groups": groups(&c.sites), "regions_name": regions_name, "regions": regions })
149        })
150        .collect();
151    countries.sort_by_key(|c| sioul_core::text::fold(c["name"].as_str().unwrap_or("")));
152    countries.push(serde_json::json!({ "name": tr().text("site-presets-everyone", None), "groups": groups(&presets.international), "regions_name": "", "regions": [] }));
153    serde_json::json!({ "countries": countries }).to_string()
154}
155
156/// Several usual sites pinned at once (a JSON list of the tree's rows): those
157/// already here left as they are. Returns {"added", "error"}.
158pub(crate) fn add_many(rows: &str) -> String {
159    let rows: Vec<serde_json::Value> = serde_json::from_str(rows).unwrap_or_default();
160    let mut added = 0;
161    let mut problems = Vec::new();
162    for row in rows {
163        let kept: Vec<String> = sites::sites(&load_config()).iter().map(|s| sites::place_of(&s.url)).collect();
164        let url = row.get("url").and_then(|u| u.as_str()).unwrap_or("");
165        if kept.contains(&sites::place_of(url)) {
166            continue;
167        }
168        let made: serde_json::Value = serde_json::from_str(&add(&row.to_string())).unwrap_or_default();
169        match made.get("error").and_then(|e| e.as_str()) {
170            Some(e) => problems.push(e.to_string()),
171            None => added += 1,
172        }
173    }
174    problems.dedup();
175    serde_json::json!({ "added": added, "error": problems.join(" ") }).to_string()
176}
177
178/// A site added as the Add dialog gives it ({"name", "url", "kind", "area",
179/// "announced_by", "categories"}): its id made from its name, another number
180/// when taken. Returns {"id"} or {"error"}.
181pub(crate) fn add(edit: &str) -> String {
182    let error = |text: String| serde_json::json!({ "error": text }).to_string();
183    let edit: serde_json::Value = serde_json::from_str(edit).unwrap_or_default();
184    let text = |k: &str| edit.get(k).and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
185    let (name, url) = (text("name"), text("url"));
186    // Only an encrypted address: a site's sign-in never travels in clear.
187    if name.is_empty() || !url.starts_with("https://") || sites::host_of(&url).is_empty() {
188        return error(tr().text("site-https-only", None));
189    }
190    let config = load_config();
191    let base = sioul_core::config::slug(&name);
192    let base = if base.is_empty() { "site".to_string() } else { base };
193    let id = (1..).map(|n| if n == 1 { base.clone() } else { format!("{base}-{n}") }).find(|id| config.site(id).is_none()).unwrap_or(base);
194    let list = |k: &str| -> Vec<String> { edit.get(k).and_then(|v| v.as_array()).into_iter().flatten().filter_map(|d| d.as_str().map(str::to_string)).collect() };
195    match sioul_core::config::add_site_as(&crate::backend::config_path(), &id, &name, &url, &text("kind"), &text("area"), &list("announced_by"), &list("categories")) {
196        Ok(()) => serde_json::json!({ "id": id }).to_string(),
197        Err(e) => error(e),
198    }
199}
200
201/// A site moved one place up (-1) or down (1) in the list; returns what went wrong, else "".
202pub(crate) fn move_site(id: &str, delta: i64) -> String {
203    // The order travels as its own setting once each site travels apart (format 2 of the sharing).
204    sioul_core::config::move_site(&crate::backend::config_path(), id, delta, crate::share::writes_format_2()).err().unwrap_or_default()
205}
206
207/// A site taken out of Sioul; its sign-ins stay in its profile until cleared. Returns what went wrong, else "".
208pub(crate) fn remove(id: &str) -> String {
209    sioul_core::config::remove_site(&crate::backend::config_path(), id).err().unwrap_or_default()
210}
211
212/// Where sites' icons are kept.
213fn favicons() -> std::path::PathBuf {
214    sioul_core::config::cache_dir().join("favicons")
215}
216
217/// One fetching of icons at a time; the hosts that had none, and when they were asked.
218static FETCHING: Mutex<()> = Mutex::new(());
219static NO_ICON: Mutex<Vec<(String, i64)>> = Mutex::new(Vec::new());
220
221/// Your sites' own icons, asked of each site when missing or a week old, off
222/// the window's thread; the list shows them once there. A site that had none
223/// is asked again the next day.
224pub(crate) fn favicon_tick(qt: &QtThread) {
225    if crate::backend::offline() {
226        return;
227    }
228    let qt = qt.clone();
229    std::thread::spawn(move || {
230        let Some(_busy) = crate::backend::one_at_a_time(&FETCHING) else { return };
231        let dir = favicons();
232        let now = jiff::Timestamp::now().as_second();
233        let mut fetched = false;
234        for site in sites::sites(&load_config()) {
235            let host = sites::host_of(&site.url);
236            let tried = NO_ICON.lock().map(|n| n.iter().any(|(h, at)| *h == host && now - at < 24 * 3600)).unwrap_or(false);
237            if host.is_empty() || tried || !sioul_sync::favicon::stale(&dir, &host) {
238                continue;
239            }
240            match sioul_sync::favicon::fetch(&site.url, &dir) {
241                Ok(_) => fetched = true,
242                Err(_) => {
243                    if let Ok(mut none) = NO_ICON.lock() {
244                        none.retain(|(h, _)| *h != host);
245                        none.push((host, now));
246                    }
247                }
248            }
249        }
250        if fetched {
251            let _ = qt.queue(|mut sioul| sioul.as_mut().sites_changed());
252        }
253    });
254}
255
256/// The last gathered notification sent: the time it was for (Unix seconds).
257static GATHERED: Mutex<i64> = Mutex::new(0);
258
259/// What sites notified, gathered in one notification at the times you set
260/// (docs/sites.md, "Notifications"): only the sites of these hours, not
261/// silenced, on the computer you are at. A site in real time, and a call,
262/// came at once already; the rest waits for the Porch as before. A gathered
263/// time the matrix of what reaches you holds (as usual: sleep, a pause, Free
264/// time, a slot of time for you, do-not-disturb) is skipped: what the sites
265/// said waits for the next one.
266pub(crate) fn gather_tick(qt: &QtThread, shared: &Arc<Shared>) {
267    let config = load_config();
268    if !config.reminders.gather || crate::hours::level(sioul_core::attention::Kind::Sites) != sioul_core::attention::Level::Gathered {
269        return;
270    }
271    let now = jiff::Zoned::now();
272    // The time just passed, within two minutes: a window closed then misses it, the Porch keeps all.
273    let due = config
274        .reminders
275        .gathered_times()
276        .iter()
277        .filter_map(|t| {
278            let (h, m) = t.trim().split_once(':')?;
279            now.date().at(h.parse().ok()?, m.parse().ok()?, 0, 0).to_zoned(now.time_zone().clone()).ok()
280        })
281        .filter(|at| at.timestamp() <= now.timestamp() && now.timestamp().as_second() - at.timestamp().as_second() < 120)
282        .map(|at| at.timestamp().as_second())
283        .max();
284    let Some(due) = due else { return };
285    match GATHERED.lock() {
286        Ok(mut last) if *last != due => *last = due,
287        _ => return,
288    }
289    // Where you are: one computer gathers (`sioul_sync::lease`).
290    let active = shared.active.load(std::sync::atomic::Ordering::Relaxed);
291    let (keeper, _) = crate::share::keeper("notices", sioul_sync::lease::Rule::FollowsYou, active, false);
292    if !keeper.mine {
293        return;
294    }
295    let notices = Notices::load(&Notices::default_path());
296    let mode = crate::backend::mode_now();
297    let lines: Vec<String> = sites::sites(&config)
298        .iter()
299        .filter(|s| !s.muted && sioul_core::areas::in_view(s.area, mode.time, mode.week))
300        .filter_map(|s| {
301            let count = notices.of(&s.id).len();
302            (count > 0).then(|| format!("{} ({count})", s.name))
303        })
304        .collect();
305    if lines.is_empty() {
306        return;
307    }
308    let qt_open = qt.clone();
309    let open: Box<dyn FnOnce() + Send> = Box::new(move || {
310        let _ = qt_open.queue(|mut sioul| sioul.as_mut().reminder_opened(cxx_qt_lib::QString::from("porch"), cxx_qt_lib::QString::default(), cxx_qt_lib::QString::default()));
311    });
312    if let Err(e) = sioul_sync::notify::remind(&tr().text("sites-gathered", None), &lines.join(" · "), Some((tr().text("sites-gathered-open", None), open))) {
313        crate::backend::tell(qt, shared, e);
314    }
315}
316
317/// The sites, for their list. In quiet time, work sites keep their news to themselves.
318pub(crate) fn list() -> String {
319    let notices = Notices::load(&Notices::default_path());
320    let mode = crate::backend::mode_now();
321    let rows: Vec<SiteRow> = sites::sites(&load_config())
322        .into_iter()
323        .map(|site| {
324            let in_view = sioul_core::areas::in_view(site.area, mode.time, mode.week);
325            let icon = sioul_sync::favicon::cached(&favicons(), &sites::host_of(&site.url)).map(|p| crate::backend::file_url(&p)).unwrap_or_default();
326            SiteRow { news: in_view && !notices.of(&site.id).is_empty(), in_view, icon, site }
327        })
328        .collect();
329    json(&rows)
330}
331
332/// A site's notification: shown at once when the site is in real time (or
333/// you asked for real time everywhere), else kept for the Porch. In quiet
334/// time, a work site's waits for work to come back; at the times the matrix
335/// of what reaches you holds them (as usual: sleep, a pause, Free time,
336/// do-not-disturb, and but for a call a slot of time for you), every site's.
337/// A chat site's that names someone your cards know follows their Messages
338/// row too, which may only hold more (docs/attention.md, Q13): a blocked
339/// person's is not even kept.
340pub(crate) fn notified(qt: &QtThread, shared: &Arc<Shared>, id: &str, title: &str, text: &str) {
341    use sioul_core::attention::{Event, Level, Source};
342    let config = load_config();
343    let Some(site) = sites::sites(&config).into_iter().find(|s| s.id == id) else { return };
344    let everywhere = shared.realtime.load(std::sync::atomic::Ordering::Relaxed);
345    // A call waits for nobody: shown at once, unless the site is silenced or
346    // resting, or the matrix holds calls in sites now.
347    let call = sites::is_call(title, text) && !site.muted;
348    let chat = site.kind == "chat";
349    let named = if chat { sioul_core::porch::Senders::load(&config).judge_name(title.trim()).map(|j| j.who) } else { None };
350    let mut now = crate::hours::attention_now();
351    now.realtime = everywhere;
352    now.holds.chats = chat && crate::health::chats_covered();
353    let out = crate::hours::attention().decide(&Event::of(Source::Site { call, live: site.realtime, named }).for_area(Some(site.area)), &now);
354    if out.level == Level::Never {
355        return;
356    }
357    if (site.realtime || everywhere || call) && out.told {
358        let heading = format!("{} · {}", site.name, title);
359        if let Err(e) = sioul_sync::notify::code(&heading, text, None) {
360            crate::backend::tell(qt, shared, e);
361        }
362        return;
363    }
364    let path = Notices::default_path();
365    let mut notices = Notices::load(&path);
366    notices.add(Notice { site: site.id, title: title.trim().to_string(), text: text.trim().chars().take(300).collect(), at: jiff::Timestamp::now().as_second() });
367    if let Err(e) = notices.save(&path) {
368        crate::backend::tell(qt, shared, e);
369    }
370    let _ = qt.queue(|mut sioul| sioul.as_mut().sites_changed());
371}
372
373/// A site opened: what it notified is seen.
374pub(crate) fn seen(qt: &QtThread, id: &str) {
375    let path = Notices::default_path();
376    let mut notices = Notices::load(&path);
377    if !notices.of(id).is_empty() {
378        notices.seen(id);
379        let _ = notices.save(&path);
380        let _ = qt.queue(|mut sioul| sioul.as_mut().sites_changed());
381    }
382}
383
384#[derive(Serialize)]
385struct Waiting {
386    id: String,
387    name: String,
388    items: Vec<String>,
389}
390
391/// What the sites notified, for the Porch: each site with its lines, oldest
392/// first; in quiet time, only your own sites'.
393pub(crate) fn waiting() -> String {
394    let notices = Notices::load(&Notices::default_path());
395    let mode = crate::backend::mode_now();
396    let rows: Vec<Waiting> = sites::sites(&load_config())
397        .into_iter()
398        .filter(|site| sioul_core::areas::in_view(site.area, mode.time, mode.week))
399        .filter_map(|site| {
400            let items: Vec<String> = notices.of(&site.id).into_iter().map(|n| if n.text.is_empty() { n.title.clone() } else { say("site-news-line", &[("title", n.title.clone()), ("text", n.text.clone())]) }).collect();
401            (!items.is_empty()).then(|| Waiting { id: site.id, name: site.name, items })
402        })
403        .collect();
404    json(&rows)
405}
406
407/// The site a message's sender announces: {"id", "name"}, else "".
408pub(crate) fn announced_by(sender: &str) -> String {
409    let all = sites::sites(&load_config());
410    sites::announcing(&all, sender).map(|s| serde_json::json!({ "id": s.id, "name": s.name }).to_string()).unwrap_or_default()
411}
412
413// Bitwarden, read here: no external program (sioul_sync::bitwarden).
414
415/// The vault, open for the session: its logins, in memory only (wiped when
416/// dropped), with what each is searched by (`bitwarden::Logins`).
417pub(crate) struct Vault {
418    logins: bitwarden::Logins,
419}
420
421impl Vault {
422    fn of(items: Vec<bitwarden::Item>) -> Vault {
423        Vault { logins: bitwarden::Logins::new(items) }
424    }
425}
426
427/// This computer, as Bitwarden knows it.
428fn device() -> String {
429    bitwarden::device_id(&sioul_core::config::state_dir().join("bitwarden-device"))
430}
431
432fn remembered(email: &str) -> String {
433    format!("Bitwarden device token for {}", email.trim().to_lowercase())
434}
435
436/// "missing" (no account set in Sites ⚙), "locked", "unlocked".
437pub(crate) fn bitwarden_state(shared: &Shared) -> String {
438    // A made-up vault for the window's tests, from a JSON list of logins (never in Sioul's builds).
439    #[cfg(feature = "insecure-test-tls")]
440    if let Some(path) = std::env::var_os("SIOUL_TEST_VAULT")
441        && let Ok(mut vault) = shared.bitwarden.lock()
442        && vault.is_none()
443    {
444        let list: Vec<serde_json::Value> = std::fs::read_to_string(path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
445        let items = list
446            .iter()
447            .map(|l| {
448                let text = |k: &str| l.get(k).and_then(|v| v.as_str()).unwrap_or_default().to_string();
449                let mut item = bitwarden::Item::default();
450                item.id = text("id");
451                item.name = text("name");
452                item.username = text("username");
453                item.password = text("password");
454                item.uris = l.get("uris").and_then(|u| u.as_array()).into_iter().flatten().filter_map(|u| u.as_str().map(str::to_string)).collect();
455                item
456            })
457            .collect();
458        *vault = Some(Vault::of(items));
459    }
460    if load_config().bitwarden.email.as_deref().is_none_or(|e| e.trim().is_empty()) {
461        return "missing".into();
462    }
463    if shared.bitwarden.lock().is_ok_and(|v| v.is_some()) { "unlocked".into() } else { "locked".into() }
464}
465
466/// Bitwarden's calls that wait for its server (an unlock, a passkey, the
467/// e-mail's code), made off the window's thread so that it never freezes
468/// meanwhile: a ticket at once, then `work`'s answer with
469/// `bitwarden_answered(ticket, answer)` on the window's thread. The dialog
470/// waits for its own ticket, saying so (VaultUnlock.qml).
471pub(crate) fn bitwarden_later(qt: &QtThread, work: impl FnOnce() -> String + Send + 'static) -> i32 {
472    static ASKED: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
473    let ticket = ASKED.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
474    let qt = qt.clone();
475    std::thread::spawn(move || {
476        let answer = work();
477        let _ = qt.queue(move |mut sioul| sioul.as_mut().bitwarden_answered(ticket, cxx_qt_lib::QString::from(&answer)));
478    });
479    ticket
480}
481
482/// Opens the vault with your master password, and the second step's code
483/// when one was asked (`provider` as Bitwarden numbers them; -1 for none,
484/// 100 for the code of a new device). Returns `{"ok"}`, `{"factor": [providers]}`,
485/// `{"new_device"}` or `{"error"}`. The password is never kept.
486pub(crate) fn bitwarden_unlock(shared: &Shared, password: &str, provider: i32, code: &str) -> String {
487    let config = load_config();
488    let Some(email) = config.bitwarden.email.clone().filter(|e| !e.trim().is_empty()) else { return serde_json::json!({ "error": tr().text("bitwarden-missing", None) }).to_string() };
489    let server = bitwarden::Server::of(config.bitwarden.server.as_deref().unwrap_or(""));
490    let device = device();
491    let token = sioul_sync::secret::named(&remembered(&email));
492    let factor = (0..100).contains(&provider).then(|| (u8::try_from(provider).unwrap_or(0), code));
493    let new_device = (provider == 100).then_some(code);
494    let unlocked = bitwarden::unlock(&server, &email, password, &device, factor, token.as_deref().filter(|_| factor.is_none()), new_device);
495    let unlocked = match unlocked {
496        // A token kept from before that is no longer good: asked again without it.
497        Err(bitwarden::Problem::SecondFactor(..)) if token.is_some() && factor.is_none() => bitwarden::unlock(&server, &email, password, &device, None, None, new_device),
498        other => other,
499    };
500    match unlocked.and_then(|u| bitwarden::sync(&server, &u).map(|items| (u, items))) {
501        Ok((unlocked, items)) => {
502            if let Some(token) = unlocked.remember.as_deref() {
503                let _ = sioul_sync::secret::save_named(&remembered(&email), token);
504            }
505            if let Ok(mut vault) = shared.bitwarden.lock() {
506                *vault = Some(Vault::of(items));
507            }
508            // The dialog proposes first what opened it last.
509            crate::work::set_view_flag(shared, "bitwarden-passkey", false);
510            serde_json::json!({ "ok": true }).to_string()
511        }
512        // The steps offered; for a security key, the vault's page and the script that ask it.
513        // The e-mail code is sent when that step is chosen (`bitwarden_send_code`).
514        Err(bitwarden::Problem::SecondFactor(providers, webauthn)) => {
515            let key = webauthn.map(|options| serde_json::json!({ "page": bitwarden::key_page(&server), "script": bitwarden::key_script(&options, false) }));
516            serde_json::json!({ "factor": providers, "key": key }).to_string()
517        }
518        Err(bitwarden::Problem::NewDevice) => serde_json::json!({ "new_device": true }).to_string(),
519        Err(bitwarden::Problem::Network(e) | bitwarden::Problem::Refused(e)) => serde_json::json!({ "error": e }).to_string(),
520        Err(bitwarden::Problem::WrongPassword | bitwarden::Problem::NoKeySecret | bitwarden::Problem::PasskeyNotForVault) => serde_json::json!({ "error": tr().text("bitwarden-refused", None) }).to_string(),
521    }
522}
523
524/// The token of a passkey login begun, until the key answers.
525static PASSKEY: Mutex<Option<String>> = Mutex::new(None);
526
527/// Bitwarden's passkey login begun: the vault's page where the key is asked,
528/// unseen, and the script that asks it there; {"page", "script"} or {"error"}.
529pub(crate) fn bitwarden_passkey_begin() -> String {
530    let config = load_config();
531    if config.bitwarden.email.as_deref().is_none_or(|e| e.trim().is_empty()) {
532        return serde_json::json!({ "error": tr().text("bitwarden-missing", None) }).to_string();
533    }
534    let server = bitwarden::Server::of(config.bitwarden.server.as_deref().unwrap_or(""));
535    match bitwarden::passkey_ask(&server) {
536        Ok(ask) => {
537            let script = bitwarden::key_script(&ask.options, true);
538            if let Ok(mut pending) = PASSKEY.lock() {
539                *pending = Some(ask.token);
540            }
541            serde_json::json!({ "page": bitwarden::key_page(&server), "script": script }).to_string()
542        }
543        Err(bitwarden::Problem::Network(e) | bitwarden::Problem::Refused(e)) => serde_json::json!({ "error": e }).to_string(),
544        Err(_) => serde_json::json!({ "error": tr().text("bitwarden-refused", None) }).to_string(),
545    }
546}
547
548/// The key's answer, from that page ({"response", "secret"} or {"error"}):
549/// the vault opened with it, for the account set here only. Answers as
550/// `bitwarden_unlock` does: {"ok"} or {"error"}.
551pub(crate) fn bitwarden_passkey(shared: &Shared, answer: &str) -> String {
552    let error = |text: String| serde_json::json!({ "error": text }).to_string();
553    let answer: serde_json::Value = serde_json::from_str(answer).unwrap_or_default();
554    let token = PASSKEY.lock().ok().and_then(|mut pending| pending.take());
555    if let Some(said) = answer.get("error").and_then(|e| e.as_str()) {
556        return error(if said == "not-allowed" { tr().text("bitwarden-key-not-allowed", None) } else { say("bitwarden-key-failed", &[("error", said.to_string())]) });
557    }
558    let (Some(token), Some(response)) = (token, answer.get("response")) else { return error(say("bitwarden-key-failed", &[("error", String::new())])) };
559    let config = load_config();
560    let Some(email) = config.bitwarden.email.clone().filter(|e| !e.trim().is_empty()) else { return error(tr().text("bitwarden-missing", None)) };
561    let server = bitwarden::Server::of(config.bitwarden.server.as_deref().unwrap_or(""));
562    let secret = answer.get("secret").and_then(|s| s.as_str()).unwrap_or("");
563    let opened = bitwarden::unlock_with_passkey(&server, &device(), &token, &response.to_string(), secret).and_then(|unlocked| {
564        // A passkey says whose vault it opens: only the account set in Sites ⚙.
565        if bitwarden::account(&unlocked).is_some_and(|account| account != email.trim().to_lowercase()) {
566            return Err(bitwarden::Problem::Refused(tr().text("bitwarden-passkey-other", None)));
567        }
568        bitwarden::sync(&server, &unlocked)
569    });
570    match opened {
571        Ok(items) => {
572            if let Ok(mut vault) = shared.bitwarden.lock() {
573                *vault = Some(Vault::of(items));
574            }
575            crate::work::set_view_flag(shared, "bitwarden-passkey", true);
576            serde_json::json!({ "ok": true }).to_string()
577        }
578        Err(bitwarden::Problem::NoKeySecret) => error(tr().text("bitwarden-passkey-no-secret", None)),
579        Err(bitwarden::Problem::PasskeyNotForVault) => error(tr().text("bitwarden-passkey-no-vault", None)),
580        Err(bitwarden::Problem::WrongPassword) => error(tr().text("bitwarden-passkey-mismatch", None)),
581        // Bitwarden's words when it does not know the key as a passkey of an account.
582        Err(bitwarden::Problem::Refused(e)) if e.to_lowercase().contains("invalid credential") => error(tr().text("bitwarden-passkey-unknown", None)),
583        Err(bitwarden::Problem::Network(e) | bitwarden::Problem::Refused(e)) => error(e),
584        Err(bitwarden::Problem::SecondFactor(..) | bitwarden::Problem::NewDevice) => error(tr().text("bitwarden-refused", None)),
585    }
586}
587
588/// Sends the e-mail step's code (when you choose that step); returns what went wrong, else "".
589pub(crate) fn bitwarden_send_code(password: &str) -> String {
590    let config = load_config();
591    let Some(email) = config.bitwarden.email.clone().filter(|e| !e.trim().is_empty()) else { return tr().text("bitwarden-missing", None) };
592    let server = bitwarden::Server::of(config.bitwarden.server.as_deref().unwrap_or(""));
593    match bitwarden::send_email_code(&server, &email, password, &device()) {
594        Ok(()) => String::new(),
595        Err(bitwarden::Problem::Network(e) | bitwarden::Problem::Refused(e)) => e,
596        Err(_) => tr().text("bitwarden-refused", None),
597    }
598}
599
600/// The login last chosen for each site (host → Bitwarden's item id): proposed first next time.
601fn chosen_path() -> std::path::PathBuf {
602    sioul_core::config::state_dir().join("bitwarden-chosen.json")
603}
604
605fn chosen() -> std::collections::BTreeMap<String, String> {
606    std::fs::read_to_string(chosen_path()).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default()
607}
608
609#[derive(Serialize)]
610struct Choice {
611    id: String,
612    name: String,
613    username: String,
614    /// Its site: the one that matched the search, else the page's, else its first.
615    site: String,
616    /// Made for another domain than the page's: said, so a look-alike site shows.
617    elsewhere: bool,
618}
619
620impl Choice {
621    fn of(found: bitwarden::Found<'_>) -> Choice {
622        Choice { id: found.item.id.clone(), name: found.item.name.clone(), username: found.item.username.clone(), site: found.site, elsewhere: !found.own }
623    }
624}
625
626/// How many logins the chooser lists; the others are counted, to narrow the search.
627const LISTED: usize = 50;
628
629fn locked() -> String {
630    serde_json::json!({ "error": tr().text("bitwarden-locked", None) }).to_string()
631}
632
633/// The chooser's search: the vault's logins by site and by user name, each
634/// optional, both matching when both are given (`bitwarden::Logins::find`);
635/// the one chosen last for `url` first, then the best matches, `url`'s own
636/// before others. Names, user names and sites only, never a password.
637/// {"found": [{"id", "name", "username", "site", "elsewhere"}], "more"} or {"error"}.
638fn bitwarden_logins(shared: &Shared, url: &str, site: &str, user: &str) -> String {
639    let Ok(vault) = shared.bitwarden.lock() else { return locked() };
640    let Some(vault) = vault.as_ref() else { return locked() };
641    let last = chosen().get(&sioul_core::sites::host_of(url)).cloned();
642    let (found, total) = vault.logins.find(site, user, url, last.as_deref(), LISTED);
643    let more = total - found.len();
644    let found: Vec<Choice> = found.into_iter().map(Choice::of).collect();
645    serde_json::json!({ "found": found, "more": more }).to_string()
646}
647
648/// Above this many logins, the chooser searches off the window's thread:
649/// 20,000 made-up logins took 1 to 5.5 ms a search on a desktop computer
650/// busy with builds (`a_large_vault_is_searched_quickly`), a phone being
651/// several times slower; below, at once, so the chooser opens filled.
652const LARGE: usize = 5_000;
653
654/// The chooser's search as the window asks it: the answer at once
655/// (`bitwarden_logins`); for a large vault {"later": n}, the answer brought
656/// by `bitwarden_found(n, …)` once searched off the window's thread.
657pub(crate) fn bitwarden_search(qt: &QtThread, shared: &Arc<Shared>, url: &str, site: &str, user: &str) -> String {
658    let large = shared.bitwarden.lock().is_ok_and(|vault| vault.as_ref().is_some_and(|vault| vault.logins.items().len() > LARGE));
659    if !large {
660        return bitwarden_logins(shared, url, site, user);
661    }
662    static ASKED: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
663    let ticket = ASKED.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
664    let (qt, shared, url, site, user) = (qt.clone(), Arc::clone(shared), url.to_string(), site.to_string(), user.to_string());
665    std::thread::spawn(move || {
666        let found = bitwarden_logins(&shared, &url, &site, &user);
667        let _ = qt.queue(move |mut sioul| sioul.as_mut().bitwarden_found(ticket, cxx_qt_lib::QString::from(&found)));
668    });
669    serde_json::json!({ "later": ticket }).to_string()
670}
671
672/// What the vault holds for an address, as the chooser opens on it and as
673/// "Fill the login" begins: {"domain": the address's registrable domain;
674/// "chosen": the domain of the login chosen last for it, "" when none; "only":
675/// its only login, filled at once unless another was chosen last for it, ""
676/// when there is a choice to make} or {"error"}.
677pub(crate) fn bitwarden_site(shared: &Shared, url: &str) -> String {
678    let Ok(vault) = shared.bitwarden.lock() else { return locked() };
679    let Some(vault) = vault.as_ref() else { return locked() };
680    let last = chosen().get(&sioul_core::sites::host_of(url)).cloned();
681    let opening = vault.logins.opening(url, last.as_deref());
682    serde_json::json!({ "domain": opening.domain, "chosen": opening.chosen, "only": opening.only.unwrap_or_default() }).to_string()
683}
684
685/// One login of the vault, for a site: {"name", "username", "password",
686/// "code"} (the one-time code now, when it keeps its secret), or {"error"};
687/// kept as the site's choice.
688pub(crate) fn bitwarden_login(shared: &Shared, url: &str, id: &str) -> String {
689    let error = |id: &str| serde_json::json!({ "error": tr().text(id, None) }).to_string();
690    let Ok(vault) = shared.bitwarden.lock() else { return error("bitwarden-locked") };
691    let Some(vault) = vault.as_ref() else { return error("bitwarden-locked") };
692    let Some(item) = vault.logins.items().iter().find(|item| item.id == id) else { return error("bitwarden-none") };
693    let host = sioul_core::sites::host_of(url);
694    if !host.is_empty() {
695        let mut all = chosen();
696        if all.get(&host).map(String::as_str) != Some(id) {
697            all.insert(host, id.to_string());
698            if let Ok(text) = serde_json::to_string_pretty(&all) {
699                let _ = std::fs::create_dir_all(sioul_core::config::state_dir());
700                let _ = std::fs::write(chosen_path(), text);
701            }
702        }
703    }
704    let code = if item.totp.is_empty() { String::new() } else { bitwarden::totp(&item.totp, jiff::Timestamp::now().as_second()).unwrap_or_default() };
705    serde_json::json!({ "name": item.name, "username": item.username, "password": item.password, "code": code }).to_string()
706}
707
708/// Locks the vault again: its logins leave memory.
709pub(crate) fn bitwarden_lock(shared: &Shared) {
710    if let Ok(mut vault) = shared.bitwarden.lock() {
711        *vault = None;
712    }
713}