mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-24 20:34:05 +02:00
Add a Host trait and move DB/model commands off Tauri (#558)
This commit is contained in:
@@ -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"] }
|
||||
@@ -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<H: Host>(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<H: Host>(_host: H, req: CmdFormatJsonReq) -> Result<String> {
|
||||
Ok(format_json(&req.text, " "))
|
||||
}
|
||||
@@ -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<H: Host>(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<H: Host>(
|
||||
host: H,
|
||||
req: CmdRevealWorkspaceKeyReq,
|
||||
) -> Result<String> {
|
||||
Ok(host.encryption_manager().reveal_workspace_key(&req.workspace_id)?)
|
||||
}
|
||||
|
||||
pub async fn cmd_set_workspace_key<H: Host>(host: H, req: CmdSetWorkspaceKeyReq) -> Result<()> {
|
||||
host.encryption_manager().set_human_key(&req.workspace_id, &req.key)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cmd_disable_encryption<H: Host>(host: H, req: CmdDisableEncryptionReq) -> Result<()> {
|
||||
host.encryption_manager().disable_encryption(&req.workspace_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cmd_decrypt_template<H: Host>(host: H, req: CmdDecryptTemplateReq) -> Result<String> {
|
||||
let plugin_context = host.plugin_context();
|
||||
Ok(decrypt_secure_template_function(host.encryption_manager(), &plugin_context, &req.template)?)
|
||||
}
|
||||
|
||||
pub async fn cmd_secure_template<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdSecureTemplateReq,
|
||||
) -> Result<String> {
|
||||
host.encrypt_secure_template(&req.template).await
|
||||
}
|
||||
@@ -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<T> = std::result::Result<T, Error>;
|
||||
@@ -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<Connection>` — `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<Output = Option<PluginMetadata>>;
|
||||
|
||||
/// 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<Output = Vec<(String, String)>>;
|
||||
|
||||
/// 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<Plugin>) -> impl Future<Output = Vec<Plugin>>;
|
||||
|
||||
/// 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<Output = crate::Result<String>>;
|
||||
}
|
||||
@@ -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<Res>` — 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};
|
||||
@@ -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<H: Host>(host: H, req: ModelsUpsertReq) -> Result<String> {
|
||||
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<H: Host>(host: H, req: ModelsDeleteReq) -> Result<String> {
|
||||
models_delete_blocking(&host, req)
|
||||
}
|
||||
|
||||
/// The body of [`models_delete`], callable from a blocking context.
|
||||
pub fn models_delete_blocking<H: Host>(host: &H, req: ModelsDeleteReq) -> Result<String> {
|
||||
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<H: Host>(host: H, req: ModelsDuplicateReq) -> Result<String> {
|
||||
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<H: Host>(
|
||||
host: H,
|
||||
req: ModelsWebsocketEventsReq,
|
||||
) -> Result<Vec<WebsocketEvent>> {
|
||||
Ok(host.db().list_websocket_events(&req.connection_id)?)
|
||||
}
|
||||
|
||||
pub async fn models_grpc_events<H: Host>(
|
||||
host: H,
|
||||
req: ModelsGrpcEventsReq,
|
||||
) -> Result<Vec<GrpcEvent>> {
|
||||
Ok(host.db().list_grpc_events(&req.connection_id)?)
|
||||
}
|
||||
|
||||
pub async fn models_get_settings<H: Host>(host: H, _req: ModelsGetSettingsReq) -> Result<Settings> {
|
||||
Ok(host.db().get_settings())
|
||||
}
|
||||
|
||||
pub async fn models_get_graphql_introspection<H: Host>(
|
||||
host: H,
|
||||
req: ModelsGetGraphqlIntrospectionReq,
|
||||
) -> Result<Option<GraphQlIntrospection>> {
|
||||
Ok(host.db().get_graphql_introspection(&req.request_id))
|
||||
}
|
||||
|
||||
pub async fn models_upsert_graphql_introspection<H: Host>(
|
||||
host: H,
|
||||
req: ModelsUpsertGraphqlIntrospectionReq,
|
||||
) -> Result<GraphQlIntrospection> {
|
||||
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<AnyModel>` 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<H: PluginHost>(
|
||||
host: H,
|
||||
req: ModelsWorkspaceModelsReq,
|
||||
) -> Result<String> {
|
||||
let mut l: Vec<AnyModel> = 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<H: Host>(
|
||||
host: H,
|
||||
req: CmdGetWorkspaceMetaReq,
|
||||
) -> Result<WorkspaceMeta> {
|
||||
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<H: Host>(
|
||||
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<H: Host>(
|
||||
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<H: Host>(
|
||||
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<H: Host>(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<H: Host>(
|
||||
_host: H,
|
||||
_req: CmdDefaultHeadersReq,
|
||||
) -> Result<Vec<HttpRequestHeader>> {
|
||||
Ok(default_headers())
|
||||
}
|
||||
@@ -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<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdPluginInfoReq,
|
||||
) -> Result<PluginMetadata> {
|
||||
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<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdPluginInitErrorsReq,
|
||||
) -> Result<Vec<(String, String)>> {
|
||||
Ok(host.take_plugin_init_errors().await)
|
||||
}
|
||||
@@ -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<PathBuf>,
|
||||
/// 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<ResponseBodyLocation> {
|
||||
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<H: Host>(
|
||||
host: H,
|
||||
req: CmdGetHttpResponseEventsReq,
|
||||
) -> Result<Vec<HttpResponseEvent>> {
|
||||
let events: Vec<HttpResponseEvent> = 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<H: Host>(
|
||||
host: H,
|
||||
req: CmdHttpResponseBodyPathReq,
|
||||
) -> Result<Option<String>> {
|
||||
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<H: Host>(
|
||||
host: H,
|
||||
req: CmdHttpRequestBodyReq,
|
||||
) -> Result<Option<Vec<u8>>> {
|
||||
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<u8> = chunks.into_iter().flat_map(|c| c.data).collect();
|
||||
Ok(Some(body))
|
||||
}
|
||||
|
||||
pub async fn cmd_save_response<H: Host>(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(())
|
||||
}
|
||||
@@ -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<Inner>,
|
||||
}
|
||||
|
||||
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<Vec<ModelPayload>>,
|
||||
rx: Mutex<std::sync::mpsc::Receiver<ModelPayload>>,
|
||||
}
|
||||
|
||||
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<ModelPayload> {
|
||||
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<Inner>,
|
||||
}
|
||||
|
||||
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<PluginMetadata> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn take_plugin_init_errors(&self) -> Vec<(String, String)> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
async fn resolve_plugins(&self, plugins: Vec<Plugin>) -> Vec<Plugin> {
|
||||
// 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<String> {
|
||||
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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
mod context;
|
||||
mod error;
|
||||
|
||||
pub use context::{AppContext, WorkspaceContext};
|
||||
pub use context::WorkspaceContext;
|
||||
pub use error::{Error, Result};
|
||||
|
||||
Reference in New Issue
Block a user