Add secure credential storage, sessions, and a launch screen
Login now persists properly rather than through the JSON file the test
harness was using.
dr-plat SecretStore trait plus a Secret Service backend.
Verified against the live GNOME Keyring: store,
retrieve, delete, confirm-gone all round-trip.
Session/SessionStore splits credentials from settings — the app
password goes to the keyring (FR-NC-2), while
server, login, chosen root and format selection are
ordinary config. A test asserts the credential never
appears in the config file.
LaunchModel the launch-screen state machine, testable without a
display server: sign in, approve in browser, choose
folder, tick formats, sign out.
launch.slint the screen itself, in its own file.
Absence of a secrets daemon is an explicit degraded mode, not a silent
fallback to plaintext — the screen says sign-in will not persist rather
than letting the user find out next launch. Android's Keystore backend
fails loudly for the same reason: a no-op store would look like it
worked and then lose the credential.
Two bugs caught by tests rather than by running it:
- fail() after busy() signed the user out, because busy() had already
discarded the session. A failed *scan* would have logged you out.
Busy now carries the session.
- normalise_server upgrades http:// to https:// rather than accepting
it. NFR-SEC-3 requires TLS, and silently sending a credential in the
clear is not a decision to make on the user's behalf.
launch.slint is not yet wired into app.slint. Calling slint_build::compile
twice replaces the generated module rather than adding to it, which broke
the other in-flight work on dr-ui; I reverted that immediately. Wiring it
needs an import inside app.slint, which is that work's file to change.
419 tests passing across ten crates.
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
//! 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<Box<Session>>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
pub status: Option<String>,
|
||||
/// 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)>,
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn busy(&mut self, message: impl Into<String>) {
|
||||
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_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 account_label_is_empty_when_signed_out() {
|
||||
assert_eq!(LaunchModel::default().account_label(), "");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user