AkurAI Build
Menu

Public repository / master

AkurAI-Drive / desktop/src-tauri/src/commands.rs

Latest change 76b668ab797c5342c1cccf4955c8f1943cba1bb7 - OLI-13: pin toolchain 1.98.1 at repo root; desktop: fmt, clippy, plist/quick-xml advisory fix, first unit tests by AkurAI Agent

//! Tauri commands: thin async wrappers around the `akurai-drive-sync` library
//! (api client + auth + config + sync). All blocking I/O (reqwest-blocking,
//! rusqlite) runs on `spawn_blocking`; every command logs its invocation (never
//! the token) and every failure with full context.

use std::path::{Path, PathBuf};
use std::sync::Arc;

use serde::Serialize;
use tauri::{AppHandle, Emitter, State};
use tauri_plugin_dialog::DialogExt;
use tauri_plugin_opener::OpenerExt;

use akurai_drive_sync::api::{Api, Bucket, Identity, RemoteFile};
use akurai_drive_sync::config::{self, Config};
use akurai_drive_sync::util;

use crate::sync::{run_one_pass, SyncManager};

type CmdResult<T> = Result<T, String>;

fn fmt_err(e: impl std::fmt::Display) -> String {
    format!("{e}")
}

/// Expand a leading `~/` to the user's home directory.
fn expand_path(input: &str) -> anyhow::Result<PathBuf> {
    if let Some(rest) = input.strip_prefix("~/") {
        let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("cannot determine home dir"))?;
        return Ok(home.join(rest));
    }
    if input == "~" {
        return dirs::home_dir().ok_or_else(|| anyhow::anyhow!("cannot determine home dir"));
    }
    Ok(PathBuf::from(input))
}

/// Build an authenticated blocking API client from the stored config.
fn authed_api(cfg: &Config) -> anyhow::Result<Api> {
    let token = cfg
        .token
        .clone()
        .filter(|t| !t.is_empty())
        .ok_or_else(|| anyhow::anyhow!("not logged in"))?;
    Api::new(cfg.resolved_api(), token)
}

/// Run a blocking closure off the async runtime, mapping both join and op errors
/// to a logged `String`.
async fn blocking<T, F>(f: F) -> CmdResult<T>
where
    T: Send + 'static,
    F: FnOnce() -> anyhow::Result<T> + Send + 'static,
{
    match tokio::task::spawn_blocking(f).await {
        Ok(Ok(v)) => Ok(v),
        Ok(Err(e)) => {
            let m = format!("{e:#}");
            tracing::error!(error = %m, "command operation failed");
            Err(m)
        }
        Err(e) => {
            let m = format!("blocking task panicked: {e}");
            tracing::error!(error = %m);
            Err(m)
        }
    }
}

fn require_bucket(cfg: &Config) -> anyhow::Result<String> {
    cfg.bucket
        .clone()
        .filter(|b| !b.is_empty())
        .ok_or_else(|| anyhow::anyhow!("no bucket configured"))
}

// ── Auth / identity ─────────────────────────────────────────────────────────

/// Run the device-token loopback login (browser → `/auth/device` → `?token=`),
/// store the token via the shared config, and return the resolved identity.
#[tauri::command]
pub async fn login(app: AppHandle, state: State<'_, Arc<SyncManager>>) -> CmdResult<Identity> {
    tracing::info!(cmd = "login", "command invoked");
    let mut cfg = Config::load().map_err(fmt_err)?;
    let api_base = cfg.resolved_api();

    let _ = app.emit(
        "sync-status",
        serde_json::json!({ "state": "login", "detail": "Opening your browser to sign in…" }),
    );
    crate::tray::update_status(&app, "login");

    let base = api_base.clone();
    let token = blocking(move || akurai_drive_sync::auth::login(&base)).await?;

    cfg.api = Some(api_base);
    cfg.token = Some(token);
    cfg.save().map_err(fmt_err)?;
    tracing::info!("login: device token stored");

    let cfg2 = cfg.clone();
    let ident = blocking(move || authed_api(&cfg2)?.me()).await?;
    if !ident.email.is_empty() {
        state.set_email(Some(ident.email.clone()));
    }
    state.trigger_sync();
    crate::tray::update_status(&app, "idle");
    Ok(ident)
}

/// Native email+password sign-in (no browser). Calls the lib's `login_password`
/// (`POST /auth/password`), stores the returned device token via the shared
/// config, then runs the same post-login flow as the browser `login`. The
/// password is logged NOWHERE — only the email and the outcome are traced.
#[tauri::command]
pub async fn login_password(
    app: AppHandle,
    state: State<'_, Arc<SyncManager>>,
    email: String,
    password: String,
) -> CmdResult<()> {
    tracing::info!(cmd = "login_password", %email, "command invoked");
    let mut cfg = Config::load().map_err(fmt_err)?;
    let api_base = cfg.resolved_api();

    let _ = app.emit(
        "sync-status",
        serde_json::json!({ "state": "login", "detail": "Signing in…" }),
    );
    crate::tray::update_status(&app, "login");

    let base = api_base.clone();
    let token = blocking(move || {
        // No token yet — build a tokenless client purely to drive the login call.
        let api = Api::new(base, String::new())?;
        api.login_password(&email, &password)
    })
    .await
    .inspect_err(|_| crate::tray::update_status(&app, "idle"))?;

    cfg.api = Some(api_base);
    cfg.token = Some(token);
    cfg.save().map_err(fmt_err)?;
    tracing::info!("login_password: device token stored");

    let cfg2 = cfg.clone();
    let ident = blocking(move || authed_api(&cfg2)?.me()).await?;
    if !ident.email.is_empty() {
        state.set_email(Some(ident.email.clone()));
    }
    state.trigger_sync();
    crate::tray::update_status(&app, "idle");
    tracing::info!(cmd = "login_password", "sign-in complete");
    Ok(())
}

/// Report the current identity. Returns `authenticated: false` (rather than an
/// error) when there is no token, or when the token is rejected/offline — so the
/// frontend can cleanly show the sign-in screen.
#[tauri::command]
pub async fn me(state: State<'_, Arc<SyncManager>>) -> CmdResult<Identity> {
    tracing::info!(cmd = "me", "command invoked");
    let cfg = Config::load().map_err(fmt_err)?;
    if !cfg.is_logged_in() {
        return Ok(Identity::default());
    }
    let cfg2 = cfg.clone();
    let ident = match blocking(move || authed_api(&cfg2)?.me()).await {
        Ok(i) => i,
        Err(e) => {
            tracing::warn!(error = %e, "me: token invalid/offline → reporting unauthenticated");
            Identity::default()
        }
    };
    if ident.authenticated && !ident.email.is_empty() {
        state.set_email(Some(ident.email.clone()));
    }
    Ok(ident)
}

/// Clear the stored device token (sign out).
#[tauri::command]
pub async fn sign_out(state: State<'_, Arc<SyncManager>>) -> CmdResult<()> {
    tracing::info!(cmd = "sign_out", "command invoked");
    let mut cfg = Config::load().map_err(fmt_err)?;
    cfg.token = None;
    cfg.save().map_err(fmt_err)?;
    state.set_email(None);
    Ok(())
}

// ── Targets / buckets ───────────────────────────────────────────────────────

/// List the buckets available to the account.
#[tauri::command]
pub async fn buckets() -> CmdResult<Vec<Bucket>> {
    tracing::info!(cmd = "buckets", "command invoked");
    blocking(|| {
        let cfg = Config::load()?;
        authed_api(&cfg)?.buckets()
    })
    .await
}

/// Configure the local sync folder + bucket (creates the folder, canonicalizes
/// it) and wake the sync loop.
#[tauri::command]
pub async fn set_target(
    state: State<'_, Arc<SyncManager>>,
    folder: String,
    bucket: String,
) -> CmdResult<()> {
    tracing::info!(cmd = "set_target", %folder, %bucket, "command invoked");
    let folder_in = folder.clone();
    let canonical = blocking(move || {
        let path = expand_path(&folder_in)?;
        std::fs::create_dir_all(&path)?;
        let c = std::fs::canonicalize(&path)?;
        Ok(c.to_string_lossy().to_string())
    })
    .await?;

    let mut cfg = Config::load().map_err(fmt_err)?;
    cfg.folder = Some(canonical);
    cfg.bucket = Some(bucket);
    cfg.api = Some(cfg.resolved_api());
    cfg.save().map_err(fmt_err)?;
    tracing::info!("set_target: config saved");
    state.trigger_sync();
    Ok(())
}

// ── Browsing ────────────────────────────────────────────────────────────────

/// One folder/file entry in the current prefix.
#[derive(Serialize)]
pub struct Entry {
    pub name: String,
    pub key: String,
    pub kind: String, // "folder" | "file"
    pub size: u64,
    pub modified: String,
}

#[derive(Serialize)]
pub struct Listing {
    pub prefix: String,
    pub entries: Vec<Entry>,
}

/// Shape a flat object listing into folders + files for `prefix`, synthesizing
/// folder entries from key prefixes (so it works whether or not the backend
/// applies a delimiter).
fn shape_listing(prefix: &str, files: Vec<RemoteFile>) -> Listing {
    use std::collections::BTreeSet;

    let mut folders: BTreeSet<String> = BTreeSet::new();
    let mut file_entries: Vec<Entry> = Vec::new();

    for f in files {
        if !f.key.starts_with(prefix) {
            continue;
        }
        let rest = &f.key[prefix.len()..];
        if rest.is_empty() {
            continue; // the folder marker for this prefix itself
        }
        match rest.find('/') {
            Some(i) => {
                let seg = &rest[..i];
                if !seg.is_empty() {
                    folders.insert(seg.to_string());
                }
            }
            None => file_entries.push(Entry {
                name: rest.to_string(),
                key: f.key.clone(),
                kind: "file".into(),
                size: f.size,
                modified: f.last_modified.clone(),
            }),
        }
    }

    // Folders first (alpha), then files (case-insensitive alpha).
    let mut entries: Vec<Entry> = folders
        .into_iter()
        .map(|name| Entry {
            key: format!("{prefix}{name}/"),
            name,
            kind: "folder".into(),
            size: 0,
            modified: String::new(),
        })
        .collect();
    file_entries.sort_by_key(|a| a.name.to_lowercase());
    entries.extend(file_entries);

    Listing {
        prefix: prefix.to_string(),
        entries,
    }
}

/// List folders + files under `prefix` for the configured bucket.
#[tauri::command]
pub async fn list(prefix: String) -> CmdResult<Listing> {
    tracing::info!(cmd = "list", %prefix, "command invoked");
    blocking(move || {
        let cfg = Config::load()?;
        let bucket = require_bucket(&cfg)?;
        let api = authed_api(&cfg)?;
        let files = api.files(&bucket, &prefix)?;
        Ok(shape_listing(&prefix, files))
    })
    .await
}

// ── File operations ─────────────────────────────────────────────────────────

/// Open a native file picker; returns the chosen absolute paths.
#[tauri::command]
pub async fn pick_files(app: AppHandle) -> CmdResult<Vec<String>> {
    tracing::info!(cmd = "pick_files", "command invoked");
    let picked = tokio::task::spawn_blocking(move || app.dialog().file().blocking_pick_files())
        .await
        .map_err(fmt_err)?;
    let out: Vec<String> = picked
        .unwrap_or_default()
        .into_iter()
        .map(|fp| fp.to_string())
        .collect();
    tracing::info!(count = out.len(), "pick_files result");
    Ok(out)
}

/// Upload local files (presign PUT → direct-to-S3 PUT) under `dest_prefix`.
#[tauri::command]
pub async fn upload(
    state: State<'_, Arc<SyncManager>>,
    local_paths: Vec<String>,
    dest_prefix: String,
) -> CmdResult<usize> {
    tracing::info!(cmd = "upload", count = local_paths.len(), %dest_prefix, "command invoked");
    let n = blocking(move || {
        let cfg = Config::load()?;
        let bucket = require_bucket(&cfg)?;
        let api = authed_api(&cfg)?;
        let mut count = 0usize;
        for p in &local_paths {
            let path = Path::new(p);
            let fname = path
                .file_name()
                .map(|n| n.to_string_lossy().to_string())
                .ok_or_else(|| anyhow::anyhow!("not a file path: {p}"))?;
            let key = format!("{dest_prefix}{fname}");
            let ct = util::content_type_for(&key);
            let presign = api.presign(&bucket, &key, "put", Some(ct))?;
            tracing::info!(%key, "S3 PUT (upload)");
            api.upload_file(&presign, path, Some(ct))?;
            count += 1;
        }
        Ok(count)
    })
    .await?;
    state.trigger_sync();
    Ok(n)
}

/// Download an object (presign GET → GET) into the sync folder (atomic rename).
#[tauri::command]
pub async fn download(state: State<'_, Arc<SyncManager>>, key: String) -> CmdResult<String> {
    tracing::info!(cmd = "download", %key, "command invoked");
    let dest = blocking(move || {
        let cfg = Config::load()?;
        let bucket = require_bucket(&cfg)?;
        let folder = cfg
            .folder
            .clone()
            .ok_or_else(|| anyhow::anyhow!("no sync folder configured"))?;
        let api = authed_api(&cfg)?;

        let presign = api.presign(&bucket, &key, "get", None)?;
        let dest = Path::new(&folder).join(&key);
        if let Some(parent) = dest.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let tmp = dest.with_file_name(format!("{}{}", util::TMP_PREFIX, util::short_id(&key)));
        tracing::info!(%key, dest = %dest.display(), "S3 GET (download)");
        api.download_to(&presign, &tmp)?;
        std::fs::rename(&tmp, &dest)?;
        Ok(dest.to_string_lossy().to_string())
    })
    .await?;
    state.trigger_sync();
    Ok(dest)
}

/// Delete a remote object.
#[tauri::command]
pub async fn delete(state: State<'_, Arc<SyncManager>>, key: String) -> CmdResult<()> {
    tracing::info!(cmd = "delete", %key, "command invoked");
    blocking(move || {
        let cfg = Config::load()?;
        let bucket = require_bucket(&cfg)?;
        authed_api(&cfg)?.delete(&bucket, &key)?;
        Ok(())
    })
    .await?;
    state.trigger_sync();
    Ok(())
}

/// Rename an object (copy = download to a temp, re-upload under the new key,
/// then delete the old one).
#[tauri::command]
pub async fn rename(
    state: State<'_, Arc<SyncManager>>,
    key: String,
    new_key: String,
) -> CmdResult<()> {
    tracing::info!(cmd = "rename", %key, %new_key, "command invoked");
    blocking(move || {
        let cfg = Config::load()?;
        let bucket = require_bucket(&cfg)?;
        let api = authed_api(&cfg)?;

        let tmpdir = config::config_dir()?.join("tmp");
        std::fs::create_dir_all(&tmpdir)?;
        let tmp = tmpdir.join(format!("{}{}", util::TMP_PREFIX, util::short_id(&key)));

        let get = api.presign(&bucket, &key, "get", None)?;
        api.download_to(&get, &tmp)?;
        let ct = util::content_type_for(&new_key);
        let put = api.presign(&bucket, &new_key, "put", Some(ct))?;
        api.upload_file(&put, &tmp, Some(ct))?;
        api.delete(&bucket, &key)?;
        let _ = std::fs::remove_file(&tmp);
        tracing::info!(%key, %new_key, "rename complete (copy + delete)");
        Ok(())
    })
    .await?;
    state.trigger_sync();
    Ok(())
}

/// Create a folder by writing a zero-byte `<prefix>/` marker object.
#[tauri::command]
pub async fn mkdir(prefix: String) -> CmdResult<()> {
    tracing::info!(cmd = "mkdir", %prefix, "command invoked");
    blocking(move || {
        let cfg = Config::load()?;
        let bucket = require_bucket(&cfg)?;
        let api = authed_api(&cfg)?;

        let key = if prefix.ends_with('/') {
            prefix.clone()
        } else {
            format!("{prefix}/")
        };
        let tmpdir = config::config_dir()?.join("tmp");
        std::fs::create_dir_all(&tmpdir)?;
        let tmp = tmpdir.join(format!("{}{}", util::TMP_PREFIX, util::short_id(&key)));
        std::fs::write(&tmp, b"")?;

        let ct = "application/x-directory";
        let presign = api.presign(&bucket, &key, "put", Some(ct))?;
        api.upload_file(&presign, &tmp, Some(ct))?;
        let _ = std::fs::remove_file(&tmp);
        tracing::info!(%key, "mkdir marker created");
        Ok(())
    })
    .await
}

// ── Sync / status / misc ────────────────────────────────────────────────────

/// Run one sync pass now and return the summary line.
#[tauri::command]
pub async fn sync_now(app: AppHandle, state: State<'_, Arc<SyncManager>>) -> CmdResult<String> {
    tracing::info!(cmd = "sync_now", "command invoked");
    let manager = state.inner().clone();
    match run_one_pass(app, manager, "manual").await {
        Some(line) => Ok(line),
        None => match state.last_error() {
            Some(e) => Err(e),
            None => Ok("nothing to sync (not fully configured)".into()),
        },
    }
}

#[derive(Serialize)]
pub struct Status {
    pub logged_in: bool,
    pub email: Option<String>,
    pub folder: Option<String>,
    pub bucket: Option<String>,
    pub syncing: bool,
    pub last_summary: Option<String>,
    pub last_error: Option<String>,
    pub api: String,
    pub log_path: String,
}

/// Snapshot of login + sync state for the UI.
#[tauri::command]
pub async fn status(state: State<'_, Arc<SyncManager>>) -> CmdResult<Status> {
    let cfg = Config::load().map_err(fmt_err)?;

    // Prefer the in-memory summary; fall back to the persisted sync-state db.
    let mut last_summary = state.last_summary();
    if last_summary.is_none() {
        if let Ok(p) = config::state_db_path() {
            if let Ok(st) = akurai_drive_sync::state::State::open(&p) {
                last_summary = st.meta_get("last_sync").ok().flatten();
            }
        }
    }

    let s = Status {
        logged_in: cfg.is_logged_in(),
        email: state.email(),
        folder: cfg.folder.clone(),
        bucket: cfg.bucket.clone(),
        syncing: state.is_syncing(),
        last_summary,
        last_error: state.last_error(),
        api: cfg.resolved_api(),
        log_path: crate::logging::log_path().to_string_lossy().to_string(),
    };
    tracing::debug!(
        logged_in = s.logged_in,
        syncing = s.syncing,
        "status reported"
    );
    Ok(s)
}

/// Open the sync folder in the system file manager.
#[tauri::command]
pub async fn open_folder(app: AppHandle) -> CmdResult<()> {
    tracing::info!(cmd = "open_folder", "command invoked");
    let cfg = Config::load().map_err(fmt_err)?;
    let folder = cfg
        .folder
        .clone()
        .ok_or_else(|| "no sync folder configured".to_string())?;
    app.opener()
        .open_path(folder.clone(), None::<&str>)
        .map_err(|e| {
            let m = format!("{e}");
            tracing::error!(error = %m, "open_folder failed");
            m
        })?;
    tracing::info!(%folder, "opened sync folder in file manager");
    Ok(())
}

/// Let the webview write into the shared log file (mirrors console).
#[tauri::command]
pub fn log_frontend(level: String, msg: String) {
    match level.as_str() {
        "error" => tracing::error!(target: "akurai_drive_desktop::frontend", "{msg}"),
        "warn" => tracing::warn!(target: "akurai_drive_desktop::frontend", "{msg}"),
        "debug" => tracing::debug!(target: "akurai_drive_desktop::frontend", "{msg}"),
        _ => tracing::info!(target: "akurai_drive_desktop::frontend", "{msg}"),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn file(key: &str, size: u64) -> RemoteFile {
        RemoteFile {
            key: key.into(),
            size,
            etag: String::new(),
            last_modified: String::new(),
        }
    }

    #[test]
    fn shape_listing_lists_folders_first_then_files_case_insensitive() {
        let files = vec![
            file("docs/b.txt", 1),
            file("docs/sub/deep.txt", 2),
            file("docs/sub/other.txt", 3),
            file("docs/Zed.txt", 4),
            file("docs/alpha.txt", 5),
            file("docs/", 0),
            file("other/x.txt", 6),
        ];
        let listing = shape_listing("docs/", files);
        assert_eq!(listing.prefix, "docs/");
        let got: Vec<(&str, &str)> = listing
            .entries
            .iter()
            .map(|e| (e.kind.as_str(), e.name.as_str()))
            .collect();
        assert_eq!(
            got,
            vec![
                ("folder", "sub"),
                ("file", "alpha.txt"),
                ("file", "b.txt"),
                ("file", "Zed.txt"),
            ]
        );
        assert_eq!(listing.entries[0].key, "docs/sub/");
    }

    #[test]
    fn expand_path_only_touches_a_leading_tilde() {
        assert_eq!(expand_path("/abs/x").unwrap(), PathBuf::from("/abs/x"));
        assert_eq!(expand_path("rel/~/x").unwrap(), PathBuf::from("rel/~/x"));
        if let Some(home) = dirs::home_dir() {
            assert_eq!(expand_path("~/a").unwrap(), home.join("a"));
            assert_eq!(expand_path("~").unwrap(), home);
        }
    }
}