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. A phone makes no such notice (docs/android.md,
266/// "Notifications"): what its sites said waits for the Porch.
267pub(crate) fn gather_tick(qt: &QtThread, shared: &Arc<Shared>) {
268    let config = load_config();
269    if cfg!(target_os = "android") || !config.reminders.gather || crate::hours::level(sioul_core::attention::Kind::Sites) != sioul_core::attention::Level::Gathered {
270        return;
271    }
272    let now = jiff::Zoned::now();
273    // The time just passed, within two minutes: a window closed then misses it, the Porch keeps all.
274    let due = config
275        .reminders
276        .gathered_times()
277        .iter()
278        .filter_map(|t| {
279            let (h, m) = t.trim().split_once(':')?;
280            now.date().at(h.parse().ok()?, m.parse().ok()?, 0, 0).to_zoned(now.time_zone().clone()).ok()
281        })
282        .filter(|at| at.timestamp() <= now.timestamp() && now.timestamp().as_second() - at.timestamp().as_second() < 120)
283        .map(|at| at.timestamp().as_second())
284        .max();
285    let Some(due) = due else { return };
286    match GATHERED.lock() {
287        Ok(mut last) if *last != due => *last = due,
288        _ => return,
289    }
290    // Where you are: one computer gathers (`sioul_sync::lease`).
291    let active = shared.active.load(std::sync::atomic::Ordering::Relaxed);
292    let (keeper, _) = crate::share::keeper("notices", sioul_sync::lease::Rule::FollowsYou, active, false);
293    if !keeper.mine {
294        return;
295    }
296    let notices = Notices::load(&Notices::default_path());
297    let mode = crate::backend::mode_now();
298    let lines: Vec<String> = sites::sites(&config)
299        .iter()
300        .filter(|s| !s.muted && sioul_core::areas::in_view(s.area, mode.time, mode.week))
301        .filter_map(|s| {
302            let count = notices.of(&s.id).len();
303            (count > 0).then(|| format!("{} ({count})", s.name))
304        })
305        .collect();
306    if lines.is_empty() {
307        return;
308    }
309    let qt_open = qt.clone();
310    let open: Box<dyn FnOnce() + Send> = Box::new(move || {
311        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()));
312    });
313    if let Err(e) = sioul_sync::notify::remind(&tr().text("sites-gathered", None), &lines.join(" · "), Some((tr().text("sites-gathered-open", None), open))) {
314        crate::backend::tell(qt, shared, e);
315    }
316}
317
318/// The sites, for their list. In quiet time, work sites keep their news to themselves.
319pub(crate) fn list() -> String {
320    let notices = Notices::load(&Notices::default_path());
321    let mode = crate::backend::mode_now();
322    let rows: Vec<SiteRow> = sites::sites(&load_config())
323        .into_iter()
324        .map(|site| {
325            let in_view = sioul_core::areas::in_view(site.area, mode.time, mode.week);
326            let icon = sioul_sync::favicon::cached(&favicons(), &sites::host_of(&site.url)).map(|p| crate::backend::file_url(&p)).unwrap_or_default();
327            SiteRow { news: in_view && !notices.of(&site.id).is_empty(), in_view, icon, site }
328        })
329        .collect();
330    json(&rows)
331}
332
333/// A site's notification: shown at once when the site is in real time (or
334/// you asked for real time everywhere), else kept for the Porch. In quiet
335/// time, a work site's waits for work to come back; at the times the matrix
336/// of what reaches you holds them (as usual: sleep, a pause, Free time,
337/// do-not-disturb, and but for a call a slot of time for you), every site's.
338/// A chat site's that names someone your cards know follows their Messages
339/// row too, which may only hold more (docs/attention.md, Q13): a blocked
340/// person's is not even kept.
341pub(crate) fn notified(qt: &QtThread, shared: &Arc<Shared>, id: &str, title: &str, text: &str) {
342    use sioul_core::attention::{Event, Level, Source};
343    let config = load_config();
344    let Some(site) = sites::sites(&config).into_iter().find(|s| s.id == id) else { return };
345    let everywhere = shared.realtime.load(std::sync::atomic::Ordering::Relaxed);
346    // A call waits for nobody: shown at once, unless the site is silenced or
347    // resting, or the matrix holds calls in sites now.
348    let call = sites::is_call(title, text) && !site.muted;
349    let chat = site.kind == "chat";
350    let named = if chat { sioul_core::porch::Senders::load(&config).judge_name(title.trim()).map(|j| j.who) } else { None };
351    let mut now = crate::hours::attention_now();
352    now.realtime = everywhere;
353    now.holds.chats = chat && crate::health::chats_covered();
354    let out = crate::hours::attention().decide(&Event::of(Source::Site { call, live: site.realtime, named }).for_area(Some(site.area)), &now);
355    if out.level == Level::Never {
356        return;
357    }
358    // A phone makes no notification of its own for a site yet (docs/android.md,
359    // "Notifications"): kept for the Porch, never told to the status line.
360    if (site.realtime || everywhere || call) && out.told && !cfg!(target_os = "android") {
361        let heading = format!("{} · {}", site.name, title);
362        if let Err(e) = sioul_sync::notify::code(&heading, text, None) {
363            crate::backend::tell(qt, shared, e);
364        }
365        return;
366    }
367    let path = Notices::default_path();
368    let mut notices = Notices::load(&path);
369    notices.add(Notice { site: site.id, title: title.trim().to_string(), text: text.trim().chars().take(300).collect(), at: jiff::Timestamp::now().as_second() });
370    if let Err(e) = notices.save(&path) {
371        crate::backend::tell(qt, shared, e);
372    }
373    let _ = qt.queue(|mut sioul| sioul.as_mut().sites_changed());
374}
375
376/// A site opened: what it notified is seen.
377pub(crate) fn seen(qt: &QtThread, id: &str) {
378    let path = Notices::default_path();
379    let mut notices = Notices::load(&path);
380    if !notices.of(id).is_empty() {
381        notices.seen(id);
382        let _ = notices.save(&path);
383        let _ = qt.queue(|mut sioul| sioul.as_mut().sites_changed());
384    }
385}
386
387#[derive(Serialize)]
388struct Waiting {
389    id: String,
390    name: String,
391    items: Vec<String>,
392}
393
394/// What the sites notified, for the Porch: each site with its lines, oldest
395/// first; in quiet time, only your own sites'.
396pub(crate) fn waiting() -> String {
397    let notices = Notices::load(&Notices::default_path());
398    let mode = crate::backend::mode_now();
399    let rows: Vec<Waiting> = sites::sites(&load_config())
400        .into_iter()
401        .filter(|site| sioul_core::areas::in_view(site.area, mode.time, mode.week))
402        .filter_map(|site| {
403            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();
404            (!items.is_empty()).then(|| Waiting { id: site.id, name: site.name, items })
405        })
406        .collect();
407    json(&rows)
408}
409
410/// The site a message's sender announces: {"id", "name"}, else "".
411pub(crate) fn announced_by(sender: &str) -> String {
412    let all = sites::sites(&load_config());
413    sites::announcing(&all, sender).map(|s| serde_json::json!({ "id": s.id, "name": s.name }).to_string()).unwrap_or_default()
414}
415
416// Bitwarden, read here: no external program (sioul_sync::bitwarden).
417
418/// The vault, open for the session: its logins, in memory only (wiped when
419/// dropped), with what each is searched by (`bitwarden::Logins`).
420pub(crate) struct Vault {
421    logins: bitwarden::Logins,
422}
423
424impl Vault {
425    fn of(items: Vec<bitwarden::Item>) -> Vault {
426        Vault { logins: bitwarden::Logins::new(items) }
427    }
428}
429
430/// This computer, as Bitwarden knows it.
431fn device() -> String {
432    bitwarden::device_id(&sioul_core::config::state_dir().join("bitwarden-device"))
433}
434
435fn remembered(email: &str) -> String {
436    format!("Bitwarden device token for {}", email.trim().to_lowercase())
437}
438
439/// "missing" (no account set in Sites ⚙), "locked", "unlocked".
440pub(crate) fn bitwarden_state(shared: &Shared) -> String {
441    // A made-up vault for the window's tests, from a JSON list of logins (never in Sioul's builds).
442    #[cfg(feature = "insecure-test-tls")]
443    if let Some(path) = std::env::var_os("SIOUL_TEST_VAULT")
444        && let Ok(mut vault) = shared.bitwarden.lock()
445        && vault.is_none()
446    {
447        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();
448        let items = list
449            .iter()
450            .map(|l| {
451                let text = |k: &str| l.get(k).and_then(|v| v.as_str()).unwrap_or_default().to_string();
452                let mut item = bitwarden::Item::default();
453                item.id = text("id");
454                item.name = text("name");
455                item.username = text("username");
456                item.password = text("password");
457                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();
458                item
459            })
460            .collect();
461        *vault = Some(Vault::of(items));
462    }
463    if load_config().bitwarden.email.as_deref().is_none_or(|e| e.trim().is_empty()) {
464        return "missing".into();
465    }
466    if shared.bitwarden.lock().is_ok_and(|v| v.is_some()) { "unlocked".into() } else { "locked".into() }
467}
468
469/// Bitwarden's calls that wait for its server (an unlock, a passkey, the
470/// e-mail's code), made off the window's thread so that it never freezes
471/// meanwhile: a ticket at once, then `work`'s answer with
472/// `bitwarden_answered(ticket, answer)` on the window's thread. The dialog
473/// waits for its own ticket, saying so (VaultUnlock.qml).
474pub(crate) fn bitwarden_later(qt: &QtThread, work: impl FnOnce() -> String + Send + 'static) -> i32 {
475    static ASKED: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
476    let ticket = ASKED.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
477    let qt = qt.clone();
478    std::thread::spawn(move || {
479        let answer = work();
480        let _ = qt.queue(move |mut sioul| sioul.as_mut().bitwarden_answered(ticket, cxx_qt_lib::QString::from(&answer)));
481    });
482    ticket
483}
484
485/// Opens the vault with your master password, and the second step's code
486/// when one was asked (`provider` as Bitwarden numbers them; -1 for none,
487/// 100 for the code of a new device). Returns `{"ok"}`, `{"factor": [providers]}`,
488/// `{"new_device"}` or `{"error"}`. The password is never kept.
489pub(crate) fn bitwarden_unlock(shared: &Shared, password: &str, provider: i32, code: &str) -> String {
490    let config = load_config();
491    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() };
492    let server = bitwarden::Server::of(config.bitwarden.server.as_deref().unwrap_or(""));
493    let device = device();
494    let token = sioul_sync::secret::named(&remembered(&email));
495    let factor = (0..100).contains(&provider).then(|| (u8::try_from(provider).unwrap_or(0), code));
496    let new_device = (provider == 100).then_some(code);
497    let unlocked = bitwarden::unlock(&server, &email, password, &device, factor, token.as_deref().filter(|_| factor.is_none()), new_device);
498    let unlocked = match unlocked {
499        // A token kept from before that is no longer good: asked again without it.
500        Err(bitwarden::Problem::SecondFactor(..)) if token.is_some() && factor.is_none() => bitwarden::unlock(&server, &email, password, &device, None, None, new_device),
501        other => other,
502    };
503    match unlocked.and_then(|u| bitwarden::sync(&server, &u).map(|items| (u, items))) {
504        Ok((unlocked, items)) => {
505            if let Some(token) = unlocked.remember.as_deref() {
506                let _ = sioul_sync::secret::save_named(&remembered(&email), token);
507            }
508            if let Ok(mut vault) = shared.bitwarden.lock() {
509                *vault = Some(Vault::of(items));
510            }
511            // The dialog proposes first what opened it last.
512            crate::work::set_view_flag(shared, "bitwarden-passkey", false);
513            serde_json::json!({ "ok": true }).to_string()
514        }
515        // The steps offered; for a security key, the vault's page and the script that ask it.
516        // The e-mail code is sent when that step is chosen (`bitwarden_send_code`).
517        Err(bitwarden::Problem::SecondFactor(providers, webauthn)) => {
518            let key = webauthn.map(|options| serde_json::json!({ "page": bitwarden::key_page(&server), "script": bitwarden::key_script(&options, false) }));
519            serde_json::json!({ "factor": providers, "key": key }).to_string()
520        }
521        Err(bitwarden::Problem::NewDevice) => serde_json::json!({ "new_device": true }).to_string(),
522        Err(bitwarden::Problem::Network(e) | bitwarden::Problem::Refused(e)) => serde_json::json!({ "error": e }).to_string(),
523        Err(bitwarden::Problem::WrongPassword | bitwarden::Problem::NoKeySecret | bitwarden::Problem::PasskeyNotForVault) => serde_json::json!({ "error": tr().text("bitwarden-refused", None) }).to_string(),
524    }
525}
526
527/// The token of a passkey login begun, until the key answers.
528static PASSKEY: Mutex<Option<String>> = Mutex::new(None);
529
530/// Bitwarden's passkey login begun: the vault's page where the key is asked,
531/// unseen, and the script that asks it there; {"page", "script"} or {"error"}.
532pub(crate) fn bitwarden_passkey_begin() -> String {
533    let config = load_config();
534    if config.bitwarden.email.as_deref().is_none_or(|e| e.trim().is_empty()) {
535        return serde_json::json!({ "error": tr().text("bitwarden-missing", None) }).to_string();
536    }
537    let server = bitwarden::Server::of(config.bitwarden.server.as_deref().unwrap_or(""));
538    match bitwarden::passkey_ask(&server) {
539        Ok(ask) => {
540            let script = bitwarden::key_script(&ask.options, true);
541            if let Ok(mut pending) = PASSKEY.lock() {
542                *pending = Some(ask.token);
543            }
544            serde_json::json!({ "page": bitwarden::key_page(&server), "script": script }).to_string()
545        }
546        Err(bitwarden::Problem::Network(e) | bitwarden::Problem::Refused(e)) => serde_json::json!({ "error": e }).to_string(),
547        Err(_) => serde_json::json!({ "error": tr().text("bitwarden-refused", None) }).to_string(),
548    }
549}
550
551/// The key's answer, from that page ({"response", "secret"} or {"error"}):
552/// the vault opened with it, for the account set here only. Answers as
553/// `bitwarden_unlock` does: {"ok"} or {"error"}.
554pub(crate) fn bitwarden_passkey(shared: &Shared, answer: &str) -> String {
555    let error = |text: String| serde_json::json!({ "error": text }).to_string();
556    let answer: serde_json::Value = serde_json::from_str(answer).unwrap_or_default();
557    let token = PASSKEY.lock().ok().and_then(|mut pending| pending.take());
558    if let Some(said) = answer.get("error").and_then(|e| e.as_str()) {
559        return error(if said == "not-allowed" { tr().text("bitwarden-key-not-allowed", None) } else { say("bitwarden-key-failed", &[("error", said.to_string())]) });
560    }
561    let (Some(token), Some(response)) = (token, answer.get("response")) else { return error(say("bitwarden-key-failed", &[("error", String::new())])) };
562    let config = load_config();
563    let Some(email) = config.bitwarden.email.clone().filter(|e| !e.trim().is_empty()) else { return error(tr().text("bitwarden-missing", None)) };
564    let server = bitwarden::Server::of(config.bitwarden.server.as_deref().unwrap_or(""));
565    let secret = answer.get("secret").and_then(|s| s.as_str()).unwrap_or("");
566    let opened = bitwarden::unlock_with_passkey(&server, &device(), &token, &response.to_string(), secret).and_then(|unlocked| {
567        // A passkey says whose vault it opens: only the account set in Sites ⚙.
568        if bitwarden::account(&unlocked).is_some_and(|account| account != email.trim().to_lowercase()) {
569            return Err(bitwarden::Problem::Refused(tr().text("bitwarden-passkey-other", None)));
570        }
571        bitwarden::sync(&server, &unlocked)
572    });
573    match opened {
574        Ok(items) => {
575            if let Ok(mut vault) = shared.bitwarden.lock() {
576                *vault = Some(Vault::of(items));
577            }
578            crate::work::set_view_flag(shared, "bitwarden-passkey", true);
579            serde_json::json!({ "ok": true }).to_string()
580        }
581        Err(bitwarden::Problem::NoKeySecret) => error(tr().text("bitwarden-passkey-no-secret", None)),
582        Err(bitwarden::Problem::PasskeyNotForVault) => error(tr().text("bitwarden-passkey-no-vault", None)),
583        Err(bitwarden::Problem::WrongPassword) => error(tr().text("bitwarden-passkey-mismatch", None)),
584        // Bitwarden's words when it does not know the key as a passkey of an account.
585        Err(bitwarden::Problem::Refused(e)) if e.to_lowercase().contains("invalid credential") => error(tr().text("bitwarden-passkey-unknown", None)),
586        Err(bitwarden::Problem::Network(e) | bitwarden::Problem::Refused(e)) => error(e),
587        Err(bitwarden::Problem::SecondFactor(..) | bitwarden::Problem::NewDevice) => error(tr().text("bitwarden-refused", None)),
588    }
589}
590
591/// Sends the e-mail step's code (when you choose that step); returns what went wrong, else "".
592pub(crate) fn bitwarden_send_code(password: &str) -> String {
593    let config = load_config();
594    let Some(email) = config.bitwarden.email.clone().filter(|e| !e.trim().is_empty()) else { return tr().text("bitwarden-missing", None) };
595    let server = bitwarden::Server::of(config.bitwarden.server.as_deref().unwrap_or(""));
596    match bitwarden::send_email_code(&server, &email, password, &device()) {
597        Ok(()) => String::new(),
598        Err(bitwarden::Problem::Network(e) | bitwarden::Problem::Refused(e)) => e,
599        Err(_) => tr().text("bitwarden-refused", None),
600    }
601}
602
603/// The login last chosen for each site (host → Bitwarden's item id): proposed first next time.
604fn chosen_path() -> std::path::PathBuf {
605    sioul_core::config::state_dir().join("bitwarden-chosen.json")
606}
607
608fn chosen() -> std::collections::BTreeMap<String, String> {
609    std::fs::read_to_string(chosen_path()).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default()
610}
611
612#[derive(Serialize)]
613struct Choice {
614    id: String,
615    name: String,
616    username: String,
617    /// Its site: the one that matched the search, else the page's, else its first.
618    site: String,
619    /// Made for another domain than the page's: said, so a look-alike site shows.
620    elsewhere: bool,
621}
622
623impl Choice {
624    fn of(found: bitwarden::Found<'_>) -> Choice {
625        Choice { id: found.item.id.clone(), name: found.item.name.clone(), username: found.item.username.clone(), site: found.site, elsewhere: !found.own }
626    }
627}
628
629/// How many logins the chooser lists; the others are counted, to narrow the search.
630const LISTED: usize = 50;
631
632fn locked() -> String {
633    serde_json::json!({ "error": tr().text("bitwarden-locked", None) }).to_string()
634}
635
636/// The chooser's search: the vault's logins by site and by user name, each
637/// optional, both matching when both are given (`bitwarden::Logins::find`);
638/// the one chosen last for `url` first, then the best matches, `url`'s own
639/// before others. Names, user names and sites only, never a password.
640/// {"found": [{"id", "name", "username", "site", "elsewhere"}], "more"} or {"error"}.
641fn bitwarden_logins(shared: &Shared, url: &str, site: &str, user: &str) -> String {
642    let Ok(vault) = shared.bitwarden.lock() else { return locked() };
643    let Some(vault) = vault.as_ref() else { return locked() };
644    let last = chosen().get(&sioul_core::sites::host_of(url)).cloned();
645    let (found, total) = vault.logins.find(site, user, url, last.as_deref(), LISTED);
646    let more = total - found.len();
647    let found: Vec<Choice> = found.into_iter().map(Choice::of).collect();
648    serde_json::json!({ "found": found, "more": more }).to_string()
649}
650
651/// Above this many logins, the chooser searches off the window's thread:
652/// 20,000 made-up logins took 1 to 5.5 ms a search on a desktop computer
653/// busy with builds (`a_large_vault_is_searched_quickly`), a phone being
654/// several times slower; below, at once, so the chooser opens filled.
655const LARGE: usize = 5_000;
656
657/// The chooser's search as the window asks it: the answer at once
658/// (`bitwarden_logins`); for a large vault {"later": n}, the answer brought
659/// by `bitwarden_found(n, …)` once searched off the window's thread.
660pub(crate) fn bitwarden_search(qt: &QtThread, shared: &Arc<Shared>, url: &str, site: &str, user: &str) -> String {
661    let large = shared.bitwarden.lock().is_ok_and(|vault| vault.as_ref().is_some_and(|vault| vault.logins.items().len() > LARGE));
662    if !large {
663        return bitwarden_logins(shared, url, site, user);
664    }
665    static ASKED: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
666    let ticket = ASKED.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
667    let (qt, shared, url, site, user) = (qt.clone(), Arc::clone(shared), url.to_string(), site.to_string(), user.to_string());
668    std::thread::spawn(move || {
669        let found = bitwarden_logins(&shared, &url, &site, &user);
670        let _ = qt.queue(move |mut sioul| sioul.as_mut().bitwarden_found(ticket, cxx_qt_lib::QString::from(&found)));
671    });
672    serde_json::json!({ "later": ticket }).to_string()
673}
674
675/// What the vault holds for an address, as the chooser opens on it and as
676/// "Fill the login" begins: {"domain": the address's registrable domain;
677/// "chosen": the domain of the login chosen last for it, "" when none; "only":
678/// its only login, filled at once unless another was chosen last for it, ""
679/// when there is a choice to make} or {"error"}.
680pub(crate) fn bitwarden_site(shared: &Shared, url: &str) -> String {
681    let Ok(vault) = shared.bitwarden.lock() else { return locked() };
682    let Some(vault) = vault.as_ref() else { return locked() };
683    let last = chosen().get(&sioul_core::sites::host_of(url)).cloned();
684    let opening = vault.logins.opening(url, last.as_deref());
685    serde_json::json!({ "domain": opening.domain, "chosen": opening.chosen, "only": opening.only.unwrap_or_default() }).to_string()
686}
687
688/// One login of the vault, for a site: {"name", "username", "password",
689/// "code"} (the one-time code now, when it keeps its secret), or {"error"};
690/// kept as the site's choice.
691pub(crate) fn bitwarden_login(shared: &Shared, url: &str, id: &str) -> String {
692    let error = |id: &str| serde_json::json!({ "error": tr().text(id, None) }).to_string();
693    let Ok(vault) = shared.bitwarden.lock() else { return error("bitwarden-locked") };
694    let Some(vault) = vault.as_ref() else { return error("bitwarden-locked") };
695    let Some(item) = vault.logins.items().iter().find(|item| item.id == id) else { return error("bitwarden-none") };
696    let host = sioul_core::sites::host_of(url);
697    if !host.is_empty() {
698        let mut all = chosen();
699        if all.get(&host).map(String::as_str) != Some(id) {
700            all.insert(host, id.to_string());
701            if let Ok(text) = serde_json::to_string_pretty(&all) {
702                let _ = std::fs::create_dir_all(sioul_core::config::state_dir());
703                let _ = std::fs::write(chosen_path(), text);
704            }
705        }
706    }
707    let code = if item.totp.is_empty() { String::new() } else { bitwarden::totp(&item.totp, jiff::Timestamp::now().as_second()).unwrap_or_default() };
708    serde_json::json!({ "name": item.name, "username": item.username, "password": item.password, "code": code }).to_string()
709}
710
711/// Locks the vault again: its logins leave memory.
712pub(crate) fn bitwarden_lock(shared: &Shared) {
713    if let Ok(mut vault) = shared.bitwarden.lock() {
714        *vault = None;
715    }
716}