Thirty-nine spawn sites in dr-ui, and one in the Android entry point, called std::thread::spawn or a Builder of their own, and most of the threads they started were <unnamed> in a panic message or a profiler. Each now calls executors::spawn with its executor and a role, so the thread is named <executor>:<role> — net:sync, decode:thumbs, io:catalog-open — and knows which executor it is on. The three that already set a name (automation, import, prefetch) keep their name as the role. Behaviour is unchanged: each job still gets a thread of its own when it starts, and spawn panics where std::thread::spawn did. The module's documentation now says how a job is assigned: by what it spends its time on, so a sweep that fetches bytes and then decodes them is Decode, and a sidecar write that touches the catalog is Network. Left as they were: the segmentation and refine workers in masks_ui.rs, which another change is reworking, and test-only threads.
1133 lines
44 KiB
Rust
1133 lines
44 KiB
Rust
//! 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 crate::executors::{self, Executor};
|
|
use std::cell::RefCell;
|
|
use std::rc::Rc;
|
|
|
|
use dr_plat::PlatformSecretStore;
|
|
use dr_sync::{Account, AccountStore, BackendProvider, RemotePath};
|
|
use dr_sync_nextcloud::{auth, NextcloudProvider};
|
|
|
|
use slint::ComponentHandle;
|
|
|
|
use crate::launch::{LaunchModel, LaunchState};
|
|
use crate::{AppWindow, View};
|
|
|
|
/// Shared launch state for the running window.
|
|
pub struct LaunchController {
|
|
pub model: RefCell<LaunchModel>,
|
|
pub store: AccountStore,
|
|
/// 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 = AccountStore::open(Box::new(PlatformSecretStore::new()));
|
|
// Before anything reads an account: an endpoint an older build stored
|
|
// as `http://` is refused by the client, so resuming it unrewritten
|
|
// would fail the library it names (NFR-SEC-3).
|
|
store.upgrade_endpoints(crate::remote::registry());
|
|
let model = LaunchModel::from_store(&store);
|
|
Rc::new(Self {
|
|
model: RefCell::new(model),
|
|
store,
|
|
poll_timer: RefCell::new(None),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// 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_label().into());
|
|
window.set_launch_endpoint_is_library(m.endpoint_is_library());
|
|
window.set_launch_server(m.server_url.clone().into());
|
|
window.set_launch_folder(m.folder_path.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());
|
|
|
|
// Folder picker.
|
|
let browsing = m.browser.is_some();
|
|
window.set_launch_browsing(browsing);
|
|
if let Some(b) = &m.browser {
|
|
window.set_launch_browse_path(b.path.clone().into());
|
|
window.set_launch_browse_loading(b.loading);
|
|
let entries: Vec<slint::SharedString> = b
|
|
.entries
|
|
.iter()
|
|
.map(|e| slint::SharedString::from(e.as_str()))
|
|
.collect();
|
|
window.set_launch_browse_entries(slint::ModelRc::new(slint::VecModel::from(entries)));
|
|
}
|
|
|
|
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(Account) + 'static,
|
|
{
|
|
wire_sign_in(window, &controller);
|
|
wire_use_folder(window, &controller);
|
|
wire_sign_out(window, &controller);
|
|
wire_formats(window, &controller);
|
|
wire_choose_folder_and_open(window, &controller, on_open_library);
|
|
wire_folder_picker_navigation(window, &controller);
|
|
wire_copy_url(window, &controller);
|
|
|
|
render(window, &controller);
|
|
}
|
|
|
|
/// Sign in: the browser flow, and the app-password fallback.
|
|
fn wire_sign_in(window: &AppWindow, controller: &Rc<LaunchController>) {
|
|
{
|
|
let weak = window.as_weak();
|
|
let ctl = controller.clone();
|
|
window.on_launch_sign_in(move |server| {
|
|
log::info!("sign-in requested for {server:?}");
|
|
let Some(w) = weak.upgrade() else { return };
|
|
// The connector owns what a valid address is — assuming HTTPS
|
|
// here would put one backend's rule in the interface.
|
|
let server = match NextcloudProvider.normalise_endpoint(&server) {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
ctl.model.borrow_mut().fail(e);
|
|
render(&w, &ctl);
|
|
return;
|
|
}
|
|
};
|
|
ctl.model.borrow_mut().begin_sign_in(server);
|
|
render(&w, &ctl);
|
|
|
|
let server = ctl.model.borrow().server_url.clone();
|
|
log::info!("starting login flow against {server}");
|
|
spawn_login(w.as_weak(), ctl.clone(), server);
|
|
});
|
|
}
|
|
|
|
// Sign in with an app password.
|
|
{
|
|
let weak = window.as_weak();
|
|
let ctl = controller.clone();
|
|
window.on_launch_sign_in_direct(move |server, login, password| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let server = match NextcloudProvider.normalise_endpoint(&server) {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
ctl.model.borrow_mut().fail(e);
|
|
render(&w, &ctl);
|
|
return;
|
|
}
|
|
};
|
|
ctl.model.borrow_mut().begin_direct_sign_in(server);
|
|
render(&w, &ctl);
|
|
|
|
let server = ctl.model.borrow().server_url.clone();
|
|
spawn_direct_login(
|
|
w.as_weak(),
|
|
ctl.clone(),
|
|
server,
|
|
login.to_string(),
|
|
password.to_string(),
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Use a folder.
|
|
///
|
|
/// No thread, no waiting state, no credential: the whole sign-in is a
|
|
/// `stat`. That asymmetry with the browser flow above is not a special
|
|
/// case in the screen — it is what [`SignIn::EndpointOnly`] means, and any
|
|
/// future connector declaring it lands here rather than in new code.
|
|
fn wire_use_folder(window: &AppWindow, controller: &Rc<LaunchController>) {
|
|
{
|
|
let weak = window.as_weak();
|
|
let ctl = controller.clone();
|
|
window.on_launch_use_folder(move |path| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
use_folder(&w, &ctl, &path);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let ctl = controller.clone();
|
|
window.on_launch_browse_local_folder(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let start = ctl.model.borrow().folder_path.clone();
|
|
let (weak, ctl) = (weak.clone(), ctl.clone());
|
|
crate::folder_dialog::ask(
|
|
&w,
|
|
"Library folder",
|
|
crate::folder_dialog::Pick::Folder,
|
|
Some(&start),
|
|
move |path| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
use_folder(&w, &ctl, &path.to_string_lossy());
|
|
},
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
fn use_folder(w: &AppWindow, ctl: &Rc<LaunchController>, path: &str) {
|
|
match open_folder_library(&ctl.store, path) {
|
|
Ok(account) => {
|
|
log::info!("using folder library at {}", account.endpoint);
|
|
ctl.model.borrow_mut().signed_in(account);
|
|
}
|
|
Err(e) => ctl.model.borrow_mut().fail(e),
|
|
}
|
|
render(w, ctl);
|
|
}
|
|
|
|
/// Sign out.
|
|
fn wire_sign_out(window: &AppWindow, controller: &Rc<LaunchController>) {
|
|
{
|
|
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.
|
|
fn wire_formats(window: &AppWindow, controller: &Rc<LaunchController>) {
|
|
{
|
|
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, and open library.
|
|
fn wire_choose_folder_and_open<F>(
|
|
window: &AppWindow,
|
|
controller: &Rc<LaunchController>,
|
|
on_open_library: F,
|
|
) where
|
|
F: Fn(Account) + 'static,
|
|
{
|
|
{
|
|
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().open_browser();
|
|
render(&w, &ctl);
|
|
spawn_folder_list(w.as_weak(), ctl.clone(), String::new());
|
|
});
|
|
}
|
|
|
|
// 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_active_view(View::Library);
|
|
on_open_library(s);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Folder picker navigation.
|
|
fn wire_folder_picker_navigation(window: &AppWindow, controller: &Rc<LaunchController>) {
|
|
{
|
|
let weak = window.as_weak();
|
|
let ctl = controller.clone();
|
|
window.on_launch_browse_into(move |name| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let target = {
|
|
let m = ctl.model.borrow();
|
|
m.browser.as_ref().map(|b| b.child_path(&name))
|
|
};
|
|
if let Some(path) = target {
|
|
ctl.model.borrow_mut().browse_to(path.clone());
|
|
render(&w, &ctl);
|
|
spawn_folder_list(w.as_weak(), ctl.clone(), path);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let ctl = controller.clone();
|
|
window.on_launch_browse_up(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let parent = {
|
|
let m = ctl.model.borrow();
|
|
m.browser.as_ref().and_then(|b| b.parent_path())
|
|
};
|
|
if let Some(path) = parent {
|
|
ctl.model.borrow_mut().browse_to(path.clone());
|
|
render(&w, &ctl);
|
|
spawn_folder_list(w.as_weak(), ctl.clone(), path);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let ctl = controller.clone();
|
|
window.on_launch_browse_confirm(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let chosen = ctl.model.borrow_mut().choose_current_folder();
|
|
if let Some(session) = chosen {
|
|
// Persist immediately: a chosen root must survive a crash
|
|
// before the library is opened.
|
|
if let Err(e) = ctl.store.update(&session) {
|
|
log::warn!("saving library root: {e}");
|
|
}
|
|
log::info!("library root set to /{}", session.root);
|
|
}
|
|
render(&w, &ctl);
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let ctl = controller.clone();
|
|
window.on_launch_browse_make(move |name| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let (account, parent) = {
|
|
let m = ctl.model.borrow();
|
|
let Some(b) = m.browser.as_ref() else { return };
|
|
let Some(account) = m.session().cloned() else {
|
|
return;
|
|
};
|
|
(account, b.path.clone())
|
|
};
|
|
let conn = match ctl
|
|
.store
|
|
.connection(&account, crate::remote::needs_secret(&account))
|
|
{
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
ctl.model.borrow_mut().fail(format!("credentials: {e}"));
|
|
render(&w, &ctl);
|
|
return;
|
|
}
|
|
};
|
|
let name = name.trim().to_string();
|
|
let child = if parent.is_empty() {
|
|
name.clone()
|
|
} else {
|
|
format!("{parent}/{name}")
|
|
};
|
|
ctl.model.borrow_mut().browse_to(parent.clone());
|
|
render(&w, &ctl);
|
|
// Made, then walked into: a folder somebody has just named is
|
|
// the one they mean to choose.
|
|
let (weak, ctl) = (weak.clone(), ctl.clone());
|
|
crate::remote_folders::make(conn, parent, name, move |result| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
match result {
|
|
Ok(_) => {
|
|
ctl.model.borrow_mut().browse_to(child.clone());
|
|
render(&w, &ctl);
|
|
spawn_folder_list(w.as_weak(), ctl.clone(), child);
|
|
}
|
|
Err(e) => {
|
|
ctl.model.borrow_mut().close_browser();
|
|
ctl.model.borrow_mut().fail(e);
|
|
render(&w, &ctl);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let ctl = controller.clone();
|
|
window.on_launch_browse_cancel(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
ctl.model.borrow_mut().close_browser();
|
|
render(&w, &ctl);
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Copy the login URL.
|
|
fn wire_copy_url(window: &AppWindow, controller: &Rc<LaunchController>) {
|
|
{
|
|
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}");
|
|
});
|
|
}
|
|
}
|
|
|
|
/// TRACES: FR-NC-13
|
|
/// Establish a folder library, returning the account to sign in as.
|
|
///
|
|
/// The whole of a [`SignIn::EndpointOnly`](dr_sync::SignIn) sign-in: check the
|
|
/// endpoint, build the account, persist it. Split out of the callback rather
|
|
/// than written inline because a Slint callback cannot be tested without a
|
|
/// display server, and this is the path that decides whether a mistyped folder
|
|
/// becomes a stored account — the failure that would then skip the launch
|
|
/// screen on the next start and surface as a library that finds nothing.
|
|
///
|
|
/// The error is a string because it goes straight to the screen's error line;
|
|
/// the connector wrote it to say what to fix.
|
|
fn open_folder_library(store: &AccountStore, path: &str) -> Result<Account, String> {
|
|
let provider = crate::remote::registry()
|
|
.get(dr_sync_folder::BACKEND_ID)
|
|
.ok_or("this build has no folder support")?;
|
|
|
|
// The connector checks the directory before an account is written for it.
|
|
let endpoint = provider.normalise_endpoint(path)?;
|
|
let account = provider.account_for(&endpoint).map_err(|e| e.to_string())?;
|
|
|
|
// `None`: there is no credential, and asking the keyring for one would
|
|
// fail on a machine with no secrets daemon — where a folder library is
|
|
// exactly the thing that should still work.
|
|
//
|
|
// A failure to persist is reported, not fatal: the library opens for this
|
|
// session and the user is asked again next launch, which is a great deal
|
|
// better than refusing to open a folder that is plainly there.
|
|
if let Err(e) = store.save(&account, None) {
|
|
log::warn!("persisting account: {e}");
|
|
}
|
|
Ok(account)
|
|
}
|
|
|
|
/// 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>();
|
|
|
|
executors::spawn(Executor::Network, "login", move || {
|
|
// A panic anywhere below would unwind the thread, drop `tx`, and leave
|
|
// the UI with nothing but a closed channel — which it can only report
|
|
// as "failed unexpectedly", losing the one piece of information that
|
|
// would explain the failure. Catch it and forward the message instead.
|
|
let panic_tx = tx.clone();
|
|
// Logging is unreliable here: android_logger delivers lines emitted
|
|
// during startup but nothing from this thread, so a failure that never
|
|
// sends is otherwise completely opaque. Reporting the last step reached
|
|
// through the channel puts it on screen, which is the one channel known
|
|
// to work.
|
|
let step_tx = tx.clone();
|
|
let step = |s: &str| {
|
|
let _ = step_tx.send(LoginMessage::Progress(s.to_string()));
|
|
};
|
|
step("thread started");
|
|
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
|
|
// Multi-thread, not current-thread: a current-thread runtime drives
|
|
// its reactor only while the thread sits inside `block_on`, and on
|
|
// Android that left reqwest's connection future never polled — the
|
|
// await never resolved, so the worker neither failed nor returned.
|
|
// A multi-thread runtime owns worker threads that poll the reactor
|
|
// regardless. One worker is plenty for a single login flow.
|
|
//
|
|
// Only IO and time are enabled; `enable_all()` would also start the
|
|
// signal driver, which wants process-wide signal handling that an
|
|
// Android app's runtime already owns.
|
|
let rt = match crate::net_runtime::build() {
|
|
Ok(rt) => rt,
|
|
Err(e) => {
|
|
let _ = tx.send(LoginMessage::Failed(format!("tokio runtime: {e}")));
|
|
return;
|
|
}
|
|
};
|
|
step("runtime built");
|
|
|
|
run_login_flow(rt, tx, server, &step);
|
|
}));
|
|
|
|
if let Err(panic) = result {
|
|
let detail = panic
|
|
.downcast_ref::<&str>()
|
|
.map(|s| (*s).to_string())
|
|
.or_else(|| panic.downcast_ref::<String>().cloned())
|
|
.unwrap_or_else(|| "panicked with a non-string payload".to_string());
|
|
log::error!("login worker panicked: {detail}");
|
|
let _ = panic_tx.send(LoginMessage::Failed(format!("internal error: {detail}")));
|
|
}
|
|
});
|
|
|
|
poll_channel(weak, ctl, rx);
|
|
}
|
|
|
|
/// TRACES: FR-NC-1
|
|
/// Verify an app password the user supplied, then keep it.
|
|
///
|
|
/// No browser and no polling: one authenticated request establishes both that
|
|
/// the credential works and what the account's canonical user id is, which is
|
|
/// what the DAV paths are built from. Reuses the same channel and drain loop as
|
|
/// the browser flow, so success and failure land in the UI identically.
|
|
fn spawn_direct_login(
|
|
weak: slint::Weak<AppWindow>,
|
|
ctl: Rc<LaunchController>,
|
|
server: String,
|
|
login: String,
|
|
password: String,
|
|
) {
|
|
let (tx, rx) = std::sync::mpsc::channel::<LoginMessage>();
|
|
|
|
executors::spawn(Executor::Network, "login", move || {
|
|
let panic_tx = tx.clone();
|
|
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
|
|
// Multi-thread for the reason the browser flow is: a current-thread
|
|
// runtime left reqwest's future unpolled on Android.
|
|
let rt = match crate::net_runtime::build() {
|
|
Ok(rt) => rt,
|
|
Err(e) => {
|
|
let _ = tx.send(LoginMessage::Failed(format!("tokio runtime: {e}")));
|
|
return;
|
|
}
|
|
};
|
|
|
|
rt.block_on(async {
|
|
let client = match dr_sync_nextcloud::http_client("DarkRoom") {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
let _ = tx.send(LoginMessage::Failed(format!("http client: {e}")));
|
|
return;
|
|
}
|
|
};
|
|
|
|
let creds = dr_sync_nextcloud::AppCredentials {
|
|
server,
|
|
login_name: login,
|
|
app_password: password,
|
|
};
|
|
|
|
// The credential is only worth storing if it actually works,
|
|
// and this is the cheapest request that proves it. A 401 comes
|
|
// back as an error here rather than as a puzzling failure on
|
|
// the first listing.
|
|
match fetch_user_id(&client, &creds).await {
|
|
Ok(user_id) => {
|
|
let _ = tx.send(LoginMessage::Success(Box::new((creds, user_id))));
|
|
}
|
|
Err(e) => {
|
|
let _ = tx.send(LoginMessage::Failed(format!(
|
|
"could not sign in with that app password: {e}"
|
|
)));
|
|
}
|
|
}
|
|
});
|
|
}));
|
|
|
|
if let Err(panic) = result {
|
|
let detail = panic
|
|
.downcast_ref::<&str>()
|
|
.map(|s| (*s).to_string())
|
|
.or_else(|| panic.downcast_ref::<String>().cloned())
|
|
.unwrap_or_else(|| "panicked with a non-string payload".to_string());
|
|
let _ = panic_tx.send(LoginMessage::Failed(format!("internal error: {detail}")));
|
|
}
|
|
});
|
|
|
|
poll_channel(weak, ctl, rx);
|
|
}
|
|
|
|
/// The body of the login flow, split out so the worker above can wrap it in
|
|
/// `catch_unwind` without a deeply nested closure.
|
|
fn run_login_flow(
|
|
rt: crate::net_runtime::NetRuntime,
|
|
tx: std::sync::mpsc::Sender<LoginMessage>,
|
|
server: String,
|
|
step: &dyn Fn(&str),
|
|
) {
|
|
{
|
|
rt.block_on(async {
|
|
step("building http client");
|
|
let client = match dr_sync_nextcloud::http_client("DarkRoom") {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
let _ = tx.send(LoginMessage::Failed(format!("http client: {e}")));
|
|
return;
|
|
}
|
|
};
|
|
step("requesting login flow");
|
|
|
|
log::info!("POST {server}/index.php/login/v2");
|
|
let flow = match auth::begin(&client, &server, "DarkRoom").await {
|
|
Ok(f) => f,
|
|
Err(e) => {
|
|
log::warn!("login flow could not be started: {e}");
|
|
let _ = tx.send(LoginMessage::Failed(e.to_string()));
|
|
return;
|
|
}
|
|
};
|
|
log::info!("login flow started; opening browser");
|
|
|
|
// Open the system browser, never an embedded webview (FR-NC-1).
|
|
//
|
|
// Failing here is terminal: the poll below waits for an approval
|
|
// that only the browser can give, so carrying on would hang until
|
|
// the flow expired and then report nothing useful.
|
|
if let Err(e) = open_in_browser(&flow.login_url) {
|
|
log::warn!("browser launch failed: {e}");
|
|
let _ = tx.send(LoginMessage::Failed(format!(
|
|
"could not open a browser to approve the sign-in: {e}"
|
|
)));
|
|
return;
|
|
}
|
|
log::info!("browser opened; polling for approval");
|
|
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()));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
enum LoginMessage {
|
|
/// The last step the worker reached, for diagnosis when it dies silently.
|
|
Progress(String),
|
|
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();
|
|
// Remembers the worker's last reported step, so a silent death names the
|
|
// point it got to rather than saying nothing.
|
|
let last_step = RefCell::new(String::from("nothing"));
|
|
|
|
timer.start(
|
|
slint::TimerMode::Repeated,
|
|
std::time::Duration::from_millis(200),
|
|
move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let ctl = &ctl_for_cb;
|
|
// Drain in one loop. A separate probe call would consume a
|
|
// pending message and throw it away — a successful login would
|
|
// vanish. Disconnection is handled as a terminal case here, so a
|
|
// worker that dies without sending cannot leave the screen on
|
|
// "Connecting…" forever.
|
|
loop {
|
|
let msg = match rx.try_recv() {
|
|
Ok(m) => m,
|
|
Err(std::sync::mpsc::TryRecvError::Empty) => break,
|
|
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
|
// Only an error if the flow never completed; a
|
|
// successful login stops the timer below.
|
|
if !ctl.model.borrow().is_signed_in() {
|
|
// Reaching here means the worker ended without
|
|
// sending anything, which `catch_unwind` in
|
|
// spawn_login should now prevent — so say that the
|
|
// worker stopped rather than blaming the sign-in.
|
|
ctl.model.borrow_mut().fail(format!(
|
|
"the sign-in worker stopped after: {}",
|
|
last_step.borrow()
|
|
));
|
|
render(&w, ctl);
|
|
}
|
|
if let Some(t) = ctl.poll_timer.borrow().as_ref() {
|
|
t.stop();
|
|
}
|
|
return;
|
|
}
|
|
};
|
|
|
|
let mut done = false;
|
|
match msg {
|
|
LoginMessage::Progress(s) => {
|
|
*last_step.borrow_mut() = s;
|
|
}
|
|
LoginMessage::AwaitingApproval(url) => {
|
|
ctl.model.borrow_mut().await_approval(url);
|
|
}
|
|
LoginMessage::Success(boxed) => {
|
|
let (creds, user_id) = *boxed;
|
|
let session = NextcloudProvider::account_from(&creds, user_id);
|
|
let secret = dr_sync::Secret::new(&creds.app_password);
|
|
if let Err(e) = ctl.store.save(&session, Some(&secret)) {
|
|
log::warn!("persisting account: {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>, path: String) {
|
|
let Some(account) = ctl.model.borrow().session().cloned() else {
|
|
return;
|
|
};
|
|
let conn = match ctl
|
|
.store
|
|
.connection(&account, crate::remote::needs_secret(&account))
|
|
{
|
|
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>>();
|
|
|
|
executors::spawn(Executor::Network, "folders", move || {
|
|
// Multi-thread for the same reason as the login worker: a
|
|
// current-thread runtime left reqwest's connection future unpolled on
|
|
// Android, so the await never resolved and the thread stopped without
|
|
// failing or returning.
|
|
let rt = crate::net_runtime::build();
|
|
let Ok(rt) = rt else {
|
|
let _ = tx.send(Err("runtime".into()));
|
|
return;
|
|
};
|
|
rt.block_on(async {
|
|
match crate::remote::connect(&conn) {
|
|
Ok(b) => match b.list(&RemotePath::new(&path), 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;
|
|
|
|
// One try_recv covers both outcomes. A panicking worker drops
|
|
// the sender without sending; treating that as "still loading"
|
|
// is exactly what leaves the picker stuck forever.
|
|
let outcome = match rx.try_recv() {
|
|
Ok(result) => Some(result),
|
|
Err(std::sync::mpsc::TryRecvError::Empty) => None,
|
|
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
|
Some(Err("folder listing failed unexpectedly".to_string()))
|
|
}
|
|
};
|
|
|
|
if let Some(result) = outcome {
|
|
match result {
|
|
// Hand the listing to the model, which clears `loading`
|
|
// and populates the picker.
|
|
Ok(dirs) => ctl.model.borrow_mut().browser_loaded(dirs),
|
|
Err(e) => {
|
|
// Close the picker before reporting: leaving it open
|
|
// on a permanent "Loading…" is what this replaced.
|
|
ctl.model.borrow_mut().close_browser();
|
|
ctl.model.borrow_mut().fail(e);
|
|
}
|
|
}
|
|
render(&w, ctl);
|
|
if let Some(t) = ctl.poll_timer.borrow().as_ref() {
|
|
t.stop();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if let Ok(result) = rx.try_recv() {
|
|
match result {
|
|
// Hand the listing to the model, which clears `loading`
|
|
// and populates the picker.
|
|
Ok(dirs) => ctl.model.borrow_mut().browser_loaded(dirs),
|
|
Err(e) => {
|
|
// Close the picker before reporting: leaving it open
|
|
// on a permanent "Loading…" is what this replaced.
|
|
ctl.model.borrow_mut().close_browser();
|
|
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.
|
|
///
|
|
/// Login Flow v2 cannot complete without this: the user approves the sign-in
|
|
/// in a browser and the poll below waits for that approval. So a platform with
|
|
/// no way to open one must say so rather than return `Ok(())` — reporting
|
|
/// success here strands the flow on "Approve the sign-in in your browser" with
|
|
/// no browser and no explanation.
|
|
fn open_in_browser(url: &str) -> std::io::Result<()> {
|
|
// TRACES: NFR-SEC-3
|
|
// The URL is the server's, and both launchers below open anything, not
|
|
// just web pages: `rundll32 url.dll,FileProtocolHandler` runs a `file:`
|
|
// or UNC path, and `xdg-open` hands it to whatever claims it. `auth::begin`
|
|
// already refuses those; this is the last point before a process starts,
|
|
// so it refuses them again rather than trust that every caller came that
|
|
// way.
|
|
if !url.starts_with("https://") {
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::InvalidInput,
|
|
format!("refusing to open a sign-in address that is not https: {url}"),
|
|
));
|
|
}
|
|
hand_to_system(url)
|
|
}
|
|
|
|
/// Hand `target` — a URL, or a local file's path — to whatever the platform
|
|
/// opens it with.
|
|
///
|
|
/// No check on what it is: [`open_in_browser`] is the caller with a server's
|
|
/// URL in hand and refuses anything but https before it gets here, and
|
|
/// `manual::open` hands over a page it wrote itself.
|
|
pub(crate) fn hand_to_system(url: &str) -> std::io::Result<()> {
|
|
// Not `target_os = "linux"`: Android is its own target_os, and reached this
|
|
// arm's `Ok(())` fallback, so the browser silently never opened.
|
|
#[cfg(all(unix, not(target_os = "android"), not(target_os = "macos")))]
|
|
{
|
|
std::process::Command::new("xdg-open")
|
|
.arg(url)
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.spawn()
|
|
.map(|_| ())
|
|
}
|
|
#[cfg(target_os = "android")]
|
|
{
|
|
android_open_url(url).map_err(|e| std::io::Error::other(e))
|
|
}
|
|
// TRACES: FR-PLAT-WIN-2
|
|
// `ShellExecute` by way of the shell's URL handler, which is what a
|
|
// double-click on a link does, and needs no crate: `rundll32
|
|
// url.dll,FileProtocolHandler` has opened the default browser since
|
|
// Windows 98 and is still what the platform documents for the purpose.
|
|
// Not `cmd /C start`, whose quoting of `&` in a query string is a
|
|
// well-known trap.
|
|
#[cfg(windows)]
|
|
{
|
|
std::process::Command::new("rundll32")
|
|
.args(["url.dll,FileProtocolHandler", url])
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.spawn()
|
|
.map(|_| ())
|
|
}
|
|
#[cfg(not(any(
|
|
all(unix, not(target_os = "android"), not(target_os = "macos")),
|
|
target_os = "android",
|
|
windows
|
|
)))]
|
|
{
|
|
let _ = url;
|
|
Err(std::io::Error::new(
|
|
std::io::ErrorKind::Unsupported,
|
|
"no browser launcher on this platform",
|
|
))
|
|
}
|
|
}
|
|
|
|
/// Hand a URL to whatever the user has set as their browser, via
|
|
/// `startActivity(new Intent(ACTION_VIEW, Uri.parse(url)))`.
|
|
///
|
|
/// Called from the login worker, which is a plain `std::thread` and therefore
|
|
/// not known to the JVM — every JNI call from it would abort the process
|
|
/// without `attach_current_thread` first. The thread detaches when the returned
|
|
/// guard drops.
|
|
///
|
|
/// The VM and activity come from `ndk_context`, which android-activity's glue
|
|
/// populates at startup; that is the same handle Slint's backend uses, so there
|
|
/// is no second JavaVM to reconcile.
|
|
#[cfg(target_os = "android")]
|
|
fn android_open_url(url: &str) -> Result<(), String> {
|
|
let ctx = ndk_context::android_context();
|
|
if ctx.vm().is_null() || ctx.context().is_null() {
|
|
return Err("no Android context available".into());
|
|
}
|
|
|
|
// SAFETY: the pointer comes from ndk_context, which android-activity fills
|
|
// in with the process's real JavaVM before any Rust runs.
|
|
let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) };
|
|
let raw_activity: jni::sys::jobject = ctx.context().cast();
|
|
|
|
// jni 0.22 scopes the attachment to a closure rather than handing back a
|
|
// guard, so all the JNI work happens in here and the thread is detached on
|
|
// the way out.
|
|
vm.attach_current_thread(|env| {
|
|
// SAFETY: valid for as long as this frame, which is all we need — the
|
|
// Intent is dispatched before the closure returns.
|
|
let activity = unsafe { jni::objects::JObject::from_raw(env, raw_activity) };
|
|
|
|
// Names go through `jni_str!` (UTF-8 literal to MUTF-8 `&'static
|
|
// JNIStr`) and signatures through `jni_sig!`, which parses and
|
|
// type-checks them at compile time — a typo in either is a build error
|
|
// rather than a NoSuchMethodError on the device.
|
|
let jurl = env.new_string(url)?;
|
|
let uri = env
|
|
.call_static_method(
|
|
jni::jni_str!("android/net/Uri"),
|
|
jni::jni_str!("parse"),
|
|
jni::jni_sig!("(Ljava/lang/String;)Landroid/net/Uri;"),
|
|
&[(&jurl).into()],
|
|
)?
|
|
.l()?;
|
|
|
|
let action = env.new_string("android.intent.action.VIEW")?;
|
|
let intent = env.new_object(
|
|
jni::jni_str!("android/content/Intent"),
|
|
jni::jni_sig!("(Ljava/lang/String;Landroid/net/Uri;)V"),
|
|
&[(&action).into(), (&uri).into()],
|
|
)?;
|
|
|
|
env.call_method(
|
|
&activity,
|
|
jni::jni_str!("startActivity"),
|
|
jni::jni_sig!("(Landroid/content/Intent;)V"),
|
|
&[(&intent).into()],
|
|
)?;
|
|
|
|
// A pending Java exception leaves the JVM unusable for the next call,
|
|
// and ActivityNotFoundException here means the device has no browser at
|
|
// all — worth reporting rather than leaving for something unrelated to
|
|
// trip over.
|
|
if env.exception_check() {
|
|
env.exception_clear();
|
|
return Err(jni::errors::Error::JavaException);
|
|
}
|
|
|
|
Ok(())
|
|
})
|
|
.map_err(|e: jni::errors::Error| e.to_string())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use dr_plat::EphemeralSecretStore;
|
|
|
|
fn store_in(dir: &std::path::Path) -> AccountStore {
|
|
AccountStore::open_at(
|
|
dir.join("sessions.json"),
|
|
Box::new(EphemeralSecretStore::new()),
|
|
)
|
|
}
|
|
|
|
fn tmpdir(name: &str) -> std::path::PathBuf {
|
|
let d = std::env::temp_dir().join(format!("dr-launch-folder-{name}"));
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
std::fs::create_dir_all(&d).unwrap();
|
|
d
|
|
}
|
|
|
|
#[test]
|
|
fn opening_a_folder_stores_an_account_with_no_credential() {
|
|
// The whole sign-in, end to end through the registry: no browser, no
|
|
// keyring, no waiting state.
|
|
let dir = tmpdir("ok");
|
|
let library = dir.join("Photos");
|
|
std::fs::create_dir_all(&library).unwrap();
|
|
let store = store_in(&dir);
|
|
|
|
let account = open_folder_library(&store, &library.to_string_lossy()).unwrap();
|
|
assert_eq!(account.backend, dr_sync_folder::BACKEND_ID);
|
|
assert!(account.login.is_empty(), "a folder has nobody to name");
|
|
|
|
// And it survives, so the next launch skips the screen.
|
|
let reloaded = store.current().expect("persisted");
|
|
assert_eq!(reloaded.endpoint, account.endpoint);
|
|
|
|
// With nothing in the keyring — the machine may have no secrets daemon
|
|
// at all, which is precisely when a folder library matters.
|
|
assert!(store.connection(&reloaded, false).unwrap().secret.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn a_mistyped_folder_is_refused_rather_than_stored() {
|
|
// The failure this guards: a stored account for a folder that is not
|
|
// there skips the launch screen next start and reads as a library
|
|
// that has lost its photographs.
|
|
let dir = tmpdir("typo");
|
|
let store = store_in(&dir);
|
|
|
|
let err = open_folder_library(&store, &dir.join("Pictrues").to_string_lossy()).unwrap_err();
|
|
assert!(err.contains("No folder"), "{err}");
|
|
assert!(store.current().is_none(), "nothing may be persisted");
|
|
}
|
|
|
|
/// TRACES: NFR-SEC-3
|
|
#[test]
|
|
fn only_an_https_address_reaches_the_launcher() {
|
|
// Refused before any process starts, so running this spawns nothing.
|
|
for url in [
|
|
"http://cloud.example/login/v2/flow/x",
|
|
"file:///C:/Windows/System32/calc.exe",
|
|
"\\\\evil\\share\\x.exe",
|
|
"-v",
|
|
] {
|
|
let e = open_in_browser(url).expect_err(url);
|
|
assert_eq!(e.kind(), std::io::ErrorKind::InvalidInput, "{url}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_error_says_what_to_fix() {
|
|
// It goes straight to the screen's error line, so it has to read as
|
|
// instruction rather than as a type name.
|
|
let dir = tmpdir("messages");
|
|
let store = store_in(&dir);
|
|
|
|
for (input, want) in [("", "Choose"), ("Pictures", "full path")] {
|
|
let err = open_folder_library(&store, input).unwrap_err();
|
|
assert!(err.contains(want), "{input:?} gave {err:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_file_is_not_a_library() {
|
|
let dir = tmpdir("file");
|
|
let f = dir.join("a.CR2");
|
|
std::fs::write(&f, b"raw").unwrap();
|
|
let store = store_in(&dir);
|
|
|
|
let err = open_folder_library(&store, &f.to_string_lossy()).unwrap_err();
|
|
assert!(err.contains("not a folder"), "{err}");
|
|
}
|
|
}
|