Files
DarkRoom/ui/dr-ui/src/launch_ui.rs
T
dtourolle 38819da222 Replace the six view booleans with View and Page enums
app.slint carried show-launch, show-library, show-identity, show-settings,
show-import and show-merge as separate booleans, so the root component chose
what to draw with five- and six-term conjunctions and nothing stopped two of
them being true at once. Replaced with two enums: View { develop, library,
identity, launch } for which top-level screen is showing, and Page { none,
settings, import, merge } for which page, if any, is drawn over it.

Two values rather than one, because the two questions are genuinely
different. Settings, Import and Merge are reachable from more than one View
and are drawn outermost without touching it — closing one has to return to
whichever View was already current, and today that works because the
underlying property is left alone while the page sits over it. A single
View with five or more variants would need a second field remembering what
to return to; Page needs nothing to remember, since View was never
overwritten in the first place. Identity, by contrast, genuinely replaces
the window the way Launch and Library do (see the existing "like the launch
screen" comment on its `if`), so it is a View variant, not a Page.

Every `if` chain in app.slint that used to compare four, five or six
booleans now compares active-view and active-page to at most one variant
each. library-visible collapsed from a six-term conjunction to
`active-page == Page.none && active-view == View.library`.

The Rust side follows: every set_show_*/get_show_* call in library_ui.rs,
identity_ui.rs, settings_ui.rs, merge_ui.rs, import_ui.rs, launch_ui.rs and
lib.rs now reads or writes active-view or active-page instead, including
lib.rs's startup match (View.launch vs View.develop, since a Startup that
skips the launch screen used to leave both old booleans false and fall
through the chain to develop) and identity_ui's close handler, which now
writes View.library or View.develop in one call where it used to write
show-library then show-identity separately.

back_one_step needed one deliberate adjustment beyond the mechanical
rename. Identity was never represented in NavState: back had nothing to do
when Identity was opened from the library (show-library stayed true,
unread by IdentityScreen's own condition) and could only reach ToLibrary
when opened from develop, which likewise wrote a property IdentityScreen
never read — so escaping out of Identity was invisible in both cases before
this change. With a single active-view, falling into the general case
would instead overwrite the value IdentityScreen's `if` does read and close
it as an unintended side effect. back_one_step now swallows the gesture
while View.identity is current, reproducing the same "nothing visible
happens" outcome for both origins without threading identity_ui's private
came-from-library state through lib.rs for one screen.

Verified with tools/manual/drive.py against a private Xvfb and the debug
build: launch screen to library, Settings opened and closed, Identity
opened and closed (including Escape doing nothing while it is open),
develop opened from a cell and closed both by the back button and by
Escape. Screenshots under verify/.
2026-09-20 20:30:56 +02:00

1029 lines
39 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::{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()));
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 };
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_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>();
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 = 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>>();
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<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))
}
// 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");
}
#[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}");
}
}