`dr-sync` defines `RemoteBackend` and a capability model the engine adapts to, so a second backend can be added without touching the code that uses one. That boundary was documentation. Seven files in `dr-ui` constructed a `NextcloudBackend` directly, ten functions took one by concrete type, and exactly two call sites in the tree — both inside `dr-sync` itself — ever held the trait object. A WebDAV or local-folder backend would have had a well-written trait to implement and nowhere to go afterwards. The change is smaller than the finding suggests, because the trait was already right. Every method the UI has ever called on a backend — `get`, `put`, `list`, `delete`, `create_dir`, `move_to` — was already on it, so nothing had to be added and no behaviour moved. Ten signatures widened to `&dyn RemoteBackend`, sixteen constructions became `remote::connect`, and `remote.rs` is now the only file in the interface that names a connector. `connect` returns `Result<Box<dyn RemoteBackend>, RemoteError>`. The error type is `dr-sync`'s rather than the connector's, which is why every call site kept its shape — the `match`, the `let Ok(..) else`, and `.map_err(ScanFailure::local)?` all still read as they did. One wrinkle worth recording: `&Box<dyn Trait>` does not reach `&dyn Trait` on its own. The compiler reaches for unsizing, which wants `Box<dyn RemoteBackend>: RemoteBackend`, and reports a confusing missing impl rather than suggesting a deref. Twelve call sites therefore say `&*backend`, and two say `let backend: &dyn RemoteBackend = &*backend` where a borrow is shared across lanes. What this does *not* do is abstract credentials. `AppCredentials` is an app password from Login Flow v2 — a Nextcloud protocol, not a general notion of authenticating to a remote — and seven files still name it. An OAuth token, a bucket key pair and an app password have no useful common shape, so deciding what an account is across backends before a second one exists would be a confident guess. code-health.md CH-2 now records that as the remaining half, and it should wait for the backend that forces it. Verified: fmt clean, clippy clean at -D warnings, 2041 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
828 lines
32 KiB
Rust
828 lines
32 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 std::cell::RefCell;
|
|
use std::rc::Rc;
|
|
|
|
use dr_plat::PlatformSecretStore;
|
|
use dr_sync::RemotePath;
|
|
use dr_sync_nextcloud::{auth, 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),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// 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());
|
|
|
|
// 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(Session) + 'static,
|
|
{
|
|
// --- sign in -------------------------------------------------------
|
|
{
|
|
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 };
|
|
ctl.model.borrow_mut().begin_sign_in(server.to_string());
|
|
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 };
|
|
ctl.model
|
|
.borrow_mut()
|
|
.begin_direct_sign_in(server.to_string());
|
|
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(),
|
|
);
|
|
});
|
|
}
|
|
|
|
// --- 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().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_show_launch(false);
|
|
on_open_library(s);
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- folder picker navigation ---------------------------------------
|
|
{
|
|
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_cancel(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
ctl.model.borrow_mut().close_browser();
|
|
render(&w, &ctl);
|
|
});
|
|
}
|
|
|
|
// --- 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 || {
|
|
// 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 tokio::runtime::Builder::new_multi_thread()
|
|
.worker_threads(1)
|
|
.enable_io()
|
|
.enable_time()
|
|
.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>();
|
|
|
|
std::thread::spawn(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 tokio::runtime::Builder::new_multi_thread()
|
|
.worker_threads(1)
|
|
.enable_io()
|
|
.enable_time()
|
|
.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: tokio::runtime::Runtime,
|
|
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 = 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>, path: String) {
|
|
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 || {
|
|
// 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 = tokio::runtime::Builder::new_multi_thread()
|
|
.worker_threads(1)
|
|
.enable_io()
|
|
.enable_time()
|
|
.build();
|
|
let Ok(rt) = rt else {
|
|
let _ = tx.send(Err("runtime".into()));
|
|
return;
|
|
};
|
|
rt.block_on(async {
|
|
match crate::remote::connect(&creds, &user_id) {
|
|
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<()> {
|
|
// 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))
|
|
}
|
|
#[cfg(not(any(
|
|
all(unix, not(target_os = "android"), not(target_os = "macos")),
|
|
target_os = "android"
|
|
)))]
|
|
{
|
|
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())
|
|
}
|