Compare commits

...

8 Commits

Author SHA1 Message Date
Zhizhen He
0b91d3aaff feat: add breadcrumbs to folder setting (#296) 2026-02-03 07:40:24 -08:00
Gregory Schier
431dc1c896 Adjust dev menu 2026-02-02 17:58:58 -08:00
Gregory Schier
bc8277b56b Fix header behavior on cross-origin redirects (#378)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 17:58:27 -08:00
gschier
0afed185d9 Deploying to main from @ mountain-loop/yaak@55cee00601 🚀 2026-02-02 15:46:27 +00:00
Gregory Schier
55cee00601 More reliable plugin runtime kill 2026-02-02 07:45:19 -08:00
Gregory Schier
b41a8e04cb Graceful oauth server shutdown 2026-02-02 07:31:55 -08:00
Gregory Schier
eff4519d91 Have cancellation work before the request is sent 2026-02-02 07:09:48 -08:00
Rahul Mishra
c4ce458f79 fix: pass down onClose properly (#376) 2026-01-31 07:34:40 -08:00
15 changed files with 389 additions and 118 deletions

View File

@@ -22,7 +22,7 @@
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https:&#x2F;&#x2F;github.com&#x2F;MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a>&nbsp;&nbsp;<a href="https://github.com/dharsanb"><img src="https:&#x2F;&#x2F;github.com&#x2F;dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a>&nbsp;&nbsp;<a href="https://github.com/railwayapp"><img src="https:&#x2F;&#x2F;github.com&#x2F;railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a>&nbsp;&nbsp;<a href="https://github.com/caseyamcl"><img src="https:&#x2F;&#x2F;github.com&#x2F;caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a>&nbsp;&nbsp;<a href="https://github.com/bytebase"><img src="https:&#x2F;&#x2F;github.com&#x2F;bytebase.png" width="80px" alt="User avatar: bytebase" /></a>&nbsp;&nbsp;<a href="https://github.com/"><img src="https:&#x2F;&#x2F;raw.githubusercontent.com&#x2F;JamesIves&#x2F;github-sponsors-readme-action&#x2F;dev&#x2F;.github&#x2F;assets&#x2F;placeholder.png" width="80px" alt="User avatar: " /></a>&nbsp;&nbsp;<!-- sponsors-premium --> <!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https:&#x2F;&#x2F;github.com&#x2F;MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a>&nbsp;&nbsp;<a href="https://github.com/dharsanb"><img src="https:&#x2F;&#x2F;github.com&#x2F;dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a>&nbsp;&nbsp;<a href="https://github.com/railwayapp"><img src="https:&#x2F;&#x2F;github.com&#x2F;railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a>&nbsp;&nbsp;<a href="https://github.com/caseyamcl"><img src="https:&#x2F;&#x2F;github.com&#x2F;caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a>&nbsp;&nbsp;<a href="https://github.com/bytebase"><img src="https:&#x2F;&#x2F;github.com&#x2F;bytebase.png" width="80px" alt="User avatar: bytebase" /></a>&nbsp;&nbsp;<a href="https://github.com/"><img src="https:&#x2F;&#x2F;raw.githubusercontent.com&#x2F;JamesIves&#x2F;github-sponsors-readme-action&#x2F;dev&#x2F;.github&#x2F;assets&#x2F;placeholder.png" width="80px" alt="User avatar: " /></a>&nbsp;&nbsp;<!-- sponsors-premium -->
</p> </p>
<p align="center"> <p align="center">
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https:&#x2F;&#x2F;github.com&#x2F;seanwash.png" width="50px" alt="User avatar: seanwash" /></a>&nbsp;&nbsp;<a href="https://github.com/jerath"><img src="https:&#x2F;&#x2F;github.com&#x2F;jerath.png" width="50px" alt="User avatar: jerath" /></a>&nbsp;&nbsp;<a href="https://github.com/itsa-sh"><img src="https:&#x2F;&#x2F;github.com&#x2F;itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a>&nbsp;&nbsp;<a href="https://github.com/dmmulroy"><img src="https:&#x2F;&#x2F;github.com&#x2F;dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a>&nbsp;&nbsp;<a href="https://github.com/timcole"><img src="https:&#x2F;&#x2F;github.com&#x2F;timcole.png" width="50px" alt="User avatar: timcole" /></a>&nbsp;&nbsp;<a href="https://github.com/VLZH"><img src="https:&#x2F;&#x2F;github.com&#x2F;VLZH.png" width="50px" alt="User avatar: VLZH" /></a>&nbsp;&nbsp;<a href="https://github.com/terasaka2k"><img src="https:&#x2F;&#x2F;github.com&#x2F;terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a>&nbsp;&nbsp;<a href="https://github.com/andriyor"><img src="https:&#x2F;&#x2F;github.com&#x2F;andriyor.png" width="50px" alt="User avatar: andriyor" /></a>&nbsp;&nbsp;<a href="https://github.com/majudhu"><img src="https:&#x2F;&#x2F;github.com&#x2F;majudhu.png" width="50px" alt="User avatar: majudhu" /></a>&nbsp;&nbsp;<a href="https://github.com/axelrindle"><img src="https:&#x2F;&#x2F;github.com&#x2F;axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a>&nbsp;&nbsp;<a href="https://github.com/jirizverina"><img src="https:&#x2F;&#x2F;github.com&#x2F;jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="50px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<a href="https://github.com/GRAYAH"><img src="https:&#x2F;&#x2F;github.com&#x2F;GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a>&nbsp;&nbsp;<!-- sponsors-base --> <!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https:&#x2F;&#x2F;github.com&#x2F;seanwash.png" width="50px" alt="User avatar: seanwash" /></a>&nbsp;&nbsp;<a href="https://github.com/jerath"><img src="https:&#x2F;&#x2F;github.com&#x2F;jerath.png" width="50px" alt="User avatar: jerath" /></a>&nbsp;&nbsp;<a href="https://github.com/itsa-sh"><img src="https:&#x2F;&#x2F;github.com&#x2F;itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a>&nbsp;&nbsp;<a href="https://github.com/dmmulroy"><img src="https:&#x2F;&#x2F;github.com&#x2F;dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a>&nbsp;&nbsp;<a href="https://github.com/timcole"><img src="https:&#x2F;&#x2F;github.com&#x2F;timcole.png" width="50px" alt="User avatar: timcole" /></a>&nbsp;&nbsp;<a href="https://github.com/VLZH"><img src="https:&#x2F;&#x2F;github.com&#x2F;VLZH.png" width="50px" alt="User avatar: VLZH" /></a>&nbsp;&nbsp;<a href="https://github.com/terasaka2k"><img src="https:&#x2F;&#x2F;github.com&#x2F;terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a>&nbsp;&nbsp;<a href="https://github.com/andriyor"><img src="https:&#x2F;&#x2F;github.com&#x2F;andriyor.png" width="50px" alt="User avatar: andriyor" /></a>&nbsp;&nbsp;<a href="https://github.com/majudhu"><img src="https:&#x2F;&#x2F;github.com&#x2F;majudhu.png" width="50px" alt="User avatar: majudhu" /></a>&nbsp;&nbsp;<a href="https://github.com/axelrindle"><img src="https:&#x2F;&#x2F;github.com&#x2F;axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a>&nbsp;&nbsp;<a href="https://github.com/jirizverina"><img src="https:&#x2F;&#x2F;github.com&#x2F;jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="50px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<a href="https://github.com/GRAYAH"><img src="https:&#x2F;&#x2F;github.com&#x2F;GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a>&nbsp;&nbsp;<a href="https://github.com/flashblaze"><img src="https:&#x2F;&#x2F;github.com&#x2F;flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a>&nbsp;&nbsp;<!-- sponsors-base -->
</p> </p>
![Yaak API Client](https://yaak.app/static/screenshot.png) ![Yaak API Client](https://yaak.app/static/screenshot.png)

View File

@@ -182,7 +182,14 @@ async fn send_http_request_inner<R: Runtime>(
); );
let env_chain = let env_chain =
window.db().resolve_environments(&workspace.id, folder_id, environment_id.as_deref())?; window.db().resolve_environments(&workspace.id, folder_id, environment_id.as_deref())?;
let request = render_http_request(&resolved, env_chain, &cb, &RenderOptions::throw()).await?; 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()));
}
};
// Build the sendable request using the new SendableHttpRequest type // Build the sendable request using the new SendableHttpRequest type
let options = SendableHttpRequestOptions { let options = SendableHttpRequestOptions {
@@ -244,16 +251,22 @@ async fn send_http_request_inner<R: Runtime>(
}) })
.await?; .await?;
// Apply authentication to the request // Apply authentication to the request, racing against cancellation since
apply_authentication( // auth plugins (e.g. OAuth2) can block indefinitely waiting for user action.
&window, let mut cancel_rx = cancelled_rx.clone();
&mut sendable_request, tokio::select! {
&request, result = apply_authentication(
auth_context_id, &window,
&plugin_manager, &mut sendable_request,
plugin_context, &request,
) auth_context_id,
.await?; &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 cookie_store = maybe_cookie_store.as_ref().map(|(cs, _)| cs.clone());
let result = execute_transaction( let result = execute_transaction(

View File

@@ -162,11 +162,16 @@ pub(crate) fn create_window<R: Runtime>(
"dev.reset_size" => webview_window "dev.reset_size" => webview_window
.set_size(LogicalSize::new(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT)) .set_size(LogicalSize::new(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT))
.unwrap(), .unwrap(),
"dev.reset_size_record" => { "dev.reset_size_16x9" => {
let width = webview_window.outer_size().unwrap().width; let width = webview_window.outer_size().unwrap().width;
let height = width * 9 / 16; let height = width * 9 / 16;
webview_window.set_size(PhysicalSize::new(width, height)).unwrap() webview_window.set_size(PhysicalSize::new(width, height)).unwrap()
} }
"dev.reset_size_16x10" => {
let width = webview_window.outer_size().unwrap().width;
let height = width * 10 / 16;
webview_window.set_size(PhysicalSize::new(width, height)).unwrap()
}
"dev.refresh" => webview_window.eval("location.reload()").unwrap(), "dev.refresh" => webview_window.eval("location.reload()").unwrap(),
"dev.generate_theme_css" => { "dev.generate_theme_css" => {
w.emit("generate_theme_css", true).unwrap(); w.emit("generate_theme_css", true).unwrap();

View File

@@ -154,8 +154,13 @@ pub fn app_menu<R: Runtime>(app_handle: &AppHandle<R>) -> tauri::Result<Menu<R>>
&MenuItemBuilder::with_id("dev.reset_size".to_string(), "Reset Size") &MenuItemBuilder::with_id("dev.reset_size".to_string(), "Reset Size")
.build(app_handle)?, .build(app_handle)?,
&MenuItemBuilder::with_id( &MenuItemBuilder::with_id(
"dev.reset_size_record".to_string(), "dev.reset_size_16x9".to_string(),
"Reset Size 16x9", "Resize to 16x9",
)
.build(app_handle)?,
&MenuItemBuilder::with_id(
"dev.reset_size_16x10".to_string(),
"Resize to 16x10",
) )
.build(app_handle)?, .build(app_handle)?,
&MenuItemBuilder::with_id( &MenuItemBuilder::with_id(

View File

@@ -168,6 +168,7 @@ impl<S: HttpSender> HttpTransaction<S> {
response.drain().await?; response.drain().await?;
// Update the request URL // Update the request URL
let previous_url = current_url.clone();
current_url = if location.starts_with("http://") || location.starts_with("https://") { current_url = if location.starts_with("http://") || location.starts_with("https://") {
// Absolute URL // Absolute URL
location location
@@ -181,6 +182,8 @@ impl<S: HttpSender> HttpTransaction<S> {
format!("{}/{}", base_path, location) format!("{}/{}", base_path, location)
}; };
Self::remove_sensitive_headers(&mut current_headers, &previous_url, &current_url);
// Determine redirect behavior based on status code and method // Determine redirect behavior based on status code and method
let behavior = if status == 303 { let behavior = if status == 303 {
// 303 See Other always changes to GET // 303 See Other always changes to GET
@@ -220,6 +223,33 @@ impl<S: HttpSender> HttpTransaction<S> {
} }
} }
/// Remove sensitive headers when redirecting to a different host.
/// This matches reqwest's `remove_sensitive_headers()` behavior and prevents
/// credentials from being forwarded to third-party servers (e.g., an
/// Authorization header sent from an API redirect to an S3 bucket).
fn remove_sensitive_headers(
headers: &mut Vec<(String, String)>,
previous_url: &str,
next_url: &str,
) {
let previous_host = Url::parse(previous_url).ok().and_then(|u| {
u.host_str().map(|h| format!("{}:{}", h, u.port_or_known_default().unwrap_or(0)))
});
let next_host = Url::parse(next_url).ok().and_then(|u| {
u.host_str().map(|h| format!("{}:{}", h, u.port_or_known_default().unwrap_or(0)))
});
if previous_host != next_host {
headers.retain(|h| {
let name_lower = h.0.to_lowercase();
name_lower != "authorization"
&& name_lower != "cookie"
&& name_lower != "cookie2"
&& name_lower != "proxy-authorization"
&& name_lower != "www-authenticate"
});
}
}
/// Check if a status code indicates a redirect /// Check if a status code indicates a redirect
fn is_redirect(status: u16) -> bool { fn is_redirect(status: u16) -> bool {
matches!(status, 301 | 302 | 303 | 307 | 308) matches!(status, 301 | 302 | 303 | 307 | 308)
@@ -269,9 +299,20 @@ mod tests {
use tokio::io::AsyncRead; use tokio::io::AsyncRead;
use tokio::sync::Mutex; use tokio::sync::Mutex;
/// Captured request metadata for test assertions
#[derive(Debug, Clone)]
#[allow(dead_code)]
struct CapturedRequest {
url: String,
method: String,
headers: Vec<(String, String)>,
}
/// Mock sender for testing /// Mock sender for testing
struct MockSender { struct MockSender {
responses: Arc<Mutex<Vec<MockResponse>>>, responses: Arc<Mutex<Vec<MockResponse>>>,
/// Captured requests for assertions
captured_requests: Arc<Mutex<Vec<CapturedRequest>>>,
} }
struct MockResponse { struct MockResponse {
@@ -282,7 +323,10 @@ mod tests {
impl MockSender { impl MockSender {
fn new(responses: Vec<MockResponse>) -> Self { fn new(responses: Vec<MockResponse>) -> Self {
Self { responses: Arc::new(Mutex::new(responses)) } Self {
responses: Arc::new(Mutex::new(responses)),
captured_requests: Arc::new(Mutex::new(Vec::new())),
}
} }
} }
@@ -290,9 +334,16 @@ mod tests {
impl HttpSender for MockSender { impl HttpSender for MockSender {
async fn send( async fn send(
&self, &self,
_request: SendableHttpRequest, request: SendableHttpRequest,
_event_tx: mpsc::Sender<HttpResponseEvent>, _event_tx: mpsc::Sender<HttpResponseEvent>,
) -> Result<HttpResponse> { ) -> Result<HttpResponse> {
// Capture the request metadata for later assertions
self.captured_requests.lock().await.push(CapturedRequest {
url: request.url.clone(),
method: request.method.clone(),
headers: request.headers.clone(),
});
let mut responses = self.responses.lock().await; let mut responses = self.responses.lock().await;
if responses.is_empty() { if responses.is_empty() {
Err(crate::error::Error::RequestError("No more mock responses".to_string())) Err(crate::error::Error::RequestError("No more mock responses".to_string()))
@@ -726,4 +777,116 @@ mod tests {
assert!(result.is_ok()); assert!(result.is_ok());
assert_eq!(request_count.load(Ordering::SeqCst), 2); assert_eq!(request_count.load(Ordering::SeqCst), 2);
} }
#[tokio::test]
async fn test_cross_origin_redirect_strips_auth_headers() {
// Redirect from api.example.com -> s3.amazonaws.com should strip Authorization
let responses = vec![
MockResponse {
status: 302,
headers: vec![(
"Location".to_string(),
"https://s3.amazonaws.com/bucket/file.pdf".to_string(),
)],
body: vec![],
},
MockResponse { status: 200, headers: Vec::new(), body: b"PDF content".to_vec() },
];
let sender = MockSender::new(responses);
let captured = sender.captured_requests.clone();
let transaction = HttpTransaction::new(sender);
let request = SendableHttpRequest {
url: "https://api.example.com/download".to_string(),
method: "GET".to_string(),
headers: vec![
("Authorization".to_string(), "Basic dXNlcjpwYXNz".to_string()),
("Accept".to_string(), "application/pdf".to_string()),
],
options: crate::types::SendableHttpRequestOptions {
follow_redirects: true,
..Default::default()
},
..Default::default()
};
let (_tx, rx) = tokio::sync::watch::channel(false);
let (event_tx, _event_rx) = mpsc::channel(100);
let result = transaction.execute_with_cancellation(request, rx, event_tx).await.unwrap();
assert_eq!(result.status, 200);
let requests = captured.lock().await;
assert_eq!(requests.len(), 2);
// First request should have the Authorization header
assert!(
requests[0].headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("authorization")),
"First request should have Authorization header"
);
// Second request (to different host) should NOT have the Authorization header
assert!(
!requests[1].headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("authorization")),
"Redirected request to different host should NOT have Authorization header"
);
// Non-sensitive headers should still be present
assert!(
requests[1].headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("accept")),
"Non-sensitive headers should be preserved across cross-origin redirects"
);
}
#[tokio::test]
async fn test_same_origin_redirect_preserves_auth_headers() {
// Redirect within the same host should keep Authorization
let responses = vec![
MockResponse {
status: 302,
headers: vec![(
"Location".to_string(),
"https://api.example.com/v2/download".to_string(),
)],
body: vec![],
},
MockResponse { status: 200, headers: Vec::new(), body: b"OK".to_vec() },
];
let sender = MockSender::new(responses);
let captured = sender.captured_requests.clone();
let transaction = HttpTransaction::new(sender);
let request = SendableHttpRequest {
url: "https://api.example.com/v1/download".to_string(),
method: "GET".to_string(),
headers: vec![
("Authorization".to_string(), "Bearer token123".to_string()),
("Accept".to_string(), "application/json".to_string()),
],
options: crate::types::SendableHttpRequestOptions {
follow_redirects: true,
..Default::default()
},
..Default::default()
};
let (_tx, rx) = tokio::sync::watch::channel(false);
let (event_tx, _event_rx) = mpsc::channel(100);
let result = transaction.execute_with_cancellation(request, rx, event_tx).await.unwrap();
assert_eq!(result.status, 200);
let requests = captured.lock().await;
assert_eq!(requests.len(), 2);
// Both requests should have the Authorization header (same host)
assert!(
requests[0].headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("authorization")),
"First request should have Authorization header"
);
assert!(
requests[1].headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("authorization")),
"Redirected request to same host should preserve Authorization header"
);
}
} }

View File

@@ -31,7 +31,7 @@ use std::time::Duration;
use tokio::fs::read_dir; use tokio::fs::read_dir;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::sync::mpsc::error::TrySendError; use tokio::sync::mpsc::error::TrySendError;
use tokio::sync::{Mutex, mpsc}; use tokio::sync::{Mutex, mpsc, oneshot};
use tokio::time::{Instant, timeout}; use tokio::time::{Instant, timeout};
use yaak_models::models::Plugin; use yaak_models::models::Plugin;
use yaak_models::util::generate_id; use yaak_models::util::generate_id;
@@ -43,6 +43,7 @@ pub struct PluginManager {
subscribers: Arc<Mutex<HashMap<String, mpsc::Sender<InternalEvent>>>>, subscribers: Arc<Mutex<HashMap<String, mpsc::Sender<InternalEvent>>>>,
plugin_handles: Arc<Mutex<Vec<PluginHandle>>>, plugin_handles: Arc<Mutex<Vec<PluginHandle>>>,
kill_tx: tokio::sync::watch::Sender<bool>, kill_tx: tokio::sync::watch::Sender<bool>,
killed_rx: Arc<Mutex<Option<oneshot::Receiver<()>>>>,
ws_service: Arc<PluginRuntimeServerWebsocket>, ws_service: Arc<PluginRuntimeServerWebsocket>,
vendored_plugin_dir: PathBuf, vendored_plugin_dir: PathBuf,
pub(crate) installed_plugin_dir: PathBuf, pub(crate) installed_plugin_dir: PathBuf,
@@ -70,6 +71,7 @@ impl PluginManager {
) -> PluginManager { ) -> PluginManager {
let (events_tx, mut events_rx) = mpsc::channel(2048); let (events_tx, mut events_rx) = mpsc::channel(2048);
let (kill_server_tx, kill_server_rx) = tokio::sync::watch::channel(false); let (kill_server_tx, kill_server_rx) = tokio::sync::watch::channel(false);
let (killed_tx, killed_rx) = oneshot::channel();
let (client_disconnect_tx, mut client_disconnect_rx) = mpsc::channel(128); let (client_disconnect_tx, mut client_disconnect_rx) = mpsc::channel(128);
let (client_connect_tx, mut client_connect_rx) = tokio::sync::watch::channel(false); let (client_connect_tx, mut client_connect_rx) = tokio::sync::watch::channel(false);
@@ -81,6 +83,7 @@ impl PluginManager {
subscribers: Default::default(), subscribers: Default::default(),
ws_service: Arc::new(ws_service.clone()), ws_service: Arc::new(ws_service.clone()),
kill_tx: kill_server_tx, kill_tx: kill_server_tx,
killed_rx: Arc::new(Mutex::new(Some(killed_rx))),
vendored_plugin_dir, vendored_plugin_dir,
installed_plugin_dir, installed_plugin_dir,
dev_mode, dev_mode,
@@ -141,9 +144,15 @@ impl PluginManager {
}); });
// 2. Start Node.js runtime // 2. Start Node.js runtime
start_nodejs_plugin_runtime(&node_bin_path, &plugin_runtime_main, addr, &kill_server_rx) start_nodejs_plugin_runtime(
.await &node_bin_path,
.unwrap(); &plugin_runtime_main,
addr,
&kill_server_rx,
killed_tx,
)
.await
.unwrap();
info!("Waiting for plugins to initialize"); info!("Waiting for plugins to initialize");
init_plugins_task.await.unwrap(); init_plugins_task.await.unwrap();
@@ -296,8 +305,15 @@ impl PluginManager {
pub async fn terminate(&self) { pub async fn terminate(&self) {
self.kill_tx.send_replace(true); self.kill_tx.send_replace(true);
// Give it a bit of time to kill // Wait for the plugin runtime process to actually exit
tokio::time::sleep(Duration::from_millis(500)).await; let killed_rx = self.killed_rx.lock().await.take();
if let Some(rx) = killed_rx {
if timeout(Duration::from_secs(5), rx).await.is_err() {
warn!("Timed out waiting for plugin runtime to exit");
} else {
info!("Plugin runtime exited")
}
}
} }
pub async fn reply( pub async fn reply(

View File

@@ -4,6 +4,7 @@ use std::net::SocketAddr;
use std::path::Path; use std::path::Path;
use std::process::Stdio; use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::sync::oneshot;
use tokio::sync::watch::Receiver; use tokio::sync::watch::Receiver;
use yaak_common::command::new_xplatform_command; use yaak_common::command::new_xplatform_command;
@@ -19,6 +20,7 @@ pub async fn start_nodejs_plugin_runtime(
plugin_runtime_main: &Path, plugin_runtime_main: &Path,
addr: SocketAddr, addr: SocketAddr,
kill_rx: &Receiver<bool>, kill_rx: &Receiver<bool>,
killed_tx: oneshot::Sender<()>,
) -> Result<()> { ) -> Result<()> {
// HACK: Remove UNC prefix for Windows paths to pass to sidecar // HACK: Remove UNC prefix for Windows paths to pass to sidecar
let plugin_runtime_main_str = let plugin_runtime_main_str =
@@ -72,6 +74,7 @@ pub async fn start_nodejs_plugin_runtime(
warn!("Failed to kill plugin runtime: {e}"); warn!("Failed to kill plugin runtime: {e}");
} }
info!("Killed plugin runtime"); info!("Killed plugin runtime");
let _ = killed_tx.send(());
}); });
Ok(()) Ok(())

View File

@@ -184,6 +184,18 @@ export function buildHostedCallbackRedirectUri(localPort: number, localPath: str
return `${HOSTED_CALLBACK_URL}?redirect_to=${encodeURIComponent(localRedirectUri)}`; return `${HOSTED_CALLBACK_URL}?redirect_to=${encodeURIComponent(localRedirectUri)}`;
} }
/**
* Stop the active callback server if one is running.
* Called during plugin dispose to ensure the server is cleaned up before the process exits.
*/
export function stopActiveServer(): void {
if (activeServer) {
console.log('[oauth2] Stopping active callback server during dispose');
activeServer.stop();
activeServer = null;
}
}
/** /**
* Open an authorization URL in the system browser, start a local callback server, * Open an authorization URL in the system browser, start a local callback server,
* and wait for the OAuth provider to redirect back. * and wait for the OAuth provider to redirect back.

View File

@@ -5,7 +5,7 @@ import type {
JsonPrimitive, JsonPrimitive,
PluginDefinition, PluginDefinition,
} from '@yaakapp/api'; } from '@yaakapp/api';
import { DEFAULT_LOCALHOST_PORT, HOSTED_CALLBACK_URL } from './callbackServer'; import { DEFAULT_LOCALHOST_PORT, HOSTED_CALLBACK_URL, stopActiveServer } from './callbackServer';
import { import {
type CallbackType, type CallbackType,
DEFAULT_PKCE_METHOD, DEFAULT_PKCE_METHOD,
@@ -78,6 +78,9 @@ const accessTokenUrls = [
]; ];
export const plugin: PluginDefinition = { export const plugin: PluginDefinition = {
dispose() {
stopActiveServer();
},
authentication: { authentication: {
name: 'oauth2', name: 'oauth2',
label: 'OAuth 2.0', label: 'OAuth 2.0',

View File

@@ -1,21 +1,14 @@
import { getModel } from '@yaakapp-internal/models'; import { getModel } from '@yaakapp-internal/models';
import { Icon } from '../components/core/Icon';
import { HStack } from '../components/core/Stacks';
import type { FolderSettingsTab } from '../components/FolderSettingsDialog'; import type { FolderSettingsTab } from '../components/FolderSettingsDialog';
import { FolderSettingsDialog } from '../components/FolderSettingsDialog'; import { FolderSettingsDialog } from '../components/FolderSettingsDialog';
import { showDialog } from '../lib/dialog'; import { showDialog } from '../lib/dialog';
import { resolvedModelName } from '../lib/resolvedModelName';
export function openFolderSettings(folderId: string, tab?: FolderSettingsTab) { export function openFolderSettings(folderId: string, tab?: FolderSettingsTab) {
const folder = getModel('folder', folderId); const folder = getModel('folder', folderId);
if (folder == null) return;
showDialog({ showDialog({
id: 'folder-settings', id: 'folder-settings',
title: ( title: null,
<HStack space={2} alignItems="center">
<Icon icon="folder_cog" size="xl" color="secondary" />
{resolvedModelName(folder)}
</HStack>
),
size: 'lg', size: 'lg',
className: 'h-[50rem]', className: 'h-[50rem]',
noPadding: true, noPadding: true,

View File

@@ -1,12 +1,18 @@
import { createWorkspaceModel, foldersAtom, patchModel } from '@yaakapp-internal/models'; import {
createWorkspaceModel,
foldersAtom,
patchModel,
} from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { useMemo } from 'react'; import { Fragment, useMemo } from 'react';
import { useAuthTab } from '../hooks/useAuthTab'; import { useAuthTab } from '../hooks/useAuthTab';
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown'; import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
import { useHeadersTab } from '../hooks/useHeadersTab'; import { useHeadersTab } from '../hooks/useHeadersTab';
import { useInheritedHeaders } from '../hooks/useInheritedHeaders'; import { useInheritedHeaders } from '../hooks/useInheritedHeaders';
import { useModelAncestors } from '../hooks/useModelAncestors';
import { Button } from './core/Button'; import { Button } from './core/Button';
import { CountBadge } from './core/CountBadge'; import { CountBadge } from './core/CountBadge';
import { Icon } from './core/Icon';
import { Input } from './core/Input'; import { Input } from './core/Input';
import { Link } from './core/Link'; import { Link } from './core/Link';
import { VStack } from './core/Stacks'; import { VStack } from './core/Stacks';
@@ -37,6 +43,8 @@ export type FolderSettingsTab =
export function FolderSettingsDialog({ folderId, tab }: Props) { export function FolderSettingsDialog({ folderId, tab }: Props) {
const folders = useAtomValue(foldersAtom); const folders = useAtomValue(foldersAtom);
const folder = folders.find((f) => f.id === folderId) ?? null; const folder = folders.find((f) => f.id === folderId) ?? null;
const ancestors = useModelAncestors(folder);
const breadcrumbs = useMemo(() => ancestors.toReversed(), [ancestors]);
const authTab = useAuthTab(TAB_AUTH, folder); const authTab = useAuthTab(TAB_AUTH, folder);
const headersTab = useHeadersTab(TAB_HEADERS, folder); const headersTab = useHeadersTab(TAB_HEADERS, folder);
const inheritedHeaders = useInheritedHeaders(folder); const inheritedHeaders = useInheritedHeaders(folder);
@@ -67,76 +75,107 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
if (folder == null) return null; if (folder == null) return null;
return ( return (
<Tabs <div className="h-full flex flex-col">
defaultValue={tab ?? TAB_GENERAL} <div className="flex items-center gap-3 px-6 pr-10 mt-4 mb-2 min-w-0 text-xl">
label="Folder Settings" <Icon icon="folder_cog" size="lg" color="secondary" className="flex-shrink-0" />
className="pt-2 pb-2 pl-3 pr-1" <div className="flex items-center gap-1.5 font-semibold text-text min-w-0 overflow-hidden flex-1">
layout="horizontal" {breadcrumbs.map((item, index) => (
addBorders <Fragment key={item.id}>
tabs={tabs} {index > 0 && (
> <Icon
<TabContent value={TAB_AUTH} className="overflow-y-auto h-full px-4"> icon="chevron_right"
<HttpAuthenticationEditor model={folder} /> size="lg"
</TabContent> className="opacity-50 flex-shrink-0"
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-4"> />
<VStack space={3} className="pb-3 h-full"> )}
<Input <span className="text-text-subtle truncate min-w-0" title={item.name}>
label="Folder Name" {item.name}
defaultValue={folder.name} </span>
onChange={(name) => patchModel(folder, { name })} </Fragment>
stateKey={`name.${folder.id}`} ))}
{breadcrumbs.length > 0 && (
<Icon icon="chevron_right" size="lg" className="opacity-50 flex-shrink-0" />
)}
<span
className="whitespace-nowrap"
title={folder.name}
>
{folder.name}
</span>
</div>
</div>
<Tabs
defaultValue={tab ?? TAB_GENERAL}
label="Folder Settings"
className="pt-2 pb-2 pl-3 pr-1 flex-1"
layout="horizontal"
addBorders
tabs={tabs}
>
<TabContent value={TAB_AUTH} className="overflow-y-auto h-full px-4">
<HttpAuthenticationEditor model={folder} />
</TabContent>
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-4">
<VStack space={3} className="pb-3 h-full">
<Input
label="Folder Name"
defaultValue={folder.name}
onChange={(name) => patchModel(folder, { name })}
stateKey={`name.${folder.id}`}
/>
<MarkdownEditor
name="folder-description"
placeholder="Folder description"
className="border border-border px-2"
defaultValue={folder.description}
stateKey={`description.${folder.id}`}
onChange={(description) => patchModel(folder, { description })}
/>
</VStack>
</TabContent>
<TabContent value={TAB_HEADERS} className="overflow-y-auto h-full px-4">
<HeadersEditor
inheritedHeaders={inheritedHeaders}
forceUpdateKey={folder.id}
headers={folder.headers}
onChange={(headers) => patchModel(folder, { headers })}
stateKey={`headers.${folder.id}`}
/> />
<MarkdownEditor </TabContent>
name="folder-description" <TabContent value={TAB_VARIABLES} className="overflow-y-auto h-full px-4">
placeholder="Folder description" {folderEnvironment == null ? (
className="border border-border px-2" <EmptyStateText>
defaultValue={folder.description} <VStack alignItems="center" space={1.5}>
stateKey={`description.${folder.id}`} <p>
onChange={(description) => patchModel(folder, { description })} Override{' '}
/> <Link href="https://yaak.app/docs/using-yaak/environments-and-variables">
</VStack> Variables
</TabContent> </Link>{' '}
<TabContent value={TAB_HEADERS} className="overflow-y-auto h-full px-4"> for requests within this folder.
<HeadersEditor </p>
inheritedHeaders={inheritedHeaders} <Button
forceUpdateKey={folder.id} variant="border"
headers={folder.headers} size="sm"
onChange={(headers) => patchModel(folder, { headers })} onClick={async () => {
stateKey={`headers.${folder.id}`} await createWorkspaceModel({
/> workspaceId: folder.workspaceId,
</TabContent> parentModel: 'folder',
<TabContent value={TAB_VARIABLES} className="overflow-y-auto h-full px-4"> parentId: folder.id,
{folderEnvironment == null ? ( model: 'environment',
<EmptyStateText> name: 'Folder Environment',
<VStack alignItems="center" space={1.5}> });
<p> }}
Override{' '} >
<Link href="https://yaak.app/docs/using-yaak/environments-and-variables"> Create Folder Environment
Variables </Button>
</Link>{' '} </VStack>
for requests within this folder. </EmptyStateText>
</p> ) : (
<Button <EnvironmentEditor hideName environment={folderEnvironment} />
variant="border" )}
size="sm" </TabContent>
onClick={async () => { </Tabs>
await createWorkspaceModel({ </div>
workspaceId: folder.workspaceId,
parentModel: 'folder',
parentId: folder.id,
model: 'environment',
name: 'Folder Environment',
});
}}
>
Create Folder Environment
</Button>
</VStack>
</EmptyStateText>
) : (
<EnvironmentEditor hideName environment={folderEnvironment} />
)}
</TabContent>
</Tabs>
); );
} }

View File

@@ -98,13 +98,14 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
renderRow={({ event, isActive, onClick }) => ( renderRow={({ event, isActive, onClick }) => (
<GrpcEventRow event={event} isActive={isActive} onClick={onClick} /> <GrpcEventRow event={event} isActive={isActive} onClick={onClick} />
)} )}
renderDetail={({ event }) => ( renderDetail={({ event, onClose }) => (
<GrpcEventDetail <GrpcEventDetail
event={event} event={event}
showLarge={showLarge} showLarge={showLarge}
showingLarge={showingLarge} showingLarge={showingLarge}
setShowLarge={setShowLarge} setShowLarge={setShowLarge}
setShowingLarge={setShowingLarge} setShowingLarge={setShowingLarge}
onClose={onClose}
/> />
)} )}
/> />
@@ -147,19 +148,26 @@ function GrpcEventDetail({
showingLarge, showingLarge,
setShowLarge, setShowLarge,
setShowingLarge, setShowingLarge,
onClose,
}: { }: {
event: GrpcEvent; event: GrpcEvent;
showLarge: boolean; showLarge: boolean;
showingLarge: boolean; showingLarge: boolean;
setShowLarge: (v: boolean) => void; setShowLarge: (v: boolean) => void;
setShowingLarge: (v: boolean) => void; setShowingLarge: (v: boolean) => void;
onClose: () => void;
}) { }) {
if (event.eventType === 'client_message' || event.eventType === 'server_message') { if (event.eventType === 'client_message' || event.eventType === 'server_message') {
const title = `Message ${event.eventType === 'client_message' ? 'Sent' : 'Received'}`; const title = `Message ${event.eventType === 'client_message' ? 'Sent' : 'Received'}`;
return ( return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]"> <div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<EventDetailHeader title={title} timestamp={event.createdAt} copyText={event.content} /> <EventDetailHeader
title={title}
timestamp={event.createdAt}
copyText={event.content}
onClose={onClose}
/>
{!showLarge && event.content.length > 1000 * 1000 ? ( {!showLarge && event.content.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest"> <VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden Message previews larger than 1MB are hidden
@@ -197,7 +205,7 @@ function GrpcEventDetail({
// Error or connection_end - show metadata/trailers // Error or connection_end - show metadata/trailers
return ( return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]"> <div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<EventDetailHeader title={event.content} timestamp={event.createdAt} /> <EventDetailHeader title={event.content} timestamp={event.createdAt} onClose={onClose} />
{event.error && ( {event.error && (
<div className="select-text cursor-text text-sm font-mono py-1 text-warning"> <div className="select-text cursor-text text-sm font-mono py-1 text-warning">
{event.error} {event.error}

View File

@@ -13,7 +13,7 @@ import { useStateWithDeps } from '../hooks/useStateWithDeps';
import { languageFromContentType } from '../lib/contentType'; import { languageFromContentType } from '../lib/contentType';
import { Button } from './core/Button'; import { Button } from './core/Button';
import { Editor } from './core/Editor/LazyEditor'; import { Editor } from './core/Editor/LazyEditor';
import { EventDetailHeader, EventViewer, type EventDetailAction } from './core/EventViewer'; import { type EventDetailAction, EventDetailHeader, EventViewer } from './core/EventViewer';
import { EventViewerRow } from './core/EventViewerRow'; import { EventViewerRow } from './core/EventViewerRow';
import { HotkeyList } from './core/HotkeyList'; import { HotkeyList } from './core/HotkeyList';
import { Icon } from './core/Icon'; import { Icon } from './core/Icon';
@@ -75,7 +75,7 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
renderRow={({ event, isActive, onClick }) => ( renderRow={({ event, isActive, onClick }) => (
<WebsocketEventRow event={event} isActive={isActive} onClick={onClick} /> <WebsocketEventRow event={event} isActive={isActive} onClick={onClick} />
)} )}
renderDetail={({ event, index }) => ( renderDetail={({ event, index, onClose }) => (
<WebsocketEventDetail <WebsocketEventDetail
event={event} event={event}
hexDump={hexDumps[index] ?? event.messageType === 'binary'} hexDump={hexDumps[index] ?? event.messageType === 'binary'}
@@ -84,6 +84,7 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
showingLarge={showingLarge} showingLarge={showingLarge}
setShowLarge={setShowLarge} setShowLarge={setShowLarge}
setShowingLarge={setShowingLarge} setShowingLarge={setShowingLarge}
onClose={onClose}
/> />
)} )}
/> />
@@ -145,6 +146,7 @@ function WebsocketEventDetail({
showingLarge, showingLarge,
setShowLarge, setShowLarge,
setShowingLarge, setShowingLarge,
onClose,
}: { }: {
event: WebsocketEvent; event: WebsocketEvent;
hexDump: boolean; hexDump: boolean;
@@ -153,6 +155,7 @@ function WebsocketEventDetail({
showingLarge: boolean; showingLarge: boolean;
setShowLarge: (v: boolean) => void; setShowLarge: (v: boolean) => void;
setShowingLarge: (v: boolean) => void; setShowingLarge: (v: boolean) => void;
onClose: () => void;
}) { }) {
const message = useMemo(() => { const message = useMemo(() => {
if (hexDump) { if (hexDump) {
@@ -185,11 +188,12 @@ function WebsocketEventDetail({
return ( return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]"> <div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<EventDetailHeader <EventDetailHeader
title={title} title={title}
timestamp={event.createdAt} timestamp={event.createdAt}
actions={actions} actions={actions}
copyText={formattedMessage || undefined} copyText={formattedMessage || undefined}
/> onClose={onClose}
/>
{!showLarge && event.message.length > 1000 * 1000 ? ( {!showLarge && event.message.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest"> <VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden Message previews larger than 1MB are hidden

View File

@@ -135,6 +135,7 @@ export function Workspace() {
open={!floatingSidebarHidden} open={!floatingSidebarHidden}
portalName="sidebar" portalName="sidebar"
onClose={() => setFloatingSidebarHidden(true)} onClose={() => setFloatingSidebarHidden(true)}
zIndex={20}
> >
<m.div <m.div
initial={{ opacity: 0, x: -20 }} initial={{ opacity: 0, x: -20 }}

View File

@@ -51,10 +51,9 @@ function ActualEventStreamViewer({ response }: Props) {
<span className="truncate text-xs">{event.data.slice(0, 1000)}</span> <span className="truncate text-xs">{event.data.slice(0, 1000)}</span>
</HStack> </HStack>
} }
/> />
)} )}
renderDetail={({ event, index }) => ( renderDetail={({ event, index, onClose }) => (
<EventDetail <EventDetail
event={event} event={event}
index={index} index={index}
@@ -62,6 +61,7 @@ function ActualEventStreamViewer({ response }: Props) {
showingLarge={showingLarge} showingLarge={showingLarge}
setShowLarge={setShowLarge} setShowLarge={setShowLarge}
setShowingLarge={setShowingLarge} setShowingLarge={setShowingLarge}
onClose={onClose}
/> />
)} )}
/> />
@@ -75,6 +75,7 @@ function EventDetail({
showingLarge, showingLarge,
setShowLarge, setShowLarge,
setShowingLarge, setShowingLarge,
onClose,
}: { }: {
event: ServerSentEvent; event: ServerSentEvent;
index: number; index: number;
@@ -82,6 +83,7 @@ function EventDetail({
showingLarge: boolean; showingLarge: boolean;
setShowLarge: (v: boolean) => void; setShowLarge: (v: boolean) => void;
setShowingLarge: (v: boolean) => void; setShowingLarge: (v: boolean) => void;
onClose: () => void;
}) { }) {
const language = useMemo<'text' | 'json'>(() => { const language = useMemo<'text' | 'json'>(() => {
if (!event?.data) return 'text'; if (!event?.data) return 'text';
@@ -90,7 +92,11 @@ function EventDetail({
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<EventDetailHeader title="Message Received" prefix={<EventLabels event={event} index={index} />} /> <EventDetailHeader
title="Message Received"
prefix={<EventLabels event={event} index={index} />}
onClose={onClose}
/>
{!showLarge && event.data.length > 1000 * 1000 ? ( {!showLarge && event.data.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest"> <VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden Message previews larger than 1MB are hidden