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:
@@ -12,7 +12,11 @@ dr-types.workspace = true
|
||||
# must come off when S1 lands (ARCH §6.1, AC-8).
|
||||
dr-gpu = { workspace = true, features = ["readback"] }
|
||||
dr-decode.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
reqwest.workspace = true
|
||||
dr-plat.workspace = true
|
||||
dr-sync.workspace = true
|
||||
dr-sync-nextcloud.workspace = true
|
||||
dr-pipeline.workspace = true
|
||||
slint = { workspace = true, features = ["compat-1-2", "renderer-femtovg", "backend-winit"] }
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
+51
-1
@@ -1,5 +1,6 @@
|
||||
import { Theme } from "theme.slint";
|
||||
import { AdjustPanel, ParamRow } from "adjust.slint";
|
||||
import { LaunchScreen } from "launch.slint";
|
||||
|
||||
// Status strip — surfaces the GPU backend and adapter, which matters during
|
||||
// v0.1 because assumption A1 is exactly "does this compositing path work on
|
||||
@@ -137,6 +138,30 @@ export component AppWindow inherits Window {
|
||||
in property <int> total: 0;
|
||||
in property <string> load-error: "";
|
||||
|
||||
// --- launch screen (FR-NC-1, FR-NC-4) ---
|
||||
//
|
||||
// The app opens here when no library is configured, and returns here to
|
||||
// sign out or switch account.
|
||||
in property <bool> show-launch: false;
|
||||
in property <bool> launch-signed-in: false;
|
||||
in property <string> launch-account: "";
|
||||
in property <string> launch-root: "";
|
||||
in property <string> launch-server: "";
|
||||
in property <bool> launch-busy: false;
|
||||
in property <string> launch-status: "";
|
||||
in property <string> launch-error: "";
|
||||
in property <string> launch-login-url: "";
|
||||
in property <bool> launch-can-remember: true;
|
||||
in-out property <[string]> launch-format-labels;
|
||||
in-out property <[bool]> launch-format-checked;
|
||||
|
||||
callback launch-sign-in(string);
|
||||
callback launch-sign-out();
|
||||
callback launch-choose-folder();
|
||||
callback launch-open-library();
|
||||
callback launch-format-toggled(int, bool);
|
||||
callback launch-copy-url();
|
||||
|
||||
callback next-image();
|
||||
callback prev-image();
|
||||
|
||||
@@ -164,7 +189,32 @@ export component AppWindow inherits Window {
|
||||
// One-way: report width outward, never read layout back into it.
|
||||
changed width => { root.window-resized(self.width); }
|
||||
|
||||
VerticalLayout {
|
||||
// The launch screen replaces the whole window rather than overlaying it:
|
||||
// there is no library to look at until an account is configured.
|
||||
if root.show-launch: LaunchScreen {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
signed-in: root.launch-signed-in;
|
||||
account: root.launch-account;
|
||||
library-root: root.launch-root;
|
||||
server-url: root.launch-server;
|
||||
busy: root.launch-busy;
|
||||
status: root.launch-status;
|
||||
error: root.launch-error;
|
||||
login-url: root.launch-login-url;
|
||||
can-remember: root.launch-can-remember;
|
||||
format-labels: root.launch-format-labels;
|
||||
format-checked: root.launch-format-checked;
|
||||
|
||||
sign-in(server) => { root.launch-sign-in(server); }
|
||||
sign-out() => { root.launch-sign-out(); }
|
||||
choose-folder() => { root.launch-choose-folder(); }
|
||||
open-library() => { root.launch-open-library(); }
|
||||
format-toggled(i, on) => { root.launch-format-toggled(i, on); }
|
||||
copy-login-url() => { root.launch-copy-url(); }
|
||||
}
|
||||
|
||||
if !root.show-launch: VerticalLayout {
|
||||
StatusBar {
|
||||
adapter: root.adapter;
|
||||
backend: root.backend;
|
||||
|
||||
Reference in New Issue
Block a user