Wire the launch screen into the app

The app now opens on the login screen when there is nothing else to show
— no local paths and no configured library — and goes straight to the
images otherwise. Making someone click past a login they already
completed is pure friction.

  launch.slint       imported by app.slint, replacing the window rather
                     than overlaying it: there is no library to look at
                     until an account is configured
  launch_ui.rs       the Slint wiring, kept out of lib.rs so the launch
                     flow can change without touching the develop window

Login runs on a worker thread and posts results back through a channel,
since Slint's event loop is single-threaded and a 20-minute browser wait
cannot block it. The system browser is opened via xdg-open, never an
embedded webview (FR-NC-1).

Sign-out deletes the local credential even if server-side revocation
fails: a network error must not leave a usable secret on the machine.
Format tick-boxes persist on each toggle, so a selection survives a
crash before the library is opened.

Two things deliberately incomplete rather than faked:

  - "Choose folder" lists the account's folders and reports them, but
    there is no picker widget yet, so selection still happens via the
    connect example.
  - "Open library" logs the request. Opening a remote library needs the
    scan-and-cache path, which belongs with the catalog work in flight.

Earlier I broke the other in-flight dr-ui work by calling
slint_build::compile twice, which replaces the generated module. The
correct wiring is an import inside app.slint, which is what this does.

30 dr-ui tests passing; both launch paths verified by running the app.
This commit is contained in:
2026-08-09 15:34:46 +02:00
parent 09e3043f4c
commit f630a3ff81
11 changed files with 1255 additions and 8 deletions
+432
View File
@@ -0,0 +1,432 @@
//! Wires [`LaunchModel`](crate::launch::LaunchModel) to the Slint screen.
//!
//! Kept apart from `lib.rs` so the launch flow can evolve without touching
//! the develop window's wiring. The model holds the state machine and is
//! tested headless; this module only moves values across the boundary.
use std::cell::RefCell;
use std::rc::Rc;
use dr_plat::PlatformSecretStore;
use dr_sync::{RemoteBackend, RemotePath};
use dr_sync_nextcloud::{auth, NextcloudBackend, Session, SessionStore};
use slint::ComponentHandle;
use crate::launch::{LaunchModel, LaunchState};
use crate::AppWindow;
/// Shared launch state for the running window.
pub struct LaunchController {
pub model: RefCell<LaunchModel>,
pub store: SessionStore,
/// Holds any in-flight poll timer. A `Timer` stops when dropped, so it
/// must outlive its own callback — parking it here avoids an Rc cycle
/// between the timer and the closure it runs.
poll_timer: RefCell<Option<slint::Timer>>,
}
impl LaunchController {
pub fn new() -> Rc<Self> {
let store = SessionStore::open(Box::new(PlatformSecretStore::new()));
let model = LaunchModel::from_store(&store);
Rc::new(Self {
model: RefCell::new(model),
store,
poll_timer: RefCell::new(None),
})
}
/// Whether the app should open on the launch screen.
///
/// Only when there is nothing to show: a configured library goes straight
/// to the images, since making someone click past a login screen they
/// already completed is pure friction.
pub fn should_show(&self, have_local_paths: bool) -> bool {
!have_local_paths && !self.model.borrow().can_open_library()
}
}
/// Push the model into the window's properties.
pub fn render(window: &AppWindow, controller: &LaunchController) {
let m = controller.model.borrow();
window.set_launch_signed_in(m.is_signed_in());
window.set_launch_account(m.account_label().into());
window.set_launch_root(m.library_root().into());
window.set_launch_server(m.server_url.clone().into());
window.set_launch_busy(m.is_busy());
window.set_launch_login_url(m.login_url().into());
window.set_launch_can_remember(m.can_remember);
let status = match &m.state {
LaunchState::Busy { message, .. } => Some(message.clone()),
_ => m.status.clone(),
};
window.set_launch_status(status.unwrap_or_default().into());
window.set_launch_error(m.error.clone().unwrap_or_default().into());
let labels: Vec<slint::SharedString> = m
.formats
.iter()
.map(|(f, _)| slint::SharedString::from(f.label()))
.collect();
let checked: Vec<bool> = m.formats.iter().map(|(_, on)| *on).collect();
window.set_launch_format_labels(slint::ModelRc::new(slint::VecModel::from(labels)));
window.set_launch_format_checked(slint::ModelRc::new(slint::VecModel::from(checked)));
}
/// Connect the screen's callbacks.
///
/// `on_open_library` runs when the user opens a configured library, carrying
/// the session so the caller can start a scan.
pub fn wire<F>(window: &AppWindow, controller: Rc<LaunchController>, on_open_library: F)
where
F: Fn(Session) + 'static,
{
// --- sign in -------------------------------------------------------
{
let weak = window.as_weak();
let ctl = controller.clone();
window.on_launch_sign_in(move |server| {
let Some(w) = weak.upgrade() else { return };
ctl.model.borrow_mut().begin_sign_in(server.to_string());
render(&w, &ctl);
let server = ctl.model.borrow().server_url.clone();
spawn_login(w.as_weak(), ctl.clone(), server);
});
}
// --- sign out ------------------------------------------------------
{
let weak = window.as_weak();
let ctl = controller.clone();
window.on_launch_sign_out(move || {
let Some(w) = weak.upgrade() else { return };
// Forget locally regardless of whether revocation succeeds — a
// network failure must not leave the credential on this machine.
let session = ctl.model.borrow().session().cloned();
if let Some(s) = session {
if let Err(e) = ctl.store.forget(&s) {
log::warn!("sign out: {e}");
}
}
ctl.model.borrow_mut().signed_out();
render(&w, &ctl);
});
}
// --- format tick-boxes ---------------------------------------------
{
let weak = window.as_weak();
let ctl = controller.clone();
window.on_launch_format_toggled(move |index, enabled| {
let Some(w) = weak.upgrade() else { return };
ctl.model.borrow_mut().set_format(index as usize, enabled);
// Persist immediately, so a choice survives a crash before the
// library is opened.
let (session, filter) = {
let m = ctl.model.borrow();
(m.session().cloned(), m.format_filter())
};
if let Some(mut s) = session {
s.set_format_filter(&filter);
if let Err(e) = ctl.store.update(&s) {
log::warn!("saving format selection: {e}");
}
}
render(&w, &ctl);
});
}
// --- choose folder --------------------------------------------------
{
let weak = window.as_weak();
let ctl = controller.clone();
window.on_launch_choose_folder(move || {
let Some(w) = weak.upgrade() else { return };
ctl.model.borrow_mut().busy("Listing folders…");
render(&w, &ctl);
spawn_folder_list(w.as_weak(), ctl.clone());
});
}
// --- open library ---------------------------------------------------
{
let weak = window.as_weak();
let ctl = controller.clone();
window.on_launch_open_library(move || {
let Some(w) = weak.upgrade() else { return };
let session = ctl.model.borrow().session().cloned();
if let Some(s) = session {
w.set_show_launch(false);
on_open_library(s);
}
});
}
// --- copy the login URL ---------------------------------------------
{
let ctl = controller.clone();
window.on_launch_copy_url(move || {
let url = ctl.model.borrow().login_url();
if url.is_empty() {
return;
}
// No clipboard dependency yet; logging at least makes the URL
// selectable from a terminal.
log::info!("login url: {url}");
});
}
render(window, &controller);
}
/// Run Login Flow v2 without blocking the UI thread.
///
/// Slint's event loop is single-threaded, so the network work happens on a
/// worker and results are posted back with `invoke_from_event_loop`.
fn spawn_login(weak: slint::Weak<AppWindow>, ctl: Rc<LaunchController>, server: String) {
// The controller is not Send, so it stays here; only plain data crosses
// the thread boundary.
let (tx, rx) = std::sync::mpsc::channel::<LoginMessage>();
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
let _ = tx.send(LoginMessage::Failed(e.to_string()));
return;
}
};
rt.block_on(async {
let client = match dr_sync_nextcloud::http_client("DarkRoom") {
Ok(c) => c,
Err(e) => {
let _ = tx.send(LoginMessage::Failed(e.to_string()));
return;
}
};
let flow = match auth::begin(&client, &server, "DarkRoom").await {
Ok(f) => f,
Err(e) => {
let _ = tx.send(LoginMessage::Failed(e.to_string()));
return;
}
};
// Open the system browser, never an embedded webview (FR-NC-1).
let _ = open_in_browser(&flow.login_url);
let _ = tx.send(LoginMessage::AwaitingApproval(flow.login_url.clone()));
match auth::poll(&client, &flow).await {
Ok(creds) => {
let user_id = fetch_user_id(&client, &creds)
.await
.unwrap_or_else(|_| creds.login_name.clone());
let _ = tx.send(LoginMessage::Success(Box::new((creds, user_id))));
}
Err(e) => {
let _ = tx.send(LoginMessage::Failed(e.to_string()));
}
}
});
});
poll_channel(weak, ctl, rx);
}
enum LoginMessage {
AwaitingApproval(String),
Success(Box<(dr_sync_nextcloud::AppCredentials, String)>),
Failed(String),
}
/// Drain the worker's messages on the UI thread.
fn poll_channel(
weak: slint::Weak<AppWindow>,
ctl: Rc<LaunchController>,
rx: std::sync::mpsc::Receiver<LoginMessage>,
) {
let timer = slint::Timer::default();
let ctl_for_cb = ctl.clone();
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(200),
move || {
let Some(w) = weak.upgrade() else { return };
let ctl = &ctl_for_cb;
while let Ok(msg) = rx.try_recv() {
let mut done = false;
match msg {
LoginMessage::AwaitingApproval(url) => {
ctl.model.borrow_mut().await_approval(url);
}
LoginMessage::Success(boxed) => {
let (creds, user_id) = *boxed;
let session = Session::new(&creds, user_id);
if let Err(e) = ctl.store.save(&session, &creds) {
log::warn!("persisting session: {e}");
}
ctl.model.borrow_mut().signed_in(session);
done = true;
}
LoginMessage::Failed(e) => {
ctl.model.borrow_mut().fail(e);
done = true;
}
}
render(&w, ctl);
if done {
// Stopping from inside the callback is fine; the timer
// itself is owned by the controller, not this closure.
if let Some(t) = ctl.poll_timer.borrow().as_ref() {
t.stop();
}
}
}
},
);
*ctl.poll_timer.borrow_mut() = Some(timer);
}
/// List top-level folders so one can be chosen as the library root.
fn spawn_folder_list(weak: slint::Weak<AppWindow>, ctl: Rc<LaunchController>) {
let Some(session) = ctl.model.borrow().session().cloned() else {
return;
};
let creds = match ctl.store.credentials(&session) {
Ok(c) => c,
Err(e) => {
ctl.model.borrow_mut().fail(format!("credentials: {e}"));
if let Some(w) = weak.upgrade() {
render(&w, &ctl);
}
return;
}
};
let (tx, rx) = std::sync::mpsc::channel::<Result<Vec<String>, String>>();
let user_id = session.user_id.clone();
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build();
let Ok(rt) = rt else {
let _ = tx.send(Err("runtime".into()));
return;
};
rt.block_on(async {
match NextcloudBackend::new(&creds, &user_id) {
Ok(b) => match b.list(&RemotePath::root(), None).await {
Ok(entries) => {
let mut dirs: Vec<String> = entries
.iter()
.filter(|e| e.kind == dr_sync::EntryKind::Directory)
.map(|e| e.path.name().to_string())
.collect();
dirs.sort_by_key(|d| d.to_ascii_lowercase());
let _ = tx.send(Ok(dirs));
}
Err(e) => {
let _ = tx.send(Err(e.to_string()));
}
},
Err(e) => {
let _ = tx.send(Err(e.to_string()));
}
}
});
});
let timer = slint::Timer::default();
let ctl_for_cb = ctl.clone();
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(150),
move || {
let Some(w) = weak.upgrade() else { return };
let ctl = &ctl_for_cb;
if let Ok(result) = rx.try_recv() {
match result {
Ok(dirs) => {
// No folder picker yet: report what is there and
// leave selection to the CLI, rather than
// pretending to offer a chooser.
let msg = if dirs.is_empty() {
"No folders found.".to_string()
} else {
format!("Folders: {}", dirs.join(", "))
};
let session = ctl.model.borrow().session().cloned();
if let Some(s) = session {
ctl.model.borrow_mut().signed_in(s);
}
ctl.model.borrow_mut().status = Some(msg);
}
Err(e) => ctl.model.borrow_mut().fail(e),
}
render(&w, ctl);
if let Some(t) = ctl.poll_timer.borrow().as_ref() {
t.stop();
}
}
},
);
*ctl.poll_timer.borrow_mut() = Some(timer);
}
async fn fetch_user_id(
client: &reqwest::Client,
creds: &dr_sync_nextcloud::AppCredentials,
) -> Result<String, String> {
let url = format!(
"{}/ocs/v2.php/cloud/user?format=json",
creds.server.trim_end_matches('/')
);
let body = client
.get(&url)
.basic_auth(&creds.login_name, Some(&creds.app_password))
.header("OCS-APIRequest", "true")
.send()
.await
.map_err(|e| e.to_string())?
.text()
.await
.map_err(|e| e.to_string())?;
let v: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?;
v["ocs"]["data"]["id"]
.as_str()
.map(str::to_string)
.ok_or_else(|| "no id in OCS response".into())
}
/// Open a URL in the system browser.
fn open_in_browser(url: &str) -> std::io::Result<()> {
#[cfg(target_os = "linux")]
{
std::process::Command::new("xdg-open")
.arg(url)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.map(|_| ())
}
#[cfg(not(target_os = "linux"))]
{
let _ = url;
Ok(())
}
}
+19
View File
@@ -27,6 +27,7 @@ use dr_decode::{Metadata, PreviewSize};
pub use develop::DevelopSession;
pub mod launch;
pub mod launch_ui;
slint::include_modules!();
@@ -203,6 +204,24 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let window = AppWindow::new()?;
// Launch screen: shown when there is nothing to display — no local paths
// and no configured library. A user who has already signed in and chosen
// a folder goes straight to their images (FR-NC-1).
{
let controller = launch_ui::LaunchController::new();
let show = controller.should_show(!paths.is_empty());
window.set_show_launch(show);
launch_ui::wire(&window, controller, |session| {
// Opening a remote library needs the scan-and-cache path, which
// lands with the catalog. Reporting that plainly beats a button
// that silently does nothing.
log::info!("open library requested for {}", session.describe());
});
if show {
log::info!("no library configured — showing the launch screen");
}
}
// The device is shared by demosaic and the adjust pass. Without one the
// app still browses through the preview path, just without develop.
let gpu = match pollster::block_on(dr_gpu::GpuContext::new_headless()) {