Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ toml = "0.8"
# Git
git2 = { version = "0.18", default-features = false, features = ["https", "vendored-libgit2", "vendored-openssl"] }

# OpenSSL — vendored so no system OpenSSL is needed (required by russh-keys on Windows)
openssl = { version = "0.10", features = ["vendored"] }

# Terminal
portable-pty = "0.8"
vte = "0.15.0"
Expand Down
3 changes: 2 additions & 1 deletion src/apps/desktop/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ serde_json = { workspace = true }

[dependencies]
# Internal crates
bitfun-core = { path = "../../crates/core" }
bitfun-core = { path = "../../crates/core", features = ["ssh-remote"] }
bitfun-transport = { path = "../../crates/transport", features = ["tauri-adapter"] }

# Tauri
Expand All @@ -42,6 +42,7 @@ similar = { workspace = true }
ignore = { workspace = true }
urlencoding = { workspace = true }
reqwest = { workspace = true }
thiserror = "1.0"

[target.'cfg(windows)'.dependencies]
win32job = { workspace = true }
12 changes: 8 additions & 4 deletions src/apps/desktop/src/api/agentic_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

use log::warn;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use tauri::{AppHandle, State};

Expand All @@ -14,6 +13,7 @@ use bitfun_core::agentic::coordination::{
use bitfun_core::agentic::core::*;
use bitfun_core::agentic::image_analysis::ImageContextData;
use bitfun_core::agentic::tools::image_context::get_image_context;
use bitfun_core::service::remote_ssh::workspace_state::get_effective_session_path;

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
Expand Down Expand Up @@ -435,8 +435,9 @@ pub async fn delete_session(
coordinator: State<'_, Arc<ConversationCoordinator>>,
request: DeleteSessionRequest,
) -> Result<(), String> {
let effective_path = get_effective_session_path(&request.workspace_path).await;
coordinator
.delete_session(&PathBuf::from(request.workspace_path), &request.session_id)
.delete_session(&effective_path, &request.session_id)
.await
.map_err(|e| format!("Failed to delete session: {}", e))
}
Expand All @@ -446,8 +447,9 @@ pub async fn restore_session(
coordinator: State<'_, Arc<ConversationCoordinator>>,
request: RestoreSessionRequest,
) -> Result<SessionResponse, String> {
let effective_path = get_effective_session_path(&request.workspace_path).await;
let session = coordinator
.restore_session(&PathBuf::from(request.workspace_path), &request.session_id)
.restore_session(&effective_path, &request.session_id)
.await
.map_err(|e| format!("Failed to restore session: {}", e))?;

Expand All @@ -459,8 +461,10 @@ pub async fn list_sessions(
coordinator: State<'_, Arc<ConversationCoordinator>>,
request: ListSessionsRequest,
) -> Result<Vec<SessionResponse>, String> {
// Map remote workspace path to local session storage path
let effective_path = get_effective_session_path(&request.workspace_path).await;
let summaries = coordinator
.list_sessions(&PathBuf::from(request.workspace_path))
.list_sessions(&effective_path)
.await
.map_err(|e| format!("Failed to list sessions: {}", e))?;

Expand Down
204 changes: 203 additions & 1 deletion src/apps/desktop/src/api/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,28 @@ use bitfun_core::agentic::{agents, tools};
use bitfun_core::infrastructure::ai::{AIClient, AIClientFactory};
use bitfun_core::miniapp::{initialize_global_miniapp_manager, JsWorkerPool, MiniAppManager};
use bitfun_core::service::{ai_rules, config, filesystem, mcp, token_usage, workspace};
use bitfun_core::service::remote_ssh::{
init_remote_workspace_manager, SSHConnectionManager, RemoteFileService, RemoteTerminalManager,
};
use bitfun_core::util::errors::*;

use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use thiserror::Error;
use tokio::sync::RwLock;

/// Errors that can occur when accessing SSH remote services
#[derive(Error, Debug)]
pub enum SSHServiceError {
#[error("SSH manager not initialized")]
ManagerNotInitialized,
#[error("Remote file service not initialized")]
FileServiceNotInitialized,
#[error("Remote terminal manager not initialized")]
TerminalManagerNotInitialized,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthStatus {
pub status: String,
Expand All @@ -28,6 +43,15 @@ pub struct AppStatistics {
pub uptime_seconds: u64,
}

/// Remote workspace information
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RemoteWorkspace {
pub connection_id: String,
pub connection_name: String,
pub remote_path: String,
}

pub struct AppState {
pub ai_client: Arc<RwLock<Option<AIClient>>>,
pub ai_client_factory: Arc<AIClientFactory>,
Expand All @@ -46,6 +70,11 @@ pub struct AppState {
pub js_worker_pool: Option<Arc<JsWorkerPool>>,
pub statistics: Arc<RwLock<AppStatistics>>,
pub start_time: std::time::Instant,
// SSH Remote connection state
pub ssh_manager: Arc<RwLock<Option<SSHConnectionManager>>>,
pub remote_file_service: Arc<RwLock<Option<RemoteFileService>>>,
pub remote_terminal_manager: Arc<RwLock<Option<RemoteTerminalManager>>>,
pub remote_workspace: Arc<RwLock<Option<RemoteWorkspace>>>,
}

impl AppState {
Expand Down Expand Up @@ -143,6 +172,74 @@ impl AppState {
}
}

// Initialize SSH Remote services synchronously so they're ready before app starts
let ssh_data_dir = dirs::data_local_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("BitFun")
.join("ssh");
let ssh_manager = Arc::new(RwLock::new(None));
let ssh_manager_clone = ssh_manager.clone();
let remote_file_service = Arc::new(RwLock::new(None));
let remote_file_service_clone = remote_file_service.clone();
let remote_terminal_manager = Arc::new(RwLock::new(None));
let remote_terminal_manager_clone = remote_terminal_manager.clone();
// Create remote_workspace before spawn so we can pass it in
let remote_workspace = Arc::new(RwLock::new(None));
let remote_workspace_clone = remote_workspace.clone();

// Initialize SSH services synchronously (not spawned) so they're ready before app starts
let manager = SSHConnectionManager::new(ssh_data_dir.clone());
if let Err(e) = manager.load_saved_connections().await {
log::error!("Failed to load saved SSH connections: {}", e);
} else {
log::info!("SSH connections loaded successfully");
}
if let Err(e) = manager.load_known_hosts().await {
log::error!("Failed to load known hosts: {}", e);
}

// Load persisted remote workspaces (may be multiple)
match manager.load_remote_workspace().await {
Ok(_) => {
let workspaces = manager.get_remote_workspaces().await;
if !workspaces.is_empty() {
log::info!("Loaded {} persisted remote workspace(s)", workspaces.len());
// Use the first one for the legacy single-workspace field
let first = &workspaces[0];
let app_workspace = RemoteWorkspace {
connection_id: first.connection_id.clone(),
remote_path: first.remote_path.clone(),
connection_name: first.connection_name.clone(),
};
*remote_workspace_clone.write().await = Some(app_workspace);
}
}
Err(e) => {
log::warn!("Failed to load remote workspace: {}", e);
}
}

let manager_arc = Arc::new(manager);
let manager_for_fs = Arc::new(tokio::sync::RwLock::new(Some(manager_arc.as_ref().clone())));
let fs = RemoteFileService::new(manager_for_fs.clone());
let tm = RemoteTerminalManager::new(manager_arc.as_ref().clone());

// Clone for storing in AppState
let fs_for_state = fs.clone();
let tm_for_state = tm.clone();

*ssh_manager_clone.write().await = Some((*manager_arc).clone());
*remote_file_service_clone.write().await = Some(fs_for_state);
*remote_terminal_manager_clone.write().await = Some(tm_for_state);

// Note: We do NOT activate the global remote workspace state here because
// there is no live SSH connection yet. The persisted workspace info is loaded
// into self.remote_workspace so the frontend can query it via remote_get_workspace_info
// and drive the reconnection flow. The global state will be activated when the
// frontend successfully reconnects and calls remote_open_workspace → set_remote_workspace.

log::info!("SSH Remote services initialized with SFTP, PTY, and known hosts support");

let app_state = Self {
ai_client,
ai_client_factory,
Expand All @@ -161,6 +258,11 @@ impl AppState {
js_worker_pool,
statistics,
start_time,
// SSH Remote connection state
ssh_manager,
remote_file_service,
remote_terminal_manager,
remote_workspace,
};

log::info!("AppState initialized successfully");
Expand Down Expand Up @@ -207,4 +309,104 @@ impl AppState {
.map(|tool| tool.name().to_string())
.collect()
}
}

// SSH Remote connection methods

/// Get SSH connection manager synchronously (must be called within async context)
pub async fn get_ssh_manager_async(&self) -> Result<SSHConnectionManager, SSHServiceError> {
self.ssh_manager.read().await.clone()
.ok_or(SSHServiceError::ManagerNotInitialized)
}

/// Get remote file service synchronously (must be called within async context)
pub async fn get_remote_file_service_async(&self) -> Result<RemoteFileService, SSHServiceError> {
self.remote_file_service.read().await.clone()
.ok_or(SSHServiceError::FileServiceNotInitialized)
}

/// Get remote terminal manager synchronously (must be called within async context)
pub async fn get_remote_terminal_manager_async(&self) -> Result<RemoteTerminalManager, SSHServiceError> {
self.remote_terminal_manager.read().await.clone()
.ok_or(SSHServiceError::TerminalManagerNotInitialized)
}

/// Set current remote workspace
pub async fn set_remote_workspace(&self, workspace: RemoteWorkspace) -> Result<(), SSHServiceError> {
// Update local state
*self.remote_workspace.write().await = Some(workspace.clone());

// Persist to SSHConnectionManager for restoration on restart
if let Ok(manager) = self.get_ssh_manager_async().await {
let core_workspace = bitfun_core::service::remote_ssh::RemoteWorkspace {
connection_id: workspace.connection_id.clone(),
remote_path: workspace.remote_path.clone(),
connection_name: workspace.connection_name.clone(),
};
if let Err(e) = manager.set_remote_workspace(core_workspace).await {
log::warn!("Failed to persist remote workspace: {}", e);
}
}

// Register in the global workspace registry
let state_manager = init_remote_workspace_manager();

// Ensure shared services are set (idempotent if already set)
let manager = self.get_ssh_manager_async().await?;
let fs = self.get_remote_file_service_async().await?;
let terminal = self.get_remote_terminal_manager_async().await?;

state_manager.set_ssh_manager(manager.clone()).await;
state_manager.set_file_service(fs.clone()).await;
state_manager.set_terminal_manager(terminal.clone()).await;

// Register this workspace (does not overwrite other workspaces)
log::info!("register_remote_workspace: connection_id={}, remote_path={}, connection_name={}",
workspace.connection_id, workspace.remote_path, workspace.connection_name);
state_manager.register_remote_workspace(
workspace.remote_path.clone(),
workspace.connection_id.clone(),
workspace.connection_name.clone(),
).await;
log::info!("Remote workspace registered: {} on {}",
workspace.remote_path, workspace.connection_name);
Ok(())
}

/// Get current remote workspace
pub async fn get_remote_workspace_async(&self) -> Option<RemoteWorkspace> {
self.remote_workspace.read().await.clone()
}

/// Clear current remote workspace
pub async fn clear_remote_workspace(&self) {
// Get the remote_path before clearing so we can unregister the specific workspace
let remote_path = {
let guard = self.remote_workspace.read().await;
guard.as_ref().map(|w| w.remote_path.clone())
};

// Clear local state
*self.remote_workspace.write().await = None;

// Remove this specific workspace from persistence (not all of them)
if let Some(path) = &remote_path {
if let Ok(manager) = self.get_ssh_manager_async().await {
if let Err(e) = manager.remove_remote_workspace(path).await {
log::warn!("Failed to remove persisted remote workspace: {}", e);
}
}

// Unregister from the global registry
if let Some(state_manager) = bitfun_core::service::remote_ssh::get_remote_workspace_manager() {
state_manager.unregister_remote_workspace(path).await;
}
}

log::info!("Remote workspace unregistered: {:?}", remote_path);
}

/// Check if currently in a remote workspace
pub async fn is_remote_workspace(&self) -> bool {
self.remote_workspace.read().await.is_some()
}
}
Loading
Loading