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);
}
}
}