Compare commits

..

19 Commits

Author SHA1 Message Date
Vantz Stockwell
6015f8669b fix: WebviewUrl::App hash fragment bug — tool windows loading empty page
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m45s
ROOT CAUSE FOUND: WebviewUrl::App takes a PathBuf, not a URL.
Passing "index.html#/tool/ping?sessionId=abc" treated the ENTIRE
string including # and ? as a file path. Tauri looked for a file
literally named "index.html#/tool/ping?sessionId=abc" which doesn't
exist. The webview loaded an empty/404 page and WKWebView killed
the content process, closing the window instantly.

Fix:
- Rust: split URL at '#' — pass only "index.html" to WebviewUrl::App,
  then set the hash fragment via window.eval() after build()
- Vue: App.vue now listens for 'hashchange' event in addition to
  checking hash on mount, so the eval-injected hash triggers the
  correct tool/detached mode

This was NEVER a CSP issue, focus issue, crossorigin issue, or
async chunk loading issue. It was always a bad file path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:04:30 -04:00
Vantz Stockwell
703ebdd557 fix: Rust-side window creation + RDP tab switch layout delay
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m51s
Tool/help/editor/detach windows:
- Moved ALL child window creation from JS-side WebviewWindow to
  Rust-side WebviewWindowBuilder via new open_child_window command.
  JS WebviewWindow on macOS WKWebView was creating windows that
  never fully initialized — the webview content process failed
  silently. Rust-side creation uses the proper main thread context.
- All four call sites (tool, help, editor, detach) now use invoke()
- Errors surface as alert() instead of silent failure

RDP tab switch:
- Immediate force_refresh on tab activation for instant visual feedback
- 300ms delayed dimension check (was double-rAF which was too fast)
- If dimensions changed, resize + 500ms delayed refresh for clean repaint
- Fixes 3/4 resolution rendering after copilot panel toggle

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:29:14 -04:00
Vantz Stockwell
d462381cce fix: create child windows hidden, show after tauri://created
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m50s
Per Gemini's analysis: WKWebView may reap windows that fail to
establish focus during creation. All WebviewWindow instances now
created with visible: false + focus: true, then wv.show() is
called only after tauri://created confirms the webview is ready.

This prevents the OS window manager from treating the window as
an orphaned popup during the brief initialization period.

Applied to: tool windows, help windows, editor windows, detached
session windows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 13:40:30 -04:00
Vantz Stockwell
10dc3f9cbe fix: synchronous ToolWindow import + bars to 48px/16px
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m47s
Tool windows (still closing instantly after every prior fix):
- Changed ToolWindow from defineAsyncComponent to direct synchronous
  import. All 14 tool components now bundled into the main JS chunk.
  Eliminates async chunk loading as a failure point — if the main
  bundle loads (which it does, since the main window works), the
  tool window code is guaranteed to be available.
- ToolWindow chunk no longer exists as a separate file

Status bar + Monitor bar:
- Both set to h-[48px] text-base px-6 (48px height, 16px text)
- Matching sizes for visual consistency

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 13:35:39 -04:00
Vantz Stockwell
cf1c10495b fix: MonitorBar squeezed by terminal container height: 100%
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m58s
.terminal-container had both height: 100% (CSS) and flex-1 (Tailwind).
In the flex-col parent, height: 100% forced the terminal to claim the
full parent height, squeezing MonitorBar below its h-6 minimum.

Fix: replaced height: 100% with min-height: 0. flex-1 handles sizing,
min-height: 0 allows proper flex shrinking so MonitorBar gets its full
24px allocation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 13:07:43 -04:00
Vantz Stockwell
0b923051c6 fix: revert StatusBar and MonitorBar to matching h-6 text-[10px]
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Has been cancelled
Both bars now identical: 24px height, 10px font. MonitorBar had been
changed during debugging — reverted to match StatusBar exactly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 13:06:35 -04:00
Vantz Stockwell
04c140f608 fix: RDP canvas re-measures container on tab switch
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m44s
When switching from SSH back to RDP, the canvas retained the resolution
from when the copilot panel was open — even after closing the panel.
The ResizeObserver doesn't fire while the tab is hidden (v-show/display),
so the container size change goes unnoticed.

Fix: On tab activation, double-rAF waits for layout, measures the
container via getBoundingClientRect, compares with canvas.width/height,
and sends rdp_resize if they differ. This ensures the RDP session
always matches the current available space.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 12:58:57 -04:00
Vantz Stockwell
6d3e973848 fix: strip crossorigin from HTML for WKWebView child windows + status bar
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m42s
Tool windows (still closing instantly with CSP=null):
- Root cause: Vite adds crossorigin attribute to <script> and <link> tags
  in index.html. This forces CORS mode for resource loading. WKWebView's
  Tauri custom protocol handler (tauri://) may not return proper CORS
  headers for child WebviewWindows, causing the module script to fail
  to load and the window to close immediately.
- Fix: Vite plugin strips crossorigin from built HTML via transformIndexHtml
- Also set crossOriginLoading: false for Rollup output chunks

Status bar:
- h-[48px] text-base px-6 — 48px height with 16px text, explicit pixel
  value to avoid Tailwind spacing ambiguity

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 12:56:24 -04:00
Vantz Stockwell
f7b806ffc0 fix: CSP null for tool windows + tauri://error diagnostic listeners
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 4m0s
Tool windows open/close immediately — diagnostic build:
- CSP set to null to eliminate it as a variable. The CSP was blocking
  IPC initialization in child WebviewWindows on macOS WKWebView.
- Added tauri://error listeners to ALL WebviewWindow creations (tool,
  help, editor, detach). If window creation fails on the Rust side,
  an alert will show the error instead of silently closing.
- If tool windows work with csp:null, we know the CSP was the cause
  and can craft a macOS-compatible policy. If they still fail, the
  error alert will reveal the actual Rust-side error.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 12:20:40 -04:00
Vantz Stockwell
a36793563c fix: WKWebView cursor/selection focus, theme restore on startup, status bar
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m45s
Cursor blink + selection (ROOT CAUSE FOUND):
- xterm.js v6 uses DOM renderer, not canvas. Cursor blink and selection
  both require CSS focus classes set by the hidden textarea's focus event.
- WKWebView doesn't focus elements with opacity:0, width:0, height:0,
  left:-9999em — the textarea never receives focus, classes never toggle.
- Fix: Override .xterm-helper-textarea to left:0, top:0, width:1px,
  height:1px, opacity:0.01 — within viewport, non-zero, focusable.

Theme restoration on startup:
- sessionStore.activeTheme started as null on every launch
- ThemePicker saved active_theme to settings but nobody restored it
- Added theme restoration to MainLayout onMounted — reads active_theme
  setting, fetches theme from backend, calls setTheme() before any
  terminals open

Status bar:
- h-10 (40px) to match toolbar height for visual balance

Selection colors:
- Solid #264f78 (VS Code selection blue) instead of rgba transparency

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 12:14:02 -04:00
Vantz Stockwell
c4335e0b4f fix: 6 UX regressions — popups, themes, cursor, selection, status bar
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 4m14s
Cursor blinking (ACTUALLY fixed this time):
- handleFocus() was STILL missing due to botched duplicate removal in
  v1.12.1. Now defined once at line 80, no duplicates.
- vue-tsc --noEmit now runs clean (was erroring with TS2393)

Tool windows:
- App.vue now has onErrorCaptured + error display overlay so tool window
  crashes show the error instead of silently closing
- defineAsyncComponent uses object form with onError callback for logging

Selection highlighting:
- Changed from rgba(88,166,255,0.4) transparent to solid #264f78
  (VS Code's selection color) — always visible on dark backgrounds
- Applied to default theme, TerminalView applyTheme, LocalTerminalView

CSP:
- Simplified to 'self' 'unsafe-inline' asset: for default-src
- Separate connect-src for IPC protocols

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:13:54 -04:00
Vantz Stockwell
2838af4ee7 fix: 6 UX regressions — popups, themes, cursor, selection, status bar
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m54s
Cursor blinking:
- handleFocus() was referenced in TerminalView template but never
  defined in script — clicking the terminal container threw a runtime
  error preventing focus. Added the missing function.
- Removed duplicate handleFocus at end of file

Tool windows:
- CSP simplified for macOS WKWebView compatibility. Previous CSP
  was blocking child WebviewWindow IPC initialization.

RDP tab switch:
- Added rdp_force_refresh command that marks frame_dirty=true and
  clears dirty region, forcing next get_frame to return full buffer
- Called on tab activation and after resize completion
- Eliminates 4-5 second wait for "keyframe" when switching tabs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:58:45 -04:00
Vantz Stockwell
09c2f1a1ff feat: RDP dynamic resize on window resize (MobaXTerm-style)
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m59s
When the Wraith window is resized, the RDP session now resizes to fill
the entire canvas area — matching MobaXTerm behavior.

Implementation:
- Enabled ironrdp displaycontrol feature for Display Control Virtual Channel
- Added Resize input event to RDP session thread
- ActiveStage::encode_resize() sends monitor layout PDU to server
- Server re-renders at new resolution and sends updated frames
- Frontend ResizeObserver on canvas wrapper, debounced 500ms
- Canvas CSS changed from max-width/max-height to width/height: 100%
- Mouse coordinate mapping uses canvas.width/height (actual resolution)
  instead of initial rdpWidth/rdpHeight
- Image and front buffer reallocated on resize to match new dimensions
- Width constrained to even numbers per RDP spec, min 200px

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:44:43 -04:00
Vantz Stockwell
1c70eb3248 fix: TS2345 null check on canvas context in RDP frame loop
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 4m21s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:39:56 -04:00
Vantz Stockwell
48f9af0824 perf: RDP dirty rectangle tracking — partial frame transfer
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 7s
Root cause: Every GraphicsUpdate copied the full 8.3MB decoded image
into the front buffer, cloned all 8.3MB for IPC, and transferred all
8.3MB to the frontend — even when only a 100x100 pixel region changed.
During window drag, this created a 25MB/frame pipeline backup.

Fix:
- Track dirty rectangles from ironrdp's GraphicsUpdate(InclusiveRectangle)
- Write path: only copy changed rows from decoded image to front buffer
  (e.g. 100 rows × 1920 pixels = 768KB vs 8.3MB full frame)
- Accumulate dirty region as union of all rects since last get_frame
- Read path: if dirty region < 50% of frame, extract only the dirty
  rectangle bytes; otherwise fall back to full frame
- Binary IPC format: 8-byte header [x,y,w,h as u16 LE] + pixel data
- Frontend: putImageData at dirty rect offset instead of full frame
- Status bar: h-9 text-sm for 3440x1440 readability

During window drag (typical 300x400 dirty rect):
  Before: 8.3MB write + 8.3MB clone + 8.3MB IPC = 24.9MB per frame
  After:  480KB write + 480KB extract + 480KB IPC = 1.4MB per frame
  ~17x reduction in data movement per frame.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:38:26 -04:00
Vantz Stockwell
38cb1f7430 fix: 6 UX regressions — popups, themes, cursor, selection, status bar
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m53s
Popup windows (tools/editor/help):
- CSP broadened for macOS: added tauri: and https://tauri.localhost to
  default-src, https://ipc.localhost and tauri: to connect-src. WKWebView
  uses different IPC scheme than Windows WebView2.

Theme application:
- terminal.refresh() after theme change forces xterm.js canvas repaint
  of existing text — was only changing background, not text colors

Selection highlighting:
- Removed CSS .xterm-selection div rule entirely — xterm.js v6 canvas
  renderer doesn't use DOM selection divs, the CSS was a no-op that
  conflicted with theme-driven selectionBackground

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:08:13 -04:00
Vantz Stockwell
aa2ef88ed7 fix: 6 UX regressions — popups, themes, cursor, selection, status bar
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m51s
Popup windows (tools/editor/help):
- CSP script-src 'self' blocked Tauri's inline IPC bridge scripts in
  child WebviewWindows. Added 'unsafe-inline' to script-src. Still
  restrictive (was null before SEC-4).

Theme application:
- Watcher on sessionStore.activeTheme needed { deep: true } — Pinia
  reactive proxy identity doesn't change on object replacement
- LocalTerminalView.vue had ZERO theme support — added full applyTheme()
  with watcher and mount-time application
- Container background now syncs with theme (was stuck on CSS variable)

Cursor blink:
- terminal.focus() after mount in useTerminal.ts — terminal opened
  without focus, xterm.js rendered static outline instead of blinking block

Selection highlighting:
- applyTheme() was overwriting theme without selectionBackground/
  selectionForeground/selectionInactiveBackground — selection invisible
  after any theme change
- Removed !important from terminal.css that overrode canvas selection
- Bumped default selection opacity 0.3 → 0.4

Status bar:
- h-6 text-[10px] → h-8 text-xs (24px/10px → 32px/12px)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:52:43 -04:00
Vantz Stockwell
6acd674905 perf: fix RDP window drag blockiness — full window drag + remove throttle
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m44s
Root cause: PerformanceFlags::default() included DISABLE_FULLWINDOWDRAG
which told the RDP server to skip window contents during drag operations.
Additionally, the frame_gen % 2 backend throttle dropped 50% of frame
notifications during rapid updates.

Fix:
- PerformanceFlags: removed DISABLE_FULLWINDOWDRAG, added DISABLE_WALLPAPER,
  DISABLE_CURSOR_SHADOW, ENABLE_DESKTOP_COMPOSITION for optimal rendering
- Removed backend frame throttle — frontend rAF coalescing handles rate limiting
- Simplified buffer architecture: eliminated back_buffer and TokioMutex,
  RDP thread writes directly to front_buffer via RwLock write lock
- Removed unused tokio::sync::Mutex import

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:34:08 -04:00
Vantz Stockwell
d657b3742f perf: enable GPU rasterization + zero-copy for Windows WebView2
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m58s
additionalBrowserArgs: --enable-gpu-rasterization --enable-zero-copy
Also re-disables msWebOOUI/msPdfOOUI/msSmartScreenProtection (wry defaults
that must be explicitly set when using additionalBrowserArgs).

macOS/Linux: no-op (WKWebView/WebKitGTK manage GPU natively).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:24:00 -04:00
22 changed files with 515 additions and 160 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

1
src-tauri/Cargo.lock generated
View File

@ -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",

View File

@ -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"

View File

@ -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;

View File

@ -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.

View 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(())
}

View File

@ -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");

View File

@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
use tokio::io::{AsyncRead, AsyncWrite}; use tokio::io::{AsyncRead, AsyncWrite};
use tokio::net::TcpStream; use tokio::net::TcpStream;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio::sync::Mutex as TokioMutex;
use ironrdp::connector::{self, ClientConnector, ConnectionResult, Credentials, DesktopSize}; use ironrdp::connector::{self, ClientConnector, ConnectionResult, Credentials, DesktopSize};
use ironrdp::graphics::image_processing::PixelFormat; use ironrdp::graphics::image_processing::PixelFormat;
@ -63,23 +63,30 @@ 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,
/// Double-buffered: back_buffer is written by the RDP thread, /// Frame buffer: RDP thread writes via RwLock write, IPC reads via RwLock read.
/// front_buffer is read by the IPC command. Swap on GraphicsUpdate
/// so reads never block writes. Arcs kept alive via struct ownership.
front_buffer: Arc<std::sync::RwLock<Vec<u8>>>, front_buffer: Arc<std::sync::RwLock<Vec<u8>>>,
#[allow(dead_code)] /// Accumulated dirty region since last get_frame — union of all GraphicsUpdate rects.
back_buffer: Arc<TokioMutex<Vec<u8>>>, dirty_region: Arc<std::sync::Mutex<Option<DirtyRect>>>,
frame_dirty: Arc<AtomicBool>, frame_dirty: Arc<AtomicBool>,
#[allow(dead_code)]
frame_generation: Arc<std::sync::atomic::AtomicU64>,
input_tx: mpsc::UnboundedSender<InputEvent>, input_tx: mpsc::UnboundedSender<InputEvent>,
} }
@ -106,10 +113,9 @@ impl RdpService {
for pixel in initial_buf.chunks_exact_mut(4) { for pixel in initial_buf.chunks_exact_mut(4) {
pixel[3] = 255; pixel[3] = 255;
} }
let front_buffer = Arc::new(std::sync::RwLock::new(initial_buf.clone())); let front_buffer = Arc::new(std::sync::RwLock::new(initial_buf));
let back_buffer = Arc::new(TokioMutex::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 frame_generation = Arc::new(std::sync::atomic::AtomicU64::new(0));
let (input_tx, input_rx) = mpsc::unbounded_channel(); let (input_tx, input_rx) = mpsc::unbounded_channel();
@ -119,9 +125,8 @@ impl RdpService {
width, width,
height, height,
front_buffer: front_buffer.clone(), front_buffer: front_buffer.clone(),
back_buffer: back_buffer.clone(), dirty_region: dirty_region.clone(),
frame_dirty: frame_dirty.clone(), frame_dirty: frame_dirty.clone(),
frame_generation: frame_generation.clone(),
input_tx, input_tx,
}); });
@ -167,10 +172,9 @@ impl RdpService {
if let Err(e) = run_active_session( if let Err(e) = run_active_session(
connection_result, connection_result,
framed, framed,
back_buffer,
front_buffer, front_buffer,
dirty_region,
frame_dirty, frame_dirty,
frame_generation,
input_rx, input_rx,
width as u16, width as u16,
height as u16, height as u16,
@ -213,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> {
@ -266,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);
@ -319,7 +366,11 @@ fn build_connector_config(config: &RdpConfig) -> Result<connector::Config, Strin
request_data: None, request_data: None,
autologon: false, autologon: false,
enable_audio_playback: false, enable_audio_playback: false,
performance_flags: PerformanceFlags::default(), performance_flags: PerformanceFlags::DISABLE_WALLPAPER
| PerformanceFlags::DISABLE_MENUANIMATIONS
| PerformanceFlags::DISABLE_CURSOR_SHADOW
| PerformanceFlags::ENABLE_FONT_SMOOTHING
| PerformanceFlags::ENABLE_DESKTOP_COMPOSITION,
desktop_scale_factor: 0, desktop_scale_factor: 0,
hardware_id: None, hardware_id: None,
license_cache: None, license_cache: None,
@ -349,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, back_buffer: Arc<TokioMutex<Vec<u8>>>, front_buffer: Arc<std::sync::RwLock<Vec<u8>>>, frame_dirty: Arc<AtomicBool>, frame_generation: Arc<std::sync::atomic::AtomicU64>, 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);
@ -401,31 +452,68 @@ 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 to back buffer (async mutex, no contention with reads) 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
{ {
let mut buf = back_buffer.lock().await;
let src = image.data(); let src = image.data();
if src.len() == buf.len() { buf.copy_from_slice(src); } else { *buf = src.to_vec(); }
}
// Swap into front buffer (RwLock write — brief, readers use read lock)
{
let back = back_buffer.lock().await;
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());
front.copy_from_slice(&back); 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]);
}
}
} }
let frame_gen = frame_generation.fetch_add(1, Ordering::Release);
// 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);
// Throttle: only emit event every other frame to avoid flooding IPC let _ = app_handle.emit(&format!("rdp:frame:{}", session_id), ());
if frame_gen % 2 == 0 {
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(()); }
ActiveStageOutput::DeactivateAll(_) => { warn!("RDP server sent DeactivateAll — reconnection not yet implemented"); return Ok(()); } ActiveStageOutput::DeactivateAll(_) => { warn!("RDP server sent DeactivateAll — reconnection not yet implemented"); return Ok(()); }

View File

@ -18,11 +18,12 @@
"minHeight": 600, "minHeight": 600,
"decorations": true, "decorations": true,
"resizable": true, "resizable": true,
"dragDropEnabled": false "dragDropEnabled": false,
"additionalBrowserArgs": "--enable-gpu-rasterization --enable-zero-copy --disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection"
} }
], ],
"security": { "security": {
"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" "csp": null
}, },
"withGlobalTauri": false "withGlobalTauri": false
}, },

View File

@ -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 />

View File

@ -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,14 +20,16 @@
height: 100%; height: 100%;
} }
/* Selection styling */ /* WKWebView focus fix: xterm.js hides its helper textarea with opacity: 0,
.terminal-container .xterm-selection div { width/height: 0, left: -9999em. macOS WKWebView doesn't reliably focus
background-color: rgba(88, 166, 255, 0.3) !important; 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 {
/* Cursor styling */ left: 0 !important;
.terminal-container .xterm-cursor-layer { top: 0 !important;
z-index: 4; width: 1px !important;
height: 1px !important;
opacity: 0.01 !important;
} }
/* Scrollbar inside terminal */ /* Scrollbar inside terminal */

View File

@ -1,5 +1,5 @@
<template> <template>
<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"> <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">

View File

@ -112,6 +112,8 @@ export interface ThemeDefinition {
brightMagenta: string; brightMagenta: string;
brightCyan: string; brightCyan: string;
brightWhite: string; brightWhite: string;
selectionBackground?: string;
selectionForeground?: string;
isBuiltin?: boolean; isBuiltin?: boolean;
} }

View File

@ -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;
setTimeout(() => {
canvasRef.value?.focus(); // Immediate focus so keyboard works right away
}, 0); 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(() => {
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;
}
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;

View File

@ -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, url: `index.html#/detached-session?sessionId=${session.id}&name=${encodeURIComponent(session.name)}&protocol=${session.protocol}`,
height: 600, width: 900, height: 600,
resizable: true, });
center: true, } catch (err) { console.error("Detach window error:", err); }
url: `index.html#/detached-session?sessionId=${session.id}&name=${encodeURIComponent(session.name)}&protocol=${session.protocol}`,
});
} }
function closeMenuTab(): void { function closeMenuTab(): void {

View File

@ -12,6 +12,7 @@
import { ref, onMounted, onBeforeUnmount, watch } from "vue"; import { ref, onMounted, onBeforeUnmount, watch } from "vue";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { useTerminal } from "@/composables/useTerminal"; import { useTerminal } from "@/composables/useTerminal";
import { useSessionStore } from "@/stores/session.store";
import "@/assets/css/terminal.css"; import "@/assets/css/terminal.css";
const props = defineProps<{ const props = defineProps<{
@ -19,13 +20,57 @@ const props = defineProps<{
isActive: boolean; isActive: boolean;
}>(); }>();
const sessionStore = useSessionStore();
const containerRef = ref<HTMLElement | null>(null); const containerRef = ref<HTMLElement | null>(null);
const { terminal, mount, fit, destroy } = useTerminal(props.sessionId, "pty"); 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(() => { onMounted(() => {
if (containerRef.value) { if (containerRef.value) {
mount(containerRef.value); mount(containerRef.value);
} }
// Apply current theme immediately if one is already active
if (sessionStore.activeTheme) {
applyTheme();
}
setTimeout(() => { setTimeout(() => {
fit(); fit();
terminal.focus(); terminal.focus();
@ -56,6 +101,11 @@ watch(
}, },
); );
// Watch for theme changes and apply to this local terminal
watch(() => sessionStore.activeTheme, (newTheme) => {
if (newTheme) applyTheme();
}, { deep: true });
onBeforeUnmount(() => { onBeforeUnmount(() => {
destroy(); destroy();
}); });

View File

@ -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">

View File

@ -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("");
@ -185,6 +189,10 @@ function applyTheme(): void {
background: theme.background, background: theme.background,
foreground: theme.foreground, foreground: theme.foreground,
cursor: theme.cursor, cursor: theme.cursor,
cursorAccent: theme.background,
selectionBackground: theme.selectionBackground ?? "#264f78",
selectionForeground: theme.selectionForeground ?? "#ffffff",
selectionInactiveBackground: theme.selectionBackground ?? "#264f78",
black: theme.black, black: theme.black,
red: theme.red, red: theme.red,
green: theme.green, green: theme.green,
@ -202,12 +210,22 @@ function applyTheme(): void {
brightCyan: theme.brightCyan, brightCyan: theme.brightCyan,
brightWhite: theme.brightWhite, 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 // 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(() => sessionStore.activeTheme, (newTheme) => { watch(() => sessionStore.activeTheme, (newTheme) => {
if (newTheme) applyTheme(); if (newTheme) applyTheme();
}); }, { deep: true });
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (resizeDisposable) { if (resizeDisposable) {
@ -215,8 +233,4 @@ onBeforeUnmount(() => {
resizeDisposable = null; resizeDisposable = null;
} }
}); });
function handleFocus(): void {
terminal.focus();
}
</script> </script>

View File

@ -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);
const expected = width * height * 4; if (rx === 0 && ry === 0 && rw === 0 && rh === 0) {
if (bytes.length !== expected) { // Full frame
console.warn( const expected = width * height * 4;
`[useRdp] Frame size mismatch: got ${bytes.length}, expected ${expected}`, if (pixelData.length !== expected) return false;
); ctx.putImageData(new ImageData(pixelData, width, height), 0, 0);
return null; } 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);
} }
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,

View File

@ -14,8 +14,9 @@ const defaultTheme = {
foreground: "#e0e0e0", foreground: "#e0e0e0",
cursor: "#58a6ff", cursor: "#58a6ff",
cursorAccent: "#0d1117", cursorAccent: "#0d1117",
selectionBackground: "rgba(88, 166, 255, 0.3)", selectionBackground: "#264f78",
selectionForeground: "#ffffff", selectionForeground: "#ffffff",
selectionInactiveBackground: "#264f78",
black: "#0d1117", black: "#0d1117",
red: "#f85149", red: "#f85149",
green: "#3fb950", green: "#3fb950",
@ -155,6 +156,7 @@ export function useTerminal(sessionId: string, backend: 'ssh' | 'pty' = 'ssh'):
// cell widths — producing tiny dashes and 200+ column terminals. // cell widths — producing tiny dashes and 200+ column terminals.
document.fonts.ready.then(() => { document.fonts.ready.then(() => {
fitAddon.fit(); fitAddon.fit();
terminal.focus();
}); });
// Right-click paste on the terminal's DOM element // Right-click paste on the terminal's DOM element

View File

@ -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, url: `index.html#/tool/help?page=${page}`,
height: 600, width: 750, height: 600,
resizable: true, });
center: true, } catch (err) { console.error("Help window error:", err); alert("Window error: " + String(err)); }
url: `index.html#/tool/help?page=${page}`,
});
} }
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, url: `index.html#/tool/${tool}?sessionId=${sessionId}`,
height: config.height, width: config.width, height: config.height,
resizable: true, });
center: true, } catch (err) { console.error("Tool window error:", err); alert("Tool window error: " + String(err)); }
url: `index.html#/tool/${tool}?sessionId=${sessionId}`,
});
} }
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 {

View File

@ -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,
}, },
}); });