1use crate::backend::{QtThread, Shared, json, load_config, say, tr};
39use cxx_qt_lib::QString;
40use serde::{Deserialize, Serialize};
41use sioul_core::config::{self, Account, AccountKind, Config, state_dir};
42use sioul_sync::loginflow::{self, Grant, Refusal};
43use sioul_sync::{remote, secret};
44use std::sync::atomic::{AtomicBool, Ordering};
45use std::sync::{Arc, Mutex};
46
47#[derive(Serialize, Clone, Debug, Default, PartialEq, Eq)]
49struct Stand {
50 step: String,
57 host: String,
59 signed: String,
61 places: Vec<String>,
63 problem: String,
65 lines: Vec<String>,
67 needs: Vec<String>,
69}
70
71static STAND: Mutex<Stand> = Mutex::new(Stand { step: String::new(), host: String::new(), signed: String::new(), places: Vec::new(), problem: String::new(), lines: Vec::new(), needs: Vec::new() });
72
73static GRANT: Mutex<Option<Grant>> = Mutex::new(None);
76
77static PAGE: Mutex<Option<loginflow::Secret>> = Mutex::new(None);
80
81static STOP: AtomicBool = AtomicBool::new(false);
83
84static RUNNING: AtomicBool = AtomicBool::new(false);
86
87struct Running;
90
91impl Drop for Running {
92 fn drop(&mut self) {
93 RUNNING.store(false, Ordering::SeqCst);
94 }
95}
96
97fn stand() -> std::sync::MutexGuard<'static, Stand> {
98 STAND.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
99}
100
101fn set(change: impl FnOnce(&mut Stand)) {
102 change(&mut stand());
103}
104
105fn grant() -> std::sync::MutexGuard<'static, Option<Grant>> {
106 GRANT.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
107}
108
109fn told(qt: &QtThread) {
111 let _ = qt.queue(|mut sioul| sioul.as_mut().join_changed());
112}
113
114pub(crate) fn status() -> String {
116 json(&*stand())
117}
118
119fn client() -> String {
122 let system = if cfg!(target_os = "android") {
123 "Android"
124 } else if cfg!(windows) {
125 "Windows"
126 } else if cfg!(target_os = "macos") {
127 "macOS"
128 } else {
129 "Linux"
130 };
131 format!("Sioul ({system})")
132}
133
134fn refusal_words(refusal: &Refusal) -> String {
136 match refusal {
137 Refusal::Address => tr().text("join-why-address", None),
138 Refusal::NotHttps(host) => say("join-why-not-https", &[("host", host.clone())]),
139 Refusal::Unreachable(host) => say("join-why-unreachable", &[("host", host.clone())]),
140 Refusal::Tls(host) => say("join-why-tls", &[("host", host.clone())]),
141 Refusal::NotNextcloud(host) => say("join-why-not-nextcloud", &[("host", host.clone())]),
142 Refusal::Elsewhere(host, other) => say("join-why-elsewhere", &[("host", host.clone()), ("other", other.clone())]),
143 Refusal::Server(host) => say("join-why-server", &[("host", host.clone())]),
144 Refusal::TimedOut => tr().text("join-why-timed-out", None),
145 Refusal::Cancelled => String::new(),
146 }
147}
148
149fn give_back(grant: &Grant) {
151 let _ = loginflow::revoke(grant);
152 let _ = loginflow::forget(&grant.login, &grant.host());
153}
154
155pub(crate) fn sign_in(qt: &QtThread, typed: String) {
160 if RUNNING.swap(true, Ordering::SeqCst) {
161 return;
162 }
163 STOP.store(false, Ordering::SeqCst);
164 if let Some(earlier) = grant().take() {
166 std::thread::spawn(move || give_back(&earlier));
167 }
168 let typed = if typed.trim().is_empty() || typed.trim().eq_ignore_ascii_case("murena") { loginflow::MURENA.to_string() } else { typed.trim().to_string() };
169 set(|s| *s = Stand { step: "signing".into(), host: remote::host_of(&typed), ..Stand::default() });
170 told(qt);
171 let qt = qt.clone();
172 std::thread::spawn(move || {
173 let running = Running;
174 let outcome = (|| -> Result<(), String> {
175 let base = loginflow::address_of(&typed).map_err(|r| refusal_words(&r))?;
176 let flow = loginflow::begin(&base, &client()).map_err(|r| refusal_words(&r))?;
177 set(|s| s.host = flow.host());
178 if let Ok(mut page) = PAGE.lock() {
179 *page = Some(loginflow::secret(flow.page()));
180 }
181 let page = flow.page().to_string();
182 let _ = qt.queue(move |mut sioul| sioul.as_mut().open_url(QString::from(&page)));
183 told(&qt);
184 let signed = loginflow::wait(&flow, &STOP, loginflow::LIMIT, loginflow::EVERY);
185 if let Ok(mut page) = PAGE.lock() {
186 *page = None;
187 }
188 let signed = signed.map_err(|r| refusal_words(&r))?;
189 if STOP.load(Ordering::SeqCst) {
190 give_back(&signed);
191 return Err(String::new());
192 }
193 loginflow::keep(&signed).map_err(|e| e.sentence(tr(), &signed.host()))?;
195 set(|s| {
196 s.step = "looking".into();
197 s.signed = say("join-signed", &[("host", signed.host()), ("login", signed.login.clone())]);
198 });
199 told(&qt);
200 let sought = remote::seek(&signed.login_for(""));
201 if STOP.load(Ordering::SeqCst) {
202 give_back(&signed);
203 return Err(String::new());
204 }
205 set(|s| looked(s, &signed.host(), sought));
206 *grant() = Some(signed);
207 Ok(())
208 })();
209 if let Err(problem) = outcome {
210 set(|s| {
211 s.step = if problem.is_empty() { String::new() } else { "failed".into() };
212 s.problem = problem;
213 });
214 }
215 drop(running);
216 if STOP.load(Ordering::SeqCst)
218 && let Some(late) = grant().take()
219 {
220 give_back(&late);
221 set(|s| *s = Stand::default());
222 }
223 told(&qt);
224 });
225}
226
227fn looked(stand: &mut Stand, host: &str, sought: Result<remote::Sought, String>) {
233 stand.places.clear();
234 stand.problem.clear();
235 match sought {
236 Ok(sought) if !sought.found.is_empty() => {
237 stand.step = "found".into();
238 stand.places = sought.found;
239 }
240 Ok(sought) if sought.complete => stand.step = "none".into(),
241 Ok(_) => {
242 stand.step = "unchecked".into();
243 stand.problem = say("join-unchecked", &[("host", host.to_string())]);
244 }
245 Err(code) => {
246 stand.step = "unchecked".into();
247 stand.problem = [say("join-unchecked", &[("host", host.to_string())]), crate::share::server_problem(&code)].join(" ");
248 }
249 }
250}
251
252pub(crate) fn look_again(qt: &QtThread) {
256 let Some(held) = grant().as_ref().map(|g| Grant::of(&g.server, &g.login, g.password())) else { return };
257 if RUNNING.swap(true, Ordering::SeqCst) {
258 return;
259 }
260 set(|s| {
261 s.step = "looking".into();
262 s.problem.clear();
263 });
264 told(qt);
265 let qt = qt.clone();
266 std::thread::spawn(move || {
267 let running = Running;
268 let sought = remote::seek(&held.login_for(""));
269 set(|s| looked(s, &held.host(), sought));
270 drop(running);
271 told(&qt);
272 });
273}
274
275pub(crate) fn open_again(qt: &QtThread) {
277 let page = PAGE.lock().ok().and_then(|p| p.as_ref().map(|p| p.to_string()));
278 if let Some(page) = page {
279 let _ = qt.queue(move |mut sioul| sioul.as_mut().open_url(QString::from(&page)));
280 }
281}
282
283pub(crate) fn cancel(qt: &QtThread) {
286 STOP.store(true, Ordering::SeqCst);
287 if !RUNNING.load(Ordering::SeqCst) {
288 if let Some(held) = grant().take() {
289 std::thread::spawn(move || give_back(&held));
290 }
291 set(|s| *s = Stand::default());
292 }
293 told(qt);
294}
295
296pub(crate) fn done(qt: &QtThread) {
298 if RUNNING.load(Ordering::SeqCst) {
299 return;
300 }
301 let step = stand().step.clone();
302 if matches!(step.as_str(), "joined" | "failed") {
303 set(|s| *s = Stand::default());
304 }
305 told(qt);
306}
307
308pub(crate) fn join(qt: &QtThread, shared: &Arc<Shared>, place: String, passphrase: String, again: String) {
314 if RUNNING.swap(true, Ordering::SeqCst) {
315 return;
316 }
317 let (qt, shared) = (qt.clone(), Arc::clone(shared));
318 let back = stand().step.clone();
319 let may_start = back == "none";
321 set(|s| {
322 s.step = "joining".into();
323 s.problem.clear();
324 });
325 told(&qt);
326 std::thread::spawn(move || {
327 let running = Running;
328 let held = grant().as_ref().map(|g| Grant::of(&g.server, &g.login, g.password()));
329 let outcome = match held {
330 None => Err(tr().text("join-why-signed-out", None)),
331 Some(signed) => joined(&qt, &shared, &signed, &place, &passphrase, &again, may_start),
332 };
333 match outcome {
334 Ok((lines, needs)) => {
335 *grant() = None;
337 set(|s| {
338 s.step = "joined".into();
339 s.lines = lines;
340 s.needs = needs;
341 s.places.clear();
342 });
343 }
344 Err(problem) => set(|s| {
345 s.step = back.clone();
346 s.problem = problem;
347 }),
348 }
349 drop(running);
350 told(&qt);
351 crate::backend::show(&qt, &shared);
352 });
353}
354
355#[allow(clippy::too_many_arguments)]
357fn joined(qt: &QtThread, shared: &Arc<Shared>, signed: &Grant, place: &str, passphrase: &str, again: &str, may_start: bool) -> Result<(Vec<String>, Vec<String>), String> {
358 let before = load_config();
359 let before_text = std::fs::read_to_string(crate::backend::config_path()).unwrap_or_default();
360 let login = signed.login_for(&agenda_id(&before, signed));
361 let since = jiff::Timestamp::now().as_second();
362 let begun = crate::share::begin_on_server(&login, place, passphrase, again, true, may_start)?;
363 crate::share::exchange_at_once(qt, shared);
365 let host = signed.host();
366 let mut lines = match begun {
367 crate::share::Begun::Started => vec![say("join-started", &[("host", host.clone()), ("place", place.trim().trim_matches('/').to_string())])],
369 crate::share::Begun::Joined => {
370 let after_text = std::fs::read_to_string(crate::backend::config_path()).unwrap_or_default();
371 said_joined(&load_config(), before_text != after_text)
372 }
373 };
374 let agenda = if begun == crate::share::Begun::Started || complete_since(since) {
376 bind_agenda(qt, shared, signed).and_then(|bound| line_of(bound, &host))
377 } else {
378 defer_agenda(signed, since);
379 Some(say("join-agenda-later", &[("host", host.clone())]))
380 };
381 lines.extend(agenda);
382 let needs = still_to_sign_in(&load_config());
383 Ok((lines, needs))
384}
385
386fn complete_since(since: i64) -> bool {
392 let state = remote::State::load(&crate::share::memory_path());
393 state_complete(&state, since) && crate::share::last_exchange().is_some_and(|(at, _)| at >= since)
394}
395
396fn state_complete(state: &remote::State, since: i64) -> bool {
400 state.mode == remote::MIRROR && state.last >= since && state.said.is_empty() && state.left.is_empty()
401}
402
403#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)]
408struct Pending {
409 server: String,
410 login: String,
411 since: i64,
412}
413
414fn pending_path() -> std::path::PathBuf {
415 state_dir().join("share").join("agenda-pending.toml")
416}
417
418fn defer_agenda(signed: &Grant, since: i64) {
419 let pending = Pending { server: signed.server.clone(), login: signed.login.clone(), since };
420 if let Ok(text) = toml::to_string(&pending) {
421 let _ = sioul_sync::share::write_atomically(&pending_path(), format!("# Sioul: the contacts and calendars of the server signed in to, decided once your other devices' settings came (docs/database.md). Never shared.\n{text}").as_bytes());
422 }
423}
424
425pub(crate) fn agenda_later(qt: &QtThread, shared: &Arc<Shared>) {
430 let path = pending_path();
431 let Some(pending) = std::fs::read_to_string(&path).ok().and_then(|t| toml::from_str::<Pending>(&t).ok()) else { return };
432 if !complete_since(pending.since) {
433 return;
434 }
435 let host = remote::host_of(&pending.server);
436 let Some(password) = loginflow::kept(&pending.login, &host) else {
437 let _ = std::fs::remove_file(&path);
438 return;
439 };
440 let signed = Grant::of(&pending.server, &pending.login, &password);
441 let line = bind_agenda(qt, shared, &signed).and_then(|bound| line_of(bound, &host));
442 let _ = std::fs::remove_file(&path);
443 if let Some(line) = line {
444 crate::backend::set_status(qt, line);
445 }
446}
447
448fn user_of(url: &str) -> Option<String> {
454 let (_, rest) = url.split_once("/remote.php/dav/")?;
455 let mut parts = rest.split('/').filter(|p| !p.is_empty());
456 let user = match parts.next()? {
457 "calendars" | "files" => parts.next()?,
458 "addressbooks" | "principals" => match parts.next()? {
459 "users" => parts.next()?,
460 _ => return None,
461 },
462 _ => return None,
463 };
464 Some(decoded(user)).filter(|u| !u.is_empty())
465}
466
467fn decoded(part: &str) -> String {
469 let bytes = part.as_bytes();
470 let mut out = Vec::with_capacity(bytes.len());
471 let mut at = 0;
472 while at < bytes.len() {
473 if bytes[at] == b'%'
474 && let Some(byte) = part.get(at + 1..at + 3).and_then(|hex| u8::from_str_radix(hex, 16).ok())
475 {
476 out.push(byte);
477 at += 3;
478 continue;
479 }
480 out.push(bytes[at]);
481 at += 1;
482 }
483 String::from_utf8_lossy(&out).into_owned()
484}
485
486fn account_host(account: &Account) -> String {
488 account.host.clone().filter(|h| !h.is_empty()).unwrap_or_else(|| account.url.as_deref().map(remote::host_of).unwrap_or_default())
489}
490
491fn on_server(account: &Account, server: &str) -> bool {
496 let origin = sioul_sync::dav::origin(server);
497 origin.is_some() && account.url.as_deref().and_then(sioul_sync::dav::origin) == origin
498}
499
500fn matching<'a>(config: &'a Config, server: &str, login: &str, user: Option<&str>) -> Option<&'a Account> {
506 config.every_account().filter(|a| a.kind == AccountKind::Dav && a.auth.is_none()).find(|a| {
507 on_server(a, server) && (a.login().is_some_and(|l| l.trim().eq_ignore_ascii_case(login.trim())) || user.is_some_and(|u| a.url.as_deref().and_then(user_of).as_deref() == Some(u)))
508 })
509}
510
511fn new_id(config: &Config, host: &str) -> String {
514 let label = host.split(['.', ':']).next().filter(|l| !l.is_empty()).unwrap_or("nextcloud");
515 let base = config::slug(&format!("{label}-agenda"));
516 std::iter::once(base.clone()).chain((2..).map(|n| format!("{base}-{n}"))).find(|id| config.every_account().all(|a| &a.id != id)).unwrap_or(base)
517}
518
519fn agenda_id(config: &Config, signed: &Grant) -> String {
523 matching(config, &signed.server, &signed.login, None).map_or_else(|| new_id(config, &signed.host()), |a| a.id.clone())
524}
525
526#[derive(Debug, Clone)]
528enum Agenda {
529 Use { id: String, same: bool },
532 Make(Account),
534}
535
536fn agenda_for(config: &Config, server: &str, login: &str, homes: &sioul_sync::dav::Homes) -> Agenda {
541 let host = remote::host_of(server);
542 let user = homes.calendars.as_deref().or(homes.contacts.as_deref()).and_then(user_of);
543 match matching(config, server, login, user.as_deref()) {
544 Some(account) => Agenda::Use { id: account.id.clone(), same: account.login().is_some_and(|l| l.trim() == login.trim()) },
545 None => {
546 let found = homes.calendars.clone().or_else(|| homes.contacts.clone()).unwrap_or_default();
547 Agenda::Make(Account::dav(&new_id(config, &host), login, &host, Some(&found), Some(login)))
548 }
549 }
550}
551
552#[derive(Debug, Clone, Copy, PartialEq, Eq)]
554enum Bound {
555 Made,
557 Kept,
559 Own,
561 Needs,
563}
564
565fn line_of(bound: Bound, host: &str) -> Option<String> {
569 match bound {
570 Bound::Made => Some(say("join-agenda-made", &[("host", host.to_string())])),
571 Bound::Kept => Some(say("join-agenda-kept", &[("host", host.to_string())])),
572 Bound::Own | Bound::Needs => None,
573 }
574}
575
576fn bind_agenda(qt: &QtThread, shared: &Arc<Shared>, signed: &Grant) -> Option<Bound> {
583 let homes = loginflow::homes(signed).ok()?;
584 let config = load_config();
585 match agenda_for(&config, &signed.server, &signed.login, &homes) {
586 Agenda::Use { id, same } => {
587 let account = config.every_account().find(|a| a.id == id).cloned()?;
588 if sioul_sync::dav::Homes::load(&account.id).is_empty() {
589 let _ = homes.save(&account.id);
590 }
591 if secret::password(&account).is_ok() {
592 return Some(Bound::Own);
593 }
594 let login = account.login().unwrap_or_default().to_string();
595 if (same || loginflow::opens(signed, &login).unwrap_or(false)) && secret::save(&account, signed.password()).is_ok() {
596 crate::pim::start_dav_watcher(qt, shared, account);
597 return Some(Bound::Kept);
598 }
599 Some(Bound::Needs)
600 }
601 Agenda::Make(account) => {
602 secret::save(&account, signed.password()).ok()?;
603 config::add_dav_account(&crate::backend::config_path(), &account).ok()?;
604 let _ = homes.save(&account.id);
605 crate::pim::start_dav_watcher(qt, shared, account);
606 crate::pim::show_pim(qt, shared);
607 Some(Bound::Made)
608 }
609 }
610}
611
612fn account_label(account: &Account) -> String {
616 if account.kind == AccountKind::Dav {
617 return say("join-agenda", &[("host", account_host(account))]);
618 }
619 account.address.clone().unwrap_or_else(|| account.id.clone())
620}
621
622fn device_label(entry: &sioul_sync::devices::Entry) -> String {
624 match (entry.kind == sioul_sync::devices::PHONE, entry.name.is_empty() || entry.name == sioul_sync::devices::PHONE) {
625 (true, true) => tr().text("device-the-phone", None),
626 _ if entry.name.is_empty() => tr().text("share-other-device", None),
627 _ => entry.name.clone(),
628 }
629}
630
631fn listed(items: Vec<String>) -> String {
633 match items.len() {
634 0 => String::new(),
635 1 => items[0].clone(),
636 n => say("join-and", &[("first", items[..n - 1].join(", ")), ("last", items[n - 1].clone())]),
637 }
638}
639
640fn said_joined(config: &Config, changed: bool) -> Vec<String> {
644 let mut lines = Vec::new();
645 let others: Vec<String> = crate::devices::entries().map(|(own, entries)| entries.iter().filter(|e| e.id != own && !e.left).map(device_label).collect()).unwrap_or_default();
646 if others.is_empty() {
647 lines.push(tr().text("join-alone", None));
648 return lines;
649 }
650 lines.push(say("join-with", &[("names", listed(others))]));
651 if changed {
652 lines.push(tr().text("join-settings", None));
653 }
654 let accounts: Vec<String> = config.accounts.iter().filter(|a| a.syncs() || a.is_dav()).map(account_label).collect();
655 if !accounts.is_empty() {
656 lines.push(say("join-accounts", &[("accounts", listed(accounts))]));
657 }
658 let set = |kind: &str| config.windows.iter().any(|w| w.kind() == kind);
659 let mut hours: Vec<String> = Vec::new();
660 if set("work") {
661 hours.push(tr().text("join-hours-work", None));
662 }
663 if set("admin") {
664 hours.push(tr().text("join-hours-admin", None));
665 }
666 lines.push(if hours.is_empty() { tr().text("join-hours-none", None) } else { say("join-hours", &[("which", listed(hours))]) });
667 lines
668}
669
670fn still_to_sign_in(config: &Config) -> Vec<String> {
673 config
674 .accounts
675 .iter()
676 .filter(|a| a.enabled && (a.syncs() || a.is_dav()))
677 .filter(|a| secret::password(a).is_err())
678 .map(|a| {
679 let words = if a.auth.is_some() { "join-needs-sign-in" } else { "join-needs-password" };
680 say(words, &[("account", account_label(a))])
681 })
682 .collect()
683}
684
685pub(crate) fn decoded_path(place: &str) -> String {
689 place.split('/').map(decoded).collect::<Vec<_>>().join("/")
690}
691
692pub(crate) fn add_device(found: Option<(String, String)>) -> Vec<String> {
699 let mut lines = vec![tr().text("share-add-intro", None)];
700 match found {
701 Some((host, place)) => {
702 lines.push(if host.eq_ignore_ascii_case(&remote::host_of(loginflow::MURENA)) { tr().text("share-add-murena", None) } else { say("share-add-nextcloud", &[("host", host)]) });
703 lines.push(say(if remote::sought(&place) { "share-add-place" } else { "share-add-place-typed" }, &[("place", place)]));
705 }
706 None => lines.push(tr().text("share-add-folder", None)),
707 }
708 lines.push(tr().text("share-add-passphrase", None));
709 lines
710}
711
712pub(crate) fn let_go(state: &remote::State) {
719 if state.user.is_empty() {
720 return;
721 }
722 let host = state.host();
723 let Some(password) = loginflow::kept(&state.user, &host) else { return };
724 let used = load_config().every_account().any(|a| secret::password(a).is_ok_and(|p| p == password));
725 if !used {
726 let server = state.url.split_once("/remote.php/").map_or(state.url.as_str(), |(server, _)| server);
727 let _ = loginflow::revoke(&Grant::of(server, &state.user, &password));
728 }
729 let _ = loginflow::forget(&state.user, &host);
730}
731
732#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
737pub(crate) struct Replacing {
738 id: String,
739 ask: String,
740}
741
742#[derive(Serialize, Deserialize, Default, Debug)]
745struct Asked {
746 #[serde(default)]
747 answered: Vec<String>,
748}
749
750fn asked_path() -> std::path::PathBuf {
751 state_dir().join("share").join("replacing.toml")
752}
753
754fn asked() -> Asked {
755 std::fs::read_to_string(asked_path()).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
756}
757
758fn moment(seconds: i64) -> String {
760 let Ok(at) = jiff::Timestamp::from_second(seconds) else { return String::new() };
761 let at = at.to_zoned(jiff::tz::TimeZone::system());
762 if at.date() == jiff::Zoned::now().date() { at.strftime("%H:%M").to_string() } else { tr().date(&at, true) }
763}
764
765const ASK_AFTER: i64 = 86_400;
773
774fn latest_of(entry: &sioul_sync::devices::Entry) -> i64 {
776 entry.started.max(entry.closed).max(entry.imported).max(entry.exported)
777}
778
779#[allow(clippy::too_many_arguments)]
786fn to_ask(own: &str, own_entry: &sioul_sync::devices::Entry, entries: &[sioul_sync::devices::Entry], answered: &[String], forgotten: &dyn Fn(&str) -> bool, quiet_since: &dyn Fn(&str) -> Option<i64>, now: i64) -> Option<sioul_sync::devices::Entry> {
787 entries
788 .iter()
789 .filter(|e| e.id != own && e.kind == sioul_sync::devices::PHONE && !e.left && quiet_since(&e.id).is_some_and(|since| now - since >= ASK_AFTER))
790 .filter(|e| !answered.contains(&e.id) && own_entry.replaces.iter().all(|r| r.id != e.id) && !forgotten(&e.id))
791 .max_by_key(|e| sioul_sync::devices::written(e))
792 .cloned()
793}
794
795pub(crate) fn replacing() -> Option<Replacing> {
800 if crate::devices::kind() != sioul_sync::devices::PHONE {
801 return None;
802 }
803 let (own, entries) = crate::devices::entries()?;
804 let own_entry = sioul_sync::devices::own(&sioul_sync::devices::own_path(&state_dir()));
805 let other = to_ask(&own, &own_entry, &entries, &asked().answered, &crate::health::forgotten_here, &crate::health::quiet_since, jiff::Timestamp::now().as_second())?;
806 let when = moment(latest_of(&other));
807 Some(Replacing { ask: say("join-replace-ask", &[("name", device_label(&other)), ("when", when)]), id: other.id })
808}
809
810pub(crate) fn replace(qt: &QtThread, shared: &Arc<Shared>, id: &str, yes: bool) -> String {
815 let id = id.trim().to_string();
816 if id.is_empty() {
817 return String::new();
818 }
819 let mut kept = asked();
820 if !kept.answered.contains(&id) {
821 kept.answered.push(id.clone());
822 }
823 let written = toml::to_string(&kept).map_err(|e| e.to_string()).and_then(|text| sioul_sync::share::write_atomically(&asked_path(), format!("# Sioul: the phones this one was asked whether it replaces (docs/database.md). Never shared.\n{text}").as_bytes()));
824 if let Err(e) = written {
825 return e;
826 }
827 if !yes {
828 return String::new();
829 }
830 let replaced = crate::health::replaced_now(&id);
831 crate::devices::replace(replaced);
832 let problem = crate::health::device_forget(&id);
833 crate::share::exchange(qt, shared);
834 problem
835}
836
837#[cfg(test)]
838mod tests {
839 use super::*;
840 use sioul_sync::dav::Homes;
841
842 fn config(accounts: &str) -> Config {
843 toml::from_str(accounts).unwrap()
844 }
845
846 #[test]
847 fn a_nextcloud_s_user_is_read_from_its_paths() {
848 assert_eq!(user_of("https://murena.io/remote.php/dav/calendars/jane/").as_deref(), Some("jane"));
849 assert_eq!(user_of("https://cloud.example.org/nc/remote.php/dav/addressbooks/users/jane%40example.org/").as_deref(), Some("jane@example.org"));
850 assert_eq!(user_of("https://murena.io/remote.php/dav/principals/users/jane/").as_deref(), Some("jane"));
851 assert_eq!(user_of("https://murena.io/remote.php/dav/files/jane/Documents/Sioul/").as_deref(), Some("jane"));
852 assert_eq!(user_of("https://murena.io/remote.php/dav/"), None);
853 assert_eq!(user_of("https://caldav.example.org/cal/jane/"), None);
854 }
855
856 #[test]
866 fn the_account_is_made_from_the_sign_in_or_yours_is_used() {
867 let homes = Homes { calendars: Some("https://murena.io/remote.php/dav/calendars/jane/".into()), contacts: Some("https://murena.io/remote.php/dav/addressbooks/users/jane/".into()) };
868 let server = "https://murena.io";
869 let empty = config("");
871 let Agenda::Make(made) = agenda_for(&empty, server, "jane", &homes) else { panic!("made") };
872 assert_eq!((made.id.as_str(), made.kind, made.host.as_deref(), made.url.as_deref(), made.login()), ("murena-agenda", AccountKind::Dav, Some("murena.io"), Some("https://murena.io/remote.php/dav/calendars/jane/"), Some("jane")));
873 assert_eq!(agenda_id(&empty, &Grant::of(server, "jane", "x")), "murena-agenda");
874 let desk = config("[[account]]\nid = \"murena\"\nkind = \"dav\"\naddress = \"Jane\"\nhost = \"murena.io\"\nurl = \"https://murena.io/remote.php/dav/calendars/jane/\"\n");
876 assert!(matches!(agenda_for(&desk, server, "jane", &homes), Agenda::Use { id, same: false } if id == "murena"), "its login Jane: the same account, the app password tried with it first");
877 let exact = config("[[account]]\nid = \"murena\"\nkind = \"dav\"\naddress = \"jane\"\nhost = \"murena.io\"\nurl = \"https://murena.io/remote.php/dav/calendars/jane/\"\n");
878 assert!(matches!(agenda_for(&exact, server, "jane", &homes), Agenda::Use { id, same: true } if id == "murena"));
879 assert_eq!(agenda_id(&desk, &Grant::of(server, "jane", "x")), "murena");
880 let by_address = config("[[account]]\nid = \"cloud\"\nkind = \"dav\"\naddress = \"jane@example.org\"\nhost = \"murena.io\"\nurl = \"https://murena.io/remote.php/dav/calendars/jane/\"\n");
882 assert!(matches!(agenda_for(&by_address, server, "jane", &homes), Agenda::Use { id, same: false } if id == "cloud"));
883 let ported = config("[[account]]\nid = \"cloud\"\nkind = \"dav\"\naddress = \"jane\"\nhost = \"cloud.example.org\"\nurl = \"https://cloud.example.org:8443/nc/remote.php/dav/calendars/jane/\"\n");
885 let ported_homes = Homes { calendars: Some("https://cloud.example.org:8443/nc/remote.php/dav/calendars/jane/".into()), contacts: None };
886 assert!(matches!(agenda_for(&ported, "https://cloud.example.org:8443/nc", "jane", &ported_homes), Agenda::Use { id, .. } if id == "cloud"));
887 let elsewhere = config("[[account]]\nid = \"odd\"\nkind = \"dav\"\naddress = \"jane\"\nhost = \"murena.io\"\nurl = \"https://dav.example.org/remote.php/dav/calendars/jane/\"\n");
889 assert!(matches!(agenda_for(&elsewhere, server, "jane", &homes), Agenda::Make(_)));
890 assert!(!on_server(&elsewhere.accounts[0], server) && on_server(&exact.accounts[0], server) && !on_server(&exact.accounts[0], "http://murena.io"));
891 let others = config(
893 "[[account]]\nid = \"murena-agenda\"\nkind = \"dav\"\naddress = \"paul@example.org\"\nhost = \"murena.io\"\nurl = \"https://murena.io/remote.php/dav/calendars/paul/\"\n\n[[account]]\nid = \"work\"\nkind = \"dav\"\naddress = \"jane\"\nhost = \"cloud.example.org\"\nurl = \"https://cloud.example.org/remote.php/dav/calendars/jane/\"\n\n[[account]]\nid = \"google\"\nkind = \"dav\"\naddress = \"jane@example.org\"\nhost = \"murena.io\"\nauth = \"google\"\n",
894 );
895 let Agenda::Make(made) = agenda_for(&others, server, "jane", &homes) else { panic!("made") };
896 assert_eq!(made.id, "murena-agenda-2");
897 }
898
899 #[test]
906 fn the_contacts_and_calendars_wait_for_a_complete_first_exchange() {
907 let since: i64 = 1_791_360_000;
908 let whole = remote::State { mode: remote::MIRROR.into(), last: since + 20, ..remote::State::default() };
909 assert!(state_complete(&whole, since));
910 assert!(!state_complete(&remote::State { last: since - 1, ..whole.clone() }, since), "no whole look since the join");
911 assert!(!state_complete(&remote::State { said: "network:murena.io".into(), ..whole.clone() }, since), "the look cut by the time out, or an error");
912 assert!(!state_complete(&remote::State { left: [("desk-1.jsonl".to_string(), 70 << 20)].into(), ..whole.clone() }, since), "records left on the server");
913 assert!(!state_complete(&remote::State { mode: String::new(), ..whole.clone() }, since), "not Sioul keeping the folder");
914 let homes = Homes { calendars: Some("https://murena.io/remote.php/dav/calendars/jane/".into()), contacts: None };
917 assert!(matches!(agenda_for(&config(""), "https://murena.io", "jane", &homes), Agenda::Make(_)), "what deciding early would do");
918 let arrived = config("[[account]]\nid = \"murena\"\nkind = \"dav\"\naddress = \"jane\"\nhost = \"murena.io\"\nurl = \"https://murena.io/remote.php/dav/calendars/jane/\"\n");
919 assert!(matches!(agenda_for(&arrived, "https://murena.io", "jane", &homes), Agenda::Use { id, .. } if id == "murena"));
920 assert!(line_of(Bound::Kept, "murena.io").is_some_and(|l| l.contains("murena.io")) && line_of(Bound::Made, "murena.io").is_some());
922 assert_eq!((line_of(Bound::Own, "murena.io"), line_of(Bound::Needs, "murena.io")), (None, None));
923 }
924
925 #[test]
929 fn a_look_that_could_not_go_through_never_offers_a_new_sharing() {
930 let mut stand = Stand::default();
931 looked(&mut stand, "murena.io", Ok(remote::Sought { found: vec!["Documents/Sioul".into()], complete: false }));
932 assert_eq!((stand.step.as_str(), stand.places.as_slice()), ("found", ["Documents/Sioul".to_string()].as_slice()));
933 looked(&mut stand, "murena.io", Ok(remote::Sought { found: Vec::new(), complete: true }));
934 assert_eq!(stand.step, "none");
935 assert!(stand.places.is_empty() && stand.problem.is_empty());
936 looked(&mut stand, "murena.io", Ok(remote::Sought { found: Vec::new(), complete: false }));
937 assert_eq!(stand.step, "unchecked");
938 assert!(stand.problem.contains("murena.io"), "{}", stand.problem);
939 looked(&mut stand, "murena.io", Err("network:murena.io".into()));
940 assert_eq!(stand.step, "unchecked");
941 let deep = add_device(Some(("murena.io".into(), "Documents/Partage/Sioul".into())));
943 let own = add_device(Some(("murena.io".into(), "Documents/Sioul".into())));
944 assert_ne!(deep[2], own[2]);
945 assert!(deep[2].contains("Documents/Partage/Sioul") && own[2].contains("Documents/Sioul"));
946 }
947
948 #[test]
954 fn a_phone_is_asked_once_whether_it_replaces_another() {
955 use sioul_sync::devices::{COMPUTER, Entry, PHONE, Replaced};
956 let now: i64 = 1_791_360_000;
957 let phone = |id: &str, exported: i64| Entry { id: id.into(), name: format!("{id} model"), kind: PHONE.into(), started: exported - 60, exported, working: false, ..Entry::default() };
958 let desk = Entry { id: "desk".into(), kind: COMPUTER.into(), started: now - 900, exported: now - 10, working: true, ..Entry::default() };
959 let own = phone("new", now);
960 let entries = vec![own.clone(), desk, phone("old", now - 3 * 86_400), phone("older", now - 9 * 86_400), Entry { left: true, ..phone("gone", now - 86_400) }, phone("second", now - 600)];
961 let never = |_: &str| false;
962 let seen = |id: &str| match id {
964 "old" => Some(now - 3 * 86_400),
965 "older" => Some(now - 9 * 86_400),
966 "second" => Some(now - 600),
967 "fresh" => Some(now - 60),
968 _ => None,
969 };
970 assert_eq!(to_ask("new", &own, &entries, &[], &never, &seen, now).map(|e| e.id), Some("old".into()), "the one heard of last, silent a day or more");
971 assert_eq!(to_ask("new", &own, &entries, &["old".into()], &never, &seen, now).map(|e| e.id), Some("older".into()), "asked once");
972 let replacing = Entry { replaces: vec![Replaced { id: "old".into(), started: now - 3 * 86_400 - 60, heard: (now - 3 * 86_400) * 1000 }], ..own.clone() };
973 assert_eq!(to_ask("new", &replacing, &entries, &["older".into()], &never, &seen, now), None, "replaced already");
974 assert_eq!(to_ask("new", &own, &entries, &["older".into()], &|id| id == "old", &seen, now), None, "forgotten here");
975 assert_eq!(to_ask("new", &own, &entries[..2], &[], &never, &seen, now), None, "a computer is never asked about");
976 assert_eq!(to_ask("new", &own, &[own.clone(), phone("second", now - 600)], &[], &never, &seen, now), None);
978 assert_eq!(to_ask("new", &own, &[own.clone(), phone("second", now - 600)], &[], &never, &seen, now + 5 * 3_600), None, "five hours of silence are no proof");
979 assert_eq!(to_ask("new", &own, &[own.clone(), phone("second", now - 600)], &[], &never, &seen, now + ASK_AFTER).map(|e| e.id), Some("second".into()), "a day of silence: asked");
980 assert_eq!(to_ask("new", &own, &[own.clone(), phone("fresh", now - 3 * 86_400)], &[], &never, &seen, now), None);
982 assert_eq!(to_ask("new", &own, &[own.clone(), phone("unknown", now - 9 * 86_400)], &[], &never, &seen, now), None);
984 }
985}