mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-23 00:58:32 +02:00
Decouple core Yaak logic from Tauri (#354)
This commit is contained in:
31
crates/yaak-plugins/Cargo.toml
Normal file
31
crates/yaak-plugins/Cargo.toml
Normal 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
8
crates/yaak-plugins/bindings/gen_api.ts
generated
Normal 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
550
crates/yaak-plugins/bindings/gen_events.ts
generated
Normal file
File diff suppressed because one or more lines are too long
82
crates/yaak-plugins/bindings/gen_models.ts
generated
Normal file
82
crates/yaak-plugins/bindings/gen_models.ts
generated
Normal 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, };
|
||||
5
crates/yaak-plugins/bindings/gen_search.ts
generated
Normal file
5
crates/yaak-plugins/bindings/gen_search.ts
generated
Normal 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, };
|
||||
3
crates/yaak-plugins/bindings/serde_json/JsonValue.ts
Normal file
3
crates/yaak-plugins/bindings/serde_json/JsonValue.ts
Normal 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;
|
||||
26
crates/yaak-plugins/index.ts
Normal file
26
crates/yaak-plugins/index.ts
Normal 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', {});
|
||||
}
|
||||
6
crates/yaak-plugins/package.json
Normal file
6
crates/yaak-plugins/package.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "@yaakapp-internal/plugins",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"main": "index.ts"
|
||||
}
|
||||
138
crates/yaak-plugins/src/api.rs
Normal file
138
crates/yaak-plugins/src/api.rs
Normal 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>,
|
||||
}
|
||||
8
crates/yaak-plugins/src/checksum.rs
Normal file
8
crates/yaak-plugins/src/checksum.rs
Normal 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)
|
||||
}
|
||||
64
crates/yaak-plugins/src/error.rs
Normal file
64
crates/yaak-plugins/src/error.rs
Normal 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>;
|
||||
1455
crates/yaak-plugins/src/events.rs
Normal file
1455
crates/yaak-plugins/src/events.rs
Normal file
File diff suppressed because it is too large
Load Diff
92
crates/yaak-plugins/src/install.rs
Normal file
92
crates/yaak-plugins/src/install.rs
Normal 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)
|
||||
}
|
||||
21
crates/yaak-plugins/src/lib.rs
Normal file
21
crates/yaak-plugins/src/lib.rs
Normal 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;
|
||||
1035
crates/yaak-plugins/src/manager.rs
Normal file
1035
crates/yaak-plugins/src/manager.rs
Normal file
File diff suppressed because it is too large
Load Diff
259
crates/yaak-plugins/src/native_template_functions.rs
Normal file
259
crates/yaak-plugins/src/native_template_functions.rs
Normal 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 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())),
|
||||
}
|
||||
}
|
||||
77
crates/yaak-plugins/src/nodejs.rs
Normal file
77
crates/yaak-plugins/src/nodejs.rs
Normal 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(())
|
||||
}
|
||||
66
crates/yaak-plugins/src/plugin_handle.rs
Normal file
66
crates/yaak-plugins/src/plugin_handle.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
64
crates/yaak-plugins/src/plugin_meta.rs
Normal file
64
crates/yaak-plugins/src/plugin_meta.rs
Normal 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 },
|
||||
}
|
||||
150
crates/yaak-plugins/src/server_ws.rs
Normal file
150
crates/yaak-plugins/src/server_ws.rs
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
82
crates/yaak-plugins/src/template_callback.rs
Normal file
82
crates/yaak-plugins/src/template_callback.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
6
crates/yaak-plugins/src/util.rs
Normal file
6
crates/yaak-plugins/src/util.rs
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user