//! Launch screen state: connect an account, choose a library, sign out. //! //! The state machine lives here, separate from the Slint bindings, so it can //! be tested without a display server. `dr-ui` owns presentation; what a //! login *is* belongs to the connector. //! //! # Two shapes of sign-in, not two screens //! //! A Nextcloud account is established through a browser handshake the app //! polls for; a folder library is established by naming a directory. The model //! below does not know which is which — it asks the //! [`BackendProvider`](dr_sync::BackendProvider) whether the account it is //! being asked to make needs a credential //! ([`SignIn`](dr_sync::SignIn)), and the screen draws the waiting state only //! where there is something to wait for. Everything after that point — picking //! a library root, ticking formats, opening the grid — is identical, because //! it goes through `dr-sync` rather than through a connector. use dr_sync::{Account, AccountStore}; use dr_types::{Format, FormatFilter}; /// What the launch screen is currently doing. #[derive(Debug, Clone, PartialEq, Eq)] pub enum LaunchState { /// No account configured; waiting for a server address. SignedOut, /// A login flow is open and the user must approve it in a browser. /// /// Carries the URL so the screen can show and copy it — the app never /// handles the password itself (FR-NC-1). AwaitingApproval { login_url: String }, /// An account is configured. SignedIn { session: Account }, /// Working; the reason is shown so a pause is never unexplained. /// /// Carries the session where there is one, so a failure mid-work returns /// to the signed-in screen rather than signing the user out. Busy { message: String, session: Option>, }, } /// What the app opens on. /// /// Three outcomes, and the middle one is easy to lose: a configured library /// means the launch screen is skipped, and skipping it must not also skip /// opening the library — otherwise the app lands on an empty view with no /// route back to the grid, because the "Open library" button is on the screen /// that was never shown. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Startup { /// Files were named on the command line; show those. ShowLocalFiles, /// An account and a folder are configured; scan and show the grid. OpenLibrary, /// Nothing configured; ask the user to sign in. ShowLaunchScreen, } /// TRACES: FR-NC-1 | FR-NC-4 | M-1 | M-3 | M-4 /// Everything the launch screen renders from. #[derive(Debug, Clone)] pub struct LaunchModel { pub state: LaunchState, /// Last-used server, prefilled so a returning user need not retype it. pub server_url: String, /// Last-used folder, prefilled for the same reason. /// /// Separate from `server_url` rather than one "endpoint" field, because /// the screen shows both at once: someone deciding between the two should /// not have to clear one to try the other. pub folder_path: String, pub error: Option, pub status: Option, /// False where no secrets daemon exists (FR-NC-2). The screen must say so /// up front rather than letting the user discover it next launch. pub can_remember: bool, /// Which formats to scan for, in `Format::ALL` order. pub formats: Vec<(Format, bool)>, /// Folder picker state, `None` when it is closed. pub browser: Option, } /// The folder picker: where it is, and what is there. /// /// Descends one level at a time because that is what the backend supports — /// `Depth: infinity` is frequently disabled server-side and prohibitively /// expensive where it is not (ARCH §8.4). #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct FolderBrowser { /// Path being listed. Empty is the account root. pub path: String, /// Child folder names at `path`, not full paths. pub entries: Vec, /// True while a listing is in flight. pub loading: bool, } impl FolderBrowser { /// Breadcrumb segments, root first. pub fn breadcrumbs(&self) -> Vec { let mut out = vec!["/".to_string()]; out.extend( self.path .split('/') .filter(|s| !s.is_empty()) .map(str::to_string), ); out } /// The path after descending into `name`. pub fn child_path(&self, name: &str) -> String { if self.path.is_empty() { name.to_string() } else { format!("{}/{}", self.path, name) } } /// The parent path, or `None` at the root. pub fn parent_path(&self) -> Option { if self.path.is_empty() { None } else { Some(match self.path.rsplit_once('/') { Some((head, _)) => head.to_string(), None => String::new(), }) } } /// Truncate to the breadcrumb at `index` (0 is the root). pub fn path_at_crumb(&self, index: usize) -> String { if index == 0 { return String::new(); } self.path .split('/') .filter(|s| !s.is_empty()) .take(index) .collect::>() .join("/") } } impl Default for LaunchModel { fn default() -> Self { Self { state: LaunchState::SignedOut, server_url: String::new(), folder_path: String::new(), error: None, status: None, can_remember: true, formats: Format::ALL.iter().map(|f| (*f, f.is_raw())).collect(), browser: None, } } } impl LaunchModel { /// Build from stored sessions, resuming the last account if there is one. pub fn from_store(store: &AccountStore) -> Self { let can_remember = store.can_remember(); match store.current() { Some(session) => { let filter = session.format_filter(); let (server_url, folder_path) = prefill(&session); Self { server_url, folder_path, formats: Format::ALL .iter() .map(|f| (*f, filter.allows(*f))) .collect(), state: LaunchState::SignedIn { session }, can_remember, ..Default::default() } } None => Self { can_remember, ..Default::default() }, } } pub fn is_signed_in(&self) -> bool { matches!(self.state, LaunchState::SignedIn { .. }) } pub fn is_busy(&self) -> bool { matches!(self.state, LaunchState::Busy { .. }) } pub fn session(&self) -> Option<&Account> { match &self.state { LaunchState::SignedIn { session } => Some(session), // A session survives a busy period; a scan failure must not log // the user out. LaunchState::Busy { session: Some(s), .. } => Some(s), _ => None, } } /// The account line, e.g. "duncan on cloud.example". pub fn account_label(&self) -> String { self.session().map(|s| s.describe()).unwrap_or_default() } /// The chosen library folder, empty until one is picked. pub fn library_root(&self) -> String { self.session().map(|s| s.root.clone()).unwrap_or_default() } /// Whether a root has been settled on, including the top level itself. /// /// Three ways to have one: a subfolder is named; the picker confirmed /// the top level, which `Account::root_chosen` records because the /// empty string cannot; or the endpoint is a folder, whose top level is /// the library by definition. pub fn root_is_chosen(&self) -> bool { self.session() .is_some_and(|s| !s.root.is_empty() || s.root_chosen) || self.endpoint_is_library() } /// What the signed-in section shows for the root: empty when nothing is /// chosen, `/` for the top level, else the path. pub fn library_root_label(&self) -> String { let root = self.library_root(); if root.is_empty() && self.root_is_chosen() { "/".to_string() } else { root } } /// Whether the endpoint itself is the library. /// /// A folder account has no sub-root to choose: the directory the user /// typed is the whole library, and asking them to pick a "library /// folder" inside it a second time reads as though the first answer was /// not taken. A cloud account is the opposite — its endpoint is an /// entire server-side tree, and a root inside it is required (see /// [`can_open_library`](Self::can_open_library)). Keyed on the absence /// of a login, the same fact [`prefill`] keys on, rather than on the /// connector's id. pub fn endpoint_is_library(&self) -> bool { self.session().is_some_and(|s| s.login.is_empty()) } /// The login URL while approval is pending. pub fn login_url(&self) -> String { match &self.state { LaunchState::AwaitingApproval { login_url } => login_url.clone(), _ => String::new(), } } /// Whether "Open library" should be clickable. /// /// A cloud account requires a signed-in session *and* a chosen folder: /// opening without one would scan the whole account, which on a real /// library is thousands of directories the user did not ask for. Chosen /// is the operative word, not non-empty — the top level is a legitimate /// choice, and a folder library is the whole folder by definition. pub fn can_open_library(&self) -> bool { self.is_signed_in() && self.root_is_chosen() } /// What the app should do on startup. /// /// Pure so it can be tested without a secret store or a display server — /// and it needs testing, because the interesting case is the one with no /// visible symptom until you are staring at an empty window. pub fn startup_action(&self, have_local_paths: bool) -> Startup { if have_local_paths { // Files named on the command line win: the user asked for those // specifically, not for their library. Startup::ShowLocalFiles } else if self.can_open_library() { Startup::OpenLibrary } else { Startup::ShowLaunchScreen } } pub fn format_filter(&self) -> FormatFilter { FormatFilter::from_formats(self.formats.iter().filter(|(_, on)| *on).map(|(f, _)| *f)) } /// Toggle one format tick-box. pub fn set_format(&mut self, index: usize, enabled: bool) { if let Some(entry) = self.formats.get_mut(index) { entry.1 = enabled; } } // --- transitions --------------------------------------------------- pub fn begin_sign_in(&mut self, server: impl Into) { self.server_url = server.into(); self.error = None; self.state = LaunchState::Busy { message: "Contacting server…".into(), session: None, }; } /// TRACES: FR-NC-1 /// Begin a sign-in with an app password the user supplied directly. /// /// The browser flow is the recommended route because it never exposes a /// credential to us, but it needs a browser and a round trip through one. /// An app password is what Nextcloud offers instead: device-scoped, /// individually revocable, and created by the user under Settings → /// Security. That makes it the workable option where no browser can /// complete the handshake. pub fn begin_direct_sign_in(&mut self, server: impl Into) { self.server_url = server.into(); self.error = None; self.state = LaunchState::Busy { message: "Checking the credentials…".into(), session: None, }; } pub fn await_approval(&mut self, login_url: impl Into) { self.status = Some("Approve the sign-in in your browser.".into()); self.state = LaunchState::AwaitingApproval { login_url: login_url.into(), }; } pub fn signed_in(&mut self, session: Account) { self.error = None; self.status = None; let (server_url, folder_path) = prefill(&session); self.server_url = server_url; if !folder_path.is_empty() { self.folder_path = folder_path; } let filter = session.format_filter(); // Adopt the session's stored selection, so a returning user sees the // tick-boxes they left. if !session.formats.is_empty() { self.formats = Format::ALL .iter() .map(|f| (*f, filter.allows(*f))) .collect(); } self.state = LaunchState::SignedIn { session }; } pub fn signed_out(&mut self) { self.error = None; self.status = None; self.state = LaunchState::SignedOut; } pub fn fail(&mut self, message: impl Into) { self.status = None; self.error = Some(message.into()); // Return to whichever resting state makes sense, so a failure never // strands the screen in Busy with no way forward. self.state = match self.session() { Some(s) => LaunchState::SignedIn { session: s.clone() }, None => LaunchState::SignedOut, }; } // --- folder picker -------------------------------------------------- /// Open the picker at the account root. pub fn open_browser(&mut self) { self.error = None; self.browser = Some(FolderBrowser { path: String::new(), entries: Vec::new(), loading: true, }); } pub fn close_browser(&mut self) { self.browser = None; } /// Begin listing `path`. pub fn browse_to(&mut self, path: impl Into) { let path = path.into(); match &mut self.browser { Some(b) => { b.path = path; b.entries.clear(); b.loading = true; } None => { self.browser = Some(FolderBrowser { path, entries: Vec::new(), loading: true, }) } } } /// Record a completed listing. pub fn browser_loaded(&mut self, entries: Vec) { if let Some(b) = &mut self.browser { b.entries = entries; b.loading = false; } } /// Adopt the picker's current path as the library root. /// /// Returns the session to persist, or `None` when signed out. pub fn choose_current_folder(&mut self) -> Option { let path = self.browser.as_ref()?.path.clone(); let mut session = self.session()?.clone(); session.root = path; // Even when `path` is the top level: that is a choice, and the // empty string alone would read as none having been made. session.root_chosen = true; self.browser = None; self.state = LaunchState::SignedIn { session: session.clone(), }; Some(session) } pub fn busy(&mut self, message: impl Into) { self.error = None; let session = self.session().cloned().map(Box::new); self.state = LaunchState::Busy { message: message.into(), session, }; } } /// Which of the two entry fields an account's endpoint belongs in. /// /// A returning user should find what they typed last time where they typed /// it. Keyed on whether the connector has a login rather than on its id, so a /// third backend does not have to be named here to be prefilled correctly. fn prefill(account: &Account) -> (String, String) { if account.login.is_empty() { (String::new(), account.endpoint.clone()) } else { (account.endpoint.clone(), String::new()) } } #[cfg(test)] mod tests { use super::*; use dr_plat::EphemeralSecretStore; use dr_sync::Secret; fn session_with_root(root: &str) -> Account { let mut s = Account::new("nextcloud", "https://cloud.example").with_login("duncan", "duncan"); s.root = root.into(); s } fn folder_with_root(root: &str) -> Account { let mut s = Account::new("folder", "/mnt/photos"); s.root = root.into(); s } #[test] fn a_fresh_model_is_signed_out_with_raw_preselected() { let m = LaunchModel::default(); assert!(!m.is_signed_in()); // RAW ticked, JPEG not: a RAW editor's sensible default. let f = m.format_filter(); assert!(f.allows(Format::Cr2)); assert!(!f.allows(Format::Jpeg)); } #[test] fn opening_a_library_needs_both_an_account_and_a_folder() { let mut m = LaunchModel::default(); assert!(!m.can_open_library(), "signed out"); m.signed_in(session_with_root("")); assert!( !m.can_open_library(), "no folder — would scan the whole account" ); m.signed_in(session_with_root("PhotosRaw")); assert!(m.can_open_library()); } #[test] fn a_configured_library_opens_rather_than_showing_nothing() { // The regression this guards: skipping the launch screen because a // library is configured used to mean the library was never opened, // since `on_open_library` only fires from a button nobody saw. let mut m = LaunchModel::default(); m.signed_in(session_with_root("PhotosRaw")); assert_eq!(m.startup_action(false), Startup::OpenLibrary); } #[test] fn no_configuration_shows_the_launch_screen() { let m = LaunchModel::default(); assert_eq!(m.startup_action(false), Startup::ShowLaunchScreen); } #[test] fn a_signed_in_account_without_a_folder_still_needs_the_screen() { // Opening without a chosen folder would scan the whole account. let mut m = LaunchModel::default(); m.signed_in(session_with_root("")); assert_eq!(m.startup_action(false), Startup::ShowLaunchScreen); } #[test] fn command_line_files_win_over_a_configured_library() { // The user asked for those files specifically. let mut m = LaunchModel::default(); m.signed_in(session_with_root("PhotosRaw")); assert_eq!(m.startup_action(true), Startup::ShowLocalFiles); } #[test] fn a_failure_never_strands_the_screen_in_busy() { let mut m = LaunchModel::default(); m.begin_sign_in("cloud.example"); assert!(m.is_busy()); m.fail("server unreachable"); assert!(!m.is_busy(), "must return to a resting state"); assert_eq!(m.state, LaunchState::SignedOut); assert!(m.error.is_some()); } #[test] fn a_failure_while_signed_in_returns_to_signed_in() { let mut m = LaunchModel::default(); m.signed_in(session_with_root("PhotosRaw")); m.busy("Scanning…"); m.fail("scan failed"); assert!(m.is_signed_in(), "a failed scan must not sign the user out"); assert!(m.error.is_some()); } #[test] fn the_login_url_is_exposed_only_while_pending() { let mut m = LaunchModel::default(); assert_eq!(m.login_url(), ""); m.await_approval("https://cloud.example/login/v2/flow/abc"); assert!(m.login_url().contains("/flow/abc")); m.signed_in(session_with_root("")); assert_eq!(m.login_url(), "", "must not linger after sign-in"); } #[test] fn a_returning_user_finds_their_endpoint_where_they_typed_it() { // Two entry fields are shown at once. Prefilling the wrong one — a // folder path into the server box — reads as a corrupted setting. let mut m = LaunchModel::default(); m.signed_in(session_with_root("PhotosRaw")); assert_eq!(m.server_url, "https://cloud.example"); assert_eq!(m.folder_path, ""); let mut m = LaunchModel::default(); m.signed_in(folder_with_root("2026")); assert_eq!(m.folder_path, "/mnt/photos"); assert_eq!(m.server_url, ""); } #[test] fn a_folder_library_reaches_the_grid_the_same_way_a_server_one_does() { // Everything past sign-in is backend-neutral, and this is the check // that keeps it so: no branch on the account's connector below here. let mut m = LaunchModel::default(); m.signed_in(folder_with_root("2026")); assert!(m.is_signed_in()); assert!(m.can_open_library()); assert_eq!(m.startup_action(false), Startup::OpenLibrary); assert_eq!(m.account_label(), "/mnt/photos/2026"); } #[test] fn a_folder_is_the_whole_library_without_choosing_a_root() { // The directory typed on the launch screen is the answer to "which // folder"; a second question with the same name is what confused // the first person to open one. A server account still needs a // root, because its endpoint is the entire tree. let mut m = LaunchModel::default(); m.signed_in(folder_with_root("")); assert!(m.endpoint_is_library()); assert!(m.can_open_library(), "the folder itself is the library"); assert_eq!(m.library_root_label(), "/"); assert_eq!(m.startup_action(false), Startup::OpenLibrary); m.signed_in(session_with_root("")); assert!(!m.endpoint_is_library()); assert!(!m.can_open_library(), "a server account needs a root"); assert_eq!(m.startup_action(false), Startup::ShowLaunchScreen); } #[test] fn format_toggles_apply_and_out_of_range_is_ignored() { let mut m = LaunchModel::default(); let jpeg = Format::ALL.iter().position(|f| *f == Format::Jpeg).unwrap(); m.set_format(jpeg, true); assert!(m.format_filter().allows(Format::Jpeg)); // Must not panic on a stale index from the UI. m.set_format(9999, true); } #[test] fn a_stored_session_is_resumed_with_its_format_selection() { let dir = std::env::temp_dir().join("darkroom-launch-test"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let store = AccountStore::open_at( dir.join("sessions.json"), Box::new(EphemeralSecretStore::new()), ); let mut s = session_with_root("PhotosRaw"); s.set_format_filter(&FormatFilter::from_formats([Format::Cr2])); store.save(&s, Some(&Secret::new("token"))).unwrap(); let m = LaunchModel::from_store(&store); assert!(m.is_signed_in()); assert_eq!(m.library_root(), "PhotosRaw"); assert!(m.format_filter().allows(Format::Cr2)); assert!(!m.format_filter().allows(Format::Dng)); assert_eq!(m.server_url, "https://cloud.example"); } #[test] fn no_stored_session_yields_a_signed_out_model() { let dir = std::env::temp_dir().join("darkroom-launch-empty"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let store = AccountStore::open_at( dir.join("sessions.json"), Box::new(EphemeralSecretStore::new()), ); let m = LaunchModel::from_store(&store); assert!(!m.is_signed_in()); } #[test] fn browsing_descends_and_ascends() { let mut m = LaunchModel::default(); m.signed_in(session_with_root("")); m.open_browser(); let b = m.browser.as_ref().unwrap(); assert_eq!(b.path, "", "opens at the account root"); assert!(b.loading); m.browser_loaded(vec!["Photos".into(), "Archive".into()]); assert!(!m.browser.as_ref().unwrap().loading); let child = m.browser.as_ref().unwrap().child_path("Photos"); assert_eq!(child, "Photos"); m.browse_to(child); m.browser_loaded(vec!["2026".into()]); let deeper = m.browser.as_ref().unwrap().child_path("2026"); assert_eq!(deeper, "Photos/2026"); m.browse_to(deeper); assert_eq!( m.browser.as_ref().unwrap().parent_path(), Some("Photos".into()) ); } #[test] fn the_root_has_no_parent() { let b = FolderBrowser::default(); assert_eq!(b.parent_path(), None, "no way up from the account root"); } #[test] fn ascending_from_a_top_level_folder_reaches_the_root() { let b = FolderBrowser { path: "Photos".into(), ..Default::default() }; assert_eq!(b.parent_path(), Some(String::new())); } #[test] fn confirming_sets_the_library_root() { let mut m = LaunchModel::default(); m.signed_in(session_with_root("")); m.open_browser(); m.browse_to("Photos/2026"); let session = m.choose_current_folder().expect("a session"); assert_eq!(session.root, "Photos/2026"); assert_eq!(m.library_root(), "Photos/2026"); assert!(m.browser.is_none(), "picker closes on confirm"); assert!(m.can_open_library()); } #[test] fn confirming_at_the_root_selects_the_whole_account() { // Legitimate: a user may keep everything at the top level — and it // must then open. It used to be recorded as an empty root, which // is indistinguishable from no root, so "Open library" stayed // disabled after the picker had plainly been answered. let mut m = LaunchModel::default(); m.signed_in(session_with_root("")); assert!(!m.can_open_library(), "nothing chosen yet"); assert_eq!(m.library_root_label(), ""); m.open_browser(); let session = m.choose_current_folder().expect("a session"); assert_eq!(session.root, ""); assert!( session.root_chosen, "the choice survives to the config file" ); assert!(m.can_open_library()); assert_eq!(m.library_root_label(), "/"); assert_eq!(m.startup_action(false), Startup::OpenLibrary); } #[test] fn cancelling_leaves_the_root_unchanged() { let mut m = LaunchModel::default(); m.signed_in(session_with_root("Original")); m.open_browser(); m.browse_to("Somewhere/Else"); m.close_browser(); assert!(m.browser.is_none()); assert_eq!(m.library_root(), "Original", "cancel must not select"); } #[test] fn confirming_while_signed_out_does_nothing() { let mut m = LaunchModel::default(); m.open_browser(); assert!(m.choose_current_folder().is_none()); } #[test] fn breadcrumbs_start_at_the_root() { let b = FolderBrowser { path: "Photos/2026/Trip".into(), ..Default::default() }; assert_eq!(b.breadcrumbs(), vec!["/", "Photos", "2026", "Trip"]); assert_eq!(b.path_at_crumb(0), ""); assert_eq!(b.path_at_crumb(2), "Photos/2026"); } #[test] fn account_label_is_empty_when_signed_out() { assert_eq!(LaunchModel::default().account_label(), ""); } }