//! 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::{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, 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>, } impl LaunchController { pub fn new() -> Rc { 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 = 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 = m .formats .iter() .map(|(f, _)| slint::SharedString::from(f.label())) .collect(); let checked: Vec = 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(window: &AppWindow, controller: Rc, 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) { { 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) { { let weak = window.as_weak(); let ctl = controller.clone(); window.on_launch_use_folder(move |path| { let Some(w) = weak.upgrade() else { return }; 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) { { 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) { { 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( window: &AppWindow, controller: &Rc, 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) { { 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. fn wire_copy_url(window: &AppWindow, controller: &Rc) { { 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 { 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, ctl: Rc, 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::(); 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::().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, ctl: Rc, server: String, login: String, password: String, ) { let (tx, rx) = std::sync::mpsc::channel::(); 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::().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, 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, ctl: Rc, rx: std::sync::mpsc::Receiver, ) { 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, ctl: Rc, 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::, String>>(); 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(&conn) { Ok(b) => match b.list(&RemotePath::new(&path), None).await { Ok(entries) => { let mut dirs: Vec = 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 { 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}"), )); } // 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}"); } }