Decouple core Yaak logic from Tauri (#354)

This commit is contained in:
Gregory Schier
2026-01-08 20:44:25 -08:00
committed by GitHub
parent 3bcc0b8356
commit ef80216ca1
465 changed files with 3052 additions and 6234 deletions

View File

@@ -0,0 +1,31 @@
[package]
name = "yaak-plugins"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
base64 = "0.22.1"
chrono = { workspace = true }
dunce = "1.0.4"
futures-util = "0.3.30"
hex = { workspace = true }
keyring = { workspace = true, features = ["apple-native", "windows-native", "sync-secret-service"] }
log = { workspace = true }
md5 = "0.7.0"
path-slash = "0.2.1"
rand = "0.9.0"
regex = "1.10.6"
reqwest = { workspace = true, features = ["json"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "process", "fs"] }
tokio-tungstenite = "0.26.1"
ts-rs = { workspace = true }
yaak-common = { workspace = true }
yaak-crypto = { workspace = true }
yaak-models = { workspace = true }
yaak-templates = { workspace = true }
zip-extract = "0.4.0"

8
crates/yaak-plugins/bindings/gen_api.ts generated Normal file
View File

@@ -0,0 +1,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { PluginVersion } from "./gen_search";
export type PluginNameVersion = { name: string, version: string, };
export type PluginSearchResponse = { plugins: Array<PluginVersion>, };
export type PluginUpdatesResponse = { plugins: Array<PluginNameVersion>, };

550
crates/yaak-plugins/bindings/gen_events.ts generated Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,82 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type AnyModel = CookieJar | Environment | Folder | GraphQlIntrospection | GrpcConnection | GrpcEvent | GrpcRequest | HttpRequest | HttpResponse | HttpResponseEvent | KeyValue | Plugin | Settings | SyncState | WebsocketConnection | WebsocketEvent | WebsocketRequest | Workspace | WorkspaceMeta;
export type ClientCertificate = { host: string, port: number | null, crtFile: string | null, keyFile: string | null, pfxFile: string | null, passphrase: string | null, enabled?: boolean, };
export type Cookie = { raw_cookie: string, domain: CookieDomain, expires: CookieExpires, path: [string, boolean], };
export type CookieDomain = { "HostOnly": string } | { "Suffix": string } | "NotPresent" | "Empty";
export type CookieExpires = { "AtUtc": string } | "SessionEnd";
export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, };
export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
export type EncryptedKey = { encryptedKey: string, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, sortPriority: number, };
export type GraphQlIntrospection = { model: "graphql_introspection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, content: string | null, };
export type GrpcConnection = { model: "grpc_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, method: string, service: string, status: number, state: GrpcConnectionState, trailers: { [key in string]?: string }, url: string, };
export type GrpcConnectionState = "initialized" | "connected" | "closed";
export type GrpcEvent = { model: "grpc_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, content: string, error: string | null, eventType: GrpcEventType, metadata: { [key in string]?: string }, status: number | null, };
export type GrpcEventType = "info" | "error" | "client_message" | "server_message" | "connection_start" | "connection_end";
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, method: string | null, name: string, service: string | null, sortPriority: number, url: string, };
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
/**
* Serializable representation of HTTP response events for DB storage.
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
* The `From` impl is in yaak-http to avoid circular dependencies.
*/
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, };
export type HttpResponseHeader = { name: string, value: string, };
export type HttpResponseState = "initialized" | "connected" | "closed";
export type HttpUrlParameter = { enabled?: boolean, name: string, value: string, id?: string, };
export type KeyValue = { model: "key_value", id: string, createdAt: string, updatedAt: string, key: string, namespace: string, value: string, };
export type Plugin = { model: "plugin", id: string, createdAt: string, updatedAt: string, checkedAt: string | null, directory: string, enabled: boolean, url: string | null, };
export type ProxySetting = { "type": "enabled", http: string, https: string, auth: ProxySettingAuth | null, bypass: string, disabled: boolean, } | { "type": "disabled" };
export type ProxySettingAuth = { user: string, password: string, };
export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, clientCertificates: Array<ClientCertificate>, coloredMethods: boolean, editorFont: string | null, editorFontSize: number, editorKeymap: EditorKeymap, editorSoftWrap: boolean, hideWindowControls: boolean, useNativeTitlebar: boolean, interfaceFont: string | null, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, hideLicenseBadge: boolean, autoupdate: boolean, autoDownloadUpdates: boolean, checkNotifications: boolean, hotkeys: { [key in string]?: Array<string> }, };
export type SyncState = { model: "sync_state", id: string, workspaceId: string, createdAt: string, updatedAt: string, flushedAt: string, modelId: string, checksum: string, relPath: string, syncDir: string, };
export type WebsocketConnection = { model: "websocket_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, headers: Array<HttpResponseHeader>, state: WebsocketConnectionState, status: number, url: string, };
export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed";
export type WebsocketEvent = { model: "websocket_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, isServer: boolean, message: Array<number>, messageType: WebsocketEventType, };
export type WebsocketEventType = "binary" | "close" | "frame" | "open" | "ping" | "pong" | "text";
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, };
export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, };

View File

@@ -0,0 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PluginMetadata = { version: string, name: string, displayName: string, description: string | null, homepageUrl: string | null, repositoryUrl: string | null, };
export type PluginVersion = { id: string, version: string, url: string, description: string | null, name: string, displayName: string, homepageUrl: string | null, repositoryUrl: string | null, checksum: string, readme: string | null, yanked: boolean, };

View File

@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type JsonValue = number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null;

View File

@@ -0,0 +1,26 @@
import { invoke } from '@tauri-apps/api/core';
import { PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse } from './bindings/gen_api';
export * from './bindings/gen_models';
export * from './bindings/gen_events';
export * from './bindings/gen_search';
export async function searchPlugins(query: string) {
return invoke<PluginSearchResponse>('cmd_plugins_search', { query });
}
export async function installPlugin(name: string, version: string | null) {
return invoke<void>('cmd_plugins_install', { name, version });
}
export async function uninstallPlugin(pluginId: string) {
return invoke<void>('cmd_plugins_uninstall', { pluginId });
}
export async function checkPluginUpdates() {
return invoke<PluginUpdatesResponse>('cmd_plugins_updates', {});
}
export async function updateAllPlugins() {
return invoke<PluginNameVersion[]>('cmd_plugins_update_all', {});
}

View File

@@ -0,0 +1,6 @@
{
"name": "@yaakapp-internal/plugins",
"private": true,
"version": "1.0.0",
"main": "index.ts"
}

View File

@@ -0,0 +1,138 @@
use crate::error::Error::ApiErr;
use crate::error::Result;
use crate::plugin_meta::get_plugin_meta;
use log::{info, warn};
use reqwest::{Client, Response, Url};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::str::FromStr;
use ts_rs::TS;
use yaak_models::models::Plugin;
/// Get plugin info from the registry.
pub async fn get_plugin(
http_client: &Client,
name: &str,
version: Option<String>,
) -> Result<PluginVersion> {
info!("Getting plugin: {name} {version:?}");
let mut url = build_url(&format!("/{name}"));
if let Some(version) = version {
let mut query_pairs = url.query_pairs_mut();
query_pairs.append_pair("version", &version);
};
let resp = http_client.get(url.clone()).send().await?;
if !resp.status().is_success() {
return Err(ApiErr(format!("{} response to {}", resp.status(), url.to_string())));
}
Ok(resp.json().await?)
}
/// Download the plugin archive from the registry.
pub async fn download_plugin_archive(
http_client: &Client,
plugin_version: &PluginVersion,
) -> Result<Response> {
let name = plugin_version.name.clone();
let version = plugin_version.version.clone();
info!("Downloading plugin: {name} {version}");
let mut url = build_url(&format!("/{}/download", name));
{
let mut query_pairs = url.query_pairs_mut();
query_pairs.append_pair("version", &version);
};
let resp = http_client.get(url.clone()).send().await?;
if !resp.status().is_success() {
warn!("Failed to download plugin: {name} {version}");
return Err(ApiErr(format!("{} response to {}", resp.status(), url.to_string())));
}
info!("Downloaded plugin: {url}");
Ok(resp)
}
/// Check for plugin updates.
/// Takes a list of plugins to check against the registry.
pub async fn check_plugin_updates(
http_client: &Client,
plugins: Vec<Plugin>,
) -> Result<PluginUpdatesResponse> {
let name_versions: Vec<PluginNameVersion> = plugins
.into_iter()
.filter(|p| p.url.is_some()) // Only check plugins with URLs (from registry)
.filter_map(|p| match get_plugin_meta(&Path::new(&p.directory)) {
Ok(m) => Some(PluginNameVersion { name: m.name, version: m.version }),
Err(e) => {
warn!("Failed to get plugin metadata: {}", e);
None
}
})
.collect();
let url = build_url("/updates");
let body = serde_json::to_vec(&PluginUpdatesResponse { plugins: name_versions })?;
let resp = http_client.post(url.clone()).body(body).send().await?;
if !resp.status().is_success() {
return Err(ApiErr(format!("{} response to {}", resp.status(), url.to_string())));
}
let results: PluginUpdatesResponse = resp.json().await?;
Ok(results)
}
/// Search for plugins in the registry.
pub async fn search_plugins(
http_client: &Client,
query: &str,
) -> Result<PluginSearchResponse> {
let mut url = build_url("/search");
{
let mut query_pairs = url.query_pairs_mut();
query_pairs.append_pair("query", query);
};
let resp = http_client.get(url).send().await?;
Ok(resp.json().await?)
}
fn build_url(path: &str) -> Url {
let base_url = "https://api.yaak.app/api/v1/plugins";
Url::from_str(&format!("{base_url}{path}")).unwrap()
}
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_search.ts")]
pub struct PluginVersion {
pub id: String,
pub version: String,
pub url: String,
pub description: Option<String>,
pub name: String,
pub display_name: String,
pub homepage_url: Option<String>,
pub repository_url: Option<String>,
pub checksum: String,
pub readme: Option<String>,
pub yanked: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_api.ts")]
pub struct PluginSearchResponse {
pub plugins: Vec<PluginVersion>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_api.ts")]
pub struct PluginNameVersion {
pub name: String,
pub version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_api.ts")]
pub struct PluginUpdatesResponse {
pub plugins: Vec<PluginNameVersion>,
}

View File

@@ -0,0 +1,8 @@
use sha2::{Digest, Sha256};
pub(crate) fn compute_checksum(bytes: impl AsRef<[u8]>) -> String {
let mut hasher = Sha256::new();
hasher.update(&bytes);
let hash = hasher.finalize();
hex::encode(hash)
}

View File

@@ -0,0 +1,64 @@
use crate::events::InternalEvent;
use serde::{Serialize, Serializer};
use thiserror::Error;
use tokio::io;
use tokio::sync::mpsc::error::SendError;
#[derive(Error, Debug)]
pub enum Error {
#[error(transparent)]
CryptoErr(#[from] yaak_crypto::error::Error),
#[error(transparent)]
DbErr(#[from] yaak_models::error::Error),
#[error(transparent)]
TemplateErr(#[from] yaak_templates::error::Error),
#[error("IO error: {0}")]
IoErr(#[from] io::Error),
#[error("Grpc send error: {0}")]
GrpcSendErr(#[from] SendError<InternalEvent>),
#[error("Failed to send request: {0}")]
RequestError(#[from] reqwest::Error),
#[error("JSON error: {0}")]
JsonErr(#[from] serde_json::Error),
#[error("API Error: {0}")]
ApiErr(String),
#[error("Timeout elapsed: {0}")]
TimeoutElapsed(#[from] tokio::time::error::Elapsed),
#[error("Plugin not found: {0}")]
PluginNotFoundErr(String),
#[error("Auth plugin not found: {0}")]
AuthPluginNotFound(String),
#[error("Plugin error: {0}")]
PluginErr(String),
#[error("zip error: {0}")]
ZipError(#[from] zip_extract::ZipExtractError),
#[error("Client not initialized error")]
ClientNotInitializedErr,
#[error("Unknown event received")]
UnknownEventErr,
}
impl Serialize for Error {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
pub type Result<T> = std::result::Result<T, Error>;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,92 @@
use crate::api::{PluginVersion, download_plugin_archive, get_plugin};
use crate::checksum::compute_checksum;
use crate::error::Error::PluginErr;
use crate::error::Result;
use crate::events::PluginContext;
use crate::manager::PluginManager;
use chrono::Utc;
use log::info;
use std::fs::{create_dir_all, remove_dir_all};
use std::io::Cursor;
use std::sync::Arc;
use yaak_models::models::Plugin;
use yaak_models::query_manager::QueryManager;
use yaak_models::util::UpdateSource;
/// Delete a plugin from the database and uninstall it.
pub async fn delete_and_uninstall(
plugin_manager: Arc<PluginManager>,
query_manager: &QueryManager,
plugin_context: &PluginContext,
plugin_id: &str,
) -> Result<Plugin> {
let update_source = match plugin_context.label.clone() {
Some(label) => UpdateSource::from_window_label(label),
None => UpdateSource::Background,
};
// Scope the db connection so it doesn't live across await
let plugin = {
let db = query_manager.connect();
db.delete_plugin_by_id(plugin_id, &update_source)?
};
plugin_manager.uninstall(plugin_context, plugin.directory.as_str()).await?;
Ok(plugin)
}
/// Download and install a plugin.
pub async fn download_and_install(
plugin_manager: Arc<PluginManager>,
query_manager: &QueryManager,
http_client: &reqwest::Client,
plugin_context: &PluginContext,
name: &str,
version: Option<String>,
) -> Result<PluginVersion> {
info!("Installing plugin {} {}", name, version.clone().unwrap_or_default());
let plugin_version = get_plugin(http_client, name, version).await?;
let resp = download_plugin_archive(http_client, &plugin_version).await?;
let bytes = resp.bytes().await?;
let checksum = compute_checksum(&bytes);
if checksum != plugin_version.checksum {
return Err(PluginErr(format!(
"Checksum mismatch {}b {checksum} != {}",
bytes.len(),
plugin_version.checksum
)));
}
info!("Checksum matched {}", checksum);
let plugin_dir = plugin_manager.installed_plugin_dir.join(name);
let plugin_dir_str = plugin_dir.to_str().unwrap().to_string();
// Re-create the plugin directory
let _ = remove_dir_all(&plugin_dir);
create_dir_all(&plugin_dir)?;
zip_extract::extract(Cursor::new(&bytes), &plugin_dir, true)?;
info!("Extracted plugin {} to {}", plugin_version.id, plugin_dir_str);
// Scope the db connection so it doesn't live across await
let plugin = {
let db = query_manager.connect();
db.upsert_plugin(
&Plugin {
id: plugin_version.id.clone(),
checked_at: Some(Utc::now().naive_utc()),
directory: plugin_dir_str.clone(),
enabled: true,
url: Some(plugin_version.url.clone()),
..Default::default()
},
&UpdateSource::Background,
)?
};
plugin_manager.add_plugin(plugin_context, &plugin).await?;
info!("Installed plugin {} to {}", plugin_version.id, plugin_dir_str);
Ok(plugin_version)
}

View File

@@ -0,0 +1,21 @@
//! Core plugin system for Yaak.
//!
//! This crate provides the plugin manager and supporting functionality
//! for running JavaScript plugins via a Node.js runtime.
//!
//! Note: This crate is Tauri-independent. Tauri integration is provided
//! by yaak-app's plugins_ext module.
pub mod api;
mod checksum;
pub mod error;
pub mod events;
pub mod install;
pub mod manager;
pub mod native_template_functions;
mod nodejs;
pub mod plugin_handle;
pub mod plugin_meta;
mod server_ws;
pub mod template_callback;
mod util;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,259 @@
//! Native template functions implemented in Rust.
//!
//! These are built-in template functions that don't require plugins:
//! - `secure()` - encrypts/decrypts values using the EncryptionManager
//! - `keychain()` / `keyring()` - accesses system keychain
use crate::events::{
Color, FormInput, FormInputBanner, FormInputBase, FormInputMarkdown, FormInputText,
PluginContext, RenderPurpose, TemplateFunction, TemplateFunctionArg,
TemplateFunctionPreviewType,
};
use crate::manager::PluginManager;
use crate::template_callback::PluginTemplateCallback;
use base64::Engine;
use base64::prelude::BASE64_STANDARD;
use keyring::Error::NoEntry;
use log::{debug, info};
use std::collections::HashMap;
use std::sync::Arc;
use yaak_common::platform::{OperatingSystem, get_os};
use yaak_crypto::manager::EncryptionManager;
use yaak_templates::error::Error::RenderError;
use yaak_templates::error::Result;
use yaak_templates::{FnArg, Parser, Token, Tokens, Val, transform_args};
pub(crate) fn template_function_secure() -> TemplateFunction {
TemplateFunction {
name: "secure".to_string(),
preview_type: Some(TemplateFunctionPreviewType::None),
description: Some("Securely store encrypted text".to_string()),
aliases: None,
preview_args: None,
args: vec![TemplateFunctionArg::FormInput(FormInput::Text(
FormInputText {
multi_line: Some(true),
password: Some(true),
base: FormInputBase {
name: "value".to_string(),
label: Some("Value".to_string()),
..Default::default()
},
..Default::default()
},
))],
}
}
pub(crate) fn template_function_keyring() -> TemplateFunction {
struct Meta {
description: String,
service_label: String,
account_label: String,
}
let meta = match get_os() {
OperatingSystem::MacOS => Meta {
description:
"Access application passwords from the macOS Login keychain".to_string(),
service_label: "Where".to_string(),
account_label: "Account".to_string(),
},
OperatingSystem::Windows => Meta {
description: "Access a secret via Windows Credential Manager".to_string(),
service_label: "Target".to_string(),
account_label: "Username".to_string(),
},
_ => Meta {
description: "Access a secret via [Secret Service](https://specifications.freedesktop.org/secret-service/latest/) (eg. Gnome keyring or KWallet)".to_string(),
service_label: "Collection".to_string(),
account_label: "Account".to_string(),
},
};
TemplateFunction {
name: "keychain".to_string(),
preview_type: Some(TemplateFunctionPreviewType::Live),
description: Some(meta.description),
aliases: Some(vec!["keyring".to_string()]),
preview_args: Some(vec!["service".to_string(), "account".to_string()]),
args: vec![
TemplateFunctionArg::FormInput(FormInput::Banner(FormInputBanner {
inputs: Some(vec![FormInput::Markdown(FormInputMarkdown {
content: "For most cases, prefer the [`secure(…)`](https://yaak.app/help/encryption) template function, which encrypts values using a key stored in keychain".to_string(),
hidden: None,
})]),
color: Some(Color::Info),
hidden: None,
})),
TemplateFunctionArg::FormInput(FormInput::Text(FormInputText {
base: FormInputBase {
name: "service".to_string(),
label: Some(meta.service_label),
description: Some("App or URL for the password".to_string()),
..Default::default()
},
..Default::default()
})),
TemplateFunctionArg::FormInput(FormInput::Text(FormInputText {
base: FormInputBase {
name: "account".to_string(),
label: Some(meta.account_label),
description: Some("Username or email address".to_string()),
..Default::default()
},
..Default::default()
})),
],
}
}
pub fn template_function_secure_run(
encryption_manager: &EncryptionManager,
args: HashMap<String, serde_json::Value>,
plugin_context: &PluginContext,
) -> Result<String> {
match plugin_context.workspace_id.clone() {
Some(wid) => {
let value = args.get("value").map(|v| v.to_owned()).unwrap_or_default();
let value = match value {
serde_json::Value::String(s) => s,
_ => return Ok("".to_string()),
};
if value.is_empty() {
return Ok("".to_string());
}
let value = match value.strip_prefix("YENC_") {
None => {
return Err(RenderError("Could not decrypt non-encrypted value".to_string()));
}
Some(v) => v,
};
let value = BASE64_STANDARD.decode(&value).unwrap();
let r = match encryption_manager.decrypt(&wid, value.as_slice()) {
Ok(r) => Ok(r),
Err(e) => Err(RenderError(e.to_string())),
}?;
let r = String::from_utf8(r).map_err(|e| RenderError(e.to_string()))?;
Ok(r)
}
_ => Err(RenderError("workspace_id missing from plugin context".to_string())),
}
}
pub fn template_function_secure_transform_arg(
encryption_manager: &EncryptionManager,
plugin_context: &PluginContext,
arg_name: &str,
arg_value: &str,
) -> Result<String> {
if arg_name != "value" {
return Ok(arg_value.to_string());
}
match plugin_context.workspace_id.clone() {
Some(wid) => {
if arg_value.is_empty() {
return Ok("".to_string());
}
if arg_value.starts_with("YENC_") {
// Already encrypted, so do nothing
return Ok(arg_value.to_string());
}
let r = encryption_manager
.encrypt(&wid, arg_value.as_bytes())
.map_err(|e| RenderError(e.to_string()))?;
let r = BASE64_STANDARD.encode(r);
Ok(format!("YENC_{}", r))
}
_ => Err(RenderError("workspace_id missing from plugin context".to_string())),
}
}
pub fn decrypt_secure_template_function(
encryption_manager: &EncryptionManager,
plugin_context: &PluginContext,
template: &str,
) -> Result<String> {
let mut parsed = Parser::new(template).parse()?;
let mut new_tokens: Vec<Token> = Vec::new();
for token in parsed.tokens.iter() {
match token {
Token::Tag { val: Val::Fn { name, args } } if name == "secure" => {
let mut args_map = HashMap::new();
for a in args {
match a.clone().value {
Val::Str { text } => {
args_map.insert(a.name.to_string(), serde_json::Value::String(text));
}
_ => continue,
}
}
new_tokens.push(Token::Raw {
text: template_function_secure_run(encryption_manager, args_map, plugin_context)?,
});
}
t => {
new_tokens.push(t.clone());
continue;
}
};
}
parsed.tokens = new_tokens;
Ok(parsed.to_string())
}
pub fn encrypt_secure_template_function(
plugin_manager: Arc<PluginManager>,
encryption_manager: Arc<EncryptionManager>,
plugin_context: &PluginContext,
template: &str,
) -> Result<String> {
let decrypted = decrypt_secure_template_function(&encryption_manager, plugin_context, template)?;
let tokens = Tokens {
tokens: vec![Token::Tag {
val: Val::Fn {
name: "secure".to_string(),
args: vec![FnArg {
name: "value".to_string(),
value: Val::Str { text: decrypted },
}],
},
}],
};
Ok(transform_args(
tokens,
&PluginTemplateCallback::new(plugin_manager, encryption_manager, plugin_context, RenderPurpose::Preview),
)?
.to_string())
}
pub fn template_function_keychain_run(args: HashMap<String, serde_json::Value>) -> Result<String> {
let service = args.get("service").and_then(|v| v.as_str()).unwrap_or_default().to_owned();
let user = args.get("account").and_then(|v| v.as_str()).unwrap_or_default().to_owned();
debug!("Getting password for service {} and user {}", service, user);
let entry = match keyring::Entry::new(&service, &user) {
Ok(e) => e,
Err(e) => {
debug!("Failed to initialize keyring entry for '{}' and '{}' {:?}", service, user, e);
return Ok("".to_string()); // Don't fail for invalid args
}
};
match entry.get_password() {
Ok(p) => Ok(p),
Err(NoEntry) => {
info!("No password found for '{}' and '{}'", service, user);
Ok("".to_string()) // Don't fail for missing passwords
}
Err(e) => Err(RenderError(e.to_string())),
}
}

View File

@@ -0,0 +1,77 @@
use crate::error::Result;
use log::{info, warn};
use std::net::SocketAddr;
use std::path::Path;
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use tokio::sync::watch::Receiver;
/// Start the Node.js plugin runtime process.
///
/// # Arguments
/// * `node_bin_path` - Path to the yaaknode binary
/// * `plugin_runtime_main` - Path to the plugin runtime index.cjs
/// * `addr` - Socket address for the plugin runtime to connect to
/// * `kill_rx` - Channel to signal shutdown
pub async fn start_nodejs_plugin_runtime(
node_bin_path: &Path,
plugin_runtime_main: &Path,
addr: SocketAddr,
kill_rx: &Receiver<bool>,
) -> Result<()> {
// HACK: Remove UNC prefix for Windows paths to pass to sidecar
let plugin_runtime_main_str =
dunce::simplified(plugin_runtime_main).to_string_lossy().to_string();
info!(
"Starting plugin runtime node={} main={}",
node_bin_path.display(),
plugin_runtime_main_str
);
let mut child = Command::new(node_bin_path)
.env("HOST", addr.ip().to_string())
.env("PORT", addr.port().to_string())
.arg(&plugin_runtime_main_str)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
info!("Spawned plugin runtime");
// Stream stdout
if let Some(stdout) = child.stdout.take() {
tokio::spawn(async move {
let reader = BufReader::new(stdout);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
info!("{}", line);
}
});
}
// Stream stderr
if let Some(stderr) = child.stderr.take() {
tokio::spawn(async move {
let reader = BufReader::new(stderr);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
warn!("{}", line);
}
});
}
// Handle kill signal
let mut kill_rx = kill_rx.clone();
tokio::spawn(async move {
kill_rx.wait_for(|b| *b == true).await.expect("Kill channel errored");
info!("Killing plugin runtime");
if let Err(e) = child.kill().await {
warn!("Failed to kill plugin runtime: {e}");
}
info!("Killed plugin runtime");
});
Ok(())
}

View File

@@ -0,0 +1,66 @@
use crate::error::Result;
use crate::events::{InternalEvent, InternalEventPayload, PluginContext};
use crate::plugin_meta::{PluginMetadata, get_plugin_meta};
use crate::util::gen_id;
use std::path::Path;
use std::sync::Arc;
use tokio::sync::{Mutex, mpsc};
#[derive(Clone)]
pub struct PluginHandle {
pub ref_id: String,
pub dir: String,
pub enabled: bool,
pub(crate) to_plugin_tx: Arc<Mutex<mpsc::Sender<InternalEvent>>>,
pub(crate) metadata: PluginMetadata,
}
impl PluginHandle {
pub fn new(dir: &str, enabled: bool, tx: mpsc::Sender<InternalEvent>) -> Result<Self> {
let ref_id = gen_id();
let metadata = get_plugin_meta(&Path::new(dir))?;
Ok(PluginHandle {
ref_id: ref_id.clone(),
dir: dir.to_string(),
to_plugin_tx: Arc::new(Mutex::new(tx)),
enabled,
metadata,
})
}
pub fn info(&self) -> PluginMetadata {
self.metadata.clone()
}
pub fn build_event_to_send(
&self,
plugin_context: &PluginContext,
payload: &InternalEventPayload,
reply_id: Option<String>,
) -> InternalEvent {
self.build_event_to_send_raw(plugin_context, payload, reply_id)
}
pub(crate) fn build_event_to_send_raw(
&self,
plugin_context: &PluginContext,
payload: &InternalEventPayload,
reply_id: Option<String>,
) -> InternalEvent {
let dir = Path::new(&self.dir);
InternalEvent {
id: gen_id(),
plugin_ref_id: self.ref_id.clone(),
plugin_name: dir.file_name().unwrap().to_str().unwrap().to_string(),
reply_id,
payload: payload.clone(),
context: plugin_context.clone(),
}
}
pub async fn send(&self, event: &InternalEvent) -> Result<()> {
self.to_plugin_tx.lock().await.send(event.to_owned()).await?;
Ok(())
}
}

View File

@@ -0,0 +1,64 @@
use crate::error::Result;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use ts_rs::TS;
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_search.ts")]
pub struct PluginMetadata {
pub version: String,
pub name: String,
pub display_name: String,
pub description: Option<String>,
pub homepage_url: Option<String>,
pub repository_url: Option<String>,
}
pub fn get_plugin_meta(plugin_dir: &Path) -> Result<PluginMetadata> {
let package_json = fs::File::open(plugin_dir.join("package.json"))?;
let package_json: PackageJson = serde_json::from_reader(package_json)?;
let display_name = match package_json.display_name {
None => {
let display_name = package_json.name.to_string();
let display_name = display_name.split('/').last().unwrap_or(&package_json.name);
let display_name = display_name.strip_prefix("yaak-plugin-").unwrap_or(&display_name);
let display_name = display_name.strip_prefix("yaak-").unwrap_or(&display_name);
display_name.to_string()
}
Some(n) => n,
};
Ok(PluginMetadata {
version: package_json.version,
description: package_json.description,
name: package_json.name,
display_name,
homepage_url: package_json.homepage,
repository_url: match package_json.repository {
None => None,
Some(RepositoryField::Object { url }) => Some(url),
Some(RepositoryField::String(url)) => Some(url),
},
})
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PackageJson {
pub name: String,
pub display_name: Option<String>,
pub version: String,
pub repository: Option<RepositoryField>,
pub homepage: Option<String>,
pub description: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum RepositoryField {
String(String),
Object { url: String },
}

View File

@@ -0,0 +1,150 @@
use crate::events::{ErrorResponse, InternalEvent, InternalEventPayload, InternalEventRawPayload};
use futures_util::{SinkExt, StreamExt};
use log::{error, info, warn};
use std::sync::Arc;
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{Mutex, mpsc};
use tokio_tungstenite::accept_async_with_config;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::protocol::WebSocketConfig;
#[derive(Clone)]
pub(crate) struct PluginRuntimeServerWebsocket {
pub(crate) app_to_plugin_events_tx: Arc<Mutex<Option<mpsc::Sender<InternalEvent>>>>,
client_disconnect_tx: mpsc::Sender<bool>,
client_connect_tx: tokio::sync::watch::Sender<bool>,
plugin_to_app_events_tx: mpsc::Sender<InternalEvent>,
}
impl PluginRuntimeServerWebsocket {
pub fn new(
events_tx: mpsc::Sender<InternalEvent>,
disconnect_tx: mpsc::Sender<bool>,
connect_tx: tokio::sync::watch::Sender<bool>,
) -> Self {
PluginRuntimeServerWebsocket {
app_to_plugin_events_tx: Arc::new(Mutex::new(None)),
client_disconnect_tx: disconnect_tx,
client_connect_tx: connect_tx,
plugin_to_app_events_tx: events_tx,
}
}
pub async fn listen(&self, listener: TcpListener) {
while let Ok((stream, _)) = listener.accept().await {
self.accept_connection(stream).await;
}
}
async fn accept_connection(&self, stream: TcpStream) {
let (to_plugin_tx, mut to_plugin_rx) = mpsc::channel::<InternalEvent>(2048);
let mut app_to_plugin_events_tx = self.app_to_plugin_events_tx.lock().await;
*app_to_plugin_events_tx = Some(to_plugin_tx);
let plugin_to_app_events_tx = self.plugin_to_app_events_tx.clone();
let client_disconnect_tx = self.client_disconnect_tx.clone();
let client_connect_tx = self.client_connect_tx.clone();
let addr = stream.peer_addr().expect("connected streams should have a peer address");
let conf = WebSocketConfig::default();
let ws_stream = accept_async_with_config(stream, Some(conf))
.await
.expect("Error during the websocket handshake occurred");
let (mut ws_sender, mut ws_receiver) = ws_stream.split();
tokio::spawn(async move {
client_connect_tx.send(true).expect("Failed to send client ready event");
info!("New plugin runtime websocket connection: {}", addr);
loop {
tokio::select! {
msg = ws_receiver.next() => {
let msg = match msg {
Some(Ok(msg)) => msg,
Some(Err(e)) => {
warn!("Websocket error {e:?}");
continue;
}
None => break,
};
// Skip non-text messages
if !msg.is_text() {
warn!("Received non-text message from plugin runtime");
continue;
}
let msg_text = match msg.into_text() {
Ok(text) => text,
Err(e) => {
error!("Failed to convert message to text: {e:?}");
continue;
}
};
let event = match serde_json::from_str::<InternalEventRawPayload>(&msg_text) {
Ok(e) => e,
Err(e) => {
error!("Failed to decode plugin event {e:?} -> {msg_text}");
continue;
}
};
// Parse everything but the payload so we can catch errors on that, specifically
let payload = serde_json::from_value::<InternalEventPayload>(event.payload.clone())
.unwrap_or_else(|e| {
warn!("Plugin event parse error from {}: {:?} {}", event.plugin_name, e, event.payload);
InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Plugin event parse error from {}: {e:?}", event.plugin_name),
})
});
let event = InternalEvent{
id: event.id,
payload,
plugin_ref_id: event.plugin_ref_id,
plugin_name: event.plugin_name,
context: event.context,
reply_id: event.reply_id,
};
// Send event to subscribers
// Emit event to the channel for server to handle
if let Err(e) = plugin_to_app_events_tx.try_send(event) {
warn!("Failed to send to channel. Receiver probably isn't listening: {:?}", e);
}
}
event_for_plugin = to_plugin_rx.recv() => {
match event_for_plugin {
None => {
error!("Plugin runtime client WS channel closed");
return;
},
Some(event) => {
let event_bytes = match serde_json::to_string(&event) {
Ok(bytes) => bytes,
Err(e) => {
error!("Failed to serialize event: {:?}", e);
continue;
}
};
let msg = Message::text(event_bytes);
if let Err(e) = ws_sender.send(msg).await {
error!("Failed to send message to plugin runtime: {:?}", e);
break;
}
}
}
}
}
}
if let Err(e) = client_disconnect_tx.send(true).await {
warn!("Failed to send killed event {:?}", e);
}
});
}
}

View File

@@ -0,0 +1,82 @@
//! Plugin template callback implementation.
//!
//! This provides a TemplateCallback implementation that delegates to plugins
//! for template function execution.
use crate::events::{JsonPrimitive, PluginContext, RenderPurpose};
use crate::manager::PluginManager;
use crate::native_template_functions::{
template_function_keychain_run, template_function_secure_run,
template_function_secure_transform_arg,
};
use std::collections::HashMap;
use std::sync::Arc;
use yaak_crypto::manager::EncryptionManager;
use yaak_templates::TemplateCallback;
use yaak_templates::error::Result;
#[derive(Clone)]
pub struct PluginTemplateCallback {
plugin_manager: Arc<PluginManager>,
encryption_manager: Arc<EncryptionManager>,
render_purpose: RenderPurpose,
plugin_context: PluginContext,
}
impl PluginTemplateCallback {
pub fn new(
plugin_manager: Arc<PluginManager>,
encryption_manager: Arc<EncryptionManager>,
plugin_context: &PluginContext,
render_purpose: RenderPurpose,
) -> PluginTemplateCallback {
PluginTemplateCallback {
plugin_manager,
encryption_manager,
render_purpose,
plugin_context: plugin_context.to_owned(),
}
}
}
impl TemplateCallback for PluginTemplateCallback {
async fn run(&self, fn_name: &str, args: HashMap<String, serde_json::Value>) -> Result<String> {
// The beta named the function `Response` but was changed in stable.
// Keep this here for a while because there's no easy way to migrate
let fn_name = if fn_name == "Response" { "response" } else { fn_name };
if fn_name == "secure" {
return template_function_secure_run(&self.encryption_manager, args, &self.plugin_context);
} else if fn_name == "keychain" || fn_name == "keyring" {
return template_function_keychain_run(args);
}
let mut primitive_args = HashMap::new();
for (key, value) in args {
primitive_args.insert(key, JsonPrimitive::from(value));
}
let resp = self.plugin_manager
.call_template_function(
&self.plugin_context,
fn_name,
primitive_args,
self.render_purpose.to_owned(),
)
.await?;
Ok(resp)
}
fn transform_arg(&self, fn_name: &str, arg_name: &str, arg_value: &str) -> Result<String> {
if fn_name == "secure" {
return template_function_secure_transform_arg(
&self.encryption_manager,
&self.plugin_context,
arg_name,
arg_value,
);
}
Ok(arg_value.to_string())
}
}

View File

@@ -0,0 +1,6 @@
use rand::Rng;
use rand::distr::Alphanumeric;
pub fn gen_id() -> String {
rand::rng().sample_iter(&Alphanumeric).take(5).map(char::from).collect()
}