CLI command architecture and DB-backed model update syncing (#397)

This commit is contained in:
Gregory Schier
2026-02-17 08:20:31 -08:00
committed by GitHub
parent 0a4ffde319
commit e1580210dc
48 changed files with 3818 additions and 1180 deletions

View File

@@ -61,6 +61,7 @@ yaak-api = { workspace = true }
yaak-common = { workspace = true }
yaak-tauri-utils = { workspace = true }
yaak-core = { workspace = true }
yaak = { workspace = true }
yaak-crypto = { workspace = true }
yaak-fonts = { workspace = true }
yaak-git = { workspace = true }

View File

@@ -3,45 +3,18 @@ use crate::error::Error::GenericError;
use crate::error::Result;
use crate::models_ext::BlobManagerExt;
use crate::models_ext::QueryManagerExt;
use crate::render::render_http_request;
use log::{debug, warn};
use std::pin::Pin;
use log::warn;
use std::sync::Arc;
use std::sync::atomic::{AtomicI32, Ordering};
use std::time::{Duration, Instant};
use std::time::Instant;
use tauri::{AppHandle, Manager, Runtime, WebviewWindow};
use tokio::fs::{File, create_dir_all};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
use tokio::sync::watch::Receiver;
use tokio_util::bytes::Bytes;
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
use yaak_crypto::manager::EncryptionManager;
use yaak_http::client::{
HttpConnectionOptions, HttpConnectionProxySetting, HttpConnectionProxySettingAuth,
};
use yaak_http::cookies::CookieStore;
use yaak_http::manager::{CachedClient, HttpConnectionManager};
use yaak_http::sender::ReqwestSender;
use yaak_http::tee_reader::TeeReader;
use yaak_http::transaction::HttpTransaction;
use yaak_http::types::{
SendableBody, SendableHttpRequest, SendableHttpRequestOptions, append_query_params,
};
use yaak_models::blob_manager::BodyChunk;
use yaak_models::models::{
CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseHeader,
HttpResponseState, ProxySetting, ProxySettingAuth,
};
use yaak_http::manager::HttpConnectionManager;
use yaak_models::models::{CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseState};
use yaak_models::util::UpdateSource;
use yaak_plugins::events::{
CallHttpAuthenticationRequest, HttpHeader, PluginContext, RenderPurpose,
};
use yaak_plugins::events::PluginContext;
use yaak_plugins::manager::PluginManager;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_templates::RenderOptions;
use yaak_tls::find_client_certificate;
/// Chunk size for storing request bodies (1MB)
const REQUEST_BODY_CHUNK_SIZE: usize = 1024 * 1024;
/// Context for managing response state during HTTP transactions.
/// Handles both persisted responses (stored in DB) and ephemeral responses (in-memory only).
@@ -168,148 +141,30 @@ async fn send_http_request_inner<R: Runtime>(
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 settings = window.db().get_settings();
let workspace_id = &unrendered_request.workspace_id;
let folder_id = unrendered_request.folder_id.as_deref();
let environment_id = environment.map(|e| e.id);
let workspace = window.db().get_workspace(workspace_id)?;
let (resolved, auth_context_id) = resolve_http_request(window, unrendered_request)?;
let cb = PluginTemplateCallback::new(
plugin_manager.clone(),
encryption_manager.clone(),
&plugin_context,
RenderPurpose::Send,
);
let env_chain =
window.db().resolve_environments(&workspace.id, folder_id, environment_id.as_deref())?;
let mut cancel_rx = cancelled_rx.clone();
let render_options = RenderOptions::throw();
let request = tokio::select! {
result = render_http_request(&resolved, env_chain, &cb, &render_options) => result?,
_ = cancel_rx.changed() => {
return Err(GenericError("Request canceled".to_string()));
}
};
let cookie_jar_id = cookie_jar.as_ref().map(|jar| jar.id.clone());
// Build the sendable request using the new SendableHttpRequest type
let options = SendableHttpRequestOptions {
follow_redirects: workspace.setting_follow_redirects,
timeout: if workspace.setting_request_timeout > 0 {
Some(Duration::from_millis(workspace.setting_request_timeout.unsigned_abs() as u64))
} else {
None
},
};
let mut sendable_request = SendableHttpRequest::from_http_request(&request, options).await?;
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,
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()))?;
debug!("Sending request to {} {}", sendable_request.method, sendable_request.url);
let proxy_setting = match settings.proxy {
None => HttpConnectionProxySetting::System,
Some(ProxySetting::Disabled) => HttpConnectionProxySetting::Disabled,
Some(ProxySetting::Enabled { http, https, auth, bypass, disabled }) => {
if disabled {
HttpConnectionProxySetting::System
} else {
HttpConnectionProxySetting::Enabled {
http,
https,
bypass,
auth: match auth {
None => None,
Some(ProxySettingAuth { user, password }) => {
Some(HttpConnectionProxySettingAuth { user, password })
}
},
}
}
}
};
let client_certificate =
find_client_certificate(&sendable_request.url, &settings.client_certificates);
// Create cookie store if a cookie jar is specified
let maybe_cookie_store = match cookie_jar.clone() {
Some(CookieJar { id, .. }) => {
// NOTE: We need to refetch the cookie jar because a chained request might have
// updated cookies when we rendered the request.
let cj = window.db().get_cookie_jar(&id)?;
let cookie_store = CookieStore::from_cookies(cj.cookies.clone());
Some((cookie_store, cj))
}
None => None,
};
let cached_client = connection_manager
.get_client(&HttpConnectionOptions {
id: plugin_context.id.clone(),
validate_certificates: workspace.setting_validate_certificates,
proxy: proxy_setting,
client_certificate,
dns_overrides: workspace.setting_dns_overrides.clone(),
})
.await?;
// Apply authentication to the request, racing against cancellation since
// auth plugins (e.g. OAuth2) can block indefinitely waiting for user action.
let mut cancel_rx = cancelled_rx.clone();
tokio::select! {
result = apply_authentication(
&window,
&mut sendable_request,
&request,
auth_context_id,
&plugin_manager,
plugin_context,
) => result?,
_ = cancel_rx.changed() => {
return Err(GenericError("Request canceled".to_string()));
}
};
let cookie_store = maybe_cookie_store.as_ref().map(|(cs, _)| cs.clone());
let result = execute_transaction(
cached_client,
sendable_request,
response_ctx,
cancelled_rx.clone(),
cookie_store,
)
.await;
// Wait for blob writing to complete and check for errors
let final_result = match result {
Ok((response, maybe_blob_write_handle)) => {
// Check if blob writing failed
if let Some(handle) = maybe_blob_write_handle {
if let Ok(Err(e)) = handle.await {
// Update response with the storage error
let _ = response_ctx.update(|r| {
let error_msg =
format!("Request succeeded but failed to store request body: {}", e);
r.error = Some(match &r.error {
Some(existing) => format!("{}; {}", existing, error_msg),
None => error_msg,
});
});
}
}
Ok(response)
}
Err(e) => Err(e),
};
// Persist cookies back to the database after the request completes
if let Some((cookie_store, mut cj)) = maybe_cookie_store {
let cookies = cookie_store.get_all_cookies();
cj.cookies = cookies;
if let Err(e) = window.db().upsert_cookie_jar(&cj, &UpdateSource::Background) {
warn!("Failed to persist cookies to database: {}", e);
}
}
final_result
Ok(result.response)
}
pub fn resolve_http_request<R: Runtime>(
@@ -328,395 +183,3 @@ pub fn resolve_http_request<R: Runtime>(
Ok((new_request, authentication_context_id))
}
async fn execute_transaction<R: Runtime>(
cached_client: CachedClient,
mut sendable_request: SendableHttpRequest,
response_ctx: &mut ResponseContext<R>,
mut cancelled_rx: Receiver<bool>,
cookie_store: Option<CookieStore>,
) -> Result<(HttpResponse, Option<tauri::async_runtime::JoinHandle<Result<()>>>)> {
let app_handle = &response_ctx.app_handle.clone();
let response_id = response_ctx.response().id.clone();
let workspace_id = response_ctx.response().workspace_id.clone();
let is_persisted = response_ctx.is_persisted();
// Keep a reference to the resolver for DNS timing events
let resolver = cached_client.resolver.clone();
let sender = ReqwestSender::with_client(cached_client.client);
let transaction = match cookie_store {
Some(cs) => HttpTransaction::with_cookie_store(sender, cs),
None => HttpTransaction::new(sender),
};
let start = Instant::now();
// Capture request headers before sending
let request_headers: Vec<HttpResponseHeader> = sendable_request
.headers
.iter()
.map(|(name, value)| HttpResponseHeader { name: name.clone(), value: value.clone() })
.collect();
// Update response with headers info
response_ctx.update(|r| {
r.url = sendable_request.url.clone();
r.request_headers = request_headers;
})?;
// Create bounded channel for receiving events and spawn a task to store them in DB
// Buffer size of 100 events provides back pressure if DB writes are slow
let (event_tx, mut event_rx) =
tokio::sync::mpsc::channel::<yaak_http::sender::HttpResponseEvent>(100);
// Set the event sender on the DNS resolver so it can emit DNS timing events
resolver.set_event_sender(Some(event_tx.clone())).await;
// Shared state to capture DNS timing from the event processing task
let dns_elapsed = Arc::new(AtomicI32::new(0));
// Write events to DB in a task (only for persisted responses)
if is_persisted {
let response_id = response_id.clone();
let app_handle = app_handle.clone();
let update_source = response_ctx.update_source.clone();
let workspace_id = workspace_id.clone();
let dns_elapsed = dns_elapsed.clone();
tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
// Capture DNS timing when we see a DNS event
if let yaak_http::sender::HttpResponseEvent::DnsResolved { duration, .. } = &event {
dns_elapsed.store(*duration as i32, Ordering::SeqCst);
}
let db_event = HttpResponseEvent::new(&response_id, &workspace_id, event.into());
let _ = app_handle.db().upsert_http_response_event(&db_event, &update_source);
}
});
} else {
// For ephemeral responses, just drain the events but still capture DNS timing
let dns_elapsed = dns_elapsed.clone();
tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
if let yaak_http::sender::HttpResponseEvent::DnsResolved { duration, .. } = &event {
dns_elapsed.store(*duration as i32, Ordering::SeqCst);
}
}
});
};
// Capture request body as it's sent (only for persisted responses)
let body_id = format!("{}.request", response_id);
let maybe_blob_write_handle = match sendable_request.body {
Some(SendableBody::Bytes(bytes)) => {
if is_persisted {
write_bytes_to_db_sync(response_ctx, &body_id, bytes.clone())?;
}
sendable_request.body = Some(SendableBody::Bytes(bytes));
None
}
Some(SendableBody::Stream { data: stream, content_length }) => {
// Wrap stream with TeeReader to capture data as it's read
// Use unbounded channel to ensure all data is captured without blocking the HTTP request
let (body_chunk_tx, body_chunk_rx) = tokio::sync::mpsc::unbounded_channel::<Vec<u8>>();
let tee_reader = TeeReader::new(stream, body_chunk_tx);
let pinned: Pin<Box<dyn AsyncRead + Send + 'static>> = Box::pin(tee_reader);
let handle = if is_persisted {
// Spawn task to write request body chunks to blob DB
let app_handle = app_handle.clone();
let response_id = response_id.clone();
let workspace_id = workspace_id.clone();
let body_id = body_id.clone();
let update_source = response_ctx.update_source.clone();
Some(tauri::async_runtime::spawn(async move {
write_stream_chunks_to_db(
app_handle,
&body_id,
&workspace_id,
&response_id,
&update_source,
body_chunk_rx,
)
.await
}))
} else {
// For ephemeral responses, just drain the body chunks
tauri::async_runtime::spawn(async move {
let mut rx = body_chunk_rx;
while rx.recv().await.is_some() {}
});
None
};
sendable_request.body = Some(SendableBody::Stream { data: pinned, content_length });
handle
}
None => {
sendable_request.body = None;
None
}
};
// Execute the transaction with cancellation support
// This returns the response with headers, but body is not yet consumed
// Events (headers, settings, chunks) are sent through the channel
let mut http_response = transaction
.execute_with_cancellation(sendable_request, cancelled_rx.clone(), event_tx)
.await?;
// Prepare the response path before consuming the body
let body_path = if response_id.is_empty() {
// Ephemeral responses: use OS temp directory for automatic cleanup
let temp_dir = std::env::temp_dir().join("yaak-ephemeral-responses");
create_dir_all(&temp_dir).await?;
temp_dir.join(uuid::Uuid::new_v4().to_string())
} else {
// Persisted responses: use app data directory
let dir = app_handle.path().app_data_dir()?;
let base_dir = dir.join("responses");
create_dir_all(&base_dir).await?;
base_dir.join(&response_id)
};
// Extract metadata before consuming the body (headers are available immediately)
// Url might change, so update again
response_ctx.update(|r| {
r.body_path = Some(body_path.to_string_lossy().to_string());
r.elapsed_headers = start.elapsed().as_millis() as i32;
r.status = http_response.status as i32;
r.status_reason = http_response.status_reason.clone();
r.url = http_response.url.clone();
r.remote_addr = http_response.remote_addr.clone();
r.version = http_response.version.clone();
r.headers = http_response
.headers
.iter()
.map(|(name, value)| HttpResponseHeader { name: name.clone(), value: value.clone() })
.collect();
r.content_length = http_response.content_length.map(|l| l as i32);
r.state = HttpResponseState::Connected;
r.request_headers = http_response
.request_headers
.iter()
.map(|(n, v)| HttpResponseHeader { name: n.clone(), value: v.clone() })
.collect();
})?;
// Get the body stream for manual consumption
let mut body_stream = http_response.into_body_stream()?;
// Open file for writing
let mut file = File::options()
.create(true)
.truncate(true)
.write(true)
.open(&body_path)
.await
.map_err(|e| GenericError(format!("Failed to open file: {}", e)))?;
// Stream body to file, with throttled DB updates to avoid excessive writes
let mut written_bytes: usize = 0;
let mut last_update_time = start;
let mut buf = [0u8; 8192];
// Throttle settings: update DB at most every 100ms
const UPDATE_INTERVAL_MS: u128 = 100;
loop {
// Check for cancellation. If we already have headers/body, just close cleanly without error
if *cancelled_rx.borrow() {
break;
}
// Use select! to race between reading and cancellation, so cancellation is immediate
let read_result = tokio::select! {
biased;
_ = cancelled_rx.changed() => {
break;
}
result = body_stream.read(&mut buf) => result,
};
match read_result {
Ok(0) => break, // EOF
Ok(n) => {
file.write_all(&buf[..n])
.await
.map_err(|e| GenericError(format!("Failed to write to file: {}", e)))?;
file.flush()
.await
.map_err(|e| GenericError(format!("Failed to flush file: {}", e)))?;
written_bytes += n;
// Throttle DB updates: only update if enough time has passed
let now = Instant::now();
let elapsed_since_update = now.duration_since(last_update_time).as_millis();
if elapsed_since_update >= UPDATE_INTERVAL_MS {
response_ctx.update(|r| {
r.elapsed = start.elapsed().as_millis() as i32;
r.content_length = Some(written_bytes as i32);
})?;
last_update_time = now;
}
}
Err(e) => {
return Err(GenericError(format!("Failed to read response body: {}", e)));
}
}
}
// Final update with closed state and accurate byte count
response_ctx.update(|r| {
r.elapsed = start.elapsed().as_millis() as i32;
r.elapsed_dns = dns_elapsed.load(Ordering::SeqCst);
r.content_length = Some(written_bytes as i32);
r.state = HttpResponseState::Closed;
})?;
// Clear the event sender from the resolver since this request is done
resolver.set_event_sender(None).await;
Ok((response_ctx.response().clone(), maybe_blob_write_handle))
}
fn write_bytes_to_db_sync<R: Runtime>(
response_ctx: &mut ResponseContext<R>,
body_id: &str,
data: Bytes,
) -> Result<()> {
if data.is_empty() {
return Ok(());
}
// Write in chunks if data is large
let mut offset = 0;
let mut chunk_index = 0;
while offset < data.len() {
let end = std::cmp::min(offset + REQUEST_BODY_CHUNK_SIZE, data.len());
let chunk_data = data.slice(offset..end).to_vec();
let chunk = BodyChunk::new(body_id, chunk_index, chunk_data);
response_ctx.app_handle.blobs().insert_chunk(&chunk)?;
offset = end;
chunk_index += 1;
}
// Update the response with the total request body size
response_ctx.update(|r| {
r.request_content_length = Some(data.len() as i32);
})?;
Ok(())
}
async fn write_stream_chunks_to_db<R: Runtime>(
app_handle: AppHandle<R>,
body_id: &str,
workspace_id: &str,
response_id: &str,
update_source: &UpdateSource,
mut rx: tokio::sync::mpsc::UnboundedReceiver<Vec<u8>>,
) -> Result<()> {
let mut buffer = Vec::with_capacity(REQUEST_BODY_CHUNK_SIZE);
let mut chunk_index = 0;
let mut total_bytes: usize = 0;
while let Some(data) = rx.recv().await {
total_bytes += data.len();
buffer.extend_from_slice(&data);
// Flush when buffer reaches chunk size
while buffer.len() >= REQUEST_BODY_CHUNK_SIZE {
debug!("Writing chunk {chunk_index} to DB");
let chunk_data: Vec<u8> = buffer.drain(..REQUEST_BODY_CHUNK_SIZE).collect();
let chunk = BodyChunk::new(body_id, chunk_index, chunk_data);
app_handle.blobs().insert_chunk(&chunk)?;
app_handle.db().upsert_http_response_event(
&HttpResponseEvent::new(
response_id,
workspace_id,
yaak_http::sender::HttpResponseEvent::ChunkSent {
bytes: REQUEST_BODY_CHUNK_SIZE,
}
.into(),
),
update_source,
)?;
chunk_index += 1;
}
}
// Flush remaining data
if !buffer.is_empty() {
let chunk = BodyChunk::new(body_id, chunk_index, buffer);
debug!("Flushing remaining data {chunk_index} {}", chunk.data.len());
app_handle.blobs().insert_chunk(&chunk)?;
app_handle.db().upsert_http_response_event(
&HttpResponseEvent::new(
response_id,
workspace_id,
yaak_http::sender::HttpResponseEvent::ChunkSent { bytes: chunk.data.len() }.into(),
),
update_source,
)?;
}
// Update the response with the total request body size
app_handle.with_tx(|tx| {
debug!("Updating final body length {total_bytes}");
if let Ok(mut response) = tx.get_http_response(&response_id) {
response.request_content_length = Some(total_bytes as i32);
tx.update_http_response_if_id(&response, update_source)?;
}
Ok(())
})?;
Ok(())
}
async fn apply_authentication<R: Runtime>(
_window: &WebviewWindow<R>,
sendable_request: &mut SendableHttpRequest,
request: &HttpRequest,
auth_context_id: String,
plugin_manager: &PluginManager,
plugin_context: &PluginContext,
) -> Result<()> {
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 req = CallHttpAuthenticationRequest {
context_id: format!("{:x}", md5::compute(auth_context_id)),
values: serde_json::from_value(serde_json::to_value(&request.authentication)?)?,
url: sendable_request.url.clone(),
method: sendable_request.method.clone(),
headers: sendable_request
.headers
.iter()
.map(|(name, value)| HttpHeader {
name: name.to_string(),
value: value.to_string(),
})
.collect(),
};
let plugin_result = plugin_manager
.call_http_authentication(plugin_context, &authentication_type, req)
.await?;
for header in plugin_result.set_headers.unwrap_or_default() {
sendable_request.insert_header((header.name, header.value));
}
if let Some(params) = plugin_result.set_query_parameters {
let params = params.into_iter().map(|p| (p.name, p.value)).collect::<Vec<_>>();
sendable_request.url = append_query_params(&sendable_request.url, params);
}
}
}
Ok(())
}

View File

@@ -1095,13 +1095,9 @@ async fn cmd_get_http_authentication_config<R: Runtime>(
// Convert HashMap<String, JsonPrimitive> to serde_json::Value for rendering
let values_json: serde_json::Value = serde_json::to_value(&values)?;
let rendered_json = render_json_value(
values_json,
environment_chain,
&cb,
&RenderOptions::return_empty(),
)
.await?;
let rendered_json =
render_json_value(values_json, environment_chain, &cb, &RenderOptions::return_empty())
.await?;
// Convert back to HashMap<String, JsonPrimitive>
let rendered_values: HashMap<String, JsonPrimitive> = serde_json::from_value(rendered_json)?;

View File

@@ -3,6 +3,9 @@
//! 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};
@@ -13,6 +16,74 @@ use yaak_models::models::{AnyModel, GraphQlIntrospection, GrpcEvent, Settings, W
use yaak_models::query_manager::QueryManager;
use yaak_models::util::UpdateSource;
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>;
@@ -262,14 +333,37 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
}
};
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);
// Forward model change events to the frontend
let app_handle = app_handle.clone();
// 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 {
app_handle.emit("model_write", payload).unwrap();
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:?}");
}
}
});

View File

@@ -8,9 +8,9 @@ use serde::{Deserialize, Serialize};
use std::time::Instant;
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow};
use ts_rs::TS;
use yaak_api::yaak_api_client;
use yaak_common::platform::get_os_str;
use yaak_models::util::UpdateSource;
use yaak_api::yaak_api_client;
// Check for updates every hour
const MAX_UPDATE_CHECK_SECONDS: u64 = 60 * 60;

View File

@@ -21,6 +21,7 @@ use tauri::{
};
use tokio::sync::Mutex;
use ts_rs::TS;
use yaak_api::yaak_api_client;
use yaak_models::models::Plugin;
use yaak_models::util::UpdateSource;
use yaak_plugins::api::{
@@ -31,7 +32,6 @@ use yaak_plugins::events::{Color, Icon, PluginContext, ShowToastRequest};
use yaak_plugins::install::{delete_and_uninstall, download_and_install};
use yaak_plugins::manager::PluginManager;
use yaak_plugins::plugin_meta::get_plugin_meta;
use yaak_api::yaak_api_client;
static EXITING: AtomicBool = AtomicBool::new(false);

View File

@@ -1,10 +1,8 @@
use log::info;
use serde_json::Value;
use std::collections::BTreeMap;
use yaak_http::path_placeholders::apply_path_placeholders;
use yaak_models::models::{
Environment, GrpcRequest, HttpRequest, HttpRequestHeader, HttpUrlParameter,
};
pub use yaak::render::render_http_request;
use yaak_models::models::{Environment, GrpcRequest, HttpRequestHeader};
use yaak_models::render::make_vars_hashmap;
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
@@ -85,151 +83,3 @@ pub async fn render_grpc_request<T: TemplateCallback>(
Ok(GrpcRequest { url, metadata, authentication, ..r.to_owned() })
}
pub async fn render_http_request<T: TemplateCallback>(
r: &HttpRequest,
environment_chain: Vec<Environment>,
cb: &T,
opt: &RenderOptions,
) -> yaak_templates::error::Result<HttpRequest> {
let vars = &make_vars_hashmap(environment_chain);
let mut url_parameters = Vec::new();
for p in r.url_parameters.clone() {
if !p.enabled {
continue;
}
url_parameters.push(HttpUrlParameter {
enabled: p.enabled,
name: parse_and_render(p.name.as_str(), vars, cb, &opt).await?,
value: parse_and_render(p.value.as_str(), vars, cb, &opt).await?,
id: p.id,
})
}
let mut headers = Vec::new();
for p in r.headers.clone() {
if !p.enabled {
continue;
}
headers.push(HttpRequestHeader {
enabled: p.enabled,
name: parse_and_render(p.name.as_str(), vars, cb, &opt).await?,
value: parse_and_render(p.value.as_str(), vars, cb, &opt).await?,
id: p.id,
})
}
let mut body = BTreeMap::new();
for (k, v) in r.body.clone() {
let v = if k == "form" { strip_disabled_form_entries(v) } else { v };
body.insert(k, render_json_value_raw(v, vars, cb, &opt).await?);
}
let authentication = {
let mut disabled = false;
let mut auth = BTreeMap::new();
match r.authentication.get("disabled") {
Some(Value::Bool(true)) => {
disabled = true;
}
Some(Value::String(tmpl)) => {
disabled = parse_and_render(tmpl.as_str(), vars, cb, &opt)
.await
.unwrap_or_default()
.is_empty();
info!(
"Rendering authentication.disabled as a template: {disabled} from \"{tmpl}\""
);
}
_ => {}
}
if disabled {
auth.insert("disabled".to_string(), Value::Bool(true));
} else {
for (k, v) in r.authentication.clone() {
if k == "disabled" {
auth.insert(k, Value::Bool(false));
} else {
auth.insert(k, render_json_value_raw(v, vars, cb, &opt).await?);
}
}
}
auth
};
let url = parse_and_render(r.url.clone().as_str(), vars, cb, &opt).await?;
// This doesn't fit perfectly with the concept of "rendering" but it kind of does
let (url, url_parameters) = apply_path_placeholders(&url, &url_parameters);
Ok(HttpRequest { url, url_parameters, headers, body, authentication, ..r.to_owned() })
}
/// Strip disabled entries from a JSON array of form objects.
fn strip_disabled_form_entries(v: Value) -> Value {
match v {
Value::Array(items) => Value::Array(
items
.into_iter()
.filter(|item| item.get("enabled").and_then(|e| e.as_bool()).unwrap_or(true))
.collect(),
),
v => v,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_strip_disabled_form_entries() {
let input = json!([
{"enabled": true, "name": "foo", "value": "bar"},
{"enabled": false, "name": "disabled", "value": "gone"},
{"enabled": true, "name": "baz", "value": "qux"},
]);
let result = strip_disabled_form_entries(input);
assert_eq!(
result,
json!([
{"enabled": true, "name": "foo", "value": "bar"},
{"enabled": true, "name": "baz", "value": "qux"},
])
);
}
#[test]
fn test_strip_disabled_form_entries_all_disabled() {
let input = json!([
{"enabled": false, "name": "a", "value": "b"},
{"enabled": false, "name": "c", "value": "d"},
]);
let result = strip_disabled_form_entries(input);
assert_eq!(result, json!([]));
}
#[test]
fn test_strip_disabled_form_entries_missing_enabled_defaults_to_kept() {
let input = json!([
{"name": "no_enabled_field", "value": "kept"},
{"enabled": false, "name": "disabled", "value": "gone"},
]);
let result = strip_disabled_form_entries(input);
assert_eq!(
result,
json!([
{"name": "no_enabled_field", "value": "kept"},
])
);
}
#[test]
fn test_strip_disabled_form_entries_non_array_passthrough() {
let input = json!("just a string");
let result = strip_disabled_form_entries(input.clone());
assert_eq!(result, input);
}
}

View File

@@ -8,11 +8,11 @@ use std::fs;
use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager, Runtime, Url};
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
use yaak_api::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;
use yaak_api::yaak_api_client;
pub(crate) async fn handle_deep_link<R: Runtime>(
app_handle: &AppHandle<R>,

View File

@@ -153,11 +153,8 @@ pub fn app_menu<R: Runtime>(app_handle: &AppHandle<R>) -> tauri::Result<Menu<R>>
.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_16x9".to_string(), "Resize to 16x9")
.build(app_handle)?,
&MenuItemBuilder::with_id(
"dev.reset_size_16x10".to_string(),
"Resize to 16x10",

View File

@@ -7,11 +7,11 @@ use std::ops::Add;
use std::time::Duration;
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow, is_dev};
use ts_rs::TS;
use yaak_api::yaak_api_client;
use yaak_common::platform::get_os_str;
use yaak_models::db_context::DbContext;
use yaak_models::query_manager::QueryManager;
use yaak_models::util::UpdateSource;
use yaak_api::yaak_api_client;
/// Extension trait for accessing the QueryManager from Tauri Manager types.
/// This is needed temporarily until all crates are refactored to not use Tauri.
@@ -159,10 +159,8 @@ pub async fn deactivate_license<R: Runtime>(window: &WebviewWindow<R>) -> Result
let app_version = window.app_handle().package_info().version.to_string();
let client = yaak_api_client(&app_version)?;
let path = format!("/licenses/activations/{}/deactivate", activation_id);
let payload = DeactivateLicenseRequestPayload {
app_platform: get_os_str().to_string(),
app_version,
};
let payload =
DeactivateLicenseRequestPayload { app_platform: get_os_str().to_string(), app_version };
let response = client.post(build_url(&path)).json(&payload).send().await?;
if response.status().is_client_error() {
@@ -189,10 +187,8 @@ pub async fn deactivate_license<R: Runtime>(window: &WebviewWindow<R>) -> Result
pub async fn check_license<R: Runtime>(window: &WebviewWindow<R>) -> Result<LicenseCheckStatus> {
let app_version = window.app_handle().package_info().version.to_string();
let payload = CheckActivationRequestPayload {
app_platform: get_os_str().to_string(),
app_version,
};
let payload =
CheckActivationRequestPayload { app_platform: get_os_str().to_string(), app_version };
let activation_id = get_activation_id(window.app_handle()).await;
let settings = window.db().get_settings();