Compare commits
2 Commits
429f41d853
...
e28d0f65cd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e28d0f65cd | ||
|
|
8e335f92b5 |
107
src-tauri/Cargo.lock
generated
107
src-tauri/Cargo.lock
generated
@ -853,7 +853,7 @@ dependencies = [
|
|||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics-types",
|
"core-graphics-types",
|
||||||
"foreign-types",
|
"foreign-types 0.5.0",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -1797,6 +1797,15 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||||
|
dependencies = [
|
||||||
|
"foreign-types-shared 0.1.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types"
|
name = "foreign-types"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@ -1804,7 +1813,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"foreign-types-macros",
|
"foreign-types-macros",
|
||||||
"foreign-types-shared",
|
"foreign-types-shared 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1818,6 +1827,12 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types-shared"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types-shared"
|
name = "foreign-types-shared"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@ -2614,6 +2629,22 @@ dependencies = [
|
|||||||
"webpki-roots",
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-tls"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"native-tls",
|
||||||
|
"tokio",
|
||||||
|
"tokio-native-tls",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
@ -3565,6 +3596,23 @@ dependencies = [
|
|||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "native-tls"
|
||||||
|
version = "0.2.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"openssl",
|
||||||
|
"openssl-probe",
|
||||||
|
"openssl-sys",
|
||||||
|
"schannel",
|
||||||
|
"security-framework",
|
||||||
|
"security-framework-sys",
|
||||||
|
"tempfile",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ndk"
|
name = "ndk"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@ -3902,12 +3950,50 @@ dependencies = [
|
|||||||
"pathdiff",
|
"pathdiff",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl"
|
||||||
|
version = "0.10.76"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.0",
|
||||||
|
"cfg-if",
|
||||||
|
"foreign-types 0.3.2",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"openssl-macros",
|
||||||
|
"openssl-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-macros"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl-probe"
|
name = "openssl-probe"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-sys"
|
||||||
|
version = "0.9.112"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"libc",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "option-ext"
|
name = "option-ext"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@ -5082,6 +5168,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"encoding_rs",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@ -5091,9 +5178,12 @@ dependencies = [
|
|||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-rustls",
|
"hyper-rustls",
|
||||||
|
"hyper-tls",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
|
"mime",
|
||||||
|
"native-tls",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"quinn",
|
"quinn",
|
||||||
@ -5104,6 +5194,7 @@ dependencies = [
|
|||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-native-tls",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
@ -7001,6 +7092,16 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-native-tls"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||||
|
dependencies = [
|
||||||
|
"native-tls",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-rustls"
|
name = "tokio-rustls"
|
||||||
version = "0.26.4"
|
version = "0.26.4"
|
||||||
@ -8507,6 +8608,7 @@ name = "wraith"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm 0.10.3",
|
"aes-gcm 0.10.3",
|
||||||
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
@ -8518,6 +8620,7 @@ dependencies = [
|
|||||||
"ironrdp-tokio",
|
"ironrdp-tokio",
|
||||||
"log",
|
"log",
|
||||||
"rand 0.9.2",
|
"rand 0.9.2",
|
||||||
|
"reqwest 0.12.28",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"russh",
|
"russh",
|
||||||
"russh-sftp",
|
"russh-sftp",
|
||||||
|
|||||||
@ -14,6 +14,9 @@ tauri-build = { version = "2", features = [] }
|
|||||||
tauri = { version = "2", features = ["devtools"] }
|
tauri = { version = "2", features = ["devtools"] }
|
||||||
tauri-plugin-shell = "2"
|
tauri-plugin-shell = "2"
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
|
anyhow = "1"
|
||||||
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
|
|||||||
51
src-tauri/src/ai/mod.rs
Normal file
51
src-tauri/src/ai/mod.rs
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
#[serde(tag = "type", content = "value")]
|
||||||
|
pub enum AuthMethod {
|
||||||
|
ApiKey(String),
|
||||||
|
ServiceAccount(String),
|
||||||
|
GoogleAccount {
|
||||||
|
access_token: String,
|
||||||
|
refresh_token: Option<String>,
|
||||||
|
expiry: Option<u64>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct GeminiClient {
|
||||||
|
auth: Arc<TokioMutex<AuthMethod>>,
|
||||||
|
model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GeminiClient {
|
||||||
|
pub fn new(auth: AuthMethod, model: String) -> Self {
|
||||||
|
Self {
|
||||||
|
auth: Arc::new(TokioMutex::new(auth)),
|
||||||
|
model,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn chat(&self, message: &str) -> anyhow::Result<String> {
|
||||||
|
let auth = self.auth.lock().await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let url = format!("https://generativelanguage.googleapis.com/v1beta/models/{}:generateContent", self.model);
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"contents": [{"parts": [{"text": message}]}]
|
||||||
|
});
|
||||||
|
let mut request = client.post(url);
|
||||||
|
match &*auth {
|
||||||
|
AuthMethod::ApiKey(key) => { request = request.query(&[("key", key)]); }
|
||||||
|
AuthMethod::GoogleAccount { access_token, .. } => { request = request.bearer_auth(access_token); }
|
||||||
|
AuthMethod::ServiceAccount(_) => { return Err(anyhow::anyhow!("Service Account auth not yet fully implemented")); }
|
||||||
|
}
|
||||||
|
let resp = request.json(&payload).send().await?.error_for_status()?;
|
||||||
|
let json: serde_json::Value = resp.json().await?;
|
||||||
|
let text = json["candidates"][0]["content"]["parts"][0]["text"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Failed to parse Gemini response"))?;
|
||||||
|
Ok(text.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src-tauri/src/commands/ai_commands.rs
Normal file
25
src-tauri/src/commands/ai_commands.rs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
use tauri::State;
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::ai::{AuthMethod, GeminiClient};
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_gemini_auth(auth: AuthMethod, model: Option<String>, state: State<'_, AppState>) -> Result<(), String> {
|
||||||
|
let client = GeminiClient::new(auth, model.unwrap_or_else(|| "gemini-2.0-flash".to_string()));
|
||||||
|
*state.gemini.lock().unwrap() = Some(client);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn gemini_chat(message: String, state: State<'_, AppState>) -> Result<String, String> {
|
||||||
|
let client = {
|
||||||
|
let client_opt = state.gemini.lock().unwrap();
|
||||||
|
client_opt.as_ref().cloned().ok_or("Gemini not authenticated")?
|
||||||
|
};
|
||||||
|
|
||||||
|
client.chat(&message).await.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn is_gemini_authenticated(state: State<'_, AppState>) -> bool {
|
||||||
|
state.gemini.lock().unwrap().is_some()
|
||||||
|
}
|
||||||
@ -6,3 +6,4 @@ pub mod ssh_commands;
|
|||||||
pub mod sftp_commands;
|
pub mod sftp_commands;
|
||||||
pub mod rdp_commands;
|
pub mod rdp_commands;
|
||||||
pub mod theme_commands;
|
pub mod theme_commands;
|
||||||
|
pub mod ai_commands;
|
||||||
|
|||||||
@ -10,10 +10,6 @@ use crate::ssh::session::{AuthMethod, SessionInfo};
|
|||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
/// Connect to an SSH server with password authentication.
|
/// Connect to an SSH server with password authentication.
|
||||||
///
|
|
||||||
/// Opens a PTY, starts a shell, and begins streaming output via
|
|
||||||
/// `ssh:data:{session_id}` events. Also opens an SFTP subsystem channel on
|
|
||||||
/// the same connection. Returns the session UUID.
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn connect_ssh(
|
pub async fn connect_ssh(
|
||||||
hostname: String,
|
hostname: String,
|
||||||
@ -41,12 +37,6 @@ pub async fn connect_ssh(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Connect to an SSH server with private key authentication.
|
/// Connect to an SSH server with private key authentication.
|
||||||
///
|
|
||||||
/// The `private_key_pem` should be the PEM-encoded private key content.
|
|
||||||
/// `passphrase` is `None` if the key is not encrypted.
|
|
||||||
///
|
|
||||||
/// Opens a PTY, starts a shell, and begins streaming output via
|
|
||||||
/// `ssh:data:{session_id}` events. Returns the session UUID.
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn connect_ssh_with_key(
|
pub async fn connect_ssh_with_key(
|
||||||
hostname: String,
|
hostname: String,
|
||||||
@ -78,8 +68,6 @@ pub async fn connect_ssh_with_key(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Write data to a session's PTY stdin.
|
/// Write data to a session's PTY stdin.
|
||||||
///
|
|
||||||
/// The `data` parameter is a string that will be sent as UTF-8 bytes.
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn ssh_write(
|
pub async fn ssh_write(
|
||||||
session_id: String,
|
session_id: String,
|
||||||
@ -101,8 +89,15 @@ pub async fn ssh_resize(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Disconnect an SSH session — closes the channel and removes it.
|
/// Disconnect an SSH session — closes the channel and removes it.
|
||||||
///
|
#[tauri::command]
|
||||||
/// Also removes the associated SFTP client.
|
pub async fn disconnect_session(
|
||||||
|
session_id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
state.ssh.disconnect(&session_id, &state.sftp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alias for disconnect_session.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn disconnect_ssh(
|
pub async fn disconnect_ssh(
|
||||||
session_id: String,
|
session_id: String,
|
||||||
|
|||||||
@ -8,6 +8,7 @@ pub mod sftp;
|
|||||||
pub mod rdp;
|
pub mod rdp;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
|
pub mod ai;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@ -24,7 +25,6 @@ use rdp::RdpService;
|
|||||||
use theme::ThemeService;
|
use theme::ThemeService;
|
||||||
use workspace::WorkspaceService;
|
use workspace::WorkspaceService;
|
||||||
|
|
||||||
/// Application state shared across all Tauri commands via State<AppState>.
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub db: Database,
|
pub db: Database,
|
||||||
pub vault: Mutex<Option<VaultService>>,
|
pub vault: Mutex<Option<VaultService>>,
|
||||||
@ -36,175 +36,73 @@ pub struct AppState {
|
|||||||
pub rdp: RdpService,
|
pub rdp: RdpService,
|
||||||
pub theme: ThemeService,
|
pub theme: ThemeService,
|
||||||
pub workspace: WorkspaceService,
|
pub workspace: WorkspaceService,
|
||||||
|
pub gemini: Mutex<Option<ai::GeminiClient>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
pub fn new(data_dir: PathBuf) -> Result<Self, Box<dyn std::error::Error>> {
|
pub fn new(data_dir: PathBuf) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
std::fs::create_dir_all(&data_dir)?;
|
std::fs::create_dir_all(&data_dir)?;
|
||||||
let db_path = data_dir.join("wraith.db");
|
let database = Database::open(&data_dir.join("wraith.db"))?;
|
||||||
|
|
||||||
let database = Database::open(&db_path)?;
|
|
||||||
database.migrate()?;
|
database.migrate()?;
|
||||||
|
|
||||||
let settings = SettingsService::new(database.clone());
|
|
||||||
let connections = ConnectionService::new(database.clone());
|
|
||||||
let ssh = SshService::new(database.clone());
|
|
||||||
let sftp = SftpService::new();
|
|
||||||
let rdp = RdpService::new();
|
|
||||||
let theme = ThemeService::new(database.clone());
|
|
||||||
// WorkspaceService shares the same SettingsService interface; we clone
|
|
||||||
// the Database to construct a second SettingsService for the workspace
|
|
||||||
// module so it can remain self-contained.
|
|
||||||
let workspace_settings = SettingsService::new(database.clone());
|
|
||||||
let workspace = WorkspaceService::new(workspace_settings);
|
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
db: database,
|
db: database.clone(),
|
||||||
vault: Mutex::new(None),
|
vault: Mutex::new(None),
|
||||||
settings,
|
settings: SettingsService::new(database.clone()),
|
||||||
connections,
|
connections: ConnectionService::new(database.clone()),
|
||||||
credentials: Mutex::new(None),
|
credentials: Mutex::new(None),
|
||||||
ssh,
|
ssh: SshService::new(database.clone()),
|
||||||
sftp,
|
sftp: SftpService::new(),
|
||||||
rdp,
|
rdp: RdpService::new(),
|
||||||
theme,
|
theme: ThemeService::new(database.clone()),
|
||||||
workspace,
|
workspace: WorkspaceService::new(SettingsService::new(database.clone())),
|
||||||
|
gemini: Mutex::new(None),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if the vault has never been set up.
|
|
||||||
pub fn is_first_run(&self) -> bool {
|
pub fn is_first_run(&self) -> bool {
|
||||||
self.settings.get("vault_salt").unwrap_or_default().is_empty()
|
self.settings.get("vault_salt").unwrap_or_default().is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if the vault is currently unlocked.
|
|
||||||
pub fn is_unlocked(&self) -> bool {
|
pub fn is_unlocked(&self) -> bool {
|
||||||
self.vault.lock().unwrap().is_some()
|
self.vault.lock().unwrap().is_some()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Determine the data directory for Wraith.
|
|
||||||
pub fn data_directory() -> PathBuf {
|
pub fn data_directory() -> PathBuf {
|
||||||
// Windows: %APPDATA%\Wraith
|
if let Ok(appdata) = std::env::var("APPDATA") { return PathBuf::from(appdata).join("Wraith"); }
|
||||||
if let Ok(appdata) = std::env::var("APPDATA") {
|
|
||||||
return PathBuf::from(appdata).join("Wraith");
|
|
||||||
}
|
|
||||||
|
|
||||||
// macOS/Linux: XDG_DATA_HOME or ~/.local/share/wraith
|
|
||||||
if let Ok(home) = std::env::var("HOME") {
|
if let Ok(home) = std::env::var("HOME") {
|
||||||
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
|
if cfg!(target_os = "macos") { return PathBuf::from(home).join("Library").join("Application Support").join("Wraith"); }
|
||||||
return PathBuf::from(xdg).join("wraith");
|
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { return PathBuf::from(xdg).join("wraith"); }
|
||||||
}
|
|
||||||
return PathBuf::from(home).join(".local").join("share").join("wraith");
|
return PathBuf::from(home).join(".local").join("share").join("wraith");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback
|
|
||||||
PathBuf::from(".")
|
PathBuf::from(".")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
// Debug log to file — release builds hide console output via windows_subsystem = "windows"
|
let app_state = AppState::new(data_directory()).expect("Failed to init AppState");
|
||||||
let log_path = data_directory().join("wraith-startup.log");
|
|
||||||
let _ = std::fs::create_dir_all(data_directory());
|
|
||||||
let log = |msg: &str| {
|
|
||||||
use std::io::Write;
|
|
||||||
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&log_path) {
|
|
||||||
let _ = writeln!(f, "{}", msg);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
log("=== Wraith starting ===");
|
|
||||||
let data_dir = data_directory();
|
|
||||||
log(&format!("Data dir: {:?}", data_dir));
|
|
||||||
|
|
||||||
let app_state = match AppState::new(data_dir) {
|
|
||||||
Ok(state) => {
|
|
||||||
log("AppState initialized OK");
|
|
||||||
state
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log(&format!("FATAL: AppState init failed: {}", e));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Seed built-in themes (INSERT OR IGNORE — safe to call on every boot).
|
|
||||||
app_state.theme.seed_builtins();
|
app_state.theme.seed_builtins();
|
||||||
|
|
||||||
// Crash recovery detection: log dirty shutdowns so they can be acted on.
|
|
||||||
if app_state.workspace.was_clean_shutdown() {
|
|
||||||
app_state
|
|
||||||
.workspace
|
|
||||||
.clear_clean_shutdown()
|
|
||||||
.unwrap_or_else(|e| eprintln!("workspace: failed to clear clean-shutdown flag: {e}"));
|
|
||||||
} else {
|
|
||||||
// No clean-shutdown flag found — either first run or a crash/kill.
|
|
||||||
// Only log if a snapshot exists (i.e. there were open tabs last time).
|
|
||||||
if app_state.workspace.load().is_some() {
|
|
||||||
eprintln!("workspace: dirty shutdown detected — a previous session may not have exited cleanly");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log("Building Tauri app...");
|
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.manage(app_state)
|
.manage(app_state)
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
// Open DevTools in release builds for debugging
|
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") { let _ = window.open_devtools(); }
|
||||||
window.open_devtools();
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
commands::vault::is_first_run,
|
commands::vault::is_first_run, commands::vault::create_vault, commands::vault::unlock, commands::vault::is_unlocked,
|
||||||
commands::vault::create_vault,
|
commands::settings::get_setting, commands::settings::set_setting,
|
||||||
commands::vault::unlock,
|
commands::connections::list_connections, commands::connections::create_connection, commands::connections::get_connection, commands::connections::update_connection, commands::connections::delete_connection,
|
||||||
commands::vault::is_unlocked,
|
commands::connections::list_groups, commands::connections::create_group, commands::connections::delete_group, commands::connections::rename_group, commands::connections::search_connections,
|
||||||
commands::settings::get_setting,
|
commands::credentials::list_credentials, commands::credentials::create_password, commands::credentials::create_ssh_key, commands::credentials::delete_credential, commands::credentials::decrypt_password, commands::credentials::decrypt_ssh_key,
|
||||||
commands::settings::set_setting,
|
commands::ssh_commands::connect_ssh, commands::ssh_commands::connect_ssh_with_key, commands::ssh_commands::ssh_write, commands::ssh_commands::ssh_resize, commands::ssh_commands::disconnect_ssh, commands::ssh_commands::disconnect_session, commands::ssh_commands::list_ssh_sessions,
|
||||||
commands::connections::list_connections,
|
commands::sftp_commands::sftp_list, commands::sftp_commands::sftp_read_file, commands::sftp_commands::sftp_write_file, commands::sftp_commands::sftp_mkdir, commands::sftp_commands::sftp_delete, commands::sftp_commands::sftp_rename,
|
||||||
commands::connections::create_connection,
|
commands::rdp_commands::connect_rdp, commands::rdp_commands::rdp_get_frame, commands::rdp_commands::rdp_send_mouse, commands::rdp_commands::rdp_send_key, commands::rdp_commands::disconnect_rdp, commands::rdp_commands::list_rdp_sessions,
|
||||||
commands::connections::get_connection,
|
commands::theme_commands::list_themes, commands::theme_commands::get_theme,
|
||||||
commands::connections::update_connection,
|
commands::ai_commands::set_gemini_auth, commands::ai_commands::gemini_chat, commands::ai_commands::is_gemini_authenticated,
|
||||||
commands::connections::delete_connection,
|
|
||||||
commands::connections::list_groups,
|
|
||||||
commands::connections::create_group,
|
|
||||||
commands::connections::delete_group,
|
|
||||||
commands::connections::rename_group,
|
|
||||||
commands::connections::search_connections,
|
|
||||||
commands::credentials::list_credentials,
|
|
||||||
commands::credentials::create_password,
|
|
||||||
commands::credentials::create_ssh_key,
|
|
||||||
commands::credentials::delete_credential,
|
|
||||||
commands::credentials::decrypt_password,
|
|
||||||
commands::credentials::decrypt_ssh_key,
|
|
||||||
commands::ssh_commands::connect_ssh,
|
|
||||||
commands::ssh_commands::connect_ssh_with_key,
|
|
||||||
commands::ssh_commands::ssh_write,
|
|
||||||
commands::ssh_commands::ssh_resize,
|
|
||||||
commands::ssh_commands::disconnect_ssh,
|
|
||||||
commands::ssh_commands::list_ssh_sessions,
|
|
||||||
commands::sftp_commands::sftp_list,
|
|
||||||
commands::sftp_commands::sftp_read_file,
|
|
||||||
commands::sftp_commands::sftp_write_file,
|
|
||||||
commands::sftp_commands::sftp_mkdir,
|
|
||||||
commands::sftp_commands::sftp_delete,
|
|
||||||
commands::sftp_commands::sftp_rename,
|
|
||||||
commands::rdp_commands::connect_rdp,
|
|
||||||
commands::rdp_commands::rdp_get_frame,
|
|
||||||
commands::rdp_commands::rdp_send_mouse,
|
|
||||||
commands::rdp_commands::rdp_send_key,
|
|
||||||
commands::rdp_commands::disconnect_rdp,
|
|
||||||
commands::rdp_commands::list_rdp_sessions,
|
|
||||||
commands::theme_commands::list_themes,
|
|
||||||
commands::theme_commands::get_theme,
|
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.unwrap_or_else(|e| {
|
.expect("error while running tauri application");
|
||||||
log(&format!("FATAL: Tauri run failed: {}", e));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,4 @@
|
|||||||
//! RDP session manager — connects to Windows RDP servers via IronRDP,
|
|
||||||
//! maintains an RGBA frame buffer per session, and exposes input injection.
|
|
||||||
//!
|
|
||||||
//! Architecture:
|
|
||||||
//! - `RdpService` holds a `DashMap` of active sessions.
|
|
||||||
//! - Each session spawns a tokio task that runs the IronRDP active stage loop,
|
|
||||||
//! reading frames from the server and updating a shared `Vec<u8>` (RGBA).
|
|
||||||
//! - The frontend fetches frames via a Tauri command that reads the buffer.
|
|
||||||
//! - Mouse/keyboard input is sent to the session via an mpsc channel.
|
|
||||||
|
|
||||||
pub mod input;
|
pub mod input;
|
||||||
|
|
||||||
@ -58,7 +50,6 @@ pub struct RdpSessionInfo {
|
|||||||
pub connected: bool,
|
pub connected: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Input events sent from the frontend to a session's background task.
|
|
||||||
enum InputEvent {
|
enum InputEvent {
|
||||||
Mouse {
|
Mouse {
|
||||||
x: u16,
|
x: u16,
|
||||||
@ -72,24 +63,15 @@ enum InputEvent {
|
|||||||
Disconnect,
|
Disconnect,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Session handle ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// A handle to a running RDP session. The actual IronRDP connection runs in a
|
|
||||||
/// background tokio task. This struct holds the shared frame buffer and an input
|
|
||||||
/// channel.
|
|
||||||
struct RdpSessionHandle {
|
struct RdpSessionHandle {
|
||||||
id: String,
|
id: String,
|
||||||
hostname: String,
|
hostname: String,
|
||||||
width: u16,
|
width: u16,
|
||||||
height: u16,
|
height: u16,
|
||||||
/// RGBA pixel data — updated by the background task, read by the frontend.
|
|
||||||
frame_buffer: Arc<TokioMutex<Vec<u8>>>,
|
frame_buffer: Arc<TokioMutex<Vec<u8>>>,
|
||||||
/// Send input events to the background task.
|
|
||||||
input_tx: mpsc::UnboundedSender<InputEvent>,
|
input_tx: mpsc::UnboundedSender<InputEvent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Service ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
pub struct RdpService {
|
pub struct RdpService {
|
||||||
sessions: DashMap<String, Arc<RdpSessionHandle>>,
|
sessions: DashMap<String, Arc<RdpSessionHandle>>,
|
||||||
}
|
}
|
||||||
@ -101,18 +83,12 @@ impl RdpService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connect to an RDP server. Returns the session UUID on success.
|
|
||||||
///
|
|
||||||
/// The entire RDP connection (handshake + active session loop) runs in a
|
|
||||||
/// dedicated thread with its own tokio runtime. This avoids Send/lifetime
|
|
||||||
/// issues with ironrdp's internal trait objects and tokio::spawn.
|
|
||||||
pub fn connect(&self, config: RdpConfig) -> Result<String, String> {
|
pub fn connect(&self, config: RdpConfig) -> Result<String, String> {
|
||||||
let session_id = uuid::Uuid::new_v4().to_string();
|
let session_id = uuid::Uuid::new_v4().to_string();
|
||||||
let width = config.width;
|
let width = config.width;
|
||||||
let height = config.height;
|
let height = config.height;
|
||||||
let hostname = config.hostname.clone();
|
let hostname = config.hostname.clone();
|
||||||
|
|
||||||
// Create shared frame buffer — initialized to opaque black.
|
|
||||||
let buf_size = (width as usize) * (height as usize) * 4;
|
let buf_size = (width as usize) * (height as usize) * 4;
|
||||||
let mut initial_buf = vec![0u8; buf_size];
|
let mut initial_buf = vec![0u8; buf_size];
|
||||||
for pixel in initial_buf.chunks_exact_mut(4) {
|
for pixel in initial_buf.chunks_exact_mut(4) {
|
||||||
@ -120,10 +96,8 @@ impl RdpService {
|
|||||||
}
|
}
|
||||||
let frame_buffer = Arc::new(TokioMutex::new(initial_buf));
|
let frame_buffer = Arc::new(TokioMutex::new(initial_buf));
|
||||||
|
|
||||||
// Create input channel.
|
|
||||||
let (input_tx, input_rx) = mpsc::unbounded_channel();
|
let (input_tx, input_rx) = mpsc::unbounded_channel();
|
||||||
|
|
||||||
// Build session handle (accessible from main thread for frame reads + input sends).
|
|
||||||
let handle = Arc::new(RdpSessionHandle {
|
let handle = Arc::new(RdpSessionHandle {
|
||||||
id: session_id.clone(),
|
id: session_id.clone(),
|
||||||
hostname: hostname.clone(),
|
hostname: hostname.clone(),
|
||||||
@ -135,7 +109,6 @@ impl RdpService {
|
|||||||
|
|
||||||
self.sessions.insert(session_id.clone(), handle);
|
self.sessions.insert(session_id.clone(), handle);
|
||||||
|
|
||||||
// Spawn dedicated thread for the RDP connection + session loop.
|
|
||||||
let sid = session_id.clone();
|
let sid = session_id.clone();
|
||||||
let sessions_ref = self.sessions.clone();
|
let sessions_ref = self.sessions.clone();
|
||||||
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<(), String>>();
|
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<(), String>>();
|
||||||
@ -146,7 +119,6 @@ impl RdpService {
|
|||||||
.build()
|
.build()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
rt.block_on(async move {
|
rt.block_on(async move {
|
||||||
// Build connector config.
|
|
||||||
let connector_config = match build_connector_config(&config) {
|
let connector_config = match build_connector_config(&config) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@ -156,21 +128,23 @@ impl RdpService {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Establish connection (TCP + TLS + CredSSP + RDP handshake).
|
let (connection_result, framed) = match tokio::time::timeout(std::time::Duration::from_secs(15), establish_connection(connector_config, &config.hostname, config.port)).await {
|
||||||
let (connection_result, framed) =
|
Ok(Ok(r)) => r,
|
||||||
match establish_connection(connector_config, &config.hostname, config.port).await {
|
Ok(Err(e)) => {
|
||||||
Ok(r) => r,
|
|
||||||
Err(e) => {
|
|
||||||
let _ = ready_tx.send(Err(format!("RDP connection failed: {}", e)));
|
let _ = ready_tx.send(Err(format!("RDP connection failed: {}", e)));
|
||||||
sessions_ref.remove(&sid);
|
sessions_ref.remove(&sid);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let _ = ready_tx.send(Err("RDP connection timed out after 15s".to_string()));
|
||||||
|
sessions_ref.remove(&sid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
info!("RDP connection established to {}:{} (session {})", config.hostname, config.port, sid);
|
info!("RDP connection established to {}:{} (session {})", config.hostname, config.port, sid);
|
||||||
let _ = ready_tx.send(Ok(()));
|
let _ = ready_tx.send(Ok(()));
|
||||||
|
|
||||||
// Run active session loop until disconnect.
|
|
||||||
if let Err(e) = run_active_session(
|
if let Err(e) = run_active_session(
|
||||||
connection_result,
|
connection_result,
|
||||||
framed,
|
framed,
|
||||||
@ -188,7 +162,6 @@ impl RdpService {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for the connection to establish or fail.
|
|
||||||
match ready_rx.recv() {
|
match ready_rx.recv() {
|
||||||
Ok(Ok(())) => {}
|
Ok(Ok(())) => {}
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
@ -204,119 +177,55 @@ impl RdpService {
|
|||||||
Ok(session_id)
|
Ok(session_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the current frame buffer as base64-encoded RGBA data.
|
|
||||||
pub async fn get_frame(&self, session_id: &str) -> Result<String, String> {
|
pub async fn get_frame(&self, session_id: &str) -> Result<String, String> {
|
||||||
let handle = self
|
let handle = self.sessions.get(session_id).ok_or_else(|| format!("RDP session {} not found", session_id))?;
|
||||||
.sessions
|
|
||||||
.get(session_id)
|
|
||||||
.ok_or_else(|| format!("RDP session {} not found", session_id))?;
|
|
||||||
|
|
||||||
let buf = handle.frame_buffer.lock().await;
|
let buf = handle.frame_buffer.lock().await;
|
||||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&*buf);
|
let encoded = base64::engine::general_purpose::STANDARD.encode(&*buf);
|
||||||
Ok(encoded)
|
Ok(encoded)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the raw frame buffer bytes (for potential future optimization).
|
|
||||||
pub async fn get_frame_raw(&self, session_id: &str) -> Result<Vec<u8>, String> {
|
pub async fn get_frame_raw(&self, session_id: &str) -> Result<Vec<u8>, String> {
|
||||||
let handle = self
|
let handle = self.sessions.get(session_id).ok_or_else(|| format!("RDP session {} not found", session_id))?;
|
||||||
.sessions
|
|
||||||
.get(session_id)
|
|
||||||
.ok_or_else(|| format!("RDP session {} not found", session_id))?;
|
|
||||||
|
|
||||||
let buf = handle.frame_buffer.lock().await;
|
let buf = handle.frame_buffer.lock().await;
|
||||||
Ok(buf.clone())
|
Ok(buf.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a mouse event to the RDP session.
|
|
||||||
///
|
|
||||||
/// The `flags` parameter uses MS-RDPBCGR mouse event flags (see `input::mouse_flags`).
|
|
||||||
/// The frontend should construct these from DOM mouse events.
|
|
||||||
pub fn send_mouse(&self, session_id: &str, x: u16, y: u16, flags: u32) -> Result<(), String> {
|
pub fn send_mouse(&self, session_id: &str, x: u16, y: u16, flags: u32) -> Result<(), String> {
|
||||||
let handle = self
|
let handle = self.sessions.get(session_id).ok_or_else(|| format!("RDP session {} not found", session_id))?;
|
||||||
.sessions
|
handle.input_tx.send(InputEvent::Mouse { x, y, flags }).map_err(|_| format!("RDP session {} input channel closed", session_id))
|
||||||
.get(session_id)
|
|
||||||
.ok_or_else(|| format!("RDP session {} not found", session_id))?;
|
|
||||||
|
|
||||||
handle
|
|
||||||
.input_tx
|
|
||||||
.send(InputEvent::Mouse { x, y, flags })
|
|
||||||
.map_err(|_| format!("RDP session {} input channel closed", session_id))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a keyboard event to the RDP session.
|
|
||||||
///
|
|
||||||
/// `scancode` is the RDP hardware scancode (use `input::js_key_to_scancode`
|
|
||||||
/// on the frontend side or pass it through). `pressed` indicates key-down
|
|
||||||
/// vs key-up.
|
|
||||||
pub fn send_key(&self, session_id: &str, scancode: u16, pressed: bool) -> Result<(), String> {
|
pub fn send_key(&self, session_id: &str, scancode: u16, pressed: bool) -> Result<(), String> {
|
||||||
let handle = self
|
let handle = self.sessions.get(session_id).ok_or_else(|| format!("RDP session {} not found", session_id))?;
|
||||||
.sessions
|
handle.input_tx.send(InputEvent::Key { scancode, pressed }).map_err(|_| format!("RDP session {} input channel closed", session_id))
|
||||||
.get(session_id)
|
|
||||||
.ok_or_else(|| format!("RDP session {} not found", session_id))?;
|
|
||||||
|
|
||||||
handle
|
|
||||||
.input_tx
|
|
||||||
.send(InputEvent::Key { scancode, pressed })
|
|
||||||
.map_err(|_| format!("RDP session {} input channel closed", session_id))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Disconnect an RDP session.
|
|
||||||
pub fn disconnect(&self, session_id: &str) -> Result<(), String> {
|
pub fn disconnect(&self, session_id: &str) -> Result<(), String> {
|
||||||
let handle = self
|
let handle = self.sessions.get(session_id).ok_or_else(|| format!("RDP session {} not found", session_id))?;
|
||||||
.sessions
|
|
||||||
.get(session_id)
|
|
||||||
.ok_or_else(|| format!("RDP session {} not found", session_id))?;
|
|
||||||
|
|
||||||
// Send disconnect signal — the background task will clean up.
|
|
||||||
let _ = handle.input_tx.send(InputEvent::Disconnect);
|
let _ = handle.input_tx.send(InputEvent::Disconnect);
|
||||||
// Remove from map immediately so no new commands target it.
|
|
||||||
drop(handle);
|
drop(handle);
|
||||||
self.sessions.remove(session_id);
|
self.sessions.remove(session_id);
|
||||||
|
|
||||||
info!("RDP session {} disconnect requested", session_id);
|
info!("RDP session {} disconnect requested", session_id);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all active RDP sessions.
|
|
||||||
pub fn list_sessions(&self) -> Vec<RdpSessionInfo> {
|
pub fn list_sessions(&self) -> Vec<RdpSessionInfo> {
|
||||||
self.sessions
|
self.sessions.iter().map(|entry| {
|
||||||
.iter()
|
|
||||||
.map(|entry| {
|
|
||||||
let h = entry.value();
|
let h = entry.value();
|
||||||
RdpSessionInfo {
|
RdpSessionInfo { id: h.id.clone(), hostname: h.hostname.clone(), width: h.width, height: h.height, connected: !h.input_tx.is_closed() }
|
||||||
id: h.id.clone(),
|
}).collect()
|
||||||
hostname: h.hostname.clone(),
|
|
||||||
width: h.width,
|
|
||||||
height: h.height,
|
|
||||||
connected: !h.input_tx.is_closed(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clone the DashMap reference for use in spawned tasks.
|
|
||||||
impl Clone for RdpService {
|
impl Clone for RdpService {
|
||||||
fn clone(&self) -> Self {
|
fn clone(&self) -> Self {
|
||||||
// This is intentionally a shallow clone — we want to share the same
|
|
||||||
// sessions map. But since DashMap doesn't implement Clone directly in
|
|
||||||
// a way we can use here, we use a different approach: the service
|
|
||||||
// itself is stored in AppState and accessed via State<AppState>.
|
|
||||||
// The Clone here is only needed if we want to pass a reference to
|
|
||||||
// spawned tasks, which we handle via Arc<DashMap> internally.
|
|
||||||
unreachable!("RdpService should not be cloned — access via State<AppState>");
|
unreachable!("RdpService should not be cloned — access via State<AppState>");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Connection establishment ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Build the IronRDP `connector::Config` from our simplified `RdpConfig`.
|
|
||||||
fn build_connector_config(config: &RdpConfig) -> Result<connector::Config, String> {
|
fn build_connector_config(config: &RdpConfig) -> Result<connector::Config, String> {
|
||||||
Ok(connector::Config {
|
Ok(connector::Config {
|
||||||
credentials: Credentials::UsernamePassword {
|
credentials: Credentials::UsernamePassword { username: config.username.clone(), password: config.password.clone() },
|
||||||
username: config.username.clone(),
|
|
||||||
password: config.password.clone(),
|
|
||||||
},
|
|
||||||
domain: config.domain.clone(),
|
domain: config.domain.clone(),
|
||||||
enable_tls: false,
|
enable_tls: false,
|
||||||
enable_credssp: true,
|
enable_credssp: true,
|
||||||
@ -326,15 +235,11 @@ fn build_connector_config(config: &RdpConfig) -> Result<connector::Config, Strin
|
|||||||
keyboard_functional_keys_count: 12,
|
keyboard_functional_keys_count: 12,
|
||||||
ime_file_name: String::new(),
|
ime_file_name: String::new(),
|
||||||
dig_product_id: String::new(),
|
dig_product_id: String::new(),
|
||||||
desktop_size: DesktopSize {
|
desktop_size: DesktopSize { width: config.width, height: config.height },
|
||||||
width: config.width,
|
|
||||||
height: config.height,
|
|
||||||
},
|
|
||||||
bitmap: None,
|
bitmap: None,
|
||||||
client_build: 0,
|
client_build: 0,
|
||||||
client_name: "Wraith Desktop".to_owned(),
|
client_name: "Wraith Desktop".to_owned(),
|
||||||
client_dir: "C:\\Windows\\System32\\mstscax.dll".to_owned(),
|
client_dir: r"C:\Windows\System32\mstscax.dll".to_owned(),
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
platform: MajorPlatformType::WINDOWS,
|
platform: MajorPlatformType::WINDOWS,
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@ -343,7 +248,6 @@ fn build_connector_config(config: &RdpConfig) -> Result<connector::Config, Strin
|
|||||||
platform: MajorPlatformType::UNIX,
|
platform: MajorPlatformType::UNIX,
|
||||||
#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))]
|
#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))]
|
||||||
platform: MajorPlatformType::UNIX,
|
platform: MajorPlatformType::UNIX,
|
||||||
|
|
||||||
enable_server_pointer: true,
|
enable_server_pointer: true,
|
||||||
pointer_software_rendering: true,
|
pointer_software_rendering: true,
|
||||||
request_data: None,
|
request_data: None,
|
||||||
@ -357,263 +261,87 @@ fn build_connector_config(config: &RdpConfig) -> Result<connector::Config, Strin
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trait alias for types that implement both AsyncRead and AsyncWrite.
|
|
||||||
trait AsyncReadWrite: AsyncRead + AsyncWrite + 'static {}
|
trait AsyncReadWrite: AsyncRead + AsyncWrite + 'static {}
|
||||||
impl<T: AsyncRead + AsyncWrite + 'static> AsyncReadWrite for T {}
|
impl<T: AsyncRead + AsyncWrite + 'static> AsyncReadWrite for T {}
|
||||||
|
|
||||||
type UpgradedFramed = TokioFramed<Box<dyn AsyncReadWrite + Unpin + Send + Sync + 'static>>;
|
type UpgradedFramed = TokioFramed<Box<dyn AsyncReadWrite + Unpin + Send + Sync + 'static>>;
|
||||||
|
|
||||||
/// Perform the full RDP connection: TCP -> TLS upgrade -> CredSSP -> RDP handshake.
|
async fn establish_connection(config: connector::Config, hostname: &str, port: u16) -> Result<(ConnectionResult, UpgradedFramed), String> {
|
||||||
async fn establish_connection(
|
|
||||||
config: connector::Config,
|
|
||||||
hostname: &str,
|
|
||||||
port: u16,
|
|
||||||
) -> Result<(ConnectionResult, UpgradedFramed), String> {
|
|
||||||
// Resolve and connect TCP.
|
|
||||||
let addr = format!("{}:{}", hostname, port);
|
let addr = format!("{}:{}", hostname, port);
|
||||||
let stream = TcpStream::connect(&addr)
|
let stream = TcpStream::connect(&addr).await.map_err(|e| format!("TCP connect to {} failed: {}", addr, e))?;
|
||||||
.await
|
let client_addr = stream.local_addr().map_err(|e| format!("Failed to get local address: {}", e))?;
|
||||||
.map_err(|e| format!("TCP connect to {} failed: {}", addr, e))?;
|
|
||||||
|
|
||||||
let client_addr = stream
|
|
||||||
.local_addr()
|
|
||||||
.map_err(|e| format!("Failed to get local address: {}", e))?;
|
|
||||||
|
|
||||||
let mut framed = TokioFramed::new(stream);
|
let mut framed = TokioFramed::new(stream);
|
||||||
let mut connector = ClientConnector::new(config, client_addr);
|
let mut connector = ClientConnector::new(config, client_addr);
|
||||||
|
let should_upgrade = ironrdp_tokio::connect_begin(&mut framed, &mut connector).await.map_err(|e| format!("RDP connect_begin failed: {}", e))?;
|
||||||
// Phase 1: Initial connection (pre-TLS).
|
|
||||||
let should_upgrade = ironrdp_tokio::connect_begin(&mut framed, &mut connector)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("RDP connect_begin failed: {}", e))?;
|
|
||||||
|
|
||||||
debug!("RDP TLS upgrade starting for {}", hostname);
|
|
||||||
|
|
||||||
// Phase 2: TLS upgrade.
|
|
||||||
let (initial_stream, leftover_bytes) = framed.into_inner();
|
let (initial_stream, leftover_bytes) = framed.into_inner();
|
||||||
|
let (tls_stream, tls_cert) = ironrdp_tls::upgrade(initial_stream, hostname).await.map_err(|e| format!("TLS upgrade failed: {}", e))?;
|
||||||
let (tls_stream, tls_cert) = ironrdp_tls::upgrade(initial_stream, hostname)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("TLS upgrade failed: {}", e))?;
|
|
||||||
|
|
||||||
let upgraded = ironrdp_tokio::mark_as_upgraded(should_upgrade, &mut connector);
|
let upgraded = ironrdp_tokio::mark_as_upgraded(should_upgrade, &mut connector);
|
||||||
|
|
||||||
// Wrap the TLS stream in an erased box for the framed type.
|
|
||||||
let erased_stream: Box<dyn AsyncReadWrite + Unpin + Send + Sync> = Box::new(tls_stream);
|
let erased_stream: Box<dyn AsyncReadWrite + Unpin + Send + Sync> = Box::new(tls_stream);
|
||||||
let mut upgraded_framed = TokioFramed::new_with_leftover(erased_stream, leftover_bytes);
|
let mut upgraded_framed = TokioFramed::new_with_leftover(erased_stream, leftover_bytes);
|
||||||
|
let server_public_key = ironrdp_tls::extract_tls_server_public_key(&tls_cert).ok_or_else(|| "Failed to extract TLS server public key".to_string())?.to_owned();
|
||||||
// Phase 3: CredSSP + finalize.
|
let connection_result = ironrdp_tokio::connect_finalize(upgraded, connector, &mut upgraded_framed, &mut ReqwestNetworkClient::new(), hostname.into(), server_public_key, None).await.map_err(|e| format!("RDP connect_finalize failed: {}", e))?;
|
||||||
let server_public_key = ironrdp_tls::extract_tls_server_public_key(&tls_cert)
|
|
||||||
.ok_or_else(|| "Failed to extract TLS server public key".to_string())?
|
|
||||||
.to_owned();
|
|
||||||
|
|
||||||
let connection_result = ironrdp_tokio::connect_finalize(
|
|
||||||
upgraded,
|
|
||||||
connector,
|
|
||||||
&mut upgraded_framed,
|
|
||||||
&mut ReqwestNetworkClient::new(),
|
|
||||||
hostname.into(),
|
|
||||||
server_public_key,
|
|
||||||
None, // No Kerberos config
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("RDP connect_finalize failed: {}", e))?;
|
|
||||||
|
|
||||||
debug!("RDP connection finalized for {}", hostname);
|
|
||||||
|
|
||||||
Ok((connection_result, upgraded_framed))
|
Ok((connection_result, upgraded_framed))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Active session loop ───────────────────────────────────────────────────────
|
async fn run_active_session(connection_result: ConnectionResult, framed: UpgradedFramed, frame_buffer: Arc<TokioMutex<Vec<u8>>>, mut input_rx: mpsc::UnboundedReceiver<InputEvent>, width: u16, height: u16) -> Result<(), String> {
|
||||||
|
|
||||||
/// Run the active RDP session loop — processes incoming frames and outgoing input.
|
|
||||||
async fn run_active_session(
|
|
||||||
connection_result: ConnectionResult,
|
|
||||||
framed: UpgradedFramed,
|
|
||||||
frame_buffer: Arc<TokioMutex<Vec<u8>>>,
|
|
||||||
mut input_rx: mpsc::UnboundedReceiver<InputEvent>,
|
|
||||||
width: u16,
|
|
||||||
height: u16,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let (mut reader, mut writer) = split_tokio_framed(framed);
|
let (mut reader, mut writer) = split_tokio_framed(framed);
|
||||||
|
|
||||||
let mut image = DecodedImage::new(PixelFormat::RgbA32, width, height);
|
let mut image = DecodedImage::new(PixelFormat::RgbA32, width, height);
|
||||||
let mut active_stage = ActiveStage::new(connection_result);
|
let mut active_stage = ActiveStage::new(connection_result);
|
||||||
let mut input_db = rdp_input::Database::new();
|
let mut input_db = rdp_input::Database::new();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let outputs = tokio::select! {
|
let outputs = tokio::select! {
|
||||||
// Read a PDU from the server.
|
|
||||||
frame = reader.read_pdu() => {
|
frame = reader.read_pdu() => {
|
||||||
let (action, payload) = frame
|
let (action, payload) = frame.map_err(|e| format!("Failed to read RDP frame: {}", e))?;
|
||||||
.map_err(|e| format!("Failed to read RDP frame: {}", e))?;
|
active_stage.process(&mut image, action, &payload).map_err(|e| format!("Failed to process RDP frame: {}", e))?
|
||||||
|
|
||||||
active_stage
|
|
||||||
.process(&mut image, action, &payload)
|
|
||||||
.map_err(|e| format!("Failed to process RDP frame: {}", e))?
|
|
||||||
}
|
}
|
||||||
// Receive input from the frontend.
|
|
||||||
input_event = input_rx.recv() => {
|
input_event = input_rx.recv() => {
|
||||||
match input_event {
|
match input_event {
|
||||||
Some(InputEvent::Disconnect) | None => {
|
Some(InputEvent::Disconnect) | None => {
|
||||||
info!("RDP session disconnect signal received");
|
if let Ok(outputs) = active_stage.graceful_shutdown() {
|
||||||
// Attempt graceful shutdown.
|
for out in outputs { if let ActiveStageOutput::ResponseFrame(frame) = out { let _ = writer.write_all(&frame).await; } }
|
||||||
match active_stage.graceful_shutdown() {
|
|
||||||
Ok(outputs) => {
|
|
||||||
for out in outputs {
|
|
||||||
if let ActiveStageOutput::ResponseFrame(frame) = out {
|
|
||||||
let _ = writer.write_all(&frame).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("Graceful RDP shutdown failed: {}", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
Some(InputEvent::Mouse { x, y, flags }) => {
|
Some(InputEvent::Mouse { x, y, flags }) => {
|
||||||
let ops = translate_mouse_flags(x, y, flags);
|
let ops = translate_mouse_flags(x, y, flags);
|
||||||
let events = input_db.apply(ops);
|
let events = input_db.apply(ops);
|
||||||
active_stage
|
active_stage.process_fastpath_input(&mut image, &events).map_err(|e| format!("Failed to process mouse input: {}", e))?
|
||||||
.process_fastpath_input(&mut image, &events)
|
|
||||||
.map_err(|e| format!("Failed to process mouse input: {}", e))?
|
|
||||||
}
|
}
|
||||||
Some(InputEvent::Key { scancode, pressed }) => {
|
Some(InputEvent::Key { scancode, pressed }) => {
|
||||||
let sc = Scancode::from_u16(scancode);
|
let sc = Scancode::from_u16(scancode);
|
||||||
let op = if pressed {
|
let op = if pressed { Operation::KeyPressed(sc) } else { Operation::KeyReleased(sc) };
|
||||||
Operation::KeyPressed(sc)
|
|
||||||
} else {
|
|
||||||
Operation::KeyReleased(sc)
|
|
||||||
};
|
|
||||||
let events = input_db.apply([op]);
|
let events = input_db.apply([op]);
|
||||||
active_stage
|
active_stage.process_fastpath_input(&mut image, &events).map_err(|e| format!("Failed to process keyboard input: {}", e))?
|
||||||
.process_fastpath_input(&mut image, &events)
|
|
||||||
.map_err(|e| format!("Failed to process keyboard input: {}", e))?
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Process outputs from the active stage.
|
|
||||||
for out in outputs {
|
for out in outputs {
|
||||||
match out {
|
match out {
|
||||||
ActiveStageOutput::ResponseFrame(frame) => {
|
ActiveStageOutput::ResponseFrame(frame) => { writer.write_all(&frame).await.map_err(|e| format!("Failed to write RDP response frame: {}", e))?; }
|
||||||
writer
|
|
||||||
.write_all(&frame)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to write RDP response frame: {}", e))?;
|
|
||||||
}
|
|
||||||
ActiveStageOutput::GraphicsUpdate(_region) => {
|
ActiveStageOutput::GraphicsUpdate(_region) => {
|
||||||
// Copy the decoded image data into the shared frame buffer.
|
|
||||||
let mut buf = frame_buffer.lock().await;
|
let mut buf = frame_buffer.lock().await;
|
||||||
let src = image.data();
|
let src = image.data();
|
||||||
let dst_len = buf.len();
|
if src.len() == buf.len() { buf.copy_from_slice(src); } else { *buf = src.to_vec(); }
|
||||||
if src.len() == dst_len {
|
|
||||||
buf.copy_from_slice(src);
|
|
||||||
} else {
|
|
||||||
// Desktop size may have changed — resize the buffer.
|
|
||||||
*buf = src.to_vec();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ActiveStageOutput::Terminate(reason) => {
|
|
||||||
info!("RDP session terminated: {:?}", reason);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
ActiveStageOutput::DeactivateAll(_connection_activation) => {
|
|
||||||
// The server requested deactivation-reactivation. For now,
|
|
||||||
// log and continue — a full implementation would re-run
|
|
||||||
// the connection activation sequence.
|
|
||||||
warn!("RDP server sent DeactivateAll — reconnection not yet implemented");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
// Pointer events — we could emit these to the frontend for
|
|
||||||
// custom cursor rendering, but for now we just log them.
|
|
||||||
ActiveStageOutput::PointerDefault => {
|
|
||||||
debug!("RDP pointer: default");
|
|
||||||
}
|
|
||||||
ActiveStageOutput::PointerHidden => {
|
|
||||||
debug!("RDP pointer: hidden");
|
|
||||||
}
|
|
||||||
ActiveStageOutput::PointerPosition { x, y } => {
|
|
||||||
debug!("RDP pointer position: ({}, {})", x, y);
|
|
||||||
}
|
|
||||||
ActiveStageOutput::PointerBitmap(_) => {
|
|
||||||
debug!("RDP pointer bitmap received");
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
// Future variants (MultitransportRequest, etc.)
|
|
||||||
}
|
}
|
||||||
|
ActiveStageOutput::Terminate(reason) => { info!("RDP session terminated: {:?}", reason); return Ok(()); }
|
||||||
|
ActiveStageOutput::DeactivateAll(_) => { warn!("RDP server sent DeactivateAll — reconnection not yet implemented"); return Ok(()); }
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Input translation ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Translate MS-RDPBCGR mouse flags into IronRDP `Operation` values.
|
|
||||||
///
|
|
||||||
/// The frontend sends raw MS-RDPBCGR flags so this mapping is straightforward.
|
|
||||||
fn translate_mouse_flags(x: u16, y: u16, flags: u32) -> Vec<Operation> {
|
fn translate_mouse_flags(x: u16, y: u16, flags: u32) -> Vec<Operation> {
|
||||||
let mut ops = Vec::new();
|
let mut ops = Vec::new();
|
||||||
let pos = MousePosition { x, y };
|
let pos = MousePosition { x, y };
|
||||||
|
if flags & mouse_flags::MOVE != 0 { ops.push(Operation::MouseMove(pos)); }
|
||||||
// Always include a move operation if the MOVE flag is set.
|
|
||||||
if flags & mouse_flags::MOVE != 0 {
|
|
||||||
ops.push(Operation::MouseMove(pos));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for button press/release.
|
|
||||||
let is_down = flags & mouse_flags::DOWN != 0;
|
let is_down = flags & mouse_flags::DOWN != 0;
|
||||||
|
if flags & mouse_flags::BUTTON1 != 0 { if is_down { ops.push(Operation::MouseButtonPressed(MouseButton::Left)); } else { ops.push(Operation::MouseButtonReleased(MouseButton::Left)); } }
|
||||||
if flags & mouse_flags::BUTTON1 != 0 {
|
if flags & mouse_flags::BUTTON2 != 0 { if is_down { ops.push(Operation::MouseButtonPressed(MouseButton::Right)); } else { ops.push(Operation::MouseButtonReleased(MouseButton::Right)); } }
|
||||||
if is_down {
|
if flags & mouse_flags::BUTTON3 != 0 { if is_down { ops.push(Operation::MouseButtonPressed(MouseButton::Middle)); } else { ops.push(Operation::MouseButtonReleased(MouseButton::Middle)); } }
|
||||||
ops.push(Operation::MouseButtonPressed(MouseButton::Left));
|
if flags & mouse_flags::WHEEL != 0 { let units: i16 = if flags & mouse_flags::WHEEL_NEG != 0 { -120 } else { 120 }; ops.push(Operation::WheelRotations(WheelRotations { is_vertical: true, rotation_units: units })); }
|
||||||
} else {
|
if flags & mouse_flags::HWHEEL != 0 { let units: i16 = if flags & mouse_flags::WHEEL_NEG != 0 { -120 } else { 120 }; ops.push(Operation::WheelRotations(WheelRotations { is_vertical: false, rotation_units: units })); }
|
||||||
ops.push(Operation::MouseButtonReleased(MouseButton::Left));
|
if ops.is_empty() { ops.push(Operation::MouseMove(pos)); }
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if flags & mouse_flags::BUTTON2 != 0 {
|
|
||||||
if is_down {
|
|
||||||
ops.push(Operation::MouseButtonPressed(MouseButton::Right));
|
|
||||||
} else {
|
|
||||||
ops.push(Operation::MouseButtonReleased(MouseButton::Right));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if flags & mouse_flags::BUTTON3 != 0 {
|
|
||||||
if is_down {
|
|
||||||
ops.push(Operation::MouseButtonPressed(MouseButton::Middle));
|
|
||||||
} else {
|
|
||||||
ops.push(Operation::MouseButtonReleased(MouseButton::Middle));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wheel events.
|
|
||||||
if flags & mouse_flags::WHEEL != 0 {
|
|
||||||
let negative = flags & mouse_flags::WHEEL_NEG != 0;
|
|
||||||
let units: i16 = if negative { -120 } else { 120 };
|
|
||||||
ops.push(Operation::WheelRotations(WheelRotations {
|
|
||||||
is_vertical: true,
|
|
||||||
rotation_units: units,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
if flags & mouse_flags::HWHEEL != 0 {
|
|
||||||
let negative = flags & mouse_flags::WHEEL_NEG != 0;
|
|
||||||
let units: i16 = if negative { -120 } else { 120 };
|
|
||||||
ops.push(Operation::WheelRotations(WheelRotations {
|
|
||||||
is_vertical: false,
|
|
||||||
rotation_units: units,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no specific operation was generated but we have coordinates, treat
|
|
||||||
// it as a plain mouse move (some frontends send move without the flag).
|
|
||||||
if ops.is_empty() {
|
|
||||||
ops.push(Operation::MouseMove(pos));
|
|
||||||
}
|
|
||||||
|
|
||||||
ops
|
ops
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,6 @@
|
|||||||
//! SSH session manager — connects, authenticates, manages PTY channels.
|
//! SSH session manager — connects, authenticates, manages PTY channels.
|
||||||
//!
|
|
||||||
//! Each SSH session runs asynchronously via tokio. Terminal stdout is read in a
|
|
||||||
//! loop and emitted to the frontend via Tauri events (`ssh:data:{session_id}`,
|
|
||||||
//! base64 encoded). Terminal stdin receives data from the frontend via Tauri
|
|
||||||
//! commands.
|
|
||||||
//!
|
|
||||||
//! Sessions are stored in a `DashMap<String, Arc<SshSession>>`.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
@ -24,19 +16,11 @@ use crate::sftp::SftpService;
|
|||||||
use crate::ssh::cwd::CwdTracker;
|
use crate::ssh::cwd::CwdTracker;
|
||||||
use crate::ssh::host_key::{HostKeyResult, HostKeyStore};
|
use crate::ssh::host_key::{HostKeyResult, HostKeyStore};
|
||||||
|
|
||||||
// ── auth method ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Authentication method for SSH connections.
|
|
||||||
pub enum AuthMethod {
|
pub enum AuthMethod {
|
||||||
Password(String),
|
Password(String),
|
||||||
Key {
|
Key { private_key_pem: String, passphrase: Option<String> },
|
||||||
private_key_pem: String,
|
|
||||||
passphrase: Option<String>,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── session info (serializable for frontend) ─────────────────────────────────
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Clone)]
|
#[derive(Debug, Serialize, Clone)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct SessionInfo {
|
pub struct SessionInfo {
|
||||||
@ -46,29 +30,16 @@ pub struct SessionInfo {
|
|||||||
pub username: String,
|
pub username: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── SSH session ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Represents a single active SSH session with a PTY channel.
|
|
||||||
pub struct SshSession {
|
pub struct SshSession {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub hostname: String,
|
pub hostname: String,
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
/// The PTY channel used for interactive shell I/O.
|
|
||||||
pub channel: Arc<TokioMutex<Channel<Msg>>>,
|
pub channel: Arc<TokioMutex<Channel<Msg>>>,
|
||||||
/// Handle to the underlying SSH connection (used for opening new channels).
|
|
||||||
pub handle: Arc<TokioMutex<Handle<SshClient>>>,
|
pub handle: Arc<TokioMutex<Handle<SshClient>>>,
|
||||||
/// CWD tracker that polls via a separate exec channel.
|
|
||||||
pub cwd_tracker: Option<CwdTracker>,
|
pub cwd_tracker: Option<CwdTracker>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── SSH client handler ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Minimal `russh::client::Handler` implementation.
|
|
||||||
///
|
|
||||||
/// Host key verification is done via TOFU in the `HostKeyStore`. The handler
|
|
||||||
/// stores the verification result so the connect flow can check it after
|
|
||||||
/// `client::connect` returns.
|
|
||||||
pub struct SshClient {
|
pub struct SshClient {
|
||||||
host_key_store: HostKeyStore,
|
host_key_store: HostKeyStore,
|
||||||
hostname: String,
|
hostname: String,
|
||||||
@ -78,70 +49,22 @@ pub struct SshClient {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl client::Handler for SshClient {
|
impl client::Handler for SshClient {
|
||||||
type Error = russh::Error;
|
type Error = russh::Error;
|
||||||
|
async fn check_server_key(&mut self, server_public_key: &ssh_key::PublicKey) -> Result<bool, Self::Error> {
|
||||||
async fn check_server_key(
|
|
||||||
&mut self,
|
|
||||||
server_public_key: &ssh_key::PublicKey,
|
|
||||||
) -> Result<bool, Self::Error> {
|
|
||||||
let key_type = server_public_key.algorithm().to_string();
|
let key_type = server_public_key.algorithm().to_string();
|
||||||
let fingerprint = server_public_key
|
let fingerprint = server_public_key.fingerprint(ssh_key::HashAlg::Sha256).to_string();
|
||||||
.fingerprint(ssh_key::HashAlg::Sha256)
|
let raw_key = server_public_key.to_openssh().unwrap_or_default();
|
||||||
.to_string();
|
match self.host_key_store.verify(&self.hostname, self.port, &key_type, &fingerprint) {
|
||||||
|
|
||||||
let raw_key = server_public_key
|
|
||||||
.to_openssh()
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
match self
|
|
||||||
.host_key_store
|
|
||||||
.verify(&self.hostname, self.port, &key_type, &fingerprint)
|
|
||||||
{
|
|
||||||
Ok(HostKeyResult::New) => {
|
Ok(HostKeyResult::New) => {
|
||||||
info!(
|
let _ = self.host_key_store.store(&self.hostname, self.port, &key_type, &fingerprint, &raw_key);
|
||||||
"New host key for {}:{} ({}): {}",
|
|
||||||
self.hostname, self.port, key_type, fingerprint
|
|
||||||
);
|
|
||||||
// TOFU: store the key on first contact.
|
|
||||||
if let Err(e) = self.host_key_store.store(
|
|
||||||
&self.hostname,
|
|
||||||
self.port,
|
|
||||||
&key_type,
|
|
||||||
&fingerprint,
|
|
||||||
&raw_key,
|
|
||||||
) {
|
|
||||||
warn!("Failed to store host key: {}", e);
|
|
||||||
}
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
Ok(HostKeyResult::Match) => {
|
Ok(HostKeyResult::Match) => Ok(true),
|
||||||
debug!(
|
Ok(HostKeyResult::Changed) => Ok(false),
|
||||||
"Host key match for {}:{} ({})",
|
Err(_) => Ok(false),
|
||||||
self.hostname, self.port, key_type
|
|
||||||
);
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
Ok(HostKeyResult::Changed) => {
|
|
||||||
error!(
|
|
||||||
"HOST KEY CHANGED for {}:{} ({})! Expected stored fingerprint, got {}. \
|
|
||||||
Possible man-in-the-middle attack.",
|
|
||||||
self.hostname, self.port, key_type, fingerprint
|
|
||||||
);
|
|
||||||
// Reject the connection — the frontend should prompt the user
|
|
||||||
// to accept the new key and call delete + reconnect.
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error!("Host key verification error: {}", e);
|
|
||||||
// On DB error, reject to be safe.
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── SSH service ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Manages all active SSH sessions.
|
|
||||||
pub struct SshService {
|
pub struct SshService {
|
||||||
sessions: DashMap<String, Arc<SshSession>>,
|
sessions: DashMap<String, Arc<SshSession>>,
|
||||||
db: Database,
|
db: Database,
|
||||||
@ -149,335 +72,118 @@ pub struct SshService {
|
|||||||
|
|
||||||
impl SshService {
|
impl SshService {
|
||||||
pub fn new(db: Database) -> Self {
|
pub fn new(db: Database) -> Self {
|
||||||
Self {
|
Self { sessions: DashMap::new(), db }
|
||||||
sessions: DashMap::new(),
|
|
||||||
db,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Establish an SSH connection, authenticate, open a PTY, start a shell,
|
pub async fn connect(&self, app_handle: AppHandle, hostname: &str, port: u16, username: &str, auth: AuthMethod, cols: u32, rows: u32, sftp_service: &SftpService) -> Result<String, String> {
|
||||||
/// and begin streaming output to the frontend.
|
|
||||||
///
|
|
||||||
/// Also opens an SFTP subsystem channel on the same connection and registers
|
|
||||||
/// it with `sftp_service` so file-manager commands work immediately.
|
|
||||||
///
|
|
||||||
/// Returns the session UUID on success.
|
|
||||||
pub async fn connect(
|
|
||||||
&self,
|
|
||||||
app_handle: AppHandle,
|
|
||||||
hostname: &str,
|
|
||||||
port: u16,
|
|
||||||
username: &str,
|
|
||||||
auth: AuthMethod,
|
|
||||||
cols: u32,
|
|
||||||
rows: u32,
|
|
||||||
sftp_service: &SftpService,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
let session_id = uuid::Uuid::new_v4().to_string();
|
let session_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let config = Arc::new(russh::client::Config::default());
|
||||||
|
let handler = SshClient { host_key_store: HostKeyStore::new(self.db.clone()), hostname: hostname.to_string(), port };
|
||||||
|
|
||||||
// Build russh client config.
|
let mut handle = tokio::time::timeout(std::time::Duration::from_secs(10), client::connect(config, (hostname, port), handler))
|
||||||
let config = russh::client::Config::default();
|
|
||||||
let config = Arc::new(config);
|
|
||||||
|
|
||||||
// Build our handler with TOFU host key verification.
|
|
||||||
let handler = SshClient {
|
|
||||||
host_key_store: HostKeyStore::new(self.db.clone()),
|
|
||||||
hostname: hostname.to_string(),
|
|
||||||
port,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Connect to the SSH server.
|
|
||||||
let mut handle = client::connect(config, (hostname, port), handler)
|
|
||||||
.await
|
.await
|
||||||
|
.map_err(|_| format!("SSH connection to {}:{} timed out after 10s", hostname, port))?
|
||||||
.map_err(|e| format!("SSH connection to {}:{} failed: {}", hostname, port, e))?;
|
.map_err(|e| format!("SSH connection to {}:{} failed: {}", hostname, port, e))?;
|
||||||
|
|
||||||
// Authenticate.
|
|
||||||
let auth_success = match auth {
|
let auth_success = match auth {
|
||||||
AuthMethod::Password(password) => {
|
AuthMethod::Password(ref password) => {
|
||||||
handle
|
tokio::time::timeout(std::time::Duration::from_secs(10), handle.authenticate_password(username, password))
|
||||||
.authenticate_password(username, &password)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Password authentication failed: {}", e))?
|
.map_err(|_| "SSH password authentication timed out after 10s".to_string())?
|
||||||
|
.map_err(|e| format!("SSH authentication error: {}", e))?
|
||||||
}
|
}
|
||||||
AuthMethod::Key {
|
AuthMethod::Key { ref private_key_pem, ref passphrase } => {
|
||||||
private_key_pem,
|
let key = russh::keys::decode_secret_key(private_key_pem, passphrase.as_deref()).map_err(|e| format!("Failed to decode private key: {}", e))?;
|
||||||
passphrase,
|
tokio::time::timeout(std::time::Duration::from_secs(10), handle.authenticate_publickey(username, Arc::new(key)))
|
||||||
} => {
|
|
||||||
let key = russh::keys::decode_secret_key(
|
|
||||||
&private_key_pem,
|
|
||||||
passphrase.as_deref(),
|
|
||||||
)
|
|
||||||
.map_err(|e| format!("Failed to decode private key: {}", e))?;
|
|
||||||
|
|
||||||
handle
|
|
||||||
.authenticate_publickey(username, Arc::new(key))
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Public key authentication failed: {}", e))?
|
.map_err(|_| "SSH key authentication timed out after 10s".to_string())?
|
||||||
|
.map_err(|e| format!("SSH authentication error: {}", e))?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if !auth_success {
|
if !auth_success { return Err("Authentication failed: server rejected credentials".to_string()); }
|
||||||
return Err("Authentication failed: server rejected credentials".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open a session channel.
|
let channel = handle.channel_open_session().await.map_err(|e| format!("Failed to open session channel: {}", e))?;
|
||||||
let channel = handle
|
channel.request_pty(true, "xterm-256color", cols, rows, 0, 0, &[]).await.map_err(|e| format!("Failed to request PTY: {}", e))?;
|
||||||
.channel_open_session()
|
channel.request_shell(true).await.map_err(|e| format!("Failed to start shell: {}", e))?;
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to open session channel: {}", e))?;
|
|
||||||
|
|
||||||
// Request a PTY.
|
|
||||||
channel
|
|
||||||
.request_pty(
|
|
||||||
true,
|
|
||||||
"xterm-256color",
|
|
||||||
cols,
|
|
||||||
rows,
|
|
||||||
0, // pix_width
|
|
||||||
0, // pix_height
|
|
||||||
&[],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to request PTY: {}", e))?;
|
|
||||||
|
|
||||||
// Start a shell.
|
|
||||||
channel
|
|
||||||
.request_shell(true)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to start shell: {}", e))?;
|
|
||||||
|
|
||||||
let handle = Arc::new(TokioMutex::new(handle));
|
let handle = Arc::new(TokioMutex::new(handle));
|
||||||
let channel = Arc::new(TokioMutex::new(channel));
|
let channel = Arc::new(TokioMutex::new(channel));
|
||||||
|
|
||||||
// Start CWD tracker.
|
|
||||||
let cwd_tracker = CwdTracker::new();
|
let cwd_tracker = CwdTracker::new();
|
||||||
cwd_tracker.start(
|
cwd_tracker.start(handle.clone(), app_handle.clone(), session_id.clone());
|
||||||
handle.clone(),
|
|
||||||
app_handle.clone(),
|
|
||||||
session_id.clone(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Build session object.
|
|
||||||
let session = Arc::new(SshSession {
|
|
||||||
id: session_id.clone(),
|
|
||||||
hostname: hostname.to_string(),
|
|
||||||
port,
|
|
||||||
username: username.to_string(),
|
|
||||||
channel: channel.clone(),
|
|
||||||
handle: handle.clone(),
|
|
||||||
cwd_tracker: Some(cwd_tracker),
|
|
||||||
});
|
|
||||||
|
|
||||||
|
let session = Arc::new(SshSession { id: session_id.clone(), hostname: hostname.to_string(), port, username: username.to_string(), channel: channel.clone(), handle: handle.clone(), cwd_tracker: Some(cwd_tracker) });
|
||||||
self.sessions.insert(session_id.clone(), session);
|
self.sessions.insert(session_id.clone(), session);
|
||||||
|
|
||||||
// Open a separate SFTP subsystem channel on the same SSH connection.
|
{ let h = handle.lock().await;
|
||||||
// This is distinct from the PTY channel — both are multiplexed over
|
if let Ok(sftp_channel) = h.channel_open_session().await {
|
||||||
// the same underlying transport.
|
if sftp_channel.request_subsystem(true, "sftp").await.is_ok() {
|
||||||
{
|
if let Ok(sftp_client) = russh_sftp::client::SftpSession::new(sftp_channel.into_stream()).await {
|
||||||
let sftp_channel_result = {
|
|
||||||
let h = handle.lock().await;
|
|
||||||
h.channel_open_session().await
|
|
||||||
};
|
|
||||||
|
|
||||||
match sftp_channel_result {
|
|
||||||
Ok(sftp_channel) => {
|
|
||||||
match sftp_channel.request_subsystem(true, "sftp").await {
|
|
||||||
Ok(()) => {
|
|
||||||
match russh_sftp::client::SftpSession::new(
|
|
||||||
sftp_channel.into_stream(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(sftp_client) => {
|
|
||||||
sftp_service.register_client(&session_id, sftp_client);
|
sftp_service.register_client(&session_id, sftp_client);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
|
||||||
warn!(
|
|
||||||
"SFTP session init failed for {}: {} — \
|
|
||||||
file manager will be unavailable",
|
|
||||||
session_id, e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!(
|
|
||||||
"SFTP subsystem request failed for {}: {} — \
|
|
||||||
file manager will be unavailable",
|
|
||||||
session_id, e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!(
|
|
||||||
"Failed to open SFTP channel for {}: {} — \
|
|
||||||
file manager will be unavailable",
|
|
||||||
session_id, e
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spawn the stdout read loop.
|
|
||||||
let sid = session_id.clone();
|
let sid = session_id.clone();
|
||||||
let chan = channel.clone();
|
let chan = channel.clone();
|
||||||
let app = app_handle.clone();
|
let app = app_handle.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
let msg = {
|
let msg = { let mut ch = chan.lock().await; ch.wait().await };
|
||||||
let mut ch = chan.lock().await;
|
|
||||||
ch.wait().await
|
|
||||||
};
|
|
||||||
|
|
||||||
match msg {
|
match msg {
|
||||||
Some(ChannelMsg::Data { ref data }) => {
|
Some(ChannelMsg::Data { ref data }) => {
|
||||||
let encoded = base64::engine::general_purpose::STANDARD
|
let encoded = base64::engine::general_purpose::STANDARD.encode(data.as_ref());
|
||||||
.encode(data.as_ref());
|
let _ = app.emit(&format!("ssh:data:{}", sid), encoded);
|
||||||
let event_name = format!("ssh:data:{}", sid);
|
|
||||||
if let Err(e) = app.emit(&event_name, encoded) {
|
|
||||||
error!("Failed to emit SSH data event: {}", e);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Some(ChannelMsg::ExtendedData { ref data, .. }) => {
|
Some(ChannelMsg::ExtendedData { ref data, .. }) => {
|
||||||
// stderr — emit on the same event channel so the
|
let encoded = base64::engine::general_purpose::STANDARD.encode(data.as_ref());
|
||||||
// terminal renders it inline (same as a real terminal).
|
let _ = app.emit(&format!("ssh:data:{}", sid), encoded);
|
||||||
let encoded = base64::engine::general_purpose::STANDARD
|
|
||||||
.encode(data.as_ref());
|
|
||||||
let event_name = format!("ssh:data:{}", sid);
|
|
||||||
if let Err(e) = app.emit(&event_name, encoded) {
|
|
||||||
error!("Failed to emit SSH stderr event: {}", e);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Some(ChannelMsg::ExitStatus { exit_status }) => {
|
Some(ChannelMsg::ExitStatus { exit_status }) => {
|
||||||
info!("SSH session {} exited with status {}", sid, exit_status);
|
let _ = app.emit(&format!("ssh:exit:{}", sid), exit_status);
|
||||||
let event_name = format!("ssh:exit:{}", sid);
|
|
||||||
let _ = app.emit(&event_name, exit_status);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Some(ChannelMsg::Eof) => {
|
Some(ChannelMsg::Close) | None => {
|
||||||
debug!("SSH session {} received EOF", sid);
|
let _ = app.emit(&format!("ssh:close:{}", sid), ());
|
||||||
}
|
|
||||||
Some(ChannelMsg::Close) => {
|
|
||||||
info!("SSH session {} channel closed", sid);
|
|
||||||
let event_name = format!("ssh:close:{}", sid);
|
|
||||||
let _ = app.emit(&event_name, ());
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
None => {
|
_ => {}
|
||||||
info!("SSH session {} channel stream ended", sid);
|
|
||||||
let event_name = format!("ssh:close:{}", sid);
|
|
||||||
let _ = app.emit(&event_name, ());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
// Ignore other channel messages (WindowAdjust, etc.)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
info!(
|
|
||||||
"SSH session {} connected to {}@{}:{}",
|
|
||||||
session_id, username, hostname, port
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(session_id)
|
Ok(session_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write data to a session's PTY stdin.
|
|
||||||
pub async fn write(&self, session_id: &str, data: &[u8]) -> Result<(), String> {
|
pub async fn write(&self, session_id: &str, data: &[u8]) -> Result<(), String> {
|
||||||
let session = self
|
let session = self.sessions.get(session_id).ok_or_else(|| format!("Session {} not found", session_id))?;
|
||||||
.sessions
|
let channel = session.channel.lock().await;
|
||||||
.get(session_id)
|
channel.data(&data[..]).await.map_err(|e| format!("Failed to write to session {}: {}", session_id, e))
|
||||||
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
|
||||||
|
|
||||||
let channel: tokio::sync::MutexGuard<'_, Channel<Msg>> =
|
|
||||||
session.channel.lock().await;
|
|
||||||
channel
|
|
||||||
.data(&data[..])
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to write to session {}: {}", session_id, e))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resize the PTY window for a session.
|
pub async fn resize(&self, session_id: &str, cols: u32, rows: u32) -> Result<(), String> {
|
||||||
pub async fn resize(
|
let session = self.sessions.get(session_id).ok_or_else(|| format!("Session {} not found", session_id))?;
|
||||||
&self,
|
let channel = session.channel.lock().await;
|
||||||
session_id: &str,
|
channel.window_change(cols, rows, 0, 0).await.map_err(|e| format!("Failed to resize session {}: {}", session_id, e))
|
||||||
cols: u32,
|
|
||||||
rows: u32,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let session = self
|
|
||||||
.sessions
|
|
||||||
.get(session_id)
|
|
||||||
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
|
||||||
|
|
||||||
let channel: tokio::sync::MutexGuard<'_, Channel<Msg>> =
|
|
||||||
session.channel.lock().await;
|
|
||||||
channel
|
|
||||||
.window_change(cols, rows, 0, 0)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to resize session {}: {}", session_id, e))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Disconnect a session — close the channel and remove it from the map.
|
pub async fn disconnect(&self, session_id: &str, sftp_service: &SftpService) -> Result<(), String> {
|
||||||
///
|
let (_, session) = self.sessions.remove(session_id).ok_or_else(|| format!("Session {} not found", session_id))?;
|
||||||
/// Pass the `sftp_service` so the SFTP client can be dropped at the same
|
{ let channel = session.channel.lock().await; let _ = channel.eof().await; let _ = channel.close().await; }
|
||||||
/// time as the SSH handle.
|
{ let handle = session.handle.lock().await; let _ = handle.disconnect(Disconnect::ByApplication, "", "en").await; }
|
||||||
pub async fn disconnect(
|
|
||||||
&self,
|
|
||||||
session_id: &str,
|
|
||||||
sftp_service: &SftpService,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let (_, session) = self
|
|
||||||
.sessions
|
|
||||||
.remove(session_id)
|
|
||||||
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
|
||||||
|
|
||||||
// Close the channel gracefully.
|
|
||||||
{
|
|
||||||
let channel: tokio::sync::MutexGuard<'_, Channel<Msg>> =
|
|
||||||
session.channel.lock().await;
|
|
||||||
let _ = channel.eof().await;
|
|
||||||
let _ = channel.close().await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disconnect the SSH connection.
|
|
||||||
{
|
|
||||||
let handle = session.handle.lock().await;
|
|
||||||
let _ = handle
|
|
||||||
.disconnect(Disconnect::ByApplication, "", "en")
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up the SFTP client for this session.
|
|
||||||
sftp_service.remove_client(session_id);
|
sftp_service.remove_client(session_id);
|
||||||
|
|
||||||
info!("SSH session {} disconnected", session_id);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a reference to a session by ID.
|
|
||||||
pub fn get_session(&self, session_id: &str) -> Option<Arc<SshSession>> {
|
pub fn get_session(&self, session_id: &str) -> Option<Arc<SshSession>> {
|
||||||
self.sessions.get(session_id).map(|entry| entry.clone())
|
self.sessions.get(session_id).map(|entry| entry.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all active sessions (metadata only).
|
|
||||||
pub fn list_sessions(&self) -> Vec<SessionInfo> {
|
pub fn list_sessions(&self) -> Vec<SessionInfo> {
|
||||||
self.sessions
|
self.sessions.iter().map(|entry| {
|
||||||
.iter()
|
|
||||||
.map(|entry| {
|
|
||||||
let s = entry.value();
|
let s = entry.value();
|
||||||
SessionInfo {
|
SessionInfo { id: s.id.clone(), hostname: s.hostname.clone(), port: s.port, username: s.username.clone() }
|
||||||
id: s.id.clone(),
|
}).collect()
|
||||||
hostname: s.hostname.clone(),
|
|
||||||
port: s.port,
|
|
||||||
username: s.username.clone(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
"app": {
|
"app": {
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "Wraith",
|
"label": "main", "url": "index.html", "title": "Wraith",
|
||||||
"width": 1200,
|
"width": 1200,
|
||||||
"height": 800,
|
"height": 800,
|
||||||
"minWidth": 800,
|
"minWidth": 800,
|
||||||
|
|||||||
90
src/components/ai/GeminiPanel.vue
Normal file
90
src/components/ai/GeminiPanel.vue
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
const messages = ref<{role: string, content: string}[]>([]);
|
||||||
|
const input = ref("");
|
||||||
|
const isAuthenticated = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const authType = ref<"ApiKey" | "ServiceAccount" | "GoogleAccount">("ApiKey");
|
||||||
|
const apiKey = ref("");
|
||||||
|
const saJson = ref("");
|
||||||
|
|
||||||
|
async function checkAuth() {
|
||||||
|
isAuthenticated.value = await invoke("is_gemini_authenticated");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAuth() {
|
||||||
|
let authValue: any = null;
|
||||||
|
if (authType.value === "ApiKey") authValue = { type: "ApiKey", value: apiKey.value };
|
||||||
|
else if (authType.value === "ServiceAccount") authValue = { type: "ServiceAccount", value: saJson.value };
|
||||||
|
|
||||||
|
if (!authValue) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke("set_gemini_auth", { auth: authValue });
|
||||||
|
isAuthenticated.value = true;
|
||||||
|
} catch (e) {
|
||||||
|
alert(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSend() {
|
||||||
|
if (!input.value.trim() || loading.value) return;
|
||||||
|
const userMsg = input.value;
|
||||||
|
messages.value.push({ role: "user", content: userMsg });
|
||||||
|
input.value = "";
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await invoke<string>("gemini_chat", { message: userMsg });
|
||||||
|
messages.value.push({ role: "model", content: response });
|
||||||
|
} catch (e) {
|
||||||
|
messages.value.push({ role: "error", content: String(e) });
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(checkAuth);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-full bg-[var(--wraith-bg-secondary)] border-l border-[var(--wraith-border)] w-80">
|
||||||
|
<div class="p-3 border-b border-[var(--wraith-border)] flex items-center justify-between">
|
||||||
|
<span class="text-xs font-bold tracking-widest text-[var(--wraith-accent-blue)]">GEMINI XO</span>
|
||||||
|
<button v-if="isAuthenticated" @click="isAuthenticated = false" class="text-[10px] text-[var(--wraith-text-muted)] hover:text-white">REAUTH</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!isAuthenticated" class="p-4 space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-[10px] uppercase text-[var(--wraith-text-secondary)]">Auth Method</label>
|
||||||
|
<select v-model="authType" class="w-full bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] rounded px-2 py-1 text-xs outline-none">
|
||||||
|
<option value="ApiKey">API Key</option>
|
||||||
|
<option value="ServiceAccount">Service Account</option>
|
||||||
|
<option value="GoogleAccount">Google Account (OAuth)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="authType === 'ApiKey'" class="space-y-2">
|
||||||
|
<label class="text-[10px] uppercase text-[var(--wraith-text-secondary)]">API Key</label>
|
||||||
|
<input v-model="apiKey" type="password" class="w-full bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] rounded px-2 py-1 text-xs outline-none" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="authType === 'ServiceAccount'" class="space-y-2">
|
||||||
|
<label class="text-[10px] uppercase text-[var(--wraith-text-secondary)]">JSON Config</label>
|
||||||
|
<textarea v-model="saJson" rows="5" class="w-full bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] rounded px-2 py-1 text-xs outline-none resize-none"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="authType === 'GoogleAccount'" class="p-4 bg-[var(--wraith-bg-tertiary)] rounded-lg text-center border border-dashed border-[var(--wraith-border)]">
|
||||||
|
<p class="text-[10px] text-[var(--wraith-text-muted)] mb-3">OAuth2 PKCE flow not yet active in this stub</p>
|
||||||
|
<button disabled class="w-full py-2 bg-white text-black font-bold rounded text-xs opacity-50">SIGN IN WITH GOOGLE</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button v-if="authType !== 'GoogleAccount'" @click="handleAuth" class="w-full py-2 bg-[var(--wraith-accent-blue)] text-black font-bold rounded text-xs">ACTIVATE XO</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex-1 overflow-y-auto p-3 space-y-4">
|
||||||
|
<div v-for="(msg, i) in messages" :key="i"
|
||||||
|
:class="[text-xs
|
||||||
@ -36,7 +36,7 @@
|
|||||||
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
||||||
@mousedown.prevent="handleFileMenuAction('settings')"
|
@mousedown.prevent="handleFileMenuAction('settings')"
|
||||||
>
|
>
|
||||||
<svg class="w-3.5 h-3.5 shrink-0" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0a8.2 8.2 0 0 1 .701.031C8.955.017 9.209 0 9.466 0a1.934 1.934 0 0 1 1.466.665c.33.367.51.831.54 1.316a7.96 7.96 0 0 1 .82.4c.463-.207.97-.29 1.476-.19.504.1.963.37 1.3.77.339.404.516.91.5 1.423a1.94 1.94 0 0 1-.405 1.168 8.02 8.02 0 0 1 .356.9 1.939 1.939 0 0 1 1.48.803 1.941 1.941 0 0 1 0 2.29 1.939 1.939 0 0 1-1.48.803c-.095.316-.215.622-.357.9a1.94 1.94 0 0 1-.094 2.59 1.94 1.94 0 0 1-2.776.22 7.96 7.96 0 0 1-.82.4 1.94 1.94 0 0 1-2.006 1.98A8.2 8.2 0 0 1 8 16a8.2 8.2 0 0 1-.701-.031 1.938 1.938 0 0 1-2.005-1.98 7.96 7.96 0 0 1-.82-.4 1.94 1.94 0 0 1-2.776-.22 1.94 1.94 0 0 1-.094-2.59 8.02 8.02 0 0 1-.357-.9A1.939 1.939 0 0 1 0 8.945a1.941 1.941 0 0 1 0-2.29 1.939 1.939 0 0 1 1.247-.803c.095-.316.215-.622.357-.9a1.94 1.94 0 0 1 .094-2.59 1.94 1.94 0 0 1 2.776-.22c.258-.157.532-.293.82-.4A1.934 1.934 0 0 1 6.834.665 1.934 1.934 0 0 1 8.3.03 8.2 8.2 0 0 1 8 0ZM8 5a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z"/></svg>
|
<svg class="w-3.5 h-3.5 shrink-0" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0a8.2 8.2 0 0 1 .701.031C8.955.017 9.209 0 9.466 0a1.934 1.934 0 0 1 1.466.665c.33.367.51.831.54 1.316a7.96 7.96 0 0 1 .82.4c.463-.207.97-.29 1.476-.19.504.1.963.37 1.3.77.339.404.516.91.5 1.423a1.94 1.94 0 0 1-.405 1.168 8.02 8.02 0 0 1 .356.9 1.939 1.939 0 0 1 1.48.803 1.941 1.941 0 0 1 0 2.29 1.939 1.939 0 0 1-1.48.803c-.095.316-.215.622-.357.9a1.94 1.94 0 0 1-.094 2.59 1.94 1.94 0 0 1-2.776.22 7.96 7.96 0 0 1-.82.4 1.94 1.94 0 0 1-2.006 1.98A8.2 8.2 0 0 1 8 16a8.2 8.2 0 0 1-.701-.031 1.938 1.938 0 0 1-2.005-1.98 7.96 7.96 0 0 1-.82-.4 1.94 1.94 0 0 1-2.776-.22 1.94 1.94 0 0 1-.094-2.59 8.02 8.02 0 0 1-.357-.9A1.939 1.939 0 0 1 0 8.945a1.941 1.941 0 0 1 1.247-.803c.095-.316.215-.622.357-.9a1.94 1.94 0 0 1 .094-2.59 1.94 1.94 0 0 1 2.776-.22c.258-.157.532-.293.82-.4A1.934 1.934 0 0 1 6.834.665 1.934 1.934 0 0 1 8.3.03 8.2 8.2 0 0 1 8 0ZM8 5a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z"/></svg>
|
||||||
<span class="flex-1">Settings</span>
|
<span class="flex-1">Settings</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="border-t border-[#30363d] my-1" />
|
<div class="border-t border-[#30363d] my-1" />
|
||||||
@ -65,6 +65,16 @@
|
|||||||
|
|
||||||
<div class="flex items-center gap-3 text-xs text-[var(--wraith-text-secondary)]">
|
<div class="flex items-center gap-3 text-xs text-[var(--wraith-text-secondary)]">
|
||||||
<span>{{ sessionStore.sessionCount }} session{{ sessionStore.sessionCount !== 1 ? "s" : "" }}</span>
|
<span>{{ sessionStore.sessionCount }} session{{ sessionStore.sessionCount !== 1 ? "s" : "" }}</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="hover:text-[var(--wraith-accent-blue)] transition-colors cursor-pointer"
|
||||||
|
:class="{ 'text-[var(--wraith-accent-blue)]': geminiVisible }"
|
||||||
|
title="Gemini XO (Ctrl+Shift+G)"
|
||||||
|
@click="geminiVisible = !geminiVisible"
|
||||||
|
>
|
||||||
|
AI
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
class="hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
||||||
title="Command palette (Ctrl+K)"
|
title="Command palette (Ctrl+K)"
|
||||||
@ -141,7 +151,7 @@
|
|||||||
<!-- Tab bar -->
|
<!-- Tab bar -->
|
||||||
<TabBar />
|
<TabBar />
|
||||||
|
|
||||||
<!-- Inline file editor — shown above the terminal when a file is open -->
|
<!-- Inline file editor -->
|
||||||
<EditorWindow
|
<EditorWindow
|
||||||
v-if="editorFile"
|
v-if="editorFile"
|
||||||
:content="editorFile.content"
|
:content="editorFile.content"
|
||||||
@ -153,27 +163,23 @@
|
|||||||
<!-- Session area -->
|
<!-- Session area -->
|
||||||
<SessionContainer ref="sessionContainer" />
|
<SessionContainer ref="sessionContainer" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Gemini AI Panel -->
|
||||||
|
<GeminiPanel v-if="geminiVisible" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status bar -->
|
<!-- Status bar -->
|
||||||
<StatusBar ref="statusBar" @open-theme-picker="themePicker?.open()" />
|
<StatusBar ref="statusBar" @open-theme-picker="themePicker?.open()" />
|
||||||
|
|
||||||
<!-- Command Palette (Ctrl+K) — stub, full implementation Phase N -->
|
|
||||||
<CommandPalette
|
<CommandPalette
|
||||||
ref="commandPalette"
|
ref="commandPalette"
|
||||||
@open-settings="settingsModal?.open()"
|
@open-settings="settingsModal?.open()"
|
||||||
@open-new-connection="connectionEditDialog?.openNew()"
|
@open-new-connection="connectionEditDialog?.openNew()"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Theme Picker -->
|
|
||||||
<ThemePicker ref="themePicker" @select="handleThemeSelect" />
|
<ThemePicker ref="themePicker" @select="handleThemeSelect" />
|
||||||
|
|
||||||
<!-- Settings Modal -->
|
|
||||||
<SettingsModal ref="settingsModal" />
|
<SettingsModal ref="settingsModal" />
|
||||||
|
|
||||||
<!-- Connection Edit Dialog (for File menu / Command Palette new connection) -->
|
|
||||||
<ConnectionEditDialog ref="connectionEditDialog" />
|
<ConnectionEditDialog ref="connectionEditDialog" />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -196,6 +202,7 @@ import ConnectionEditDialog from "@/components/connections/ConnectionEditDialog.
|
|||||||
import FileTree from "@/components/sftp/FileTree.vue";
|
import FileTree from "@/components/sftp/FileTree.vue";
|
||||||
import TransferProgress from "@/components/sftp/TransferProgress.vue";
|
import TransferProgress from "@/components/sftp/TransferProgress.vue";
|
||||||
import EditorWindow from "@/components/editor/EditorWindow.vue";
|
import EditorWindow from "@/components/editor/EditorWindow.vue";
|
||||||
|
import GeminiPanel from "@/components/ai/GeminiPanel.vue";
|
||||||
import type { FileEntry } from "@/composables/useSftp";
|
import type { FileEntry } from "@/composables/useSftp";
|
||||||
|
|
||||||
import type { ThemeDefinition } from "@/components/common/ThemePicker.vue";
|
import type { ThemeDefinition } from "@/components/common/ThemePicker.vue";
|
||||||
@ -205,15 +212,13 @@ const appStore = useAppStore();
|
|||||||
const connectionStore = useConnectionStore();
|
const connectionStore = useConnectionStore();
|
||||||
const sessionStore = useSessionStore();
|
const sessionStore = useSessionStore();
|
||||||
|
|
||||||
/** Active SSH session ID, exposed to the SFTP sidebar. */
|
|
||||||
const activeSessionId = computed(() => sessionStore.activeSessionId);
|
const activeSessionId = computed(() => sessionStore.activeSessionId);
|
||||||
|
|
||||||
const sidebarWidth = ref(240);
|
const sidebarWidth = ref(240);
|
||||||
const sidebarVisible = ref(true);
|
const sidebarVisible = ref(true);
|
||||||
const sidebarTab = ref<SidebarTab>("connections");
|
const sidebarTab = ref<SidebarTab>("connections");
|
||||||
|
const geminiVisible = ref(false);
|
||||||
const quickConnectInput = ref("");
|
const quickConnectInput = ref("");
|
||||||
|
|
||||||
|
|
||||||
const commandPalette = ref<InstanceType<typeof CommandPalette> | null>(null);
|
const commandPalette = ref<InstanceType<typeof CommandPalette> | null>(null);
|
||||||
const themePicker = ref<InstanceType<typeof ThemePicker> | null>(null);
|
const themePicker = ref<InstanceType<typeof ThemePicker> | null>(null);
|
||||||
const settingsModal = ref<InstanceType<typeof SettingsModal> | null>(null);
|
const settingsModal = ref<InstanceType<typeof SettingsModal> | null>(null);
|
||||||
@ -221,238 +226,80 @@ const connectionEditDialog = ref<InstanceType<typeof ConnectionEditDialog> | nul
|
|||||||
const statusBar = ref<InstanceType<typeof StatusBar> | null>(null);
|
const statusBar = ref<InstanceType<typeof StatusBar> | null>(null);
|
||||||
const sessionContainer = ref<InstanceType<typeof SessionContainer> | null>(null);
|
const sessionContainer = ref<InstanceType<typeof SessionContainer> | null>(null);
|
||||||
|
|
||||||
/** Currently open file in the inline editor. Null when the editor is closed. */
|
interface EditorFile { path: string; content: string; sessionId: string; }
|
||||||
interface EditorFile {
|
|
||||||
path: string;
|
|
||||||
content: string;
|
|
||||||
sessionId: string;
|
|
||||||
}
|
|
||||||
const editorFile = ref<EditorFile | null>(null);
|
const editorFile = ref<EditorFile | null>(null);
|
||||||
|
|
||||||
/** File menu dropdown state. */
|
|
||||||
const showFileMenu = ref(false);
|
const showFileMenu = ref(false);
|
||||||
|
|
||||||
/** Close the file menu after a short delay (allows click events to fire first). */
|
|
||||||
function closeFileMenuDeferred(): void {
|
function closeFileMenuDeferred(): void {
|
||||||
setTimeout(() => { showFileMenu.value = false; }, 150);
|
setTimeout(() => { showFileMenu.value = false; }, 150);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Handle file menu item clicks. */
|
|
||||||
async function handleFileMenuAction(action: string): Promise<void> {
|
async function handleFileMenuAction(action: string): Promise<void> {
|
||||||
showFileMenu.value = false;
|
showFileMenu.value = false;
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "new-connection":
|
case "new-connection": connectionEditDialog.value?.openNew(); break;
|
||||||
connectionEditDialog.value?.openNew();
|
case "settings": settingsModal.value?.open(); break;
|
||||||
break;
|
case "exit": try { await getCurrentWindow().close(); } catch { window.close(); } break;
|
||||||
case "settings":
|
|
||||||
settingsModal.value?.open();
|
|
||||||
break;
|
|
||||||
case "exit":
|
|
||||||
try {
|
|
||||||
await getCurrentWindow().close();
|
|
||||||
} catch {
|
|
||||||
window.close();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Handle theme selection from the ThemePicker. */
|
|
||||||
function handleThemeSelect(theme: ThemeDefinition): void {
|
function handleThemeSelect(theme: ThemeDefinition): void {
|
||||||
statusBar.value?.setThemeName(theme.name);
|
statusBar.value?.setThemeName(theme.name);
|
||||||
// Propagate theme to all active terminal instances via the session store
|
|
||||||
sessionStore.setTheme(theme);
|
sessionStore.setTheme(theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the user double-clicks a file in the SFTP FileTree.
|
|
||||||
* Reads the file content via Tauri SFTP and opens it in the inline editor.
|
|
||||||
*/
|
|
||||||
async function handleOpenFile(entry: FileEntry): Promise<void> {
|
async function handleOpenFile(entry: FileEntry): Promise<void> {
|
||||||
if (!activeSessionId.value) return;
|
if (!activeSessionId.value) return;
|
||||||
try {
|
try {
|
||||||
const content = await invoke<string>("sftp_read_file", {
|
const content = await invoke<string>("sftp_read_file", { sessionId: activeSessionId.value, path: entry.path });
|
||||||
sessionId: activeSessionId.value,
|
editorFile.value = { path: entry.path, content, sessionId: activeSessionId.value };
|
||||||
path: entry.path,
|
} catch (err) { console.error("Failed to open SFTP file:", err); }
|
||||||
});
|
|
||||||
editorFile.value = {
|
|
||||||
path: entry.path,
|
|
||||||
content,
|
|
||||||
sessionId: activeSessionId.value,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to open SFTP file:", err);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Quick Connect: parse user@host:port and open a session.
|
|
||||||
* Default protocol: SSH, default port: 22.
|
|
||||||
* If port is 3389, use RDP.
|
|
||||||
*/
|
|
||||||
async function handleQuickConnect(): Promise<void> {
|
async function handleQuickConnect(): Promise<void> {
|
||||||
const raw = quickConnectInput.value.trim();
|
const raw = quickConnectInput.value.trim();
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
|
let username = "", hostname = "", port = 22, protocol: "ssh" | "rdp" = "ssh", hostPart = raw;
|
||||||
let username = "";
|
|
||||||
let hostname = "";
|
|
||||||
let port = 22;
|
|
||||||
let protocol: "ssh" | "rdp" = "ssh";
|
|
||||||
|
|
||||||
let hostPart = raw;
|
|
||||||
|
|
||||||
// Extract username if present (user@...)
|
|
||||||
const atIdx = raw.indexOf("@");
|
const atIdx = raw.indexOf("@");
|
||||||
if (atIdx > 0) {
|
if (atIdx > 0) { username = raw.substring(0, atIdx); hostPart = raw.substring(atIdx + 1); }
|
||||||
username = raw.substring(0, atIdx);
|
|
||||||
hostPart = raw.substring(atIdx + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract port if present (...:port)
|
|
||||||
const colonIdx = hostPart.lastIndexOf(":");
|
const colonIdx = hostPart.lastIndexOf(":");
|
||||||
if (colonIdx > 0) {
|
if (colonIdx > 0) {
|
||||||
const portStr = hostPart.substring(colonIdx + 1);
|
const portStr = hostPart.substring(colonIdx + 1);
|
||||||
const parsedPort = parseInt(portStr, 10);
|
const parsedPort = parseInt(portStr, 10);
|
||||||
if (!isNaN(parsedPort) && parsedPort > 0 && parsedPort <= 65535) {
|
if (!isNaN(parsedPort) && parsedPort > 0 && parsedPort <= 65535) { port = parsedPort; hostPart = hostPart.substring(0, colonIdx); }
|
||||||
port = parsedPort;
|
|
||||||
hostPart = hostPart.substring(0, colonIdx);
|
|
||||||
}
|
}
|
||||||
}
|
hostname = hostPart; if (!hostname) return;
|
||||||
|
if (port === 3389) protocol = "rdp";
|
||||||
hostname = hostPart;
|
|
||||||
if (!hostname) return;
|
|
||||||
|
|
||||||
// Auto-detect RDP by port
|
|
||||||
if (port === 3389) {
|
|
||||||
protocol = "rdp";
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = username ? `${username}@${hostname}` : hostname;
|
const name = username ? `${username}@${hostname}` : hostname;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create a persistent connection record then connect to it
|
const conn = await invoke<{ id: number }>("create_connection", { name, hostname, port, protocol, groupId: null, credentialId: null, color: "", tags: username ? [username] : [], notes: "", options: username ? JSON.stringify({ username }) : "{}" });
|
||||||
const conn = await invoke<{ id: number }>("create_connection", {
|
connectionStore.connections.push({ id: conn.id, name, hostname, port, protocol, groupId: null, tags: username ? [username] : [], options: username ? JSON.stringify({ username }) : "{}" });
|
||||||
name,
|
|
||||||
hostname,
|
|
||||||
port,
|
|
||||||
protocol,
|
|
||||||
groupId: null,
|
|
||||||
credentialId: null,
|
|
||||||
color: "",
|
|
||||||
tags: username ? [username] : [],
|
|
||||||
notes: "",
|
|
||||||
options: username ? JSON.stringify({ username }) : "{}",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add to local store so sessionStore.connect can find it
|
|
||||||
connectionStore.connections.push({
|
|
||||||
id: conn.id,
|
|
||||||
name,
|
|
||||||
hostname,
|
|
||||||
port,
|
|
||||||
protocol,
|
|
||||||
groupId: null,
|
|
||||||
tags: username ? [username] : [],
|
|
||||||
options: username ? JSON.stringify({ username }) : "{}",
|
|
||||||
});
|
|
||||||
|
|
||||||
await sessionStore.connect(conn.id);
|
await sessionStore.connect(conn.id);
|
||||||
quickConnectInput.value = "";
|
quickConnectInput.value = "";
|
||||||
} catch (err) {
|
} catch (err) { console.error("Quick connect failed:", err); }
|
||||||
console.error("Quick connect failed:", err);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Global keyboard shortcut handler. */
|
|
||||||
function handleKeydown(event: KeyboardEvent): void {
|
function handleKeydown(event: KeyboardEvent): void {
|
||||||
// Skip shortcuts when the user is typing in an input, textarea, or select
|
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
const isInputFocused = target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT";
|
const isInputFocused = target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT";
|
||||||
|
|
||||||
const ctrl = event.ctrlKey || event.metaKey;
|
const ctrl = event.ctrlKey || event.metaKey;
|
||||||
|
if (ctrl && event.key === "k") { event.preventDefault(); commandPalette.value?.toggle(); return; }
|
||||||
// Ctrl+K — open command palette (fires even in inputs to match VS Code behavior)
|
|
||||||
if (ctrl && event.key === "k") {
|
|
||||||
event.preventDefault();
|
|
||||||
commandPalette.value?.toggle();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// All remaining shortcuts skip when typing in input fields
|
|
||||||
if (isInputFocused) return;
|
if (isInputFocused) return;
|
||||||
|
if (ctrl && event.key === "w") { event.preventDefault(); const active = sessionStore.activeSession; if (active) sessionStore.closeSession(active.id); return; }
|
||||||
// Ctrl+W — close active tab
|
if (ctrl && event.key === "Tab" && !event.shiftKey) { event.preventDefault(); const sessions = sessionStore.sessions; if (sessions.length < 2) return; const idx = sessions.findIndex((s) => s.id === sessionStore.activeSessionId); const next = sessions[(idx + 1) % sessions.length]; sessionStore.activateSession(next.id); return; }
|
||||||
if (ctrl && event.key === "w") {
|
if (ctrl && event.key === "Tab" && event.shiftKey) { event.preventDefault(); const sessions = sessionStore.sessions; if (sessions.length < 2) return; const idx = sessions.findIndex((s) => s.id === sessionStore.activeSessionId); const prev = sessions[(idx - 1 + sessions.length) % sessions.length]; sessionStore.activateSession(prev.id); return; }
|
||||||
event.preventDefault();
|
if (ctrl && event.key >= "1" && event.key <= "9") { const tabIndex = parseInt(event.key, 10) - 1; const sessions = sessionStore.sessions; if (tabIndex < sessions.length) { event.preventDefault(); sessionStore.activateSession(sessions[tabIndex].id); } return; }
|
||||||
const active = sessionStore.activeSession;
|
if (ctrl && event.key === "b") { event.preventDefault(); sidebarVisible.value = !sidebarVisible.value; return; }
|
||||||
if (active) {
|
if (ctrl && event.shiftKey && event.key.toLowerCase() === "g") { event.preventDefault(); geminiVisible.value = !geminiVisible.value; return; }
|
||||||
sessionStore.closeSession(active.id);
|
if (ctrl && event.key === "f") { const active = sessionStore.activeSession; if (active?.protocol === "ssh") { event.preventDefault(); sessionContainer.value?.openActiveSearch(); } return; }
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+Tab — next tab
|
|
||||||
if (ctrl && event.key === "Tab" && !event.shiftKey) {
|
|
||||||
event.preventDefault();
|
|
||||||
const sessions = sessionStore.sessions;
|
|
||||||
if (sessions.length < 2) return;
|
|
||||||
const idx = sessions.findIndex((s) => s.id === sessionStore.activeSessionId);
|
|
||||||
const next = sessions[(idx + 1) % sessions.length];
|
|
||||||
sessionStore.activateSession(next.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+Shift+Tab — previous tab
|
|
||||||
if (ctrl && event.key === "Tab" && event.shiftKey) {
|
|
||||||
event.preventDefault();
|
|
||||||
const sessions = sessionStore.sessions;
|
|
||||||
if (sessions.length < 2) return;
|
|
||||||
const idx = sessions.findIndex((s) => s.id === sessionStore.activeSessionId);
|
|
||||||
const prev = sessions[(idx - 1 + sessions.length) % sessions.length];
|
|
||||||
sessionStore.activateSession(prev.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+1 through Ctrl+9 — switch to tab by index
|
|
||||||
if (ctrl && event.key >= "1" && event.key <= "9") {
|
|
||||||
const tabIndex = parseInt(event.key, 10) - 1;
|
|
||||||
const sessions = sessionStore.sessions;
|
|
||||||
if (tabIndex < sessions.length) {
|
|
||||||
event.preventDefault();
|
|
||||||
sessionStore.activateSession(sessions[tabIndex].id);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+B — toggle sidebar
|
|
||||||
if (ctrl && event.key === "b") {
|
|
||||||
event.preventDefault();
|
|
||||||
sidebarVisible.value = !sidebarVisible.value;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+F — open terminal scrollback search (SSH sessions only)
|
|
||||||
if (ctrl && event.key === "f") {
|
|
||||||
const active = sessionStore.activeSession;
|
|
||||||
if (active?.protocol === "ssh") {
|
|
||||||
event.preventDefault();
|
|
||||||
sessionContainer.value?.openActiveSearch();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
document.addEventListener("keydown", handleKeydown);
|
document.addEventListener("keydown", handleKeydown);
|
||||||
// Load connections and groups from the Rust backend after vault unlock
|
|
||||||
await connectionStore.loadAll();
|
await connectionStore.loadAll();
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener("keydown", handleKeydown);
|
document.removeEventListener("keydown", handleKeydown);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
</style>
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user