snapshot of https://github.com/mountain-loop/yaak.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Lint and Test / Lint/Test (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-23 17:39:41 +05:00
commit 906fb231cc
1544 changed files with 164131 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
[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"
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 = "7"

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>, };

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

File diff suppressed because one or more lines are too long

514
crates/yaak-plugins/bindings/gen_models.ts generated Normal file
View File

@@ -0,0 +1,514 @@
// 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
| ImportSource
| 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 = {
name: string;
value: string;
domain: CookieDomain;
expires: CookieExpires;
path: string;
secure: boolean;
httpOnly: boolean;
sameSite: CookieSameSite | null;
};
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 CookieSameSite = "Strict" | "Lax" | "None";
export type DnsOverride = {
hostname: string;
ipv4: Array<string>;
ipv6: Array<string>;
enabled?: boolean;
};
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 defined in this environment scope.
* Child environments override parent variables by name.
*/
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;
settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
settingHttpVersion: InheritedHttpVersionSetting;
};
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;
/**
* Server URL (http for plaintext or https for secure)
*/
url: string;
settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
};
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;
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>;
settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
settingHttpVersion: InheritedHttpVersionSetting;
};
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;
contentLength: number | null;
contentLengthCompressed: number | null;
elapsed: number;
elapsedHeaders: number;
elapsedDns: 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;
source_model?: string;
source_id?: string;
source_name?: string;
}
| { type: "info"; message: string }
| {
type: "redirect";
url: string;
status: number;
behavior: string;
dropped_body: boolean;
dropped_headers: Array<string>;
}
| {
type: "send_url";
method: string;
scheme: string;
username: string;
password: string;
host: string;
port: number;
path: string;
query: string;
fragment: 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 }
| {
type: "dns_resolved";
hostname: string;
addresses: Array<string>;
duration: bigint;
overridden: boolean;
};
export type HttpResponseHeader = { name: string; value: string };
export type HttpResponseState = "initialized" | "connected" | "closed";
export type HttpUrlParameter = {
enabled?: boolean;
/**
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
* Other entries are appended as query parameters
*/
name: string;
value: string;
id?: string;
};
export type HttpVersion = "auto" | "http1" | "http2";
export type ImportSource = {
model: "import_source";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
importer: string;
origin: string;
originLabel: string;
lastImportedAt: string;
};
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
export type InheritedIntSetting = { enabled?: boolean; value: number };
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;
source: PluginSource;
};
export type PluginSource = "bundled" | "filesystem" | "registry";
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;
promptFeedback: 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"
| "error"
| "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;
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>;
settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
};
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;
settingRequestMessageSize: number;
settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean;
settingStoreCookies: boolean;
settingHttpVersion: HttpVersion;
};
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,30 @@
import { platform } from "@yaakapp-internal/platform";
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 platform.rpc<PluginSearchResponse>("cmd_plugins_search", { query });
}
export async function installPlugin(name: string, version: string | null) {
return platform.rpc<void>("cmd_plugins_install", { name, version });
}
export async function uninstallPlugin(pluginId: string) {
return platform.rpc<void>("cmd_plugins_uninstall", { pluginId });
}
export async function checkPluginUpdates() {
return platform.rpc<PluginUpdatesResponse>("cmd_plugins_updates", {});
}
export async function updateAllPlugins() {
return platform.rpc<PluginNameVersion[]>("cmd_plugins_update_all", {});
}
export async function installPluginFromDirectory(directory: string) {
return platform.rpc<void>("cmd_plugins_install_from_directory", { directory });
}

View File

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

View File

@@ -0,0 +1,135 @@
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, PluginSource};
/// 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| matches!(p.source, PluginSource::Registry)) // Only check registry-installed plugins
.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::result::ZipError),
#[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, PluginNotFoundErr};
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, PluginSource};
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,
};
let plugin = query_manager.with_tx(|db| db.delete_plugin_by_id(plugin_id, &update_source))?;
if let Err(err) = plugin_manager.uninstall(plugin_context, plugin.directory.as_str()).await {
if !matches!(err, PluginNotFoundErr(_)) {
return Err(err);
}
}
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::ZipArchive::new(Cursor::new(&bytes))?
.extract_unwrapped_root_dir(&plugin_dir, zip::read::root_dir_common_filter)?;
info!("Extracted plugin {} to {}", plugin_version.id, plugin_dir_str);
let plugin = query_manager.with_tx(|db| {
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()),
source: PluginSource::Registry,
..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,269 @@
//! 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,98 @@
use crate::error::Result;
use log::{error, info, warn};
use std::net::SocketAddr;
use std::path::Path;
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::sync::oneshot;
use tokio::sync::watch::Receiver;
use yaak_common::command::new_xplatform_command;
/// 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
/// * `killed_tx` - Notified once the runtime is killed after a shutdown signal
/// * `unexpected_exit_tx` - Set to the exit status if the runtime exits
/// without being asked to
pub async fn start_nodejs_plugin_runtime(
node_bin_path: &Path,
plugin_runtime_main: &Path,
addr: SocketAddr,
kill_rx: &Receiver<bool>,
killed_tx: oneshot::Sender<()>,
unexpected_exit_tx: tokio::sync::watch::Sender<Option<String>>,
) -> 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 cmd = new_xplatform_command(node_bin_path);
cmd.env("HOST", addr.ip().to_string())
.env("PORT", addr.port().to_string())
.arg(&plugin_runtime_main_str)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.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);
}
});
}
// Wait for either an explicit kill signal or the child exiting on its own.
// An unexpected exit is reported to the caller, which decides whether to
// abort startup or surface a user-facing error.
let mut kill_rx = kill_rx.clone();
tokio::spawn(async move {
tokio::select! {
status = child.wait() => {
let status = status.map(|s| s.to_string()).unwrap_or_else(|e| e.to_string());
error!("Plugin runtime exited unexpectedly ({status})");
let _ = unexpected_exit_tx.send(Some(status));
}
closed = async { kill_rx.wait_for(|b| *b == true).await.is_err() } => {
if closed {
warn!("Kill channel closed before explicit shutdown; terminating plugin runtime");
}
info!("Killing plugin runtime");
if let Err(e) = child.kill().await {
warn!("Failed to kill plugin runtime: {e}");
}
info!("Killed plugin runtime");
let _ = killed_tx.send(());
}
}
});
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,87 @@
//! 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()
}