Compare commits
No commits in common. "main" and "v1.10.1" have entirely different histories.
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB |
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
@ -2991,7 +2991,6 @@ checksum = "47c225751e8fbfaaaac5572a80e25d0a0921e9cf408c55509526161b5609157c"
|
||||
dependencies = [
|
||||
"ironrdp-connector",
|
||||
"ironrdp-core",
|
||||
"ironrdp-displaycontrol",
|
||||
"ironrdp-graphics",
|
||||
"ironrdp-input",
|
||||
"ironrdp-pdu",
|
||||
|
||||
@ -65,7 +65,7 @@ ureq = "3"
|
||||
png = "0.17"
|
||||
|
||||
# RDP (IronRDP)
|
||||
ironrdp = { version = "0.14", features = ["connector", "session", "graphics", "input", "displaycontrol"] }
|
||||
ironrdp = { version = "0.14", features = ["connector", "session", "graphics", "input"] }
|
||||
ironrdp-tokio = { version = "0.8", features = ["reqwest-rustls-ring"] }
|
||||
ironrdp-tls = { version = "0.2", features = ["rustls"] }
|
||||
tokio-rustls = "0.26"
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
use tauri::State;
|
||||
use serde::Serialize;
|
||||
use crate::AppState;
|
||||
use crate::ssh::exec::exec_on_session;
|
||||
use crate::utils::shell_escape;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@ -39,7 +38,7 @@ pub struct DockerVolume {
|
||||
pub async fn docker_list_containers(session_id: String, all: Option<bool>, state: State<'_, AppState>) -> Result<Vec<DockerContainer>, String> {
|
||||
let session = state.ssh.get_session(&session_id).ok_or("Session not found")?;
|
||||
let flag = if all.unwrap_or(true) { "-a" } else { "" };
|
||||
let output = exec_on_session(&session.handle, &format!("docker ps {} --format '{{{{.ID}}}}|{{{{.Names}}}}|{{{{.Image}}}}|{{{{.Status}}}}|{{{{.Ports}}}}|{{{{.CreatedAt}}}}' 2>&1", flag)).await?;
|
||||
let output = exec(&session.handle, &format!("docker ps {} --format '{{{{.ID}}}}|{{{{.Names}}}}|{{{{.Image}}}}|{{{{.Status}}}}|{{{{.Ports}}}}|{{{{.CreatedAt}}}}' 2>&1", flag)).await?;
|
||||
Ok(output.lines().filter(|l| !l.is_empty() && !l.starts_with("CONTAINER")).map(|line| {
|
||||
let p: Vec<&str> = line.splitn(6, '|').collect();
|
||||
DockerContainer {
|
||||
@ -56,7 +55,7 @@ pub async fn docker_list_containers(session_id: String, all: Option<bool>, state
|
||||
#[tauri::command]
|
||||
pub async fn docker_list_images(session_id: String, state: State<'_, AppState>) -> Result<Vec<DockerImage>, String> {
|
||||
let session = state.ssh.get_session(&session_id).ok_or("Session not found")?;
|
||||
let output = exec_on_session(&session.handle, "docker images --format '{{.ID}}|{{.Repository}}|{{.Tag}}|{{.Size}}|{{.CreatedAt}}' 2>&1").await?;
|
||||
let output = exec(&session.handle, "docker images --format '{{.ID}}|{{.Repository}}|{{.Tag}}|{{.Size}}|{{.CreatedAt}}' 2>&1").await?;
|
||||
Ok(output.lines().filter(|l| !l.is_empty()).map(|line| {
|
||||
let p: Vec<&str> = line.splitn(5, '|').collect();
|
||||
DockerImage {
|
||||
@ -72,7 +71,7 @@ pub async fn docker_list_images(session_id: String, state: State<'_, AppState>)
|
||||
#[tauri::command]
|
||||
pub async fn docker_list_volumes(session_id: String, state: State<'_, AppState>) -> Result<Vec<DockerVolume>, String> {
|
||||
let session = state.ssh.get_session(&session_id).ok_or("Session not found")?;
|
||||
let output = exec_on_session(&session.handle, "docker volume ls --format '{{.Name}}|{{.Driver}}|{{.Mountpoint}}' 2>&1").await?;
|
||||
let output = exec(&session.handle, "docker volume ls --format '{{.Name}}|{{.Driver}}|{{.Mountpoint}}' 2>&1").await?;
|
||||
Ok(output.lines().filter(|l| !l.is_empty()).map(|line| {
|
||||
let p: Vec<&str> = line.splitn(3, '|').collect();
|
||||
DockerVolume {
|
||||
@ -100,6 +99,19 @@ pub async fn docker_action(session_id: String, action: String, target: String, s
|
||||
"system-prune-all" => "docker system prune -a -f 2>&1".to_string(),
|
||||
_ => return Err(format!("Unknown docker action: {}", action)),
|
||||
};
|
||||
exec_on_session(&session.handle, &cmd).await
|
||||
exec(&session.handle, &cmd).await
|
||||
}
|
||||
|
||||
async fn exec(handle: &std::sync::Arc<tokio::sync::Mutex<russh::client::Handle<crate::ssh::session::SshClient>>>, cmd: &str) -> Result<String, String> {
|
||||
let mut channel = { let h = handle.lock().await; h.channel_open_session().await.map_err(|e| format!("Exec failed: {}", e))? };
|
||||
channel.exec(true, cmd).await.map_err(|e| format!("Exec failed: {}", e))?;
|
||||
let mut output = String::new();
|
||||
loop {
|
||||
match channel.wait().await {
|
||||
Some(russh::ChannelMsg::Data { ref data }) => { if let Ok(t) = std::str::from_utf8(data.as_ref()) { output.push_str(t); } }
|
||||
Some(russh::ChannelMsg::Eof) | Some(russh::ChannelMsg::Close) | None => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
@ -14,4 +14,3 @@ pub mod updater;
|
||||
pub mod tools_commands_r2;
|
||||
pub mod workspace_commands;
|
||||
pub mod docker_commands;
|
||||
pub mod window_commands;
|
||||
|
||||
@ -19,37 +19,18 @@ pub fn connect_rdp(
|
||||
state.rdp.connect(config, app_handle)
|
||||
}
|
||||
|
||||
/// Get the dirty region since last call as raw RGBA bytes via binary IPC.
|
||||
/// Get the current frame buffer as raw RGBA bytes via binary IPC.
|
||||
///
|
||||
/// Binary format: 8-byte header + pixel data
|
||||
/// Header: [x: u16, y: u16, width: u16, height: u16] (little-endian)
|
||||
/// If header is all zeros, the payload is a full frame (width*height*4 bytes).
|
||||
/// If header is non-zero, payload contains only the dirty rectangle pixels.
|
||||
/// Returns empty payload if nothing changed.
|
||||
/// Uses `tauri::ipc::Response` to return raw bytes without JSON serialization.
|
||||
/// Pixel format: RGBA, 4 bytes per pixel, row-major, top-left origin.
|
||||
/// Returns empty payload if frame hasn't changed since last call.
|
||||
#[tauri::command]
|
||||
pub fn rdp_get_frame(
|
||||
pub async fn rdp_get_frame(
|
||||
session_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Response, String> {
|
||||
let (region, pixels) = state.rdp.get_frame(&session_id)?;
|
||||
if pixels.is_empty() {
|
||||
return Ok(Response::new(Vec::new()));
|
||||
}
|
||||
// Prepend 8-byte dirty rect header
|
||||
let mut out = Vec::with_capacity(8 + pixels.len());
|
||||
match region {
|
||||
Some(rect) => {
|
||||
out.extend_from_slice(&rect.x.to_le_bytes());
|
||||
out.extend_from_slice(&rect.y.to_le_bytes());
|
||||
out.extend_from_slice(&rect.width.to_le_bytes());
|
||||
out.extend_from_slice(&rect.height.to_le_bytes());
|
||||
}
|
||||
None => {
|
||||
out.extend_from_slice(&[0u8; 8]); // full frame marker
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(&pixels);
|
||||
Ok(Response::new(out))
|
||||
let frame = state.rdp.get_frame(&session_id).await?;
|
||||
Ok(Response::new(frame))
|
||||
}
|
||||
|
||||
/// Send a mouse event to an RDP session.
|
||||
@ -64,7 +45,7 @@ pub fn rdp_get_frame(
|
||||
/// - 0x0100 = negative wheel direction
|
||||
/// - 0x0400 = horizontal wheel
|
||||
#[tauri::command]
|
||||
pub fn rdp_send_mouse(
|
||||
pub async fn rdp_send_mouse(
|
||||
session_id: String,
|
||||
x: u16,
|
||||
y: u16,
|
||||
@ -82,7 +63,7 @@ pub fn rdp_send_mouse(
|
||||
///
|
||||
/// `pressed` is `true` for key-down, `false` for key-up.
|
||||
#[tauri::command]
|
||||
pub fn rdp_send_key(
|
||||
pub async fn rdp_send_key(
|
||||
session_id: String,
|
||||
scancode: u16,
|
||||
pressed: bool,
|
||||
@ -93,7 +74,7 @@ pub fn rdp_send_key(
|
||||
|
||||
/// Send clipboard text to an RDP session by simulating keystrokes.
|
||||
#[tauri::command]
|
||||
pub fn rdp_send_clipboard(
|
||||
pub async fn rdp_send_clipboard(
|
||||
session_id: String,
|
||||
text: String,
|
||||
state: State<'_, AppState>,
|
||||
@ -101,34 +82,11 @@ pub fn rdp_send_clipboard(
|
||||
state.rdp.send_clipboard(&session_id, &text)
|
||||
}
|
||||
|
||||
/// Force the next get_frame to return a full frame regardless of dirty state.
|
||||
/// Used when switching tabs or after resize to ensure the canvas is fully repainted.
|
||||
#[tauri::command]
|
||||
pub fn rdp_force_refresh(
|
||||
session_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
state.rdp.force_refresh(&session_id)
|
||||
}
|
||||
|
||||
/// Resize the RDP session's desktop resolution.
|
||||
/// Sends a Display Control Virtual Channel request to the server.
|
||||
/// The server will re-render at the new resolution and send updated frames.
|
||||
#[tauri::command]
|
||||
pub fn rdp_resize(
|
||||
session_id: String,
|
||||
width: u16,
|
||||
height: u16,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
state.rdp.resize(&session_id, width, height)
|
||||
}
|
||||
|
||||
/// Disconnect an RDP session.
|
||||
///
|
||||
/// Sends a graceful shutdown to the RDP server and removes the session.
|
||||
#[tauri::command]
|
||||
pub fn disconnect_rdp(
|
||||
pub async fn disconnect_rdp(
|
||||
session_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
@ -137,7 +95,7 @@ pub fn disconnect_rdp(
|
||||
|
||||
/// List all active RDP sessions (metadata only).
|
||||
#[tauri::command]
|
||||
pub fn list_rdp_sessions(
|
||||
pub async fn list_rdp_sessions(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<RdpSessionInfo>, String> {
|
||||
Ok(state.rdp.list_sessions())
|
||||
|
||||
@ -4,7 +4,6 @@ use tauri::State;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::ssh::exec::exec_on_session;
|
||||
use crate::utils::shell_escape;
|
||||
|
||||
// ── Ping ─────────────────────────────────────────────────────────────────────
|
||||
@ -186,3 +185,32 @@ pub fn tool_generate_password_inner(
|
||||
Ok(password)
|
||||
}
|
||||
|
||||
// ── Helper ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async fn exec_on_session(
|
||||
handle: &std::sync::Arc<tokio::sync::Mutex<russh::client::Handle<crate::ssh::session::SshClient>>>,
|
||||
cmd: &str,
|
||||
) -> Result<String, String> {
|
||||
let mut channel = {
|
||||
let h = handle.lock().await;
|
||||
h.channel_open_session().await.map_err(|e| format!("Exec channel failed: {}", e))?
|
||||
};
|
||||
|
||||
channel.exec(true, cmd).await.map_err(|e| format!("Exec failed: {}", e))?;
|
||||
|
||||
let mut output = String::new();
|
||||
loop {
|
||||
match channel.wait().await {
|
||||
Some(russh::ChannelMsg::Data { ref data }) => {
|
||||
if let Ok(text) = std::str::from_utf8(data.as_ref()) {
|
||||
output.push_str(text);
|
||||
}
|
||||
}
|
||||
Some(russh::ChannelMsg::Eof) | Some(russh::ChannelMsg::Close) | None => break,
|
||||
Some(russh::ChannelMsg::ExitStatus { .. }) => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
@ -4,7 +4,6 @@ use tauri::State;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::ssh::exec::exec_on_session;
|
||||
use crate::utils::shell_escape;
|
||||
|
||||
// ── DNS Lookup ───────────────────────────────────────────────────────────────
|
||||
@ -182,3 +181,27 @@ fn to_ip(val: u32) -> String {
|
||||
format!("{}.{}.{}.{}", val >> 24, (val >> 16) & 0xFF, (val >> 8) & 0xFF, val & 0xFF)
|
||||
}
|
||||
|
||||
// ── Helper ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async fn exec_on_session(
|
||||
handle: &std::sync::Arc<tokio::sync::Mutex<russh::client::Handle<crate::ssh::session::SshClient>>>,
|
||||
cmd: &str,
|
||||
) -> Result<String, String> {
|
||||
let mut channel = {
|
||||
let h = handle.lock().await;
|
||||
h.channel_open_session().await.map_err(|e| format!("Exec channel failed: {}", e))?
|
||||
};
|
||||
channel.exec(true, cmd).await.map_err(|e| format!("Exec failed: {}", e))?;
|
||||
let mut output = String::new();
|
||||
loop {
|
||||
match channel.wait().await {
|
||||
Some(russh::ChannelMsg::Data { ref data }) => {
|
||||
if let Ok(text) = std::str::from_utf8(data.as_ref()) { output.push_str(text); }
|
||||
}
|
||||
Some(russh::ChannelMsg::Eof) | Some(russh::ChannelMsg::Close) | None => break,
|
||||
Some(russh::ChannelMsg::ExitStatus { .. }) => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
@ -1,40 +0,0 @@
|
||||
use tauri::AppHandle;
|
||||
use tauri::WebviewWindowBuilder;
|
||||
|
||||
/// Open a child window from the Rust side using WebviewWindowBuilder.
|
||||
///
|
||||
/// The `url` parameter supports hash fragments (e.g. "index.html#/tool/ping?sessionId=abc").
|
||||
/// WebviewUrl::App takes a PathBuf and cannot handle hash/query — so we load plain
|
||||
/// index.html and set the hash via JS after the window is created.
|
||||
#[tauri::command]
|
||||
pub fn open_child_window(
|
||||
app_handle: AppHandle,
|
||||
label: String,
|
||||
title: String,
|
||||
url: String,
|
||||
width: f64,
|
||||
height: f64,
|
||||
) -> Result<(), String> {
|
||||
// Split "index.html#/tool/ping?sessionId=abc" into path and fragment
|
||||
let (path, hash) = match url.split_once('#') {
|
||||
Some((p, h)) => (p.to_string(), Some(format!("#{}", h))),
|
||||
None => (url.clone(), None),
|
||||
};
|
||||
|
||||
let webview_url = tauri::WebviewUrl::App(path.into());
|
||||
let window = WebviewWindowBuilder::new(&app_handle, &label, webview_url)
|
||||
.title(&title)
|
||||
.inner_size(width, height)
|
||||
.resizable(true)
|
||||
.center()
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create window '{}': {}", label, e))?;
|
||||
|
||||
// Set the hash fragment after the window loads — this triggers App.vue's
|
||||
// onMounted hash detection to render the correct tool/detached component.
|
||||
if let Some(hash) = hash {
|
||||
let _ = window.eval(&format!("window.location.hash = '{}';", hash));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@ -434,9 +434,6 @@ impl ConnectionService {
|
||||
/// Batch-update sort_order for a list of connection IDs.
|
||||
pub fn reorder_connections(&self, ids: &[i64]) -> Result<(), String> {
|
||||
let conn = self.db.conn();
|
||||
conn.execute_batch("BEGIN")
|
||||
.map_err(|e| format!("Failed to begin reorder transaction: {e}"))?;
|
||||
let result = (|| {
|
||||
for (i, id) in ids.iter().enumerate() {
|
||||
conn.execute(
|
||||
"UPDATE connections SET sort_order = ?1 WHERE id = ?2",
|
||||
@ -445,22 +442,11 @@ impl ConnectionService {
|
||||
.map_err(|e| format!("Failed to reorder connection {id}: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = conn.execute_batch("ROLLBACK");
|
||||
} else {
|
||||
conn.execute_batch("COMMIT")
|
||||
.map_err(|e| format!("Failed to commit reorder transaction: {e}"))?;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Batch-update sort_order for a list of group IDs.
|
||||
pub fn reorder_groups(&self, ids: &[i64]) -> Result<(), String> {
|
||||
let conn = self.db.conn();
|
||||
conn.execute_batch("BEGIN")
|
||||
.map_err(|e| format!("Failed to begin reorder transaction: {e}"))?;
|
||||
let result = (|| {
|
||||
for (i, id) in ids.iter().enumerate() {
|
||||
conn.execute(
|
||||
"UPDATE groups SET sort_order = ?1 WHERE id = ?2",
|
||||
@ -469,14 +455,6 @@ impl ConnectionService {
|
||||
.map_err(|e| format!("Failed to reorder group {id}: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = conn.execute_batch("ROLLBACK");
|
||||
} else {
|
||||
conn.execute_batch("COMMIT")
|
||||
.map_err(|e| format!("Failed to commit reorder transaction: {e}"))?;
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -224,7 +224,7 @@ pub fn run() {
|
||||
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::disconnect_session, 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_force_refresh, commands::rdp_commands::rdp_send_mouse, commands::rdp_commands::rdp_send_key, commands::rdp_commands::rdp_send_clipboard, commands::rdp_commands::rdp_resize, commands::rdp_commands::disconnect_rdp, commands::rdp_commands::list_rdp_sessions,
|
||||
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::rdp_send_clipboard, commands::rdp_commands::disconnect_rdp, commands::rdp_commands::list_rdp_sessions,
|
||||
commands::theme_commands::list_themes, commands::theme_commands::get_theme,
|
||||
commands::pty_commands::list_available_shells, commands::pty_commands::spawn_local_shell, commands::pty_commands::pty_write, commands::pty_commands::pty_resize, commands::pty_commands::disconnect_pty,
|
||||
commands::mcp_commands::mcp_list_sessions, commands::mcp_commands::mcp_terminal_read, commands::mcp_commands::mcp_terminal_execute, commands::mcp_commands::mcp_get_session_context, commands::mcp_commands::mcp_bridge_path,
|
||||
@ -234,7 +234,6 @@ pub fn run() {
|
||||
commands::updater::check_for_updates,
|
||||
commands::workspace_commands::save_workspace, commands::workspace_commands::load_workspace,
|
||||
commands::docker_commands::docker_list_containers, commands::docker_commands::docker_list_images, commands::docker_commands::docker_list_volumes, commands::docker_commands::docker_action,
|
||||
commands::window_commands::open_child_window,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@ -36,7 +36,7 @@ impl ScrollbackRegistry {
|
||||
|
||||
/// Get the scrollback buffer for a session.
|
||||
pub fn get(&self, session_id: &str) -> Option<Arc<ScrollbackBuffer>> {
|
||||
self.buffers.get(session_id).map(|r| r.value().clone())
|
||||
self.buffers.get(session_id).map(|entry| entry.clone())
|
||||
}
|
||||
|
||||
/// Remove a session's scrollback buffer.
|
||||
|
||||
@ -19,7 +19,6 @@ use tokio::net::TcpListener;
|
||||
use crate::mcp::ScrollbackRegistry;
|
||||
use crate::rdp::RdpService;
|
||||
use crate::sftp::SftpService;
|
||||
use crate::ssh::exec::exec_on_session;
|
||||
use crate::ssh::session::SshService;
|
||||
use crate::utils::shell_escape;
|
||||
|
||||
@ -187,7 +186,7 @@ async fn handle_screenshot(
|
||||
AxumState(state): AxumState<Arc<McpServerState>>,
|
||||
Json(req): Json<ScreenshotRequest>,
|
||||
) -> Json<McpResponse<String>> {
|
||||
match state.rdp.screenshot_png_base64(&req.session_id) {
|
||||
match state.rdp.screenshot_png_base64(&req.session_id).await {
|
||||
Ok(b64) => ok_response(b64),
|
||||
Err(e) => err_response(e),
|
||||
}
|
||||
@ -309,32 +308,32 @@ struct ToolPassgenRequest { length: Option<usize>, uppercase: Option<bool>, lowe
|
||||
|
||||
async fn handle_tool_ping(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionTarget>) -> Json<McpResponse<String>> {
|
||||
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
||||
match exec_on_session(&session.handle, &format!("ping -c 4 {} 2>&1", shell_escape(&req.target))).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
||||
match tool_exec(&session.handle, &format!("ping -c 4 {} 2>&1", shell_escape(&req.target))).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
||||
}
|
||||
|
||||
async fn handle_tool_traceroute(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionTarget>) -> Json<McpResponse<String>> {
|
||||
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
||||
let t = shell_escape(&req.target);
|
||||
match exec_on_session(&session.handle, &format!("traceroute {} 2>&1 || tracert {} 2>&1", t, t)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
||||
match tool_exec(&session.handle, &format!("traceroute {} 2>&1 || tracert {} 2>&1", t, t)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
||||
}
|
||||
|
||||
async fn handle_tool_dns(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolDnsRequest>) -> Json<McpResponse<String>> {
|
||||
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
||||
let rt = shell_escape(&req.record_type.unwrap_or_else(|| "A".to_string()));
|
||||
let d = shell_escape(&req.domain);
|
||||
match exec_on_session(&session.handle, &format!("dig {} {} +short 2>/dev/null || nslookup -type={} {} 2>/dev/null || host -t {} {} 2>/dev/null", d, rt, rt, d, rt, d)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
||||
match tool_exec(&session.handle, &format!("dig {} {} +short 2>/dev/null || nslookup -type={} {} 2>/dev/null || host -t {} {} 2>/dev/null", d, rt, rt, d, rt, d)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
||||
}
|
||||
|
||||
async fn handle_tool_whois(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionTarget>) -> Json<McpResponse<String>> {
|
||||
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
||||
match exec_on_session(&session.handle, &format!("whois {} 2>&1 | head -80", shell_escape(&req.target))).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
||||
match tool_exec(&session.handle, &format!("whois {} 2>&1 | head -80", shell_escape(&req.target))).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
||||
}
|
||||
|
||||
async fn handle_tool_wol(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolWolRequest>) -> Json<McpResponse<String>> {
|
||||
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
||||
let mac_clean = req.mac_address.replace([':', '-'], "");
|
||||
let cmd = format!(r#"python3 -c "import socket;mac=bytes.fromhex({});pkt=b'\xff'*6+mac*16;s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM);s.setsockopt(socket.SOL_SOCKET,socket.SO_BROADCAST,1);s.sendto(pkt,('255.255.255.255',9));s.close();print('WoL sent to {}')" 2>&1"#, shell_escape(&mac_clean), shell_escape(&req.mac_address));
|
||||
match exec_on_session(&session.handle, &cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
||||
match tool_exec(&session.handle, &cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
||||
}
|
||||
|
||||
async fn handle_tool_scan_network(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolScanNetworkRequest>) -> Json<McpResponse<serde_json::Value>> {
|
||||
@ -365,7 +364,7 @@ async fn handle_tool_subnet(_state: AxumState<Arc<McpServerState>>, Json(req): J
|
||||
async fn handle_tool_bandwidth(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionOnly>) -> Json<McpResponse<String>> {
|
||||
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
||||
let cmd = r#"if command -v speedtest-cli >/dev/null 2>&1; then speedtest-cli --simple 2>&1; elif command -v curl >/dev/null 2>&1; then curl -o /dev/null -w "Download: %{speed_download} bytes/sec\n" https://speed.cloudflare.com/__down?bytes=25000000 2>/dev/null; else echo "No speedtest tool found"; fi"#;
|
||||
match exec_on_session(&session.handle, cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
||||
match tool_exec(&session.handle, cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
||||
}
|
||||
|
||||
async fn handle_tool_keygen(_state: AxumState<Arc<McpServerState>>, Json(req): Json<ToolKeygenRequest>) -> Json<McpResponse<serde_json::Value>> {
|
||||
@ -382,6 +381,20 @@ async fn handle_tool_passgen(_state: AxumState<Arc<McpServerState>>, Json(req):
|
||||
}
|
||||
}
|
||||
|
||||
async fn tool_exec(handle: &std::sync::Arc<tokio::sync::Mutex<russh::client::Handle<crate::ssh::session::SshClient>>>, cmd: &str) -> Result<String, String> {
|
||||
let mut channel = { let h = handle.lock().await; h.channel_open_session().await.map_err(|e| format!("Exec failed: {}", e))? };
|
||||
channel.exec(true, cmd).await.map_err(|e| format!("Exec failed: {}", e))?;
|
||||
let mut output = String::new();
|
||||
loop {
|
||||
match channel.wait().await {
|
||||
Some(russh::ChannelMsg::Data { ref data }) => { if let Ok(t) = std::str::from_utf8(data.as_ref()) { output.push_str(t); } }
|
||||
Some(russh::ChannelMsg::Eof) | Some(russh::ChannelMsg::Close) | None => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
// ── Docker handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@ -395,7 +408,7 @@ struct DockerExecRequest { session_id: String, container: String, command: Strin
|
||||
|
||||
async fn handle_docker_ps(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<DockerListRequest>) -> Json<McpResponse<String>> {
|
||||
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
||||
match exec_on_session(&session.handle, "docker ps -a --format '{{.Names}}|{{.Image}}|{{.Status}}|{{.Ports}}' 2>&1").await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
||||
match tool_exec(&session.handle, "docker ps -a --format '{{.Names}}|{{.Image}}|{{.Status}}|{{.Ports}}' 2>&1").await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
||||
}
|
||||
|
||||
async fn handle_docker_action(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<DockerActionRequest>) -> Json<McpResponse<String>> {
|
||||
@ -411,13 +424,13 @@ async fn handle_docker_action(AxumState(state): AxumState<Arc<McpServerState>>,
|
||||
"system-prune" => "docker system prune -f 2>&1".to_string(),
|
||||
_ => return err_response(format!("Unknown action: {}", req.action)),
|
||||
};
|
||||
match exec_on_session(&session.handle, &cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
||||
match tool_exec(&session.handle, &cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
||||
}
|
||||
|
||||
async fn handle_docker_exec(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<DockerExecRequest>) -> Json<McpResponse<String>> {
|
||||
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
||||
let cmd = format!("docker exec {} {} 2>&1", shell_escape(&req.container), shell_escape(&req.command));
|
||||
match exec_on_session(&session.handle, &cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
||||
match tool_exec(&session.handle, &cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
||||
}
|
||||
|
||||
// ── Service/process handlers ─────────────────────────────────────────────────
|
||||
@ -425,13 +438,13 @@ async fn handle_docker_exec(AxumState(state): AxumState<Arc<McpServerState>>, Js
|
||||
async fn handle_service_status(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionTarget>) -> Json<McpResponse<String>> {
|
||||
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
||||
let t = shell_escape(&req.target);
|
||||
match exec_on_session(&session.handle, &format!("systemctl status {} --no-pager 2>&1 || service {} status 2>&1", t, t)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
||||
match tool_exec(&session.handle, &format!("systemctl status {} --no-pager 2>&1 || service {} status 2>&1", t, t)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
||||
}
|
||||
|
||||
async fn handle_process_list(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionTarget>) -> Json<McpResponse<String>> {
|
||||
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
||||
let filter = if req.target.is_empty() { "aux --sort=-%cpu | head -30".to_string() } else { format!("aux | grep -i {} | grep -v grep", shell_escape(&req.target)) };
|
||||
match exec_on_session(&session.handle, &format!("ps {}", filter)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
||||
match tool_exec(&session.handle, &format!("ps {}", filter)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
||||
}
|
||||
|
||||
// ── Git handlers ─────────────────────────────────────────────────────────────
|
||||
@ -441,17 +454,17 @@ struct GitRequest { session_id: String, path: String }
|
||||
|
||||
async fn handle_git_status(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<GitRequest>) -> Json<McpResponse<String>> {
|
||||
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
||||
match exec_on_session(&session.handle, &format!("cd {} && git status --short --branch 2>&1", shell_escape(&req.path))).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
||||
match tool_exec(&session.handle, &format!("cd {} && git status --short --branch 2>&1", shell_escape(&req.path))).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
||||
}
|
||||
|
||||
async fn handle_git_pull(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<GitRequest>) -> Json<McpResponse<String>> {
|
||||
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
||||
match exec_on_session(&session.handle, &format!("cd {} && git pull 2>&1", shell_escape(&req.path))).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
||||
match tool_exec(&session.handle, &format!("cd {} && git pull 2>&1", shell_escape(&req.path))).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
||||
}
|
||||
|
||||
async fn handle_git_log(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<GitRequest>) -> Json<McpResponse<String>> {
|
||||
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
||||
match exec_on_session(&session.handle, &format!("cd {} && git log --oneline -20 2>&1", shell_escape(&req.path))).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
||||
match tool_exec(&session.handle, &format!("cd {} && git log --oneline -20 2>&1", shell_escape(&req.path))).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
||||
}
|
||||
|
||||
// ── Session creation handlers ────────────────────────────────────────────────
|
||||
|
||||
@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
|
||||
use ironrdp::connector::{self, ClientConnector, ConnectionResult, Credentials, DesktopSize};
|
||||
use ironrdp::graphics::image_processing::PixelFormat;
|
||||
@ -63,29 +63,15 @@ enum InputEvent {
|
||||
pressed: bool,
|
||||
},
|
||||
Clipboard(String),
|
||||
Resize { width: u16, height: u16 },
|
||||
Disconnect,
|
||||
}
|
||||
|
||||
/// Dirty rectangle from the last GraphicsUpdate — used for partial frame transfer.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DirtyRect {
|
||||
pub x: u16,
|
||||
pub y: u16,
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
}
|
||||
|
||||
struct RdpSessionHandle {
|
||||
id: String,
|
||||
hostname: String,
|
||||
width: u16,
|
||||
height: u16,
|
||||
/// Frame buffer: RDP thread writes via RwLock write, IPC reads via RwLock read.
|
||||
front_buffer: Arc<std::sync::RwLock<Vec<u8>>>,
|
||||
/// Accumulated dirty region since last get_frame — union of all GraphicsUpdate rects.
|
||||
dirty_region: Arc<std::sync::Mutex<Option<DirtyRect>>>,
|
||||
frame_buffer: Arc<TokioMutex<Vec<u8>>>,
|
||||
frame_dirty: Arc<AtomicBool>,
|
||||
input_tx: mpsc::UnboundedSender<InputEvent>,
|
||||
}
|
||||
@ -113,8 +99,7 @@ impl RdpService {
|
||||
for pixel in initial_buf.chunks_exact_mut(4) {
|
||||
pixel[3] = 255;
|
||||
}
|
||||
let front_buffer = Arc::new(std::sync::RwLock::new(initial_buf));
|
||||
let dirty_region = Arc::new(std::sync::Mutex::new(None));
|
||||
let frame_buffer = Arc::new(TokioMutex::new(initial_buf));
|
||||
let frame_dirty = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let (input_tx, input_rx) = mpsc::unbounded_channel();
|
||||
@ -124,8 +109,7 @@ impl RdpService {
|
||||
hostname: hostname.clone(),
|
||||
width,
|
||||
height,
|
||||
front_buffer: front_buffer.clone(),
|
||||
dirty_region: dirty_region.clone(),
|
||||
frame_buffer: frame_buffer.clone(),
|
||||
frame_dirty: frame_dirty.clone(),
|
||||
input_tx,
|
||||
});
|
||||
@ -172,8 +156,7 @@ impl RdpService {
|
||||
if let Err(e) = run_active_session(
|
||||
connection_result,
|
||||
framed,
|
||||
front_buffer,
|
||||
dirty_region,
|
||||
frame_buffer,
|
||||
frame_dirty,
|
||||
input_rx,
|
||||
width as u16,
|
||||
@ -217,57 +200,27 @@ impl RdpService {
|
||||
Ok(session_id)
|
||||
}
|
||||
|
||||
/// Get the dirty region since the last call. Returns (region_metadata, pixel_bytes).
|
||||
/// The pixel bytes contain only the dirty rectangle in row-major RGBA order.
|
||||
/// If nothing changed, returns empty bytes. If the dirty region covers >50% of the
|
||||
/// frame, falls back to full frame for efficiency (avoids row-by-row extraction).
|
||||
pub fn get_frame(&self, session_id: &str) -> Result<(Option<DirtyRect>, Vec<u8>), String> {
|
||||
pub async fn get_frame(&self, session_id: &str) -> Result<Vec<u8>, String> {
|
||||
let handle = self.sessions.get(session_id).ok_or_else(|| format!("RDP session {} not found", session_id))?;
|
||||
if !handle.frame_dirty.swap(false, Ordering::Acquire) {
|
||||
return Ok((None, Vec::new()));
|
||||
if !handle.frame_dirty.swap(false, Ordering::Relaxed) {
|
||||
return Ok(Vec::new()); // No change — return empty
|
||||
}
|
||||
let buf = handle.frame_buffer.lock().await;
|
||||
Ok(buf.clone())
|
||||
}
|
||||
|
||||
let region = handle.dirty_region.lock().unwrap_or_else(|e| e.into_inner()).take();
|
||||
let buf = handle.front_buffer.read().unwrap_or_else(|e| e.into_inner());
|
||||
let stride = handle.width as usize * 4;
|
||||
let total_pixels = handle.width as usize * handle.height as usize;
|
||||
|
||||
match region {
|
||||
Some(rect) if (rect.width as usize * rect.height as usize) < total_pixels / 2 => {
|
||||
// Partial: extract only the dirty rectangle
|
||||
let rw = rect.width as usize;
|
||||
let rh = rect.height as usize;
|
||||
let rx = rect.x as usize;
|
||||
let ry = rect.y as usize;
|
||||
let mut out = Vec::with_capacity(rw * rh * 4);
|
||||
for row in ry..ry + rh {
|
||||
let start = row * stride + rx * 4;
|
||||
let end = start + rw * 4;
|
||||
if end <= buf.len() {
|
||||
out.extend_from_slice(&buf[start..end]);
|
||||
}
|
||||
}
|
||||
Ok((Some(rect), out))
|
||||
}
|
||||
_ => {
|
||||
// Full frame: dirty region covers most of the screen or is missing
|
||||
Ok((None, buf.clone()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub 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.sessions.get(session_id).ok_or_else(|| format!("RDP session {} not found", session_id))?;
|
||||
let buf = handle.front_buffer.read().unwrap_or_else(|e| e.into_inner());
|
||||
let buf = handle.frame_buffer.lock().await;
|
||||
Ok(buf.clone())
|
||||
}
|
||||
|
||||
/// Capture the current RDP frame as a base64-encoded PNG.
|
||||
pub fn screenshot_png_base64(&self, session_id: &str) -> Result<String, String> {
|
||||
pub async fn screenshot_png_base64(&self, session_id: &str) -> Result<String, String> {
|
||||
let handle = self.sessions.get(session_id).ok_or_else(|| format!("RDP session {} not found", session_id))?;
|
||||
let width = handle.width as u32;
|
||||
let height = handle.height as u32;
|
||||
let buf = handle.front_buffer.read().unwrap_or_else(|e| e.into_inner());
|
||||
let buf = handle.frame_buffer.lock().await;
|
||||
|
||||
// Encode RGBA raw bytes to PNG (fast compression for speed)
|
||||
let mut png_data = Vec::new();
|
||||
@ -300,19 +253,6 @@ impl RdpService {
|
||||
handle.input_tx.send(InputEvent::Key { scancode, pressed }).map_err(|_| format!("RDP session {} input channel closed", session_id))
|
||||
}
|
||||
|
||||
pub fn force_refresh(&self, session_id: &str) -> Result<(), String> {
|
||||
let handle = self.sessions.get(session_id).ok_or_else(|| format!("RDP session {} not found", session_id))?;
|
||||
// Clear any accumulated dirty region so get_frame returns the full buffer
|
||||
*handle.dirty_region.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
handle.frame_dirty.store(true, Ordering::Release);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn resize(&self, session_id: &str, width: u16, height: u16) -> Result<(), String> {
|
||||
let handle = self.sessions.get(session_id).ok_or_else(|| format!("RDP session {} not found", session_id))?;
|
||||
handle.input_tx.send(InputEvent::Resize { width, height }).map_err(|_| format!("RDP session {} input channel closed", session_id))
|
||||
}
|
||||
|
||||
pub fn disconnect(&self, session_id: &str) -> Result<(), String> {
|
||||
let handle = self.sessions.get(session_id).ok_or_else(|| format!("RDP session {} not found", session_id))?;
|
||||
let _ = handle.input_tx.send(InputEvent::Disconnect);
|
||||
@ -366,11 +306,7 @@ fn build_connector_config(config: &RdpConfig) -> Result<connector::Config, Strin
|
||||
request_data: None,
|
||||
autologon: false,
|
||||
enable_audio_playback: false,
|
||||
performance_flags: PerformanceFlags::DISABLE_WALLPAPER
|
||||
| PerformanceFlags::DISABLE_MENUANIMATIONS
|
||||
| PerformanceFlags::DISABLE_CURSOR_SHADOW
|
||||
| PerformanceFlags::ENABLE_FONT_SMOOTHING
|
||||
| PerformanceFlags::ENABLE_DESKTOP_COMPOSITION,
|
||||
performance_flags: PerformanceFlags::default(),
|
||||
desktop_scale_factor: 0,
|
||||
hardware_id: None,
|
||||
license_cache: None,
|
||||
@ -400,7 +336,7 @@ async fn establish_connection(config: connector::Config, hostname: &str, port: u
|
||||
Ok((connection_result, upgraded_framed))
|
||||
}
|
||||
|
||||
async fn run_active_session(connection_result: ConnectionResult, framed: UpgradedFramed, front_buffer: Arc<std::sync::RwLock<Vec<u8>>>, dirty_region: Arc<std::sync::Mutex<Option<DirtyRect>>>, frame_dirty: Arc<AtomicBool>, mut input_rx: mpsc::UnboundedReceiver<InputEvent>, mut width: u16, mut height: u16, app_handle: tauri::AppHandle, session_id: String) -> Result<(), String> {
|
||||
async fn run_active_session(connection_result: ConnectionResult, framed: UpgradedFramed, frame_buffer: Arc<TokioMutex<Vec<u8>>>, frame_dirty: Arc<AtomicBool>, mut input_rx: mpsc::UnboundedReceiver<InputEvent>, width: u16, height: u16, app_handle: tauri::AppHandle, session_id: String) -> Result<(), String> {
|
||||
let (mut reader, mut writer) = split_tokio_framed(framed);
|
||||
let mut image = DecodedImage::new(PixelFormat::RgbA32, width, height);
|
||||
let mut active_stage = ActiveStage::new(connection_result);
|
||||
@ -452,67 +388,18 @@ async fn run_active_session(connection_result: ConnectionResult, framed: Upgrade
|
||||
}
|
||||
all_outputs
|
||||
}
|
||||
Some(InputEvent::Resize { width: new_w, height: new_h }) => {
|
||||
// Ensure dimensions are within RDP spec (200-8192, even width)
|
||||
let w = (new_w.max(200).min(8192) & !1) as u32;
|
||||
let h = new_h.max(200).min(8192) as u32;
|
||||
if let Some(Ok(resize_frame)) = active_stage.encode_resize(w, h, None, None) {
|
||||
writer.write_all(&resize_frame).await.map_err(|e| format!("Failed to send resize: {}", e))?;
|
||||
// Reallocate image and front buffer for new dimensions
|
||||
image = DecodedImage::new(PixelFormat::RgbA32, w as u16, h as u16);
|
||||
let buf_size = w as usize * h as usize * 4;
|
||||
let mut new_buf = vec![0u8; buf_size];
|
||||
for pixel in new_buf.chunks_exact_mut(4) { pixel[3] = 255; }
|
||||
*front_buffer.write().unwrap_or_else(|e| e.into_inner()) = new_buf;
|
||||
width = w as u16;
|
||||
height = h as u16;
|
||||
info!("RDP session {} resized to {}x{}", session_id, width, height);
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
for out in outputs {
|
||||
match out {
|
||||
ActiveStageOutput::ResponseFrame(frame) => { writer.write_all(&frame).await.map_err(|e| format!("Failed to write RDP response frame: {}", e))?; }
|
||||
ActiveStageOutput::GraphicsUpdate(region) => {
|
||||
let rx = region.left as usize;
|
||||
let ry = region.top as usize;
|
||||
let rr = (region.right as usize).saturating_add(1).min(width as usize);
|
||||
let rb = (region.bottom as usize).saturating_add(1).min(height as usize);
|
||||
let stride = width as usize * 4;
|
||||
|
||||
// Copy only the dirty rectangle rows from decoded image → front buffer
|
||||
{
|
||||
ActiveStageOutput::GraphicsUpdate(_region) => {
|
||||
let mut buf = frame_buffer.lock().await;
|
||||
let src = image.data();
|
||||
let mut front = front_buffer.write().unwrap_or_else(|e| e.into_inner());
|
||||
for row in ry..rb {
|
||||
let src_start = row * stride + rx * 4;
|
||||
let src_end = row * stride + rr * 4;
|
||||
if src_end <= src.len() && src_end <= front.len() {
|
||||
front[src_start..src_end].copy_from_slice(&src[src_start..src_end]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Accumulate dirty region (union of all rects since last get_frame)
|
||||
{
|
||||
let new_rect = DirtyRect { x: rx as u16, y: ry as u16, width: (rr - rx) as u16, height: (rb - ry) as u16 };
|
||||
let mut dr = dirty_region.lock().unwrap_or_else(|e| e.into_inner());
|
||||
*dr = Some(match dr.take() {
|
||||
None => new_rect,
|
||||
Some(prev) => {
|
||||
let x = prev.x.min(new_rect.x);
|
||||
let y = prev.y.min(new_rect.y);
|
||||
let r = (prev.x + prev.width).max(new_rect.x + new_rect.width);
|
||||
let b = (prev.y + prev.height).max(new_rect.y + new_rect.height);
|
||||
DirtyRect { x, y, width: r - x, height: b - y }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
frame_dirty.store(true, Ordering::Release);
|
||||
if src.len() == buf.len() { buf.copy_from_slice(src); } else { *buf = src.to_vec(); }
|
||||
frame_dirty.store(true, Ordering::Relaxed);
|
||||
// Push frame notification to frontend — no data, just a signal to fetch
|
||||
let _ = app_handle.emit(&format!("rdp:frame:{}", session_id), ());
|
||||
}
|
||||
ActiveStageOutput::Terminate(reason) => { info!("RDP session terminated: {:?}", reason); return Ok(()); }
|
||||
|
||||
@ -64,36 +64,11 @@ fn service_name(port: u16) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate that `subnet` contains exactly three dot-separated octet groups,
|
||||
/// each consisting only of 1–3 ASCII digits (e.g. "192.168.1").
|
||||
/// Returns an error string if the format is invalid.
|
||||
fn validate_subnet(subnet: &str) -> Result<(), String> {
|
||||
let parts: Vec<&str> = subnet.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err(format!(
|
||||
"Invalid subnet '{}': expected three octets (e.g. 192.168.1)",
|
||||
subnet
|
||||
));
|
||||
}
|
||||
for part in &parts {
|
||||
if part.is_empty() || part.len() > 3 || !part.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Err(format!(
|
||||
"Invalid subnet '{}': each octet must be 1–3 decimal digits",
|
||||
subnet
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Discover hosts on the remote network using ARP table and ping sweep.
|
||||
pub async fn scan_network(
|
||||
handle: &Arc<TokioMutex<Handle<SshClient>>>,
|
||||
subnet: &str,
|
||||
) -> Result<Vec<DiscoveredHost>, String> {
|
||||
// Validate subnet format before using it in remote shell commands.
|
||||
validate_subnet(subnet)?;
|
||||
|
||||
// Script that works on Linux and macOS:
|
||||
// 1. Ping sweep the subnet to populate ARP cache
|
||||
// 2. Read ARP table for IP/MAC pairs
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
//! provides all file operations needed by the frontend.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
use dashmap::DashMap;
|
||||
use log::{debug, info};
|
||||
@ -34,6 +35,9 @@ pub struct FileEntry {
|
||||
|
||||
/// Format a Unix timestamp (seconds since epoch) as "Mon DD HH:MM".
|
||||
fn format_mtime(unix_secs: u32) -> String {
|
||||
// Build a SystemTime from the raw epoch value.
|
||||
let st = UNIX_EPOCH + Duration::from_secs(unix_secs as u64);
|
||||
|
||||
// Convert to seconds-since-epoch for manual formatting. We avoid pulling
|
||||
// in chrono just for this; a simple manual decomposition is sufficient for
|
||||
// the "Mar 17 14:30" display format expected by the frontend.
|
||||
@ -50,10 +54,12 @@ fn format_mtime(unix_secs: u32) -> String {
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let _y = if m <= 2 { y + 1 } else { y };
|
||||
|
||||
let month = match m {
|
||||
1 => "Jan",
|
||||
@ -71,6 +77,9 @@ fn format_mtime(unix_secs: u32) -> String {
|
||||
_ => "???",
|
||||
};
|
||||
|
||||
// Suppress unused variable warning — st is only used as a sanity anchor.
|
||||
let _ = st;
|
||||
|
||||
format!("{} {:2} {:02}:{:02}", month, d, hours, minutes)
|
||||
}
|
||||
|
||||
@ -310,7 +319,7 @@ impl SftpService {
|
||||
) -> Result<Arc<TokioMutex<SftpSession>>, String> {
|
||||
self.clients
|
||||
.get(session_id)
|
||||
.map(|r| r.value().clone())
|
||||
.map(|r| r.clone())
|
||||
.ok_or_else(|| format!("No SFTP client for session {}", session_id))
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,51 +0,0 @@
|
||||
//! Shared SSH exec-channel helper used by commands, MCP handlers, and tools.
|
||||
//!
|
||||
//! Opens a one-shot exec channel on an existing SSH handle, runs `cmd`, collects
|
||||
//! all stdout/stderr, and returns it as a `String`. The caller is responsible
|
||||
//! for ensuring the session is still alive.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
|
||||
use crate::ssh::session::SshClient;
|
||||
|
||||
/// Execute `cmd` on a separate exec channel and return all output as a `String`.
|
||||
///
|
||||
/// Locks the handle for only as long as it takes to open the channel, then
|
||||
/// releases it before reading — this avoids holding the lock while waiting on
|
||||
/// remote I/O.
|
||||
pub async fn exec_on_session(
|
||||
handle: &Arc<TokioMutex<russh::client::Handle<SshClient>>>,
|
||||
cmd: &str,
|
||||
) -> Result<String, String> {
|
||||
let mut channel = {
|
||||
let h = handle.lock().await;
|
||||
h.channel_open_session()
|
||||
.await
|
||||
.map_err(|e| format!("Exec channel failed: {}", e))?
|
||||
};
|
||||
|
||||
channel
|
||||
.exec(true, cmd)
|
||||
.await
|
||||
.map_err(|e| format!("Exec failed: {}", e))?;
|
||||
|
||||
let mut output = String::new();
|
||||
loop {
|
||||
match channel.wait().await {
|
||||
Some(russh::ChannelMsg::Data { ref data }) => {
|
||||
if let Ok(text) = std::str::from_utf8(data.as_ref()) {
|
||||
output.push_str(text);
|
||||
}
|
||||
}
|
||||
Some(russh::ChannelMsg::Eof)
|
||||
| Some(russh::ChannelMsg::Close)
|
||||
| None => break,
|
||||
Some(russh::ChannelMsg::ExitStatus { .. }) => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
@ -2,4 +2,3 @@ pub mod session;
|
||||
pub mod host_key;
|
||||
pub mod cwd;
|
||||
pub mod monitor;
|
||||
pub mod exec;
|
||||
|
||||
@ -258,7 +258,7 @@ impl SshService {
|
||||
}
|
||||
|
||||
pub fn get_session(&self, session_id: &str) -> Option<Arc<SshSession>> {
|
||||
self.sessions.get(session_id).map(|r| r.value().clone())
|
||||
self.sessions.get(session_id).map(|entry| entry.clone())
|
||||
}
|
||||
|
||||
pub fn list_sessions(&self) -> Vec<SessionInfo> {
|
||||
@ -405,23 +405,22 @@ fn extract_osc7_cwd(data: &[u8]) -> Option<String> {
|
||||
}
|
||||
|
||||
fn percent_decode(input: &str) -> String {
|
||||
let mut bytes: Vec<u8> = Vec::with_capacity(input.len());
|
||||
let mut output = String::with_capacity(input.len());
|
||||
let mut chars = input.chars();
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch == '%' {
|
||||
let hex: String = chars.by_ref().take(2).collect();
|
||||
if let Ok(byte) = u8::from_str_radix(&hex, 16) {
|
||||
bytes.push(byte);
|
||||
output.push(byte as char);
|
||||
} else {
|
||||
bytes.extend_from_slice(b"%");
|
||||
bytes.extend_from_slice(hex.as_bytes());
|
||||
output.push('%');
|
||||
output.push_str(&hex);
|
||||
}
|
||||
} else {
|
||||
let mut buf = [0u8; 4];
|
||||
bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
|
||||
output.push(ch);
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
output
|
||||
}
|
||||
|
||||
/// Resolve a private key string — if it looks like PEM content, return as-is.
|
||||
|
||||
@ -59,7 +59,6 @@ struct BuiltinTheme {
|
||||
|
||||
// ── service ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ThemeService {
|
||||
db: Database,
|
||||
}
|
||||
@ -254,7 +253,7 @@ impl ThemeService {
|
||||
t.bright_blue, t.bright_magenta, t.bright_cyan, t.bright_white,
|
||||
],
|
||||
) {
|
||||
wraith_log!("theme::seed_builtins: failed to seed '{}': {}", t.name, e);
|
||||
eprintln!("theme::seed_builtins: failed to seed '{}': {}", t.name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -273,7 +272,7 @@ impl ThemeService {
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
wraith_log!("theme::list: failed to prepare query: {}", e);
|
||||
eprintln!("theme::list: failed to prepare query: {}", e);
|
||||
return vec![];
|
||||
}
|
||||
};
|
||||
@ -281,12 +280,12 @@ impl ThemeService {
|
||||
match stmt.query_map([], map_theme_row) {
|
||||
Ok(rows) => rows
|
||||
.filter_map(|r| {
|
||||
r.map_err(|e| wraith_log!("theme::list: row error: {}", e))
|
||||
r.map_err(|e| eprintln!("theme::list: row error: {}", e))
|
||||
.ok()
|
||||
})
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
wraith_log!("theme::list: query failed: {}", e);
|
||||
eprintln!("theme::list: query failed: {}", e);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,7 +24,6 @@ pub struct WorkspaceSnapshot {
|
||||
const SNAPSHOT_KEY: &str = "workspace_snapshot";
|
||||
const CLEAN_SHUTDOWN_KEY: &str = "clean_shutdown";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WorkspaceService {
|
||||
settings: SettingsService,
|
||||
}
|
||||
@ -48,7 +47,7 @@ impl WorkspaceService {
|
||||
pub fn load(&self) -> Option<WorkspaceSnapshot> {
|
||||
let json = self.settings.get(SNAPSHOT_KEY)?;
|
||||
serde_json::from_str(&json)
|
||||
.map_err(|e| wraith_log!("workspace::load: failed to deserialize snapshot: {e}"))
|
||||
.map_err(|e| eprintln!("workspace::load: failed to deserialize snapshot: {e}"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
|
||||
@ -18,12 +18,11 @@
|
||||
"minHeight": 600,
|
||||
"decorations": true,
|
||||
"resizable": true,
|
||||
"dragDropEnabled": false,
|
||||
"additionalBrowserArgs": "--enable-gpu-rasterization --enable-zero-copy --disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection"
|
||||
"dragDropEnabled": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: https://asset.localhost data:; connect-src 'self' ipc: http://ipc.localhost"
|
||||
},
|
||||
"withGlobalTauri": false
|
||||
},
|
||||
|
||||
55
src/App.vue
55
src/App.vue
@ -1,65 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onErrorCaptured, defineAsyncComponent } from "vue";
|
||||
import { ref, onMounted, defineAsyncComponent } from "vue";
|
||||
import { useAppStore } from "@/stores/app.store";
|
||||
import UnlockLayout from "@/layouts/UnlockLayout.vue";
|
||||
import ToolWindow from "@/components/tools/ToolWindow.vue";
|
||||
|
||||
const MainLayout = defineAsyncComponent({
|
||||
loader: () => import("@/layouts/MainLayout.vue"),
|
||||
onError(error) { console.error("[App] MainLayout load failed:", error); },
|
||||
});
|
||||
const DetachedSession = defineAsyncComponent({
|
||||
loader: () => import("@/components/session/DetachedSession.vue"),
|
||||
onError(error) { console.error("[App] DetachedSession load failed:", error); },
|
||||
});
|
||||
const MainLayout = defineAsyncComponent(
|
||||
() => import("@/layouts/MainLayout.vue")
|
||||
);
|
||||
const ToolWindow = defineAsyncComponent(
|
||||
() => import("@/components/tools/ToolWindow.vue")
|
||||
);
|
||||
const DetachedSession = defineAsyncComponent(
|
||||
() => import("@/components/session/DetachedSession.vue")
|
||||
);
|
||||
|
||||
const app = useAppStore();
|
||||
const appError = ref<string | null>(null);
|
||||
|
||||
// Tool window mode — detected from URL hash: #/tool/network-scanner?sessionId=abc
|
||||
const isToolMode = ref(false);
|
||||
const isDetachedMode = ref(false);
|
||||
const toolName = ref("");
|
||||
const toolSessionId = ref("");
|
||||
|
||||
onErrorCaptured((err) => {
|
||||
appError.value = err instanceof Error ? err.message : String(err);
|
||||
console.error("[App] Uncaught error:", err);
|
||||
return false;
|
||||
});
|
||||
|
||||
/** Parse hash and set mode flags. Called on mount and on hashchange. */
|
||||
function applyHash(hash: string): void {
|
||||
onMounted(async () => {
|
||||
const hash = window.location.hash;
|
||||
if (hash.startsWith("#/tool/")) {
|
||||
isToolMode.value = true;
|
||||
const rest = hash.substring(7);
|
||||
const rest = hash.substring(7); // after "#/tool/"
|
||||
const [name, query] = rest.split("?");
|
||||
toolName.value = name;
|
||||
toolSessionId.value = new URLSearchParams(query || "").get("sessionId") || "";
|
||||
} else if (hash.startsWith("#/detached-session")) {
|
||||
isDetachedMode.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Check hash at load time (present if JS-side WebviewWindow set it in the URL)
|
||||
applyHash(window.location.hash);
|
||||
|
||||
// Also listen for hash changes (Rust-side window sets hash via eval after load)
|
||||
window.addEventListener("hashchange", () => applyHash(window.location.hash));
|
||||
|
||||
// Only init vault for the main app window (no hash)
|
||||
if (!isToolMode.value && !isDetachedMode.value) {
|
||||
} else {
|
||||
await app.checkVaultState();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="appError" class="fixed inset-0 z-50 flex items-center justify-center bg-[#0d1117] text-red-400 p-8 text-sm font-mono whitespace-pre-wrap">
|
||||
{{ appError }}
|
||||
</div>
|
||||
<DetachedSession v-else-if="isDetachedMode" />
|
||||
<!-- Detached session window mode -->
|
||||
<DetachedSession v-if="isDetachedMode" />
|
||||
<!-- Tool popup window mode -->
|
||||
<ToolWindow v-else-if="isToolMode" :tool="toolName" :session-id="toolSessionId" />
|
||||
<!-- Normal app mode -->
|
||||
<div v-else class="app-root">
|
||||
<UnlockLayout v-if="!app.isUnlocked" />
|
||||
<MainLayout v-else />
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
.terminal-container {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--wraith-bg-primary);
|
||||
@ -20,16 +20,14 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* WKWebView focus fix: xterm.js hides its helper textarea with opacity: 0,
|
||||
width/height: 0, left: -9999em. macOS WKWebView doesn't reliably focus
|
||||
elements with zero dimensions positioned off-screen. Override to keep it
|
||||
within the viewport with non-zero dimensions so focus events fire. */
|
||||
.terminal-container .xterm .xterm-helper-textarea {
|
||||
left: 0 !important;
|
||||
top: 0 !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
opacity: 0.01 !important;
|
||||
/* Selection styling */
|
||||
.terminal-container .xterm-selection div {
|
||||
background-color: rgba(88, 166, 255, 0.3) !important;
|
||||
}
|
||||
|
||||
/* Cursor styling */
|
||||
.terminal-container .xterm-cursor-layer {
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
/* Scrollbar inside terminal */
|
||||
|
||||
@ -116,9 +116,9 @@ const connectionStore = useConnectionStore();
|
||||
const sessionStore = useSessionStore();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"open-import": [];
|
||||
"open-settings": [];
|
||||
"open-new-connection": [protocol?: "ssh" | "rdp"];
|
||||
(e: "open-import"): void;
|
||||
(e: "open-settings"): void;
|
||||
(e: "open-new-connection", protocol?: "ssh" | "rdp"): void;
|
||||
}>();
|
||||
|
||||
const actions: PaletteAction[] = [
|
||||
|
||||
@ -422,16 +422,9 @@ watch(
|
||||
() => settings.value.defaultProtocol,
|
||||
(val) => invoke("set_setting", { key: "default_protocol", value: val }).catch(console.error),
|
||||
);
|
||||
let sidebarWidthDebounce: ReturnType<typeof setTimeout>;
|
||||
watch(
|
||||
() => settings.value.sidebarWidth,
|
||||
(val) => {
|
||||
clearTimeout(sidebarWidthDebounce);
|
||||
sidebarWidthDebounce = setTimeout(
|
||||
() => invoke("set_setting", { key: "sidebar_width", value: String(val) }).catch(console.error),
|
||||
300,
|
||||
);
|
||||
},
|
||||
(val) => invoke("set_setting", { key: "sidebar_width", value: String(val) }).catch(console.error),
|
||||
);
|
||||
watch(
|
||||
() => settings.value.terminalTheme,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="h-[48px] flex items-center justify-between px-6 bg-[var(--wraith-bg-secondary)] border-t border-[var(--wraith-border)] text-base text-[var(--wraith-text-muted)] shrink-0">
|
||||
<div class="h-6 flex items-center justify-between px-4 bg-[var(--wraith-bg-secondary)] border-t border-[var(--wraith-border)] text-[10px] text-[var(--wraith-text-muted)] shrink-0">
|
||||
<!-- Left: connection info -->
|
||||
<div class="flex items-center gap-3">
|
||||
<template v-if="sessionStore.activeSession">
|
||||
@ -47,7 +47,7 @@ const connectionStore = useConnectionStore();
|
||||
const activeThemeName = ref("Default");
|
||||
|
||||
const emit = defineEmits<{
|
||||
"open-theme-picker": [];
|
||||
(e: "open-theme-picker"): void;
|
||||
}>();
|
||||
|
||||
const connectionInfo = computed(() => {
|
||||
|
||||
@ -112,8 +112,6 @@ export interface ThemeDefinition {
|
||||
brightMagenta: string;
|
||||
brightCyan: string;
|
||||
brightWhite: string;
|
||||
selectionBackground?: string;
|
||||
selectionForeground?: string;
|
||||
isBuiltin?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@ -28,8 +28,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from "vue";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from "vue";
|
||||
import { useRdp, MouseFlag } from "@/composables/useRdp";
|
||||
|
||||
const props = defineProps<{
|
||||
@ -43,8 +42,8 @@ const containerRef = ref<HTMLElement | null>(null);
|
||||
const canvasWrapper = ref<HTMLElement | null>(null);
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
|
||||
const rdpWidth = computed(() => props.width ?? 1920);
|
||||
const rdpHeight = computed(() => props.height ?? 1080);
|
||||
const rdpWidth = props.width ?? 1920;
|
||||
const rdpHeight = props.height ?? 1080;
|
||||
|
||||
const {
|
||||
connected,
|
||||
@ -77,8 +76,8 @@ function toRdpCoords(e: MouseEvent): { x: number; y: number } | null {
|
||||
if (!canvas) return null;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
const scaleX = rdpWidth / rect.width;
|
||||
const scaleY = rdpHeight / rect.height;
|
||||
|
||||
return {
|
||||
x: Math.floor((e.clientX - rect.left) * scaleX),
|
||||
@ -154,95 +153,25 @@ function handleKeyUp(e: KeyboardEvent): void {
|
||||
sendKey(props.sessionId, e.code, false);
|
||||
}
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
if (canvasRef.value) {
|
||||
startFrameLoop(props.sessionId, canvasRef.value, rdpWidth.value, rdpHeight.value);
|
||||
}
|
||||
|
||||
// Watch container size and request server-side RDP resize (debounced 500ms)
|
||||
if (canvasWrapper.value) {
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (!entry || !connected.value) return;
|
||||
const { width: cw, height: ch } = entry.contentRect;
|
||||
if (cw < 200 || ch < 200) return;
|
||||
|
||||
// Round to even width (RDP spec requirement)
|
||||
const newW = Math.round(cw) & ~1;
|
||||
const newH = Math.round(ch);
|
||||
|
||||
if (resizeTimeout) clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(() => {
|
||||
invoke("rdp_resize", {
|
||||
sessionId: props.sessionId,
|
||||
width: newW,
|
||||
height: newH,
|
||||
}).then(() => {
|
||||
if (canvasRef.value) {
|
||||
canvasRef.value.width = newW;
|
||||
canvasRef.value.height = newH;
|
||||
}
|
||||
// Force full frame after resize so canvas gets a clean repaint
|
||||
setTimeout(() => {
|
||||
invoke("rdp_force_refresh", { sessionId: props.sessionId }).catch(() => {});
|
||||
}, 200);
|
||||
}).catch((err: unknown) => {
|
||||
console.warn("[RdpView] resize failed:", err);
|
||||
});
|
||||
}, 500);
|
||||
});
|
||||
resizeObserver.observe(canvasWrapper.value);
|
||||
startFrameLoop(props.sessionId, canvasRef.value, rdpWidth, rdpHeight);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopFrameLoop();
|
||||
if (resizeObserver) { resizeObserver.disconnect(); resizeObserver = null; }
|
||||
if (resizeTimeout) { clearTimeout(resizeTimeout); resizeTimeout = null; }
|
||||
});
|
||||
|
||||
// Focus canvas, re-check dimensions, and force full frame on tab switch.
|
||||
// Uses 300ms delay to let the flex layout fully settle (copilot panel toggle, etc.)
|
||||
// Focus canvas when this tab becomes active and keyboard is grabbed
|
||||
watch(
|
||||
() => props.isActive,
|
||||
(active) => {
|
||||
if (!active || !canvasRef.value) return;
|
||||
|
||||
// Immediate focus so keyboard works right away
|
||||
if (keyboardGrabbed.value) canvasRef.value.focus();
|
||||
|
||||
// Immediate force refresh to show SOMETHING while we check dimensions
|
||||
invoke("rdp_force_refresh", { sessionId: props.sessionId }).catch(() => {});
|
||||
|
||||
// Delayed dimension check — layout needs time to settle
|
||||
if (active && keyboardGrabbed.value && canvasRef.value) {
|
||||
setTimeout(() => {
|
||||
const wrapper = canvasWrapper.value;
|
||||
const canvas = canvasRef.value;
|
||||
if (!wrapper || !canvas) return;
|
||||
|
||||
const { width: cw, height: ch } = wrapper.getBoundingClientRect();
|
||||
const newW = Math.round(cw) & ~1;
|
||||
const newH = Math.round(ch);
|
||||
|
||||
if (newW >= 200 && newH >= 200 && (newW !== canvas.width || newH !== canvas.height)) {
|
||||
invoke("rdp_resize", {
|
||||
sessionId: props.sessionId,
|
||||
width: newW,
|
||||
height: newH,
|
||||
}).then(() => {
|
||||
if (canvas) {
|
||||
canvas.width = newW;
|
||||
canvas.height = newH;
|
||||
canvasRef.value?.focus();
|
||||
}, 0);
|
||||
}
|
||||
setTimeout(() => {
|
||||
invoke("rdp_force_refresh", { sessionId: props.sessionId }).catch(() => {});
|
||||
}, 500);
|
||||
}).catch(() => {});
|
||||
}
|
||||
}, 300);
|
||||
},
|
||||
);
|
||||
</script>
|
||||
@ -267,8 +196,9 @@ watch(
|
||||
}
|
||||
|
||||
.rdp-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
cursor: default;
|
||||
outline: none;
|
||||
image-rendering: auto;
|
||||
|
||||
@ -133,14 +133,16 @@ async function detachTab(): Promise<void> {
|
||||
session.active = false;
|
||||
|
||||
// Open a new Tauri window for this session
|
||||
try {
|
||||
await invoke("open_child_window", {
|
||||
label: `detached-${session.id.substring(0, 8)}-${Date.now()}`,
|
||||
const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow");
|
||||
const label = `detached-${session.id.substring(0, 8)}-${Date.now()}`;
|
||||
new WebviewWindow(label, {
|
||||
title: `${session.name} — Wraith`,
|
||||
width: 900,
|
||||
height: 600,
|
||||
resizable: true,
|
||||
center: true,
|
||||
url: `index.html#/detached-session?sessionId=${session.id}&name=${encodeURIComponent(session.name)}&protocol=${session.protocol}`,
|
||||
width: 900, height: 600,
|
||||
});
|
||||
} catch (err) { console.error("Detach window error:", err); }
|
||||
}
|
||||
|
||||
function closeMenuTab(): void {
|
||||
|
||||
@ -371,31 +371,6 @@ function handleFileSelected(event: Event): void {
|
||||
failTransfer(transferId);
|
||||
};
|
||||
|
||||
// Guard: the backend sftp_write_file command accepts a UTF-8 string only.
|
||||
// Binary files (images, archives, executables, etc.) will be corrupted if
|
||||
// sent as text. Warn and abort for known binary extensions or large files.
|
||||
const BINARY_EXTENSIONS = new Set([
|
||||
"png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "tiff", "svg",
|
||||
"zip", "tar", "gz", "bz2", "xz", "7z", "rar", "zst",
|
||||
"exe", "dll", "so", "dylib", "bin", "elf",
|
||||
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx",
|
||||
"mp3", "mp4", "avi", "mkv", "mov", "flac", "wav", "ogg",
|
||||
"ttf", "otf", "woff", "woff2",
|
||||
"db", "sqlite", "sqlite3",
|
||||
]);
|
||||
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
|
||||
const isBinary = BINARY_EXTENSIONS.has(ext);
|
||||
const isLarge = file.size > 1 * 1024 * 1024; // 1 MB
|
||||
|
||||
if (isBinary || isLarge) {
|
||||
const reason = isBinary
|
||||
? `"${ext}" files are binary and cannot be safely uploaded as text`
|
||||
: `file is ${(file.size / (1024 * 1024)).toFixed(1)} MB — only text files under 1 MB are supported`;
|
||||
alert(`Upload blocked: ${reason}.\n\nBinary file upload support will be added in a future release.`);
|
||||
failTransfer(transferId);
|
||||
return;
|
||||
}
|
||||
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
|
||||
@ -52,15 +52,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { ref } from "vue";
|
||||
import { useTransfers } from "@/composables/useTransfers";
|
||||
|
||||
const expanded = ref(false);
|
||||
const { transfers } = useTransfers();
|
||||
|
||||
// Auto-expand when transfers become active, collapse when all are gone
|
||||
watch(() => transfers.value.length, (newLen, oldLen) => {
|
||||
if (newLen > 0 && oldLen === 0) expanded.value = true;
|
||||
if (newLen === 0) expanded.value = false;
|
||||
});
|
||||
const { transfers } = useTransfers();
|
||||
</script>
|
||||
|
||||
@ -110,7 +110,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { ref } from "vue";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useConnectionStore, type Connection, type Group } from "@/stores/connection.store";
|
||||
import { useSessionStore } from "@/stores/session.store";
|
||||
@ -224,15 +224,6 @@ const expandedGroups = ref<Set<number>>(
|
||||
new Set(connectionStore.groups.map((g) => g.id)),
|
||||
);
|
||||
|
||||
// Auto-expand groups added after initial load
|
||||
watch(() => connectionStore.groups, (newGroups) => {
|
||||
for (const group of newGroups) {
|
||||
if (!expandedGroups.value.has(group.id)) {
|
||||
expandedGroups.value.add(group.id);
|
||||
}
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
function toggleGroup(groupId: number): void {
|
||||
if (expandedGroups.value.has(groupId)) {
|
||||
expandedGroups.value.delete(groupId);
|
||||
|
||||
@ -5,11 +5,11 @@
|
||||
:key="tab.id"
|
||||
class="flex-1 py-2 text-xs font-medium text-center transition-colors cursor-pointer"
|
||||
:class="
|
||||
model === tab.id
|
||||
modelValue === tab.id
|
||||
? 'text-[var(--wraith-accent-blue)] border-b-2 border-[var(--wraith-accent-blue)]'
|
||||
: 'text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-secondary)]'
|
||||
"
|
||||
@click="model = tab.id"
|
||||
@click="emit('update:modelValue', tab.id)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
@ -24,5 +24,11 @@ const tabs = [
|
||||
{ id: "sftp" as const, label: "SFTP" },
|
||||
];
|
||||
|
||||
const model = defineModel<SidebarTab>();
|
||||
defineProps<{
|
||||
modelValue: SidebarTab;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [tab: SidebarTab];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
@ -12,7 +12,6 @@
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from "vue";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useTerminal } from "@/composables/useTerminal";
|
||||
import { useSessionStore } from "@/stores/session.store";
|
||||
import "@/assets/css/terminal.css";
|
||||
|
||||
const props = defineProps<{
|
||||
@ -20,57 +19,13 @@ const props = defineProps<{
|
||||
isActive: boolean;
|
||||
}>();
|
||||
|
||||
const sessionStore = useSessionStore();
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
const { terminal, mount, fit, destroy } = useTerminal(props.sessionId, "pty");
|
||||
|
||||
/** Apply the session store's active theme to this local terminal instance. */
|
||||
function applyTheme(): void {
|
||||
const theme = sessionStore.activeTheme;
|
||||
if (!theme) return;
|
||||
terminal.options.theme = {
|
||||
background: theme.background,
|
||||
foreground: theme.foreground,
|
||||
cursor: theme.cursor,
|
||||
cursorAccent: theme.background,
|
||||
selectionBackground: theme.selectionBackground ?? "#264f78",
|
||||
selectionForeground: theme.selectionForeground ?? "#ffffff",
|
||||
selectionInactiveBackground: theme.selectionBackground ?? "#264f78",
|
||||
black: theme.black,
|
||||
red: theme.red,
|
||||
green: theme.green,
|
||||
yellow: theme.yellow,
|
||||
blue: theme.blue,
|
||||
magenta: theme.magenta,
|
||||
cyan: theme.cyan,
|
||||
white: theme.white,
|
||||
brightBlack: theme.brightBlack,
|
||||
brightRed: theme.brightRed,
|
||||
brightGreen: theme.brightGreen,
|
||||
brightYellow: theme.brightYellow,
|
||||
brightBlue: theme.brightBlue,
|
||||
brightMagenta: theme.brightMagenta,
|
||||
brightCyan: theme.brightCyan,
|
||||
brightWhite: theme.brightWhite,
|
||||
};
|
||||
|
||||
if (containerRef.value) {
|
||||
containerRef.value.style.backgroundColor = theme.background;
|
||||
}
|
||||
|
||||
terminal.refresh(0, terminal.rows - 1);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (containerRef.value) {
|
||||
mount(containerRef.value);
|
||||
}
|
||||
|
||||
// Apply current theme immediately if one is already active
|
||||
if (sessionStore.activeTheme) {
|
||||
applyTheme();
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
fit();
|
||||
terminal.focus();
|
||||
@ -101,11 +56,6 @@ watch(
|
||||
},
|
||||
);
|
||||
|
||||
// Watch for theme changes and apply to this local terminal
|
||||
watch(() => sessionStore.activeTheme, (newTheme) => {
|
||||
if (newTheme) applyTheme();
|
||||
}, { deep: true });
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
destroy();
|
||||
});
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="stats"
|
||||
class="flex items-center gap-4 px-6 h-[48px] bg-[var(--wraith-bg-tertiary)] border-t border-[var(--wraith-border)] text-base font-mono shrink-0 select-none"
|
||||
class="flex items-center gap-4 px-3 h-6 bg-[var(--wraith-bg-tertiary)] border-t border-[var(--wraith-border)] text-[10px] font-mono shrink-0 select-none"
|
||||
>
|
||||
<!-- CPU -->
|
||||
<span class="flex items-center gap-1">
|
||||
@ -55,7 +55,6 @@ interface SystemStats {
|
||||
|
||||
const stats = ref<SystemStats | null>(null);
|
||||
let unlistenFn: UnlistenFn | null = null;
|
||||
let subscribeGeneration = 0;
|
||||
|
||||
function colorClass(value: number, warnThreshold: number, critThreshold: number): string {
|
||||
if (value >= critThreshold) return "text-[#f85149]"; // red
|
||||
@ -71,17 +70,10 @@ function formatBytes(bytes: number): string {
|
||||
}
|
||||
|
||||
async function subscribe(): Promise<void> {
|
||||
const gen = ++subscribeGeneration;
|
||||
if (unlistenFn) unlistenFn();
|
||||
const fn = await listen<SystemStats>(`ssh:monitor:${props.sessionId}`, (event) => {
|
||||
unlistenFn = await listen<SystemStats>(`ssh:monitor:${props.sessionId}`, (event) => {
|
||||
stats.value = event.payload;
|
||||
});
|
||||
if (gen !== subscribeGeneration) {
|
||||
// A newer subscribe() call has already taken over — discard this listener
|
||||
fn();
|
||||
return;
|
||||
}
|
||||
unlistenFn = fn;
|
||||
}
|
||||
|
||||
onMounted(subscribe);
|
||||
|
||||
@ -77,10 +77,6 @@ const containerRef = ref<HTMLElement | null>(null);
|
||||
const { terminal, searchAddon, mount, fit } = useTerminal(props.sessionId);
|
||||
let resizeDisposable: IDisposable | null = null;
|
||||
|
||||
function handleFocus(): void {
|
||||
terminal.focus();
|
||||
}
|
||||
|
||||
// --- Search state ---
|
||||
const searchVisible = ref(false);
|
||||
const searchQuery = ref("");
|
||||
@ -189,10 +185,6 @@ function applyTheme(): void {
|
||||
background: theme.background,
|
||||
foreground: theme.foreground,
|
||||
cursor: theme.cursor,
|
||||
cursorAccent: theme.background,
|
||||
selectionBackground: theme.selectionBackground ?? "#264f78",
|
||||
selectionForeground: theme.selectionForeground ?? "#ffffff",
|
||||
selectionInactiveBackground: theme.selectionBackground ?? "#264f78",
|
||||
black: theme.black,
|
||||
red: theme.red,
|
||||
green: theme.green,
|
||||
@ -210,22 +202,12 @@ function applyTheme(): void {
|
||||
brightCyan: theme.brightCyan,
|
||||
brightWhite: theme.brightWhite,
|
||||
};
|
||||
|
||||
// Sync the container background so areas outside the canvas match the theme
|
||||
if (containerRef.value) {
|
||||
containerRef.value.style.backgroundColor = theme.background;
|
||||
}
|
||||
|
||||
// Force xterm.js to repaint all visible rows with the new theme colors
|
||||
terminal.refresh(0, terminal.rows - 1);
|
||||
}
|
||||
|
||||
// Watch for theme changes in the session store and apply to this terminal.
|
||||
// Uses deep comparison because the theme is an object — a shallow watch may miss
|
||||
// updates if Pinia returns the same reactive proxy wrapper after reassignment.
|
||||
// Watch for theme changes in the session store and apply to this terminal
|
||||
watch(() => sessionStore.activeTheme, (newTheme) => {
|
||||
if (newTheme) applyTheme();
|
||||
}, { deep: true });
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (resizeDisposable) {
|
||||
@ -233,4 +215,8 @@ onBeforeUnmount(() => {
|
||||
resizeDisposable = null;
|
||||
}
|
||||
});
|
||||
|
||||
function handleFocus(): void {
|
||||
terminal.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<ToolShell ref="shell" placeholder="Select a mode and click Run Test">
|
||||
<template #default="{ running }">
|
||||
<div class="flex flex-col h-full p-4 gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<select v-model="mode" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none cursor-pointer">
|
||||
<option value="speedtest">Internet Speed Test</option>
|
||||
<option value="iperf">iperf3 (LAN)</option>
|
||||
@ -13,31 +13,32 @@
|
||||
<button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer disabled:opacity-40" :disabled="running" @click="run">
|
||||
{{ running ? "Testing..." : "Run Test" }}
|
||||
</button>
|
||||
</template>
|
||||
</ToolShell>
|
||||
</div>
|
||||
<pre class="flex-1 overflow-auto bg-[#161b22] border border-[#30363d] rounded p-3 text-xs font-mono whitespace-pre-wrap text-[#e0e0e0]">{{ output || "Select a mode and click Run Test" }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ToolShell from "./ToolShell.vue";
|
||||
|
||||
const props = defineProps<{ sessionId: string }>();
|
||||
const mode = ref("speedtest");
|
||||
const server = ref("");
|
||||
const duration = ref(5);
|
||||
const shell = ref<InstanceType<typeof ToolShell> | null>(null);
|
||||
const output = ref("");
|
||||
const running = ref(false);
|
||||
|
||||
async function run(): Promise<void> {
|
||||
if (mode.value === "iperf" && !server.value) {
|
||||
shell.value?.setOutput("Enter an iperf3 server IP");
|
||||
return;
|
||||
}
|
||||
shell.value?.execute(() => {
|
||||
running.value = true;
|
||||
output.value = mode.value === "iperf" ? `Running iperf3 to ${server.value}...\n` : "Running speed test...\n";
|
||||
try {
|
||||
if (mode.value === "iperf") {
|
||||
return invoke<string>("tool_bandwidth_iperf", { sessionId: props.sessionId, server: server.value, duration: duration.value });
|
||||
if (!server.value) { output.value = "Enter an iperf3 server IP"; running.value = false; return; }
|
||||
output.value = await invoke<string>("tool_bandwidth_iperf", { sessionId: props.sessionId, server: server.value, duration: duration.value });
|
||||
} else {
|
||||
output.value = await invoke<string>("tool_bandwidth_speedtest", { sessionId: props.sessionId });
|
||||
}
|
||||
return invoke<string>("tool_bandwidth_speedtest", { sessionId: props.sessionId });
|
||||
});
|
||||
} catch (err) { output.value = String(err); }
|
||||
running.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -1,29 +1,31 @@
|
||||
<template>
|
||||
<ToolShell ref="shell" placeholder="Enter a domain and click Lookup">
|
||||
<template #default="{ running }">
|
||||
<div class="flex flex-col h-full p-4 gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<input v-model="domain" type="text" placeholder="Domain name" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] flex-1" @keydown.enter="lookup" />
|
||||
<select v-model="recordType" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none cursor-pointer">
|
||||
<option v-for="t in ['A','AAAA','MX','NS','TXT','CNAME','SOA','SRV','PTR']" :key="t" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
<button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer disabled:opacity-40" :disabled="running" @click="lookup">Lookup</button>
|
||||
</template>
|
||||
</ToolShell>
|
||||
</div>
|
||||
<pre class="flex-1 overflow-auto bg-[#161b22] border border-[#30363d] rounded p-3 text-xs font-mono whitespace-pre-wrap text-[#e0e0e0]">{{ output || "Enter a domain and click Lookup" }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ToolShell from "./ToolShell.vue";
|
||||
|
||||
const props = defineProps<{ sessionId: string }>();
|
||||
const domain = ref("");
|
||||
const recordType = ref("A");
|
||||
const shell = ref<InstanceType<typeof ToolShell> | null>(null);
|
||||
const output = ref("");
|
||||
const running = ref(false);
|
||||
|
||||
async function lookup(): Promise<void> {
|
||||
if (!domain.value) return;
|
||||
shell.value?.execute(() =>
|
||||
invoke<string>("tool_dns_lookup", { sessionId: props.sessionId, domain: domain.value, recordType: recordType.value })
|
||||
);
|
||||
running.value = true;
|
||||
try {
|
||||
output.value = await invoke<string>("tool_dns_lookup", { sessionId: props.sessionId, domain: domain.value, recordType: recordType.value });
|
||||
} catch (err) { output.value = String(err); }
|
||||
running.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -81,16 +81,12 @@
|
||||
import { ref, onMounted } from "vue";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
interface DockerContainer { id: string; name: string; image: string; status: string; ports: string; }
|
||||
interface DockerImage { repository: string; tag: string; id: string; size: string; }
|
||||
interface DockerVolume { name: string; driver: string; mountpoint: string; }
|
||||
|
||||
const props = defineProps<{ sessionId: string }>();
|
||||
|
||||
const tab = ref("containers");
|
||||
const containers = ref<DockerContainer[]>([]);
|
||||
const images = ref<DockerImage[]>([]);
|
||||
const volumes = ref<DockerVolume[]>([]);
|
||||
const containers = ref<any[]>([]);
|
||||
const images = ref<any[]>([]);
|
||||
const volumes = ref<any[]>([]);
|
||||
const output = ref("");
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
|
||||
@ -85,6 +85,5 @@ function exportCsv(): void {
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `wraith-scan-${subnet.value}-${Date.now()}.csv`;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -1,28 +1,32 @@
|
||||
<template>
|
||||
<ToolShell ref="shell" placeholder="Enter a host and click Ping">
|
||||
<template #default="{ running }">
|
||||
<div class="flex flex-col h-full p-4 gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<input v-model="target" type="text" placeholder="Host to ping" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] flex-1" @keydown.enter="ping" />
|
||||
<input v-model.number="count" type="number" min="1" max="100" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] w-16" />
|
||||
<button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer disabled:opacity-40" :disabled="running" @click="ping">Ping</button>
|
||||
</template>
|
||||
</ToolShell>
|
||||
</div>
|
||||
<pre class="flex-1 overflow-auto bg-[#161b22] border border-[#30363d] rounded p-3 text-xs font-mono whitespace-pre-wrap text-[#e0e0e0]">{{ output || "Enter a host and click Ping" }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ToolShell from "./ToolShell.vue";
|
||||
|
||||
const props = defineProps<{ sessionId: string }>();
|
||||
const target = ref("");
|
||||
const count = ref(4);
|
||||
const shell = ref<InstanceType<typeof ToolShell> | null>(null);
|
||||
const output = ref("");
|
||||
const running = ref(false);
|
||||
|
||||
async function ping(): Promise<void> {
|
||||
if (!target.value) return;
|
||||
shell.value?.execute(async () => {
|
||||
running.value = true;
|
||||
output.value = `Pinging ${target.value}...\n`;
|
||||
try {
|
||||
const result = await invoke<{ target: string; output: string }>("tool_ping", { sessionId: props.sessionId, target: target.value, count: count.value });
|
||||
return result.output;
|
||||
});
|
||||
output.value = result.output;
|
||||
} catch (err) { output.value = String(err); }
|
||||
running.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -1,37 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
|
||||
defineProps<{
|
||||
placeholder?: string;
|
||||
}>();
|
||||
|
||||
const output = ref("");
|
||||
const running = ref(false);
|
||||
|
||||
async function execute(fn: () => Promise<string>): Promise<void> {
|
||||
running.value = true;
|
||||
output.value = "";
|
||||
try {
|
||||
output.value = await fn();
|
||||
} catch (err: unknown) {
|
||||
output.value = `Error: ${err instanceof Error ? err.message : String(err)}`;
|
||||
} finally {
|
||||
running.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setOutput(value: string): void {
|
||||
output.value = value;
|
||||
}
|
||||
|
||||
defineExpose({ execute, setOutput, output, running });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full p-4 gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<slot :running="running" />
|
||||
</div>
|
||||
<pre class="flex-1 overflow-auto bg-[#161b22] border border-[#30363d] rounded p-3 text-xs font-mono whitespace-pre-wrap text-[#e0e0e0]">{{ output || placeholder || "Ready." }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
@ -1,25 +1,29 @@
|
||||
<template>
|
||||
<ToolShell ref="shell" placeholder="Enter a host and click Trace">
|
||||
<template #default="{ running }">
|
||||
<div class="flex flex-col h-full p-4 gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<input v-model="target" type="text" placeholder="Host to trace" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] flex-1" @keydown.enter="trace" />
|
||||
<button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer disabled:opacity-40" :disabled="running" @click="trace">Trace</button>
|
||||
</template>
|
||||
</ToolShell>
|
||||
</div>
|
||||
<pre class="flex-1 overflow-auto bg-[#161b22] border border-[#30363d] rounded p-3 text-xs font-mono whitespace-pre-wrap text-[#e0e0e0]">{{ output || "Enter a host and click Trace" }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ToolShell from "./ToolShell.vue";
|
||||
|
||||
const props = defineProps<{ sessionId: string }>();
|
||||
const target = ref("");
|
||||
const shell = ref<InstanceType<typeof ToolShell> | null>(null);
|
||||
const output = ref("");
|
||||
const running = ref(false);
|
||||
|
||||
async function trace(): Promise<void> {
|
||||
if (!target.value) return;
|
||||
shell.value?.execute(() =>
|
||||
invoke<string>("tool_traceroute", { sessionId: props.sessionId, target: target.value })
|
||||
);
|
||||
running.value = true;
|
||||
output.value = `Tracing route to ${target.value}...\n`;
|
||||
try {
|
||||
output.value = await invoke<string>("tool_traceroute", { sessionId: props.sessionId, target: target.value });
|
||||
} catch (err) { output.value = String(err); }
|
||||
running.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -1,25 +1,26 @@
|
||||
<template>
|
||||
<ToolShell ref="shell" placeholder="Enter a domain or IP and click Whois">
|
||||
<template #default="{ running }">
|
||||
<div class="flex flex-col h-full p-4 gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<input v-model="target" type="text" placeholder="Domain or IP" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] flex-1" @keydown.enter="lookup" />
|
||||
<button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer disabled:opacity-40" :disabled="running" @click="lookup">Whois</button>
|
||||
</template>
|
||||
</ToolShell>
|
||||
</div>
|
||||
<pre class="flex-1 overflow-auto bg-[#161b22] border border-[#30363d] rounded p-3 text-xs font-mono whitespace-pre-wrap text-[#e0e0e0]">{{ output || "Enter a domain or IP and click Whois" }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ToolShell from "./ToolShell.vue";
|
||||
|
||||
const props = defineProps<{ sessionId: string }>();
|
||||
const target = ref("");
|
||||
const shell = ref<InstanceType<typeof ToolShell> | null>(null);
|
||||
const output = ref("");
|
||||
const running = ref(false);
|
||||
|
||||
async function lookup(): Promise<void> {
|
||||
if (!target.value) return;
|
||||
shell.value?.execute(() =>
|
||||
invoke<string>("tool_whois", { sessionId: props.sessionId, target: target.value })
|
||||
);
|
||||
running.value = true;
|
||||
try { output.value = await invoke<string>("tool_whois", { sessionId: props.sessionId, target: target.value }); }
|
||||
catch (err) { output.value = String(err); }
|
||||
running.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -1,106 +0,0 @@
|
||||
import { onMounted, onBeforeUnmount } from "vue";
|
||||
import type { Ref } from "vue";
|
||||
import type { useSessionStore } from "@/stores/session.store";
|
||||
|
||||
interface KeyboardShortcutActions {
|
||||
sessionStore: ReturnType<typeof useSessionStore>;
|
||||
sidebarVisible: Ref<boolean>;
|
||||
copilotVisible: Ref<boolean>;
|
||||
openCommandPalette: () => void;
|
||||
openActiveSearch: () => void;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts(actions: KeyboardShortcutActions): void {
|
||||
const { sessionStore, sidebarVisible, copilotVisible, openCommandPalette, openActiveSearch } = actions;
|
||||
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
const target = event.target as HTMLElement;
|
||||
const isInputFocused =
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.tagName === "SELECT";
|
||||
const ctrl = event.ctrlKey || event.metaKey;
|
||||
|
||||
// Ctrl+K — command palette (fires even when input is focused)
|
||||
if (ctrl && event.key === "k") {
|
||||
event.preventDefault();
|
||||
openCommandPalette();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInputFocused) return;
|
||||
|
||||
// Ctrl+W — close active tab
|
||||
if (ctrl && event.key === "w") {
|
||||
event.preventDefault();
|
||||
const active = sessionStore.activeSession;
|
||||
if (active) sessionStore.closeSession(active.id);
|
||||
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-9 — jump 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+Shift+G — toggle AI copilot
|
||||
if (ctrl && event.shiftKey && event.key.toLowerCase() === "g") {
|
||||
event.preventDefault();
|
||||
copilotVisible.value = !copilotVisible.value;
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+F — terminal search (SSH sessions only)
|
||||
if (ctrl && event.key === "f") {
|
||||
const active = sessionStore.activeSession;
|
||||
if (active?.protocol === "ssh") {
|
||||
event.preventDefault();
|
||||
openActiveSearch();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("keydown", handleKeydown);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("keydown", handleKeydown);
|
||||
});
|
||||
}
|
||||
@ -1,5 +1,4 @@
|
||||
import { ref, onBeforeUnmount } from "vue";
|
||||
import type { Ref } from "vue";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
/**
|
||||
@ -153,13 +152,13 @@ export function jsKeyToScancode(code: string): number | null {
|
||||
|
||||
export interface UseRdpReturn {
|
||||
/** Whether the RDP session is connected (first frame received) */
|
||||
connected: Ref<boolean>;
|
||||
connected: ReturnType<typeof ref<boolean>>;
|
||||
/** Whether keyboard capture is enabled */
|
||||
keyboardGrabbed: Ref<boolean>;
|
||||
keyboardGrabbed: ReturnType<typeof ref<boolean>>;
|
||||
/** Whether clipboard sync is enabled */
|
||||
clipboardSync: Ref<boolean>;
|
||||
/** Fetch and render the dirty region directly to a canvas context */
|
||||
fetchAndRender: (sessionId: string, width: number, height: number, ctx: CanvasRenderingContext2D) => Promise<boolean>;
|
||||
clipboardSync: ReturnType<typeof ref<boolean>>;
|
||||
/** Fetch the current frame as RGBA ImageData */
|
||||
fetchFrame: (sessionId: string, width: number, height: number) => Promise<ImageData | null>;
|
||||
/** Send a mouse event to the backend */
|
||||
sendMouse: (sessionId: string, x: number, y: number, flags: number) => void;
|
||||
/** Send a key event to the backend */
|
||||
@ -199,50 +198,38 @@ export function useRdp(): UseRdpReturn {
|
||||
let unlistenFrame: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* Fetch the dirty region from the Rust RDP backend and apply it to the canvas.
|
||||
* Fetch the current frame from the Rust RDP backend.
|
||||
*
|
||||
* Binary format from backend: 8-byte header + pixel data
|
||||
* Header: [x: u16, y: u16, w: u16, h: u16] (little-endian)
|
||||
* If header is all zeros → full frame (width*height*4 bytes)
|
||||
* If header is non-zero → dirty rectangle (w*h*4 bytes)
|
||||
*
|
||||
* Returns true if a frame was rendered, false if nothing changed.
|
||||
* rdp_get_frame returns raw RGBA bytes (width*height*4) serialised as a
|
||||
* base64 string over Tauri's IPC bridge. We decode it to Uint8ClampedArray
|
||||
* and wrap in an ImageData for putImageData().
|
||||
*/
|
||||
async function fetchAndRender(
|
||||
async function fetchFrame(
|
||||
sessionId: string,
|
||||
width: number,
|
||||
height: number,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
): Promise<boolean> {
|
||||
): Promise<ImageData | null> {
|
||||
let raw: ArrayBuffer;
|
||||
try {
|
||||
raw = await invoke<ArrayBuffer>("rdp_get_frame", { sessionId });
|
||||
} catch {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!raw || raw.byteLength <= 8) return false;
|
||||
if (!raw || raw.byteLength === 0) return null;
|
||||
|
||||
const view = new DataView(raw);
|
||||
const rx = view.getUint16(0, true);
|
||||
const ry = view.getUint16(2, true);
|
||||
const rw = view.getUint16(4, true);
|
||||
const rh = view.getUint16(6, true);
|
||||
const pixelData = new Uint8ClampedArray(raw, 8);
|
||||
// Binary IPC — tauri::ipc::Response delivers raw bytes as ArrayBuffer
|
||||
const bytes = new Uint8ClampedArray(raw);
|
||||
|
||||
if (rx === 0 && ry === 0 && rw === 0 && rh === 0) {
|
||||
// Full frame
|
||||
const expected = width * height * 4;
|
||||
if (pixelData.length !== expected) return false;
|
||||
ctx.putImageData(new ImageData(pixelData, width, height), 0, 0);
|
||||
} else {
|
||||
// Dirty rectangle — apply at offset
|
||||
const expected = rw * rh * 4;
|
||||
if (pixelData.length !== expected) return false;
|
||||
ctx.putImageData(new ImageData(pixelData, rw, rh), rx, ry);
|
||||
if (bytes.length !== expected) {
|
||||
console.warn(
|
||||
`[useRdp] Frame size mismatch: got ${bytes.length}, expected ${expected}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return true;
|
||||
return new ImageData(bytes, width, height);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -311,35 +298,30 @@ export function useRdp(): UseRdpReturn {
|
||||
canvas.height = height;
|
||||
|
||||
let fetchPending = false;
|
||||
let rafScheduled = false;
|
||||
|
||||
// Fetch and render dirty region when backend signals new frame data.
|
||||
// Uses rAF to coalesce rapid events into one fetch per display frame.
|
||||
function scheduleFrameFetch(): void {
|
||||
if (rafScheduled) return;
|
||||
rafScheduled = true;
|
||||
animFrameId = requestAnimationFrame(async () => {
|
||||
rafScheduled = false;
|
||||
if (fetchPending) return;
|
||||
// Fetch frame when backend signals a new frame is ready
|
||||
async function onFrameReady(): Promise<void> {
|
||||
if (fetchPending) return; // Don't stack fetches
|
||||
fetchPending = true;
|
||||
if (!ctx) return;
|
||||
const rendered = await fetchAndRender(sessionId, width, height, ctx);
|
||||
const imageData = await fetchFrame(sessionId, width, height);
|
||||
fetchPending = false;
|
||||
if (rendered && !connected.value) connected.value = true;
|
||||
});
|
||||
if (imageData && ctx) {
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
if (!connected.value) connected.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for frame events from the backend (push model)
|
||||
import("@tauri-apps/api/event").then(({ listen }) => {
|
||||
listen(`rdp:frame:${sessionId}`, () => {
|
||||
scheduleFrameFetch();
|
||||
onFrameReady();
|
||||
}).then((unlisten) => {
|
||||
unlistenFrame = unlisten;
|
||||
});
|
||||
});
|
||||
|
||||
// Initial poll in case frames arrived before listener was set up
|
||||
scheduleFrameFetch();
|
||||
// Also do an initial poll in case frames arrived before listener was set up
|
||||
onFrameReady();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -373,7 +355,7 @@ export function useRdp(): UseRdpReturn {
|
||||
connected,
|
||||
keyboardGrabbed,
|
||||
clipboardSync,
|
||||
fetchAndRender,
|
||||
fetchFrame,
|
||||
sendMouse,
|
||||
sendKey,
|
||||
sendClipboard,
|
||||
|
||||
@ -24,11 +24,6 @@ export interface UseSftpReturn {
|
||||
// Persist the last browsed path per session so switching tabs restores position
|
||||
const sessionPaths: Record<string, string> = {};
|
||||
|
||||
/** Remove a session's saved path from the module-level cache. Call on session close. */
|
||||
export function cleanupSession(sessionId: string): void {
|
||||
delete sessionPaths[sessionId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable that manages SFTP file browsing state.
|
||||
* Accepts a reactive session ID ref so it reinitializes on tab switch
|
||||
|
||||
@ -14,9 +14,8 @@ const defaultTheme = {
|
||||
foreground: "#e0e0e0",
|
||||
cursor: "#58a6ff",
|
||||
cursorAccent: "#0d1117",
|
||||
selectionBackground: "#264f78",
|
||||
selectionBackground: "rgba(88, 166, 255, 0.3)",
|
||||
selectionForeground: "#ffffff",
|
||||
selectionInactiveBackground: "#264f78",
|
||||
black: "#0d1117",
|
||||
red: "#f85149",
|
||||
green: "#3fb950",
|
||||
@ -156,7 +155,6 @@ export function useTerminal(sessionId: string, backend: 'ssh' | 'pty' = 'ssh'):
|
||||
// cell widths — producing tiny dashes and 200+ column terminals.
|
||||
document.fonts.ready.then(() => {
|
||||
fitAddon.fit();
|
||||
terminal.focus();
|
||||
});
|
||||
|
||||
// Right-click paste on the terminal's DOM element
|
||||
|
||||
@ -308,7 +308,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import { useKeyboardShortcuts } from "@/composables/useKeyboardShortcuts";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { useAppStore } from "@/stores/app.store";
|
||||
@ -367,14 +366,16 @@ function closeHelpMenuDeferred(): void {
|
||||
|
||||
async function handleHelpAction(page: string): Promise<void> {
|
||||
showHelpMenu.value = false;
|
||||
try {
|
||||
await invoke("open_child_window", {
|
||||
label: `help-${page}-${Date.now()}`,
|
||||
title: "Wraith — Help",
|
||||
const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow");
|
||||
const label = `help-${page}-${Date.now()}`;
|
||||
new WebviewWindow(label, {
|
||||
title: `Wraith — Help`,
|
||||
width: 750,
|
||||
height: 600,
|
||||
resizable: true,
|
||||
center: true,
|
||||
url: `index.html#/tool/help?page=${page}`,
|
||||
width: 750, height: 600,
|
||||
});
|
||||
} catch (err) { console.error("Help window error:", err); alert("Window error: " + String(err)); }
|
||||
}
|
||||
|
||||
async function handleToolAction(tool: string): Promise<void> {
|
||||
@ -388,6 +389,8 @@ async function handleToolAction(tool: string): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow");
|
||||
|
||||
const toolConfig: Record<string, { title: string; width: number; height: number }> = {
|
||||
"network-scanner": { title: "Network Scanner", width: 800, height: 600 },
|
||||
"port-scanner": { title: "Port Scanner", width: 700, height: 500 },
|
||||
@ -408,14 +411,16 @@ async function handleToolAction(tool: string): Promise<void> {
|
||||
|
||||
const sessionId = activeSessionId.value || "";
|
||||
|
||||
try {
|
||||
await invoke("open_child_window", {
|
||||
label: `tool-${tool}-${Date.now()}`,
|
||||
// Open tool in a new Tauri window
|
||||
const label = `tool-${tool}-${Date.now()}`;
|
||||
new WebviewWindow(label, {
|
||||
title: `Wraith — ${config.title}`,
|
||||
width: config.width,
|
||||
height: config.height,
|
||||
resizable: true,
|
||||
center: true,
|
||||
url: `index.html#/tool/${tool}?sessionId=${sessionId}`,
|
||||
width: config.width, height: config.height,
|
||||
});
|
||||
} catch (err) { console.error("Tool window error:", err); alert("Tool window error: " + String(err)); }
|
||||
}
|
||||
|
||||
async function handleFileMenuAction(action: string): Promise<void> {
|
||||
@ -435,13 +440,18 @@ function handleThemeSelect(theme: ThemeDefinition): void {
|
||||
async function handleOpenFile(entry: FileEntry): Promise<void> {
|
||||
if (!activeSessionId.value) return;
|
||||
try {
|
||||
const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow");
|
||||
const fileName = entry.path.split("/").pop() || entry.path;
|
||||
const label = `editor-${Date.now()}`;
|
||||
const sessionId = activeSessionId.value;
|
||||
await invoke("open_child_window", {
|
||||
label: `editor-${Date.now()}`,
|
||||
|
||||
new WebviewWindow(label, {
|
||||
title: `${fileName} — Wraith Editor`,
|
||||
width: 800,
|
||||
height: 600,
|
||||
resizable: true,
|
||||
center: true,
|
||||
url: `index.html#/tool/editor?sessionId=${sessionId}&path=${encodeURIComponent(entry.path)}`,
|
||||
width: 800, height: 600,
|
||||
});
|
||||
} catch (err) { console.error("Failed to open editor:", err); }
|
||||
}
|
||||
@ -469,13 +479,20 @@ async function handleQuickConnect(): Promise<void> {
|
||||
} catch (err) { console.error("Quick connect failed:", err); }
|
||||
}
|
||||
|
||||
useKeyboardShortcuts({
|
||||
sessionStore,
|
||||
sidebarVisible,
|
||||
copilotVisible,
|
||||
openCommandPalette: () => commandPalette.value?.toggle(),
|
||||
openActiveSearch: () => sessionContainer.value?.openActiveSearch(),
|
||||
});
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
const target = event.target as HTMLElement;
|
||||
const isInputFocused = target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT";
|
||||
const ctrl = event.ctrlKey || event.metaKey;
|
||||
if (ctrl && event.key === "k") { event.preventDefault(); commandPalette.value?.toggle(); return; }
|
||||
if (isInputFocused) return;
|
||||
if (ctrl && event.key === "w") { event.preventDefault(); const active = sessionStore.activeSession; if (active) sessionStore.closeSession(active.id); return; }
|
||||
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 === "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; }
|
||||
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; }
|
||||
if (ctrl && event.key === "b") { event.preventDefault(); sidebarVisible.value = !sidebarVisible.value; return; }
|
||||
if (ctrl && event.shiftKey && event.key.toLowerCase() === "g") { event.preventDefault(); copilotVisible.value = !copilotVisible.value; return; }
|
||||
if (ctrl && event.key === "f") { const active = sessionStore.activeSession; if (active?.protocol === "ssh") { event.preventDefault(); sessionContainer.value?.openActiveSearch(); } return; }
|
||||
}
|
||||
|
||||
let workspaceSaveInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
@ -486,24 +503,13 @@ function handleBeforeUnload(e: BeforeUnloadEvent): void {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener("keydown", handleKeydown);
|
||||
|
||||
// Confirm before closing if sessions are active (synchronous — won't hang)
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
|
||||
await connectionStore.loadAll();
|
||||
|
||||
// Restore saved theme so every terminal opens with the user's preferred colors
|
||||
try {
|
||||
const savedThemeName = await invoke<string | null>("get_setting", { key: "active_theme" });
|
||||
if (savedThemeName) {
|
||||
const themes = await invoke<Array<{ name: string; foreground: string; background: string; cursor: string; black: string; red: string; green: string; yellow: string; blue: string; magenta: string; cyan: string; white: string; brightBlack: string; brightRed: string; brightGreen: string; brightYellow: string; brightBlue: string; brightMagenta: string; brightCyan: string; brightWhite: string }>>("list_themes");
|
||||
const theme = themes?.find(t => t.name === savedThemeName);
|
||||
if (theme) {
|
||||
sessionStore.setTheme(theme);
|
||||
statusBar.value?.setThemeName(theme.name);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Restore workspace — reconnect saved tabs (non-blocking, non-fatal)
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
@ -540,6 +546,7 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("keydown", handleKeydown);
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
if (workspaceSaveInterval !== null) {
|
||||
clearInterval(workspaceSaveInterval);
|
||||
|
||||
@ -50,25 +50,68 @@ const displayError = computed(() => localError.value ?? app.error);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex items-center justify-center bg-[var(--wraith-bg-primary)]">
|
||||
<div class="w-full max-w-[400px] p-10 bg-[var(--wraith-bg-secondary)] border border-[var(--wraith-border)] rounded-xl shadow-[0_8px_32px_rgba(0,0,0,0.5)]">
|
||||
<div
|
||||
class="unlock-root"
|
||||
style="
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--wraith-bg-primary);
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="unlock-card"
|
||||
style="
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 2.5rem;
|
||||
background-color: var(--wraith-bg-secondary);
|
||||
border: 1px solid var(--wraith-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
"
|
||||
>
|
||||
<!-- Logo -->
|
||||
<div class="text-center mb-8">
|
||||
<span class="text-[2rem] font-extrabold tracking-[0.3em] text-[var(--wraith-accent-blue)] uppercase font-['Inter',monospace]">
|
||||
<div style="text-align: center; margin-bottom: 2rem">
|
||||
<span
|
||||
style="
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.3em;
|
||||
color: var(--wraith-accent-blue);
|
||||
text-transform: uppercase;
|
||||
font-family: 'Inter', monospace;
|
||||
"
|
||||
>
|
||||
WRAITH
|
||||
</span>
|
||||
<p class="mt-2 text-[0.8rem] text-[var(--wraith-text-muted)] tracking-[0.15em] uppercase">
|
||||
<p
|
||||
style="
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--wraith-text-muted);
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
"
|
||||
>
|
||||
{{ isFirstRun ? "Initialize Secure Vault" : "Secure Desktop" }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-4">
|
||||
<form @submit.prevent="handleSubmit" style="display: flex; flex-direction: column; gap: 1rem">
|
||||
<!-- Master password -->
|
||||
<div>
|
||||
<label
|
||||
for="master-password"
|
||||
class="block mb-[0.4rem] text-[0.8rem] text-[var(--wraith-text-secondary)] tracking-[0.05em]"
|
||||
style="
|
||||
display: block;
|
||||
margin-bottom: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--wraith-text-secondary);
|
||||
letter-spacing: 0.05em;
|
||||
"
|
||||
>
|
||||
MASTER PASSWORD
|
||||
</label>
|
||||
@ -79,7 +122,20 @@ const displayError = computed(() => localError.value ?? app.error);
|
||||
autocomplete="current-password"
|
||||
placeholder="Enter master password"
|
||||
:disabled="loading"
|
||||
class="w-full px-[0.9rem] py-[0.65rem] bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] rounded-[6px] text-[var(--wraith-text-primary)] text-[0.95rem] outline-none transition-colors duration-150 box-border focus:border-[var(--wraith-accent-blue)]"
|
||||
style="
|
||||
width: 100%;
|
||||
padding: 0.65rem 0.9rem;
|
||||
background-color: var(--wraith-bg-tertiary);
|
||||
border: 1px solid var(--wraith-border);
|
||||
border-radius: 6px;
|
||||
color: var(--wraith-text-primary);
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease;
|
||||
box-sizing: border-box;
|
||||
"
|
||||
@focus="($event.target as HTMLInputElement).style.borderColor = 'var(--wraith-accent-blue)'"
|
||||
@blur="($event.target as HTMLInputElement).style.borderColor = 'var(--wraith-border)'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -87,7 +143,13 @@ const displayError = computed(() => localError.value ?? app.error);
|
||||
<div v-if="isFirstRun">
|
||||
<label
|
||||
for="confirm-password"
|
||||
class="block mb-[0.4rem] text-[0.8rem] text-[var(--wraith-text-secondary)] tracking-[0.05em]"
|
||||
style="
|
||||
display: block;
|
||||
margin-bottom: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--wraith-text-secondary);
|
||||
letter-spacing: 0.05em;
|
||||
"
|
||||
>
|
||||
CONFIRM PASSWORD
|
||||
</label>
|
||||
@ -98,9 +160,28 @@ const displayError = computed(() => localError.value ?? app.error);
|
||||
autocomplete="new-password"
|
||||
placeholder="Confirm master password"
|
||||
:disabled="loading"
|
||||
class="w-full px-[0.9rem] py-[0.65rem] bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] rounded-[6px] text-[var(--wraith-text-primary)] text-[0.95rem] outline-none transition-colors duration-150 box-border focus:border-[var(--wraith-accent-blue)]"
|
||||
style="
|
||||
width: 100%;
|
||||
padding: 0.65rem 0.9rem;
|
||||
background-color: var(--wraith-bg-tertiary);
|
||||
border: 1px solid var(--wraith-border);
|
||||
border-radius: 6px;
|
||||
color: var(--wraith-text-primary);
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease;
|
||||
box-sizing: border-box;
|
||||
"
|
||||
@focus="($event.target as HTMLInputElement).style.borderColor = 'var(--wraith-accent-blue)'"
|
||||
@blur="($event.target as HTMLInputElement).style.borderColor = 'var(--wraith-border)'"
|
||||
/>
|
||||
<p class="mt-[0.4rem] text-[0.75rem] text-[var(--wraith-text-muted)]">
|
||||
<p
|
||||
style="
|
||||
margin: 0.4rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--wraith-text-muted);
|
||||
"
|
||||
>
|
||||
Minimum 12 characters. This password cannot be recovered.
|
||||
</p>
|
||||
</div>
|
||||
@ -108,7 +189,14 @@ const displayError = computed(() => localError.value ?? app.error);
|
||||
<!-- Error message -->
|
||||
<div
|
||||
v-if="displayError"
|
||||
class="px-[0.9rem] py-[0.6rem] bg-[rgba(248,81,73,0.1)] border border-[rgba(248,81,73,0.3)] rounded-[6px] text-[var(--wraith-accent-red)] text-[0.85rem]"
|
||||
style="
|
||||
padding: 0.6rem 0.9rem;
|
||||
background-color: rgba(248, 81, 73, 0.1);
|
||||
border: 1px solid rgba(248, 81, 73, 0.3);
|
||||
border-radius: 6px;
|
||||
color: var(--wraith-accent-red);
|
||||
font-size: 0.85rem;
|
||||
"
|
||||
>
|
||||
{{ displayError }}
|
||||
</div>
|
||||
@ -117,8 +205,22 @@ const displayError = computed(() => localError.value ?? app.error);
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="w-full py-[0.7rem] mt-2 bg-[var(--wraith-accent-blue)] text-[#0d1117] font-bold text-[0.9rem] tracking-[0.08em] uppercase border-none rounded-[6px] transition-[opacity,background-color] duration-150"
|
||||
:class="loading ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'"
|
||||
style="
|
||||
width: 100%;
|
||||
padding: 0.7rem;
|
||||
margin-top: 0.5rem;
|
||||
background-color: var(--wraith-accent-blue);
|
||||
color: #0d1117;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease, background-color 0.15s ease;
|
||||
"
|
||||
:style="{ opacity: loading ? '0.6' : '1', cursor: loading ? 'not-allowed' : 'pointer' }"
|
||||
>
|
||||
<span v-if="loading">
|
||||
{{ isFirstRun ? "Creating vault..." : "Unlocking..." }}
|
||||
@ -130,7 +232,14 @@ const displayError = computed(() => localError.value ?? app.error);
|
||||
</form>
|
||||
|
||||
<!-- Footer hint -->
|
||||
<p class="mt-6 text-center text-[0.75rem] text-[var(--wraith-text-muted)]">
|
||||
<p
|
||||
style="
|
||||
margin: 1.5rem 0 0;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
color: var(--wraith-text-muted);
|
||||
"
|
||||
>
|
||||
<template v-if="isFirstRun">
|
||||
Your vault will be encrypted with AES-256-GCM.
|
||||
</template>
|
||||
|
||||
@ -51,33 +51,22 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
);
|
||||
});
|
||||
|
||||
/** Memoized map of groupId → filtered connections. Recomputes only when connections or searchQuery change. */
|
||||
const connectionsByGroupMap = computed<Record<number, Connection[]>>(() => {
|
||||
const q = searchQuery.value.toLowerCase().trim();
|
||||
const map: Record<number, Connection[]> = {};
|
||||
for (const c of connections.value) {
|
||||
if (c.groupId === null) continue;
|
||||
if (q) {
|
||||
const match =
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.hostname.toLowerCase().includes(q) ||
|
||||
c.tags?.some((t) => t.toLowerCase().includes(q));
|
||||
if (!match) continue;
|
||||
}
|
||||
if (!map[c.groupId]) map[c.groupId] = [];
|
||||
map[c.groupId].push(c);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
/** Get connections belonging to a specific group. */
|
||||
function connectionsByGroup(groupId: number): Connection[] {
|
||||
return connectionsByGroupMap.value[groupId] ?? [];
|
||||
const q = searchQuery.value.toLowerCase().trim();
|
||||
const groupConns = connections.value.filter((c) => c.groupId === groupId);
|
||||
if (!q) return groupConns;
|
||||
return groupConns.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.hostname.toLowerCase().includes(q) ||
|
||||
c.tags?.some((t) => t.toLowerCase().includes(q)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Check if a group has any matching connections (for search filtering). */
|
||||
function groupHasResults(groupId: number): boolean {
|
||||
return (connectionsByGroupMap.value[groupId]?.length ?? 0) > 0;
|
||||
return connectionsByGroup(groupId).length > 0;
|
||||
}
|
||||
|
||||
/** Load connections from the Rust backend. */
|
||||
@ -112,7 +101,6 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
groups,
|
||||
searchQuery,
|
||||
filteredConnections,
|
||||
connectionsByGroupMap,
|
||||
connectionsByGroup,
|
||||
groupHasResults,
|
||||
loadConnections,
|
||||
|
||||
@ -1,20 +1,10 @@
|
||||
import { defineConfig, type Plugin } from "vite";
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { resolve } from "path";
|
||||
|
||||
/** Strip crossorigin attribute from HTML — WKWebView + Tauri custom protocol compatibility. */
|
||||
function stripCrossOrigin(): Plugin {
|
||||
return {
|
||||
name: "strip-crossorigin",
|
||||
transformIndexHtml(html) {
|
||||
return html.replace(/ crossorigin/g, "");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss(), stripCrossOrigin()],
|
||||
plugins: [vue(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "src"),
|
||||
@ -33,9 +23,5 @@ export default defineConfig({
|
||||
target: ["es2021", "chrome100", "safari13"],
|
||||
minify: !process.env.TAURI_DEBUG ? "esbuild" : false,
|
||||
sourcemap: !!process.env.TAURI_DEBUG,
|
||||
// Disable crossorigin attribute on script/link tags — WKWebView on
|
||||
// macOS may reject CORS-mode requests for Tauri's custom tauri://
|
||||
// protocol in dynamically created child WebviewWindows.
|
||||
crossOriginLoading: false,
|
||||
},
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user