Add a Host trait and move DB/model commands off Tauri (#558)

This commit is contained in:
Gregory Schier
2026-08-16 08:41:35 -07:00
committed by GitHub
parent 6f91f76064
commit 6a02cbe525
24 changed files with 1017 additions and 518 deletions
+23
View File
@@ -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, " "))
}
+41
View File
@@ -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
}
+30
View File
@@ -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>;
+118
View File
@@ -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>>;
}
+23
View File
@@ -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};
+179
View File
@@ -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())
}
+48
View File
@@ -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)
}
+86
View File
@@ -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(())
}