refactor(v2): extract bterminal-core crate with EventSink trait

Create Cargo workspace at v2/ level with members: src-tauri,
bterminal-core, bterminal-relay. Extract PtyManager and SidecarManager
into shared bterminal-core crate with EventSink trait for abstracting
event emission. TauriEventSink wraps AppHandle. src-tauri pty.rs and
sidecar.rs become thin re-exports. Move Cargo.lock to workspace root.
Add v2/target/ to .gitignore.
This commit is contained in:
Hibryda 2026-03-06 19:05:35 +01:00
parent 250ea17d3e
commit f894c2862c
13 changed files with 972 additions and 453 deletions

5822
v2/src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -17,17 +17,20 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2.5.6", features = [] }
[dependencies]
bterminal-core = { path = "../bterminal-core" }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.10.3", features = [] }
tauri-plugin-log = "2"
portable-pty = "0.8"
uuid = { version = "1", features = ["v4"] }
rusqlite = { version = "0.31", features = ["bundled"] }
dirs = "5"
notify = { version = "6", features = ["macos_fsevent"] }
tauri-plugin-updater = "2.10.0"
uuid = { version = "1", features = ["v4"] }
tokio-tungstenite = { version = "0.21", features = ["native-tls"] }
tokio = { version = "1", features = ["full"] }
futures-util = "0.3"
[dev-dependencies]
tempfile = "3"

View file

@ -0,0 +1,11 @@
use bterminal_core::event::EventSink;
use tauri::{AppHandle, Emitter};
/// Bridges bterminal-core's EventSink trait to Tauri's event system.
pub struct TauriEventSink(pub AppHandle);
impl EventSink for TauriEventSink {
fn emit(&self, event: &str, payload: serde_json::Value) {
let _ = self.0.emit(event, &payload);
}
}

View file

@ -1,16 +1,20 @@
mod ctx;
mod event_sink;
mod pty;
mod remote;
mod sidecar;
mod watcher;
mod session;
mod watcher;
use ctx::CtxDb;
use event_sink::TauriEventSink;
use pty::{PtyManager, PtyOptions};
use remote::{RemoteManager, RemoteMachineConfig};
use session::{Session, SessionDb, LayoutState, SshSession};
use sidecar::{AgentQueryOptions, SidecarManager};
use sidecar::{AgentQueryOptions, SidecarConfig, SidecarManager};
use watcher::FileWatcherManager;
use std::sync::Arc;
use tauri::State;
use tauri::{Manager, State};
struct AppState {
pty_manager: Arc<PtyManager>,
@ -18,17 +22,17 @@ struct AppState {
session_db: Arc<SessionDb>,
file_watcher: Arc<FileWatcherManager>,
ctx_db: Arc<CtxDb>,
remote_manager: Arc<RemoteManager>,
}
// --- PTY commands ---
#[tauri::command]
fn pty_spawn(
app: tauri::AppHandle,
state: State<'_, AppState>,
options: PtyOptions,
) -> Result<String, String> {
state.pty_manager.spawn(&app, options)
state.pty_manager.spawn(options)
}
#[tauri::command]
@ -72,8 +76,8 @@ fn agent_ready(state: State<'_, AppState>) -> bool {
}
#[tauri::command]
fn agent_restart(app: tauri::AppHandle, state: State<'_, AppState>) -> Result<(), String> {
state.sidecar_manager.restart(&app)
fn agent_restart(state: State<'_, AppState>) -> Result<(), String> {
state.sidecar_manager.restart()
}
// --- File watcher commands ---
@ -201,32 +205,66 @@ fn ctx_search(state: State<'_, AppState>, query: String) -> Result<Vec<ctx::CtxE
state.ctx_db.search(&query)
}
// --- Remote machine commands ---
#[tauri::command]
fn remote_list(state: State<'_, AppState>) -> Vec<remote::RemoteMachineInfo> {
state.remote_manager.list_machines()
}
#[tauri::command]
async fn remote_add(state: State<'_, AppState>, config: RemoteMachineConfig) -> Result<String, String> {
Ok(state.remote_manager.add_machine(config))
}
#[tauri::command]
async fn remote_remove(state: State<'_, AppState>, machine_id: String) -> Result<(), String> {
state.remote_manager.remove_machine(&machine_id)
}
#[tauri::command]
async fn remote_connect(app: tauri::AppHandle, state: State<'_, AppState>, machine_id: String) -> Result<(), String> {
state.remote_manager.connect(&app, &machine_id).await
}
#[tauri::command]
async fn remote_disconnect(state: State<'_, AppState>, machine_id: String) -> Result<(), String> {
state.remote_manager.disconnect(&machine_id).await
}
#[tauri::command]
async fn remote_agent_query(state: State<'_, AppState>, machine_id: String, options: AgentQueryOptions) -> Result<(), String> {
state.remote_manager.agent_query(&machine_id, &options).await
}
#[tauri::command]
async fn remote_agent_stop(state: State<'_, AppState>, machine_id: String, session_id: String) -> Result<(), String> {
state.remote_manager.agent_stop(&machine_id, &session_id).await
}
#[tauri::command]
async fn remote_pty_spawn(state: State<'_, AppState>, machine_id: String, options: PtyOptions) -> Result<String, String> {
state.remote_manager.pty_spawn(&machine_id, &options).await
}
#[tauri::command]
async fn remote_pty_write(state: State<'_, AppState>, machine_id: String, id: String, data: String) -> Result<(), String> {
state.remote_manager.pty_write(&machine_id, &id, &data).await
}
#[tauri::command]
async fn remote_pty_resize(state: State<'_, AppState>, machine_id: String, id: String, cols: u16, rows: u16) -> Result<(), String> {
state.remote_manager.pty_resize(&machine_id, &id, cols, rows).await
}
#[tauri::command]
async fn remote_pty_kill(state: State<'_, AppState>, machine_id: String, id: String) -> Result<(), String> {
state.remote_manager.pty_kill(&machine_id, &id).await
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let pty_manager = Arc::new(PtyManager::new());
let sidecar_manager = Arc::new(SidecarManager::new());
// Initialize session database in app data directory
let data_dir = dirs::data_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("bterminal");
let session_db = Arc::new(
SessionDb::open(&data_dir).expect("Failed to open session database")
);
let file_watcher = Arc::new(FileWatcherManager::new());
let ctx_db = Arc::new(CtxDb::new());
let app_state = AppState {
pty_manager,
sidecar_manager: sidecar_manager.clone(),
session_db,
file_watcher,
ctx_db,
};
tauri::Builder::default()
.manage(app_state)
.invoke_handler(tauri::generate_handler![
pty_spawn,
pty_write,
@ -258,6 +296,17 @@ pub fn run() {
ctx_get_shared,
ctx_get_summaries,
ctx_search,
remote_list,
remote_add,
remote_remove,
remote_connect,
remote_disconnect,
remote_agent_query,
remote_agent_stop,
remote_pty_spawn,
remote_pty_write,
remote_pty_resize,
remote_pty_kill,
])
.plugin(tauri_plugin_updater::Builder::new().build())
.setup(move |app| {
@ -269,12 +318,57 @@ pub fn run() {
)?;
}
// Start sidecar on app launch
match sidecar_manager.start(app.handle()) {
// Create TauriEventSink for core managers
let sink: Arc<dyn bterminal_core::event::EventSink> =
Arc::new(TauriEventSink(app.handle().clone()));
// Build sidecar config from Tauri paths
let resource_dir = app
.handle()
.path()
.resource_dir()
.unwrap_or_default();
let dev_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.to_path_buf();
let sidecar_config = SidecarConfig {
search_paths: vec![
resource_dir.join("sidecar"),
dev_root.join("sidecar"),
],
};
let pty_manager = Arc::new(PtyManager::new(sink.clone()));
let sidecar_manager = Arc::new(SidecarManager::new(sink, sidecar_config));
// Initialize session database
let data_dir = dirs::data_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("bterminal");
let session_db = Arc::new(
SessionDb::open(&data_dir).expect("Failed to open session database"),
);
let file_watcher = Arc::new(FileWatcherManager::new());
let ctx_db = Arc::new(CtxDb::new());
let remote_manager = Arc::new(RemoteManager::new());
// Start local sidecar
match sidecar_manager.start() {
Ok(()) => log::info!("Sidecar startup initiated"),
Err(e) => log::warn!("Sidecar startup failed (agent features unavailable): {e}"),
}
app.manage(AppState {
pty_manager,
sidecar_manager,
session_db,
file_watcher,
ctx_db,
remote_manager,
});
Ok(())
})
.run(tauri::generate_context!())

View file

@ -1,160 +1,4 @@
use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::{BufReader, Write};
use std::sync::{Arc, Mutex};
use std::thread;
use tauri::{AppHandle, Emitter};
use uuid::Uuid;
// Thin wrapper — re-exports bterminal_core::pty types.
// PtyManager is now in bterminal-core; this module only re-exports for lib.rs.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PtyOptions {
pub shell: Option<String>,
pub cwd: Option<String>,
pub args: Option<Vec<String>>,
pub cols: Option<u16>,
pub rows: Option<u16>,
}
struct PtyInstance {
master: Box<dyn MasterPty + Send>,
writer: Box<dyn Write + Send>,
}
pub struct PtyManager {
instances: Arc<Mutex<HashMap<String, PtyInstance>>>,
}
impl PtyManager {
pub fn new() -> Self {
Self {
instances: Arc::new(Mutex::new(HashMap::new())),
}
}
pub fn spawn(
&self,
app: &AppHandle,
options: PtyOptions,
) -> Result<String, String> {
let pty_system = native_pty_system();
let cols = options.cols.unwrap_or(80);
let rows = options.rows.unwrap_or(24);
let pair = pty_system
.openpty(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| format!("Failed to open PTY: {e}"))?;
let shell = options.shell.unwrap_or_else(|| {
std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string())
});
let mut cmd = CommandBuilder::new(&shell);
if let Some(args) = &options.args {
for arg in args {
cmd.arg(arg);
}
}
if let Some(cwd) = &options.cwd {
cmd.cwd(cwd);
}
let _child = pair
.slave
.spawn_command(cmd)
.map_err(|e| format!("Failed to spawn command: {e}"))?;
// Drop the slave side — we only need the master
drop(pair.slave);
let id = Uuid::new_v4().to_string();
let reader = pair
.master
.try_clone_reader()
.map_err(|e| format!("Failed to clone PTY reader: {e}"))?;
let writer = pair
.master
.take_writer()
.map_err(|e| format!("Failed to take PTY writer: {e}"))?;
// Spawn reader thread that emits Tauri events
let event_id = id.clone();
let app_handle = app.clone();
thread::spawn(move || {
let mut buf_reader = BufReader::with_capacity(4096, reader);
let mut buf = vec![0u8; 4096];
loop {
match std::io::Read::read(&mut buf_reader, &mut buf) {
Ok(0) => {
// PTY closed
let _ = app_handle.emit(&format!("pty-exit-{event_id}"), ());
break;
}
Ok(n) => {
let data = String::from_utf8_lossy(&buf[..n]).to_string();
let _ = app_handle.emit(&format!("pty-data-{event_id}"), &data);
}
Err(e) => {
log::error!("PTY read error for {event_id}: {e}");
let _ = app_handle.emit(&format!("pty-exit-{event_id}"), ());
break;
}
}
}
});
let instance = PtyInstance { master: pair.master, writer };
self.instances.lock().unwrap().insert(id.clone(), instance);
log::info!("Spawned PTY {id} ({shell})");
Ok(id)
}
pub fn write(&self, id: &str, data: &str) -> Result<(), String> {
let mut instances = self.instances.lock().unwrap();
let instance = instances
.get_mut(id)
.ok_or_else(|| format!("PTY {id} not found"))?;
instance
.writer
.write_all(data.as_bytes())
.map_err(|e| format!("PTY write error: {e}"))?;
instance
.writer
.flush()
.map_err(|e| format!("PTY flush error: {e}"))?;
Ok(())
}
pub fn resize(&self, id: &str, cols: u16, rows: u16) -> Result<(), String> {
let instances = self.instances.lock().unwrap();
let instance = instances
.get(id)
.ok_or_else(|| format!("PTY {id} not found"))?;
instance
.master
.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| format!("PTY resize error: {e}"))?;
Ok(())
}
pub fn kill(&self, id: &str) -> Result<(), String> {
let mut instances = self.instances.lock().unwrap();
if instances.remove(id).is_some() {
log::info!("Killed PTY {id}");
Ok(())
} else {
Err(format!("PTY {id} not found"))
}
}
}
pub use bterminal_core::pty::{PtyManager, PtyOptions};

View file

@ -1,257 +1,4 @@
// Sidecar lifecycle management (Deno-first, Node.js fallback)
// Spawns agent-runner-deno.ts (or agent-runner.mjs), communicates via stdio NDJSON
// Thin wrapper — re-exports bterminal_core::sidecar types.
// SidecarManager is now in bterminal-core; this module only re-exports for lib.rs.
use serde::{Deserialize, Serialize};
use std::io::{BufRead, BufReader, Write};
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::thread;
use tauri::{AppHandle, Emitter, Manager};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentQueryOptions {
pub session_id: String,
pub prompt: String,
pub cwd: Option<String>,
pub max_turns: Option<u32>,
pub max_budget_usd: Option<f64>,
pub resume_session_id: Option<String>,
}
struct SidecarCommand {
program: String,
args: Vec<String>,
}
pub struct SidecarManager {
child: Arc<Mutex<Option<Child>>>,
stdin_writer: Arc<Mutex<Option<Box<dyn Write + Send>>>>,
ready: Arc<Mutex<bool>>,
}
impl SidecarManager {
pub fn new() -> Self {
Self {
child: Arc::new(Mutex::new(None)),
stdin_writer: Arc::new(Mutex::new(None)),
ready: Arc::new(Mutex::new(false)),
}
}
pub fn start(&self, app: &AppHandle) -> Result<(), String> {
let mut child_lock = self.child.lock().unwrap();
if child_lock.is_some() {
return Err("Sidecar already running".to_string());
}
// Resolve sidecar command (Deno-first, Node.js fallback)
let cmd = Self::resolve_sidecar_command(app)?;
log::info!("Starting sidecar: {} {}", cmd.program, cmd.args.join(" "));
let mut child = Command::new(&cmd.program)
.args(&cmd.args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to start sidecar: {e}"))?;
let child_stdin = child.stdin.take().ok_or("Failed to capture sidecar stdin")?;
let child_stdout = child.stdout.take().ok_or("Failed to capture sidecar stdout")?;
let child_stderr = child.stderr.take().ok_or("Failed to capture sidecar stderr")?;
*self.stdin_writer.lock().unwrap() = Some(Box::new(child_stdin));
// Stdout reader thread — forwards NDJSON to Tauri events
let app_handle = app.clone();
let ready = self.ready.clone();
thread::spawn(move || {
let reader = BufReader::new(child_stdout);
for line in reader.lines() {
match line {
Ok(line) => {
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<serde_json::Value>(&line) {
Ok(msg) => {
// Check for ready signal
if msg.get("type").and_then(|t| t.as_str()) == Some("ready") {
*ready.lock().unwrap() = true;
log::info!("Sidecar ready");
}
let _ = app_handle.emit("sidecar-message", &msg);
}
Err(e) => {
log::warn!("Invalid JSON from sidecar: {e}: {line}");
}
}
}
Err(e) => {
log::error!("Sidecar stdout read error: {e}");
break;
}
}
}
log::info!("Sidecar stdout reader exited");
let _ = app_handle.emit("sidecar-exited", ());
});
// Stderr reader thread — logs only
thread::spawn(move || {
let reader = BufReader::new(child_stderr);
for line in reader.lines() {
match line {
Ok(line) => log::info!("[sidecar stderr] {line}"),
Err(e) => {
log::error!("Sidecar stderr read error: {e}");
break;
}
}
}
});
*child_lock = Some(child);
Ok(())
}
pub fn send_message(&self, msg: &serde_json::Value) -> Result<(), String> {
let mut writer_lock = self.stdin_writer.lock().unwrap();
let writer = writer_lock
.as_mut()
.ok_or("Sidecar not running")?;
let line = serde_json::to_string(msg)
.map_err(|e| format!("JSON serialize error: {e}"))?;
writer
.write_all(line.as_bytes())
.map_err(|e| format!("Sidecar write error: {e}"))?;
writer
.write_all(b"\n")
.map_err(|e| format!("Sidecar write error: {e}"))?;
writer
.flush()
.map_err(|e| format!("Sidecar flush error: {e}"))?;
Ok(())
}
pub fn query(&self, options: &AgentQueryOptions) -> Result<(), String> {
if !*self.ready.lock().unwrap() {
return Err("Sidecar not ready".to_string());
}
let msg = serde_json::json!({
"type": "query",
"sessionId": options.session_id,
"prompt": options.prompt,
"cwd": options.cwd,
"maxTurns": options.max_turns,
"maxBudgetUsd": options.max_budget_usd,
"resumeSessionId": options.resume_session_id,
});
self.send_message(&msg)
}
pub fn stop_session(&self, session_id: &str) -> Result<(), String> {
let msg = serde_json::json!({
"type": "stop",
"sessionId": session_id,
});
self.send_message(&msg)
}
pub fn restart(&self, app: &AppHandle) -> Result<(), String> {
log::info!("Restarting sidecar");
let _ = self.shutdown();
self.start(app)
}
pub fn shutdown(&self) -> Result<(), String> {
let mut child_lock = self.child.lock().unwrap();
if let Some(ref mut child) = *child_lock {
log::info!("Shutting down sidecar");
// Drop stdin to signal EOF
*self.stdin_writer.lock().unwrap() = None;
// Give it a moment, then kill
let _ = child.kill();
let _ = child.wait();
}
*child_lock = None;
*self.ready.lock().unwrap() = false;
Ok(())
}
pub fn is_ready(&self) -> bool {
*self.ready.lock().unwrap()
}
fn resolve_sidecar_command(app: &AppHandle) -> Result<SidecarCommand, String> {
let resource_dir = app
.path()
.resource_dir()
.map_err(|e| format!("Failed to get resource dir: {e}"))?;
let dev_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.to_path_buf();
// Try Deno first (runs TypeScript directly, no build step needed)
let deno_paths = [
resource_dir.join("sidecar").join("agent-runner-deno.ts"),
dev_root.join("sidecar").join("agent-runner-deno.ts"),
];
for path in &deno_paths {
if path.exists() {
// Check if deno is available
if Command::new("deno").arg("--version").output().is_ok() {
return Ok(SidecarCommand {
program: "deno".to_string(),
args: vec![
"run".to_string(),
"--allow-run".to_string(),
"--allow-env".to_string(),
"--allow-read".to_string(),
path.to_string_lossy().to_string(),
],
});
}
log::warn!("Deno sidecar found at {} but deno not in PATH, falling back to Node.js", path.display());
}
}
// Fallback to Node.js
let node_paths = [
resource_dir.join("sidecar").join("dist").join("agent-runner.mjs"),
dev_root.join("sidecar").join("dist").join("agent-runner.mjs"),
];
for path in &node_paths {
if path.exists() {
return Ok(SidecarCommand {
program: "node".to_string(),
args: vec![path.to_string_lossy().to_string()],
});
}
}
Err(format!(
"Sidecar not found. Checked Deno ({}, {}) and Node.js ({}, {})",
deno_paths[0].display(),
deno_paths[1].display(),
node_paths[0].display(),
node_paths[1].display(),
))
}
}
impl Drop for SidecarManager {
fn drop(&mut self) {
let _ = self.shutdown();
}
}
pub use bterminal_core::sidecar::{AgentQueryOptions, SidecarConfig, SidecarManager};