Compare commits

..
Author SHA1 Message Date
Gregory SchierandClaude Fable 5 4122b9d72a Adapt the bridge to id-keyed response bodies
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 11:03:21 -07:00
Gregory SchierandClaude Fable 5 e294e6bcef Add the Yaak Bridge so a browser tab can run the real engine
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 11:03:21 -07:00
34 changed files with 1422 additions and 1323 deletions
Generated
-18
View File
@@ -11106,7 +11106,6 @@ dependencies = [
"yaak-models",
"yaak-plugins",
"yaak-rpc",
"yaak-rpc-schema",
"yaak-sse",
"yaak-sync",
"yaak-system-appearance",
@@ -11444,22 +11443,6 @@ dependencies = [
"ts-rs",
]
[[package]]
name = "yaak-rpc-schema"
version = "0.0.0"
dependencies = [
"serde",
"ts-rs",
"yaak-git",
"yaak-grpc",
"yaak-models",
"yaak-plugins",
"yaak-sse",
"yaak-sync",
"yaak-templates",
"yaak-ws",
]
[[package]]
name = "yaak-server"
version = "0.1.0"
@@ -11490,7 +11473,6 @@ dependencies = [
"yaak-models",
"yaak-plugins",
"yaak-rpc",
"yaak-rpc-schema",
"yaak-sse",
"yaak-templates",
]
-2
View File
@@ -5,7 +5,6 @@ members = [
# Common/foundation crates
"crates/common/yaak-database",
"crates/common/yaak-rpc",
"crates/common/yaak-rpc-schema",
# Shared crates (no Tauri dependency)
"crates/yaak-core",
"crates/yaak-common",
@@ -66,7 +65,6 @@ ts-rs = "11.1.0"
# Internal crates - common/foundation
yaak-database = { path = "crates/common/yaak-database" }
yaak-rpc = { path = "crates/common/yaak-rpc" }
yaak-rpc-schema = { path = "crates/common/yaak-rpc-schema" }
# Internal crates - shared
yaak-core = { path = "crates/yaak-core" }
+1 -1
View File
@@ -1,6 +1,6 @@
import type { RpcPayload } from "@yaakapp-internal/platform";
import { platform } from "@yaakapp-internal/platform";
import type { RpcSchema } from "@yaakapp-internal/rpc-schema";
import type { RpcSchema } from "@yaakapp-internal/tauri-client";
/**
* Every backend command the app can call: the generated wire schema, one field
+1 -1
View File
@@ -1,5 +1,5 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import type { EphemeralHttpResponse } from "@yaakapp-internal/rpc-schema";
import type { EphemeralHttpResponse } from "@yaakapp-internal/tauri-client";
import { getActiveCookieJar } from "../hooks/useActiveCookieJar";
import { rpc } from "./rpc";
-1
View File
@@ -43,6 +43,5 @@ yaak-http = { workspace = true }
yaak-models = { workspace = true }
yaak-plugins = { workspace = true }
yaak-rpc = { workspace = true }
yaak-rpc-schema = { workspace = true }
yaak-sse = { workspace = true }
yaak-templates = { workspace = true }
+334 -148
View File
@@ -7,14 +7,15 @@
//! they are plain data. The command *bodies* are what matter, and they call the
//! same engine functions the desktop calls.
use super::{BridgeCtx, unsupported_command};
use super::{BridgeCtx, UNSUPPORTED_COMMANDS, unsupported_command};
use mime_guess::{Mime, mime};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use std::str::FromStr;
use yaak::import::{ImportDataParams, import_data as import_data_shared};
use yaak::models_ops::{delete_model, duplicate_model, upsert_model};
use yaak::send::{ResponseBody, SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
use yaak_core::WorkspaceContext;
use yaak_models::models::{
AnyModel, Environment, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader,
@@ -35,11 +36,10 @@ use yaak_plugins::native_template_functions::{
};
use yaak_plugins::plugin_meta::PluginMetadata;
use yaak_rpc::{RpcError, RpcRouter, rpc_handler_async};
use yaak_rpc_schema::*;
use yaak_sse::sse::ServerSentEvent;
use yaak_templates::format_json::format_json;
use yaak_templates::{
RenderErrorBehavior, RenderOptions, TemplateCallback, parse_and_render,
RenderErrorBehavior, RenderOptions, TemplateCallback, Tokens, parse_and_render,
render_json_value_raw,
};
@@ -68,11 +68,51 @@ where
}
}
macro_rules! rpc_commands {
( $( $name:ident ),* $(,)? ) => {
pub fn build_router() -> RpcRouter<BridgeCtx> {
let mut router = RpcRouter::new();
$( router.register(stringify!($name), rpc_handler_async!($name)); )*
for cmd in UNSUPPORTED_COMMANDS {
router.register(
cmd,
Box::new(move |_ctx, _payload| {
let cmd = *cmd;
Box::pin(async move { Err(unsupported_command(cmd)) })
}),
);
}
router
}
};
}
// -- App metadata --
async fn cmd_metadata(ctx: BridgeCtx, _req: CmdMetadataReq) -> Result<AppMetaData> {
#[derive(Debug, Deserialize)]
pub struct EmptyReq {}
/// Deliberately not the desktop's `AppMetaData`: that type lives in a Tauri
/// crate and half its fields are Tauri paths. The serialized shape is the same,
/// which is what the frontend reads.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BridgeMetaData {
is_dev: bool,
version: String,
cli_version: Option<String>,
name: String,
app_data_dir: String,
app_log_dir: String,
vendored_plugin_dir: String,
default_project_dir: String,
feature_updater: bool,
feature_license: bool,
}
async fn cmd_metadata(ctx: BridgeCtx, _req: EmptyReq) -> Result<BridgeMetaData> {
let data_dir = ctx.state.data_dir().to_string_lossy().to_string();
Ok(AppMetaData {
Ok(BridgeMetaData {
is_dev: ctx.state.is_dev,
version: env!("CARGO_PKG_VERSION").to_string(),
cli_version: None,
@@ -97,11 +137,23 @@ async fn cmd_metadata(ctx: BridgeCtx, _req: CmdMetadataReq) -> Result<AppMetaDat
// -- Models --
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelsUpsertReq {
pub model: AnyModel,
}
async fn models_upsert(ctx: BridgeCtx, req: ModelsUpsertReq) -> Result<String> {
let db = ctx.state.db();
upsert_model(&db, ctx.state.blob_manager(), req.model, &ctx.update_source()).map_err(err)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelsDeleteReq {
pub model: AnyModel,
}
/// Deletes run on a blocking thread, as they do on the desktop: a transaction
/// holds a raw sqlite connection, which is neither `Send` nor cheap to hold —
/// dropping a workspace with thousands of requests would otherwise stall the
@@ -116,6 +168,13 @@ async fn models_delete(ctx: BridgeCtx, req: ModelsDeleteReq) -> Result<String> {
.await
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelsDuplicateReq {
pub model_type: String,
pub model_id: String,
}
async fn models_duplicate(ctx: BridgeCtx, req: ModelsDuplicateReq) -> Result<String> {
let source = ctx.update_source();
blocking(move || {
@@ -126,10 +185,16 @@ async fn models_duplicate(ctx: BridgeCtx, req: ModelsDuplicateReq) -> Result<Str
.await
}
async fn models_get_settings(ctx: BridgeCtx, _req: ModelsGetSettingsReq) -> Result<Settings> {
async fn models_get_settings(ctx: BridgeCtx, _req: EmptyReq) -> Result<Settings> {
Ok(ctx.state.db().get_settings())
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelsWorkspaceModelsReq {
pub workspace_id: Option<String>,
}
/// Everything the frontend's model store needs for a workspace, as one JSON
/// string.
///
@@ -203,6 +268,12 @@ async fn models_workspace_models(ctx: BridgeCtx, req: ModelsWorkspaceModelsReq)
serde_json::to_string(&l).map_err(err)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelsWebsocketEventsReq {
pub connection_id: String,
}
async fn models_websocket_events(
ctx: BridgeCtx,
req: ModelsWebsocketEventsReq,
@@ -210,10 +281,22 @@ async fn models_websocket_events(
ctx.state.db().list_websocket_events(&req.connection_id).map_err(err)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelsGrpcEventsReq {
pub connection_id: String,
}
async fn models_grpc_events(ctx: BridgeCtx, req: ModelsGrpcEventsReq) -> Result<Vec<GrpcEvent>> {
ctx.state.db().list_grpc_events(&req.connection_id).map_err(err)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelsGetGraphqlIntrospectionReq {
pub request_id: String,
}
async fn models_get_graphql_introspection(
ctx: BridgeCtx,
req: ModelsGetGraphqlIntrospectionReq,
@@ -221,6 +304,14 @@ async fn models_get_graphql_introspection(
Ok(ctx.state.db().get_graphql_introspection(&req.request_id))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelsUpsertGraphqlIntrospectionReq {
pub request_id: String,
pub workspace_id: String,
pub content: Option<String>,
}
async fn models_upsert_graphql_introspection(
ctx: BridgeCtx,
req: ModelsUpsertGraphqlIntrospectionReq,
@@ -236,6 +327,12 @@ async fn models_upsert_graphql_introspection(
.map_err(err)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CmdGetWorkspaceMetaReq {
pub workspace_id: String,
}
async fn cmd_get_workspace_meta(
ctx: BridgeCtx,
req: CmdGetWorkspaceMetaReq,
@@ -247,6 +344,14 @@ async fn cmd_get_workspace_meta(
// -- Sending --
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CmdSendHttpRequestReq {
pub environment_id: Option<String>,
pub cookie_jar_id: Option<String>,
pub request_id: String,
}
/// Send a saved request.
///
/// Same sequence as the desktop (crates-tauri/.../lib.rs `cmd_send_http_request`):
@@ -338,13 +443,20 @@ async fn send_persisted(
Ok(result.response)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CmdSendEphemeralRequestReq {
pub request: HttpRequest,
pub environment_id: Option<String>,
pub cookie_jar_id: Option<String>,
}
/// Send without saving. An empty request id keeps the engine from persisting
/// anything, so the body comes back in memory and rides along with the
/// response — there is no row to look up later and no file to serve.
/// anything, so the body comes back in memory instead of on disk.
async fn cmd_send_ephemeral_request(
ctx: BridgeCtx,
req: CmdSendEphemeralRequestReq,
) -> Result<EphemeralHttpResponse> {
) -> Result<HttpResponse> {
let mut request = req.request;
request.id = String::new();
let plugin_manager = ctx.plugins()?;
@@ -370,18 +482,18 @@ async fn cmd_send_ephemeral_request(
.await
.map_err(err)?;
// Blanking the request id above is what makes this send unsaved, so the
// engine always hands the body back. Failing loudly beats returning an
// empty body that reads as "the server sent nothing".
let ResponseBody::Returned(body) = result.response_body else {
return Err(RpcError { message: "Unsaved response did not return a body".to_string() });
};
Ok(EphemeralHttpResponse { response: result.response, body })
Ok(result.response)
}
// -- Reading responses --
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CmdHttpResponseBodyReq {
pub response_id: String,
pub filter: Option<String>,
}
/// 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.
async fn cmd_http_response_body(
@@ -408,6 +520,12 @@ async fn cmd_http_response_body(
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CmdHttpResponseBodyPathReq {
pub response_id: String,
}
/// The desktop host uses this to open the file itself. A tab cannot open a
/// path, so the bridge's browser host never calls it — it fetches
/// `/responses/:id/body` instead — but the command answers honestly for any
@@ -437,6 +555,12 @@ fn parse_charset(content_type: &str) -> Option<String> {
mime.get_param(mime::CHARSET).map(|v| v.to_string())
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CmdHttpRequestBodyReq {
pub response_id: String,
}
async fn cmd_http_request_body(
ctx: BridgeCtx,
req: CmdHttpRequestBodyReq,
@@ -449,6 +573,12 @@ async fn cmd_http_request_body(
Ok(Some(chunks.into_iter().flat_map(|c| c.data).collect()))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CmdGetHttpResponseEventsReq {
pub response_id: String,
}
async fn cmd_get_http_response_events(
ctx: BridgeCtx,
req: CmdGetHttpResponseEventsReq,
@@ -456,6 +586,12 @@ async fn cmd_get_http_response_events(
ctx.state.db().list_http_response_events(&req.response_id).map_err(err)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CmdGetSseEventsReq {
pub response_id: String,
}
async fn cmd_get_sse_events(
ctx: BridgeCtx,
req: CmdGetSseEventsReq,
@@ -485,6 +621,12 @@ async fn cmd_get_sse_events(
Ok(events)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CmdDeleteAllHttpResponsesReq {
pub request_id: String,
}
async fn cmd_delete_all_http_responses(
ctx: BridgeCtx,
req: CmdDeleteAllHttpResponsesReq,
@@ -496,6 +638,12 @@ async fn cmd_delete_all_http_responses(
Ok(())
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CmdDeleteSendHistoryReq {
pub workspace_id: String,
}
async fn cmd_delete_send_history(ctx: BridgeCtx, req: CmdDeleteSendHistoryReq) -> Result<()> {
let source = ctx.update_source();
blocking(move || {
@@ -511,10 +659,22 @@ async fn cmd_delete_send_history(ctx: BridgeCtx, req: CmdDeleteSendHistoryReq) -
// -- Formatting and templates --
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CmdFormatJsonReq {
pub text: String,
}
async fn cmd_format_json(_ctx: BridgeCtx, req: CmdFormatJsonReq) -> Result<String> {
Ok(format_json(&req.text, " "))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CmdFormatGraphqlReq {
pub text: String,
}
async fn cmd_format_graphql(_ctx: BridgeCtx, req: CmdFormatGraphqlReq) -> Result<String> {
match pretty_graphql::format_text(&req.text, &Default::default()) {
Ok(formatted) => Ok(formatted),
@@ -522,6 +682,16 @@ async fn cmd_format_graphql(_ctx: BridgeCtx, req: CmdFormatGraphqlReq) -> Result
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CmdRenderTemplateReq {
pub template: String,
pub workspace_id: String,
pub environment_id: Option<String>,
pub purpose: Option<RenderPurpose>,
pub ignore_error: Option<bool>,
}
async fn cmd_render_template(ctx: BridgeCtx, req: CmdRenderTemplateReq) -> Result<String> {
let environment_chain = ctx
.state
@@ -556,6 +726,12 @@ async fn render_json_value<T: TemplateCallback>(
render_json_value_raw(value, vars, cb, opt).await
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CmdTemplateTokensToStringReq {
pub tokens: Tokens,
}
async fn cmd_template_tokens_to_string(
_ctx: BridgeCtx,
req: CmdTemplateTokensToStringReq,
@@ -563,6 +739,12 @@ async fn cmd_template_tokens_to_string(
Ok(req.tokens.to_string())
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CmdDecryptTemplateReq {
pub template: String,
}
async fn cmd_decrypt_template(ctx: BridgeCtx, req: CmdDecryptTemplateReq) -> Result<String> {
decrypt_secure_template_function(
&ctx.state.encryption_manager,
@@ -572,6 +754,12 @@ async fn cmd_decrypt_template(ctx: BridgeCtx, req: CmdDecryptTemplateReq) -> Res
.map_err(err)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CmdSecureTemplateReq {
pub template: String,
}
async fn cmd_secure_template(ctx: BridgeCtx, req: CmdSecureTemplateReq) -> Result<String> {
encrypt_secure_template_function(
ctx.plugins()?,
@@ -582,13 +770,13 @@ async fn cmd_secure_template(ctx: BridgeCtx, req: CmdSecureTemplateReq) -> Resul
.map_err(err)
}
async fn cmd_default_headers(_ctx: BridgeCtx, _req: CmdDefaultHeadersReq) -> Result<Vec<HttpRequestHeader>> {
async fn cmd_default_headers(_ctx: BridgeCtx, _req: EmptyReq) -> Result<Vec<HttpRequestHeader>> {
Ok(default_headers())
}
// -- Plugins --
async fn cmd_get_themes(ctx: BridgeCtx, _req: CmdGetThemesReq) -> Result<Vec<GetThemesResponse>> {
async fn cmd_get_themes(ctx: BridgeCtx, _req: EmptyReq) -> Result<Vec<GetThemesResponse>> {
// Themes are optional: the TypeScript package ships defaults, and an empty
// list still renders. Don't fail boot when the runtime is down.
let Ok(plugins) = ctx.plugins() else {
@@ -597,13 +785,19 @@ async fn cmd_get_themes(ctx: BridgeCtx, _req: CmdGetThemesReq) -> Result<Vec<Get
plugins.get_themes(&ctx.plugin_context()).await.map_err(err)
}
async fn cmd_plugin_init_errors(ctx: BridgeCtx, _req: CmdPluginInitErrorsReq) -> Result<Vec<(String, String)>> {
async fn cmd_plugin_init_errors(ctx: BridgeCtx, _req: EmptyReq) -> Result<Vec<(String, String)>> {
let Ok(plugins) = ctx.plugins() else {
return Ok(Vec::new());
};
Ok(plugins.take_init_errors().await)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CmdPluginInfoReq {
pub id: String,
}
async fn cmd_plugin_info(ctx: BridgeCtx, req: CmdPluginInfoReq) -> Result<PluginMetadata> {
let plugin = ctx.state.db().get_plugin(&req.id).map_err(err)?;
let plugins = ctx.plugins()?;
@@ -616,11 +810,24 @@ async fn cmd_plugin_info(ctx: BridgeCtx, req: CmdPluginInfoReq) -> Result<Plugin
async fn cmd_template_function_summaries(
ctx: BridgeCtx,
_req: CmdTemplateFunctionSummariesReq,
_req: EmptyReq,
) -> Result<Vec<GetTemplateFunctionSummaryResponse>> {
ctx.plugins()?.get_template_function_summaries(&ctx.plugin_context()).await.map_err(err)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CmdTemplateFunctionConfigReq {
pub function_name: String,
pub values: HashMap<String, JsonPrimitive>,
pub model: AnyModel,
/// Sent by the frontend, unused here — same as the desktop, which takes it
/// as `_environment_id`. Template function values are not pre-rendered the
/// way auth values are.
#[allow(dead_code)]
pub environment_id: Option<String>,
}
async fn cmd_template_function_config(
ctx: BridgeCtx,
req: CmdTemplateFunctionConfigReq,
@@ -638,13 +845,22 @@ async fn cmd_template_function_config(
async fn cmd_get_http_authentication_summaries(
ctx: BridgeCtx,
_req: CmdGetHttpAuthenticationSummariesReq,
_req: EmptyReq,
) -> Result<Vec<GetHttpAuthenticationSummaryResponse>> {
let results =
ctx.plugins()?.get_http_authentication_summaries(&ctx.plugin_context()).await.map_err(err)?;
Ok(results.into_iter().map(|(_, a)| a).collect())
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CmdGetHttpAuthenticationConfigReq {
pub auth_name: String,
pub values: HashMap<String, JsonPrimitive>,
pub model: AnyModel,
pub environment_id: Option<String>,
}
async fn cmd_get_http_authentication_config(
ctx: BridgeCtx,
req: CmdGetHttpAuthenticationConfigReq,
@@ -662,6 +878,16 @@ async fn cmd_get_http_authentication_config(
.map_err(err)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CmdCallHttpAuthenticationActionReq {
pub auth_name: String,
pub action_index: i32,
pub values: HashMap<String, JsonPrimitive>,
pub model: AnyModel,
pub environment_id: Option<String>,
}
async fn cmd_call_http_authentication_action(
ctx: BridgeCtx,
req: CmdCallHttpAuthenticationActionReq,
@@ -726,11 +952,17 @@ async fn render_auth_values(
async fn cmd_http_request_actions(
ctx: BridgeCtx,
_req: CmdHttpRequestActionsReq,
_req: EmptyReq,
) -> Result<Vec<GetHttpRequestActionsResponse>> {
ctx.plugins()?.get_http_request_actions(&ctx.plugin_context()).await.map_err(err)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CmdCallHttpRequestActionReq {
pub req: CallHttpRequestActionRequest,
}
async fn cmd_call_http_request_action(
ctx: BridgeCtx,
req: CmdCallHttpRequestActionReq,
@@ -765,11 +997,17 @@ async fn cmd_call_http_request_action(
async fn cmd_workspace_actions(
ctx: BridgeCtx,
_req: CmdWorkspaceActionsReq,
_req: EmptyReq,
) -> Result<Vec<GetWorkspaceActionsResponse>> {
ctx.plugins()?.get_workspace_actions(&ctx.plugin_context()).await.map_err(err)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CmdCallWorkspaceActionReq {
pub req: CallWorkspaceActionRequest,
}
async fn cmd_call_workspace_action(ctx: BridgeCtx, req: CmdCallWorkspaceActionReq) -> Result<()> {
use yaak_plugins::events::CallWorkspaceActionArgs;
@@ -783,10 +1021,16 @@ async fn cmd_call_workspace_action(ctx: BridgeCtx, req: CmdCallWorkspaceActionRe
.map_err(err)
}
async fn cmd_folder_actions(ctx: BridgeCtx, _req: CmdFolderActionsReq) -> Result<Vec<GetFolderActionsResponse>> {
async fn cmd_folder_actions(ctx: BridgeCtx, _req: EmptyReq) -> Result<Vec<GetFolderActionsResponse>> {
ctx.plugins()?.get_folder_actions(&ctx.plugin_context()).await.map_err(err)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CmdCallFolderActionReq {
pub req: CallFolderActionRequest,
}
async fn cmd_call_folder_action(ctx: BridgeCtx, req: CmdCallFolderActionReq) -> Result<()> {
use yaak_plugins::events::CallFolderActionArgs;
@@ -802,6 +1046,13 @@ async fn cmd_call_folder_action(ctx: BridgeCtx, req: CmdCallFolderActionReq) ->
// -- Import --
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CmdCurlToRequestReq {
pub command: String,
pub workspace_id: String,
}
async fn cmd_curl_to_request(ctx: BridgeCtx, req: CmdCurlToRequestReq) -> Result<HttpRequest> {
let import_result =
ctx.plugins()?.import_data(&ctx.plugin_context(), &req.command).await.map_err(err)?;
@@ -818,6 +1069,12 @@ async fn cmd_curl_to_request(ctx: BridgeCtx, req: CmdCurlToRequestReq) -> Result
Ok(request)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CmdImportDataReq {
pub file_path: String,
}
/// Import from a path on the *bridge's* machine.
///
/// The desktop gets this path from a native file dialog. A tab has no way to
@@ -846,131 +1103,60 @@ async fn cmd_import_data(ctx: BridgeCtx, req: CmdImportDataReq) -> Result<BatchU
.map_err(err)
}
// -- Not on this host --
rpc_commands! {
cmd_metadata,
cmd_default_headers,
cmd_get_themes,
cmd_plugin_init_errors,
cmd_plugin_info,
/// Commands the bridge does not implement. Each still gets an adapter, so the
/// schema stays fully covered and the frontend receives a structured error
/// naming the command and this host rather than a bare "unknown command".
///
/// One list, two uses: `unsupported_commands!` emits both the adapters and the
/// `UNSUPPORTED_COMMANDS` array `implemented_commands` subtracts.
macro_rules! unsupported_commands {
( $( $name:ident ( $req:ty ) ),* $(,)? ) => {
// The stub never produces a value, so it doesn't need to name the
// response type — which keeps git, gRPC and WebSocket crates out of a
// binary that will never call them. `Never` serializes fine.
$( async fn $name(_ctx: BridgeCtx, _req: $req) -> Result<Never> {
Err(unsupported_command(stringify!($name)))
} )*
pub const UNSUPPORTED_COMMANDS: &[&str] = &[ $( stringify!($name), )* ];
};
models_upsert,
models_delete,
models_duplicate,
models_get_settings,
models_workspace_models,
models_websocket_events,
models_grpc_events,
models_get_graphql_introspection,
models_upsert_graphql_introspection,
cmd_get_workspace_meta,
cmd_send_http_request,
cmd_send_ephemeral_request,
cmd_http_response_body,
cmd_http_response_body_path,
cmd_http_request_body,
cmd_get_http_response_events,
cmd_get_sse_events,
cmd_delete_all_http_responses,
cmd_delete_send_history,
cmd_format_json,
cmd_format_graphql,
cmd_render_template,
cmd_template_tokens_to_string,
cmd_decrypt_template,
cmd_secure_template,
cmd_template_function_summaries,
cmd_template_function_config,
cmd_get_http_authentication_summaries,
cmd_get_http_authentication_config,
cmd_call_http_authentication_action,
cmd_http_request_actions,
cmd_call_http_request_action,
cmd_workspace_actions,
cmd_call_workspace_action,
cmd_folder_actions,
cmd_call_folder_action,
cmd_curl_to_request,
cmd_import_data,
}
/// A value that cannot exist. The unsupported adapters return `Result<Never>`
/// and always take the `Err` branch, so `rpc_handler_async!` has something
/// serializable to name without a real response type ever being constructed.
#[derive(serde::Serialize)]
enum Never {}
unsupported_commands! {
// Multi-window. A tab is one window; Settings opens through this on the desktop and is therefore unreachable in the browser today.
cmd_new_child_window(CmdNewChildWindowReq),
cmd_new_main_window(CmdNewMainWindowReq),
// gRPC and WebSocket sending.
cmd_grpc_reflect(CmdGrpcReflectReq),
cmd_grpc_go(CmdGrpcGoReq),
cmd_grpc_request_actions(CmdGrpcRequestActionsReq),
cmd_call_grpc_request_action(CmdCallGrpcRequestActionReq),
cmd_delete_all_grpc_connections(CmdDeleteAllGrpcConnectionsReq),
cmd_ws_connect(CmdWsConnectReq),
cmd_ws_send(CmdWsSendReq),
cmd_ws_close(CmdWsCloseReq),
cmd_ws_delete_connections(CmdWsDeleteConnectionsReq),
cmd_websocket_request_actions(CmdWebsocketRequestActionsReq),
cmd_call_websocket_request_action(CmdCallWebsocketRequestActionReq),
// Git-backed workspaces.
cmd_git_checkout(CmdGitCheckoutReq),
cmd_git_branch(CmdGitBranchReq),
cmd_git_delete_branch(CmdGitDeleteBranchReq),
cmd_git_delete_remote_branch(CmdGitDeleteRemoteBranchReq),
cmd_git_merge_branch(CmdGitMergeBranchReq),
cmd_git_rename_branch(CmdGitRenameBranchReq),
cmd_git_status(CmdGitStatusReq),
cmd_git_branch_info(CmdGitBranchInfoReq),
cmd_git_worktree_status(CmdGitWorktreeStatusReq),
cmd_git_log(CmdGitLogReq),
cmd_git_log_for_file(CmdGitLogForFileReq),
cmd_git_file_diff_for_commit(CmdGitFileDiffForCommitReq),
cmd_git_initialize(CmdGitInitializeReq),
cmd_git_clone(CmdGitCloneReq),
cmd_git_commit(CmdGitCommitReq),
cmd_git_fetch_all(CmdGitFetchAllReq),
cmd_git_push(CmdGitPushReq),
cmd_git_pull(CmdGitPullReq),
cmd_git_pull_force_reset(CmdGitPullForceResetReq),
cmd_git_pull_merge(CmdGitPullMergeReq),
cmd_git_add(CmdGitAddReq),
cmd_git_unstage(CmdGitUnstageReq),
cmd_git_reset_changes(CmdGitResetChangesReq),
cmd_git_restore_files(CmdGitRestoreFilesReq),
cmd_git_restore_file_from_commit(CmdGitRestoreFileFromCommitReq),
cmd_git_add_credential(CmdGitAddCredentialReq),
cmd_git_remotes(CmdGitRemotesReq),
cmd_git_add_remote(CmdGitAddRemoteReq),
cmd_git_rm_remote(CmdGitRmRemoteReq),
cmd_git_watch_worktree_status(CmdGitWatchWorktreeStatusReq),
// Filesystem sync.
cmd_sync_calculate(CmdSyncCalculateReq),
cmd_sync_calculate_fs(CmdSyncCalculateFsReq),
cmd_sync_apply(CmdSyncApplyReq),
cmd_sync_watch(CmdSyncWatchReq),
// Workspace encryption.
cmd_enable_encryption(CmdEnableEncryptionReq),
cmd_disable_encryption(CmdDisableEncryptionReq),
cmd_reveal_workspace_key(CmdRevealWorkspaceKeyReq),
cmd_set_workspace_key(CmdSetWorkspaceKeyReq),
// Things that need a local filesystem the tab can point at.
cmd_export_data(CmdExportDataReq),
cmd_save_response(CmdSaveResponseReq),
cmd_save_base64_to_binary(CmdSaveBase64ToBinaryReq),
cmd_plugins_install_from_directory(CmdPluginsInstallFromDirectoryReq),
cmd_import_url(CmdImportUrlReq),
// Desktop application management.
cmd_restart(CmdRestartReq),
cmd_check_for_updates(CmdCheckForUpdatesReq),
cmd_dismiss_notification(CmdDismissNotificationReq),
cmd_send_feedback(CmdSendFeedbackReq),
cmd_plugins_search(CmdPluginsSearchReq),
cmd_plugins_install(CmdPluginsInstallReq),
cmd_plugins_uninstall(CmdPluginsUninstallReq),
cmd_plugins_updates(CmdPluginsUpdatesReq),
cmd_plugins_update_all(CmdPluginsUpdateAllReq),
cmd_reload_plugins(CmdReloadPluginsReq),
}
// -- The router --
/// Every command in the schema, wired to an adapter here.
///
/// The list comes from `yaak_rpc_schema`, so this host cannot silently miss a
/// command the frontend knows about: a schema entry with no adapter below is a
/// compile error, not a runtime "unknown command". Commands the bridge does not
/// support still get an adapter — one that says so — which is what lets the
/// frontend tell a host that will never do git from one that is out of date.
macro_rules! register_commands {
( $( $name:ident ( $req:ty ) -> $res:ty ),* $(,)? ) => {
pub fn build_router() -> RpcRouter<BridgeCtx> {
let mut router = RpcRouter::new();
$( router.register(stringify!($name), rpc_handler_async!($name)); )*
router
}
};
}
yaak_rpc_schema::with_commands!(register_commands);
/// The names of the commands this host actually implements — everything in
/// the schema minus the ones whose adapter is `unsupported`. Reported to the
/// browser so it can fail fast with a clear message.
/// Command names this host implements, for the capability report.
pub fn implemented_commands(router: &RpcRouter<BridgeCtx>) -> Vec<String> {
let unsupported: std::collections::HashSet<&str> =
UNSUPPORTED_COMMANDS.iter().copied().collect();
+86 -7
View File
@@ -1,17 +1,15 @@
//! The bridge's RPC surface.
//!
//! Same envelope, same command names, same request and response types as the
//! desktop — all of that comes from `yaak_rpc_schema` — dispatched through the
//! Same envelope and same command names as the desktop, dispatched through the
//! same `RpcRouter`. Only the adapters differ: the desktop's take a Tauri
//! window and read the workspace off its URL, while these take a `BridgeCtx`
//! carrying the connected tab's reported URL. The bodies underneath call the
//! same engine functions in `yaak`, `yaak-models` and `yaak-plugins`.
//!
//! The router is built from the schema's full command list, so every command
//! the frontend knows has an adapter here — the ones this host doesn't
//! implement return a structured error naming the command and the host, and
//! the frontend surfaces "not supported by the Yaak Bridge" instead of a bare
//! failure. Enough is implemented to boot, edit, send and inspect.
//! This is a subset — enough to boot, edit, send and inspect. Anything not
//! registered here still gets a well-formed answer: `unsupported_command`
//! turns it into an RPC error naming the command and this host, so the frontend
//! surfaces "not supported by the Yaak Bridge" instead of a bare failure.
mod commands;
@@ -56,6 +54,87 @@ pub fn build_router() -> RpcRouter<BridgeCtx> {
commands::build_router()
}
/// Every command the desktop has that the bridge does not implement.
///
/// Registered explicitly rather than left to fall through to "unknown command",
/// so the message says *why* — the frontend can tell a host that will never
/// support git from one that is simply out of date.
pub const UNSUPPORTED_COMMANDS: &[&str] = &[
// Multi-window. A tab is one window; Settings opens through this on the
// desktop and is therefore unreachable in the browser today.
"cmd_new_child_window",
"cmd_new_main_window",
// gRPC and WebSocket sending.
"cmd_grpc_reflect",
"cmd_grpc_go",
"cmd_grpc_request_actions",
"cmd_call_grpc_request_action",
"cmd_delete_all_grpc_connections",
"cmd_ws_connect",
"cmd_ws_send",
"cmd_ws_close",
"cmd_ws_delete_connections",
"cmd_websocket_request_actions",
"cmd_call_websocket_request_action",
// Git-backed workspaces.
"cmd_git_checkout",
"cmd_git_branch",
"cmd_git_delete_branch",
"cmd_git_delete_remote_branch",
"cmd_git_merge_branch",
"cmd_git_rename_branch",
"cmd_git_status",
"cmd_git_branch_info",
"cmd_git_worktree_status",
"cmd_git_log",
"cmd_git_log_for_file",
"cmd_git_file_diff_for_commit",
"cmd_git_initialize",
"cmd_git_clone",
"cmd_git_commit",
"cmd_git_fetch_all",
"cmd_git_push",
"cmd_git_pull",
"cmd_git_pull_force_reset",
"cmd_git_pull_merge",
"cmd_git_add",
"cmd_git_unstage",
"cmd_git_reset_changes",
"cmd_git_restore_files",
"cmd_git_restore_file_from_commit",
"cmd_git_add_credential",
"cmd_git_remotes",
"cmd_git_add_remote",
"cmd_git_rm_remote",
"cmd_git_watch_worktree_status",
// Filesystem sync.
"cmd_sync_calculate",
"cmd_sync_calculate_fs",
"cmd_sync_apply",
"cmd_sync_watch",
// Workspace encryption.
"cmd_enable_encryption",
"cmd_disable_encryption",
"cmd_reveal_workspace_key",
"cmd_set_workspace_key",
// Things that need a local filesystem the tab can point at.
"cmd_export_data",
"cmd_save_response",
"cmd_save_base64_to_binary",
"cmd_plugins_install_from_directory",
// Desktop application management.
"cmd_restart",
"cmd_check_for_updates",
"cmd_dismiss_notification",
"cmd_send_feedback",
"cmd_plugins_search",
"cmd_plugins_install",
"cmd_plugins_uninstall",
"cmd_plugins_updates",
"cmd_plugins_update_all",
"cmd_reload_plugins",
];
pub fn unsupported_command(cmd: &str) -> RpcError {
RpcError {
message: format!("`{cmd}` is not supported on this host (Yaak Bridge)"),
-1
View File
@@ -73,7 +73,6 @@ url = "2"
tokio-util = { version = "0.7", features = ["codec"] }
ts-rs = { workspace = true }
yaak-rpc = { workspace = true }
yaak-rpc-schema = { workspace = true }
uuid = "1.12.1"
yaak-api = { workspace = true }
yaak-common = { workspace = true }
File diff suppressed because one or more lines are too long
+4
View File
@@ -1,5 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type GitWatchResult = { unlistenEvent: string, };
export type PluginUpdateInfo = { name: string, currentVersion: string, latestVersion: string, };
export type PluginUpdateNotification = { updateCount: number, plugins: Array<PluginUpdateInfo>, };
@@ -10,6 +12,8 @@ export type UpdateResponse = { "type": "ack" } | { "type": "action", action: Upd
export type UpdateResponseAction = "install" | "skip";
export type WatchResult = { unlistenEvent: string, };
export type YaakNotification = { timestamp: string, timeout: number | null, id: string, title: string | null, message: string, color: string | null, action: YaakNotificationAction | null, };
export type YaakNotificationAction = { label: string, url: string, };
+3 -3
View File
@@ -1,4 +1,4 @@
// ts-rs owns bindings/index.ts and rewrites it on export. What remains here
// after the RPC schema moved to @yaakapp-internal/rpc-schema is the
// desktop-only surface: updater and notification types.
// ts-rs owns bindings/index.ts and rewrites it on export, so this hand-written
// entry point is where the generated files come together.
export * from "./bindings/gen_rpc";
export * from "./bindings/index";
@@ -2,6 +2,7 @@ use crate::error::{Error, Result};
use chrono::Utc;
use log::{debug, error, warn};
use notify::Watcher;
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::mpsc;
use std::time::Duration;
@@ -9,11 +10,18 @@ use tauri::{AppHandle, Listener, Runtime};
use tokio::select;
use tokio::sync::watch;
use tokio::time::sleep;
use ts_rs::TS;
use yaak_git::{GitWorktreeStatus, git_path_is_ignored, git_repository_paths, git_worktree_status};
use yaak_rpc_schema::GitWatchResult;
const GIT_STATUS_COALESCE_WINDOW: Duration = Duration::from_millis(250);
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "index.ts")]
pub(crate) struct GitWatchResult {
unlisten_event: String,
}
pub(crate) async fn watch_git_worktree_status<R, F>(
app_handle: AppHandle<R>,
dir: &Path,
+17 -1
View File
@@ -8,6 +8,7 @@ use crate::import::{import_data, import_url};
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
use crate::notifications::YaakNotifier;
use crate::render::{render_grpc_request, render_json_value, render_template};
use crate::rpc_ext::EphemeralHttpResponse;
use crate::updates::{UpdateMode, UpdateTrigger, YaakUpdater};
use crate::uri_scheme::handle_deep_link;
use error::Result as YaakResult;
@@ -56,7 +57,6 @@ use yaak_plugins::events::{
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;
@@ -184,6 +184,22 @@ impl<R: Runtime> PluginContextExt<R> for WebviewWindow<R> {
}
}
#[derive(serde::Serialize, ts_rs::TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct AppMetaData {
is_dev: bool,
version: String,
cli_version: Option<String>,
name: String,
app_data_dir: String,
app_log_dir: String,
vendored_plugin_dir: String,
default_project_dir: String,
feature_updater: bool,
feature_license: bool,
}
async fn cmd_metadata<R: Runtime>(app_handle: AppHandle<R>) -> YaakResult<AppMetaData> {
let app_data_dir = app_handle.path().app_data_dir()?;
let app_log_dir = app_handle.path().app_log_dir()?;
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -6,10 +6,11 @@ use crate::error::Result;
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
use chrono::Utc;
use log::warn;
use serde::{Deserialize, Serialize};
use std::path::Path;
use tauri::{AppHandle, Listener, Runtime};
use tokio::sync::watch;
use yaak_rpc_schema::WatchResult;
use ts_rs::TS;
use yaak_sync::error::Error::InvalidSyncDirectory;
use yaak_sync::sync::{
FsCandidate, SyncOp, apply_sync_ops, apply_sync_state_ops, compute_sync_ops, get_db_candidates,
@@ -56,6 +57,13 @@ pub(crate) async fn cmd_sync_apply<R: Runtime>(
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "index.ts")]
pub(crate) struct WatchResult {
unlisten_event: String,
}
pub(crate) async fn sync_watch<R, F>(
app_handle: AppHandle<R>,
sync_dir: &Path,
-18
View File
@@ -1,18 +0,0 @@
[package]
name = "yaak-rpc-schema"
version = "0.0.0"
edition = "2024"
authors = ["Gregory Schier"]
publish = false
[dependencies]
serde = { workspace = true, features = ["derive"] }
ts-rs = { workspace = true }
yaak-git = { workspace = true }
yaak-grpc = { workspace = true }
yaak-models = { workspace = true }
yaak-plugins = { workspace = true }
yaak-sse = { workspace = true }
yaak-sync = { workspace = true }
yaak-templates = { workspace = true }
yaak-ws = { workspace = true }
-44
View File
@@ -1,44 +0,0 @@
# yaak-rpc-schema
The wire schema for the app's RPC surface: every command name, its request
payload, and its response type, declared once.
Every host that serves the Yaak UI — the desktop app today, the browser bridge
and anything after it — imports these types and implements the commands against
them. That is what keeps a request's shape from drifting between hosts, and it
is why the TypeScript bindings (`bindings/gen_rpc.ts`, exposed to the frontend
as `@yaakapp-internal/rpc-schema`) are generated from one place.
Nothing here depends on Tauri or on any host. Request structs are plain data,
and so are the few response types declared here rather than in an engine crate.
Command *bodies* live with the host that runs them.
## Adding a command
1. Add its request struct and an entry in `with_commands!` in `src/lib.rs`.
2. Write the adapter in each host — the desktop's live in
`crates-tauri/yaak-app-client/src/rpc_ext.rs`. A host that does not support
the command still has to say so; a missing adapter fails to compile.
3. Regenerate the bindings: `cargo test -p yaak-rpc-schema` writes
`bindings/gen_rpc.ts`, which is committed.
## How hosts consume the list
`with_commands!` takes the name of a `macro_rules!` macro and calls it with the
full `name(Req) -> Res` list. Each host writes a small macro that receives that
list and builds its router:
```rust
macro_rules! register_commands {
( $( $name:ident ( $req:ty ) -> $res:ty ),* $(,)? ) => {
pub fn build_router() -> RpcRouter<MyCtx> {
let mut router = RpcRouter::new();
$( router.register(stringify!($name), rpc_handler_async!($name)); )*
router
}
};
}
yaak_rpc_schema::with_commands!(register_commands);
```
The schema decides *what* commands exist; the host decides *how* each one runs.
-4
View File
@@ -1,4 +0,0 @@
// The RPC wire schema, generated by ts-rs from the Rust declarations in
// src/lib.rs. `RpcSchema` maps every command name to its (request, response)
// pair; the app's `rpc()` helper derives its command union from it.
export * from "./bindings/gen_rpc";
@@ -1,6 +0,0 @@
{
"name": "@yaakapp-internal/rpc-schema",
"version": "1.0.0",
"private": true,
"main": "index.ts"
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,5 +1,5 @@
import { platform } from "@yaakapp-internal/platform";
import type { WatchResult } from "@yaakapp-internal/rpc-schema";
import type { WatchResult } from "@yaakapp-internal/tauri-client";
import { SyncOp } from "./bindings/gen_sync";
import { WatchEvent } from "./bindings/gen_watch";
-1
View File
@@ -58,7 +58,6 @@
"crates-tauri/yaak-fonts",
"crates-tauri/yaak-license",
"crates-tauri/yaak-mac-window",
"crates/common/yaak-rpc-schema",
"crates/yaak-crypto",
"crates/yaak-git",
"crates/yaak-models",
-5
View File
@@ -213,11 +213,6 @@ export function createBridgePlatform(baseUrl: string, token: string | null): Pla
readDir: async () => {
throw unsupported("Browsing the filesystem");
},
// Only ever called with a path this host handed out, and this host has
// no dialog or drag-drop to hand one out with.
readText: async () => {
throw unsupported("Reading a local file");
},
// No filesystem here, so a path is just a string this host echoes back.
url: (path) => path,
basename: async (path) => path.split(/[/\\]/).pop() ?? path,
+7 -5
View File
@@ -20,16 +20,18 @@ declare global {
interface Window {
__TAURI_INTERNALS__?: unknown;
}
// Declared here rather than by depending on Vite's types: this package is
// consumed by a bundler that provides them, and only this one variable.
interface ImportMeta {
readonly env?: Record<string, string | undefined>;
}
}
function bridgeUrl(): string {
// Set when the frontend runs on a Vite dev server and the bridge is on its
// own port. When the bridge serves the built app, they share an origin.
// Vite inlines `import.meta.env` at build time. Read it through a cast so
// this package typechecks on its own without depending on Vite's types, and
// still picks up the real declaration when the app compiles it.
const env = (import.meta as { env?: Record<string, string | undefined> }).env;
const configured = env?.VITE_YAAK_BRIDGE_URL;
const configured = import.meta.env?.VITE_YAAK_BRIDGE_URL;
return (configured ?? window.location.origin).replace(/\/$/, "");
}