//! Public Module API commands (Plugin API 1.7.0 compatible). Every command checks the //! calling module window and its manifest permission before touching a Core service. use super::{module_call, module_result, text, CommandResult}; use crate::core::app::CoreSlot; use crate::core::error::{CoreError, CoreResult}; use crate::core::logging::{normalize_limit, read_tail}; use serde_json::{json, Value}; use tauri::{Manager, State, Webview}; macro_rules! module_cmd { ($name:ident, $channel:expr, $perm:expr, |$core:ident, $ctx:ident, $p:ident| $body:expr) => { #[tauri::command] pub async fn $name(webview: Webview, slot: State<'_, CoreSlot>, payload: Option) -> CommandResult { #[allow(unused_variables)] let ($core, $ctx) = module_call(&webview, &slot, $perm)?; #[allow(unused_variables)] let $p = super::payload(payload); let result: CoreResult = async { $body }.await; module_result($channel, result) } }; } /// Native dialog texts of the Hash & Checksum module come from its own catalogs (as in 0.1.0). fn hash_text(core: &crate::core::app::Core, key: &str) -> String { let dir = core.paths.bundled_modules().join("chj.hash-checksum").join("0.0.1").join("ui").join("i18n"); core.i18n .chain(&core.language()) .iter() .find_map(|code| { std::fs::read(dir.join(format!("{code}.json"))) .ok() .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) .and_then(|catalog| catalog.get(key).and_then(Value::as_str).map(str::to_string)) }) .unwrap_or_else(|| key.to_string()) } /// `payload.options || {}`. fn options(payload: &Value) -> Value { payload.get("options").filter(|v| v.is_object()).cloned().unwrap_or_else(|| json!({})) } /// JavaScript truthiness for flags passed as `Boolean(value)`. fn truthy(value: &Value) -> bool { match value { Value::Null => false, Value::Bool(b) => *b, Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0 && !f.is_nan()), Value::String(s) => !s.is_empty(), _ => true, } } /// 0.1.0 `ids(payload)`. fn transfer_ids(payload: &Value) -> Value { match &payload["ids"] { Value::String(s) if s == "all" => json!("all"), Value::Array(list) => Value::Array(list.iter().take(20000).map(|v| json!(v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()))).collect()), other => json!([other.as_str().map(str::to_string).unwrap_or_else(|| other.to_string())]), } } /// Explicit file/folder choice; both native pickers support multiple selections on every OS. async fn pick_upload_paths(core: &crate::core::app::Core, label: &str) -> Vec { use tauri_plugin_dialog::{DialogExt, MessageDialogButtons}; let title = core.t("native.files.selectUpload"); let parent = core.app.get_webview_window(label); let files = core.t("chrome.files"); let folders = core.t("chrome.folders"); let (tx, rx) = tokio::sync::oneshot::channel(); let mut dialog = core.app.dialog().message(&title).title(&title) .buttons(MessageDialogButtons::YesNoCancelCustom(files.clone(), folders.clone(), core.t("chrome.cancel"))); if let Some(window) = &parent { dialog = dialog.parent(window); } dialog.show_with_result(move |result| { let _ = tx.send(result); }); let Ok(choice) = rx.await else { return Vec::new() }; choose_upload_paths(choice, &files, &folders, crate::dialogs::pick_files(&core.app, parent.as_ref(), Some(&title), &[]), async { let (tx, rx) = tokio::sync::oneshot::channel(); let mut dialog = core.app.dialog().file().set_title(&title).set_can_create_directories(true); if let Some(window) = &parent { dialog = dialog.set_parent(window); } dialog.pick_folders(move |paths| { let _ = tx.send(paths.unwrap_or_default().into_iter().filter_map(|p| p.into_path().ok()).collect()); }); rx.await.unwrap_or_default() }).await } async fn choose_upload_paths( choice: tauri_plugin_dialog::MessageDialogResult, files: &str, folders: &str, pick_files: impl std::future::Future>, pick_folders: impl std::future::Future>, ) -> Vec { use tauri_plugin_dialog::MessageDialogResult; if choice == MessageDialogResult::Yes || choice == MessageDialogResult::Custom(files.to_string()) { pick_files.await } else if choice == MessageDialogResult::No || choice == MessageDialogResult::Custom(folders.to_string()) { pick_folders.await } else { Vec::new() } } // --- window, language, sessions -------------------------------------------------------------- module_cmd!(module_get_info, "plugin:getInfo", None, |core, ctx, p| Ok(ctx.manifest.clone())); module_cmd!(module_ui_get_language, "plugin:ui:getLanguage", None, |core, ctx, p| Ok(json!(core.language()))); module_cmd!(module_close, "plugin:close", None, |core, ctx, p| { core.services.modules.close_label(&core, &ctx.label); Ok(json!({ "ok": true })) }); module_cmd!(module_window_minimize, "plugin:window:minimize", None, |core, ctx, p| { core.services.modules.minimize_label(&core, &ctx.label, true); Ok(json!({ "ok": true })) }); module_cmd!(module_sessions_list, "plugin:sessions:list", Some("session.read"), |core, ctx, p| Ok(Value::Array(core.services.sessions.list()))); // --- system monitor --------------------------------------------------------------------------- module_cmd!(module_system_metrics, "plugin:system:metrics", Some("system.metrics.read"), |core, ctx, p| { crate::core::ssh::metrics::read_system_metrics(&core.services.sessions, text(&p, "sessionId")).await }); module_cmd!(module_system_virtual_memory, "plugin:system:virtualMemory", Some("system.metrics.read"), |core, ctx, p| { core.services.virtual_memory.read(text(&p, "sessionId"), &json!({ "sudoPassword": p["sudoPassword"], "privileged": p["privileged"] == true })).await }); module_cmd!(module_system_manage_virtual_memory, "plugin:system:manageVirtualMemory", Some("system.memory.manage"), |core, ctx, p| core.services.virtual_memory.manage(text(&p, "sessionId"), &p).await); // --- disks ------------------------------------------------------------------------------------- module_cmd!(module_disks_inspect, "plugin:disks:inspect", Some("disks.read"), |core, ctx, p| core.services.disks.inspect(text(&p, "sessionId"), &json!({ "privileged": p["privileged"] == true, "sudoPassword": p["sudoPassword"], "rememberSudo": p["rememberSudo"] == true })).await); module_cmd!(module_disks_smart, "plugin:disks:smart", Some("disks.read"), |core, ctx, p| core.services.disks.smart(text(&p, "sessionId"), &p).await); module_cmd!(module_disks_jobs, "plugin:disks:jobs", Some("disks.read"), |core, ctx, p| core.services.disks.jobs(text(&p, "sessionId"), &p).await); module_cmd!(module_disks_job_status, "plugin:disks:jobStatus", Some("disks.read"), |core, ctx, p| core.services.disks.job_status(text(&p, "sessionId"), &p).await); module_cmd!(module_disks_plan, "plugin:disks:plan", Some("disks.manage"), |core, ctx, p| core.services.disks.plan(text(&p, "sessionId"), &p).await); module_cmd!(module_disks_auto_expand, "plugin:disks:autoExpand", Some("disks.manage"), |core, ctx, p| core.services.disks.auto_expand(text(&p, "sessionId"), &p).await); module_cmd!(module_disks_cancel_plan, "plugin:disks:cancelPlan", Some("disks.manage"), |core, ctx, p| Ok(core.services.disks.cancel_plan(&p["planId"]))); module_cmd!(module_disks_apply, "plugin:disks:apply", Some("disks.manage"), |core, ctx, p| core.services.disks.apply(text(&p, "sessionId"), &p).await); // --- keys, logs -------------------------------------------------------------------------------- module_cmd!(module_keys_generate, "plugin:keys:generate", Some("keys.generate"), |core, ctx, p| core.services.keys.generate(&p).await); module_cmd!(module_keys_save, "plugin:keys:save", Some("keys.generate"), |core, ctx, p| { let generated = core.services.keys.generated(text(&p, "generationId"))?; let title = core.i18n.t(&core.language(), "native.keys.saveTitle"); let parent = core.app.get_webview_window(&ctx.label); let suggested = generated["suggestedName"].as_str().unwrap_or("id_key").to_string(); match crate::dialogs::save_file(&core.app, parent.as_ref(), Some(&title), &suggested, &[]).await { Some(path) => crate::core::keys::save_pair(&path, &generated), None => Ok(json!({ "canceled": true })), } }); module_cmd!(module_keys_password, "plugin:keys:password", Some("keys.generate"), |core, ctx, p| core.services.keys.generate_password(&p)); module_cmd!(module_keys_copy_password, "plugin:keys:copyPassword", Some("keys.generate"), |core, ctx, p| { use tauri_plugin_clipboard_manager::ClipboardExt; let password = core.services.keys.password(text(&p, "passwordId"))?; core.app.clipboard().write_text(password.as_str()).map_err(|_| CoreError::plain("Clipboard is unavailable."))?; Ok(json!({ "copied": true })) }); module_cmd!(module_logs_read, "plugin:logs:read", Some("logs.read"), |core, ctx, p| { let tail = read_tail(core.logger.path(), normalize_limit(p.get("maxBytes")))?; Ok(serde_json::to_value(tail)?) }); // --- users -------------------------------------------------------------------------------------- module_cmd!(module_users_list, "plugin:users:list", Some("users.read"), |core, ctx, p| core.services.users.list(text(&p, "sessionId")).await); module_cmd!(module_users_privileges, "plugin:users:privileges", Some("users.read"), |core, ctx, p| core.services.users.privileges(text(&p, "sessionId"), p["refresh"] == true).await); module_cmd!(module_users_inventory, "plugin:users:inventory", Some("users.read"), |core, ctx, p| core.services.users.inventory(text(&p, "sessionId"), &p).await); module_cmd!(module_users_detail, "plugin:users:detail", Some("users.read"), |core, ctx, p| core.services.users.detail(text(&p, "sessionId"), &p).await); module_cmd!(module_users_manage, "plugin:users:manage", Some("users.manage"), |core, ctx, p| core.services.users.manage(text(&p, "sessionId"), &p).await); module_cmd!(module_users_keys, "plugin:users:keys", Some("users.manage"), |core, ctx, p| core.services.users.keys(text(&p, "sessionId"), &p).await); module_cmd!(module_users_ssh_policy, "plugin:users:sshPolicy", Some("users.manage"), |core, ctx, p| core.services.users.ssh_policy(text(&p, "sessionId"), &p).await); // --- nginx --------------------------------------------------------------------------------------- use crate::core::ssh::nginx; module_cmd!(module_nginx_inspect, "plugin:nginx:inspect", Some("nginx.read"), |core, ctx, p| nginx::inspect(&core.services.sessions, text(&p, "sessionId")).await); module_cmd!(module_nginx_read_config, "plugin:nginx:readConfig", Some("nginx.read"), |core, ctx, p| nginx::read_config(&core.services.sessions, text(&p, "sessionId"), &p).await); module_cmd!(module_nginx_dump_config, "plugin:nginx:dumpConfig", Some("nginx.read"), |core, ctx, p| nginx::dump_config(&core.services.sessions, text(&p, "sessionId"), &p).await); module_cmd!(module_nginx_test_config, "plugin:nginx:testConfig", Some("nginx.read"), |core, ctx, p| nginx::test_config(&core.services.sessions, text(&p, "sessionId"), &p).await); module_cmd!(module_nginx_save_config, "plugin:nginx:saveConfig", Some("nginx.manage"), |core, ctx, p| nginx::save_config(&core.services.sessions, text(&p, "sessionId"), &p).await); module_cmd!(module_nginx_reload, "plugin:nginx:reload", Some("nginx.manage"), |core, ctx, p| nginx::reload(&core.services.sessions, text(&p, "sessionId"), &p).await); // --- files / transfers --------------------------------------------------------------------------- module_cmd!(module_files_list, "plugin:files:list", Some("files.read"), |core, ctx, p| core.services.files.list(text(&p, "sessionId"), &p["path"], &options(&p)).await); module_cmd!(module_files_privileges, "plugin:files:privileges", Some("files.read"), |core, ctx, p| core.services.files.privileges(text(&p, "sessionId")).await); module_cmd!(module_files_read_text, "plugin:files:readText", Some("files.read"), |core, ctx, p| core.services.files.read_text(text(&p, "sessionId"), &p["path"], &options(&p), &ctx.id).await); module_cmd!(module_files_write_text, "plugin:files:writeText", Some("files.write"), |core, ctx, p| core.services.files.write_text(text(&p, "sessionId"), &p["path"], &p["text"], &options(&p), &ctx.id).await); module_cmd!(module_files_save_text, "plugin:files:saveText", Some("files.write"), |core, ctx, p| Ok(core.services.files.save_text(text(&p, "sessionId"), &p["path"], &p["text"], &options(&p), &ctx.id).await)); module_cmd!(module_files_list_recovery, "plugin:files:listRecovery", Some("files.read"), |core, ctx, p| core.services.files.list_recovery(text(&p, "sessionId"), &options(&p)).await); module_cmd!(module_files_read_recovery, "plugin:files:readRecovery", Some("files.read"), |core, ctx, p| core.services.files.read_recovery(text(&p, "sessionId"), &p["recoveryId"], &options(&p)).await); module_cmd!(module_files_cleanup_recovery, "plugin:files:cleanupRecovery", Some("files.write"), |core, ctx, p| core.services.files.cleanup_recovery(text(&p, "sessionId"), &p["recoveryId"], &options(&p)).await); module_cmd!(module_files_close_text, "plugin:files:closeText", Some("files.read"), |core, ctx, p| Ok(core.services.files.close_text(text(&p, "editId"), &ctx.id))); module_cmd!(module_files_create_file, "plugin:files:createFile", Some("files.write"), |core, ctx, p| core.services.files.create_file(text(&p, "sessionId"), &p["path"], &options(&p)).await); module_cmd!(module_files_mkdir, "plugin:files:mkdir", Some("files.write"), |core, ctx, p| core.services.files.mkdir(text(&p, "sessionId"), &p["path"], &options(&p)).await); module_cmd!(module_files_rename, "plugin:files:rename", Some("files.write"), |core, ctx, p| core.services.files.rename(text(&p, "sessionId"), &p["from"], &p["to"], &options(&p)).await); module_cmd!(module_files_remove, "plugin:files:remove", Some("files.write"), |core, ctx, p| core.services.files.remove(text(&p, "sessionId"), &p["path"], &options(&p)).await); module_cmd!(module_files_remove_many, "plugin:files:removeMany", Some("files.write"), |core, ctx, p| core.services.files.remove_many(text(&p, "sessionId"), &p["paths"], p["recursive"].as_bool().unwrap_or(false) || truthy(&p["recursive"]), &options(&p)).await); module_cmd!(module_files_upload, "plugin:files:upload", Some("files.transfer"), |core, ctx, p| { let picked = pick_upload_paths(&core, &ctx.label).await; core.services.files.upload(text(&p, "sessionId"), &p["directory"], picked).await }); module_cmd!(module_files_download, "plugin:files:download", Some("files.transfer"), |core, ctx, p| { let session = text(&p, "sessionId"); let (target, size) = core.services.files.download_check(session, &p["path"]).await?; let title = core.t("native.files.saveDownload"); let parent = core.app.get_webview_window(&ctx.label); match crate::dialogs::save_file(&core.app, parent.as_ref(), Some(&title), &crate::core::files::posix::basename(&target), &[]).await { Some(local) => core.services.files.download_to(session, &target, size, &local).await, None => Ok(json!({ "canceled": true })), } }); module_cmd!(module_files_download_many, "plugin:files:downloadMany", Some("files.transfer"), |core, ctx, p| { let targets = crate::core::files::service::RemoteFileService::normalize_selection(&p["paths"])?; let title = core.t("native.files.selectDownloadDirectory"); let parent = core.app.get_webview_window(&ctx.label); match crate::dialogs::pick_folder(&core.app, parent.as_ref(), Some(&title)).await { Some(directory) => core.services.files.download_many(text(&p, "sessionId"), &targets, &directory).await, None => Ok(json!({ "canceled": true, "items": [] })), } }); module_cmd!(module_files_download_archive, "plugin:files:downloadArchive", Some("files.transfer"), |core, ctx, p| { use crate::core::files::service::{safe_entry_name, RemoteFileService}; let targets = RemoteFileService::normalize_selection(&p["paths"])?; if targets.iter().any(|t| t == "/") { return Err(CoreError::plain("Archiving the entire remote root is not allowed.")); } let format = RemoteFileService::archive_format(&p["format"])?; let base = if targets.len() == 1 { safe_entry_name(&crate::core::files::posix::basename(&targets[0]))? } else { "remote-selection".to_string() }; let title = core.t("native.files.saveArchive"); let parent = core.app.get_webview_window(&ctx.label); let extensions: &[&str] = match format.as_str() { "tar.gz" => &["tar.gz", "tgz"], "tar" => &["tar"], _ => &["zip"] }; let upper = format.to_uppercase(); let filters = [crate::dialogs::Filter { name: &upper, extensions }]; match crate::dialogs::save_file(&core.app, parent.as_ref(), Some(&title), &format!("{base}.{format}"), &filters).await { Some(destination) => core.services.files.download_archive(text(&p, "sessionId"), &targets, &format, &destination).await, None => Ok(json!({ "canceled": true })), } }); // The local disk belongs to the first-party File Manager only, whatever another module's manifest asks for. fn local_data_dir(core: &crate::core::app::Core, ctx: &crate::security::caller::ModuleContext) -> CoreResult { if ctx.id != "chj.file-manager" { return Err(CoreError::new("MODULE_PERMISSION_DENIED", "Only the File Manager can use the local disk.")); } Ok(core.paths.data.clone()) } module_cmd!(module_local_places, "plugin:files:local:places", Some("files.local"), |core, ctx, p| { local_data_dir(&core, &ctx)?; Ok(crate::core::files::local::places()) }); module_cmd!(module_local_list, "plugin:files:local:list", Some("files.local"), |core, ctx, p| { let data = local_data_dir(&core, &ctx)?; let path = text(&p, "path").to_string(); tokio::task::spawn_blocking(move || crate::core::files::local::list(&path, &data)).await.map_err(|_| CoreError::plain("Local folder read failed."))? }); module_cmd!(module_local_mkdir, "plugin:files:local:mkdir", Some("files.local"), |core, ctx, p| { let data = local_data_dir(&core, &ctx)?; crate::core::files::local::mkdir(text(&p, "parent"), text(&p, "name"), &data) }); module_cmd!(module_local_create_file, "plugin:files:local:createFile", Some("files.local"), |core, ctx, p| { let data = local_data_dir(&core, &ctx)?; crate::core::files::local::create_file(text(&p, "parent"), text(&p, "name"), &data) }); module_cmd!(module_local_rename, "plugin:files:local:rename", Some("files.local"), |core, ctx, p| { let data = local_data_dir(&core, &ctx)?; crate::core::files::local::rename(text(&p, "path"), text(&p, "name"), &data) }); module_cmd!(module_local_remove, "plugin:files:local:remove", Some("files.local"), |core, ctx, p| { let data = local_data_dir(&core, &ctx)?; let paths: Vec = p["paths"].as_array().map(|list| list.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default(); tokio::task::spawn_blocking(move || crate::core::files::local::remove(&paths, &data)).await.map_err(|_| CoreError::plain("Local delete failed."))? }); // Copy between panels: no picker, the destination comes from the panel. Local ends need `files.local`. module_cmd!(module_transfers_plan_copy, "plugin:files:transfers:planCopy", Some("files.transfer"), |core, ctx, p| { let (from, to) = (&p["source"], &p["destination"]); match (text(from, "kind"), text(to, "kind")) { ("remote", "remote") => core.services.transfers.plan_relay(&ctx.id, text(from, "sessionId"), &from["paths"], text(to, "sessionId"), &to["directory"]).await, ("local", "remote") => { ctx.require("files.local")?; let data = local_data_dir(&core, &ctx)?; let mut paths = Vec::new(); for value in from["paths"].as_array().cloned().unwrap_or_default() { paths.push(crate::core::files::local::checked_path(value.as_str().unwrap_or(""), &data)?); } let pick: crate::core::files::transfers::PickPaths = Box::new(move || Box::pin(async move { Some(paths) })); core.services.transfers.plan_upload(&ctx.id, text(to, "sessionId"), &to["directory"], pick).await } ("remote", "local") => { ctx.require("files.local")?; let data = local_data_dir(&core, &ctx)?; let directory = crate::core::files::local::checked_path(text(to, "directory"), &data)?; let file_directory = directory.clone(); let pick_file: crate::core::files::transfers::PickSavePath = Box::new(move |name: String| { Box::pin(async move { crate::core::files::service::safe_entry_name(&name).ok().map(|name| file_directory.join(name)) }) }); let pick_directory: crate::core::files::transfers::PickDirectory = Box::new(move || Box::pin(async move { Some(directory) })); core.services.transfers.plan_download(&ctx.id, text(from, "sessionId"), &from["paths"], &json!({ "noSaveDialog": true }), pick_file, pick_directory).await } _ => Err(crate::core::files::service::file_error("TRANSFER_INVALID_SELECTION", "Copy between two different panels; a local copy is not a transfer.")), } }); module_cmd!(module_transfers_list, "plugin:files:transfers:list", Some("files.transfer"), |core, ctx, p| Ok(core.services.transfers.list(&ctx.id))); module_cmd!(module_transfers_plan_upload, "plugin:files:transfers:planUpload", Some("files.transfer"), |core, ctx, p| { let (dialog_core, label) = (core.clone(), ctx.label.clone()); let pick: crate::core::files::transfers::PickPaths = Box::new(move || Box::pin(async move { Some(pick_upload_paths(&dialog_core, &label).await) })); core.services.transfers.plan_upload(&ctx.id, text(&p, "sessionId"), &p["directory"], pick).await }); module_cmd!(module_transfers_plan_download, "plugin:files:transfers:planDownload", Some("files.transfer"), |core, ctx, p| { let (file_core, file_label) = (core.clone(), ctx.label.clone()); let pick_file: crate::core::files::transfers::PickSavePath = Box::new(move |name: String| { Box::pin(async move { let title = file_core.t("native.files.saveDownload"); let parent = file_core.app.get_webview_window(&file_label); crate::dialogs::save_file(&file_core.app, parent.as_ref(), Some(&title), &name, &[]).await }) }); let (dir_core, dir_label) = (core.clone(), ctx.label.clone()); let pick_directory: crate::core::files::transfers::PickDirectory = Box::new(move || { Box::pin(async move { let title = dir_core.t("native.files.selectDownloadDirectory"); let parent = dir_core.app.get_webview_window(&dir_label); crate::dialogs::pick_folder(&dir_core.app, parent.as_ref(), Some(&title)).await }) }); core.services.transfers.plan_download(&ctx.id, text(&p, "sessionId"), &p["paths"], &options(&p), pick_file, pick_directory).await }); module_cmd!(module_transfers_start, "plugin:files:transfers:start", Some("files.transfer"), |core, ctx, p| core.services.transfers.start(&ctx.id, &p["planId"], &options(&p)).await); module_cmd!(module_transfers_pause, "plugin:files:transfers:pause", Some("files.transfer"), |core, ctx, p| Ok(core.services.transfers.pause(&ctx.id, &transfer_ids(&p)))); module_cmd!(module_transfers_resume, "plugin:files:transfers:resume", Some("files.transfer"), |core, ctx, p| Ok(core.services.transfers.resume(&ctx.id, &transfer_ids(&p), &options(&p)))); module_cmd!(module_transfers_retry, "plugin:files:transfers:retry", Some("files.transfer"), |core, ctx, p| Ok(core.services.transfers.retry(&ctx.id, &transfer_ids(&p), &options(&p)))); module_cmd!(module_transfers_cancel, "plugin:files:transfers:cancel", Some("files.transfer"), |core, ctx, p| Ok(core.services.transfers.cancel(&ctx.id, &transfer_ids(&p)).await)); module_cmd!(module_transfers_remove, "plugin:files:transfers:remove", Some("files.transfer"), |core, ctx, p| Ok(core.services.transfers.remove(&ctx.id, &transfer_ids(&p)))); module_cmd!(module_transfers_settings, "plugin:files:transfers:settings", Some("files.transfer"), |core, ctx, p| core.services.transfers.update_settings(p.get("settings").filter(|v| v.is_object()).unwrap_or(&json!({})))); // --- hashing --------------------------------------------------------------------------------------- module_cmd!(module_hashing_algorithms, "plugin:hashing:algorithms", Some("local.hash"), |core, ctx, p| Ok(core.services.hashing.algorithms())); module_cmd!(module_hashing_select_files, "plugin:hashing:selectFiles", Some("local.hash"), |core, ctx, p| { let multiple = p["multiple"] == true; let title = hash_text(&core, if multiple { "Select local files to hash" } else { "Select a local file to hash" }); let parent = core.app.get_webview_window(&ctx.label); let picked = if multiple { Some(crate::dialogs::pick_files(&core.app, parent.as_ref(), Some(&title), &[]).await).filter(|v| !v.is_empty()) } else { crate::dialogs::pick_file(&core.app, parent.as_ref(), Some(&title), &[]).await.map(|p| vec![p]) }; core.services.hashing.select_files(&ctx.id, multiple, picked) }); module_cmd!(module_hashing_select_directory, "plugin:hashing:selectDirectory", Some("local.hash"), |core, ctx, p| { let title = hash_text(&core, "Select a local directory"); let parent = core.app.get_webview_window(&ctx.label); let picked = crate::dialogs::pick_folder(&core.app, parent.as_ref(), Some(&title)).await; core.services.hashing.select_directory(&ctx.id, picked) }); module_cmd!(module_hashing_select_manifest, "plugin:hashing:selectManifest", Some("local.hash"), |core, ctx, p| { let title = hash_text(&core, "Select a checksum manifest"); let name = hash_text(&core, "Checksum manifests"); let parent = core.app.get_webview_window(&ctx.label); let filters = [crate::dialogs::Filter { name: &name, extensions: &["sha224", "sha256", "sha384", "sha512", "sha3", "blake3", "md5", "sha1", "sfv", "checksums", "txt"] }]; let picked = crate::dialogs::pick_file(&core.app, parent.as_ref(), Some(&title), &filters).await; core.services.hashing.select_manifest(&ctx.id, picked) }); module_cmd!(module_hashing_select_manifest_destination, "plugin:hashing:selectManifestDestination", Some("local.hash"), |core, ctx, p| { let suggested = crate::core::hashing::service::LocalHashService::suggested_manifest_name(&p); let title = hash_text(&core, "Save checksum manifest"); let parent = core.app.get_webview_window(&ctx.label); let picked = crate::dialogs::save_file(&core.app, parent.as_ref(), Some(&title), &suggested, &[]).await; core.services.hashing.select_manifest_destination(&ctx.id, picked) }); module_cmd!(module_hashing_start, "plugin:hashing:start", Some("local.hash"), |core, ctx, p| core.services.hashing.start(&ctx.id, &p)); module_cmd!(module_hashing_verify, "plugin:hashing:verify", Some("local.hash"), |core, ctx, p| core.services.hashing.verify(&ctx.id, &p)); module_cmd!(module_hashing_compare, "plugin:hashing:compare", Some("local.hash"), |core, ctx, p| core.services.hashing.compare(&ctx.id, &p)); module_cmd!(module_hashing_generate_manifest, "plugin:hashing:generateManifest", Some("local.hash"), |core, ctx, p| core.services.hashing.generate_manifest(&ctx.id, &p)); module_cmd!(module_hashing_verify_manifest, "plugin:hashing:verifyManifest", Some("local.hash"), |core, ctx, p| { let (service, id) = (core.services.hashing.clone(), ctx.id.clone()); // Manifest parsing and path checks touch the disk: keep them off the async workers. tokio::task::spawn_blocking(move || service.verify_manifest(&id, &p)).await.map_err(|_| CoreError::new("HASH_INTERNAL_ERROR", "An internal hashing error occurred."))? }); module_cmd!(module_hashing_export_results, "plugin:hashing:exportResults", Some("local.hash"), |core, ctx, p| core.services.hashing.export_results(&ctx.id, &p)); module_cmd!(module_hashing_copy_result, "plugin:hashing:copyResult", Some("local.hash"), |core, ctx, p| { use tauri_plugin_clipboard_manager::ClipboardExt; let (text, count) = core.services.hashing.copy_text(&ctx.id, &p)?; core.app.clipboard().write_text(text).map_err(|_| CoreError::plain("Clipboard is unavailable."))?; Ok(json!({ "ok": true, "count": count })) }); module_cmd!(module_hashing_status, "plugin:hashing:status", Some("local.hash"), |core, ctx, p| core.services.hashing.status(&ctx.id, &p["jobId"])); module_cmd!(module_hashing_cancel, "plugin:hashing:cancel", Some("local.hash"), |core, ctx, p| core.services.hashing.cancel(&ctx.id, &p["jobId"])); pub fn _keep(app: &tauri::AppHandle) { let _ = app.app_handle(); } #[cfg(test)] mod upload_tests { use super::*; use tauri_plugin_dialog::MessageDialogResult; #[tokio::test] async fn upload_choice_preserves_multiselection_and_cancel_opens_no_picker() { for choice in [MessageDialogResult::Yes, MessageDialogResult::Custom("Soubory".into())] { let paths = choose_upload_paths(choice, "Soubory", "Složky", async { vec!["a.txt".into(), "b.txt".into()] }, async { panic!("folder picker opened") }).await; assert_eq!(paths, vec![std::path::PathBuf::from("a.txt"), "b.txt".into()]); } for choice in [MessageDialogResult::No, MessageDialogResult::Custom("Složky".into())] { let paths = choose_upload_paths(choice, "Soubory", "Složky", async { panic!("file picker opened") }, async { vec!["dir-a".into(), "dir-b".into()] }).await; assert_eq!(paths, vec![std::path::PathBuf::from("dir-a"), "dir-b".into()]); } let paths = choose_upload_paths(MessageDialogResult::Cancel, "Soubory", "Složky", async { panic!("file picker opened") }, async { panic!("folder picker opened") }).await; assert!(paths.is_empty()); } }