1use 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 news: bool,
25 in_view: bool,
27 icon: String,
29}
30
31#[derive(Serialize)]
32struct PresetRow {
33 name: String,
34 url: String,
35 host: String,
36 kind: String,
38 categories: Vec<String>,
40 area: String,
41 about: String,
42 announced_by: Vec<String>,
43 calls: Option<bool>,
44 kept: bool,
46}
47
48pub(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 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(®ion.name, &lang) })).collect();
87 serde_json::json!({ "code": code, "name": sioul_core::presets::words(&c.name, &lang), "regions": regions })
88 })
89 .collect();
90 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
97pub(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 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
156pub(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
178pub(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 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
201pub(crate) fn move_site(id: &str, delta: i64) -> String {
203 sioul_core::config::move_site(&crate::backend::config_path(), id, delta, crate::share::writes_format_2()).err().unwrap_or_default()
205}
206
207pub(crate) fn remove(id: &str) -> String {
209 sioul_core::config::remove_site(&crate::backend::config_path(), id).err().unwrap_or_default()
210}
211
212fn favicons() -> std::path::PathBuf {
214 sioul_core::config::cache_dir().join("favicons")
215}
216
217static FETCHING: Mutex<()> = Mutex::new(());
219static NO_ICON: Mutex<Vec<(String, i64)>> = Mutex::new(Vec::new());
220
221pub(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
256static GATHERED: Mutex<i64> = Mutex::new(0);
258
259pub(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 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 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
317pub(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
332pub(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 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
373pub(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
391pub(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
407pub(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
413pub(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
427fn 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
436pub(crate) fn bitwarden_state(shared: &Shared) -> String {
438 #[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
466pub(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
482pub(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 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 crate::work::set_view_flag(shared, "bitwarden-passkey", false);
510 serde_json::json!({ "ok": true }).to_string()
511 }
512 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
524static PASSKEY: Mutex<Option<String>> = Mutex::new(None);
526
527pub(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
548pub(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 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 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
588pub(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
600fn 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 site: String,
616 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
626const LISTED: usize = 50;
628
629fn locked() -> String {
630 serde_json::json!({ "error": tr().text("bitwarden-locked", None) }).to_string()
631}
632
633fn 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
648const LARGE: usize = 5_000;
653
654pub(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
672pub(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
685pub(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
708pub(crate) fn bitwarden_lock(shared: &Shared) {
710 if let Ok(mut vault) = shared.bitwarden.lock() {
711 *vault = None;
712 }
713}