mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-24 12:24:01 +02:00
Add a Host trait and move DB/model commands off Tauri
Command handlers took Tauri types, so running them anywhere else meant rewriting them. `yaak_commands::Host` is what a handler actually needs from its surroundings: who the client is, what it's looking at, and the shared managers. Handlers are generic over it and keep the router's shape, so another host registers one with rpc_handler_async! and no adapter at all. 28 commands move: models, deletes, response reads, encryption, plugin info, export. ClientCtx implements Host, so the desktop adapters are one line and behavior is unchanged. PluginHost is separate because PluginManager is "spawn a Node sidecar and talk to it" — a browser host runs plugins in a worker and can't hand one back. Only 4 of the 28 need it; the rest work without a plugin runtime, and the compiler says which is which. Also drops yaak_core::AppContext, an earlier sketch of this that was never implemented.
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,52 @@
|
||||
//! Workspace encryption keys and the `secure()` template function.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::host::{Host, PluginHost};
|
||||
use std::sync::Arc;
|
||||
use yaak_plugins::native_template_functions::{
|
||||
decrypt_secure_template_function, encrypt_secure_template_function,
|
||||
};
|
||||
use yaak_rpc_schema::*;
|
||||
|
||||
pub async fn cmd_enable_encryption<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> {
|
||||
let plugin_manager = Arc::new(host.plugin_manager().clone());
|
||||
let encryption_manager = Arc::new(host.encryption_manager().clone());
|
||||
let plugin_context = host.plugin_context();
|
||||
Ok(encrypt_secure_template_function(
|
||||
plugin_manager,
|
||||
encryption_manager,
|
||||
&plugin_context,
|
||||
&req.template,
|
||||
)?)
|
||||
}
|
||||
@@ -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,75 @@
|
||||
//! What a command needs from whatever is running it.
|
||||
//!
|
||||
//! A command handler is invoked on behalf of one client (a desktop window today)
|
||||
//! and needs a handful of things from its surroundings: the shared engine
|
||||
//! managers, who the client is, what the client is looking at, and a little
|
||||
//! about the app. `Host` is that handful and nothing more. The desktop
|
||||
//! implements it over a `WebviewWindow`; a server would implement it over a
|
||||
//! connection. Handlers are generic over it, so the same handler body runs
|
||||
//! under either without knowing which.
|
||||
//!
|
||||
//! The surface grows only when a handler being moved here needs something new,
|
||||
//! and stays as narrow as those handlers allow. What is deliberately *not* here
|
||||
//! is anything only a desktop can do — open a native window, run the updater,
|
||||
//! show a native dialog — those handlers stay with the desktop.
|
||||
|
||||
use yaak_core::WorkspaceContext;
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_models::blob_manager::{BlobContext, BlobManager};
|
||||
use yaak_models::client_db::ClientDb;
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::UpdateSource;
|
||||
use yaak_plugins::events::PluginContext;
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
|
||||
pub trait Host: Clone + Send + Sync + 'static {
|
||||
/// Stable identity of the client this call is for. On the desktop this is
|
||||
/// the window label. It rides on every model write so the client that made
|
||||
/// a change can tell its own echo from everyone else's.
|
||||
fn client_id(&self) -> &str;
|
||||
|
||||
/// What the client is currently looking at: workspace, environment, cookie
|
||||
/// jar, request. Read at call time, since the client can navigate between
|
||||
/// calls (and during one).
|
||||
fn session(&self) -> WorkspaceContext;
|
||||
|
||||
/// The app version, as reported to the Yaak API and stamped on exports.
|
||||
fn app_version(&self) -> String;
|
||||
|
||||
fn query_manager(&self) -> &QueryManager;
|
||||
fn blob_manager(&self) -> &BlobManager;
|
||||
fn encryption_manager(&self) -> &EncryptionManager;
|
||||
|
||||
// -- Conveniences derived from the above; hosts do not override these --
|
||||
|
||||
fn update_source(&self) -> UpdateSource {
|
||||
UpdateSource::from_window_label(self.client_id())
|
||||
}
|
||||
|
||||
fn plugin_context(&self) -> PluginContext {
|
||||
PluginContext::new(Some(self.client_id().to_string()), self.session().workspace_id)
|
||||
}
|
||||
|
||||
fn db(&self) -> ClientDb<'_> {
|
||||
self.query_manager().connect()
|
||||
}
|
||||
|
||||
fn blobs(&self) -> BlobContext {
|
||||
self.blob_manager().connect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A host that can also reach plugins.
|
||||
///
|
||||
/// Separate from [`Host`] because the plugin runtime is the one piece that is
|
||||
/// not the same shape everywhere. `PluginManager` *is* "spawn a Node sidecar
|
||||
/// and talk to it"; a browser host runs plugins in a Worker it reaches by
|
||||
/// message instead, and cannot hand back a `&PluginManager` at all.
|
||||
///
|
||||
/// Keeping it out of `Host` also stops a command that only touches the
|
||||
/// database from demanding a plugin runtime it never calls: a host with no
|
||||
/// plugins can still serve those, and only handlers bounded on `PluginHost`
|
||||
/// are closed to it.
|
||||
pub trait PluginHost: Host {
|
||||
fn plugin_manager(&self) -> &PluginManager;
|
||||
}
|
||||
@@ -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,174 @@
|
||||
//! Reads and writes of models, keyed by the client's identity so the frontend
|
||||
//! can suppress its own echoes.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::host::{Host, PluginHost};
|
||||
use yaak_models::models::{
|
||||
AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequestHeader, Settings, WebsocketEvent,
|
||||
WorkspaceMeta,
|
||||
};
|
||||
use yaak_models::queries::workspaces::default_headers;
|
||||
use yaak_rpc_schema::*;
|
||||
|
||||
pub async fn models_upsert<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_ops::upsert_model(&db, blobs, req.model, &source)?)
|
||||
}
|
||||
|
||||
/// Deletes run on a blocking thread: they cascade (a workspace can hold
|
||||
/// thousands of requests), and a transaction holds a raw connection that
|
||||
/// would otherwise stall the runtime and every other call with it.
|
||||
pub async fn models_delete<H: Host>(host: H, req: ModelsDeleteReq) -> Result<String> {
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let source = host.update_source();
|
||||
host.query_manager().with_tx(|tx| {
|
||||
yaak::models_ops::delete_model(tx, host.blob_manager(), req.model, &source)
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| Error::Generic(format!("Delete task failed: {e}")))?;
|
||||
Ok(result?)
|
||||
}
|
||||
|
||||
/// Duplicates recurse, so this runs in a transaction too.
|
||||
pub async fn models_duplicate<H: Host>(host: H, req: ModelsDuplicateReq) -> Result<String> {
|
||||
let source = host.update_source();
|
||||
Ok(host.query_manager().with_tx(|tx| {
|
||||
yaak::models_ops::duplicate_model(tx, &req.model_type, &req.model_id, &source)
|
||||
})?)
|
||||
}
|
||||
|
||||
pub async fn models_websocket_events<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.plugin_manager().resolve_plugins_for_runtime_from_db(plugins).await;
|
||||
l.append(&mut plugins.into_iter().map(Into::into).collect());
|
||||
|
||||
// Add the workspace children
|
||||
if let Some(wid) = req.workspace_id.as_deref() {
|
||||
let db = host.db();
|
||||
l.append(&mut db.list_cookie_jars(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_environments_ensure_base(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_folders(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_grpc_connections(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_grpc_requests(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_http_requests(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_http_responses(wid, None)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_websocket_connections(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_websocket_requests(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_workspace_metas(wid)?.into_iter().map(Into::into).collect());
|
||||
}
|
||||
|
||||
Ok(serde_json::to_string(&l)?)
|
||||
}
|
||||
|
||||
pub async fn cmd_get_workspace_meta<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,50 @@
|
||||
//! Plugin queries that only need the plugin manager and the database.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::host::PluginHost;
|
||||
use std::path::PathBuf;
|
||||
use yaak_plugins::plugin_meta::{PluginMetadata, get_plugin_meta};
|
||||
use yaak_rpc_schema::*;
|
||||
|
||||
pub async fn cmd_plugin_info<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdPluginInfoReq,
|
||||
) -> Result<PluginMetadata> {
|
||||
let plugin = host.db().get_plugin(&req.id)?;
|
||||
if let Some(plugin_handle) =
|
||||
host.plugin_manager().get_plugin_by_dir(plugin.directory.as_str()).await
|
||||
{
|
||||
return Ok(plugin_handle.info());
|
||||
}
|
||||
|
||||
if let Ok(metadata) = get_plugin_meta(&PathBuf::from(&plugin.directory)) {
|
||||
return Ok(metadata);
|
||||
}
|
||||
|
||||
Ok(fallback_plugin_metadata(&plugin.directory))
|
||||
}
|
||||
|
||||
fn fallback_plugin_metadata(directory: &str) -> PluginMetadata {
|
||||
let display_name = PathBuf::from(directory)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or(directory)
|
||||
.to_string();
|
||||
|
||||
PluginMetadata {
|
||||
version: "Unavailable".to_string(),
|
||||
name: directory.to_string(),
|
||||
display_name,
|
||||
description: Some(format!("Plugin metadata could not be loaded from {directory}")),
|
||||
homepage_url: None,
|
||||
repository_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn cmd_plugin_init_errors<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdPluginInitErrorsReq,
|
||||
) -> Result<Vec<(String, String)>> {
|
||||
Ok(host.plugin_manager().take_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,138 @@
|
||||
//! A host that is nothing but the trait: a temp database, a fixed client id,
|
||||
//! a fixed session. It exists to prove that the handlers really do run without
|
||||
//! a desktop around them, and that the client's identity reaches the writes.
|
||||
//!
|
||||
//! It implements `Host` and not `PluginHost`, which is the point: there is no
|
||||
//! Node runtime here, and the commands exercised below never needed one. A
|
||||
//! handler that reaches for plugins would not compile against this host.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tempfile::TempDir;
|
||||
use yaak_commands::Host;
|
||||
use yaak_commands::models::{
|
||||
cmd_default_headers, cmd_get_workspace_meta, models_delete, models_upsert,
|
||||
};
|
||||
use yaak_core::WorkspaceContext;
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
use yaak_models::models::{AnyModel, Workspace};
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::{ModelPayload, UpdateSource};
|
||||
use yaak_rpc_schema::{
|
||||
CmdDefaultHeadersReq, CmdGetWorkspaceMetaReq, ModelsDeleteReq, ModelsUpsertReq,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TestHost {
|
||||
inner: Arc<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 run through spawn_blocking and a transaction; make sure that
|
||||
// path also works off a plain tokio runtime.
|
||||
let workspace = host.db().get_workspace(&id).expect("get workspace");
|
||||
let deleted =
|
||||
models_delete(host.clone(), ModelsDeleteReq { model: AnyModel::Workspace(workspace) })
|
||||
.await
|
||||
.expect("delete");
|
||||
assert_eq!(deleted, id);
|
||||
assert!(host.db().get_workspace(&id).is_err(), "workspace should be gone");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_free_handlers_need_no_state() {
|
||||
let host = TestHost::new();
|
||||
let headers = cmd_default_headers(host, CmdDefaultHeadersReq {}).await.expect("headers");
|
||||
assert!(!headers.is_empty());
|
||||
}
|
||||
@@ -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