From 6a02cbe5254f402ab1461111fb546ec84bdf37b3 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sun, 16 Aug 2026 08:41:35 -0700 Subject: [PATCH] Add a Host trait and move DB/model commands off Tauri (#558) --- Cargo.lock | 20 ++ Cargo.toml | 2 + crates-tauri/yaak-app-client/Cargo.toml | 1 + crates-tauri/yaak-app-client/src/commands.rs | 82 +----- crates-tauri/yaak-app-client/src/error.rs | 3 + crates-tauri/yaak-app-client/src/lib.rs | 200 +-------------- .../yaak-app-client/src/models_ext.rs | 153 ----------- .../yaak-app-client/src/plugins_ext.rs | 6 - crates-tauri/yaak-app-client/src/rpc_ext.rs | 182 +++++++++++--- crates-tauri/yaak-app-client/src/ws_ext.rs | 11 - crates-tauri/yaak-tauri-utils/Cargo.toml | 1 + crates-tauri/yaak-tauri-utils/src/window.rs | 45 ++-- crates/yaak-commands/Cargo.toml | 23 ++ crates/yaak-commands/src/data.rs | 23 ++ crates/yaak-commands/src/encryption.rs | 41 +++ crates/yaak-commands/src/error.rs | 30 +++ crates/yaak-commands/src/host.rs | 118 +++++++++ crates/yaak-commands/src/lib.rs | 23 ++ crates/yaak-commands/src/models.rs | 179 +++++++++++++ crates/yaak-commands/src/plugins.rs | 48 ++++ crates/yaak-commands/src/responses.rs | 86 +++++++ crates/yaak-commands/tests/test_host.rs | 237 ++++++++++++++++++ crates/yaak-core/src/context.rs | 19 -- crates/yaak-core/src/lib.rs | 2 +- 24 files changed, 1017 insertions(+), 518 deletions(-) create mode 100644 crates/yaak-commands/Cargo.toml create mode 100644 crates/yaak-commands/src/data.rs create mode 100644 crates/yaak-commands/src/encryption.rs create mode 100644 crates/yaak-commands/src/error.rs create mode 100644 crates/yaak-commands/src/host.rs create mode 100644 crates/yaak-commands/src/lib.rs create mode 100644 crates/yaak-commands/src/models.rs create mode 100644 crates/yaak-commands/src/plugins.rs create mode 100644 crates/yaak-commands/src/responses.rs create mode 100644 crates/yaak-commands/tests/test_host.rs diff --git a/Cargo.lock b/Cargo.lock index e6288f86..61734d63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11267,6 +11267,7 @@ dependencies = [ "uuid", "yaak", "yaak-api", + "yaak-commands", "yaak-common", "yaak-core", "yaak-crypto", @@ -11346,6 +11347,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" @@ -11677,6 +11696,7 @@ version = "0.1.0" dependencies = [ "regex 1.11.1", "tauri", + "yaak-core", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d8eeaa4d..d1542d74 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", @@ -70,6 +71,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 7803ad89..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::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::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::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..7913c2c6 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; @@ -19,7 +22,10 @@ use crate::updates::YaakUpdater; use log::warn; use serde::Serialize; use tauri::{Manager, Runtime, State, WebviewWindow}; +use std::sync::Arc; 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 +33,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, @@ -41,11 +49,13 @@ use yaak_plugins::events::{ }; use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse}; use yaak_plugins::manager::PluginManager; +use yaak_plugins::native_template_functions::encrypt_secure_template_function; use yaak_plugins::plugin_meta::PluginMetadata; 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 +75,65 @@ 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 answers all of these out of the `PluginManager` it already +/// runs — the Node sidecar. Each is a delegation, which is the point: the +/// operations are what the handlers need, and this is one host's way of +/// providing them. +impl PluginHost for ClientCtx { + async fn loaded_plugin_metadata(&self, directory: &str) -> Option { + let manager = self.window.state::(); + let handle = manager.get_plugin_by_dir(directory).await?; + Some(handle.info()) + } + + async fn take_plugin_init_errors(&self) -> Vec<(String, String)> { + self.window.state::().take_init_errors().await + } + + async fn resolve_plugins(&self, plugins: Vec) -> Vec { + self.window.state::().resolve_plugins_for_runtime_from_db(plugins).await + } + + async fn encrypt_secure_template(&self, template: &str) -> yaak_commands::Result { + let plugin_manager = Arc::new((*self.window.state::()).clone()); + let encryption_manager = Arc::new(self.encryption_manager().clone()); + Ok(encrypt_secure_template_function( + plugin_manager, + encryption_manager, + &self.plugin_context(), + template, + )?) + } +} + /// The one Tauri command. The payload is the yaak-rpc envelope's payload; /// a missing payload means an empty one. #[tauri::command] @@ -189,8 +258,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 +271,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 +283,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 +359,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 +367,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 +379,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 +411,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 +423,98 @@ 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?) } +/// Runs on a blocking thread rather than the async runtime: a cascading delete +/// (a workspace with thousands of requests) holds a transaction for its whole +/// duration, and stalling the runtime stalls every other IPC call behind it. +/// That is this host's concern, so the shared handler stays plain and the +/// relocation happens here. async fn models_delete(ctx: ClientCtx, req: ModelsDeleteReq) -> Result { - Ok(crate::models_ext::models_delete(ctx.window.clone(), req.model).await?) + let deleted = tauri::async_runtime::spawn_blocking(move || { + yaak_commands::models::models_delete_blocking(&ctx, req) + }) + .await + .map_err(|e| crate::error::Error::GenericError(format!("Delete task failed: {e}")))?; + Ok(deleted?) } 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 +646,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 +677,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..5aab9cae --- /dev/null +++ b/crates/yaak-commands/src/encryption.rs @@ -0,0 +1,41 @@ +//! Workspace encryption keys and the `secure()` template function. + +use crate::error::Result; +use crate::host::{Host, PluginHost}; +use yaak_plugins::native_template_functions::decrypt_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 { + host.encrypt_secure_template(&req.template).await +} 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..ef525542 --- /dev/null +++ b/crates/yaak-commands/src/host.rs @@ -0,0 +1,118 @@ +//! 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 std::future::Future; +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::models::Plugin; +use yaak_models::query_manager::QueryManager; +use yaak_models::util::UpdateSource; +use yaak_plugins::events::PluginContext; +use yaak_plugins::plugin_meta::PluginMetadata; + +/// Only `Clone` is required here. `Send`/`Sync`/`'static` are deliberately +/// *not*: a browser host is single-threaded and its connection pool is an +/// `Rc` — `rusqlite::Connection` is not `Sync` to begin with — so a +/// thread-safety bound on the trait would lock that host out of implementing it +/// at all. The router needs those bounds and states them itself, which is where +/// they belong: they are a property of a particular transport, not of a command. +pub trait Host: Clone { + /// 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`] so that a command which only touches the database +/// never demands a plugin runtime it does not call: a host with no plugins +/// still serves those, and only handlers bounded on `PluginHost` are closed to +/// it. +/// +/// These are *operations*, not a handle. Handing back a `&PluginManager` would +/// have been shorter, but that type is specifically "spawn a Node sidecar and +/// talk to it over a socket", and a browser host runs plugins in a Worker it +/// reaches by message — it can answer any of the questions below and can never +/// produce that type. Naming the questions instead of the answerer is what lets +/// both hosts exist. +/// +/// Same rule as [`Host`]: this grows only when a migrated handler needs +/// something new, and stays as narrow as those handlers allow. Today it is the +/// four things batch 1 asks for. +/// +/// The types crossing this boundary still come from `yaak-plugins` — fine on +/// the desktop, and once its plain data types are split out from its runtime +/// that becomes an import-path change here rather than an interface one. +pub trait PluginHost: Host { + /// What the running plugin runtime knows about the plugin installed in + /// `directory`, or `None` if it has not loaded one from there. Callers fall + /// back to reading the plugin's manifest off disk. + fn loaded_plugin_metadata( + &self, + directory: &str, + ) -> impl Future>; + + /// Failures from plugin initialization, drained — reporting them clears + /// them, so a caller that drops these has lost them. + fn take_plugin_init_errors(&self) -> impl Future>; + + /// The plugin rows as the runtime sees them: the database says what is + /// installed, the runtime knows which are bundled and what version actually + /// loaded. A host without a runtime can return them untouched. + fn resolve_plugins(&self, plugins: Vec) -> impl Future>; + + /// Re-encrypt the `secure(...)` values in a template. + /// + /// Whole operation rather than its pieces because the encryption is only + /// half of it: the value is also run through the plugin template functions, + /// so this needs the plugin runtime and not just a key. + fn encrypt_secure_template( + &self, + template: &str, + ) -> impl Future>; +} 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..86ec67d7 --- /dev/null +++ b/crates/yaak-commands/src/models.rs @@ -0,0 +1,179 @@ +//! Reads and writes of models, keyed by the client's identity so the frontend +//! can suppress its own echoes. + +use crate::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::models_ops::upsert_model(&db, blobs, req.model, &source)?) +} + +/// Deletes cascade — a workspace can hold thousands of requests — and run in a +/// transaction, which holds a raw connection for the duration. +/// +/// Whether that wants a blocking thread is the *host's* question, not the +/// delete's: a desktop with a multi-threaded runtime should keep this off the +/// runtime (see its adapter), while a single-threaded host has nothing to move +/// it to and runs it here. So this is the plain version, and a host that wants +/// to relocate it calls [`models_delete_blocking`] itself. +pub async fn models_delete(host: H, req: ModelsDeleteReq) -> Result { + models_delete_blocking(&host, req) +} + +/// The body of [`models_delete`], callable from a blocking context. +pub fn models_delete_blocking(host: &H, req: ModelsDeleteReq) -> Result { + let source = host.update_source(); + Ok(host.query_manager().with_tx(|tx| { + yaak_models::models_ops::delete_model(tx, host.blob_manager(), req.model, &source) + })?) +} + +/// 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::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.resolve_plugins(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..0971f930 --- /dev/null +++ b/crates/yaak-commands/src/plugins.rs @@ -0,0 +1,48 @@ +//! Plugin queries: what the runtime has loaded, and what failed to load. + +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(metadata) = host.loaded_plugin_metadata(&plugin.directory).await { + return Ok(metadata); + } + + 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.take_plugin_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..41bc5618 --- /dev/null +++ b/crates/yaak-commands/tests/test_host.rs @@ -0,0 +1,237 @@ +//! 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. +//! +//! Neither host here has a plugin runtime — no `PluginManager`, no sidecar. +//! `TestHost` implements `Host` alone, so a handler that reaches for plugins +//! would not compile against it. `SingleThreadedHost` goes further and answers +//! `PluginHost` too, without one, which is only possible because that trait +//! names operations rather than handing back a manager. + +use std::rc::Rc; +use std::sync::{Arc, Mutex}; +use tempfile::TempDir; +use yaak_commands::models::{ + cmd_default_headers, cmd_get_workspace_meta, models_delete, models_upsert, + models_workspace_models, +}; +use yaak_commands::{Host, PluginHost}; +use yaak_core::WorkspaceContext; +use yaak_crypto::manager::EncryptionManager; +use yaak_models::blob_manager::BlobManager; +use yaak_models::models::{AnyModel, Plugin, Workspace}; +use yaak_models::query_manager::QueryManager; +use yaak_models::util::{ModelPayload, UpdateSource}; +use yaak_plugins::plugin_meta::PluginMetadata; +use yaak_rpc_schema::{ + CmdDefaultHeadersReq, CmdGetWorkspaceMetaReq, ModelsDeleteReq, ModelsUpsertReq, + ModelsWorkspaceModelsReq, +}; + +#[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 cascade inside a transaction; make sure that path works with no + // host doing anything special around it. + 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()); +} + +/// A host that is deliberately **not** `Send` or `Sync`: it keeps its state in +/// an `Rc`, the way a single-threaded browser host has to, since +/// `rusqlite::Connection` is not `Sync` to begin with. It also has no plugin +/// runtime of any kind — no `PluginManager`, no sidecar, nothing to spawn. +/// +/// Nothing here asserts much at runtime; the test is largely that it compiles. +/// A `Host` demanding thread-safety, or a `PluginHost` handing back a +/// `&PluginManager`, would shut such a host out of the traits entirely and this +/// file would stop building. +#[derive(Clone)] +struct SingleThreadedHost { + inner: Rc, +} + +impl Host for SingleThreadedHost { + fn client_id(&self) -> &str { + "tab-1" + } + + fn session(&self) -> WorkspaceContext { + WorkspaceContext::new() + } + + fn app_version(&self) -> String { + "0.0.0-web".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 + } +} + +/// Answering plugin questions with no plugin runtime behind them. A browser +/// host would put a `postMessage` round-trip to its Worker where these return +/// constants; the shape of the trait is what makes either possible. +impl PluginHost for SingleThreadedHost { + async fn loaded_plugin_metadata(&self, _directory: &str) -> Option { + None + } + + async fn take_plugin_init_errors(&self) -> Vec<(String, String)> { + Vec::new() + } + + async fn resolve_plugins(&self, plugins: Vec) -> Vec { + // No runtime to enrich them with; the database rows are still the truth + // about what is installed. + plugins + } + + async fn encrypt_secure_template(&self, _template: &str) -> yaak_commands::Result { + Err(yaak_commands::Error::Generic("no plugin runtime on this host".into())) + } +} + +#[tokio::test] +async fn a_single_threaded_host_can_implement_the_trait() { + let TestHost { inner } = TestHost::new(); + let host = SingleThreadedHost { inner: Rc::new(Arc::into_inner(inner).expect("sole owner")) }; + + let workspace = Workspace { name: "From one thread".to_string(), ..Default::default() }; + let id = models_upsert(host.clone(), ModelsUpsertReq { model: AnyModel::Workspace(workspace) }) + .await + .expect("upsert"); + + // A `PluginHost` command, on a host with no plugin runtime at all. This is + // the one that could not be written when the trait handed back a + // `&PluginManager`. + let json = models_workspace_models( + host.clone(), + ModelsWorkspaceModelsReq { workspace_id: Some(id.clone()) }, + ) + .await + .expect("workspace models"); + assert!(json.contains(&id), "the workspace should be in its own bootstrap payload"); + + // The delete path too, since it is the one that used to reach for a + // blocking thread this host does not have. + let workspace = host.db().get_workspace(&id).expect("get workspace"); + let deleted = models_delete(host, ModelsDeleteReq { model: AnyModel::Workspace(workspace) }) + .await + .expect("delete"); + assert_eq!(deleted, id); +} 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};