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>) {
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 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 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
318pub(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
333pub(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 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 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
376pub(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
394pub(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
410pub(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
416pub(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
430fn 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
439pub(crate) fn bitwarden_state(shared: &Shared) -> String {
441 #[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
469pub(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
485pub(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 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 crate::work::set_view_flag(shared, "bitwarden-passkey", false);
513 serde_json::json!({ "ok": true }).to_string()
514 }
515 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
527static PASSKEY: Mutex<Option<String>> = Mutex::new(None);
529
530pub(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
551pub(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 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 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
591pub(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
603fn 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 site: String,
619 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
629const LISTED: usize = 50;
631
632fn locked() -> String {
633 serde_json::json!({ "error": tr().text("bitwarden-locked", None) }).to_string()
634}
635
636fn 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
651const LARGE: usize = 5_000;
656
657pub(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
675pub(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
688pub(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
711pub(crate) fn bitwarden_lock(shared: &Shared) {
713 if let Ok(mut vault) = shared.bitwarden.lock() {
714 *vault = None;
715 }
716}