//! 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. use dr_sync_nextcloud::{Session, SessionStore}; 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: Session }, /// 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, 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(), 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: &SessionStore) -> Self { let can_remember = store.can_remember(); match store.current() { Some(session) => { let filter = session.format_filter(); Self { server_url: session.server.clone(), 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<&Session> { 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() } /// 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. /// /// Requires a signed-in account *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. pub fn can_open_library(&self) -> bool { self.is_signed_in() && !self.library_root().is_empty() } /// 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 = normalise_server(&server.into()); self.error = None; self.state = LaunchState::Busy { message: "Contacting server…".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: Session) { self.error = None; self.status = None; self.server_url = session.server.clone(); 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; 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, }; } } /// Normalise a server address typed by hand. /// /// Users type `cloud.example.com`, not a URL. Assume HTTPS rather than /// failing, and never silently accept plain HTTP — NFR-SEC-3 requires TLS, /// and an unencrypted default would be a security decision made on the user's /// behalf without telling them. pub fn normalise_server(input: &str) -> String { let s = input.trim().trim_end_matches('/'); if s.is_empty() { return String::new(); } if s.starts_with("https://") { s.to_string() } else if let Some(rest) = s.strip_prefix("http://") { // Upgrade rather than accept. If the server genuinely has no TLS the // connection fails loudly, which is the correct outcome. format!("https://{rest}") } else { format!("https://{s}") } } #[cfg(test)] mod tests { use super::*; use dr_plat::EphemeralSecretStore; use dr_sync_nextcloud::AppCredentials; fn creds() -> AppCredentials { AppCredentials { server: "https://cloud.example".into(), login_name: "duncan".into(), app_password: "token".into(), } } fn session_with_root(root: &str) -> Session { let mut s = Session::new(&creds(), "duncan"); 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 server_addresses_are_normalised_to_https() { assert_eq!(normalise_server("cloud.example"), "https://cloud.example"); assert_eq!( normalise_server("https://cloud.example/"), "https://cloud.example" ); assert_eq!( normalise_server(" cloud.example "), "https://cloud.example" ); assert_eq!(normalise_server(""), ""); } #[test] fn plain_http_is_upgraded_rather_than_accepted() { // NFR-SEC-3: TLS is required. Failing loudly beats silently sending a // credential in the clear. assert_eq!( normalise_server("http://cloud.example"), "https://cloud.example" ); } #[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 = SessionStore::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, &creds()).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 = SessionStore::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. let mut m = LaunchModel::default(); m.signed_in(session_with_root("")); m.open_browser(); let session = m.choose_current_folder().expect("a session"); assert_eq!(session.root, ""); } #[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(), ""); } }