diff --git a/Cargo.lock b/Cargo.lock index b8ba4b2f..3e0d6d4c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11029,6 +11029,7 @@ dependencies = [ "uuid", "yaak", "yaak-api", + "yaak-commands", "yaak-common", "yaak-core", "yaak-crypto", @@ -11108,6 +11109,24 @@ dependencies = [ "zip", ] +[[package]] +name = "yaak-commands" +version = "0.0.0" +dependencies = [ + "log 0.4.29", + "serde_json", + "tempfile", + "thiserror 2.0.17", + "tokio", + "yaak", + "yaak-core", + "yaak-crypto", + "yaak-models", + "yaak-plugins", + "yaak-rpc-schema", + "yaak-templates", +] + [[package]] name = "yaak-common" version = "0.1.0" @@ -11439,6 +11458,7 @@ version = "0.1.0" dependencies = [ "regex 1.11.1", "tauri", + "yaak-core", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5f9ced20..4f013a57 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "crates/yaak", + "crates/yaak-commands", # Common/foundation crates "crates/common/yaak-database", "crates/common/yaak-rpc", @@ -69,6 +70,7 @@ yaak-rpc-schema = { path = "crates/common/yaak-rpc-schema" } # Internal crates - shared yaak-core = { path = "crates/yaak-core" } yaak = { path = "crates/yaak" } +yaak-commands = { path = "crates/yaak-commands" } yaak-common = { path = "crates/yaak-common" } yaak-crypto = { path = "crates/yaak-crypto" } yaak-git = { path = "crates/yaak-git" } diff --git a/crates-tauri/yaak-app-client/Cargo.toml b/crates-tauri/yaak-app-client/Cargo.toml index a307ad70..344cf41e 100644 --- a/crates-tauri/yaak-app-client/Cargo.toml +++ b/crates-tauri/yaak-app-client/Cargo.toml @@ -80,6 +80,7 @@ yaak-common = { workspace = true } yaak-tauri-utils = { workspace = true } yaak-core = { workspace = true } yaak = { workspace = true } +yaak-commands = { workspace = true } yaak-crypto = { workspace = true } yaak-fonts = { workspace = true } yaak-git = { workspace = true } diff --git a/crates-tauri/yaak-app-client/src/commands.rs b/crates-tauri/yaak-app-client/src/commands.rs index bd40c86c..f8b9dbc2 100644 --- a/crates-tauri/yaak-app-client/src/commands.rs +++ b/crates-tauri/yaak-app-client/src/commands.rs @@ -1,51 +1,8 @@ use crate::PluginContextExt; use crate::error::Result; -use std::sync::Arc; -use tauri::{AppHandle, Manager, Runtime, State, WebviewWindow}; -use yaak_crypto::manager::EncryptionManager; -use yaak_models::models::HttpRequestHeader; -use yaak_models::queries::workspaces::default_headers; +use tauri::{Runtime, State, WebviewWindow}; use yaak_plugins::events::GetThemesResponse; use yaak_plugins::manager::PluginManager; -use yaak_plugins::native_template_functions::{ - decrypt_secure_template_function, encrypt_secure_template_function, -}; - -/// Extension trait for accessing the EncryptionManager from Tauri Manager types. -pub trait EncryptionManagerExt<'a, R> { - fn crypto(&'a self) -> State<'a, EncryptionManager>; -} - -impl<'a, R: Runtime, M: Manager> EncryptionManagerExt<'a, R> for M { - fn crypto(&'a self) -> State<'a, EncryptionManager> { - self.state::() - } -} - -pub(crate) async fn cmd_decrypt_template( - window: WebviewWindow, - template: &str, -) -> Result { - let encryption_manager = window.app_handle().state::(); - let plugin_context = window.plugin_context(); - Ok(decrypt_secure_template_function(&encryption_manager, &plugin_context, template)?) -} - -pub(crate) async fn cmd_secure_template( - app_handle: AppHandle, - window: WebviewWindow, - template: &str, -) -> Result { - let plugin_manager = Arc::new((*app_handle.state::()).clone()); - let encryption_manager = Arc::new((*app_handle.state::()).clone()); - let plugin_context = window.plugin_context(); - Ok(encrypt_secure_template_function( - plugin_manager, - encryption_manager, - &plugin_context, - template, - )?) -} pub(crate) async fn cmd_get_themes( window: WebviewWindow, @@ -53,40 +10,3 @@ pub(crate) async fn cmd_get_themes( ) -> Result> { Ok(plugin_manager.get_themes(&window.plugin_context()).await?) } - -pub(crate) async fn cmd_enable_encryption( - window: WebviewWindow, - workspace_id: &str, -) -> Result<()> { - window.crypto().ensure_workspace_key(workspace_id)?; - window.crypto().reveal_workspace_key(workspace_id)?; - Ok(()) -} - -pub(crate) async fn cmd_reveal_workspace_key( - window: WebviewWindow, - workspace_id: &str, -) -> Result { - Ok(window.crypto().reveal_workspace_key(workspace_id)?) -} - -pub(crate) async fn cmd_set_workspace_key( - window: WebviewWindow, - workspace_id: &str, - key: &str, -) -> Result<()> { - window.crypto().set_human_key(workspace_id, key)?; - Ok(()) -} - -pub(crate) async fn cmd_disable_encryption( - window: WebviewWindow, - workspace_id: &str, -) -> Result<()> { - window.crypto().disable_encryption(workspace_id)?; - Ok(()) -} - -pub(crate) fn cmd_default_headers() -> Vec { - default_headers() -} diff --git a/crates-tauri/yaak-app-client/src/error.rs b/crates-tauri/yaak-app-client/src/error.rs index a8a6f446..19574683 100644 --- a/crates-tauri/yaak-app-client/src/error.rs +++ b/crates-tauri/yaak-app-client/src/error.rs @@ -41,6 +41,9 @@ pub enum Error { #[error(transparent)] YaakError(#[from] yaak::Error), + #[error(transparent)] + CommandError(#[from] yaak_commands::Error), + #[error(transparent)] ClipboardError(#[from] tauri_plugin_clipboard_manager::Error), diff --git a/crates-tauri/yaak-app-client/src/lib.rs b/crates-tauri/yaak-app-client/src/lib.rs index 53e45519..8408cc42 100644 --- a/crates-tauri/yaak-app-client/src/lib.rs +++ b/crates-tauri/yaak-app-client/src/lib.rs @@ -14,7 +14,7 @@ use error::Result as YaakResult; use eventsource_client::{EventParser, SSE}; use log::{debug, error, info, warn}; use std::collections::HashMap; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; use std::time::Duration; @@ -29,8 +29,8 @@ use tauri_plugin_log::{Builder, Target, TargetKind, log}; use tokio::sync::Mutex; use tokio::task::block_in_place; use tokio::time; -use yaak::export::{self, ExportDataParams}; use yaak::send::ResponseBody; +use yaak_commands::responses::locate_response_body; use yaak_common::command::new_checked_command; use yaak_crypto::manager::EncryptionManager; use yaak_grpc::manager::{GrpcConfig, GrpcHandle}; @@ -38,8 +38,7 @@ use yaak_grpc::{Code, ServiceDefinition}; use yaak_mac_window::AppHandleMacWindowExt; use yaak_models::models::{ AnyModel, CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent, - GrpcEventType, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState, Workspace, - WorkspaceMeta, + GrpcEventType, HttpRequest, HttpResponse, HttpResponseState, Workspace, }; use yaak_models::util::{BatchUpsertResult, UpdateSource}; use yaak_plugins::events::{ @@ -54,12 +53,10 @@ use yaak_plugins::events::{ InternalEventPayload, JsonPrimitive, PluginContext, RenderPurpose, ShowToastRequest, }; use yaak_plugins::manager::PluginManager; -use yaak_plugins::plugin_meta::{PluginMetadata, get_plugin_meta}; use yaak_plugins::template_callback::PluginTemplateCallback; use yaak_rpc_schema::{AppMetaData, EphemeralHttpResponse}; use yaak_sse::sse::ServerSentEvent; use yaak_tauri_utils::window::WorkspaceWindowTrait; -use yaak_templates::format_json::format_json; use yaak_templates::strip_json_comments::strip_json_comments; use yaak_templates::{RenderErrorBehavior, RenderOptions, Tokens, transform_args}; use yaak_tls::find_client_certificate; @@ -1011,10 +1008,6 @@ async fn cmd_send_ephemeral_request( Ok(EphemeralHttpResponse { response: sent.response, body }) } -async fn cmd_format_json(text: &str) -> YaakResult { - Ok(format_json(text, " ")) -} - async fn cmd_format_graphql(text: &str) -> YaakResult { match pretty_graphql::format_text(text, &Default::default()) { Ok(formatted) => Ok(formatted), @@ -1022,44 +1015,13 @@ async fn cmd_format_graphql(text: &str) -> YaakResult { } } -/// Where a response's body is, and what it is meant to be read as. -struct ResponseBodyLocation { - /// None when the response has no stored body. - path: Option, - /// The response's declared `Content-Type`, empty when it has none. - content_type: String, -} - -/// Find a response's body from its id alone. -/// -/// The frontend hands back an id and never a path, so the only bodies reachable -/// here are ones the engine wrote and the database still knows about. A -/// response that was never saved has no entry, and its body came back from the -/// send that made it. -fn locate_response_body( - app_handle: &AppHandle, - response_id: &str, -) -> YaakResult { - let response = app_handle.db().get_http_response(response_id)?; - - Ok(ResponseBodyLocation { - path: response.body_path.map(PathBuf::from), - content_type: response - .headers - .iter() - .find(|h| h.name.eq_ignore_ascii_case("content-type")) - .map(|h| h.value.clone()) - .unwrap_or_default(), - }) -} - async fn cmd_http_response_body( window: WebviewWindow, plugin_manager: State<'_, PluginManager>, response_id: &str, filter: Option<&str>, ) -> YaakResult { - let location = locate_response_body(window.app_handle(), response_id)?; + let location = locate_response_body(&window.db(), response_id)?; let Some(body_path) = location.path else { return Ok(FilterResponse { content: String::new(), error: None }); }; @@ -1077,41 +1039,11 @@ async fn cmd_http_response_body( } } -/// The body's path on this machine, for the desktop host to read or hand to the -/// webview's asset protocol. -/// -/// The frontend holds response ids; only `packages/platform`'s Tauri host sees -/// the path, and only because it is about to open the file itself. Hosts -/// without a filesystem serve the same bytes over HTTP instead. -async fn cmd_http_response_body_path( - app_handle: AppHandle, - response_id: &str, -) -> YaakResult> { - let location = locate_response_body(&app_handle, response_id)?; - Ok(location.path.map(|p| p.to_string_lossy().to_string())) -} - -async fn cmd_http_request_body( - app_handle: AppHandle, - response_id: &str, -) -> YaakResult>> { - let body_id = format!("{}.request", response_id); - let chunks = app_handle.blobs().get_chunks(&body_id)?; - - if chunks.is_empty() { - return Ok(None); - } - - // Concatenate all chunks - let body: Vec = chunks.into_iter().flat_map(|c| c.data).collect(); - Ok(Some(body)) -} - async fn cmd_get_sse_events( app_handle: AppHandle, response_id: &str, ) -> YaakResult> { - let Some(body_path) = locate_response_body(&app_handle, response_id)?.path else { + let Some(body_path) = locate_response_body(&app_handle.db(), response_id)?.path else { return Ok(Vec::new()); }; @@ -1134,14 +1066,6 @@ async fn cmd_get_sse_events( Ok(events) } -async fn cmd_get_http_response_events( - app_handle: AppHandle, - response_id: &str, -) -> YaakResult> { - let events: Vec = app_handle.db().list_http_response_events(response_id)?; - Ok(events) -} - async fn cmd_import_data( window: WebviewWindow, file_path: &str, @@ -1434,22 +1358,6 @@ async fn cmd_curl_to_request( })?) } -async fn cmd_export_data( - app_handle: AppHandle, - export_path: &str, - workspace_ids: Vec<&str>, - include_private_environments: bool, -) -> YaakResult<()> { - let version = app_handle.package_info().version.to_string(); - Ok(export::export_data(ExportDataParams { - query_manager: &app_handle.db_manager(), - yaak_version: &version, - export_path: Path::new(export_path), - workspace_ids, - include_private_environments, - })?) -} - /// Decodes base64 and writes the bytes to a file the user picked. /// /// The webview can't do this itself: its `fs` permissions are read-only and scoped to the app @@ -1473,20 +1381,6 @@ async fn cmd_save_base64_to_binary( Ok(()) } -async fn cmd_save_response( - app_handle: AppHandle, - response_id: &str, - filepath: &str, -) -> YaakResult<()> { - let response = app_handle.db().get_http_response(response_id)?; - - let body_path = - response.body_path.ok_or(GenericError("Response does not have a body".to_string()))?; - fs::copy(body_path, filepath).map_err(|e| GenericError(e.to_string()))?; - - Ok(()) -} - async fn cmd_send_http_request( app_handle: AppHandle, window: WebviewWindow, @@ -1570,90 +1464,6 @@ async fn cmd_reload_plugins( Ok(errors) } -async fn cmd_plugin_info( - id: &str, - app_handle: AppHandle, - plugin_manager: State<'_, PluginManager>, -) -> YaakResult { - let plugin = app_handle.db().get_plugin(id)?; - if let Some(plugin_handle) = plugin_manager - .get_plugin_by_dir(plugin.directory.as_str()) - .await - { - return Ok(plugin_handle.info()); - } - - if let Ok(metadata) = get_plugin_meta(&PathBuf::from(&plugin.directory)) { - return Ok(metadata); - } - - Ok(fallback_plugin_metadata(&plugin.directory)) -} - -fn fallback_plugin_metadata(directory: &str) -> PluginMetadata { - let display_name = PathBuf::from(directory) - .file_name() - .and_then(|name| name.to_str()) - .filter(|name| !name.is_empty()) - .unwrap_or(directory) - .to_string(); - - PluginMetadata { - version: "Unavailable".to_string(), - name: directory.to_string(), - display_name, - description: Some(format!("Plugin metadata could not be loaded from {directory}")), - homepage_url: None, - repository_url: None, - } -} - -async fn cmd_delete_all_grpc_connections( - request_id: &str, - app_handle: AppHandle, - window: WebviewWindow, -) -> YaakResult<()> { - Ok(app_handle.db().delete_all_grpc_connections_for_request( - request_id, - &UpdateSource::from_window_label(window.label()), - )?) -} - -async fn cmd_delete_send_history( - workspace_id: &str, - app_handle: AppHandle, - window: WebviewWindow, -) -> YaakResult<()> { - Ok(app_handle.with_tx(|tx| { - let source = &UpdateSource::from_window_label(window.label()); - tx.delete_all_http_responses_for_workspace(workspace_id, source)?; - tx.delete_all_grpc_connections_for_workspace(workspace_id, source)?; - tx.delete_all_websocket_connections_for_workspace(workspace_id, source)?; - Ok(()) - })?) -} - -async fn cmd_delete_all_http_responses( - request_id: &str, - app_handle: AppHandle, - window: WebviewWindow, -) -> YaakResult<()> { - app_handle.db().delete_all_http_responses_for_request( - request_id, - &UpdateSource::from_window_label(window.label()), - )?; - Ok(()) -} - -async fn cmd_get_workspace_meta( - app_handle: AppHandle, - workspace_id: &str, -) -> YaakResult { - let db = app_handle.db(); - let workspace = db.get_workspace(workspace_id)?; - Ok(db.get_or_create_workspace_meta(&workspace.id)?) -} - async fn cmd_new_child_window( parent_window: WebviewWindow, url: &str, diff --git a/crates-tauri/yaak-app-client/src/models_ext.rs b/crates-tauri/yaak-app-client/src/models_ext.rs index 92b2b2dc..0e58c62f 100644 --- a/crates-tauri/yaak-app-client/src/models_ext.rs +++ b/crates-tauri/yaak-app-client/src/models_ext.rs @@ -12,10 +12,8 @@ use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; use yaak_models::blob_manager::BlobManager; use yaak_models::client_db::ClientDb; use yaak_models::error::Result; -use yaak_models::models::{AnyModel, GraphQlIntrospection, GrpcEvent, Settings, WebsocketEvent}; use yaak_models::query_manager::QueryManager; use yaak_models::util::{ModelPayload, UpdateSource}; -use yaak_plugins::manager::PluginManager; const MODEL_CHANGES_RETENTION_HOURS: i64 = 1; const MODEL_CHANGES_POLL_INTERVAL_MS: u64 = 1000; @@ -123,163 +121,12 @@ impl<'a, R: Runtime, M: Manager> QueryManagerExt<'a, R> for M { /// Extension trait for accessing the BlobManager from Tauri Manager types. pub trait BlobManagerExt<'a, R> { fn blob_manager(&'a self) -> State<'a, BlobManager>; - fn blobs(&'a self) -> yaak_models::blob_manager::BlobContext; } impl<'a, R: Runtime, M: Manager> BlobManagerExt<'a, R> for M { fn blob_manager(&'a self) -> State<'a, BlobManager> { self.state::() } - - fn blobs(&'a self) -> yaak_models::blob_manager::BlobContext { - let manager = self.state::(); - manager.inner().connect() - } -} - -// Commands for yaak-models -use tauri::WebviewWindow; - -pub(crate) fn models_upsert( - window: WebviewWindow, - model: AnyModel, -) -> Result { - let db = window.db(); - let blobs = window.blob_manager(); - let source = &UpdateSource::from_window_label(window.label()); - yaak::models_ops::upsert_model(&db, &blobs, model, source) -} - -// Async so cascading deletes (e.g. a workspace with thousands of requests) run on a -// blocking thread instead of stalling the main thread and all other IPC. -pub(crate) async fn models_delete( - window: WebviewWindow, - model: AnyModel, -) -> Result { - use yaak_models::error::Error::GenericError; - - tauri::async_runtime::spawn_blocking(move || { - let blobs = window.blob_manager(); - // Use transaction for deletions because it might recurse - window.with_tx(|tx| { - let source = &UpdateSource::from_window_label(window.label()); - yaak::models_ops::delete_model(tx, &blobs, model, source) - }) - }) - .await - .map_err(|e| GenericError(format!("Delete task failed: {e}")))? -} - -pub(crate) fn models_duplicate( - window: WebviewWindow, - model_type: String, - model_id: String, -) -> Result { - // Use transaction for duplications because it might recurse - window.with_tx(|tx| { - let source = &UpdateSource::from_window_label(window.label()); - yaak::models_ops::duplicate_model(tx, &model_type, &model_id, source) - }) -} - -pub(crate) fn models_websocket_events( - app_handle: tauri::AppHandle, - connection_id: &str, -) -> Result> { - Ok(app_handle.db().list_websocket_events(connection_id)?) -} - -pub(crate) fn models_grpc_events( - app_handle: tauri::AppHandle, - connection_id: &str, -) -> Result> { - Ok(app_handle.db().list_grpc_events(connection_id)?) -} - -pub(crate) fn models_get_settings(app_handle: tauri::AppHandle) -> Result { - Ok(app_handle.db().get_settings()) -} - -pub(crate) fn models_get_graphql_introspection( - app_handle: tauri::AppHandle, - request_id: &str, -) -> Result> { - Ok(app_handle.db().get_graphql_introspection(request_id)) -} - -pub(crate) fn models_upsert_graphql_introspection( - app_handle: tauri::AppHandle, - request_id: &str, - workspace_id: &str, - content: Option, - window: WebviewWindow, -) -> Result { - let source = UpdateSource::from_window_label(window.label()); - Ok(app_handle.db().upsert_graphql_introspection(workspace_id, request_id, content, &source)?) -} - -pub(crate) async fn models_workspace_models( - window: WebviewWindow, - workspace_id: Option<&str>, - plugin_manager: State<'_, PluginManager>, -) -> Result { - let mut l: Vec = Vec::new(); - - // Add the global models - { - let db = window.db(); - l.push(db.get_settings().into()); - l.append(&mut db.list_workspaces()?.into_iter().map(Into::into).collect()); - l.append(&mut db.list_key_values()?.into_iter().map(Into::into).collect()); - } - - let plugins = { - let db = window.db(); - db.list_plugins()? - }; - - let plugins = plugin_manager.resolve_plugins_for_runtime_from_db(plugins).await; - l.append(&mut plugins.into_iter().map(Into::into).collect()); - - // Add the workspace children - if let Some(wid) = workspace_id { - let db = window.db(); - l.append(&mut db.list_cookie_jars(wid)?.into_iter().map(Into::into).collect()); - l.append(&mut db.list_environments_ensure_base(wid)?.into_iter().map(Into::into).collect()); - l.append(&mut db.list_folders(wid)?.into_iter().map(Into::into).collect()); - l.append(&mut db.list_grpc_connections(wid)?.into_iter().map(Into::into).collect()); - l.append(&mut db.list_grpc_requests(wid)?.into_iter().map(Into::into).collect()); - l.append(&mut db.list_http_requests(wid)?.into_iter().map(Into::into).collect()); - l.append(&mut db.list_http_responses(wid, None)?.into_iter().map(Into::into).collect()); - l.append(&mut db.list_websocket_connections(wid)?.into_iter().map(Into::into).collect()); - l.append(&mut db.list_websocket_requests(wid)?.into_iter().map(Into::into).collect()); - l.append(&mut db.list_workspace_metas(wid)?.into_iter().map(Into::into).collect()); - } - - let j = serde_json::to_string(&l)?; - - Ok(escape_str_for_webview(&j)) -} - -fn escape_str_for_webview(input: &str) -> String { - input - .chars() - .map(|c| { - let code = c as u32; - // ASCII - if code <= 0x7F { - c.to_string() - // BMP characters encoded normally - } else if code < 0xFFFF { - format!("\\u{:04X}", code) - // Beyond BMP encoded a surrogate pairs - } else { - let high = ((code - 0x10000) >> 10) + 0xD800; - let low = ((code - 0x10000) & 0x3FF) + 0xDC00; - format!("\\u{:04X}\\u{:04X}", high, low) - } - }) - .collect() } /// Initialize database managers as a plugin (for initialization order). diff --git a/crates-tauri/yaak-app-client/src/plugins_ext.rs b/crates-tauri/yaak-app-client/src/plugins_ext.rs index f59bae9d..a9a5d246 100644 --- a/crates-tauri/yaak-app-client/src/plugins_ext.rs +++ b/crates-tauri/yaak-app-client/src/plugins_ext.rs @@ -194,12 +194,6 @@ pub async fn cmd_plugins_uninstall( Ok(delete_and_uninstall(plugin_manager, &query_manager, &plugin_context, plugin_id).await?) } -pub async fn cmd_plugin_init_errors( - plugin_manager: State<'_, PluginManager>, -) -> Result> { - Ok(plugin_manager.take_init_errors().await) -} - pub async fn cmd_plugins_updates( app_handle: AppHandle, ) -> Result { diff --git a/crates-tauri/yaak-app-client/src/rpc_ext.rs b/crates-tauri/yaak-app-client/src/rpc_ext.rs index b3614532..328545d9 100644 --- a/crates-tauri/yaak-app-client/src/rpc_ext.rs +++ b/crates-tauri/yaak-app-client/src/rpc_ext.rs @@ -8,10 +8,13 @@ //! is the only way the frontend reaches any of it — one envelope, //! `{ cmd, payload }`, exactly like the proxy app. //! -//! Adapters exist so command implementations keep their natural Tauri -//! signatures (window, app handle, managed state) while the wire format stays -//! transport-agnostic: another host builds its router from the same schema with -//! a different context type and its own adapters, and the frontend cannot tell. +//! Command bodies live in one of two places. Host-independent ones are in +//! `yaak_commands`, written against its `Host` trait, which `ClientCtx` +//! implements below; their adapters are one line. The rest still have their +//! natural Tauri signatures (window, app handle, managed state) and their +//! adapters unpack the request for them. Either way the wire format stays +//! transport-agnostic: another host builds its router from the same schema +//! with its own `Host`, and the frontend cannot tell. use crate::error::Result; use crate::notifications::YaakNotifier; @@ -20,6 +23,8 @@ use log::warn; use serde::Serialize; use tauri::{Manager, Runtime, State, WebviewWindow}; use tokio::sync::Mutex; +use yaak_commands::{Host, PluginHost}; +use yaak_core::WorkspaceContext; use yaak_crypto::manager::EncryptionManager; use yaak_git::{ BranchDeleteResult, CloneResult, GitBranchInfo, GitCommit, GitFileDiff, GitRemote, @@ -27,10 +32,12 @@ use yaak_git::{ }; use yaak_grpc::manager::GrpcHandle; use yaak_grpc::ServiceDefinition; +use yaak_models::blob_manager::BlobManager; use yaak_models::models::{ GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse, HttpResponseEvent, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta, }; +use yaak_models::query_manager::QueryManager; use yaak_models::util::BatchUpsertResult; use yaak_plugins::events::{ FilterResponse, GetFolderActionsResponse, GetGrpcRequestActionsResponse, @@ -46,6 +53,7 @@ use yaak_rpc::RpcRouter; use yaak_rpc_schema::*; use yaak_sse::sse::ServerSentEvent; use yaak_sync::sync::SyncOp; +use yaak_tauri_utils::window::WorkspaceWindowTrait; use yaak_ws::WebsocketManager; /// Per-call context: the window a command was invoked from. @@ -65,6 +73,42 @@ impl Clone for ClientCtx { } } +/// The desktop is a host: the client is the window, the session is the +/// window's URL, and the shared managers are Tauri managed state. +impl Host for ClientCtx { + fn client_id(&self) -> &str { + self.window.label() + } + + fn session(&self) -> WorkspaceContext { + self.window.workspace_context() + } + + fn app_version(&self) -> String { + self.window.package_info().version.to_string() + } + + fn query_manager(&self) -> &QueryManager { + self.window.state::().inner() + } + + fn blob_manager(&self) -> &BlobManager { + self.window.state::().inner() + } + + fn encryption_manager(&self) -> &EncryptionManager { + self.window.state::().inner() + } +} + +/// The desktop runs plugins the usual way, so it serves the plugin-backed +/// commands too. +impl PluginHost for ClientCtx { + fn plugin_manager(&self) -> &PluginManager { + self.window.state::().inner() + } +} + /// The one Tauri command. The payload is the yaak-rpc envelope's payload; /// a missing payload means an empty one. #[tauri::command] @@ -189,8 +233,8 @@ async fn cmd_send_ephemeral_request(ctx: ClientCtx, req: CmdSendE Ok(crate::cmd_send_ephemeral_request(req.request, req.environment_id.as_deref(), req.cookie_jar_id.as_deref(), ctx.window.clone(), ctx.window.app_handle().clone()).await?) } -async fn cmd_format_json(_ctx: ClientCtx, req: CmdFormatJsonReq) -> Result { - Ok(crate::cmd_format_json(&req.text).await?) +async fn cmd_format_json(ctx: ClientCtx, req: CmdFormatJsonReq) -> Result { + Ok(yaak_commands::data::cmd_format_json(ctx, req).await?) } async fn cmd_format_graphql(_ctx: ClientCtx, req: CmdFormatGraphqlReq) -> Result { @@ -202,11 +246,11 @@ async fn cmd_http_response_body(ctx: ClientCtx, req: CmdHttpRespo } async fn cmd_http_response_body_path(ctx: ClientCtx, req: CmdHttpResponseBodyPathReq) -> Result> { - Ok(crate::cmd_http_response_body_path(ctx.window.app_handle().clone(), &req.response_id).await?) + Ok(yaak_commands::responses::cmd_http_response_body_path(ctx, req).await?) } async fn cmd_http_request_body(ctx: ClientCtx, req: CmdHttpRequestBodyReq) -> Result>> { - Ok(crate::cmd_http_request_body(ctx.window.app_handle().clone(), &req.response_id).await?) + Ok(yaak_commands::responses::cmd_http_request_body(ctx, req).await?) } async fn cmd_get_sse_events(ctx: ClientCtx, req: CmdGetSseEventsReq) -> Result> { @@ -214,7 +258,7 @@ async fn cmd_get_sse_events(ctx: ClientCtx, req: CmdGetSseEventsR } async fn cmd_get_http_response_events(ctx: ClientCtx, req: CmdGetHttpResponseEventsReq) -> Result> { - Ok(crate::cmd_get_http_response_events(ctx.window.app_handle().clone(), &req.response_id).await?) + Ok(yaak_commands::responses::cmd_get_http_response_events(ctx, req).await?) } async fn cmd_import_data(ctx: ClientCtx, req: CmdImportDataReq) -> Result { @@ -290,7 +334,7 @@ async fn cmd_curl_to_request(ctx: ClientCtx, req: CmdCurlToReques } async fn cmd_export_data(ctx: ClientCtx, req: CmdExportDataReq) -> Result<()> { - Ok(crate::cmd_export_data(ctx.window.app_handle().clone(), &req.export_path, req.workspace_ids.iter().map(|s| s.as_str()).collect(), req.include_private_environments).await?) + Ok(yaak_commands::data::cmd_export_data(ctx, req).await?) } async fn cmd_save_base64_to_binary(ctx: ClientCtx, req: CmdSaveBase64ToBinaryReq) -> Result<()> { @@ -298,7 +342,7 @@ async fn cmd_save_base64_to_binary(ctx: ClientCtx, req: CmdSaveBa } async fn cmd_save_response(ctx: ClientCtx, req: CmdSaveResponseReq) -> Result<()> { - Ok(crate::cmd_save_response(ctx.window.app_handle().clone(), &req.response_id, &req.filepath).await?) + Ok(yaak_commands::responses::cmd_save_response(ctx, req).await?) } async fn cmd_send_http_request(ctx: ClientCtx, req: CmdSendHttpRequestReq) -> Result { @@ -310,23 +354,23 @@ async fn cmd_reload_plugins(ctx: ClientCtx, _req: CmdReloadPlugin } async fn cmd_plugin_info(ctx: ClientCtx, req: CmdPluginInfoReq) -> Result { - Ok(crate::cmd_plugin_info(&req.id, ctx.window.app_handle().clone(), ctx.window.app_handle().state::()).await?) + Ok(yaak_commands::plugins::cmd_plugin_info(ctx, req).await?) } async fn cmd_delete_all_grpc_connections(ctx: ClientCtx, req: CmdDeleteAllGrpcConnectionsReq) -> Result<()> { - Ok(crate::cmd_delete_all_grpc_connections(&req.request_id, ctx.window.app_handle().clone(), ctx.window.clone()).await?) + Ok(yaak_commands::models::cmd_delete_all_grpc_connections(ctx, req).await?) } async fn cmd_delete_send_history(ctx: ClientCtx, req: CmdDeleteSendHistoryReq) -> Result<()> { - Ok(crate::cmd_delete_send_history(&req.workspace_id, ctx.window.app_handle().clone(), ctx.window.clone()).await?) + Ok(yaak_commands::models::cmd_delete_send_history(ctx, req).await?) } async fn cmd_delete_all_http_responses(ctx: ClientCtx, req: CmdDeleteAllHttpResponsesReq) -> Result<()> { - Ok(crate::cmd_delete_all_http_responses(&req.request_id, ctx.window.app_handle().clone(), ctx.window.clone()).await?) + Ok(yaak_commands::models::cmd_delete_all_http_responses(ctx, req).await?) } async fn cmd_get_workspace_meta(ctx: ClientCtx, req: CmdGetWorkspaceMetaReq) -> Result { - Ok(crate::cmd_get_workspace_meta(ctx.window.app_handle().clone(), &req.workspace_id).await?) + Ok(yaak_commands::models::cmd_get_workspace_meta(ctx, req).await?) } async fn cmd_new_child_window(ctx: ClientCtx, req: CmdNewChildWindowReq) -> Result<()> { @@ -342,11 +386,11 @@ async fn cmd_check_for_updates(ctx: ClientCtx, _req: CmdCheckForU } async fn cmd_decrypt_template(ctx: ClientCtx, req: CmdDecryptTemplateReq) -> Result { - Ok(crate::commands::cmd_decrypt_template(ctx.window.clone(), &req.template).await?) + Ok(yaak_commands::encryption::cmd_decrypt_template(ctx, req).await?) } async fn cmd_secure_template(ctx: ClientCtx, req: CmdSecureTemplateReq) -> Result { - Ok(crate::commands::cmd_secure_template(ctx.window.app_handle().clone(), ctx.window.clone(), &req.template).await?) + Ok(yaak_commands::encryption::cmd_secure_template(ctx, req).await?) } async fn cmd_get_themes(ctx: ClientCtx, _req: CmdGetThemesReq) -> Result> { @@ -354,59 +398,88 @@ async fn cmd_get_themes(ctx: ClientCtx, _req: CmdGetThemesReq) -> } async fn cmd_enable_encryption(ctx: ClientCtx, req: CmdEnableEncryptionReq) -> Result<()> { - Ok(crate::commands::cmd_enable_encryption(ctx.window.clone(), &req.workspace_id).await?) + Ok(yaak_commands::encryption::cmd_enable_encryption(ctx, req).await?) } async fn cmd_reveal_workspace_key(ctx: ClientCtx, req: CmdRevealWorkspaceKeyReq) -> Result { - Ok(crate::commands::cmd_reveal_workspace_key(ctx.window.clone(), &req.workspace_id).await?) + Ok(yaak_commands::encryption::cmd_reveal_workspace_key(ctx, req).await?) } async fn cmd_set_workspace_key(ctx: ClientCtx, req: CmdSetWorkspaceKeyReq) -> Result<()> { - Ok(crate::commands::cmd_set_workspace_key(ctx.window.clone(), &req.workspace_id, &req.key).await?) + Ok(yaak_commands::encryption::cmd_set_workspace_key(ctx, req).await?) } async fn cmd_disable_encryption(ctx: ClientCtx, req: CmdDisableEncryptionReq) -> Result<()> { - Ok(crate::commands::cmd_disable_encryption(ctx.window.clone(), &req.workspace_id).await?) + Ok(yaak_commands::encryption::cmd_disable_encryption(ctx, req).await?) } -async fn cmd_default_headers(_ctx: ClientCtx, _req: CmdDefaultHeadersReq) -> Result> { - Ok(crate::commands::cmd_default_headers()) +async fn cmd_default_headers(ctx: ClientCtx, req: CmdDefaultHeadersReq) -> Result> { + Ok(yaak_commands::models::cmd_default_headers(ctx, req).await?) } async fn models_upsert(ctx: ClientCtx, req: ModelsUpsertReq) -> Result { - Ok(crate::models_ext::models_upsert(ctx.window.clone(), req.model)?) + Ok(yaak_commands::models::models_upsert(ctx, req).await?) } async fn models_delete(ctx: ClientCtx, req: ModelsDeleteReq) -> Result { - Ok(crate::models_ext::models_delete(ctx.window.clone(), req.model).await?) + Ok(yaak_commands::models::models_delete(ctx, req).await?) } async fn models_duplicate(ctx: ClientCtx, req: ModelsDuplicateReq) -> Result { - Ok(crate::models_ext::models_duplicate(ctx.window.clone(), req.model_type, req.model_id)?) + Ok(yaak_commands::models::models_duplicate(ctx, req).await?) } async fn models_websocket_events(ctx: ClientCtx, req: ModelsWebsocketEventsReq) -> Result> { - Ok(crate::models_ext::models_websocket_events(ctx.window.app_handle().clone(), &req.connection_id)?) + Ok(yaak_commands::models::models_websocket_events(ctx, req).await?) } async fn models_grpc_events(ctx: ClientCtx, req: ModelsGrpcEventsReq) -> Result> { - Ok(crate::models_ext::models_grpc_events(ctx.window.app_handle().clone(), &req.connection_id)?) + Ok(yaak_commands::models::models_grpc_events(ctx, req).await?) } -async fn models_get_settings(ctx: ClientCtx, _req: ModelsGetSettingsReq) -> Result { - Ok(crate::models_ext::models_get_settings(ctx.window.app_handle().clone())?) +async fn models_get_settings(ctx: ClientCtx, req: ModelsGetSettingsReq) -> Result { + Ok(yaak_commands::models::models_get_settings(ctx, req).await?) } async fn models_get_graphql_introspection(ctx: ClientCtx, req: ModelsGetGraphqlIntrospectionReq) -> Result> { - Ok(crate::models_ext::models_get_graphql_introspection(ctx.window.app_handle().clone(), &req.request_id)?) + Ok(yaak_commands::models::models_get_graphql_introspection(ctx, req).await?) } async fn models_upsert_graphql_introspection(ctx: ClientCtx, req: ModelsUpsertGraphqlIntrospectionReq) -> Result { - Ok(crate::models_ext::models_upsert_graphql_introspection(ctx.window.app_handle().clone(), &req.request_id, &req.workspace_id, req.content, ctx.window.clone())?) + Ok(yaak_commands::models::models_upsert_graphql_introspection(ctx, req).await?) } +/// Non-ASCII is escaped to `\uXXXX` before the JSON crosses into the webview: +/// on Linux, sending Cyrillic (and possibly other) characters through this +/// payload leaves every string in the parsed models subtly mis-encoded and +/// CodeMirror unable to place the cursor (feedback: "editing the URL sometimes +/// freezes the app"). Escape sequences sidestep it. This is a quirk of the +/// webview transport, not of the data, so it lives in the adapter rather than +/// the shared handler. async fn models_workspace_models(ctx: ClientCtx, req: ModelsWorkspaceModelsReq) -> Result { - Ok(crate::models_ext::models_workspace_models(ctx.window.clone(), req.workspace_id.as_deref(), ctx.window.app_handle().state::()).await?) + let json = yaak_commands::models::models_workspace_models(ctx, req).await?; + Ok(escape_str_for_webview(&json)) +} + +fn escape_str_for_webview(input: &str) -> String { + input + .chars() + .map(|c| { + let code = c as u32; + // ASCII + if code <= 0x7F { + c.to_string() + // BMP characters encoded normally + } else if code < 0xFFFF { + format!("\\u{:04X}", code) + // Beyond BMP encoded a surrogate pairs + } else { + let high = ((code - 0x10000) >> 10) + 0xD800; + let low = ((code - 0x10000) & 0x3FF) + 0xDC00; + format!("\\u{:04X}\\u{:04X}", high, low) + } + }) + .collect() } async fn cmd_git_checkout(_ctx: ClientCtx, req: CmdGitCheckoutReq) -> Result { @@ -538,7 +611,7 @@ async fn cmd_sync_apply(ctx: ClientCtx, req: CmdSyncApplyReq) -> } async fn cmd_ws_delete_connections(ctx: ClientCtx, req: CmdWsDeleteConnectionsReq) -> Result<()> { - Ok(crate::ws_ext::cmd_ws_delete_connections(&req.request_id, ctx.window.app_handle().clone(), ctx.window.clone()).await?) + Ok(yaak_commands::models::cmd_ws_delete_connections(ctx, req).await?) } async fn cmd_ws_send(ctx: ClientCtx, req: CmdWsSendReq) -> Result { @@ -569,8 +642,8 @@ async fn cmd_plugins_uninstall(ctx: ClientCtx, req: CmdPluginsUni Ok(crate::plugins_ext::cmd_plugins_uninstall(&req.plugin_id, ctx.window.clone()).await?) } -async fn cmd_plugin_init_errors(ctx: ClientCtx, _req: CmdPluginInitErrorsReq) -> Result> { - Ok(crate::plugins_ext::cmd_plugin_init_errors(ctx.window.app_handle().state::()).await?) +async fn cmd_plugin_init_errors(ctx: ClientCtx, req: CmdPluginInitErrorsReq) -> Result> { + Ok(yaak_commands::plugins::cmd_plugin_init_errors(ctx, req).await?) } async fn cmd_plugins_updates(ctx: ClientCtx, _req: CmdPluginsUpdatesReq) -> Result { diff --git a/crates-tauri/yaak-app-client/src/ws_ext.rs b/crates-tauri/yaak-app-client/src/ws_ext.rs index 8a96cabe..2f79e770 100644 --- a/crates-tauri/yaak-app-client/src/ws_ext.rs +++ b/crates-tauri/yaak-app-client/src/ws_ext.rs @@ -29,17 +29,6 @@ use yaak_templates::{RenderErrorBehavior, RenderOptions}; use yaak_tls::find_client_certificate; use yaak_ws::{WebsocketManager, render_websocket_request}; -pub async fn cmd_ws_delete_connections( - request_id: &str, - app_handle: AppHandle, - window: WebviewWindow, -) -> Result<()> { - Ok(app_handle.db().delete_all_websocket_connections_for_request( - request_id, - &UpdateSource::from_window_label(window.label()), - )?) -} - pub async fn cmd_ws_send( connection_id: &str, environment_id: Option<&str>, diff --git a/crates-tauri/yaak-tauri-utils/Cargo.toml b/crates-tauri/yaak-tauri-utils/Cargo.toml index 74272621..668945b5 100644 --- a/crates-tauri/yaak-tauri-utils/Cargo.toml +++ b/crates-tauri/yaak-tauri-utils/Cargo.toml @@ -7,3 +7,4 @@ publish = false [dependencies] tauri = { workspace = true } regex = "1.11.0" +yaak-core = { workspace = true } diff --git a/crates-tauri/yaak-tauri-utils/src/window.rs b/crates-tauri/yaak-tauri-utils/src/window.rs index 004ef18f..4237b952 100644 --- a/crates-tauri/yaak-tauri-utils/src/window.rs +++ b/crates-tauri/yaak-tauri-utils/src/window.rs @@ -1,38 +1,53 @@ use regex::Regex; -use tauri::{Runtime, WebviewWindow}; +use tauri::{Runtime, Url, WebviewWindow}; +use yaak_core::WorkspaceContext; pub trait WorkspaceWindowTrait { fn workspace_id(&self) -> Option; fn cookie_jar_id(&self) -> Option; fn environment_id(&self) -> Option; fn request_id(&self) -> Option; + /// All four at once, from a single read of the window URL. + fn workspace_context(&self) -> WorkspaceContext; } impl WorkspaceWindowTrait for WebviewWindow { fn workspace_id(&self) -> Option { - let url = self.url().unwrap(); - let re = Regex::new(r"/workspaces/(?\w+)").unwrap(); - match re.captures(url.as_str()) { - None => None, - Some(captures) => captures.name("id").map(|c| c.as_str().to_string()), - } + workspace_id_from_url(&self.url().unwrap()) } fn cookie_jar_id(&self) -> Option { - let url = self.url().unwrap(); - let mut query_pairs = url.query_pairs(); - query_pairs.find(|(k, _v)| k == "cookie_jar_id").map(|(_k, v)| v.to_string()) + query_param(&self.url().unwrap(), "cookie_jar_id") } fn environment_id(&self) -> Option { - let url = self.url().unwrap(); - let mut query_pairs = url.query_pairs(); - query_pairs.find(|(k, _v)| k == "environment_id").map(|(_k, v)| v.to_string()) + query_param(&self.url().unwrap(), "environment_id") } fn request_id(&self) -> Option { + query_param(&self.url().unwrap(), "request_id") + } + + fn workspace_context(&self) -> WorkspaceContext { let url = self.url().unwrap(); - let mut query_pairs = url.query_pairs(); - query_pairs.find(|(k, _v)| k == "request_id").map(|(_k, v)| v.to_string()) + WorkspaceContext { + workspace_id: workspace_id_from_url(&url), + environment_id: query_param(&url, "environment_id"), + cookie_jar_id: query_param(&url, "cookie_jar_id"), + request_id: query_param(&url, "request_id"), + } } } + +fn workspace_id_from_url(url: &Url) -> Option { + let re = Regex::new(r"/workspaces/(?\w+)").unwrap(); + match re.captures(url.as_str()) { + None => None, + Some(captures) => captures.name("id").map(|c| c.as_str().to_string()), + } +} + +fn query_param(url: &Url, key: &str) -> Option { + let mut query_pairs = url.query_pairs(); + query_pairs.find(|(k, _v)| k == key).map(|(_k, v)| v.to_string()) +} diff --git a/crates/yaak-commands/Cargo.toml b/crates/yaak-commands/Cargo.toml new file mode 100644 index 00000000..ce120d9c --- /dev/null +++ b/crates/yaak-commands/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "yaak-commands" +version = "0.0.0" +edition = "2024" +authors = ["Gregory Schier"] +publish = false + +[dependencies] +log = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["rt"] } +yaak = { workspace = true } +yaak-core = { workspace = true } +yaak-crypto = { workspace = true } +yaak-models = { workspace = true } +yaak-plugins = { workspace = true } +yaak-rpc-schema = { workspace = true } +yaak-templates = { workspace = true } + +[dev-dependencies] +tempfile = "3" +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/crates/yaak-commands/src/data.rs b/crates/yaak-commands/src/data.rs new file mode 100644 index 00000000..0069506f --- /dev/null +++ b/crates/yaak-commands/src/data.rs @@ -0,0 +1,23 @@ +//! Export and formatting. + +use crate::error::Result; +use crate::host::Host; +use std::path::Path; +use yaak::export::{self, ExportDataParams}; +use yaak_rpc_schema::*; +use yaak_templates::format_json::format_json; + +pub async fn cmd_export_data(host: H, req: CmdExportDataReq) -> Result<()> { + let version = host.app_version(); + Ok(export::export_data(ExportDataParams { + query_manager: host.query_manager(), + yaak_version: &version, + export_path: Path::new(&req.export_path), + workspace_ids: req.workspace_ids.iter().map(|s| s.as_str()).collect(), + include_private_environments: req.include_private_environments, + })?) +} + +pub async fn cmd_format_json(_host: H, req: CmdFormatJsonReq) -> Result { + Ok(format_json(&req.text, " ")) +} diff --git a/crates/yaak-commands/src/encryption.rs b/crates/yaak-commands/src/encryption.rs new file mode 100644 index 00000000..2734ecfe --- /dev/null +++ b/crates/yaak-commands/src/encryption.rs @@ -0,0 +1,52 @@ +//! Workspace encryption keys and the `secure()` template function. + +use crate::error::Result; +use crate::host::{Host, PluginHost}; +use std::sync::Arc; +use yaak_plugins::native_template_functions::{ + decrypt_secure_template_function, encrypt_secure_template_function, +}; +use yaak_rpc_schema::*; + +pub async fn cmd_enable_encryption(host: H, req: CmdEnableEncryptionReq) -> Result<()> { + host.encryption_manager().ensure_workspace_key(&req.workspace_id)?; + host.encryption_manager().reveal_workspace_key(&req.workspace_id)?; + Ok(()) +} + +pub async fn cmd_reveal_workspace_key( + host: H, + req: CmdRevealWorkspaceKeyReq, +) -> Result { + Ok(host.encryption_manager().reveal_workspace_key(&req.workspace_id)?) +} + +pub async fn cmd_set_workspace_key(host: H, req: CmdSetWorkspaceKeyReq) -> Result<()> { + host.encryption_manager().set_human_key(&req.workspace_id, &req.key)?; + Ok(()) +} + +pub async fn cmd_disable_encryption(host: H, req: CmdDisableEncryptionReq) -> Result<()> { + host.encryption_manager().disable_encryption(&req.workspace_id)?; + Ok(()) +} + +pub async fn cmd_decrypt_template(host: H, req: CmdDecryptTemplateReq) -> Result { + let plugin_context = host.plugin_context(); + Ok(decrypt_secure_template_function(host.encryption_manager(), &plugin_context, &req.template)?) +} + +pub async fn cmd_secure_template( + host: H, + req: CmdSecureTemplateReq, +) -> Result { + let plugin_manager = Arc::new(host.plugin_manager().clone()); + let encryption_manager = Arc::new(host.encryption_manager().clone()); + let plugin_context = host.plugin_context(); + Ok(encrypt_secure_template_function( + plugin_manager, + encryption_manager, + &plugin_context, + &req.template, + )?) +} diff --git a/crates/yaak-commands/src/error.rs b/crates/yaak-commands/src/error.rs new file mode 100644 index 00000000..dd033d78 --- /dev/null +++ b/crates/yaak-commands/src/error.rs @@ -0,0 +1,30 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Error { + #[error(transparent)] + Yaak(#[from] yaak::Error), + + #[error(transparent)] + Model(#[from] yaak_models::error::Error), + + #[error(transparent)] + Plugin(#[from] yaak_plugins::error::Error), + + #[error(transparent)] + Crypto(#[from] yaak_crypto::error::Error), + + #[error(transparent)] + Template(#[from] yaak_templates::error::Error), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + #[error("{0}")] + Generic(String), +} + +pub type Result = std::result::Result; diff --git a/crates/yaak-commands/src/host.rs b/crates/yaak-commands/src/host.rs new file mode 100644 index 00000000..a1e4c87d --- /dev/null +++ b/crates/yaak-commands/src/host.rs @@ -0,0 +1,75 @@ +//! What a command needs from whatever is running it. +//! +//! A command handler is invoked on behalf of one client (a desktop window today) +//! and needs a handful of things from its surroundings: the shared engine +//! managers, who the client is, what the client is looking at, and a little +//! about the app. `Host` is that handful and nothing more. The desktop +//! implements it over a `WebviewWindow`; a server would implement it over a +//! connection. Handlers are generic over it, so the same handler body runs +//! under either without knowing which. +//! +//! The surface grows only when a handler being moved here needs something new, +//! and stays as narrow as those handlers allow. What is deliberately *not* here +//! is anything only a desktop can do — open a native window, run the updater, +//! show a native dialog — those handlers stay with the desktop. + +use yaak_core::WorkspaceContext; +use yaak_crypto::manager::EncryptionManager; +use yaak_models::blob_manager::{BlobContext, BlobManager}; +use yaak_models::client_db::ClientDb; +use yaak_models::query_manager::QueryManager; +use yaak_models::util::UpdateSource; +use yaak_plugins::events::PluginContext; +use yaak_plugins::manager::PluginManager; + +pub trait Host: Clone + Send + Sync + 'static { + /// Stable identity of the client this call is for. On the desktop this is + /// the window label. It rides on every model write so the client that made + /// a change can tell its own echo from everyone else's. + fn client_id(&self) -> &str; + + /// What the client is currently looking at: workspace, environment, cookie + /// jar, request. Read at call time, since the client can navigate between + /// calls (and during one). + fn session(&self) -> WorkspaceContext; + + /// The app version, as reported to the Yaak API and stamped on exports. + fn app_version(&self) -> String; + + fn query_manager(&self) -> &QueryManager; + fn blob_manager(&self) -> &BlobManager; + fn encryption_manager(&self) -> &EncryptionManager; + + // -- Conveniences derived from the above; hosts do not override these -- + + fn update_source(&self) -> UpdateSource { + UpdateSource::from_window_label(self.client_id()) + } + + fn plugin_context(&self) -> PluginContext { + PluginContext::new(Some(self.client_id().to_string()), self.session().workspace_id) + } + + fn db(&self) -> ClientDb<'_> { + self.query_manager().connect() + } + + fn blobs(&self) -> BlobContext { + self.blob_manager().connect() + } +} + +/// A host that can also reach plugins. +/// +/// Separate from [`Host`] because the plugin runtime is the one piece that is +/// not the same shape everywhere. `PluginManager` *is* "spawn a Node sidecar +/// and talk to it"; a browser host runs plugins in a Worker it reaches by +/// message instead, and cannot hand back a `&PluginManager` at all. +/// +/// Keeping it out of `Host` also stops a command that only touches the +/// database from demanding a plugin runtime it never calls: a host with no +/// plugins can still serve those, and only handlers bounded on `PluginHost` +/// are closed to it. +pub trait PluginHost: Host { + fn plugin_manager(&self) -> &PluginManager; +} diff --git a/crates/yaak-commands/src/lib.rs b/crates/yaak-commands/src/lib.rs new file mode 100644 index 00000000..d9a99583 --- /dev/null +++ b/crates/yaak-commands/src/lib.rs @@ -0,0 +1,23 @@ +//! Command handlers for the RPC surface, written against [`Host`] instead of +//! any particular host. +//! +//! `yaak_rpc_schema` declares what each command is called and what it takes +//! and returns; this crate is where the bodies live. Every handler has the +//! shape the router wants — `async fn(host, Req) -> Result` — so a host +//! registers one with a one-line adapter (or none at all), and never +//! redeclares a command. +//! +//! Not every command is here yet. Handlers move in as they are freed of +//! host-specific types; the ones that stay behind are the ones only a desktop +//! can serve (native windows, the updater, dialogs) or that still lean on it. + +pub mod data; +pub mod encryption; +pub mod error; +pub mod host; +pub mod models; +pub mod plugins; +pub mod responses; + +pub use error::{Error, Result}; +pub use host::{Host, PluginHost}; diff --git a/crates/yaak-commands/src/models.rs b/crates/yaak-commands/src/models.rs new file mode 100644 index 00000000..5f53b212 --- /dev/null +++ b/crates/yaak-commands/src/models.rs @@ -0,0 +1,174 @@ +//! Reads and writes of models, keyed by the client's identity so the frontend +//! can suppress its own echoes. + +use crate::error::{Error, Result}; +use crate::host::{Host, PluginHost}; +use yaak_models::models::{ + AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequestHeader, Settings, WebsocketEvent, + WorkspaceMeta, +}; +use yaak_models::queries::workspaces::default_headers; +use yaak_rpc_schema::*; + +pub async fn models_upsert(host: H, req: ModelsUpsertReq) -> Result { + let db = host.db(); + let blobs = host.blob_manager(); + let source = host.update_source(); + Ok(yaak::models_ops::upsert_model(&db, blobs, req.model, &source)?) +} + +/// Deletes run on a blocking thread: they cascade (a workspace can hold +/// thousands of requests), and a transaction holds a raw connection that +/// would otherwise stall the runtime and every other call with it. +pub async fn models_delete(host: H, req: ModelsDeleteReq) -> Result { + let result = tokio::task::spawn_blocking(move || { + let source = host.update_source(); + host.query_manager().with_tx(|tx| { + yaak::models_ops::delete_model(tx, host.blob_manager(), req.model, &source) + }) + }) + .await + .map_err(|e| Error::Generic(format!("Delete task failed: {e}")))?; + Ok(result?) +} + +/// Duplicates recurse, so this runs in a transaction too. +pub async fn models_duplicate(host: H, req: ModelsDuplicateReq) -> Result { + let source = host.update_source(); + Ok(host.query_manager().with_tx(|tx| { + yaak::models_ops::duplicate_model(tx, &req.model_type, &req.model_id, &source) + })?) +} + +pub async fn models_websocket_events( + host: H, + req: ModelsWebsocketEventsReq, +) -> Result> { + Ok(host.db().list_websocket_events(&req.connection_id)?) +} + +pub async fn models_grpc_events( + host: H, + req: ModelsGrpcEventsReq, +) -> Result> { + Ok(host.db().list_grpc_events(&req.connection_id)?) +} + +pub async fn models_get_settings(host: H, _req: ModelsGetSettingsReq) -> Result { + Ok(host.db().get_settings()) +} + +pub async fn models_get_graphql_introspection( + host: H, + req: ModelsGetGraphqlIntrospectionReq, +) -> Result> { + Ok(host.db().get_graphql_introspection(&req.request_id)) +} + +pub async fn models_upsert_graphql_introspection( + host: H, + req: ModelsUpsertGraphqlIntrospectionReq, +) -> Result { + let source = host.update_source(); + Ok(host.db().upsert_graphql_introspection( + &req.workspace_id, + &req.request_id, + req.content, + &source, + )?) +} + +/// Everything the frontend's model store needs to boot, as one JSON string. +/// +/// A string rather than a `Vec` because the desktop has to escape +/// this payload before it crosses into the webview (see its adapter), and the +/// frontend `JSON.parse`s either form the same way. +pub async fn models_workspace_models( + host: H, + req: ModelsWorkspaceModelsReq, +) -> Result { + let mut l: Vec = Vec::new(); + + // Add the global models + { + let db = host.db(); + l.push(db.get_settings().into()); + l.append(&mut db.list_workspaces()?.into_iter().map(Into::into).collect()); + l.append(&mut db.list_key_values()?.into_iter().map(Into::into).collect()); + } + + let plugins = { + let db = host.db(); + db.list_plugins()? + }; + + let plugins = host.plugin_manager().resolve_plugins_for_runtime_from_db(plugins).await; + l.append(&mut plugins.into_iter().map(Into::into).collect()); + + // Add the workspace children + if let Some(wid) = req.workspace_id.as_deref() { + let db = host.db(); + l.append(&mut db.list_cookie_jars(wid)?.into_iter().map(Into::into).collect()); + l.append(&mut db.list_environments_ensure_base(wid)?.into_iter().map(Into::into).collect()); + l.append(&mut db.list_folders(wid)?.into_iter().map(Into::into).collect()); + l.append(&mut db.list_grpc_connections(wid)?.into_iter().map(Into::into).collect()); + l.append(&mut db.list_grpc_requests(wid)?.into_iter().map(Into::into).collect()); + l.append(&mut db.list_http_requests(wid)?.into_iter().map(Into::into).collect()); + l.append(&mut db.list_http_responses(wid, None)?.into_iter().map(Into::into).collect()); + l.append(&mut db.list_websocket_connections(wid)?.into_iter().map(Into::into).collect()); + l.append(&mut db.list_websocket_requests(wid)?.into_iter().map(Into::into).collect()); + l.append(&mut db.list_workspace_metas(wid)?.into_iter().map(Into::into).collect()); + } + + Ok(serde_json::to_string(&l)?) +} + +pub async fn cmd_get_workspace_meta( + host: H, + req: CmdGetWorkspaceMetaReq, +) -> Result { + let db = host.db(); + let workspace = db.get_workspace(&req.workspace_id)?; + Ok(db.get_or_create_workspace_meta(&workspace.id)?) +} + +pub async fn cmd_delete_all_grpc_connections( + host: H, + req: CmdDeleteAllGrpcConnectionsReq, +) -> Result<()> { + Ok(host.db().delete_all_grpc_connections_for_request(&req.request_id, &host.update_source())?) +} + +pub async fn cmd_delete_all_http_responses( + host: H, + req: CmdDeleteAllHttpResponsesReq, +) -> Result<()> { + host.db().delete_all_http_responses_for_request(&req.request_id, &host.update_source())?; + Ok(()) +} + +pub async fn cmd_ws_delete_connections( + host: H, + req: CmdWsDeleteConnectionsReq, +) -> Result<()> { + Ok(host + .db() + .delete_all_websocket_connections_for_request(&req.request_id, &host.update_source())?) +} + +pub async fn cmd_delete_send_history(host: H, req: CmdDeleteSendHistoryReq) -> Result<()> { + Ok(host.query_manager().with_tx(|tx| { + let source = &host.update_source(); + tx.delete_all_http_responses_for_workspace(&req.workspace_id, source)?; + tx.delete_all_grpc_connections_for_workspace(&req.workspace_id, source)?; + tx.delete_all_websocket_connections_for_workspace(&req.workspace_id, source)?; + Ok::<(), yaak_models::error::Error>(()) + })?) +} + +pub async fn cmd_default_headers( + _host: H, + _req: CmdDefaultHeadersReq, +) -> Result> { + Ok(default_headers()) +} diff --git a/crates/yaak-commands/src/plugins.rs b/crates/yaak-commands/src/plugins.rs new file mode 100644 index 00000000..75116761 --- /dev/null +++ b/crates/yaak-commands/src/plugins.rs @@ -0,0 +1,50 @@ +//! Plugin queries that only need the plugin manager and the database. + +use crate::error::Result; +use crate::host::PluginHost; +use std::path::PathBuf; +use yaak_plugins::plugin_meta::{PluginMetadata, get_plugin_meta}; +use yaak_rpc_schema::*; + +pub async fn cmd_plugin_info( + host: H, + req: CmdPluginInfoReq, +) -> Result { + let plugin = host.db().get_plugin(&req.id)?; + if let Some(plugin_handle) = + host.plugin_manager().get_plugin_by_dir(plugin.directory.as_str()).await + { + return Ok(plugin_handle.info()); + } + + if let Ok(metadata) = get_plugin_meta(&PathBuf::from(&plugin.directory)) { + return Ok(metadata); + } + + Ok(fallback_plugin_metadata(&plugin.directory)) +} + +fn fallback_plugin_metadata(directory: &str) -> PluginMetadata { + let display_name = PathBuf::from(directory) + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or(directory) + .to_string(); + + PluginMetadata { + version: "Unavailable".to_string(), + name: directory.to_string(), + display_name, + description: Some(format!("Plugin metadata could not be loaded from {directory}")), + homepage_url: None, + repository_url: None, + } +} + +pub async fn cmd_plugin_init_errors( + host: H, + _req: CmdPluginInitErrorsReq, +) -> Result> { + Ok(host.plugin_manager().take_init_errors().await) +} diff --git a/crates/yaak-commands/src/responses.rs b/crates/yaak-commands/src/responses.rs new file mode 100644 index 00000000..6602b615 --- /dev/null +++ b/crates/yaak-commands/src/responses.rs @@ -0,0 +1,86 @@ +//! Reading back what a send left behind: response events, request bodies, and +//! where a response body lives. + +use crate::error::{Error, Result}; +use crate::host::Host; +use std::fs; +use std::path::PathBuf; +use yaak_models::client_db::ClientDb; +use yaak_models::models::HttpResponseEvent; +use yaak_rpc_schema::*; + +/// Where a response's body is, and what it is meant to be read as. +pub struct ResponseBodyLocation { + /// None when the response has no stored body. + pub path: Option, + /// The response's declared `Content-Type`, empty when it has none. + pub content_type: String, +} + +/// Find a response's body from its id alone. +/// +/// The frontend hands back an id and never a path, so the only bodies reachable +/// here are ones the engine wrote and the database still knows about. A +/// response that was never saved has no entry, and its body came back from the +/// send that made it. +pub fn locate_response_body(db: &ClientDb, response_id: &str) -> Result { + let response = db.get_http_response(response_id)?; + + Ok(ResponseBodyLocation { + path: response.body_path.map(PathBuf::from), + content_type: response + .headers + .iter() + .find(|h| h.name.eq_ignore_ascii_case("content-type")) + .map(|h| h.value.clone()) + .unwrap_or_default(), + }) +} + +pub async fn cmd_get_http_response_events( + host: H, + req: CmdGetHttpResponseEventsReq, +) -> Result> { + let events: Vec = host.db().list_http_response_events(&req.response_id)?; + Ok(events) +} + +/// The body's path on this machine, for the desktop host to read or hand to the +/// webview's asset protocol. +/// +/// The frontend holds response ids; only `packages/platform`'s Tauri host sees +/// the path, and only because it is about to open the file itself. Hosts +/// without a filesystem serve the same bytes over HTTP instead. +pub async fn cmd_http_response_body_path( + host: H, + req: CmdHttpResponseBodyPathReq, +) -> Result> { + let location = locate_response_body(&host.db(), &req.response_id)?; + Ok(location.path.map(|p| p.to_string_lossy().to_string())) +} + +pub async fn cmd_http_request_body( + host: H, + req: CmdHttpRequestBodyReq, +) -> Result>> { + let body_id = format!("{}.request", req.response_id); + let chunks = host.blobs().get_chunks(&body_id)?; + + if chunks.is_empty() { + return Ok(None); + } + + // Concatenate all chunks + let body: Vec = chunks.into_iter().flat_map(|c| c.data).collect(); + Ok(Some(body)) +} + +pub async fn cmd_save_response(host: H, req: CmdSaveResponseReq) -> Result<()> { + let response = host.db().get_http_response(&req.response_id)?; + + let body_path = + response.body_path.ok_or(Error::Generic("Response does not have a body".to_string()))?; + fs::copy(body_path, &req.filepath).map_err(|e| Error::Generic(e.to_string()))?; + + Ok(()) +} diff --git a/crates/yaak-commands/tests/test_host.rs b/crates/yaak-commands/tests/test_host.rs new file mode 100644 index 00000000..9f018081 --- /dev/null +++ b/crates/yaak-commands/tests/test_host.rs @@ -0,0 +1,138 @@ +//! A host that is nothing but the trait: a temp database, a fixed client id, +//! a fixed session. It exists to prove that the handlers really do run without +//! a desktop around them, and that the client's identity reaches the writes. +//! +//! It implements `Host` and not `PluginHost`, which is the point: there is no +//! Node runtime here, and the commands exercised below never needed one. A +//! handler that reaches for plugins would not compile against this host. + +use std::sync::{Arc, Mutex}; +use tempfile::TempDir; +use yaak_commands::Host; +use yaak_commands::models::{ + cmd_default_headers, cmd_get_workspace_meta, models_delete, models_upsert, +}; +use yaak_core::WorkspaceContext; +use yaak_crypto::manager::EncryptionManager; +use yaak_models::blob_manager::BlobManager; +use yaak_models::models::{AnyModel, Workspace}; +use yaak_models::query_manager::QueryManager; +use yaak_models::util::{ModelPayload, UpdateSource}; +use yaak_rpc_schema::{ + CmdDefaultHeadersReq, CmdGetWorkspaceMetaReq, ModelsDeleteReq, ModelsUpsertReq, +}; + +#[derive(Clone)] +struct TestHost { + inner: Arc, +} + +struct Inner { + _dir: TempDir, + query_manager: QueryManager, + blob_manager: BlobManager, + encryption_manager: EncryptionManager, + /// Every model write the database reported, so a test can check who it + /// says made them. + writes: Mutex>, + rx: Mutex>, +} + +impl TestHost { + fn new() -> Self { + let dir = TempDir::new().expect("temp dir"); + let (query_manager, blob_manager, rx) = yaak_models::init_standalone( + dir.path().join("db.sqlite"), + dir.path().join("blobs.sqlite"), + ) + .expect("init db"); + let encryption_manager = EncryptionManager::new(query_manager.clone(), "app.yaak.test"); + Self { + inner: Arc::new(Inner { + _dir: dir, + query_manager, + blob_manager, + encryption_manager, + writes: Mutex::new(Vec::new()), + rx: Mutex::new(rx), + }), + } + } + + fn drain_writes(&self) -> Vec { + let rx = self.inner.rx.lock().unwrap(); + let mut writes = self.inner.writes.lock().unwrap(); + while let Ok(payload) = rx.try_recv() { + writes.push(payload); + } + writes.drain(..).collect() + } +} + +impl Host for TestHost { + fn client_id(&self) -> &str { + "test-client" + } + + fn session(&self) -> WorkspaceContext { + WorkspaceContext::new().with_workspace("wk_test") + } + + fn app_version(&self) -> String { + "0.0.0-test".to_string() + } + + fn query_manager(&self) -> &QueryManager { + &self.inner.query_manager + } + + fn blob_manager(&self) -> &BlobManager { + &self.inner.blob_manager + } + + fn encryption_manager(&self) -> &EncryptionManager { + &self.inner.encryption_manager + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn writes_carry_the_client_id() { + let host = TestHost::new(); + + let workspace = Workspace { name: "From a test".to_string(), ..Default::default() }; + let id = models_upsert(host.clone(), ModelsUpsertReq { model: AnyModel::Workspace(workspace) }) + .await + .expect("upsert"); + assert!(id.starts_with("wk_"), "unexpected id {id}"); + + let writes = host.drain_writes(); + assert_eq!(writes.len(), 1); + assert!( + matches!(&writes[0].update_source, UpdateSource::Window { label } if label == "test-client"), + "the write should be attributed to the calling client, got {:?}", + writes[0].update_source, + ); + + let meta = + cmd_get_workspace_meta(host.clone(), CmdGetWorkspaceMetaReq { workspace_id: id.clone() }) + .await + .expect("workspace meta"); + assert_eq!(meta.workspace_id, id); + + // Deletes run through spawn_blocking and a transaction; make sure that + // path also works off a plain tokio runtime. + let workspace = host.db().get_workspace(&id).expect("get workspace"); + let deleted = + models_delete(host.clone(), ModelsDeleteReq { model: AnyModel::Workspace(workspace) }) + .await + .expect("delete"); + assert_eq!(deleted, id); + assert!(host.db().get_workspace(&id).is_err(), "workspace should be gone"); +} + +#[tokio::test] +async fn host_free_handlers_need_no_state() { + let host = TestHost::new(); + let headers = cmd_default_headers(host, CmdDefaultHeadersReq {}).await.expect("headers"); + assert!(!headers.is_empty()); +} diff --git a/crates/yaak-core/src/context.rs b/crates/yaak-core/src/context.rs index e792fd28..ccadf15a 100644 --- a/crates/yaak-core/src/context.rs +++ b/crates/yaak-core/src/context.rs @@ -1,5 +1,3 @@ -use std::path::PathBuf; - /// Context for a workspace operation. /// /// In Tauri, this is extracted from the WebviewWindow URL. @@ -37,20 +35,3 @@ impl WorkspaceContext { self } } - -/// Application context trait for accessing app-level resources. -/// -/// This abstracts over Tauri's `AppHandle` for path resolution and app identity. -/// Implemented by Tauri's AppHandle and by CLI's own context struct. -pub trait AppContext: Send + Sync + Clone { - /// Returns the path to the application data directory. - /// This is where the database and other persistent data are stored. - fn app_data_dir(&self) -> PathBuf; - - /// Returns the application identifier (e.g., "app.yaak.desktop"). - /// Used for keyring access and other platform-specific features. - fn app_identifier(&self) -> &str; - - /// Returns true if running in development mode. - fn is_dev(&self) -> bool; -} diff --git a/crates/yaak-core/src/lib.rs b/crates/yaak-core/src/lib.rs index 101b53e3..728bc28c 100644 --- a/crates/yaak-core/src/lib.rs +++ b/crates/yaak-core/src/lib.rs @@ -6,5 +6,5 @@ mod context; mod error; -pub use context::{AppContext, WorkspaceContext}; +pub use context::WorkspaceContext; pub use error::{Error, Result};