Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6015f8669b | ||
|
|
703ebdd557 | ||
|
|
d462381cce | ||
|
|
10dc3f9cbe | ||
|
|
cf1c10495b | ||
|
|
0b923051c6 | ||
|
|
04c140f608 | ||
|
|
6d3e973848 | ||
|
|
f7b806ffc0 | ||
|
|
a36793563c | ||
|
|
c4335e0b4f | ||
|
|
2838af4ee7 | ||
|
|
09c2f1a1ff | ||
|
|
1c70eb3248 | ||
|
|
48f9af0824 | ||
|
|
38cb1f7430 |
BIN
docs/screenshots/stats-and-status-bars.png
Normal file
BIN
docs/screenshots/stats-and-status-bars.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
@ -2991,6 +2991,7 @@ checksum = "47c225751e8fbfaaaac5572a80e25d0a0921e9cf408c55509526161b5609157c"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"ironrdp-connector",
|
"ironrdp-connector",
|
||||||
"ironrdp-core",
|
"ironrdp-core",
|
||||||
|
"ironrdp-displaycontrol",
|
||||||
"ironrdp-graphics",
|
"ironrdp-graphics",
|
||||||
"ironrdp-input",
|
"ironrdp-input",
|
||||||
"ironrdp-pdu",
|
"ironrdp-pdu",
|
||||||
|
|||||||
@ -65,7 +65,7 @@ ureq = "3"
|
|||||||
png = "0.17"
|
png = "0.17"
|
||||||
|
|
||||||
# RDP (IronRDP)
|
# RDP (IronRDP)
|
||||||
ironrdp = { version = "0.14", features = ["connector", "session", "graphics", "input"] }
|
ironrdp = { version = "0.14", features = ["connector", "session", "graphics", "input", "displaycontrol"] }
|
||||||
ironrdp-tokio = { version = "0.8", features = ["reqwest-rustls-ring"] }
|
ironrdp-tokio = { version = "0.8", features = ["reqwest-rustls-ring"] }
|
||||||
ironrdp-tls = { version = "0.2", features = ["rustls"] }
|
ironrdp-tls = { version = "0.2", features = ["rustls"] }
|
||||||
tokio-rustls = "0.26"
|
tokio-rustls = "0.26"
|
||||||
|
|||||||
@ -14,3 +14,4 @@ pub mod updater;
|
|||||||
pub mod tools_commands_r2;
|
pub mod tools_commands_r2;
|
||||||
pub mod workspace_commands;
|
pub mod workspace_commands;
|
||||||
pub mod docker_commands;
|
pub mod docker_commands;
|
||||||
|
pub mod window_commands;
|
||||||
|
|||||||
@ -19,18 +19,37 @@ pub fn connect_rdp(
|
|||||||
state.rdp.connect(config, app_handle)
|
state.rdp.connect(config, app_handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the current frame buffer as raw RGBA bytes via binary IPC.
|
/// Get the dirty region since last call as raw RGBA bytes via binary IPC.
|
||||||
///
|
///
|
||||||
/// Uses `tauri::ipc::Response` to return raw bytes without JSON serialization.
|
/// Binary format: 8-byte header + pixel data
|
||||||
/// Pixel format: RGBA, 4 bytes per pixel, row-major, top-left origin.
|
/// Header: [x: u16, y: u16, width: u16, height: u16] (little-endian)
|
||||||
/// Returns empty payload if frame hasn't changed since last call.
|
/// 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.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn rdp_get_frame(
|
pub fn rdp_get_frame(
|
||||||
session_id: String,
|
session_id: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<Response, String> {
|
) -> Result<Response, String> {
|
||||||
let frame = state.rdp.get_frame(&session_id)?;
|
let (region, pixels) = state.rdp.get_frame(&session_id)?;
|
||||||
Ok(Response::new(frame))
|
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a mouse event to an RDP session.
|
/// Send a mouse event to an RDP session.
|
||||||
@ -82,6 +101,29 @@ pub fn rdp_send_clipboard(
|
|||||||
state.rdp.send_clipboard(&session_id, &text)
|
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.
|
/// Disconnect an RDP session.
|
||||||
///
|
///
|
||||||
/// Sends a graceful shutdown to the RDP server and removes the session.
|
/// Sends a graceful shutdown to the RDP server and removes the session.
|
||||||
|
|||||||
40
src-tauri/src/commands/window_commands.rs
Normal file
40
src-tauri/src/commands/window_commands.rs
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
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(())
|
||||||
|
}
|
||||||
@ -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::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::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::sftp_commands::sftp_list, commands::sftp_commands::sftp_read_file, commands::sftp_commands::sftp_write_file, commands::sftp_commands::sftp_mkdir, commands::sftp_commands::sftp_delete, commands::sftp_commands::sftp_rename,
|
||||||
commands::rdp_commands::connect_rdp, commands::rdp_commands::rdp_get_frame, commands::rdp_commands::rdp_send_mouse, commands::rdp_commands::rdp_send_key, commands::rdp_commands::rdp_send_clipboard, 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_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::theme_commands::list_themes, commands::theme_commands::get_theme,
|
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::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,
|
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,6 +234,7 @@ pub fn run() {
|
|||||||
commands::updater::check_for_updates,
|
commands::updater::check_for_updates,
|
||||||
commands::workspace_commands::save_workspace, commands::workspace_commands::load_workspace,
|
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::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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@ -63,17 +63,29 @@ enum InputEvent {
|
|||||||
pressed: bool,
|
pressed: bool,
|
||||||
},
|
},
|
||||||
Clipboard(String),
|
Clipboard(String),
|
||||||
|
Resize { width: u16, height: u16 },
|
||||||
Disconnect,
|
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 {
|
struct RdpSessionHandle {
|
||||||
id: String,
|
id: String,
|
||||||
hostname: String,
|
hostname: String,
|
||||||
width: u16,
|
width: u16,
|
||||||
height: u16,
|
height: u16,
|
||||||
/// Frame buffer: RDP thread writes via RwLock write, IPC reads via RwLock read.
|
/// Frame buffer: RDP thread writes via RwLock write, IPC reads via RwLock read.
|
||||||
/// Brief write-lock per GraphicsUpdate, concurrent reads for get_frame.
|
|
||||||
front_buffer: Arc<std::sync::RwLock<Vec<u8>>>,
|
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_dirty: Arc<AtomicBool>,
|
frame_dirty: Arc<AtomicBool>,
|
||||||
input_tx: mpsc::UnboundedSender<InputEvent>,
|
input_tx: mpsc::UnboundedSender<InputEvent>,
|
||||||
}
|
}
|
||||||
@ -102,6 +114,7 @@ impl RdpService {
|
|||||||
pixel[3] = 255;
|
pixel[3] = 255;
|
||||||
}
|
}
|
||||||
let front_buffer = Arc::new(std::sync::RwLock::new(initial_buf));
|
let front_buffer = Arc::new(std::sync::RwLock::new(initial_buf));
|
||||||
|
let dirty_region = Arc::new(std::sync::Mutex::new(None));
|
||||||
let frame_dirty = Arc::new(AtomicBool::new(false));
|
let frame_dirty = Arc::new(AtomicBool::new(false));
|
||||||
|
|
||||||
let (input_tx, input_rx) = mpsc::unbounded_channel();
|
let (input_tx, input_rx) = mpsc::unbounded_channel();
|
||||||
@ -112,6 +125,7 @@ impl RdpService {
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
front_buffer: front_buffer.clone(),
|
front_buffer: front_buffer.clone(),
|
||||||
|
dirty_region: dirty_region.clone(),
|
||||||
frame_dirty: frame_dirty.clone(),
|
frame_dirty: frame_dirty.clone(),
|
||||||
input_tx,
|
input_tx,
|
||||||
});
|
});
|
||||||
@ -159,6 +173,7 @@ impl RdpService {
|
|||||||
connection_result,
|
connection_result,
|
||||||
framed,
|
framed,
|
||||||
front_buffer,
|
front_buffer,
|
||||||
|
dirty_region,
|
||||||
frame_dirty,
|
frame_dirty,
|
||||||
input_rx,
|
input_rx,
|
||||||
width as u16,
|
width as u16,
|
||||||
@ -202,13 +217,43 @@ impl RdpService {
|
|||||||
Ok(session_id)
|
Ok(session_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_frame(&self, session_id: &str) -> Result<Vec<u8>, String> {
|
/// 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> {
|
||||||
let handle = self.sessions.get(session_id).ok_or_else(|| format!("RDP session {} not found", session_id))?;
|
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) {
|
if !handle.frame_dirty.swap(false, Ordering::Acquire) {
|
||||||
return Ok(Vec::new()); // No change — return empty
|
return Ok((None, Vec::new()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 buf = handle.front_buffer.read().unwrap_or_else(|e| e.into_inner());
|
||||||
Ok(buf.clone())
|
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 fn get_frame_raw(&self, session_id: &str) -> Result<Vec<u8>, String> {
|
||||||
@ -255,6 +300,19 @@ impl RdpService {
|
|||||||
handle.input_tx.send(InputEvent::Key { scancode, pressed }).map_err(|_| format!("RDP session {} input channel closed", session_id))
|
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> {
|
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 = self.sessions.get(session_id).ok_or_else(|| format!("RDP session {} not found", session_id))?;
|
||||||
let _ = handle.input_tx.send(InputEvent::Disconnect);
|
let _ = handle.input_tx.send(InputEvent::Disconnect);
|
||||||
@ -342,7 +400,7 @@ async fn establish_connection(config: connector::Config, hostname: &str, port: u
|
|||||||
Ok((connection_result, upgraded_framed))
|
Ok((connection_result, upgraded_framed))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_active_session(connection_result: ConnectionResult, framed: UpgradedFramed, front_buffer: Arc<std::sync::RwLock<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> {
|
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> {
|
||||||
let (mut reader, mut writer) = split_tokio_framed(framed);
|
let (mut reader, mut writer) = split_tokio_framed(framed);
|
||||||
let mut image = DecodedImage::new(PixelFormat::RgbA32, width, height);
|
let mut image = DecodedImage::new(PixelFormat::RgbA32, width, height);
|
||||||
let mut active_stage = ActiveStage::new(connection_result);
|
let mut active_stage = ActiveStage::new(connection_result);
|
||||||
@ -394,22 +452,67 @@ async fn run_active_session(connection_result: ConnectionResult, framed: Upgrade
|
|||||||
}
|
}
|
||||||
all_outputs
|
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 {
|
for out in outputs {
|
||||||
match out {
|
match out {
|
||||||
ActiveStageOutput::ResponseFrame(frame) => { writer.write_all(&frame).await.map_err(|e| format!("Failed to write RDP response frame: {}", e))?; }
|
ActiveStageOutput::ResponseFrame(frame) => { writer.write_all(&frame).await.map_err(|e| format!("Failed to write RDP response frame: {}", e))?; }
|
||||||
ActiveStageOutput::GraphicsUpdate(_region) => {
|
ActiveStageOutput::GraphicsUpdate(region) => {
|
||||||
// Write decoded image directly to front buffer.
|
let rx = region.left as usize;
|
||||||
// Single RwLock write — readers use read lock, no contention.
|
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
|
||||||
{
|
{
|
||||||
let src = image.data();
|
let src = image.data();
|
||||||
let mut front = front_buffer.write().unwrap_or_else(|e| e.into_inner());
|
let mut front = front_buffer.write().unwrap_or_else(|e| e.into_inner());
|
||||||
if src.len() == front.len() { front.copy_from_slice(src); } else { *front = src.to_vec(); }
|
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);
|
frame_dirty.store(true, Ordering::Release);
|
||||||
// Signal frontend — rAF coalescing prevents flood
|
|
||||||
let _ = app_handle.emit(&format!("rdp:frame:{}", session_id), ());
|
let _ = app_handle.emit(&format!("rdp:frame:{}", session_id), ());
|
||||||
}
|
}
|
||||||
ActiveStageOutput::Terminate(reason) => { info!("RDP session terminated: {:?}", reason); return Ok(()); }
|
ActiveStageOutput::Terminate(reason) => { info!("RDP session terminated: {:?}", reason); return Ok(()); }
|
||||||
|
|||||||
@ -23,7 +23,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: https://asset.localhost data:; connect-src 'self' ipc: http://ipc.localhost"
|
"csp": null
|
||||||
},
|
},
|
||||||
"withGlobalTauri": false
|
"withGlobalTauri": false
|
||||||
},
|
},
|
||||||
|
|||||||
55
src/App.vue
55
src/App.vue
@ -1,48 +1,65 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, defineAsyncComponent } from "vue";
|
import { ref, onMounted, onErrorCaptured, defineAsyncComponent } from "vue";
|
||||||
import { useAppStore } from "@/stores/app.store";
|
import { useAppStore } from "@/stores/app.store";
|
||||||
import UnlockLayout from "@/layouts/UnlockLayout.vue";
|
import UnlockLayout from "@/layouts/UnlockLayout.vue";
|
||||||
|
import ToolWindow from "@/components/tools/ToolWindow.vue";
|
||||||
|
|
||||||
const MainLayout = defineAsyncComponent(
|
const MainLayout = defineAsyncComponent({
|
||||||
() => import("@/layouts/MainLayout.vue")
|
loader: () => import("@/layouts/MainLayout.vue"),
|
||||||
);
|
onError(error) { console.error("[App] MainLayout load failed:", error); },
|
||||||
const ToolWindow = defineAsyncComponent(
|
});
|
||||||
() => import("@/components/tools/ToolWindow.vue")
|
const DetachedSession = defineAsyncComponent({
|
||||||
);
|
loader: () => import("@/components/session/DetachedSession.vue"),
|
||||||
const DetachedSession = defineAsyncComponent(
|
onError(error) { console.error("[App] DetachedSession load failed:", error); },
|
||||||
() => import("@/components/session/DetachedSession.vue")
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const app = useAppStore();
|
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 isToolMode = ref(false);
|
||||||
const isDetachedMode = ref(false);
|
const isDetachedMode = ref(false);
|
||||||
const toolName = ref("");
|
const toolName = ref("");
|
||||||
const toolSessionId = ref("");
|
const toolSessionId = ref("");
|
||||||
|
|
||||||
onMounted(async () => {
|
onErrorCaptured((err) => {
|
||||||
const hash = window.location.hash;
|
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 {
|
||||||
if (hash.startsWith("#/tool/")) {
|
if (hash.startsWith("#/tool/")) {
|
||||||
isToolMode.value = true;
|
isToolMode.value = true;
|
||||||
const rest = hash.substring(7); // after "#/tool/"
|
const rest = hash.substring(7);
|
||||||
const [name, query] = rest.split("?");
|
const [name, query] = rest.split("?");
|
||||||
toolName.value = name;
|
toolName.value = name;
|
||||||
toolSessionId.value = new URLSearchParams(query || "").get("sessionId") || "";
|
toolSessionId.value = new URLSearchParams(query || "").get("sessionId") || "";
|
||||||
} else if (hash.startsWith("#/detached-session")) {
|
} else if (hash.startsWith("#/detached-session")) {
|
||||||
isDetachedMode.value = true;
|
isDetachedMode.value = true;
|
||||||
} else {
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
await app.checkVaultState();
|
await app.checkVaultState();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- Detached session window mode -->
|
<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">
|
||||||
<DetachedSession v-if="isDetachedMode" />
|
{{ appError }}
|
||||||
<!-- Tool popup window mode -->
|
</div>
|
||||||
|
<DetachedSession v-else-if="isDetachedMode" />
|
||||||
<ToolWindow v-else-if="isToolMode" :tool="toolName" :session-id="toolSessionId" />
|
<ToolWindow v-else-if="isToolMode" :tool="toolName" :session-id="toolSessionId" />
|
||||||
<!-- Normal app mode -->
|
|
||||||
<div v-else class="app-root">
|
<div v-else class="app-root">
|
||||||
<UnlockLayout v-if="!app.isUnlocked" />
|
<UnlockLayout v-if="!app.isUnlocked" />
|
||||||
<MainLayout v-else />
|
<MainLayout v-else />
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
.terminal-container {
|
.terminal-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
min-height: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--wraith-bg-primary);
|
background: var(--wraith-bg-primary);
|
||||||
@ -20,16 +20,16 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Selection styling — let xterm.js theme handle selection colors.
|
/* WKWebView focus fix: xterm.js hides its helper textarea with opacity: 0,
|
||||||
The !important override was removed because it conflicts with
|
width/height: 0, left: -9999em. macOS WKWebView doesn't reliably focus
|
||||||
theme-driven selectionBackground set via terminal.options.theme. */
|
elements with zero dimensions positioned off-screen. Override to keep it
|
||||||
.terminal-container .xterm-selection div {
|
within the viewport with non-zero dimensions so focus events fire. */
|
||||||
background-color: rgba(88, 166, 255, 0.3);
|
.terminal-container .xterm .xterm-helper-textarea {
|
||||||
}
|
left: 0 !important;
|
||||||
|
top: 0 !important;
|
||||||
/* Cursor styling */
|
width: 1px !important;
|
||||||
.terminal-container .xterm-cursor-layer {
|
height: 1px !important;
|
||||||
z-index: 4;
|
opacity: 0.01 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar inside terminal */
|
/* Scrollbar inside terminal */
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-8 flex items-center justify-between px-4 bg-[var(--wraith-bg-secondary)] border-t border-[var(--wraith-border)] text-xs text-[var(--wraith-text-muted)] shrink-0">
|
<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">
|
||||||
<!-- Left: connection info -->
|
<!-- Left: connection info -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<template v-if="sessionStore.activeSession">
|
<template v-if="sessionStore.activeSession">
|
||||||
|
|||||||
@ -29,6 +29,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from "vue";
|
import { ref, computed, onMounted, onBeforeUnmount, watch } from "vue";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { useRdp, MouseFlag } from "@/composables/useRdp";
|
import { useRdp, MouseFlag } from "@/composables/useRdp";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@ -76,8 +77,8 @@ function toRdpCoords(e: MouseEvent): { x: number; y: number } | null {
|
|||||||
if (!canvas) return null;
|
if (!canvas) return null;
|
||||||
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const scaleX = rdpWidth.value / rect.width;
|
const scaleX = canvas.width / rect.width;
|
||||||
const scaleY = rdpHeight.value / rect.height;
|
const scaleY = canvas.height / rect.height;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
x: Math.floor((e.clientX - rect.left) * scaleX),
|
x: Math.floor((e.clientX - rect.left) * scaleX),
|
||||||
@ -153,25 +154,95 @@ function handleKeyUp(e: KeyboardEvent): void {
|
|||||||
sendKey(props.sessionId, e.code, false);
|
sendKey(props.sessionId, e.code, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (canvasRef.value) {
|
if (canvasRef.value) {
|
||||||
startFrameLoop(props.sessionId, canvasRef.value, rdpWidth.value, rdpHeight.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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
stopFrameLoop();
|
stopFrameLoop();
|
||||||
|
if (resizeObserver) { resizeObserver.disconnect(); resizeObserver = null; }
|
||||||
|
if (resizeTimeout) { clearTimeout(resizeTimeout); resizeTimeout = null; }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Focus canvas when this tab becomes active and keyboard is grabbed
|
// 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.)
|
||||||
watch(
|
watch(
|
||||||
() => props.isActive,
|
() => props.isActive,
|
||||||
(active) => {
|
(active) => {
|
||||||
if (active && keyboardGrabbed.value && canvasRef.value) {
|
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
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
canvasRef.value?.focus();
|
const wrapper = canvasWrapper.value;
|
||||||
}, 0);
|
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;
|
||||||
}
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
invoke("rdp_force_refresh", { sessionId: props.sessionId }).catch(() => {});
|
||||||
|
}, 500);
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
@ -196,9 +267,8 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.rdp-canvas {
|
.rdp-canvas {
|
||||||
max-width: 100%;
|
width: 100%;
|
||||||
max-height: 100%;
|
height: 100%;
|
||||||
object-fit: contain;
|
|
||||||
cursor: default;
|
cursor: default;
|
||||||
outline: none;
|
outline: none;
|
||||||
image-rendering: auto;
|
image-rendering: auto;
|
||||||
|
|||||||
@ -133,16 +133,14 @@ async function detachTab(): Promise<void> {
|
|||||||
session.active = false;
|
session.active = false;
|
||||||
|
|
||||||
// Open a new Tauri window for this session
|
// Open a new Tauri window for this session
|
||||||
const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow");
|
try {
|
||||||
const label = `detached-${session.id.substring(0, 8)}-${Date.now()}`;
|
await invoke("open_child_window", {
|
||||||
new WebviewWindow(label, {
|
label: `detached-${session.id.substring(0, 8)}-${Date.now()}`,
|
||||||
title: `${session.name} — Wraith`,
|
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}`,
|
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 {
|
function closeMenuTab(): void {
|
||||||
|
|||||||
@ -33,9 +33,9 @@ function applyTheme(): void {
|
|||||||
foreground: theme.foreground,
|
foreground: theme.foreground,
|
||||||
cursor: theme.cursor,
|
cursor: theme.cursor,
|
||||||
cursorAccent: theme.background,
|
cursorAccent: theme.background,
|
||||||
selectionBackground: theme.selectionBackground ?? "rgba(88, 166, 255, 0.4)",
|
selectionBackground: theme.selectionBackground ?? "#264f78",
|
||||||
selectionForeground: theme.selectionForeground ?? "#ffffff",
|
selectionForeground: theme.selectionForeground ?? "#ffffff",
|
||||||
selectionInactiveBackground: theme.selectionBackground ?? "rgba(88, 166, 255, 0.2)",
|
selectionInactiveBackground: theme.selectionBackground ?? "#264f78",
|
||||||
black: theme.black,
|
black: theme.black,
|
||||||
red: theme.red,
|
red: theme.red,
|
||||||
green: theme.green,
|
green: theme.green,
|
||||||
@ -57,6 +57,8 @@ function applyTheme(): void {
|
|||||||
if (containerRef.value) {
|
if (containerRef.value) {
|
||||||
containerRef.value.style.backgroundColor = theme.background;
|
containerRef.value.style.backgroundColor = theme.background;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
terminal.refresh(0, terminal.rows - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="stats"
|
v-if="stats"
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<!-- CPU -->
|
<!-- CPU -->
|
||||||
<span class="flex items-center gap-1">
|
<span class="flex items-center gap-1">
|
||||||
|
|||||||
@ -77,6 +77,10 @@ const containerRef = ref<HTMLElement | null>(null);
|
|||||||
const { terminal, searchAddon, mount, fit } = useTerminal(props.sessionId);
|
const { terminal, searchAddon, mount, fit } = useTerminal(props.sessionId);
|
||||||
let resizeDisposable: IDisposable | null = null;
|
let resizeDisposable: IDisposable | null = null;
|
||||||
|
|
||||||
|
function handleFocus(): void {
|
||||||
|
terminal.focus();
|
||||||
|
}
|
||||||
|
|
||||||
// --- Search state ---
|
// --- Search state ---
|
||||||
const searchVisible = ref(false);
|
const searchVisible = ref(false);
|
||||||
const searchQuery = ref("");
|
const searchQuery = ref("");
|
||||||
@ -186,9 +190,9 @@ function applyTheme(): void {
|
|||||||
foreground: theme.foreground,
|
foreground: theme.foreground,
|
||||||
cursor: theme.cursor,
|
cursor: theme.cursor,
|
||||||
cursorAccent: theme.background,
|
cursorAccent: theme.background,
|
||||||
selectionBackground: theme.selectionBackground ?? "rgba(88, 166, 255, 0.4)",
|
selectionBackground: theme.selectionBackground ?? "#264f78",
|
||||||
selectionForeground: theme.selectionForeground ?? "#ffffff",
|
selectionForeground: theme.selectionForeground ?? "#ffffff",
|
||||||
selectionInactiveBackground: theme.selectionBackground ?? "rgba(88, 166, 255, 0.2)",
|
selectionInactiveBackground: theme.selectionBackground ?? "#264f78",
|
||||||
black: theme.black,
|
black: theme.black,
|
||||||
red: theme.red,
|
red: theme.red,
|
||||||
green: theme.green,
|
green: theme.green,
|
||||||
@ -211,6 +215,9 @@ function applyTheme(): void {
|
|||||||
if (containerRef.value) {
|
if (containerRef.value) {
|
||||||
containerRef.value.style.backgroundColor = theme.background;
|
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.
|
// Watch for theme changes in the session store and apply to this terminal.
|
||||||
@ -226,8 +233,4 @@ onBeforeUnmount(() => {
|
|||||||
resizeDisposable = null;
|
resizeDisposable = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleFocus(): void {
|
|
||||||
terminal.focus();
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -158,8 +158,8 @@ export interface UseRdpReturn {
|
|||||||
keyboardGrabbed: Ref<boolean>;
|
keyboardGrabbed: Ref<boolean>;
|
||||||
/** Whether clipboard sync is enabled */
|
/** Whether clipboard sync is enabled */
|
||||||
clipboardSync: Ref<boolean>;
|
clipboardSync: Ref<boolean>;
|
||||||
/** Fetch the current frame as RGBA ImageData */
|
/** Fetch and render the dirty region directly to a canvas context */
|
||||||
fetchFrame: (sessionId: string, width: number, height: number) => Promise<ImageData | null>;
|
fetchAndRender: (sessionId: string, width: number, height: number, ctx: CanvasRenderingContext2D) => Promise<boolean>;
|
||||||
/** Send a mouse event to the backend */
|
/** Send a mouse event to the backend */
|
||||||
sendMouse: (sessionId: string, x: number, y: number, flags: number) => void;
|
sendMouse: (sessionId: string, x: number, y: number, flags: number) => void;
|
||||||
/** Send a key event to the backend */
|
/** Send a key event to the backend */
|
||||||
@ -199,38 +199,50 @@ export function useRdp(): UseRdpReturn {
|
|||||||
let unlistenFrame: (() => void) | null = null;
|
let unlistenFrame: (() => void) | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch the current frame from the Rust RDP backend.
|
* Fetch the dirty region from the Rust RDP backend and apply it to the canvas.
|
||||||
*
|
*
|
||||||
* rdp_get_frame returns raw RGBA bytes (width*height*4) serialised as a
|
* Binary format from backend: 8-byte header + pixel data
|
||||||
* base64 string over Tauri's IPC bridge. We decode it to Uint8ClampedArray
|
* Header: [x: u16, y: u16, w: u16, h: u16] (little-endian)
|
||||||
* and wrap in an ImageData for putImageData().
|
* 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.
|
||||||
*/
|
*/
|
||||||
async function fetchFrame(
|
async function fetchAndRender(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
): Promise<ImageData | null> {
|
ctx: CanvasRenderingContext2D,
|
||||||
|
): Promise<boolean> {
|
||||||
let raw: ArrayBuffer;
|
let raw: ArrayBuffer;
|
||||||
try {
|
try {
|
||||||
raw = await invoke<ArrayBuffer>("rdp_get_frame", { sessionId });
|
raw = await invoke<ArrayBuffer>("rdp_get_frame", { sessionId });
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!raw || raw.byteLength === 0) return null;
|
if (!raw || raw.byteLength <= 8) return false;
|
||||||
|
|
||||||
// Binary IPC — tauri::ipc::Response delivers raw bytes as ArrayBuffer
|
const view = new DataView(raw);
|
||||||
const bytes = new Uint8ClampedArray(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);
|
||||||
|
|
||||||
|
if (rx === 0 && ry === 0 && rw === 0 && rh === 0) {
|
||||||
|
// Full frame
|
||||||
const expected = width * height * 4;
|
const expected = width * height * 4;
|
||||||
if (bytes.length !== expected) {
|
if (pixelData.length !== expected) return false;
|
||||||
console.warn(
|
ctx.putImageData(new ImageData(pixelData, width, height), 0, 0);
|
||||||
`[useRdp] Frame size mismatch: got ${bytes.length}, expected ${expected}`,
|
} else {
|
||||||
);
|
// Dirty rectangle — apply at offset
|
||||||
return null;
|
const expected = rw * rh * 4;
|
||||||
|
if (pixelData.length !== expected) return false;
|
||||||
|
ctx.putImageData(new ImageData(pixelData, rw, rh), rx, ry);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ImageData(bytes, width, height);
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -301,7 +313,7 @@ export function useRdp(): UseRdpReturn {
|
|||||||
let fetchPending = false;
|
let fetchPending = false;
|
||||||
let rafScheduled = false;
|
let rafScheduled = false;
|
||||||
|
|
||||||
// Fetch frame when backend signals a new frame is ready.
|
// Fetch and render dirty region when backend signals new frame data.
|
||||||
// Uses rAF to coalesce rapid events into one fetch per display frame.
|
// Uses rAF to coalesce rapid events into one fetch per display frame.
|
||||||
function scheduleFrameFetch(): void {
|
function scheduleFrameFetch(): void {
|
||||||
if (rafScheduled) return;
|
if (rafScheduled) return;
|
||||||
@ -310,12 +322,10 @@ export function useRdp(): UseRdpReturn {
|
|||||||
rafScheduled = false;
|
rafScheduled = false;
|
||||||
if (fetchPending) return;
|
if (fetchPending) return;
|
||||||
fetchPending = true;
|
fetchPending = true;
|
||||||
const imageData = await fetchFrame(sessionId, width, height);
|
if (!ctx) return;
|
||||||
|
const rendered = await fetchAndRender(sessionId, width, height, ctx);
|
||||||
fetchPending = false;
|
fetchPending = false;
|
||||||
if (imageData && ctx) {
|
if (rendered && !connected.value) connected.value = true;
|
||||||
ctx.putImageData(imageData, 0, 0);
|
|
||||||
if (!connected.value) connected.value = true;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -363,7 +373,7 @@ export function useRdp(): UseRdpReturn {
|
|||||||
connected,
|
connected,
|
||||||
keyboardGrabbed,
|
keyboardGrabbed,
|
||||||
clipboardSync,
|
clipboardSync,
|
||||||
fetchFrame,
|
fetchAndRender,
|
||||||
sendMouse,
|
sendMouse,
|
||||||
sendKey,
|
sendKey,
|
||||||
sendClipboard,
|
sendClipboard,
|
||||||
|
|||||||
@ -14,8 +14,9 @@ const defaultTheme = {
|
|||||||
foreground: "#e0e0e0",
|
foreground: "#e0e0e0",
|
||||||
cursor: "#58a6ff",
|
cursor: "#58a6ff",
|
||||||
cursorAccent: "#0d1117",
|
cursorAccent: "#0d1117",
|
||||||
selectionBackground: "rgba(88, 166, 255, 0.4)",
|
selectionBackground: "#264f78",
|
||||||
selectionForeground: "#ffffff",
|
selectionForeground: "#ffffff",
|
||||||
|
selectionInactiveBackground: "#264f78",
|
||||||
black: "#0d1117",
|
black: "#0d1117",
|
||||||
red: "#f85149",
|
red: "#f85149",
|
||||||
green: "#3fb950",
|
green: "#3fb950",
|
||||||
|
|||||||
@ -367,16 +367,14 @@ function closeHelpMenuDeferred(): void {
|
|||||||
|
|
||||||
async function handleHelpAction(page: string): Promise<void> {
|
async function handleHelpAction(page: string): Promise<void> {
|
||||||
showHelpMenu.value = false;
|
showHelpMenu.value = false;
|
||||||
const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow");
|
try {
|
||||||
const label = `help-${page}-${Date.now()}`;
|
await invoke("open_child_window", {
|
||||||
new WebviewWindow(label, {
|
label: `help-${page}-${Date.now()}`,
|
||||||
title: `Wraith — Help`,
|
title: "Wraith — Help",
|
||||||
width: 750,
|
|
||||||
height: 600,
|
|
||||||
resizable: true,
|
|
||||||
center: true,
|
|
||||||
url: `index.html#/tool/help?page=${page}`,
|
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> {
|
async function handleToolAction(tool: string): Promise<void> {
|
||||||
@ -390,8 +388,6 @@ async function handleToolAction(tool: string): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow");
|
|
||||||
|
|
||||||
const toolConfig: Record<string, { title: string; width: number; height: number }> = {
|
const toolConfig: Record<string, { title: string; width: number; height: number }> = {
|
||||||
"network-scanner": { title: "Network Scanner", width: 800, height: 600 },
|
"network-scanner": { title: "Network Scanner", width: 800, height: 600 },
|
||||||
"port-scanner": { title: "Port Scanner", width: 700, height: 500 },
|
"port-scanner": { title: "Port Scanner", width: 700, height: 500 },
|
||||||
@ -412,16 +408,14 @@ async function handleToolAction(tool: string): Promise<void> {
|
|||||||
|
|
||||||
const sessionId = activeSessionId.value || "";
|
const sessionId = activeSessionId.value || "";
|
||||||
|
|
||||||
// Open tool in a new Tauri window
|
try {
|
||||||
const label = `tool-${tool}-${Date.now()}`;
|
await invoke("open_child_window", {
|
||||||
new WebviewWindow(label, {
|
label: `tool-${tool}-${Date.now()}`,
|
||||||
title: `Wraith — ${config.title}`,
|
title: `Wraith — ${config.title}`,
|
||||||
width: config.width,
|
|
||||||
height: config.height,
|
|
||||||
resizable: true,
|
|
||||||
center: true,
|
|
||||||
url: `index.html#/tool/${tool}?sessionId=${sessionId}`,
|
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> {
|
async function handleFileMenuAction(action: string): Promise<void> {
|
||||||
@ -441,18 +435,13 @@ function handleThemeSelect(theme: ThemeDefinition): void {
|
|||||||
async function handleOpenFile(entry: FileEntry): Promise<void> {
|
async function handleOpenFile(entry: FileEntry): Promise<void> {
|
||||||
if (!activeSessionId.value) return;
|
if (!activeSessionId.value) return;
|
||||||
try {
|
try {
|
||||||
const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow");
|
|
||||||
const fileName = entry.path.split("/").pop() || entry.path;
|
const fileName = entry.path.split("/").pop() || entry.path;
|
||||||
const label = `editor-${Date.now()}`;
|
|
||||||
const sessionId = activeSessionId.value;
|
const sessionId = activeSessionId.value;
|
||||||
|
await invoke("open_child_window", {
|
||||||
new WebviewWindow(label, {
|
label: `editor-${Date.now()}`,
|
||||||
title: `${fileName} — Wraith Editor`,
|
title: `${fileName} — Wraith Editor`,
|
||||||
width: 800,
|
|
||||||
height: 600,
|
|
||||||
resizable: true,
|
|
||||||
center: true,
|
|
||||||
url: `index.html#/tool/editor?sessionId=${sessionId}&path=${encodeURIComponent(entry.path)}`,
|
url: `index.html#/tool/editor?sessionId=${sessionId}&path=${encodeURIComponent(entry.path)}`,
|
||||||
|
width: 800, height: 600,
|
||||||
});
|
});
|
||||||
} catch (err) { console.error("Failed to open editor:", err); }
|
} catch (err) { console.error("Failed to open editor:", err); }
|
||||||
}
|
}
|
||||||
@ -502,6 +491,19 @@ onMounted(async () => {
|
|||||||
|
|
||||||
await connectionStore.loadAll();
|
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)
|
// Restore workspace — reconnect saved tabs (non-blocking, non-fatal)
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,10 +1,20 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig, type Plugin } from "vite";
|
||||||
import vue from "@vitejs/plugin-vue";
|
import vue from "@vitejs/plugin-vue";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import { resolve } from "path";
|
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({
|
export default defineConfig({
|
||||||
plugins: [vue(), tailwindcss()],
|
plugins: [vue(), tailwindcss(), stripCrossOrigin()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": resolve(__dirname, "src"),
|
"@": resolve(__dirname, "src"),
|
||||||
@ -23,5 +33,9 @@ export default defineConfig({
|
|||||||
target: ["es2021", "chrome100", "safari13"],
|
target: ["es2021", "chrome100", "safari13"],
|
||||||
minify: !process.env.TAURI_DEBUG ? "esbuild" : false,
|
minify: !process.env.TAURI_DEBUG ? "esbuild" : false,
|
||||||
sourcemap: !!process.env.TAURI_DEBUG,
|
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