mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-05-17 13:17:01 +02:00
Split codebase (#455)
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
use crate::PluginContextExt;
|
||||
use crate::error::Result;
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Manager, Runtime, State, WebviewWindow, command};
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_models::models::HttpRequestHeader;
|
||||
use yaak_models::queries::workspaces::default_headers;
|
||||
use yaak_plugins::events::GetThemesResponse;
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_plugins::native_template_functions::{
|
||||
decrypt_secure_template_function, encrypt_secure_template_function,
|
||||
};
|
||||
|
||||
/// Extension trait for accessing the EncryptionManager from Tauri Manager types.
|
||||
pub trait EncryptionManagerExt<'a, R> {
|
||||
fn crypto(&'a self) -> State<'a, EncryptionManager>;
|
||||
}
|
||||
|
||||
impl<'a, R: Runtime, M: Manager<R>> EncryptionManagerExt<'a, R> for M {
|
||||
fn crypto(&'a self) -> State<'a, EncryptionManager> {
|
||||
self.state::<EncryptionManager>()
|
||||
}
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn cmd_decrypt_template<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
template: &str,
|
||||
) -> Result<String> {
|
||||
let encryption_manager = window.app_handle().state::<EncryptionManager>();
|
||||
let plugin_context = window.plugin_context();
|
||||
Ok(decrypt_secure_template_function(&encryption_manager, &plugin_context, template)?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn cmd_secure_template<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
window: WebviewWindow<R>,
|
||||
template: &str,
|
||||
) -> Result<String> {
|
||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
let plugin_context = window.plugin_context();
|
||||
Ok(encrypt_secure_template_function(
|
||||
plugin_manager,
|
||||
encryption_manager,
|
||||
&plugin_context,
|
||||
template,
|
||||
)?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn cmd_get_themes<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> Result<Vec<GetThemesResponse>> {
|
||||
Ok(plugin_manager.get_themes(&window.plugin_context()).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn cmd_enable_encryption<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
workspace_id: &str,
|
||||
) -> Result<()> {
|
||||
window.crypto().ensure_workspace_key(workspace_id)?;
|
||||
window.crypto().reveal_workspace_key(workspace_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn cmd_reveal_workspace_key<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
workspace_id: &str,
|
||||
) -> Result<String> {
|
||||
Ok(window.crypto().reveal_workspace_key(workspace_id)?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn cmd_set_workspace_key<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
workspace_id: &str,
|
||||
key: &str,
|
||||
) -> Result<()> {
|
||||
window.crypto().set_human_key(workspace_id, key)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn cmd_disable_encryption<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
workspace_id: &str,
|
||||
) -> Result<()> {
|
||||
window.crypto().disable_encryption(workspace_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) fn cmd_default_headers() -> Vec<HttpRequestHeader> {
|
||||
default_headers()
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
use mime_guess::{Mime, mime};
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
use tokio::fs;
|
||||
|
||||
pub async fn read_response_body(body_path: impl AsRef<Path>, content_type: &str) -> Option<String> {
|
||||
let body = fs::read(body_path).await.ok()?;
|
||||
let body_charset = parse_charset(content_type).unwrap_or("utf-8".to_string());
|
||||
if let Some(decoder) = charset::Charset::for_label(body_charset.as_bytes()) {
|
||||
let (cow, _real_encoding, _exist_replace) = decoder.decode(&body);
|
||||
return cow.into_owned().into();
|
||||
}
|
||||
|
||||
Some(String::from_utf8_lossy(&body).to_string())
|
||||
}
|
||||
|
||||
fn parse_charset(content_type: &str) -> Option<String> {
|
||||
let mime: Mime = Mime::from_str(content_type).ok()?;
|
||||
mime.get_param(mime::CHARSET).map(|v| v.to_string())
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use serde::{Serialize, Serializer};
|
||||
use std::io;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error(transparent)]
|
||||
TemplateError(#[from] yaak_templates::error::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
ModelError(#[from] yaak_models::error::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
SyncError(#[from] yaak_sync::error::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
CryptoError(#[from] yaak_crypto::error::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
HttpError(#[from] yaak_http::error::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
GitError(#[from] yaak_git::error::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
TokioTimeoutElapsed(#[from] tokio::time::error::Elapsed),
|
||||
|
||||
#[error(transparent)]
|
||||
WebsocketError(#[from] yaak_ws::error::Error),
|
||||
|
||||
#[cfg(feature = "license")]
|
||||
#[error(transparent)]
|
||||
LicenseError(#[from] yaak_license::error::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
PluginError(#[from] yaak_plugins::error::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
ApiError(#[from] yaak_api::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
ClipboardError(#[from] tauri_plugin_clipboard_manager::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
OpenerError(#[from] tauri_plugin_opener::Error),
|
||||
|
||||
#[error("Updater error: {0}")]
|
||||
UpdaterError(#[from] tauri_plugin_updater::Error),
|
||||
|
||||
#[error("JSON error: {0}")]
|
||||
JsonError(#[from] serde_json::error::Error),
|
||||
|
||||
#[error("Tauri error: {0}")]
|
||||
TauriError(#[from] tauri::Error),
|
||||
|
||||
#[error("Event source error: {0}")]
|
||||
EventSourceError(#[from] eventsource_client::Error),
|
||||
|
||||
#[error("I/O error: {0}")]
|
||||
IOError(#[from] io::Error),
|
||||
|
||||
#[error("Request error: {0}")]
|
||||
RequestError(#[from] reqwest::Error),
|
||||
|
||||
#[error("{0}")]
|
||||
GenericError(String),
|
||||
}
|
||||
|
||||
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>;
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Tauri-specific extensions for yaak-git.
|
||||
//!
|
||||
//! This module provides the Tauri commands for git functionality.
|
||||
|
||||
use crate::error::Result;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::command;
|
||||
use yaak_git::{
|
||||
BranchDeleteResult, CloneResult, GitCommit, GitRemote, GitStatusSummary, PullResult,
|
||||
PushResult, git_add, git_add_credential, git_add_remote, git_checkout_branch, git_clone,
|
||||
git_commit, git_create_branch, git_delete_branch, git_delete_remote_branch, git_fetch_all,
|
||||
git_init, git_log, git_merge_branch, git_pull, git_pull_force_reset, git_pull_merge, git_push,
|
||||
git_remotes, git_rename_branch, git_reset_changes, git_rm_remote, git_status, git_unstage,
|
||||
};
|
||||
|
||||
// NOTE: All of these commands are async to prevent blocking work from locking up the UI
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_checkout(dir: &Path, branch: &str, force: bool) -> Result<String> {
|
||||
Ok(git_checkout_branch(dir, branch, force).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_branch(dir: &Path, branch: &str, base: Option<&str>) -> Result<()> {
|
||||
Ok(git_create_branch(dir, branch, base).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_delete_branch(
|
||||
dir: &Path,
|
||||
branch: &str,
|
||||
force: Option<bool>,
|
||||
) -> Result<BranchDeleteResult> {
|
||||
Ok(git_delete_branch(dir, branch, force.unwrap_or(false)).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_delete_remote_branch(dir: &Path, branch: &str) -> Result<()> {
|
||||
Ok(git_delete_remote_branch(dir, branch).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_merge_branch(dir: &Path, branch: &str) -> Result<()> {
|
||||
Ok(git_merge_branch(dir, branch).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_rename_branch(dir: &Path, old_name: &str, new_name: &str) -> Result<()> {
|
||||
Ok(git_rename_branch(dir, old_name, new_name).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_status(dir: &Path) -> Result<GitStatusSummary> {
|
||||
Ok(git_status(dir)?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_log(dir: &Path) -> Result<Vec<GitCommit>> {
|
||||
Ok(git_log(dir)?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_initialize(dir: &Path) -> Result<()> {
|
||||
Ok(git_init(dir)?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_clone(url: &str, dir: &Path) -> Result<CloneResult> {
|
||||
Ok(git_clone(url, dir).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_commit(dir: &Path, message: &str) -> Result<()> {
|
||||
Ok(git_commit(dir, message).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_fetch_all(dir: &Path) -> Result<()> {
|
||||
Ok(git_fetch_all(dir).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_push(dir: &Path) -> Result<PushResult> {
|
||||
Ok(git_push(dir).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_pull(dir: &Path) -> Result<PullResult> {
|
||||
Ok(git_pull(dir).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_pull_force_reset(
|
||||
dir: &Path,
|
||||
remote: &str,
|
||||
branch: &str,
|
||||
) -> Result<PullResult> {
|
||||
Ok(git_pull_force_reset(dir, remote, branch).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_pull_merge(dir: &Path, remote: &str, branch: &str) -> Result<PullResult> {
|
||||
Ok(git_pull_merge(dir, remote, branch).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_add(dir: &Path, rela_paths: Vec<PathBuf>) -> Result<()> {
|
||||
for path in rela_paths {
|
||||
git_add(dir, &path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_unstage(dir: &Path, rela_paths: Vec<PathBuf>) -> Result<()> {
|
||||
for path in rela_paths {
|
||||
git_unstage(dir, &path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_reset_changes(dir: &Path) -> Result<()> {
|
||||
Ok(git_reset_changes(dir).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_add_credential(
|
||||
remote_url: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<()> {
|
||||
Ok(git_add_credential(remote_url, username, password).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_remotes(dir: &Path) -> Result<Vec<GitRemote>> {
|
||||
Ok(git_remotes(dir)?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_add_remote(dir: &Path, name: &str, url: &str) -> Result<GitRemote> {
|
||||
Ok(git_add_remote(dir, name, url)?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_rm_remote(dir: &Path, name: &str) -> Result<()> {
|
||||
Ok(git_rm_remote(dir, name)?)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::PluginContextExt;
|
||||
use crate::error::Result;
|
||||
use crate::models_ext::QueryManagerExt;
|
||||
use KeyAndValueRef::{Ascii, Binary};
|
||||
use tauri::{Manager, Runtime, WebviewWindow};
|
||||
use yaak_grpc::{KeyAndValueRef, MetadataMap};
|
||||
use yaak_models::models::GrpcRequest;
|
||||
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
|
||||
pub(crate) fn metadata_to_map(metadata: MetadataMap) -> BTreeMap<String, String> {
|
||||
let mut entries = BTreeMap::new();
|
||||
for r in metadata.iter() {
|
||||
match r {
|
||||
Ascii(k, v) => entries.insert(k.to_string(), v.to_str().unwrap().to_string()),
|
||||
Binary(k, v) => entries.insert(k.to_string(), format!("{:?}", v)),
|
||||
};
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_grpc_request<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
request: &GrpcRequest,
|
||||
) -> Result<(GrpcRequest, String)> {
|
||||
let mut new_request = request.clone();
|
||||
|
||||
let (authentication_type, authentication, authentication_context_id) =
|
||||
window.db().resolve_auth_for_grpc_request(request)?;
|
||||
new_request.authentication_type = authentication_type;
|
||||
new_request.authentication = authentication;
|
||||
|
||||
let metadata = window.db().resolve_metadata_for_grpc_request(request)?;
|
||||
new_request.metadata = metadata;
|
||||
|
||||
Ok((new_request, authentication_context_id))
|
||||
}
|
||||
|
||||
pub(crate) async fn build_metadata<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
request: &GrpcRequest,
|
||||
authentication_context_id: &str,
|
||||
) -> Result<BTreeMap<String, String>> {
|
||||
let plugin_manager = window.state::<PluginManager>();
|
||||
let mut metadata = BTreeMap::new();
|
||||
|
||||
// Add the rest of metadata
|
||||
for h in request.metadata.clone() {
|
||||
if h.name.is_empty() && h.value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !h.enabled {
|
||||
continue;
|
||||
}
|
||||
|
||||
metadata.insert(h.name, h.value);
|
||||
}
|
||||
|
||||
match request.authentication_type.clone() {
|
||||
None => {
|
||||
// No authentication found. Not even inherited
|
||||
}
|
||||
Some(authentication_type) if authentication_type == "none" => {
|
||||
// Explicitly no authentication
|
||||
}
|
||||
Some(authentication_type) => {
|
||||
let auth = request.authentication.clone();
|
||||
let plugin_req = CallHttpAuthenticationRequest {
|
||||
context_id: format!("{:x}", md5::compute(authentication_context_id)),
|
||||
values: serde_json::from_value(serde_json::to_value(&auth)?)?,
|
||||
method: "POST".to_string(),
|
||||
url: request.url.clone(),
|
||||
headers: metadata
|
||||
.iter()
|
||||
.map(|(name, value)| HttpHeader {
|
||||
name: name.to_string(),
|
||||
value: value.to_string(),
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let plugin_result = plugin_manager
|
||||
.call_http_authentication(
|
||||
&window.plugin_context(),
|
||||
&authentication_type,
|
||||
plugin_req,
|
||||
)
|
||||
.await?;
|
||||
for header in plugin_result.set_headers.unwrap_or_default() {
|
||||
metadata.insert(header.name, header.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(metadata)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
use crate::models_ext::QueryManagerExt;
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
use log::debug;
|
||||
use std::sync::OnceLock;
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use yaak_models::util::UpdateSource;
|
||||
|
||||
const NAMESPACE: &str = "analytics";
|
||||
const NUM_LAUNCHES_KEY: &str = "num_launches";
|
||||
const LAST_VERSION_KEY: &str = "last_tracked_version";
|
||||
const PREV_VERSION_KEY: &str = "last_tracked_version_prev";
|
||||
const VERSION_SINCE_KEY: &str = "last_tracked_version_since";
|
||||
|
||||
#[derive(Default, Debug, Clone)]
|
||||
pub struct LaunchEventInfo {
|
||||
pub current_version: String,
|
||||
pub previous_version: String,
|
||||
pub launched_after_update: bool,
|
||||
pub version_since: NaiveDateTime,
|
||||
pub user_since: NaiveDateTime,
|
||||
pub num_launches: i32,
|
||||
}
|
||||
|
||||
static LAUNCH_INFO: OnceLock<LaunchEventInfo> = OnceLock::new();
|
||||
|
||||
pub fn get_or_upsert_launch_info<R: Runtime>(app_handle: &AppHandle<R>) -> &LaunchEventInfo {
|
||||
LAUNCH_INFO.get_or_init(|| {
|
||||
let now = Utc::now().naive_utc();
|
||||
let mut info = LaunchEventInfo {
|
||||
version_since: app_handle.db().get_key_value_dte(NAMESPACE, VERSION_SINCE_KEY, now),
|
||||
current_version: app_handle.package_info().version.to_string(),
|
||||
user_since: app_handle.db().get_settings().created_at,
|
||||
num_launches: app_handle.db().get_key_value_int(NAMESPACE, NUM_LAUNCHES_KEY, 0) + 1,
|
||||
|
||||
// The rest will be set below
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
app_handle
|
||||
.with_tx(|tx| {
|
||||
// Load the previously tracked version
|
||||
let curr_db = tx.get_key_value_str(NAMESPACE, LAST_VERSION_KEY, "");
|
||||
let prev_db = tx.get_key_value_str(NAMESPACE, PREV_VERSION_KEY, "");
|
||||
|
||||
// We just updated if the app version is different from the last tracked version we stored
|
||||
if !curr_db.is_empty() && info.current_version != curr_db {
|
||||
info.launched_after_update = true;
|
||||
}
|
||||
|
||||
// If we just updated, track the previous version as the "previous" current version
|
||||
if info.launched_after_update {
|
||||
info.previous_version = curr_db.clone();
|
||||
info.version_since = now;
|
||||
} else {
|
||||
info.previous_version = prev_db.clone();
|
||||
}
|
||||
|
||||
// Rotate stored versions: move previous into the "prev" slot before overwriting
|
||||
let source = &UpdateSource::Background;
|
||||
|
||||
tx.set_key_value_str(NAMESPACE, PREV_VERSION_KEY, &info.previous_version, source);
|
||||
tx.set_key_value_str(NAMESPACE, LAST_VERSION_KEY, &info.current_version, source);
|
||||
tx.set_key_value_dte(NAMESPACE, VERSION_SINCE_KEY, info.version_since, source);
|
||||
tx.set_key_value_int(NAMESPACE, NUM_LAUNCHES_KEY, info.num_launches, source);
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
debug!("Initialized launch info");
|
||||
|
||||
info
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
use crate::PluginContextExt;
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::error::Result;
|
||||
use crate::models_ext::BlobManagerExt;
|
||||
use crate::models_ext::QueryManagerExt;
|
||||
use log::warn;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tauri::{AppHandle, Manager, Runtime, WebviewWindow};
|
||||
use tokio::sync::watch::Receiver;
|
||||
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_http::manager::HttpConnectionManager;
|
||||
use yaak_models::models::{CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseState};
|
||||
use yaak_models::util::UpdateSource;
|
||||
use yaak_plugins::events::PluginContext;
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
|
||||
/// Context for managing response state during HTTP transactions.
|
||||
/// Handles both persisted responses (stored in DB) and ephemeral responses (in-memory only).
|
||||
struct ResponseContext<R: Runtime> {
|
||||
app_handle: AppHandle<R>,
|
||||
response: HttpResponse,
|
||||
update_source: UpdateSource,
|
||||
}
|
||||
|
||||
impl<R: Runtime> ResponseContext<R> {
|
||||
fn new(app_handle: AppHandle<R>, response: HttpResponse, update_source: UpdateSource) -> Self {
|
||||
Self { app_handle, response, update_source }
|
||||
}
|
||||
|
||||
/// Whether this response is persisted (has a non-empty ID)
|
||||
fn is_persisted(&self) -> bool {
|
||||
!self.response.id.is_empty()
|
||||
}
|
||||
|
||||
/// Update the response state. For persisted responses, fetches from DB, applies the
|
||||
/// closure, and updates the DB. For ephemeral responses, just applies the closure
|
||||
/// to the in-memory response.
|
||||
fn update<F>(&mut self, func: F) -> Result<()>
|
||||
where
|
||||
F: FnOnce(&mut HttpResponse),
|
||||
{
|
||||
if self.is_persisted() {
|
||||
let r = self.app_handle.with_tx(|tx| {
|
||||
let mut r = tx.get_http_response(&self.response.id)?;
|
||||
func(&mut r);
|
||||
tx.update_http_response_if_id(&r, &self.update_source)?;
|
||||
Ok(r)
|
||||
})?;
|
||||
self.response = r;
|
||||
Ok(())
|
||||
} else {
|
||||
func(&mut self.response);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current response state
|
||||
fn response(&self) -> &HttpResponse {
|
||||
&self.response
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_http_request<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
unrendered_request: &HttpRequest,
|
||||
og_response: &HttpResponse,
|
||||
environment: Option<Environment>,
|
||||
cookie_jar: Option<CookieJar>,
|
||||
cancelled_rx: &mut Receiver<bool>,
|
||||
) -> Result<HttpResponse> {
|
||||
send_http_request_with_context(
|
||||
window,
|
||||
unrendered_request,
|
||||
og_response,
|
||||
environment,
|
||||
cookie_jar,
|
||||
cancelled_rx,
|
||||
&window.plugin_context(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn send_http_request_with_context<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
unrendered_request: &HttpRequest,
|
||||
og_response: &HttpResponse,
|
||||
environment: Option<Environment>,
|
||||
cookie_jar: Option<CookieJar>,
|
||||
cancelled_rx: &Receiver<bool>,
|
||||
plugin_context: &PluginContext,
|
||||
) -> Result<HttpResponse> {
|
||||
let app_handle = window.app_handle().clone();
|
||||
let update_source = UpdateSource::from_window_label(window.label());
|
||||
let mut response_ctx =
|
||||
ResponseContext::new(app_handle.clone(), og_response.clone(), update_source);
|
||||
|
||||
// Execute the inner send logic and handle errors consistently
|
||||
let start = Instant::now();
|
||||
let result = send_http_request_inner(
|
||||
window,
|
||||
unrendered_request,
|
||||
environment,
|
||||
cookie_jar,
|
||||
cancelled_rx,
|
||||
plugin_context,
|
||||
&mut response_ctx,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(response) => Ok(response),
|
||||
Err(e) => {
|
||||
let error = e.to_string();
|
||||
let elapsed = start.elapsed().as_millis() as i32;
|
||||
warn!("Failed to send request: {error:?}");
|
||||
let _ = response_ctx.update(|r| {
|
||||
r.state = HttpResponseState::Closed;
|
||||
r.elapsed = elapsed;
|
||||
if r.elapsed_headers == 0 {
|
||||
r.elapsed_headers = elapsed;
|
||||
}
|
||||
r.error = Some(error);
|
||||
});
|
||||
Ok(response_ctx.response().clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_http_request_inner<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
unrendered_request: &HttpRequest,
|
||||
environment: Option<Environment>,
|
||||
cookie_jar: Option<CookieJar>,
|
||||
cancelled_rx: &Receiver<bool>,
|
||||
plugin_context: &PluginContext,
|
||||
response_ctx: &mut ResponseContext<R>,
|
||||
) -> Result<HttpResponse> {
|
||||
let app_handle = window.app_handle().clone();
|
||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
let connection_manager = app_handle.state::<HttpConnectionManager>();
|
||||
let environment_id = environment.map(|e| e.id);
|
||||
let cookie_jar_id = cookie_jar.as_ref().map(|jar| jar.id.clone());
|
||||
|
||||
let response_dir = app_handle.path().app_data_dir()?.join("responses");
|
||||
let result = send_http_request_with_plugins(SendHttpRequestWithPluginsParams {
|
||||
query_manager: app_handle.db_manager().inner(),
|
||||
blob_manager: app_handle.blob_manager().inner(),
|
||||
request: unrendered_request.clone(),
|
||||
environment_id: environment_id.as_deref(),
|
||||
update_source: response_ctx.update_source.clone(),
|
||||
cookie_jar_id,
|
||||
response_dir: &response_dir,
|
||||
emit_events_to: None,
|
||||
emit_response_body_chunks_to: None,
|
||||
existing_response: Some(response_ctx.response().clone()),
|
||||
plugin_manager,
|
||||
encryption_manager,
|
||||
plugin_context,
|
||||
cancelled_rx: Some(cancelled_rx.clone()),
|
||||
connection_manager: Some(connection_manager.inner()),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| GenericError(e.to_string()))?;
|
||||
|
||||
Ok(result.response)
|
||||
}
|
||||
|
||||
pub fn resolve_http_request<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
request: &HttpRequest,
|
||||
) -> Result<(HttpRequest, String)> {
|
||||
let mut new_request = request.clone();
|
||||
|
||||
let (authentication_type, authentication, authentication_context_id) =
|
||||
window.db().resolve_auth_for_http_request(request)?;
|
||||
new_request.authentication_type = authentication_type;
|
||||
new_request.authentication = authentication;
|
||||
|
||||
let headers = window.db().resolve_headers_for_http_request(request)?;
|
||||
new_request.headers = headers;
|
||||
|
||||
Ok((new_request, authentication_context_id))
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
use crate::PluginContextExt;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::models_ext::QueryManagerExt;
|
||||
use log::info;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::read_to_string;
|
||||
use std::io::ErrorKind;
|
||||
use tauri::{Manager, Runtime, WebviewWindow};
|
||||
use yaak_core::WorkspaceContext;
|
||||
use yaak_models::models::{
|
||||
Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace,
|
||||
};
|
||||
use yaak_models::util::{BatchUpsertResult, UpdateSource, maybe_gen_id, maybe_gen_id_opt};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
||||
|
||||
pub(crate) async fn import_data<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
file_path: &str,
|
||||
) -> Result<BatchUpsertResult> {
|
||||
let plugin_manager = window.state::<PluginManager>();
|
||||
let file = read_import_file(file_path)?;
|
||||
let file_contents = file.as_str();
|
||||
let import_result = plugin_manager.import_data(&window.plugin_context(), file_contents).await?;
|
||||
|
||||
let mut id_map: BTreeMap<String, String> = BTreeMap::new();
|
||||
|
||||
// Create WorkspaceContext from window
|
||||
let ctx = WorkspaceContext {
|
||||
workspace_id: window.workspace_id(),
|
||||
environment_id: window.environment_id(),
|
||||
cookie_jar_id: window.cookie_jar_id(),
|
||||
request_id: None,
|
||||
};
|
||||
|
||||
let resources = import_result.resources;
|
||||
|
||||
let workspaces: Vec<Workspace> = resources
|
||||
.workspaces
|
||||
.into_iter()
|
||||
.map(|mut v| {
|
||||
v.id = maybe_gen_id::<Workspace>(&ctx, v.id.as_str(), &mut id_map);
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
|
||||
let environments: Vec<Environment> = resources
|
||||
.environments
|
||||
.into_iter()
|
||||
.map(|mut v| {
|
||||
v.id = maybe_gen_id::<Environment>(&ctx, v.id.as_str(), &mut id_map);
|
||||
v.workspace_id = maybe_gen_id::<Workspace>(&ctx, v.workspace_id.as_str(), &mut id_map);
|
||||
match (v.parent_model.as_str(), v.parent_id.clone().as_deref()) {
|
||||
("folder", Some(parent_id)) => {
|
||||
v.parent_id = Some(maybe_gen_id::<Folder>(&ctx, &parent_id, &mut id_map));
|
||||
}
|
||||
("", _) => {
|
||||
// Fix any empty ones
|
||||
v.parent_model = "workspace".to_string();
|
||||
}
|
||||
_ => {
|
||||
// Parent ID only required for the folder case
|
||||
v.parent_id = None;
|
||||
}
|
||||
};
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
|
||||
let folders: Vec<Folder> = resources
|
||||
.folders
|
||||
.into_iter()
|
||||
.map(|mut v| {
|
||||
v.id = maybe_gen_id::<Folder>(&ctx, v.id.as_str(), &mut id_map);
|
||||
v.workspace_id = maybe_gen_id::<Workspace>(&ctx, v.workspace_id.as_str(), &mut id_map);
|
||||
v.folder_id = maybe_gen_id_opt::<Folder>(&ctx, v.folder_id, &mut id_map);
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
|
||||
let http_requests: Vec<HttpRequest> = resources
|
||||
.http_requests
|
||||
.into_iter()
|
||||
.map(|mut v| {
|
||||
v.id = maybe_gen_id::<HttpRequest>(&ctx, v.id.as_str(), &mut id_map);
|
||||
v.workspace_id = maybe_gen_id::<Workspace>(&ctx, v.workspace_id.as_str(), &mut id_map);
|
||||
v.folder_id = maybe_gen_id_opt::<Folder>(&ctx, v.folder_id, &mut id_map);
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
|
||||
let grpc_requests: Vec<GrpcRequest> = resources
|
||||
.grpc_requests
|
||||
.into_iter()
|
||||
.map(|mut v| {
|
||||
v.id = maybe_gen_id::<GrpcRequest>(&ctx, v.id.as_str(), &mut id_map);
|
||||
v.workspace_id = maybe_gen_id::<Workspace>(&ctx, v.workspace_id.as_str(), &mut id_map);
|
||||
v.folder_id = maybe_gen_id_opt::<Folder>(&ctx, v.folder_id, &mut id_map);
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
|
||||
let websocket_requests: Vec<WebsocketRequest> = resources
|
||||
.websocket_requests
|
||||
.into_iter()
|
||||
.map(|mut v| {
|
||||
v.id = maybe_gen_id::<WebsocketRequest>(&ctx, v.id.as_str(), &mut id_map);
|
||||
v.workspace_id = maybe_gen_id::<Workspace>(&ctx, v.workspace_id.as_str(), &mut id_map);
|
||||
v.folder_id = maybe_gen_id_opt::<Folder>(&ctx, v.folder_id, &mut id_map);
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
|
||||
info!("Importing data");
|
||||
|
||||
let upserted = window.with_tx(|tx| {
|
||||
tx.batch_upsert(
|
||||
workspaces,
|
||||
environments,
|
||||
folders,
|
||||
http_requests,
|
||||
grpc_requests,
|
||||
websocket_requests,
|
||||
&UpdateSource::Import,
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(upserted)
|
||||
}
|
||||
|
||||
fn read_import_file(file_path: &str) -> Result<String> {
|
||||
read_to_string(file_path).map_err(|err| {
|
||||
if err.kind() == ErrorKind::InvalidData {
|
||||
Error::GenericError(format!(
|
||||
"Import file must be UTF-8 text; binary files are not supported: {file_path}"
|
||||
))
|
||||
} else {
|
||||
Error::GenericError(format!("Unable to read import file {file_path}: {err}"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs::{remove_file, write};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[test]
|
||||
fn read_import_file_returns_error_for_binary_file() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"yaak-import-binary-{}.pftrace",
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system time before unix epoch")
|
||||
.as_nanos()
|
||||
));
|
||||
write(&path, [0xff, 0xfe, 0xfd]).expect("write binary fixture");
|
||||
|
||||
let err = read_import_file(path.to_str().expect("temp path is utf-8"))
|
||||
.expect_err("binary import should return an error");
|
||||
|
||||
assert!(err.to_string().contains("binary files are not supported"));
|
||||
|
||||
remove_file(path).expect("remove binary fixture");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
tauri_app_client_lib::run();
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
//! Tauri-specific extensions for yaak-models.
|
||||
//!
|
||||
//! This module provides the Tauri plugin initialization and extension traits
|
||||
//! that allow accessing QueryManager and BlobManager from Tauri's Manager types.
|
||||
|
||||
use chrono::Utc;
|
||||
use log::error;
|
||||
use std::time::Duration;
|
||||
use tauri::plugin::TauriPlugin;
|
||||
use tauri::{Emitter, Manager, Runtime, State};
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
use yaak_models::client_db::ClientDb;
|
||||
use yaak_models::error::Result;
|
||||
use yaak_models::models::{AnyModel, GraphQlIntrospection, GrpcEvent, Settings, WebsocketEvent};
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::UpdateSource;
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
|
||||
const MODEL_CHANGES_RETENTION_HOURS: i64 = 1;
|
||||
const MODEL_CHANGES_POLL_INTERVAL_MS: u64 = 1000;
|
||||
const MODEL_CHANGES_POLL_BATCH_SIZE: usize = 200;
|
||||
|
||||
struct ModelChangeCursor {
|
||||
created_at: String,
|
||||
id: i64,
|
||||
}
|
||||
|
||||
impl ModelChangeCursor {
|
||||
fn from_launch_time() -> Self {
|
||||
Self {
|
||||
created_at: Utc::now().naive_utc().format("%Y-%m-%d %H:%M:%S%.3f").to_string(),
|
||||
id: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_model_changes_batch<R: Runtime>(
|
||||
query_manager: &QueryManager,
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
cursor: &mut ModelChangeCursor,
|
||||
) -> bool {
|
||||
let changes = match query_manager.connect().list_model_changes_since(
|
||||
&cursor.created_at,
|
||||
cursor.id,
|
||||
MODEL_CHANGES_POLL_BATCH_SIZE,
|
||||
) {
|
||||
Ok(changes) => changes,
|
||||
Err(err) => {
|
||||
error!("Failed to poll model_changes rows: {err:?}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if changes.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let fetched_count = changes.len();
|
||||
for change in changes {
|
||||
cursor.created_at = change.created_at;
|
||||
cursor.id = change.id;
|
||||
|
||||
// Local window-originated writes are forwarded immediately from the
|
||||
// in-memory model event channel.
|
||||
if matches!(change.payload.update_source, UpdateSource::Window { .. }) {
|
||||
continue;
|
||||
}
|
||||
if let Err(err) = app_handle.emit("model_write", change.payload) {
|
||||
error!("Failed to emit model_write event: {err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fetched_count == MODEL_CHANGES_POLL_BATCH_SIZE
|
||||
}
|
||||
|
||||
async fn run_model_change_poller<R: Runtime>(
|
||||
query_manager: QueryManager,
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
mut cursor: ModelChangeCursor,
|
||||
) {
|
||||
loop {
|
||||
while drain_model_changes_batch(&query_manager, &app_handle, &mut cursor) {}
|
||||
tokio::time::sleep(Duration::from_millis(MODEL_CHANGES_POLL_INTERVAL_MS)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension trait for accessing the QueryManager from Tauri Manager types.
|
||||
pub trait QueryManagerExt<'a, R> {
|
||||
fn db_manager(&'a self) -> State<'a, QueryManager>;
|
||||
fn db(&'a self) -> ClientDb<'a>;
|
||||
fn with_tx<F, T>(&'a self, func: F) -> Result<T>
|
||||
where
|
||||
F: FnOnce(&ClientDb) -> Result<T>;
|
||||
}
|
||||
|
||||
impl<'a, R: Runtime, M: Manager<R>> QueryManagerExt<'a, R> for M {
|
||||
fn db_manager(&'a self) -> State<'a, QueryManager> {
|
||||
self.state::<QueryManager>()
|
||||
}
|
||||
|
||||
fn db(&'a self) -> ClientDb<'a> {
|
||||
let qm = self.state::<QueryManager>();
|
||||
qm.inner().connect()
|
||||
}
|
||||
|
||||
fn with_tx<F, T>(&'a self, func: F) -> Result<T>
|
||||
where
|
||||
F: FnOnce(&ClientDb) -> Result<T>,
|
||||
{
|
||||
let qm = self.state::<QueryManager>();
|
||||
qm.inner().with_tx(func)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension trait for accessing the BlobManager from Tauri Manager types.
|
||||
pub trait BlobManagerExt<'a, R> {
|
||||
fn blob_manager(&'a self) -> State<'a, BlobManager>;
|
||||
fn blobs(&'a self) -> yaak_models::blob_manager::BlobContext;
|
||||
}
|
||||
|
||||
impl<'a, R: Runtime, M: Manager<R>> BlobManagerExt<'a, R> for M {
|
||||
fn blob_manager(&'a self) -> State<'a, BlobManager> {
|
||||
self.state::<BlobManager>()
|
||||
}
|
||||
|
||||
fn blobs(&'a self) -> yaak_models::blob_manager::BlobContext {
|
||||
let manager = self.state::<BlobManager>();
|
||||
manager.inner().connect()
|
||||
}
|
||||
}
|
||||
|
||||
// Commands for yaak-models
|
||||
use tauri::WebviewWindow;
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn models_upsert<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
model: AnyModel,
|
||||
) -> Result<String> {
|
||||
use yaak_models::error::Error::GenericError;
|
||||
|
||||
let db = window.db();
|
||||
let blobs = window.blob_manager();
|
||||
let source = &UpdateSource::from_window_label(window.label());
|
||||
let id = match model {
|
||||
AnyModel::CookieJar(m) => db.upsert_cookie_jar(&m, source)?.id,
|
||||
AnyModel::Environment(m) => db.upsert_environment(&m, source)?.id,
|
||||
AnyModel::Folder(m) => db.upsert_folder(&m, source)?.id,
|
||||
AnyModel::GrpcRequest(m) => db.upsert_grpc_request(&m, source)?.id,
|
||||
AnyModel::HttpRequest(m) => db.upsert_http_request(&m, source)?.id,
|
||||
AnyModel::HttpResponse(m) => db.upsert_http_response(&m, source, &blobs)?.id,
|
||||
AnyModel::KeyValue(m) => db.upsert_key_value(&m, source)?.id,
|
||||
AnyModel::Plugin(m) => db.upsert_plugin(&m, source)?.id,
|
||||
AnyModel::Settings(m) => db.upsert_settings(&m, source)?.id,
|
||||
AnyModel::WebsocketRequest(m) => db.upsert_websocket_request(&m, source)?.id,
|
||||
AnyModel::Workspace(m) => db.upsert_workspace(&m, source)?.id,
|
||||
AnyModel::WorkspaceMeta(m) => db.upsert_workspace_meta(&m, source)?.id,
|
||||
a => return Err(GenericError(format!("Cannot upsert AnyModel {a:?})"))),
|
||||
};
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn models_delete<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
model: AnyModel,
|
||||
) -> Result<String> {
|
||||
use yaak_models::error::Error::GenericError;
|
||||
|
||||
let blobs = window.blob_manager();
|
||||
// Use transaction for deletions because it might recurse
|
||||
window.with_tx(|tx| {
|
||||
let source = &UpdateSource::from_window_label(window.label());
|
||||
let id = match model {
|
||||
AnyModel::CookieJar(m) => tx.delete_cookie_jar(&m, source)?.id,
|
||||
AnyModel::Environment(m) => tx.delete_environment(&m, source)?.id,
|
||||
AnyModel::Folder(m) => tx.delete_folder(&m, source)?.id,
|
||||
AnyModel::GrpcConnection(m) => tx.delete_grpc_connection(&m, source)?.id,
|
||||
AnyModel::GrpcRequest(m) => tx.delete_grpc_request(&m, source)?.id,
|
||||
AnyModel::HttpRequest(m) => tx.delete_http_request(&m, source)?.id,
|
||||
AnyModel::HttpResponse(m) => tx.delete_http_response(&m, source, &blobs)?.id,
|
||||
AnyModel::Plugin(m) => tx.delete_plugin(&m, source)?.id,
|
||||
AnyModel::WebsocketConnection(m) => tx.delete_websocket_connection(&m, source)?.id,
|
||||
AnyModel::WebsocketRequest(m) => tx.delete_websocket_request(&m, source)?.id,
|
||||
AnyModel::Workspace(m) => tx.delete_workspace(&m, source)?.id,
|
||||
a => return Err(GenericError(format!("Cannot delete AnyModel {a:?})"))),
|
||||
};
|
||||
Ok(id)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn models_duplicate<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
model: AnyModel,
|
||||
) -> Result<String> {
|
||||
use yaak_models::error::Error::GenericError;
|
||||
|
||||
// Use transaction for duplications because it might recurse
|
||||
window.with_tx(|tx| {
|
||||
let source = &UpdateSource::from_window_label(window.label());
|
||||
let id = match model {
|
||||
AnyModel::Environment(m) => tx.duplicate_environment(&m, source)?.id,
|
||||
AnyModel::Folder(m) => tx.duplicate_folder(&m, source)?.id,
|
||||
AnyModel::GrpcRequest(m) => tx.duplicate_grpc_request(&m, source)?.id,
|
||||
AnyModel::HttpRequest(m) => tx.duplicate_http_request(&m, source)?.id,
|
||||
AnyModel::WebsocketRequest(m) => tx.duplicate_websocket_request(&m, source)?.id,
|
||||
a => return Err(GenericError(format!("Cannot duplicate AnyModel {a:?})"))),
|
||||
};
|
||||
|
||||
Ok(id)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn models_websocket_events<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
connection_id: &str,
|
||||
) -> Result<Vec<WebsocketEvent>> {
|
||||
Ok(app_handle.db().list_websocket_events(connection_id)?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn models_grpc_events<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
connection_id: &str,
|
||||
) -> Result<Vec<GrpcEvent>> {
|
||||
Ok(app_handle.db().list_grpc_events(connection_id)?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn models_get_settings<R: Runtime>(app_handle: tauri::AppHandle<R>) -> Result<Settings> {
|
||||
Ok(app_handle.db().get_settings())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn models_get_graphql_introspection<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
request_id: &str,
|
||||
) -> Result<Option<GraphQlIntrospection>> {
|
||||
Ok(app_handle.db().get_graphql_introspection(request_id))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn models_upsert_graphql_introspection<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
request_id: &str,
|
||||
workspace_id: &str,
|
||||
content: Option<String>,
|
||||
window: WebviewWindow<R>,
|
||||
) -> Result<GraphQlIntrospection> {
|
||||
let source = UpdateSource::from_window_label(window.label());
|
||||
Ok(app_handle.db().upsert_graphql_introspection(workspace_id, request_id, content, &source)?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn models_workspace_models<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
workspace_id: Option<&str>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> Result<String> {
|
||||
let mut l: Vec<AnyModel> = Vec::new();
|
||||
|
||||
// Add the global models
|
||||
{
|
||||
let db = window.db();
|
||||
l.push(db.get_settings().into());
|
||||
l.append(&mut db.list_workspaces()?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_key_values()?.into_iter().map(Into::into).collect());
|
||||
}
|
||||
|
||||
let plugins = {
|
||||
let db = window.db();
|
||||
db.list_plugins()?
|
||||
};
|
||||
|
||||
let plugins = plugin_manager.resolve_plugins_for_runtime_from_db(plugins).await;
|
||||
l.append(&mut plugins.into_iter().map(Into::into).collect());
|
||||
|
||||
// Add the workspace children
|
||||
if let Some(wid) = workspace_id {
|
||||
let db = window.db();
|
||||
l.append(&mut db.list_cookie_jars(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_environments_ensure_base(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_folders(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_grpc_connections(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_grpc_requests(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_http_requests(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_http_responses(wid, None)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_websocket_connections(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_websocket_requests(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_workspace_metas(wid)?.into_iter().map(Into::into).collect());
|
||||
}
|
||||
|
||||
let j = serde_json::to_string(&l)?;
|
||||
|
||||
Ok(escape_str_for_webview(&j))
|
||||
}
|
||||
|
||||
fn escape_str_for_webview(input: &str) -> String {
|
||||
input
|
||||
.chars()
|
||||
.map(|c| {
|
||||
let code = c as u32;
|
||||
// ASCII
|
||||
if code <= 0x7F {
|
||||
c.to_string()
|
||||
// BMP characters encoded normally
|
||||
} else if code < 0xFFFF {
|
||||
format!("\\u{:04X}", code)
|
||||
// Beyond BMP encoded a surrogate pairs
|
||||
} else {
|
||||
let high = ((code - 0x10000) >> 10) + 0xD800;
|
||||
let low = ((code - 0x10000) & 0x3FF) + 0xDC00;
|
||||
format!("\\u{:04X}\\u{:04X}", high, low)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Initialize database managers as a plugin (for initialization order).
|
||||
/// Commands are in the main invoke_handler.
|
||||
/// This must be registered before other plugins that depend on the database.
|
||||
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
tauri::plugin::Builder::new("yaak-models-db")
|
||||
.setup(|app_handle, _api| {
|
||||
let app_path = app_handle.path().app_data_dir().unwrap();
|
||||
let db_path = app_path.join("db.sqlite");
|
||||
let blob_path = app_path.join("blobs.sqlite");
|
||||
|
||||
let (query_manager, blob_manager, rx) =
|
||||
match yaak_models::init_standalone(&db_path, &blob_path) {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
app_handle
|
||||
.dialog()
|
||||
.message(e.to_string())
|
||||
.kind(MessageDialogKind::Error)
|
||||
.blocking_show();
|
||||
return Err(Box::from(e.to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
let db = query_manager.connect();
|
||||
if let Err(err) = db.prune_model_changes_older_than_hours(MODEL_CHANGES_RETENTION_HOURS)
|
||||
{
|
||||
error!("Failed to prune model_changes rows on startup: {err:?}");
|
||||
}
|
||||
// Only stream writes that happen after this app launch.
|
||||
let cursor = ModelChangeCursor::from_launch_time();
|
||||
|
||||
let poll_query_manager = query_manager.clone();
|
||||
|
||||
app_handle.manage(query_manager);
|
||||
app_handle.manage(blob_manager);
|
||||
|
||||
// Poll model_changes so all writers (including external CLI processes) update the UI.
|
||||
let app_handle_poll = app_handle.clone();
|
||||
let query_manager = poll_query_manager;
|
||||
tauri::async_runtime::spawn(async move {
|
||||
run_model_change_poller(query_manager, app_handle_poll, cursor).await;
|
||||
});
|
||||
|
||||
// Fast path for local app writes initiated by frontend windows. This keeps the
|
||||
// current sync-model UX snappy, while DB polling handles external writers (CLI).
|
||||
let app_handle_local = app_handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
for payload in rx {
|
||||
if !matches!(payload.update_source, UpdateSource::Window { .. }) {
|
||||
continue;
|
||||
}
|
||||
if let Err(err) = app_handle_local.emit("model_write", payload) {
|
||||
error!("Failed to emit local model_write event: {err:?}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.build()
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
use crate::error::Result;
|
||||
use crate::history::get_or_upsert_launch_info;
|
||||
use crate::models_ext::QueryManagerExt;
|
||||
use chrono::{DateTime, Utc};
|
||||
use log::{debug, info};
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Instant;
|
||||
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow};
|
||||
use ts_rs::TS;
|
||||
use yaak_api::{ApiClientKind, yaak_api_client};
|
||||
use yaak_common::platform::get_os_str;
|
||||
use yaak_models::util::UpdateSource;
|
||||
|
||||
// Check for updates every hour
|
||||
const MAX_UPDATE_CHECK_SECONDS: u64 = 60 * 60;
|
||||
|
||||
const KV_NAMESPACE: &str = "notifications";
|
||||
const KV_KEY: &str = "seen";
|
||||
|
||||
// Create updater struct
|
||||
pub struct YaakNotifier {
|
||||
last_check: Option<Instant>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "index.ts")]
|
||||
pub struct YaakNotification {
|
||||
timestamp: DateTime<Utc>,
|
||||
timeout: Option<f64>,
|
||||
id: String,
|
||||
title: Option<String>,
|
||||
message: String,
|
||||
color: Option<String>,
|
||||
action: Option<YaakNotificationAction>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "index.ts")]
|
||||
pub struct YaakNotificationAction {
|
||||
label: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
impl YaakNotifier {
|
||||
pub fn new() -> Self {
|
||||
Self { last_check: None }
|
||||
}
|
||||
|
||||
pub async fn seen<R: Runtime>(&mut self, window: &WebviewWindow<R>, id: &str) -> Result<()> {
|
||||
let app_handle = window.app_handle();
|
||||
let mut seen = get_kv(app_handle).await?;
|
||||
seen.push(id.to_string());
|
||||
debug!("Marked notification as seen {}", id);
|
||||
let seen_json = serde_json::to_string(&seen)?;
|
||||
window.db().set_key_value_raw(
|
||||
KV_NAMESPACE,
|
||||
KV_KEY,
|
||||
seen_json.as_str(),
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn maybe_check<R: Runtime>(&mut self, window: &WebviewWindow<R>) -> Result<()> {
|
||||
let app_handle = window.app_handle();
|
||||
if let Some(i) = self.last_check
|
||||
&& i.elapsed().as_secs() < MAX_UPDATE_CHECK_SECONDS
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.last_check = Some(Instant::now());
|
||||
|
||||
if !app_handle.db().get_settings().check_notifications {
|
||||
info!("Notifications are disabled. Skipping check.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
debug!("Checking for notifications");
|
||||
|
||||
#[cfg(feature = "license")]
|
||||
let license_check = {
|
||||
use yaak_license::{LicenseCheckStatus, check_license};
|
||||
match check_license(window).await {
|
||||
Ok(LicenseCheckStatus::PersonalUse { .. }) => "personal",
|
||||
Ok(LicenseCheckStatus::Active { .. }) => "commercial",
|
||||
Ok(LicenseCheckStatus::PastDue { .. }) => "past_due",
|
||||
Ok(LicenseCheckStatus::Inactive { .. }) => "invalid_license",
|
||||
Ok(LicenseCheckStatus::Trialing { .. }) => "trialing",
|
||||
Ok(LicenseCheckStatus::Expired { .. }) => "expired",
|
||||
Ok(LicenseCheckStatus::Error { .. }) => "error",
|
||||
Err(_) => "unknown",
|
||||
}
|
||||
.to_string()
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "license"))]
|
||||
let license_check = "disabled".to_string();
|
||||
|
||||
let launch_info = get_or_upsert_launch_info(app_handle);
|
||||
let app_version = app_handle.package_info().version.to_string();
|
||||
let req = yaak_api_client(ApiClientKind::App, &app_version)?
|
||||
.request(Method::GET, "https://notify.yaak.app/notifications")
|
||||
.query(&[
|
||||
("version", &launch_info.current_version),
|
||||
("version_prev", &launch_info.previous_version),
|
||||
("launches", &launch_info.num_launches.to_string()),
|
||||
("installed", &launch_info.user_since.format("%Y-%m-%d").to_string()),
|
||||
("license", &license_check),
|
||||
("updates", &get_updater_status(app_handle).to_string()),
|
||||
("platform", &get_os_str().to_string()),
|
||||
]);
|
||||
let resp = req.send().await?;
|
||||
if resp.status() != 200 {
|
||||
debug!("Skipping notification status code {}", resp.status());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for notification in resp.json::<Vec<YaakNotification>>().await? {
|
||||
let seen = get_kv(app_handle).await?;
|
||||
if seen.contains(¬ification.id) {
|
||||
debug!("Already seen notification {}", notification.id);
|
||||
continue;
|
||||
}
|
||||
debug!("Got notification {:?}", notification);
|
||||
|
||||
let _ = app_handle.emit_to(window.label(), "notification", notification.clone());
|
||||
break; // Only show one notification
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_kv<R: Runtime>(app_handle: &AppHandle<R>) -> Result<Vec<String>> {
|
||||
match app_handle.db().get_key_value_raw("notifications", "seen") {
|
||||
None => Ok(Vec::new()),
|
||||
Some(v) => Ok(serde_json::from_str(&v.value)?),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
fn get_updater_status<R: Runtime>(app_handle: &AppHandle<R>) -> &'static str {
|
||||
#[cfg(not(feature = "updater"))]
|
||||
{
|
||||
// Updater is not enabled as a Rust feature
|
||||
return "missing";
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "updater", target_os = "linux"))]
|
||||
{
|
||||
let settings = app_handle.db().get_settings();
|
||||
if !settings.autoupdate {
|
||||
// Updates are explicitly disabled
|
||||
"disabled"
|
||||
} else if std::env::var("APPIMAGE").is_err() {
|
||||
// Updates are enabled, but unsupported
|
||||
"unsupported"
|
||||
} else {
|
||||
// Updates are enabled and supported
|
||||
"enabled"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "updater", not(target_os = "linux")))]
|
||||
{
|
||||
let settings = app_handle.db().get_settings();
|
||||
if settings.autoupdate { "enabled" } else { "disabled" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
use crate::error::Result;
|
||||
use crate::http_request::send_http_request_with_context;
|
||||
use crate::models_ext::BlobManagerExt;
|
||||
use crate::models_ext::QueryManagerExt;
|
||||
use crate::render::{render_grpc_request, render_http_request, render_json_value};
|
||||
use yaak_window::window::{CreateWindowConfig, create_window};
|
||||
use crate::{
|
||||
call_frontend, cookie_jar_from_window, environment_from_window, get_window_from_plugin_context,
|
||||
workspace_from_window,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use cookie::Cookie;
|
||||
use log::error;
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Emitter, Listener, Manager, Runtime};
|
||||
use tauri_plugin_clipboard_manager::ClipboardExt;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
use yaak::plugin_events::{
|
||||
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
|
||||
};
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_http::cookies::get_cookie_value_from_jar;
|
||||
use yaak_models::models::{HttpResponse, Plugin};
|
||||
use yaak_models::queries::any_request::AnyRequest;
|
||||
use yaak_models::util::UpdateSource;
|
||||
use yaak_plugins::error::Error::PluginErr;
|
||||
use yaak_plugins::events::{
|
||||
Color, EmptyPayload, ErrorResponse, GetCookieValueResponse, Icon, InternalEvent,
|
||||
InternalEventPayload, ListCookieNamesResponse, ListOpenWorkspacesResponse,
|
||||
RenderGrpcRequestResponse, RenderHttpRequestResponse, SendHttpRequestResponse,
|
||||
ShowToastRequest, TemplateRenderResponse, WindowInfoResponse, WindowNavigateEvent,
|
||||
WorkspaceInfo,
|
||||
};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_plugins::plugin_handle::PluginHandle;
|
||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
||||
use yaak_templates::{RenderErrorBehavior, RenderOptions};
|
||||
|
||||
pub(crate) async fn handle_plugin_event<R: Runtime>(
|
||||
app_handle: &AppHandle<R>,
|
||||
event: &InternalEvent,
|
||||
plugin_handle: &PluginHandle,
|
||||
) -> Result<Option<InternalEventPayload>> {
|
||||
// log::debug!("Got event to app {event:?}");
|
||||
let plugin_context = event.context.to_owned();
|
||||
let plugin_name = plugin_handle.info().name;
|
||||
let fallback_workspace_id = plugin_context.workspace_id.clone().or_else(|| {
|
||||
plugin_context
|
||||
.label
|
||||
.as_ref()
|
||||
.and_then(|label| app_handle.get_webview_window(label))
|
||||
.and_then(|window| workspace_from_window(&window).map(|workspace| workspace.id))
|
||||
});
|
||||
|
||||
match handle_shared_plugin_event(
|
||||
app_handle.db_manager().inner(),
|
||||
&event.payload,
|
||||
SharedPluginEventContext {
|
||||
plugin_name: &plugin_name,
|
||||
workspace_id: fallback_workspace_id.as_deref(),
|
||||
},
|
||||
) {
|
||||
GroupedPluginEvent::Handled(payload) => Ok(payload),
|
||||
GroupedPluginEvent::ToHandle(host_request) => {
|
||||
handle_host_plugin_request(
|
||||
app_handle,
|
||||
event,
|
||||
plugin_handle,
|
||||
&plugin_context,
|
||||
host_request,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_host_plugin_request<R: Runtime>(
|
||||
app_handle: &AppHandle<R>,
|
||||
event: &InternalEvent,
|
||||
plugin_handle: &PluginHandle,
|
||||
plugin_context: &yaak_plugins::events::PluginContext,
|
||||
host_request: HostRequest<'_>,
|
||||
) -> Result<Option<InternalEventPayload>> {
|
||||
match host_request {
|
||||
HostRequest::ErrorResponse(resp) => {
|
||||
error!("Plugin error: {}: {:?}", resp.error, resp);
|
||||
let toast_event = plugin_handle.build_event_to_send(
|
||||
plugin_context,
|
||||
&InternalEventPayload::ShowToastRequest(ShowToastRequest {
|
||||
message: format!(
|
||||
"Plugin error from {}: {}",
|
||||
plugin_handle.info().name,
|
||||
resp.error
|
||||
),
|
||||
color: Some(Color::Danger),
|
||||
timeout: Some(30000),
|
||||
..Default::default()
|
||||
}),
|
||||
None,
|
||||
);
|
||||
Box::pin(handle_plugin_event(app_handle, &toast_event, plugin_handle)).await
|
||||
}
|
||||
HostRequest::ReloadResponse(req) => {
|
||||
let plugins = app_handle.db().list_plugins()?;
|
||||
for plugin in plugins {
|
||||
if plugin.directory != plugin_handle.dir {
|
||||
continue;
|
||||
}
|
||||
|
||||
let new_plugin = Plugin { updated_at: Utc::now().naive_utc(), ..plugin };
|
||||
app_handle.db().upsert_plugin(&new_plugin, &UpdateSource::Plugin)?;
|
||||
}
|
||||
|
||||
if !req.silent {
|
||||
let info = plugin_handle.info();
|
||||
let toast_event = plugin_handle.build_event_to_send(
|
||||
plugin_context,
|
||||
&InternalEventPayload::ShowToastRequest(ShowToastRequest {
|
||||
message: format!("Reloaded plugin {}@{}", info.name, info.version),
|
||||
icon: Some(Icon::Info),
|
||||
timeout: Some(5000),
|
||||
..Default::default()
|
||||
}),
|
||||
None,
|
||||
);
|
||||
Box::pin(handle_plugin_event(app_handle, &toast_event, plugin_handle)).await
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
HostRequest::CopyText(req) => {
|
||||
app_handle.clipboard().write_text(req.text.as_str())?;
|
||||
Ok(Some(InternalEventPayload::CopyTextResponse(EmptyPayload {})))
|
||||
}
|
||||
HostRequest::ShowToast(req) => {
|
||||
match &plugin_context.label {
|
||||
Some(label) => app_handle.emit_to(label, "show_toast", req)?,
|
||||
None => app_handle.emit("show_toast", req)?,
|
||||
};
|
||||
Ok(Some(InternalEventPayload::ShowToastResponse(EmptyPayload {})))
|
||||
}
|
||||
HostRequest::PromptText(_) => {
|
||||
let window = get_window_from_plugin_context(app_handle, plugin_context)?;
|
||||
Ok(call_frontend(&window, event).await)
|
||||
}
|
||||
HostRequest::PromptForm(_) => {
|
||||
let window = get_window_from_plugin_context(app_handle, plugin_context)?;
|
||||
if event.reply_id.is_some() {
|
||||
window.emit_to(window.label(), "plugin_event", event.clone())?;
|
||||
Ok(None)
|
||||
} else {
|
||||
window.emit_to(window.label(), "plugin_event", event.clone()).unwrap();
|
||||
|
||||
let event_id = event.id.clone();
|
||||
let plugin_handle = plugin_handle.clone();
|
||||
let plugin_context = plugin_context.clone();
|
||||
let window = window.clone();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<InternalEvent>(128);
|
||||
|
||||
let listener_id = window.listen(event_id, move |ev: tauri::Event| {
|
||||
let resp: InternalEvent = serde_json::from_str(ev.payload()).unwrap();
|
||||
let _ = tx.try_send(resp);
|
||||
});
|
||||
|
||||
while let Some(resp) = rx.recv().await {
|
||||
let is_done = matches!(
|
||||
&resp.payload,
|
||||
InternalEventPayload::PromptFormResponse(r) if r.done.unwrap_or(false)
|
||||
);
|
||||
|
||||
let event_to_send = plugin_handle.build_event_to_send(
|
||||
&plugin_context,
|
||||
&resp.payload,
|
||||
Some(resp.reply_id.unwrap_or_default()),
|
||||
);
|
||||
if let Err(e) = plugin_handle.send(&event_to_send).await {
|
||||
log::warn!("Failed to forward form response to plugin: {:?}", e);
|
||||
}
|
||||
|
||||
if is_done {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
window.unlisten(listener_id);
|
||||
});
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
HostRequest::RenderGrpcRequest(req) => {
|
||||
let window = get_window_from_plugin_context(app_handle, plugin_context)?;
|
||||
|
||||
let workspace =
|
||||
workspace_from_window(&window).expect("Failed to get workspace_id from window URL");
|
||||
let environment_id = environment_from_window(&window).map(|e| e.id);
|
||||
let environment_chain = window.db().resolve_environments(
|
||||
&workspace.id,
|
||||
req.grpc_request.folder_id.as_deref(),
|
||||
environment_id.as_deref(),
|
||||
)?;
|
||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
let cb = PluginTemplateCallback::new(
|
||||
plugin_manager,
|
||||
encryption_manager,
|
||||
plugin_context,
|
||||
req.purpose.clone(),
|
||||
);
|
||||
let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };
|
||||
let grpc_request =
|
||||
render_grpc_request(&req.grpc_request, environment_chain, &cb, &opt).await?;
|
||||
Ok(Some(InternalEventPayload::RenderGrpcRequestResponse(RenderGrpcRequestResponse {
|
||||
grpc_request,
|
||||
})))
|
||||
}
|
||||
HostRequest::RenderHttpRequest(req) => {
|
||||
let window = get_window_from_plugin_context(app_handle, plugin_context)?;
|
||||
|
||||
let workspace =
|
||||
workspace_from_window(&window).expect("Failed to get workspace_id from window URL");
|
||||
let environment_id = environment_from_window(&window).map(|e| e.id);
|
||||
let environment_chain = window.db().resolve_environments(
|
||||
&workspace.id,
|
||||
req.http_request.folder_id.as_deref(),
|
||||
environment_id.as_deref(),
|
||||
)?;
|
||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
let cb = PluginTemplateCallback::new(
|
||||
plugin_manager,
|
||||
encryption_manager,
|
||||
plugin_context,
|
||||
req.purpose.clone(),
|
||||
);
|
||||
let opt = &RenderOptions { error_behavior: RenderErrorBehavior::Throw };
|
||||
let http_request =
|
||||
render_http_request(&req.http_request, environment_chain, &cb, opt).await?;
|
||||
Ok(Some(InternalEventPayload::RenderHttpRequestResponse(RenderHttpRequestResponse {
|
||||
http_request,
|
||||
})))
|
||||
}
|
||||
HostRequest::TemplateRender(req) => {
|
||||
let window = get_window_from_plugin_context(app_handle, plugin_context)?;
|
||||
|
||||
let workspace =
|
||||
workspace_from_window(&window).expect("Failed to get workspace_id from window URL");
|
||||
let environment_id = environment_from_window(&window).map(|e| e.id);
|
||||
let folder_id = if let Some(id) = window.request_id() {
|
||||
match window.db().get_any_request(&id) {
|
||||
Ok(AnyRequest::HttpRequest(r)) => r.folder_id,
|
||||
Ok(AnyRequest::GrpcRequest(r)) => r.folder_id,
|
||||
Ok(AnyRequest::WebsocketRequest(r)) => r.folder_id,
|
||||
Err(_) => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let environment_chain = window.db().resolve_environments(
|
||||
&workspace.id,
|
||||
folder_id.as_deref(),
|
||||
environment_id.as_deref(),
|
||||
)?;
|
||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
let cb = PluginTemplateCallback::new(
|
||||
plugin_manager,
|
||||
encryption_manager,
|
||||
plugin_context,
|
||||
req.purpose.clone(),
|
||||
);
|
||||
let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };
|
||||
let data = render_json_value(req.data.clone(), environment_chain, &cb, &opt).await?;
|
||||
Ok(Some(InternalEventPayload::TemplateRenderResponse(TemplateRenderResponse { data })))
|
||||
}
|
||||
HostRequest::SendHttpRequest(req) => {
|
||||
let window = get_window_from_plugin_context(app_handle, plugin_context)?;
|
||||
let mut http_request = req.http_request.clone();
|
||||
let workspace =
|
||||
workspace_from_window(&window).expect("Failed to get workspace_id from window URL");
|
||||
let cookie_jar = cookie_jar_from_window(&window);
|
||||
let environment = environment_from_window(&window);
|
||||
|
||||
if http_request.workspace_id.is_empty() {
|
||||
http_request.workspace_id = workspace.id;
|
||||
}
|
||||
|
||||
let http_response = if http_request.id.is_empty() {
|
||||
HttpResponse::default()
|
||||
} else {
|
||||
let blobs = window.blob_manager();
|
||||
window.db().upsert_http_response(
|
||||
&HttpResponse {
|
||||
request_id: http_request.id.clone(),
|
||||
workspace_id: http_request.workspace_id.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
&blobs,
|
||||
)?
|
||||
};
|
||||
|
||||
let http_response = send_http_request_with_context(
|
||||
&window,
|
||||
&http_request,
|
||||
&http_response,
|
||||
environment,
|
||||
cookie_jar,
|
||||
&mut tokio::sync::watch::channel(false).1,
|
||||
plugin_context,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Some(InternalEventPayload::SendHttpRequestResponse(SendHttpRequestResponse {
|
||||
http_response,
|
||||
})))
|
||||
}
|
||||
HostRequest::OpenWindow(req) => {
|
||||
let (navigation_tx, mut navigation_rx) = tokio::sync::mpsc::channel(128);
|
||||
let (close_tx, mut close_rx) = tokio::sync::mpsc::channel(128);
|
||||
let use_native_titlebar = app_handle.db().get_settings().use_native_titlebar;
|
||||
let win_config = CreateWindowConfig {
|
||||
url: &req.url,
|
||||
label: &req.label,
|
||||
title: &req.title.clone().unwrap_or_default(),
|
||||
navigation_tx: Some(navigation_tx),
|
||||
close_tx: Some(close_tx),
|
||||
inner_size: req.size.clone().map(|s| (s.width, s.height)),
|
||||
data_dir_key: req.data_dir_key.clone(),
|
||||
use_native_titlebar,
|
||||
..Default::default()
|
||||
};
|
||||
if let Err(e) = create_window(app_handle, win_config) {
|
||||
let error_event = plugin_handle.build_event_to_send(
|
||||
plugin_context,
|
||||
&InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: format!("Failed to create window: {:?}", e),
|
||||
}),
|
||||
None,
|
||||
);
|
||||
return Box::pin(handle_plugin_event(app_handle, &error_event, plugin_handle))
|
||||
.await;
|
||||
}
|
||||
|
||||
{
|
||||
let event_id = event.id.clone();
|
||||
let plugin_handle = plugin_handle.clone();
|
||||
let plugin_context = plugin_context.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
while let Some(url) = navigation_rx.recv().await {
|
||||
let url = url.to_string();
|
||||
let event_to_send = plugin_handle.build_event_to_send(
|
||||
&plugin_context,
|
||||
&InternalEventPayload::WindowNavigateEvent(WindowNavigateEvent { url }),
|
||||
Some(event_id.clone()),
|
||||
);
|
||||
plugin_handle.send(&event_to_send).await.unwrap();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let event_id = event.id.clone();
|
||||
let plugin_handle = plugin_handle.clone();
|
||||
let plugin_context = plugin_context.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
while close_rx.recv().await.is_some() {
|
||||
let event_to_send = plugin_handle.build_event_to_send(
|
||||
&plugin_context,
|
||||
&InternalEventPayload::WindowCloseEvent,
|
||||
Some(event_id.clone()),
|
||||
);
|
||||
plugin_handle.send(&event_to_send).await.unwrap();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
HostRequest::CloseWindow(req) => {
|
||||
if let Some(window) = app_handle.webview_windows().get(&req.label) {
|
||||
window.close()?;
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
HostRequest::OpenExternalUrl(req) => {
|
||||
app_handle.opener().open_url(&req.url, None::<&str>)?;
|
||||
Ok(Some(InternalEventPayload::OpenExternalUrlResponse(EmptyPayload {})))
|
||||
}
|
||||
HostRequest::ListOpenWorkspaces(_) => {
|
||||
let mut workspaces = Vec::new();
|
||||
for (_, window) in app_handle.webview_windows() {
|
||||
if let Some(workspace) = workspace_from_window(&window) {
|
||||
workspaces.push(WorkspaceInfo {
|
||||
id: workspace.id.clone(),
|
||||
name: workspace.name.clone(),
|
||||
label: window.label().to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(Some(InternalEventPayload::ListOpenWorkspacesResponse(ListOpenWorkspacesResponse {
|
||||
workspaces,
|
||||
})))
|
||||
}
|
||||
HostRequest::ListCookieNames(_) => {
|
||||
let window = get_window_from_plugin_context(app_handle, plugin_context)?;
|
||||
let names = match cookie_jar_from_window(&window) {
|
||||
None => Vec::new(),
|
||||
Some(j) => j
|
||||
.cookies
|
||||
.into_iter()
|
||||
.filter_map(|c| Cookie::parse(c.raw_cookie).ok().map(|c| c.name().to_string()))
|
||||
.collect(),
|
||||
};
|
||||
Ok(Some(InternalEventPayload::ListCookieNamesResponse(ListCookieNamesResponse {
|
||||
names,
|
||||
})))
|
||||
}
|
||||
HostRequest::GetCookieValue(req) => {
|
||||
let window = get_window_from_plugin_context(app_handle, plugin_context)?;
|
||||
let value = match cookie_jar_from_window(&window) {
|
||||
None => None,
|
||||
Some(j) => get_cookie_value_from_jar(j.cookies, &req.name, req.domain.as_deref()),
|
||||
};
|
||||
Ok(Some(InternalEventPayload::GetCookieValueResponse(GetCookieValueResponse { value })))
|
||||
}
|
||||
HostRequest::WindowInfo(req) => {
|
||||
let w = app_handle
|
||||
.get_webview_window(&req.label)
|
||||
.ok_or(PluginErr(format!("Failed to find window for {}", req.label)))?;
|
||||
|
||||
let environment_id = environment_from_window(&w).map(|m| m.id);
|
||||
let workspace_id = workspace_from_window(&w).map(|m| m.id);
|
||||
let request_id =
|
||||
match app_handle.db().get_any_request(&w.request_id().unwrap_or_default()) {
|
||||
Ok(AnyRequest::HttpRequest(r)) => Some(r.id),
|
||||
Ok(AnyRequest::WebsocketRequest(r)) => Some(r.id),
|
||||
Ok(AnyRequest::GrpcRequest(r)) => Some(r.id),
|
||||
Err(_) => None,
|
||||
};
|
||||
|
||||
Ok(Some(InternalEventPayload::WindowInfoResponse(WindowInfoResponse {
|
||||
label: w.label().to_string(),
|
||||
request_id,
|
||||
workspace_id,
|
||||
environment_id,
|
||||
})))
|
||||
}
|
||||
HostRequest::OtherRequest(req) => {
|
||||
Ok(Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: format!(
|
||||
"Unsupported plugin request in app host handler: {}",
|
||||
req.type_name()
|
||||
),
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
//! Tauri-specific plugin management code.
|
||||
//!
|
||||
//! This module contains all Tauri integration for the plugin system:
|
||||
//! - Plugin initialization and lifecycle management
|
||||
//! - Tauri commands for plugin search/install/uninstall
|
||||
//! - Plugin update checking
|
||||
|
||||
use crate::PluginContextExt;
|
||||
use crate::error::Result;
|
||||
use crate::models_ext::QueryManagerExt;
|
||||
use log::{error, info, warn};
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::path::BaseDirectory;
|
||||
use tauri::plugin::{Builder, TauriPlugin};
|
||||
use tauri::{
|
||||
AppHandle, Emitter, Manager, RunEvent, Runtime, State, WebviewWindow, WindowEvent, command,
|
||||
is_dev,
|
||||
};
|
||||
use tokio::sync::Mutex;
|
||||
use ts_rs::TS;
|
||||
use yaak_api::{ApiClientKind, yaak_api_client};
|
||||
use yaak_models::models::{Plugin, PluginSource};
|
||||
use yaak_models::util::UpdateSource;
|
||||
use yaak_plugins::api::{
|
||||
PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse, check_plugin_updates,
|
||||
search_plugins,
|
||||
};
|
||||
use yaak_plugins::events::PluginContext;
|
||||
use yaak_plugins::install::{delete_and_uninstall, download_and_install};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_plugins::plugin_meta::get_plugin_meta;
|
||||
|
||||
static EXITING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Updater
|
||||
// ============================================================================
|
||||
|
||||
const MAX_UPDATE_CHECK_HOURS: u64 = 12;
|
||||
|
||||
pub struct PluginUpdater {
|
||||
last_check: Option<Instant>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "index.ts")]
|
||||
pub struct PluginUpdateNotification {
|
||||
pub update_count: usize,
|
||||
pub plugins: Vec<PluginUpdateInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "index.ts")]
|
||||
pub struct PluginUpdateInfo {
|
||||
pub name: String,
|
||||
pub current_version: String,
|
||||
pub latest_version: String,
|
||||
}
|
||||
|
||||
impl PluginUpdater {
|
||||
pub fn new() -> Self {
|
||||
Self { last_check: None }
|
||||
}
|
||||
|
||||
pub async fn check_now<R: Runtime>(&mut self, window: &WebviewWindow<R>) -> Result<bool> {
|
||||
self.last_check = Some(Instant::now());
|
||||
|
||||
info!("Checking for plugin updates");
|
||||
|
||||
let app_version = window.app_handle().package_info().version.to_string();
|
||||
let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;
|
||||
let plugins = window.app_handle().db().list_plugins()?;
|
||||
let updates = check_plugin_updates(&http_client, plugins.clone()).await?;
|
||||
|
||||
if updates.plugins.is_empty() {
|
||||
info!("No plugin updates available");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Get current plugin versions to build notification
|
||||
let mut update_infos = Vec::new();
|
||||
|
||||
for update in &updates.plugins {
|
||||
if let Some(plugin) = plugins.iter().find(|p| {
|
||||
if let Ok(meta) = get_plugin_meta(&std::path::Path::new(&p.directory)) {
|
||||
meta.name == update.name
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}) {
|
||||
if let Ok(meta) = get_plugin_meta(&std::path::Path::new(&plugin.directory)) {
|
||||
update_infos.push(PluginUpdateInfo {
|
||||
name: update.name.clone(),
|
||||
current_version: meta.version,
|
||||
latest_version: update.version.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let notification =
|
||||
PluginUpdateNotification { update_count: update_infos.len(), plugins: update_infos };
|
||||
|
||||
info!("Found {} plugin update(s)", notification.update_count);
|
||||
|
||||
if let Err(e) = window.emit_to(window.label(), "plugin_updates_available", ¬ification) {
|
||||
error!("Failed to emit plugin_updates_available event: {}", e);
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn maybe_check<R: Runtime>(&mut self, window: &WebviewWindow<R>) -> Result<bool> {
|
||||
let update_period_seconds = MAX_UPDATE_CHECK_HOURS * 60 * 60;
|
||||
|
||||
if let Some(i) = self.last_check
|
||||
&& i.elapsed().as_secs() < update_period_seconds
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
self.check_now(window).await
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tauri Commands
|
||||
// ============================================================================
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_plugins_search<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
query: &str,
|
||||
) -> Result<PluginSearchResponse> {
|
||||
let app_version = app_handle.package_info().version.to_string();
|
||||
let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;
|
||||
Ok(search_plugins(&http_client, query).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_plugins_install<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
name: &str,
|
||||
version: Option<String>,
|
||||
) -> Result<()> {
|
||||
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
|
||||
let app_version = window.app_handle().package_info().version.to_string();
|
||||
let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;
|
||||
let query_manager = window.state::<yaak_models::query_manager::QueryManager>();
|
||||
let plugin_context = window.plugin_context();
|
||||
download_and_install(
|
||||
plugin_manager,
|
||||
&query_manager,
|
||||
&http_client,
|
||||
&plugin_context,
|
||||
name,
|
||||
version,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_plugins_install_from_directory<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
directory: &str,
|
||||
) -> Result<Plugin> {
|
||||
let plugin = window.db().upsert_plugin(
|
||||
&Plugin {
|
||||
directory: directory.into(),
|
||||
url: None,
|
||||
enabled: true,
|
||||
source: PluginSource::Filesystem,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)?;
|
||||
|
||||
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
|
||||
plugin_manager.add_plugin(&window.plugin_context(), &plugin).await?;
|
||||
|
||||
Ok(plugin)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_plugins_uninstall<R: Runtime>(
|
||||
plugin_id: &str,
|
||||
window: WebviewWindow<R>,
|
||||
) -> Result<Plugin> {
|
||||
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
|
||||
let query_manager = window.state::<yaak_models::query_manager::QueryManager>();
|
||||
let plugin_context = window.plugin_context();
|
||||
Ok(delete_and_uninstall(plugin_manager, &query_manager, &plugin_context, plugin_id).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_plugin_init_errors(
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> Result<Vec<(String, String)>> {
|
||||
Ok(plugin_manager.take_init_errors().await)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_plugins_updates<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
) -> Result<PluginUpdatesResponse> {
|
||||
let app_version = app_handle.package_info().version.to_string();
|
||||
let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;
|
||||
let plugins = app_handle.db().list_plugins()?;
|
||||
Ok(check_plugin_updates(&http_client, plugins).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_plugins_update_all<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
) -> Result<Vec<PluginNameVersion>> {
|
||||
let app_version = window.app_handle().package_info().version.to_string();
|
||||
let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;
|
||||
let plugins = window.db().list_plugins()?;
|
||||
|
||||
// Get list of available updates (already filtered to only registry plugins)
|
||||
let updates = check_plugin_updates(&http_client, plugins).await?;
|
||||
|
||||
if updates.plugins.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
|
||||
let query_manager = window.state::<yaak_models::query_manager::QueryManager>();
|
||||
let plugin_context = window.plugin_context();
|
||||
|
||||
let mut updated = Vec::new();
|
||||
|
||||
for update in updates.plugins {
|
||||
info!("Updating plugin: {} to version {}", update.name, update.version);
|
||||
match download_and_install(
|
||||
plugin_manager.clone(),
|
||||
&query_manager,
|
||||
&http_client,
|
||||
&plugin_context,
|
||||
&update.name,
|
||||
Some(update.version.clone()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
info!("Successfully updated plugin: {}", update.name);
|
||||
updated.push(update.clone());
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to update plugin {}: {:?}", update.name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tauri Plugin Initialization
|
||||
// ============================================================================
|
||||
|
||||
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
Builder::new("yaak-plugins")
|
||||
.setup(|app_handle, _| {
|
||||
// Resolve paths for plugin manager
|
||||
let vendored_plugin_dir = app_handle
|
||||
.path()
|
||||
.resolve("vendored/plugins", BaseDirectory::Resource)
|
||||
.expect("failed to resolve plugin directory resource");
|
||||
|
||||
let installed_plugin_dir = app_handle
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.expect("failed to get app data dir")
|
||||
.join("installed-plugins");
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let node_bin_name = "yaaknode.exe";
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let node_bin_name = "yaaknode";
|
||||
|
||||
let node_bin_path = app_handle
|
||||
.path()
|
||||
.resolve(format!("vendored/node/{}", node_bin_name), BaseDirectory::Resource)
|
||||
.expect("failed to resolve yaaknode binary");
|
||||
|
||||
let plugin_runtime_main = app_handle
|
||||
.path()
|
||||
.resolve("vendored/plugin-runtime", BaseDirectory::Resource)
|
||||
.expect("failed to resolve plugin runtime")
|
||||
.join("index.cjs");
|
||||
|
||||
let dev_mode = is_dev();
|
||||
let query_manager =
|
||||
app_handle.state::<yaak_models::query_manager::QueryManager>().inner().clone();
|
||||
|
||||
// Create plugin manager asynchronously
|
||||
let app_handle_clone = app_handle.clone();
|
||||
tauri::async_runtime::block_on(async move {
|
||||
let manager = PluginManager::new(
|
||||
vendored_plugin_dir,
|
||||
installed_plugin_dir,
|
||||
node_bin_path,
|
||||
plugin_runtime_main,
|
||||
&query_manager,
|
||||
&PluginContext::new_empty(),
|
||||
dev_mode,
|
||||
)
|
||||
.await
|
||||
.expect("Failed to start plugin runtime");
|
||||
|
||||
app_handle_clone.manage(manager);
|
||||
});
|
||||
|
||||
let plugin_updater = PluginUpdater::new();
|
||||
app_handle.manage(Mutex::new(plugin_updater));
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.on_event(|app, e| match e {
|
||||
RunEvent::ExitRequested { api, .. } => {
|
||||
if EXITING.swap(true, Ordering::SeqCst) {
|
||||
return; // Only exit once to prevent infinite recursion
|
||||
}
|
||||
api.prevent_exit();
|
||||
tauri::async_runtime::block_on(async move {
|
||||
info!("Exiting plugin runtime due to app exit");
|
||||
let manager: State<PluginManager> = app.state();
|
||||
manager.terminate().await;
|
||||
app.exit(0);
|
||||
});
|
||||
}
|
||||
RunEvent::WindowEvent { event: WindowEvent::Focused(true), label, .. } => {
|
||||
// Check for plugin updates on window focus
|
||||
let w = app.get_webview_window(&label).unwrap();
|
||||
let h = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
let val: State<'_, Mutex<PluginUpdater>> = h.state();
|
||||
if let Err(e) = val.lock().await.maybe_check(&w).await {
|
||||
warn!("Failed to check for plugin updates {e:?}");
|
||||
}
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.build()
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
use serde_json::Value;
|
||||
pub use yaak::render::{render_grpc_request, render_http_request};
|
||||
use yaak_models::models::Environment;
|
||||
use yaak_models::render::make_vars_hashmap;
|
||||
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
|
||||
|
||||
pub async fn render_template<T: TemplateCallback>(
|
||||
template: &str,
|
||||
environment_chain: Vec<Environment>,
|
||||
cb: &T,
|
||||
opt: &RenderOptions,
|
||||
) -> yaak_templates::error::Result<String> {
|
||||
let vars = &make_vars_hashmap(environment_chain);
|
||||
parse_and_render(template, vars, cb, &opt).await
|
||||
}
|
||||
|
||||
pub async fn render_json_value<T: TemplateCallback>(
|
||||
value: Value,
|
||||
environment_chain: Vec<Environment>,
|
||||
cb: &T,
|
||||
opt: &RenderOptions,
|
||||
) -> yaak_templates::error::Result<Value> {
|
||||
let vars = &make_vars_hashmap(environment_chain);
|
||||
render_json_value_raw(value, vars, cb, opt).await
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Tauri-specific extensions for yaak-sync.
|
||||
//!
|
||||
//! This module provides the Tauri commands for sync functionality.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::models_ext::QueryManagerExt;
|
||||
use chrono::Utc;
|
||||
use log::warn;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tauri::ipc::Channel;
|
||||
use tauri::{AppHandle, Listener, Runtime, command};
|
||||
use tokio::sync::watch;
|
||||
use ts_rs::TS;
|
||||
use yaak_sync::error::Error::InvalidSyncDirectory;
|
||||
use yaak_sync::sync::{
|
||||
FsCandidate, SyncOp, apply_sync_ops, apply_sync_state_ops, compute_sync_ops, get_db_candidates,
|
||||
get_fs_candidates,
|
||||
};
|
||||
use yaak_sync::watch::{WatchEvent, watch_directory};
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn cmd_sync_calculate<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
workspace_id: &str,
|
||||
sync_dir: &Path,
|
||||
) -> Result<Vec<SyncOp>> {
|
||||
if !sync_dir.exists() {
|
||||
return Err(InvalidSyncDirectory(sync_dir.to_string_lossy().to_string()).into());
|
||||
}
|
||||
|
||||
let db = app_handle.db();
|
||||
let version = app_handle.package_info().version.to_string();
|
||||
let db_candidates = get_db_candidates(&db, &version, workspace_id, sync_dir)?;
|
||||
let fs_candidates = get_fs_candidates(sync_dir)?
|
||||
.into_iter()
|
||||
// Only keep items in the same workspace
|
||||
.filter(|fs| fs.model.workspace_id() == workspace_id)
|
||||
.collect::<Vec<FsCandidate>>();
|
||||
Ok(compute_sync_ops(db_candidates, fs_candidates))
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn cmd_sync_calculate_fs(dir: &Path) -> Result<Vec<SyncOp>> {
|
||||
let db_candidates = Vec::new();
|
||||
let fs_candidates = get_fs_candidates(dir)?;
|
||||
Ok(compute_sync_ops(db_candidates, fs_candidates))
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn cmd_sync_apply<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
sync_ops: Vec<SyncOp>,
|
||||
sync_dir: &Path,
|
||||
workspace_id: &str,
|
||||
) -> Result<()> {
|
||||
let db = app_handle.db();
|
||||
let sync_state_ops = apply_sync_ops(&db, workspace_id, sync_dir, sync_ops)?;
|
||||
apply_sync_state_ops(&db, workspace_id, sync_dir, sync_state_ops)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "index.ts")]
|
||||
pub(crate) struct WatchResult {
|
||||
unlisten_event: String,
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn cmd_sync_watch<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
sync_dir: &Path,
|
||||
workspace_id: &str,
|
||||
channel: Channel<WatchEvent>,
|
||||
) -> Result<WatchResult> {
|
||||
let (cancel_tx, cancel_rx) = watch::channel(());
|
||||
|
||||
// Create a callback that forwards events to the Tauri channel
|
||||
let callback = move |event: WatchEvent| {
|
||||
if let Err(e) = channel.send(event) {
|
||||
warn!("Failed to send watch event: {:?}", e);
|
||||
}
|
||||
};
|
||||
|
||||
watch_directory(&sync_dir, callback, cancel_rx).await?;
|
||||
|
||||
let app_handle_inner = app_handle.clone();
|
||||
let unlisten_event =
|
||||
format!("watch-unlisten-{}-{}", workspace_id, Utc::now().timestamp_millis());
|
||||
|
||||
// TODO: Figure out a way to unlisten when the client app_handle refreshes or closes. Perhaps with
|
||||
// a heartbeat mechanism, or ensuring only a single subscription per workspace (at least
|
||||
// this won't create `n` subs). We could also maybe have a global fs watcher that we keep
|
||||
// adding to here.
|
||||
app_handle.listen_any(unlisten_event.clone(), move |event| {
|
||||
app_handle_inner.unlisten(event.id());
|
||||
if let Err(e) = cancel_tx.send(()) {
|
||||
warn!("Failed to send cancel signal to watcher {e:?}");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(WatchResult { unlisten_event })
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::models_ext::QueryManagerExt;
|
||||
use log::{debug, error, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{Emitter, Listener, Manager, Runtime, WebviewWindow};
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons};
|
||||
use tauri_plugin_updater::{Update, UpdaterExt};
|
||||
use tokio::task::block_in_place;
|
||||
use tokio::time::sleep;
|
||||
use ts_rs::TS;
|
||||
use yaak_models::util::generate_id;
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
|
||||
use url::Url;
|
||||
use yaak_api::get_system_proxy_url;
|
||||
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::is_dev;
|
||||
|
||||
const MAX_UPDATE_CHECK_HOURS_STABLE: u64 = 12;
|
||||
const MAX_UPDATE_CHECK_HOURS_BETA: u64 = 3;
|
||||
const MAX_UPDATE_CHECK_HOURS_ALPHA: u64 = 1;
|
||||
|
||||
// Create updater struct
|
||||
pub struct YaakUpdater {
|
||||
last_check: Option<Instant>,
|
||||
}
|
||||
|
||||
pub enum UpdateMode {
|
||||
Stable,
|
||||
Beta,
|
||||
Alpha,
|
||||
}
|
||||
|
||||
impl Display for UpdateMode {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
let s = match self {
|
||||
UpdateMode::Stable => "stable",
|
||||
UpdateMode::Beta => "beta",
|
||||
UpdateMode::Alpha => "alpha",
|
||||
};
|
||||
write!(f, "{}", s)
|
||||
}
|
||||
}
|
||||
|
||||
impl UpdateMode {
|
||||
pub fn new(mode: &str) -> UpdateMode {
|
||||
match mode {
|
||||
"beta" => UpdateMode::Beta,
|
||||
"alpha" => UpdateMode::Alpha,
|
||||
_ => UpdateMode::Stable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub enum UpdateTrigger {
|
||||
Background,
|
||||
User,
|
||||
}
|
||||
|
||||
impl YaakUpdater {
|
||||
pub fn new() -> Self {
|
||||
Self { last_check: None }
|
||||
}
|
||||
|
||||
pub async fn check_now<R: Runtime>(
|
||||
&mut self,
|
||||
window: &WebviewWindow<R>,
|
||||
mode: UpdateMode,
|
||||
auto_download: bool,
|
||||
update_trigger: UpdateTrigger,
|
||||
) -> Result<bool> {
|
||||
// Only AppImage supports updates on Linux, so skip if it's not
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if std::env::var("APPIMAGE").is_err() {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
|
||||
let settings = window.db().get_settings();
|
||||
let update_key = format!("{:x}", md5::compute(settings.id));
|
||||
self.last_check = Some(Instant::now());
|
||||
|
||||
info!("Checking for updates mode={} autodl={}", mode, auto_download);
|
||||
|
||||
let w = window.clone();
|
||||
let mut updater_builder = w.updater_builder();
|
||||
if let Some(proxy_url) = get_system_proxy_url() {
|
||||
if let Ok(url) = Url::parse(&proxy_url) {
|
||||
updater_builder = updater_builder.proxy(url);
|
||||
}
|
||||
}
|
||||
let update_check_result = updater_builder
|
||||
.on_before_exit(move || {
|
||||
// Kill plugin manager before exit or NSIS installer will fail to replace sidecar
|
||||
// while it's running.
|
||||
// NOTE: This is only called on Windows
|
||||
let w = w.clone();
|
||||
block_in_place(|| {
|
||||
tauri::async_runtime::block_on(async move {
|
||||
info!("Shutting down plugin manager before update");
|
||||
let plugin_manager = w.state::<PluginManager>();
|
||||
plugin_manager.terminate().await;
|
||||
});
|
||||
});
|
||||
})
|
||||
.header("X-Update-Mode", mode.to_string())?
|
||||
.header("X-Update-Key", update_key)?
|
||||
.header(
|
||||
"X-Update-Trigger",
|
||||
match update_trigger {
|
||||
UpdateTrigger::Background => "background",
|
||||
UpdateTrigger::User => "user",
|
||||
},
|
||||
)?
|
||||
.header("X-Install-Mode", detect_install_mode().unwrap_or("unknown"))?
|
||||
.build()?
|
||||
.check()
|
||||
.await;
|
||||
|
||||
let result = match update_check_result? {
|
||||
None => false,
|
||||
Some(update) => {
|
||||
let w = window.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// Force native updater if specified (useful if a release broke the UI)
|
||||
let native_install_mode =
|
||||
update.raw_json.get("install_mode").map(|v| v.as_str()).unwrap_or_default()
|
||||
== Some("native");
|
||||
if native_install_mode {
|
||||
start_native_update(&w, &update).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// If it's a background update, try downloading it first
|
||||
if update_trigger == UpdateTrigger::Background && auto_download {
|
||||
info!("Downloading update {} in background", update.version);
|
||||
if let Err(e) = download_update_idempotent(&w, &update).await {
|
||||
error!("Failed to download {}: {}", update.version, e);
|
||||
}
|
||||
}
|
||||
|
||||
match start_integrated_update(&w, &update).await {
|
||||
Ok(UpdateResponseAction::Skip) => {
|
||||
info!("Confirmed {}: skipped", update.version);
|
||||
}
|
||||
Ok(UpdateResponseAction::Install) => {
|
||||
info!("Confirmed {}: install", update.version);
|
||||
if let Err(e) = install_update_maybe_download(&w, &update).await {
|
||||
error!("Failed to install: {e}");
|
||||
return;
|
||||
};
|
||||
|
||||
info!("Installed {}", update.version);
|
||||
finish_integrated_update(&w, &update).await;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to notify frontend, falling back: {e}",);
|
||||
start_native_update(&w, &update).await;
|
||||
}
|
||||
};
|
||||
});
|
||||
true
|
||||
}
|
||||
};
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
pub async fn maybe_check<R: Runtime>(
|
||||
&mut self,
|
||||
window: &WebviewWindow<R>,
|
||||
auto_download: bool,
|
||||
mode: UpdateMode,
|
||||
) -> Result<bool> {
|
||||
let update_period_seconds = match mode {
|
||||
UpdateMode::Stable => MAX_UPDATE_CHECK_HOURS_STABLE,
|
||||
UpdateMode::Beta => MAX_UPDATE_CHECK_HOURS_BETA,
|
||||
UpdateMode::Alpha => MAX_UPDATE_CHECK_HOURS_ALPHA,
|
||||
} * (60 * 60);
|
||||
|
||||
if let Some(i) = self.last_check
|
||||
&& i.elapsed().as_secs() < update_period_seconds
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Don't check if development (can still with manual user trigger)
|
||||
if is_dev() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
self.check_now(window, mode, auto_download, UpdateTrigger::Background).await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Default, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "index.ts")]
|
||||
struct UpdateInfo {
|
||||
reply_event_id: String,
|
||||
version: String,
|
||||
downloaded: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase", tag = "type")]
|
||||
#[ts(export, export_to = "index.ts")]
|
||||
enum UpdateResponse {
|
||||
Ack,
|
||||
Action { action: UpdateResponseAction },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export, export_to = "index.ts")]
|
||||
enum UpdateResponseAction {
|
||||
Install,
|
||||
Skip,
|
||||
}
|
||||
|
||||
async fn finish_integrated_update<R: Runtime>(window: &WebviewWindow<R>, update: &Update) {
|
||||
if let Err(e) = window.emit_to(window.label(), "update_installed", update.version.to_string()) {
|
||||
warn!("Failed to notify frontend of update install: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_integrated_update<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
update: &Update,
|
||||
) -> Result<UpdateResponseAction> {
|
||||
let download_path = ensure_download_path(window, update)?;
|
||||
debug!("Download path: {}", download_path.display());
|
||||
let downloaded = download_path.exists();
|
||||
let ack_wait = Duration::from_secs(3);
|
||||
let reply_id = generate_id();
|
||||
|
||||
// 1) Start listening BEFORE emitting to avoid missing a fast reply
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<UpdateResponse>();
|
||||
let w_for_listener = window.clone();
|
||||
|
||||
let event_id = w_for_listener.listen(reply_id.clone(), move |ev| {
|
||||
match serde_json::from_str::<UpdateResponse>(ev.payload()) {
|
||||
Ok(UpdateResponse::Ack) => {
|
||||
let _ = tx.send(UpdateResponse::Ack);
|
||||
}
|
||||
Ok(UpdateResponse::Action { action }) => {
|
||||
let _ = tx.send(UpdateResponse::Action { action });
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to parse update reply from frontend: {e:?}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Make sure we always unlisten
|
||||
struct Unlisten<'a, R: Runtime> {
|
||||
win: &'a WebviewWindow<R>,
|
||||
id: tauri::EventId,
|
||||
}
|
||||
impl<'a, R: Runtime> Drop for Unlisten<'a, R> {
|
||||
fn drop(&mut self) {
|
||||
self.win.unlisten(self.id);
|
||||
}
|
||||
}
|
||||
let _guard = Unlisten { win: window, id: event_id };
|
||||
|
||||
// 2) Emit the event now that listener is in place
|
||||
let info =
|
||||
UpdateInfo { version: update.version.to_string(), downloaded, reply_event_id: reply_id };
|
||||
window
|
||||
.emit_to(window.label(), "update_available", &info)
|
||||
.map_err(|e| GenericError(format!("Failed to emit update_available: {e}")))?;
|
||||
|
||||
// 3) Two-stage timeout: first wait for ack, then wait for final action
|
||||
// --- Phase 1: wait for ACK with timeout ---
|
||||
let ack_timer = sleep(ack_wait);
|
||||
tokio::pin!(ack_timer);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg = rx.recv() => match msg {
|
||||
Some(UpdateResponse::Ack) => break, // proceed to Phase 2
|
||||
Some(UpdateResponse::Action{action}) => return Ok(action), // user was fast
|
||||
None => return Err(GenericError("frontend channel closed before ack".into())),
|
||||
},
|
||||
_ = &mut ack_timer => {
|
||||
return Err(GenericError("timed out waiting for frontend ack".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Phase 2: wait forever for final action ---
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Some(UpdateResponse::Action { action }) => return Ok(action),
|
||||
Some(UpdateResponse::Ack) => { /* ignore extra acks */ }
|
||||
None => return Err(GenericError("frontend channel closed before action".into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_native_update<R: Runtime>(window: &WebviewWindow<R>, update: &Update) {
|
||||
// If the frontend doesn't respond, fallback to native dialogs
|
||||
let confirmed = window
|
||||
.dialog()
|
||||
.message(format!(
|
||||
"{} is available. Would you like to download and install it now?",
|
||||
update.version
|
||||
))
|
||||
.buttons(MessageDialogButtons::OkCancelCustom("Download".to_string(), "Later".to_string()))
|
||||
.title("Update Available")
|
||||
.blocking_show();
|
||||
if !confirmed {
|
||||
return;
|
||||
}
|
||||
|
||||
match update.download_and_install(|_, _| {}, || {}).await {
|
||||
Ok(()) => {
|
||||
if window
|
||||
.dialog()
|
||||
.message("Would you like to restart the app?")
|
||||
.title("Update Installed")
|
||||
.buttons(MessageDialogButtons::OkCancelCustom(
|
||||
"Restart".to_string(),
|
||||
"Later".to_string(),
|
||||
))
|
||||
.blocking_show()
|
||||
{
|
||||
window.app_handle().request_restart();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
window.dialog().message(format!("The update failed to install: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn download_update_idempotent<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
update: &Update,
|
||||
) -> Result<PathBuf> {
|
||||
let dl_path = ensure_download_path(window, update)?;
|
||||
|
||||
if dl_path.exists() {
|
||||
info!("{} already downloaded to {}", update.version, dl_path.display());
|
||||
return Ok(dl_path);
|
||||
}
|
||||
|
||||
info!("{} downloading: {}", update.version, dl_path.display());
|
||||
let dl_bytes = update.download(|_, _| {}, || {}).await?;
|
||||
std::fs::write(&dl_path, dl_bytes)
|
||||
.map_err(|e| GenericError(format!("Failed to write update: {e}")))?;
|
||||
|
||||
info!("{} downloaded", update.version);
|
||||
|
||||
Ok(dl_path)
|
||||
}
|
||||
|
||||
/// Detect the installer type so the update server can serve the correct artifact.
|
||||
fn detect_install_mode() -> Option<&'static str> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
let path = exe.to_string_lossy().to_lowercase();
|
||||
if path.starts_with(r"c:\program files") {
|
||||
return Some("nsis-machine");
|
||||
}
|
||||
}
|
||||
return Some("nsis");
|
||||
}
|
||||
#[allow(unreachable_code)]
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn install_update_maybe_download<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
update: &Update,
|
||||
) -> Result<()> {
|
||||
let dl_path = download_update_idempotent(window, update).await?;
|
||||
let update_bytes = std::fs::read(&dl_path)?;
|
||||
update.install(update_bytes.as_slice())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn ensure_download_path<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
update: &Update,
|
||||
) -> Result<PathBuf> {
|
||||
// Ensure dir exists
|
||||
let base_dir = window.path().app_cache_dir()?.join("updates");
|
||||
std::fs::create_dir_all(&base_dir)?;
|
||||
|
||||
// Generate name based on signature
|
||||
let sig_digest = md5::compute(&update.signature);
|
||||
let name = format!("yaak-{}-{:x}", update.version, sig_digest);
|
||||
let dl_path = base_dir.join(name);
|
||||
|
||||
Ok(dl_path)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
use crate::PluginContextExt;
|
||||
use crate::error::Result;
|
||||
use crate::import::import_data;
|
||||
use crate::models_ext::QueryManagerExt;
|
||||
use log::{info, warn};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Emitter, Manager, Runtime, Url};
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
|
||||
use yaak_api::{ApiClientKind, yaak_api_client};
|
||||
use yaak_models::util::generate_id;
|
||||
use yaak_plugins::events::{Color, ShowToastRequest};
|
||||
use yaak_plugins::install::download_and_install;
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
|
||||
pub(crate) async fn handle_deep_link<R: Runtime>(
|
||||
app_handle: &AppHandle<R>,
|
||||
url: &Url,
|
||||
) -> Result<()> {
|
||||
let command = url.domain().unwrap_or_default();
|
||||
info!("Yaak URI scheme invoked {}?{}", command, url.query().unwrap_or_default());
|
||||
|
||||
let query_map: HashMap<String, String> = url.query_pairs().into_owned().collect();
|
||||
let windows = app_handle.webview_windows();
|
||||
let (_, window) = windows.iter().next().unwrap();
|
||||
|
||||
match command {
|
||||
"install-plugin" => {
|
||||
let name = query_map.get("name").unwrap();
|
||||
let version = query_map.get("version").cloned();
|
||||
_ = window.set_focus();
|
||||
let confirmed_install = app_handle
|
||||
.dialog()
|
||||
.message(format!("Install plugin {name} {version:?}?"))
|
||||
.kind(MessageDialogKind::Info)
|
||||
.buttons(MessageDialogButtons::OkCancelCustom(
|
||||
"Install".to_string(),
|
||||
"Cancel".to_string(),
|
||||
))
|
||||
.blocking_show();
|
||||
if !confirmed_install {
|
||||
// Cancelled installation
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
|
||||
let query_manager = app_handle.db_manager();
|
||||
let app_version = app_handle.package_info().version.to_string();
|
||||
let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;
|
||||
let plugin_context = window.plugin_context();
|
||||
let pv = download_and_install(
|
||||
plugin_manager,
|
||||
&query_manager,
|
||||
&http_client,
|
||||
&plugin_context,
|
||||
name,
|
||||
version,
|
||||
)
|
||||
.await?;
|
||||
app_handle.emit(
|
||||
"show_toast",
|
||||
ShowToastRequest {
|
||||
message: format!("Installed {name}@{}", pv.version),
|
||||
color: Some(Color::Success),
|
||||
icon: None,
|
||||
timeout: Some(5000),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
"import-data" => {
|
||||
let mut file_path = query_map.get("path").map(|s| s.to_owned());
|
||||
let name = query_map.get("name").map(|s| s.to_owned()).unwrap_or("data".to_string());
|
||||
_ = window.set_focus();
|
||||
|
||||
if let Some(file_url) = query_map.get("url") {
|
||||
let confirmed_import = app_handle
|
||||
.dialog()
|
||||
.message(format!("Import {name} from {file_url}?"))
|
||||
.kind(MessageDialogKind::Info)
|
||||
.buttons(MessageDialogButtons::OkCancelCustom(
|
||||
"Import".to_string(),
|
||||
"Cancel".to_string(),
|
||||
))
|
||||
.blocking_show();
|
||||
if !confirmed_import {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let app_version = app_handle.package_info().version.to_string();
|
||||
let resp =
|
||||
yaak_api_client(ApiClientKind::App, &app_version)?.get(file_url).send().await?;
|
||||
let json = resp.bytes().await?;
|
||||
let p = app_handle
|
||||
.path()
|
||||
.temp_dir()?
|
||||
.join(format!("import-{}", generate_id()))
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
fs::write(&p, json)?;
|
||||
file_path = Some(p);
|
||||
}
|
||||
|
||||
let file_path = match file_path {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
app_handle.emit(
|
||||
"show_toast",
|
||||
ShowToastRequest {
|
||||
message: "Failed to import data".to_string(),
|
||||
color: Some(Color::Danger),
|
||||
icon: None,
|
||||
timeout: None,
|
||||
},
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let results = import_data(window, &file_path).await?;
|
||||
window.emit(
|
||||
"show_toast",
|
||||
ShowToastRequest {
|
||||
message: format!("Imported data for {} workspaces", results.workspaces.len()),
|
||||
color: Some(Color::Success),
|
||||
icon: None,
|
||||
timeout: Some(5000),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
_ => {
|
||||
warn!("Unknown deep link command: {command}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
pub use tauri::AppHandle;
|
||||
use tauri::Runtime;
|
||||
use tauri::menu::{
|
||||
AboutMetadata, HELP_SUBMENU_ID, Menu, MenuItemBuilder, PredefinedMenuItem, Submenu,
|
||||
WINDOW_SUBMENU_ID,
|
||||
};
|
||||
|
||||
pub fn app_menu<R: Runtime>(app_handle: &AppHandle<R>) -> tauri::Result<Menu<R>> {
|
||||
let pkg_info = app_handle.package_info();
|
||||
let config = app_handle.config();
|
||||
let about_metadata = AboutMetadata {
|
||||
name: Some(pkg_info.name.clone()),
|
||||
version: Some(pkg_info.version.to_string()),
|
||||
copyright: config.bundle.copyright.clone(),
|
||||
authors: config.bundle.publisher.clone().map(|p| vec![p]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let window_menu = Submenu::with_id_and_items(
|
||||
app_handle,
|
||||
WINDOW_SUBMENU_ID,
|
||||
"Window",
|
||||
true,
|
||||
&[
|
||||
&PredefinedMenuItem::minimize(app_handle, None)?,
|
||||
&PredefinedMenuItem::maximize(app_handle, None)?,
|
||||
#[cfg(target_os = "macos")]
|
||||
&PredefinedMenuItem::separator(app_handle)?,
|
||||
&PredefinedMenuItem::close_window(app_handle, None)?,
|
||||
],
|
||||
)?;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
window_menu.set_as_windows_menu_for_nsapp()?;
|
||||
}
|
||||
|
||||
let help_menu = Submenu::with_id_and_items(
|
||||
app_handle,
|
||||
HELP_SUBMENU_ID,
|
||||
"Help",
|
||||
true,
|
||||
&[
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
&PredefinedMenuItem::about(app_handle, None, Some(about_metadata.clone()))?,
|
||||
#[cfg(target_os = "macos")]
|
||||
&MenuItemBuilder::with_id("open_feedback".to_string(), "Give Feedback")
|
||||
.build(app_handle)?,
|
||||
],
|
||||
)?;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
help_menu.set_as_windows_menu_for_nsapp()?;
|
||||
}
|
||||
|
||||
let menu = Menu::with_items(
|
||||
app_handle,
|
||||
&[
|
||||
#[cfg(target_os = "macos")]
|
||||
&Submenu::with_items(
|
||||
app_handle,
|
||||
pkg_info.name.clone(),
|
||||
true,
|
||||
&[
|
||||
&PredefinedMenuItem::about(app_handle, None, Some(about_metadata))?,
|
||||
&PredefinedMenuItem::separator(app_handle)?,
|
||||
&MenuItemBuilder::with_id("settings".to_string(), "Settings")
|
||||
.accelerator("CmdOrCtrl+,")
|
||||
.build(app_handle)?,
|
||||
&PredefinedMenuItem::separator(app_handle)?,
|
||||
&PredefinedMenuItem::services(app_handle, None)?,
|
||||
&PredefinedMenuItem::separator(app_handle)?,
|
||||
&PredefinedMenuItem::hide(app_handle, None)?,
|
||||
&PredefinedMenuItem::hide_others(app_handle, None)?,
|
||||
&PredefinedMenuItem::separator(app_handle)?,
|
||||
// NOTE: Replace the predefined quit item with a custom one because, for some
|
||||
// reason, ExitRequested events are not fired on cmd+Q. Perhaps this will be
|
||||
// fixed in the future?
|
||||
// https://github.com/tauri-apps/tauri/issues/9198
|
||||
&MenuItemBuilder::with_id(
|
||||
"hacked_quit".to_string(),
|
||||
format!("Quit {}", app_handle.package_info().name),
|
||||
)
|
||||
.accelerator("CmdOrCtrl+q")
|
||||
.build(app_handle)?,
|
||||
],
|
||||
)?,
|
||||
#[cfg(not(any(
|
||||
target_os = "linux",
|
||||
target_os = "dragonfly",
|
||||
target_os = "freebsd",
|
||||
target_os = "netbsd",
|
||||
target_os = "openbsd"
|
||||
)))]
|
||||
&Submenu::with_items(
|
||||
app_handle,
|
||||
"File",
|
||||
true,
|
||||
&[
|
||||
&PredefinedMenuItem::close_window(app_handle, None)?,
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
&PredefinedMenuItem::quit(app_handle, None)?,
|
||||
],
|
||||
)?,
|
||||
&Submenu::with_items(
|
||||
app_handle,
|
||||
"Edit",
|
||||
true,
|
||||
&[
|
||||
&PredefinedMenuItem::undo(app_handle, None)?,
|
||||
&PredefinedMenuItem::redo(app_handle, None)?,
|
||||
&PredefinedMenuItem::separator(app_handle)?,
|
||||
&PredefinedMenuItem::cut(app_handle, None)?,
|
||||
&PredefinedMenuItem::copy(app_handle, None)?,
|
||||
&PredefinedMenuItem::paste(app_handle, None)?,
|
||||
&PredefinedMenuItem::select_all(app_handle, None)?,
|
||||
],
|
||||
)?,
|
||||
&Submenu::with_items(
|
||||
app_handle,
|
||||
"View",
|
||||
true,
|
||||
&[
|
||||
#[cfg(target_os = "macos")]
|
||||
&PredefinedMenuItem::fullscreen(app_handle, None)?,
|
||||
#[cfg(target_os = "macos")]
|
||||
&PredefinedMenuItem::separator(app_handle)?,
|
||||
&MenuItemBuilder::with_id("zoom_reset".to_string(), "Zoom to Actual Size")
|
||||
.accelerator("CmdOrCtrl+0")
|
||||
.build(app_handle)?,
|
||||
&MenuItemBuilder::with_id("zoom_in".to_string(), "Zoom In")
|
||||
.accelerator("CmdOrCtrl+=")
|
||||
.build(app_handle)?,
|
||||
&MenuItemBuilder::with_id("zoom_out".to_string(), "Zoom Out")
|
||||
.accelerator("CmdOrCtrl+-")
|
||||
.build(app_handle)?,
|
||||
],
|
||||
)?,
|
||||
&window_menu,
|
||||
&help_menu,
|
||||
#[cfg(dev)]
|
||||
&Submenu::with_items(
|
||||
app_handle,
|
||||
"Develop",
|
||||
true,
|
||||
&[
|
||||
&MenuItemBuilder::with_id("dev.refresh".to_string(), "Refresh")
|
||||
.accelerator("CmdOrCtrl+Shift+r")
|
||||
.build(app_handle)?,
|
||||
&MenuItemBuilder::with_id("dev.toggle_devtools".to_string(), "Open Devtools")
|
||||
.accelerator("CmdOrCtrl+Option+i")
|
||||
.build(app_handle)?,
|
||||
&MenuItemBuilder::with_id("dev.reset_size".to_string(), "Reset Size")
|
||||
.build(app_handle)?,
|
||||
&MenuItemBuilder::with_id("dev.reset_size_16x9".to_string(), "Resize to 16x9")
|
||||
.build(app_handle)?,
|
||||
&MenuItemBuilder::with_id(
|
||||
"dev.reset_size_16x10".to_string(),
|
||||
"Resize to 16x10",
|
||||
)
|
||||
.build(app_handle)?,
|
||||
&MenuItemBuilder::with_id(
|
||||
"dev.generate_theme_css".to_string(),
|
||||
"Generate Theme CSS",
|
||||
)
|
||||
.build(app_handle)?,
|
||||
],
|
||||
)?,
|
||||
],
|
||||
)?;
|
||||
|
||||
Ok(menu)
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
//! WebSocket Tauri command wrappers
|
||||
//! These wrap the core yaak-ws functionality for Tauri IPC.
|
||||
|
||||
use crate::PluginContextExt;
|
||||
use crate::error::Result;
|
||||
use crate::models_ext::QueryManagerExt;
|
||||
use http::HeaderMap;
|
||||
use log::{debug, info, warn};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use tauri::http::HeaderValue;
|
||||
use tauri::{AppHandle, Manager, Runtime, State, WebviewWindow, command};
|
||||
use tokio::sync::{Mutex, mpsc};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use url::Url;
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_http::cookies::CookieStore;
|
||||
use yaak_http::path_placeholders::apply_path_placeholders;
|
||||
use yaak_models::models::{
|
||||
HttpResponseHeader, WebsocketConnection, WebsocketConnectionState, WebsocketEvent,
|
||||
WebsocketEventType, WebsocketRequest,
|
||||
};
|
||||
use yaak_models::util::UpdateSource;
|
||||
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader, RenderPurpose};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||
use yaak_templates::strip_json_comments::maybe_strip_json_comments;
|
||||
use yaak_templates::{RenderErrorBehavior, RenderOptions};
|
||||
use yaak_tls::find_client_certificate;
|
||||
use yaak_ws::{WebsocketManager, render_websocket_request};
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_ws_delete_connections<R: Runtime>(
|
||||
request_id: &str,
|
||||
app_handle: AppHandle<R>,
|
||||
window: WebviewWindow<R>,
|
||||
) -> Result<()> {
|
||||
Ok(app_handle.db().delete_all_websocket_connections_for_request(
|
||||
request_id,
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_ws_send<R: Runtime>(
|
||||
connection_id: &str,
|
||||
environment_id: Option<&str>,
|
||||
app_handle: AppHandle<R>,
|
||||
window: WebviewWindow<R>,
|
||||
ws_manager: State<'_, Mutex<WebsocketManager>>,
|
||||
) -> Result<WebsocketConnection> {
|
||||
let connection = app_handle.db().get_websocket_connection(connection_id)?;
|
||||
let unrendered_request = app_handle.db().get_websocket_request(&connection.request_id)?;
|
||||
let environment_chain = app_handle.db().resolve_environments(
|
||||
&unrendered_request.workspace_id,
|
||||
unrendered_request.folder_id.as_deref(),
|
||||
environment_id,
|
||||
)?;
|
||||
let (resolved_request, _auth_context_id) =
|
||||
resolve_websocket_request(&window, &unrendered_request)?;
|
||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
let request = render_websocket_request(
|
||||
&resolved_request,
|
||||
environment_chain,
|
||||
&PluginTemplateCallback::new(
|
||||
plugin_manager,
|
||||
encryption_manager,
|
||||
&window.plugin_context(),
|
||||
RenderPurpose::Send,
|
||||
),
|
||||
&RenderOptions { error_behavior: RenderErrorBehavior::Throw },
|
||||
)
|
||||
.await?;
|
||||
|
||||
let message = maybe_strip_json_comments(&request.message);
|
||||
|
||||
let mut ws_manager = ws_manager.lock().await;
|
||||
ws_manager.send(&connection.id, Message::Text(message.clone().into())).await?;
|
||||
|
||||
app_handle.db().upsert_websocket_event(
|
||||
&WebsocketEvent {
|
||||
connection_id: connection.id.clone(),
|
||||
request_id: request.id.clone(),
|
||||
workspace_id: connection.workspace_id.clone(),
|
||||
is_server: false,
|
||||
message_type: WebsocketEventType::Text,
|
||||
message: message.into(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)?;
|
||||
|
||||
Ok(connection)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_ws_close<R: Runtime>(
|
||||
connection_id: &str,
|
||||
app_handle: AppHandle<R>,
|
||||
window: WebviewWindow<R>,
|
||||
ws_manager: State<'_, Mutex<WebsocketManager>>,
|
||||
) -> Result<WebsocketConnection> {
|
||||
let connection = {
|
||||
let db = app_handle.db();
|
||||
let connection = db.get_websocket_connection(connection_id)?;
|
||||
db.upsert_websocket_connection(
|
||||
&WebsocketConnection { state: WebsocketConnectionState::Closing, ..connection },
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)?
|
||||
};
|
||||
|
||||
let mut ws_manager = ws_manager.lock().await;
|
||||
if let Err(e) = ws_manager.close(&connection.id).await {
|
||||
warn!("Failed to close WebSocket connection: {e:?}");
|
||||
};
|
||||
|
||||
Ok(connection)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_ws_connect<R: Runtime>(
|
||||
request_id: &str,
|
||||
environment_id: Option<&str>,
|
||||
cookie_jar_id: Option<&str>,
|
||||
app_handle: AppHandle<R>,
|
||||
window: WebviewWindow<R>,
|
||||
_plugin_manager: State<'_, PluginManager>,
|
||||
ws_manager: State<'_, Mutex<WebsocketManager>>,
|
||||
) -> Result<WebsocketConnection> {
|
||||
let unrendered_request = app_handle.db().get_websocket_request(request_id)?;
|
||||
let environment_chain = app_handle.db().resolve_environments(
|
||||
&unrendered_request.workspace_id,
|
||||
unrendered_request.folder_id.as_deref(),
|
||||
environment_id,
|
||||
)?;
|
||||
let workspace = app_handle.db().get_workspace(&unrendered_request.workspace_id)?;
|
||||
let settings = app_handle.db().get_settings();
|
||||
let (resolved_request, auth_context_id) =
|
||||
resolve_websocket_request(&window, &unrendered_request)?;
|
||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
let request = render_websocket_request(
|
||||
&resolved_request,
|
||||
environment_chain,
|
||||
&PluginTemplateCallback::new(
|
||||
plugin_manager.clone(),
|
||||
encryption_manager.clone(),
|
||||
&window.plugin_context(),
|
||||
RenderPurpose::Send,
|
||||
),
|
||||
&RenderOptions { error_behavior: RenderErrorBehavior::Throw },
|
||||
)
|
||||
.await?;
|
||||
|
||||
let connection = app_handle.db().upsert_websocket_connection(
|
||||
&WebsocketConnection {
|
||||
workspace_id: request.workspace_id.clone(),
|
||||
request_id: request_id.to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)?;
|
||||
|
||||
let (mut url, url_parameters) = apply_path_placeholders(&request.url, &request.url_parameters);
|
||||
if !url.starts_with("ws://") && !url.starts_with("wss://") {
|
||||
url.insert_str(0, "ws://");
|
||||
}
|
||||
|
||||
// Add URL parameters to URL
|
||||
let mut url = match Url::parse(&url) {
|
||||
Ok(url) => url,
|
||||
Err(e) => {
|
||||
return Ok(app_handle.db().upsert_websocket_connection(
|
||||
&WebsocketConnection {
|
||||
error: Some(format!("Failed to parse URL {}", e.to_string())),
|
||||
state: WebsocketConnectionState::Closed,
|
||||
..connection
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)?);
|
||||
}
|
||||
};
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
for h in request.headers.clone() {
|
||||
if h.name.is_empty() && h.value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !h.enabled {
|
||||
continue;
|
||||
}
|
||||
|
||||
headers.insert(
|
||||
http::HeaderName::from_str(&h.name).unwrap(),
|
||||
HeaderValue::from_str(&h.value).unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
match request.authentication_type {
|
||||
None => {
|
||||
// No authentication found. Not even inherited
|
||||
}
|
||||
Some(authentication_type) if authentication_type == "none" => {
|
||||
// Explicitly no authentication
|
||||
}
|
||||
Some(authentication_type) => {
|
||||
let auth = request.authentication.clone();
|
||||
let plugin_req = CallHttpAuthenticationRequest {
|
||||
context_id: format!("{:x}", md5::compute(auth_context_id)),
|
||||
values: serde_json::from_value(serde_json::to_value(&auth).unwrap()).unwrap(),
|
||||
method: "POST".to_string(),
|
||||
url: request.url.clone(),
|
||||
headers: request
|
||||
.headers
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|h| HttpHeader { name: h.name, value: h.value })
|
||||
.collect(),
|
||||
};
|
||||
let plugin_result = plugin_manager
|
||||
.call_http_authentication(
|
||||
&window.plugin_context(),
|
||||
&authentication_type,
|
||||
plugin_req,
|
||||
)
|
||||
.await?;
|
||||
for header in plugin_result.set_headers.unwrap_or_default() {
|
||||
match (
|
||||
http::HeaderName::from_str(&header.name),
|
||||
HeaderValue::from_str(&header.value),
|
||||
) {
|
||||
(Ok(name), Ok(value)) => {
|
||||
headers.insert(name, value);
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
}
|
||||
if let Some(params) = plugin_result.set_query_parameters {
|
||||
let mut query_pairs = url.query_pairs_mut();
|
||||
for p in params {
|
||||
query_pairs.append_pair(&p.name, &p.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add cookies to WS HTTP Upgrade
|
||||
if let Some(id) = cookie_jar_id {
|
||||
let cookie_jar = app_handle.db().get_cookie_jar(&id)?;
|
||||
let store = CookieStore::from_cookies(cookie_jar.cookies);
|
||||
|
||||
// Convert WS URL -> HTTP URL because our cookie store matches based on
|
||||
// Path/HttpOnly/Secure attributes even though WS upgrades are HTTP requests
|
||||
let http_url = convert_ws_url_to_http(&url);
|
||||
if let Some(cookie_header_value) = store.get_cookie_header(&http_url) {
|
||||
debug!("Inserting cookies into WS upgrade to {}: {}", url, cookie_header_value);
|
||||
headers.insert(
|
||||
http::HeaderName::from_static("cookie"),
|
||||
HeaderValue::from_str(&cookie_header_value).unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let (receive_tx, mut receive_rx) = mpsc::channel::<Message>(128);
|
||||
let mut ws_manager = ws_manager.lock().await;
|
||||
|
||||
{
|
||||
let valid_query_pairs = url_parameters
|
||||
.into_iter()
|
||||
.filter(|p| p.enabled && !p.name.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
// NOTE: Only mutate query pairs if there are any, or it will append an empty `?` to the URL
|
||||
if !valid_query_pairs.is_empty() {
|
||||
let mut query_pairs = url.query_pairs_mut();
|
||||
for p in valid_query_pairs {
|
||||
query_pairs.append_pair(p.name.as_str(), p.value.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let client_cert = find_client_certificate(url.as_str(), &settings.client_certificates);
|
||||
|
||||
let response = match ws_manager
|
||||
.connect(
|
||||
&connection.id,
|
||||
url.as_str(),
|
||||
headers,
|
||||
receive_tx,
|
||||
workspace.setting_validate_certificates,
|
||||
client_cert,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return Ok(app_handle.db().upsert_websocket_connection(
|
||||
&WebsocketConnection {
|
||||
error: Some(e.to_string()),
|
||||
state: WebsocketConnectionState::Closed,
|
||||
..connection
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)?);
|
||||
}
|
||||
};
|
||||
|
||||
app_handle.db().upsert_websocket_event(
|
||||
&WebsocketEvent {
|
||||
connection_id: connection.id.clone(),
|
||||
request_id: request.id.clone(),
|
||||
workspace_id: connection.workspace_id.clone(),
|
||||
is_server: false,
|
||||
message_type: WebsocketEventType::Open,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)?;
|
||||
|
||||
let response_headers = response
|
||||
.headers()
|
||||
.into_iter()
|
||||
.map(|(name, value)| HttpResponseHeader {
|
||||
name: name.to_string(),
|
||||
value: value.to_str().unwrap().to_string(),
|
||||
})
|
||||
.collect::<Vec<HttpResponseHeader>>();
|
||||
|
||||
let connection = app_handle.db().upsert_websocket_connection(
|
||||
&WebsocketConnection {
|
||||
state: WebsocketConnectionState::Connected,
|
||||
headers: response_headers,
|
||||
status: response.status().as_u16() as i32,
|
||||
url: request.url.clone(),
|
||||
..connection
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)?;
|
||||
|
||||
{
|
||||
let connection_id = connection.id.clone();
|
||||
let request_id = request.id.to_string();
|
||||
let workspace_id = request.workspace_id.clone();
|
||||
let connection = connection.clone();
|
||||
let window_label = window.label().to_string();
|
||||
let mut has_written_close = false;
|
||||
tokio::spawn(async move {
|
||||
while let Some(message) = receive_rx.recv().await {
|
||||
if let Message::Close(_) = message {
|
||||
has_written_close = true;
|
||||
}
|
||||
|
||||
app_handle
|
||||
.db()
|
||||
.upsert_websocket_event(
|
||||
&WebsocketEvent {
|
||||
connection_id: connection_id.clone(),
|
||||
request_id: request_id.clone(),
|
||||
workspace_id: workspace_id.clone(),
|
||||
is_server: true,
|
||||
message_type: match message {
|
||||
Message::Text(_) => WebsocketEventType::Text,
|
||||
Message::Binary(_) => WebsocketEventType::Binary,
|
||||
Message::Ping(_) => WebsocketEventType::Ping,
|
||||
Message::Pong(_) => WebsocketEventType::Pong,
|
||||
Message::Close(_) => WebsocketEventType::Close,
|
||||
// Raw frame will never happen during a read
|
||||
Message::Frame(_) => WebsocketEventType::Frame,
|
||||
},
|
||||
message: message.into_data().into(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::from_window_label(&window_label),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
info!("Websocket connection closed");
|
||||
if !has_written_close {
|
||||
app_handle
|
||||
.db()
|
||||
.upsert_websocket_event(
|
||||
&WebsocketEvent {
|
||||
connection_id: connection_id.clone(),
|
||||
request_id: request_id.clone(),
|
||||
workspace_id: workspace_id.clone(),
|
||||
is_server: true,
|
||||
message_type: WebsocketEventType::Close,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::from_window_label(&window_label),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
app_handle
|
||||
.db()
|
||||
.upsert_websocket_connection(
|
||||
&WebsocketConnection {
|
||||
workspace_id: request.workspace_id.clone(),
|
||||
request_id: request_id.to_string(),
|
||||
state: WebsocketConnectionState::Closed,
|
||||
..connection
|
||||
},
|
||||
&UpdateSource::from_window_label(&window_label),
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
Ok(connection)
|
||||
}
|
||||
|
||||
/// Resolve inherited authentication and headers for a websocket request
|
||||
fn resolve_websocket_request<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
request: &WebsocketRequest,
|
||||
) -> Result<(WebsocketRequest, String)> {
|
||||
let mut new_request = request.clone();
|
||||
|
||||
let (authentication_type, authentication, authentication_context_id) =
|
||||
window.db().resolve_auth_for_websocket_request(request)?;
|
||||
new_request.authentication_type = authentication_type;
|
||||
new_request.authentication = authentication;
|
||||
|
||||
let headers = window.db().resolve_headers_for_websocket_request(request)?;
|
||||
new_request.headers = headers;
|
||||
|
||||
Ok((new_request, authentication_context_id))
|
||||
}
|
||||
|
||||
/// Convert WS URL to HTTP URL for cookie filtering
|
||||
/// WebSocket upgrade requests are HTTP requests initially, so HttpOnly cookies should apply
|
||||
fn convert_ws_url_to_http(ws_url: &Url) -> Url {
|
||||
let mut http_url = ws_url.clone();
|
||||
|
||||
match ws_url.scheme() {
|
||||
"ws" => {
|
||||
http_url.set_scheme("http").expect("Failed to set http scheme");
|
||||
}
|
||||
"wss" => {
|
||||
http_url.set_scheme("https").expect("Failed to set https scheme");
|
||||
}
|
||||
_ => {
|
||||
// Already HTTP/HTTPS, no conversion needed
|
||||
}
|
||||
}
|
||||
|
||||
http_url
|
||||
}
|
||||
Reference in New Issue
Block a user