From 6eba7ffbd434cb2a6ce47b562c7abe7414bf677e Mon Sep 17 00:00:00 2001 From: slkzgm Date: Thu, 15 Jan 2026 16:40:45 +0100 Subject: [PATCH] refactor(backend): isolate app-server core from tauri glue --- src-tauri/src/backend/app_server.rs | 315 +++++++++++++++++++++++++++ src-tauri/src/backend/events.rs | 22 ++ src-tauri/src/backend/mod.rs | 2 + src-tauri/src/codex.rs | 327 ++-------------------------- src-tauri/src/event_sink.rs | 24 ++ src-tauri/src/lib.rs | 2 + src-tauri/src/terminal.rs | 20 +- 7 files changed, 390 insertions(+), 322 deletions(-) create mode 100644 src-tauri/src/backend/app_server.rs create mode 100644 src-tauri/src/backend/events.rs create mode 100644 src-tauri/src/backend/mod.rs create mode 100644 src-tauri/src/event_sink.rs diff --git a/src-tauri/src/backend/app_server.rs b/src-tauri/src/backend/app_server.rs new file mode 100644 index 000000000..7a843d366 --- /dev/null +++ b/src-tauri/src/backend/app_server.rs @@ -0,0 +1,315 @@ +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::env; +use std::io::ErrorKind; +use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, ChildStdin, Command}; +use tokio::sync::{oneshot, Mutex}; +use tokio::time::timeout; + +use crate::backend::events::{AppServerEvent, EventSink}; +use crate::types::WorkspaceEntry; + +pub(crate) struct WorkspaceSession { + pub(crate) entry: WorkspaceEntry, + pub(crate) child: Mutex, + pub(crate) stdin: Mutex, + pub(crate) pending: Mutex>>, + pub(crate) next_id: AtomicU64, +} + +impl WorkspaceSession { + async fn write_message(&self, value: Value) -> Result<(), String> { + let mut stdin = self.stdin.lock().await; + let mut line = serde_json::to_string(&value).map_err(|e| e.to_string())?; + line.push('\n'); + stdin + .write_all(line.as_bytes()) + .await + .map_err(|e| e.to_string()) + } + + pub(crate) async fn send_request(&self, method: &str, params: Value) -> Result { + let id = self.next_id.fetch_add(1, Ordering::SeqCst); + let (tx, rx) = oneshot::channel(); + self.pending.lock().await.insert(id, tx); + self.write_message(json!({ "id": id, "method": method, "params": params })) + .await?; + rx.await.map_err(|_| "request canceled".to_string()) + } + + pub(crate) async fn send_notification( + &self, + method: &str, + params: Option, + ) -> Result<(), String> { + let value = if let Some(params) = params { + json!({ "method": method, "params": params }) + } else { + json!({ "method": method }) + }; + self.write_message(value).await + } + + pub(crate) async fn send_response(&self, id: u64, result: Value) -> Result<(), String> { + self.write_message(json!({ "id": id, "result": result })) + .await + } +} + +pub(crate) fn build_codex_path_env(codex_bin: Option<&str>) -> Option { + let mut paths: Vec = env::var("PATH") + .unwrap_or_default() + .split(':') + .filter(|value| !value.is_empty()) + .map(|value| value.to_string()) + .collect(); + let mut extras = vec![ + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + ] + .into_iter() + .map(|value| value.to_string()) + .collect::>(); + if let Ok(home) = env::var("HOME") { + extras.push(format!("{home}/.local/bin")); + extras.push(format!("{home}/.local/share/mise/shims")); + extras.push(format!("{home}/.cargo/bin")); + extras.push(format!("{home}/.bun/bin")); + let nvm_root = Path::new(&home).join(".nvm/versions/node"); + if let Ok(entries) = std::fs::read_dir(nvm_root) { + for entry in entries.flatten() { + let bin_path = entry.path().join("bin"); + if bin_path.is_dir() { + extras.push(bin_path.to_string_lossy().to_string()); + } + } + } + } + if let Some(bin_path) = codex_bin.filter(|value| !value.trim().is_empty()) { + let parent = Path::new(bin_path).parent(); + if let Some(parent) = parent { + extras.push(parent.to_string_lossy().to_string()); + } + } + for extra in extras { + if !paths.contains(&extra) { + paths.push(extra); + } + } + if paths.is_empty() { + None + } else { + Some(paths.join(":")) + } +} + +pub(crate) fn build_codex_command_with_bin(codex_bin: Option) -> Command { + let bin = codex_bin + .clone() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "codex".into()); + let mut command = Command::new(bin); + if let Some(path_env) = build_codex_path_env(codex_bin.as_deref()) { + command.env("PATH", path_env); + } + command +} + +pub(crate) async fn check_codex_installation( + codex_bin: Option, +) -> Result, String> { + let mut command = build_codex_command_with_bin(codex_bin); + command.arg("--version"); + command.stdout(std::process::Stdio::piped()); + command.stderr(std::process::Stdio::piped()); + + let output = match timeout(Duration::from_secs(5), command.output()).await { + Ok(result) => result.map_err(|e| { + if e.kind() == ErrorKind::NotFound { + "Codex CLI not found. Install Codex and ensure `codex` is on your PATH." + .to_string() + } else { + e.to_string() + } + })?, + Err(_) => { + return Err( + "Timed out while checking Codex CLI. Make sure `codex --version` runs in Terminal." + .to_string(), + ); + } + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let detail = if stderr.trim().is_empty() { + stdout.trim() + } else { + stderr.trim() + }; + if detail.is_empty() { + return Err( + "Codex CLI failed to start. Try running `codex --version` in Terminal." + .to_string(), + ); + } + return Err(format!( + "Codex CLI failed to start: {detail}. Try running `codex --version` in Terminal." + )); + } + + let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); + Ok(if version.is_empty() { None } else { Some(version) }) +} + +pub(crate) async fn spawn_workspace_session( + entry: WorkspaceEntry, + default_codex_bin: Option, + client_version: String, + event_sink: E, +) -> Result, String> { + let codex_bin = entry + .codex_bin + .clone() + .filter(|value| !value.trim().is_empty()) + .or(default_codex_bin); + let _ = check_codex_installation(codex_bin.clone()).await?; + + let mut command = build_codex_command_with_bin(codex_bin); + command.current_dir(&entry.path); + command.arg("app-server"); + command.stdin(std::process::Stdio::piped()); + command.stdout(std::process::Stdio::piped()); + command.stderr(std::process::Stdio::piped()); + + let mut child = command.spawn().map_err(|e| e.to_string())?; + let stdin = child.stdin.take().ok_or("missing stdin")?; + let stdout = child.stdout.take().ok_or("missing stdout")?; + let stderr = child.stderr.take().ok_or("missing stderr")?; + + let session = Arc::new(WorkspaceSession { + entry: entry.clone(), + child: Mutex::new(child), + stdin: Mutex::new(stdin), + pending: Mutex::new(HashMap::new()), + next_id: AtomicU64::new(1), + }); + + let session_clone = Arc::clone(&session); + let workspace_id = entry.id.clone(); + let event_sink_clone = event_sink.clone(); + tokio::spawn(async move { + let mut lines = BufReader::new(stdout).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if line.trim().is_empty() { + continue; + } + let value: Value = match serde_json::from_str(&line) { + Ok(value) => value, + Err(err) => { + let payload = AppServerEvent { + workspace_id: workspace_id.clone(), + message: json!({ + "method": "codex/parseError", + "params": { "error": err.to_string(), "raw": line }, + }), + }; + event_sink_clone.emit_app_server_event(payload); + continue; + } + }; + + let maybe_id = value.get("id").and_then(|id| id.as_u64()); + let has_method = value.get("method").is_some(); + let has_result_or_error = value.get("result").is_some() || value.get("error").is_some(); + if let Some(id) = maybe_id { + if has_result_or_error { + if let Some(tx) = session_clone.pending.lock().await.remove(&id) { + let _ = tx.send(value); + } + } else if has_method { + let payload = AppServerEvent { + workspace_id: workspace_id.clone(), + message: value, + }; + event_sink_clone.emit_app_server_event(payload); + } else if let Some(tx) = session_clone.pending.lock().await.remove(&id) { + let _ = tx.send(value); + } + } else if has_method { + let payload = AppServerEvent { + workspace_id: workspace_id.clone(), + message: value, + }; + event_sink_clone.emit_app_server_event(payload); + } + } + }); + + let workspace_id = entry.id.clone(); + let event_sink_clone = event_sink.clone(); + tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if line.trim().is_empty() { + continue; + } + let payload = AppServerEvent { + workspace_id: workspace_id.clone(), + message: json!({ + "method": "codex/stderr", + "params": { "message": line }, + }), + }; + event_sink_clone.emit_app_server_event(payload); + } + }); + + let init_params = json!({ + "clientInfo": { + "name": "codex_monitor", + "title": "CodexMonitor", + "version": client_version + } + }); + let init_result = timeout( + Duration::from_secs(15), + session.send_request("initialize", init_params), + ) + .await; + let init_response = match init_result { + Ok(response) => response, + Err(_) => { + let mut child = session.child.lock().await; + let _ = child.kill().await; + return Err( + "Codex app-server did not respond to initialize. Check that `codex app-server` works in Terminal." + .to_string(), + ); + } + }; + init_response?; + session.send_notification("initialized", None).await?; + + let payload = AppServerEvent { + workspace_id: entry.id.clone(), + message: json!({ + "method": "codex/connected", + "params": { "workspaceId": entry.id.clone() } + }), + }; + event_sink.emit_app_server_event(payload); + + Ok(session) +} diff --git a/src-tauri/src/backend/events.rs b/src-tauri/src/backend/events.rs new file mode 100644 index 000000000..69da89ae4 --- /dev/null +++ b/src-tauri/src/backend/events.rs @@ -0,0 +1,22 @@ +use serde::Serialize; +use serde_json::Value; + +#[derive(Serialize, Clone)] +pub(crate) struct AppServerEvent { + pub(crate) workspace_id: String, + pub(crate) message: Value, +} + +#[derive(Debug, Serialize, Clone)] +pub(crate) struct TerminalOutput { + #[serde(rename = "workspaceId")] + pub(crate) workspace_id: String, + #[serde(rename = "terminalId")] + pub(crate) terminal_id: String, + pub(crate) data: String, +} + +pub(crate) trait EventSink: Clone + Send + Sync + 'static { + fn emit_app_server_event(&self, event: AppServerEvent); + fn emit_terminal_output(&self, event: TerminalOutput); +} diff --git a/src-tauri/src/backend/mod.rs b/src-tauri/src/backend/mod.rs new file mode 100644 index 000000000..3737beadb --- /dev/null +++ b/src-tauri/src/backend/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod app_server; +pub(crate) mod events; diff --git a/src-tauri/src/codex.rs b/src-tauri/src/codex.rs index 63b7d4f5e..9888cadd0 100644 --- a/src-tauri/src/codex.rs +++ b/src-tauri/src/codex.rs @@ -1,320 +1,29 @@ -use serde::Serialize; use serde_json::{json, Map, Value}; -use std::collections::HashMap; -use std::env; use std::io::ErrorKind; -use std::path::Path; -use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::Duration; -use tauri::{AppHandle, Emitter, State}; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::process::{Child, ChildStdin, Command}; -use tokio::sync::{oneshot, Mutex}; +use tauri::{AppHandle, State}; +use tokio::process::Command; use tokio::time::timeout; +pub(crate) use crate::backend::app_server::WorkspaceSession; +use crate::backend::app_server::{ + build_codex_command_with_bin, build_codex_path_env, check_codex_installation, + spawn_workspace_session as spawn_workspace_session_inner, +}; +use crate::event_sink::TauriEventSink; use crate::state::AppState; use crate::types::WorkspaceEntry; -#[derive(Serialize, Clone)] -struct AppServerEvent { - workspace_id: String, - message: Value, -} - -pub(crate) struct WorkspaceSession { - pub(crate) entry: WorkspaceEntry, - pub(crate) child: Mutex, - pub(crate) stdin: Mutex, - pub(crate) pending: Mutex>>, - pub(crate) next_id: AtomicU64, -} - -impl WorkspaceSession { - async fn write_message(&self, value: Value) -> Result<(), String> { - let mut stdin = self.stdin.lock().await; - let mut line = serde_json::to_string(&value).map_err(|e| e.to_string())?; - line.push('\n'); - stdin - .write_all(line.as_bytes()) - .await - .map_err(|e| e.to_string()) - } - - async fn send_request(&self, method: &str, params: Value) -> Result { - let id = self.next_id.fetch_add(1, Ordering::SeqCst); - let (tx, rx) = oneshot::channel(); - self.pending.lock().await.insert(id, tx); - self.write_message(json!({ "id": id, "method": method, "params": params })) - .await?; - rx.await.map_err(|_| "request canceled".to_string()) - } - - async fn send_notification(&self, method: &str, params: Option) -> Result<(), String> { - let value = if let Some(params) = params { - json!({ "method": method, "params": params }) - } else { - json!({ "method": method }) - }; - self.write_message(value).await - } - - async fn send_response(&self, id: u64, result: Value) -> Result<(), String> { - self.write_message(json!({ "id": id, "result": result })) - .await - } -} - -fn build_codex_path_env(codex_bin: Option<&str>) -> Option { - let mut paths: Vec = env::var("PATH") - .unwrap_or_default() - .split(':') - .filter(|value| !value.is_empty()) - .map(|value| value.to_string()) - .collect(); - let mut extras = vec![ - "/opt/homebrew/bin", - "/usr/local/bin", - "/usr/bin", - "/bin", - "/usr/sbin", - "/sbin", - ] - .into_iter() - .map(|value| value.to_string()) - .collect::>(); - if let Ok(home) = env::var("HOME") { - extras.push(format!("{home}/.local/bin")); - extras.push(format!("{home}/.local/share/mise/shims")); - extras.push(format!("{home}/.cargo/bin")); - extras.push(format!("{home}/.bun/bin")); - let nvm_root = Path::new(&home).join(".nvm/versions/node"); - if let Ok(entries) = std::fs::read_dir(nvm_root) { - for entry in entries.flatten() { - let bin_path = entry.path().join("bin"); - if bin_path.is_dir() { - extras.push(bin_path.to_string_lossy().to_string()); - } - } - } - } - if let Some(bin_path) = codex_bin.filter(|value| !value.trim().is_empty()) { - let parent = Path::new(bin_path).parent(); - if let Some(parent) = parent { - extras.push(parent.to_string_lossy().to_string()); - } - } - for extra in extras { - if !paths.contains(&extra) { - paths.push(extra); - } - } - if paths.is_empty() { - None - } else { - Some(paths.join(":")) - } -} - -fn build_codex_command_with_bin(codex_bin: Option) -> Command { - let bin = codex_bin - .clone() - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| "codex".into()); - let mut command = Command::new(bin); - if let Some(path_env) = build_codex_path_env(codex_bin.as_deref()) { - command.env("PATH", path_env); - } - command -} - -async fn check_codex_installation(codex_bin: Option) -> Result, String> { - let mut command = build_codex_command_with_bin(codex_bin); - command.arg("--version"); - command.stdout(std::process::Stdio::piped()); - command.stderr(std::process::Stdio::piped()); - - let output = match timeout(Duration::from_secs(5), command.output()).await { - Ok(result) => result.map_err(|e| { - if e.kind() == ErrorKind::NotFound { - "Codex CLI not found. Install Codex and ensure `codex` is on your PATH." - .to_string() - } else { - e.to_string() - } - })?, - Err(_) => { - return Err( - "Timed out while checking Codex CLI. Make sure `codex --version` runs in Terminal." - .to_string(), - ); - } - }; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); - let detail = if stderr.trim().is_empty() { - stdout.trim() - } else { - stderr.trim() - }; - if detail.is_empty() { - return Err( - "Codex CLI failed to start. Try running `codex --version` in Terminal." - .to_string(), - ); - } - return Err(format!( - "Codex CLI failed to start: {detail}. Try running `codex --version` in Terminal." - )); - } - - let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); - Ok(if version.is_empty() { None } else { Some(version) }) -} - pub(crate) async fn spawn_workspace_session( entry: WorkspaceEntry, default_codex_bin: Option, app_handle: AppHandle, ) -> Result, String> { - let codex_bin = entry - .codex_bin - .clone() - .filter(|value| !value.trim().is_empty()) - .or(default_codex_bin); - let _ = check_codex_installation(codex_bin.clone()).await?; - - let mut command = build_codex_command_with_bin(codex_bin); - command.current_dir(&entry.path); - command.arg("app-server"); - command.stdin(std::process::Stdio::piped()); - command.stdout(std::process::Stdio::piped()); - command.stderr(std::process::Stdio::piped()); - - let mut child = command.spawn().map_err(|e| e.to_string())?; - let stdin = child.stdin.take().ok_or("missing stdin")?; - let stdout = child.stdout.take().ok_or("missing stdout")?; - let stderr = child.stderr.take().ok_or("missing stderr")?; - - let session = Arc::new(WorkspaceSession { - entry: entry.clone(), - child: Mutex::new(child), - stdin: Mutex::new(stdin), - pending: Mutex::new(HashMap::new()), - next_id: AtomicU64::new(1), - }); - - let session_clone = Arc::clone(&session); - let workspace_id = entry.id.clone(); - let app_handle_clone = app_handle.clone(); - tauri::async_runtime::spawn(async move { - let mut lines = BufReader::new(stdout).lines(); - while let Ok(Some(line)) = lines.next_line().await { - if line.trim().is_empty() { - continue; - } - let value: Value = match serde_json::from_str(&line) { - Ok(value) => value, - Err(err) => { - let payload = AppServerEvent { - workspace_id: workspace_id.clone(), - message: json!({ - "method": "codex/parseError", - "params": { "error": err.to_string(), "raw": line }, - }), - }; - let _ = app_handle_clone.emit("app-server-event", payload); - continue; - } - }; - - let maybe_id = value.get("id").and_then(|id| id.as_u64()); - let has_method = value.get("method").is_some(); - let has_result_or_error = - value.get("result").is_some() || value.get("error").is_some(); - if let Some(id) = maybe_id { - if has_result_or_error { - if let Some(tx) = session_clone.pending.lock().await.remove(&id) { - let _ = tx.send(value); - } - } else if has_method { - let payload = AppServerEvent { - workspace_id: workspace_id.clone(), - message: value, - }; - let _ = app_handle_clone.emit("app-server-event", payload); - } else if let Some(tx) = session_clone.pending.lock().await.remove(&id) { - let _ = tx.send(value); - } - } else if has_method { - let payload = AppServerEvent { - workspace_id: workspace_id.clone(), - message: value, - }; - let _ = app_handle_clone.emit("app-server-event", payload); - } - } - }); - - let workspace_id = entry.id.clone(); - let app_handle_clone = app_handle.clone(); - tauri::async_runtime::spawn(async move { - let mut lines = BufReader::new(stderr).lines(); - while let Ok(Some(line)) = lines.next_line().await { - if line.trim().is_empty() { - continue; - } - let payload = AppServerEvent { - workspace_id: workspace_id.clone(), - message: json!({ - "method": "codex/stderr", - "params": { "message": line }, - }), - }; - let _ = app_handle_clone.emit("app-server-event", payload); - } - }); - let client_version = app_handle.package_info().version.to_string(); - let init_params = json!({ - "clientInfo": { - "name": "codex_monitor", - "title": "CodexMonitor", - "version": client_version - } - }); - let init_result = timeout( - Duration::from_secs(15), - session.send_request("initialize", init_params), - ) - .await; - let init_response = match init_result { - Ok(response) => response, - Err(_) => { - let mut child = session.child.lock().await; - let _ = child.kill().await; - return Err( - "Codex app-server did not respond to initialize. Check that `codex app-server` works in Terminal." - .to_string(), - ); - } - }; - init_response?; - session.send_notification("initialized", None).await?; - - let payload = AppServerEvent { - workspace_id: entry.id.clone(), - message: json!({ - "method": "codex/connected", - "params": { "workspaceId": entry.id.clone() } - }), - }; - let _ = app_handle.emit("app-server-event", payload); - - Ok(session) + let event_sink = TauriEventSink::new(app_handle); + spawn_workspace_session_inner(entry, default_codex_bin, client_version, event_sink).await } #[tauri::command] @@ -353,8 +62,9 @@ pub(crate) async fn codex_doctor( Ok(result) => match result { Ok(output) => { if output.status.success() { - let version = - String::from_utf8_lossy(&output.stdout).trim().to_string(); + let version = String::from_utf8_lossy(&output.stdout) + .trim() + .to_string(); ( !version.is_empty(), if version.is_empty() { None } else { Some(version) }, @@ -387,11 +97,7 @@ pub(crate) async fn codex_doctor( } } }, - Err(_) => ( - false, - None, - Some("Timed out while checking Node.".to_string()), - ), + Err(_) => (false, None, Some("Timed out while checking Node.".to_string())), } }; let details = if app_server_ok { @@ -525,7 +231,10 @@ pub(crate) async fn send_user_message( if trimmed.is_empty() { continue; } - if trimmed.starts_with("data:") || trimmed.starts_with("http://") || trimmed.starts_with("https://") { + if trimmed.starts_with("data:") + || trimmed.starts_with("http://") + || trimmed.starts_with("https://") + { input.push(json!({ "type": "image", "url": trimmed })); } else { input.push(json!({ "type": "localImage", "path": trimmed })); diff --git a/src-tauri/src/event_sink.rs b/src-tauri/src/event_sink.rs new file mode 100644 index 000000000..d1933346a --- /dev/null +++ b/src-tauri/src/event_sink.rs @@ -0,0 +1,24 @@ +use tauri::{AppHandle, Emitter}; + +use crate::backend::events::{AppServerEvent, EventSink, TerminalOutput}; + +#[derive(Clone)] +pub(crate) struct TauriEventSink { + app: AppHandle, +} + +impl TauriEventSink { + pub(crate) fn new(app: AppHandle) -> Self { + Self { app } + } +} + +impl EventSink for TauriEventSink { + fn emit_app_server_event(&self, event: AppServerEvent) { + let _ = self.app.emit("app-server-event", event); + } + + fn emit_terminal_output(&self, event: TerminalOutput) { + let _ = self.app.emit("terminal-output", event); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c584ea9e3..af108ad8b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,7 +1,9 @@ use tauri::menu::{Menu, MenuItemBuilder, PredefinedMenuItem, Submenu}; use tauri::{Manager, WebviewUrl, WebviewWindowBuilder}; +mod backend; mod codex; +mod event_sink; mod git; mod prompts; mod settings; diff --git a/src-tauri/src/terminal.rs b/src-tauri/src/terminal.rs index 315590088..dba7f9870 100644 --- a/src-tauri/src/terminal.rs +++ b/src-tauri/src/terminal.rs @@ -4,9 +4,11 @@ use std::sync::Arc; use portable_pty::{native_pty_system, CommandBuilder, PtySize}; use serde::Serialize; -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, State}; use tokio::sync::Mutex; +use crate::backend::events::{EventSink, TerminalOutput}; +use crate::event_sink::TauriEventSink; use crate::state::AppState; pub(crate) struct TerminalSession { @@ -21,15 +23,6 @@ pub(crate) struct TerminalSessionInfo { id: String, } -#[derive(Debug, Serialize, Clone)] -struct TerminalOutput { - #[serde(rename = "workspaceId")] - workspace_id: String, - #[serde(rename = "terminalId")] - terminal_id: String, - data: String, -} - fn terminal_key(workspace_id: &str, terminal_id: &str) -> String { format!("{workspace_id}:{terminal_id}") } @@ -39,7 +32,7 @@ fn shell_path() -> String { } fn spawn_terminal_reader( - app: AppHandle, + event_sink: impl EventSink, workspace_id: String, terminal_id: String, mut reader: Box, @@ -56,7 +49,7 @@ fn spawn_terminal_reader( terminal_id: terminal_id.clone(), data, }; - let _ = app.emit("terminal-output", payload); + event_sink.emit_terminal_output(payload); } Err(_) => break, } @@ -146,7 +139,8 @@ pub(crate) async fn terminal_open( } sessions.insert(key, session); } - spawn_terminal_reader(app, workspace_id, terminal_id, reader); + let event_sink = TauriEventSink::new(app); + spawn_terminal_reader(event_sink, workspace_id, terminal_id, reader); Ok(TerminalSessionInfo { id: session_id,