Compare commits

...

36 Commits
v1.9.6 ... main

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
Vantz Stockwell
a2770d3edf perf: double-buffered RDP frames + frontend rAF throttling
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 4m20s
Root cause: RDP was unresponsive due to frame pipeline bottleneck.
- get_frame() held tokio::Mutex while cloning 8.3MB, blocking the
  RDP session thread from writing new frames (mutex contention)
- Frontend fetched on every backend event with no coalescing
- Every GraphicsUpdate emitted an IPC event, flooding the frontend

Fix:
- Double-buffer: back_buffer (tokio::Mutex, write path) and
  front_buffer (std::sync::RwLock, read path) — reads never block writes
- get_frame() now synchronous, reads from front_buffer via RwLock
- Backend throttles frame events to every other GraphicsUpdate
- Frontend coalesces events via requestAnimationFrame
- RdpView props now reactive (computed) for correct resize behavior
- rdp_get_frame command no longer async (no .await needed)
- screenshot_png_base64 no longer async

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:19:40 -04:00
Vantz Stockwell
c2afb6a50f refactor: 5 code quality fixes — shared ssh exec, wraith_log!, idiomatic Clone, Clone derives, sync RDP commands
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m46s
- Extract duplicate exec_on_session into ssh::exec module; remove 4 private copies from tools_commands, tools_commands_r2, docker_commands, mcp::server
- Replace eprintln! with wraith_log! in theme::mod and workspace::mod
- Replace .map(|entry| entry.clone()) with .map(|r| r.value().clone()) for DashMap refs in ssh::session, mcp::mod, sftp::mod
- Add #[derive(Clone)] to ThemeService and WorkspaceService
- Remove unnecessary async from rdp_send_mouse, rdp_send_key, rdp_send_clipboard, disconnect_rdp, list_rdp_sessions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 16:56:55 -04:00
Vantz Stockwell
d4bfb3d5fd refactor: migrate UnlockLayout to Tailwind + extract ToolShell wrapper
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m54s
- Convert all ~40 inline styles in UnlockLayout.vue to Tailwind CSS v4 arbitrary-value classes, matching MainLayout.vue color conventions (CSS variables + hex arbitraries). Visual appearance preserved exactly.
- Create ToolShell.vue reusable wrapper that owns output/running state and execute/setOutput API via defineExpose.
- Refactor PingTool, TracerouteTool, DnsLookup, WhoisTool, BandwidthTest to use ToolShell — each tool now contains only its unique inputs and invoke calls. Zero vue-tsc errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 16:53:57 -04:00
Vantz Stockwell
b86e2d68d8 refactor: extract keyboard shortcuts composable + 5 UX bug fixes
- Extract handleKeydown into useKeyboardShortcuts.ts composable; reduces
  MainLayout by ~20 lines and isolates keyboard logic cleanly
- ConnectionTree: watch groups for additions and auto-expand new entries
- MonitorBar: generation counter prevents stale event listeners on rapid
  tab switching
- NetworkScanner: revoke blob URL after CSV export click (memory leak)
- TransferProgress: implement the auto-expand/collapse watcher that was
  only commented but never wired up
- FileTree: block binary/large file uploads with clear user error rather
  than silently corrupting — backend sftp_write_file is text-only

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 16:53:46 -04:00
Vantz Stockwell
28619bba3f refactor: Vue 3 state, TypeScript, and lifecycle improvements
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m49s
- connectionsByGroup memoized as computed map — eliminates redundant filter on every render
- DockerPanel: replace any[] with typed DockerContainer/Image/Volume interfaces
- useRdp: replace ReturnType<typeof ref<boolean>> with Ref<boolean>
- SettingsModal: debounce sidebarWidth slider watch (300ms) to prevent rapid IPC calls
- useSftp: export cleanupSession() to prevent sessionPaths memory leak
- StatusBar, CommandPalette: migrate defineEmits to Vue 3.3+ tuple syntax
- SidebarToggle: replace manual v-model (defineProps + defineEmits) with defineModel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 16:53:17 -04:00
Vantz Stockwell
ebd3cee49e fix: four backend correctness bugs — UTF-8 paths, dead vars, transactions, subnet validation
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m46s
- percent_decode: collect bytes into Vec<u8> then String::from_utf8_lossy to handle
  non-ASCII paths correctly instead of casting byte as char (corrupted codepoints > 127)
- format_mtime: remove dead `st`/`y`/`_y` variables and unused UNIX_EPOCH/Duration imports
- reorder_connections/reorder_groups: wrap UPDATE loops in BEGIN/COMMIT transactions
  with ROLLBACK on error to prevent partial sort order writes
- scan_network: validate subnet matches 3-octet format before use in remote shell commands

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 16:52:33 -04:00
Vantz Stockwell
3842d48390 merge: SEC-1/SEC-2 shell escape + MCP bearer token auth
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m48s
2026-03-29 16:41:56 -04:00
Vantz Stockwell
687ccfb982 chore: ignore worktree directories 2026-03-29 16:41:51 -04:00
Vantz Stockwell
8a66103d3d merge: SEC-3/CONC-1/2/3 vault zeroize + async mutex + cancellation (resolved lib.rs conflict) 2026-03-29 16:41:47 -04:00
Vantz Stockwell
15c95841be merge: PERF-1/2/3 scrollback, RDP binary IPC, settings dedup 2026-03-29 16:41:14 -04:00
Vantz Stockwell
625a4500bc merge: MEM-1/2/3 event listener leak cleanup (resolved session.store.ts conflict) 2026-03-29 16:41:08 -04:00
Vantz Stockwell
3843f18b31 merge: VUE-1 through VUE-8 lifecycle fixes 2026-03-29 16:40:21 -04:00
Vantz Stockwell
17973fc3dc fix: SEC-1/SEC-2 shell escape utility + MCP bearer token auth
- New shell_escape() utility for safe command interpolation
- Applied across all MCP tools, docker, scanner, network commands
- MCP server generates random bearer token at startup
- Token written to mcp-token file with 0600 permissions
- All MCP HTTP requests require Authorization header
- Bridge binary reads token and sends on every request

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 16:40:13 -04:00
Vantz Stockwell
da2dd5bbfc fix: SEC-3/CONC-1/2/3 vault zeroize + async mutex + cancellation tokens
- Vault key uses Zeroizing<[u8; 32]>, passwords zeroized after use
- vault/credentials Mutex upgraded to tokio::sync::Mutex
- CWD tracker + monitor use CancellationToken for clean shutdown
- Monitor exec_command has 10s timeout, 3-strike dead connection heuristic

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 16:40:10 -04:00
Vantz Stockwell
fca6ed023e perf: PERF-1/2/3 scrollback bulk write, RDP binary IPC, settings dedup
- Scrollback: bulk copy_from_slice replaces byte-by-byte loop
- RDP frames: tauri::ipc::Response for zero-overhead binary transfer
- SettingsService: derive Clone, eliminate duplicate instance

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 16:40:08 -04:00
Vantz Stockwell
24e8b1e359 fix: MEM-1/2/3 Tauri event listener leak cleanup
- TabBar: move listen() into onMounted, store UnlistenFn
- Session store: track per-session unlisten fns, clean on close
- useRdp: store frame unlisten in composable scope, call in stopFrameLoop

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 16:40:04 -04:00
Vantz Stockwell
a907213d57 fix: SEC-4/SEC-5 CSP lockdown + IPC-1 serde camelCase
- Set restrictive CSP policy (was null)
- Gate devtools behind feature flag
- Set withGlobalTauri: false
- Add camelCase serde rename to ConnectionGroup

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 16:40:00 -04:00
67 changed files with 1576 additions and 766 deletions

1
.gitignore vendored
View File

@ -4,3 +4,4 @@ src-tauri/target/
src-tauri/binaries/ src-tauri/binaries/
*.log *.log
.DS_Store .DS_Store
.claude/worktrees/

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

3
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",
@ -8913,9 +8914,11 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tokio-util",
"ureq", "ureq",
"uuid", "uuid",
"x509-cert", "x509-cert",
"zeroize",
] ]
[[package]] [[package]]

View File

@ -12,11 +12,15 @@ crate-type = ["lib", "cdylib", "staticlib"]
name = "wraith-mcp-bridge" name = "wraith-mcp-bridge"
path = "src/bin/wraith_mcp_bridge.rs" path = "src/bin/wraith_mcp_bridge.rs"
[features]
default = []
devtools = ["tauri/devtools"]
[build-dependencies] [build-dependencies]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = ["devtools"] } tauri = { version = "2", features = [] }
tauri-plugin-shell = "2" tauri-plugin-shell = "2"
tauri-plugin-updater = "2" tauri-plugin-updater = "2"
anyhow = "1" anyhow = "1"
@ -33,6 +37,8 @@ uuid = { version = "1", features = ["v4"] }
base64 = "0.22" base64 = "0.22"
dashmap = "6" dashmap = "6"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
tokio-util = "0.7"
zeroize = { version = "1", features = ["derive"] }
async-trait = "0.1" async-trait = "0.1"
log = "0.4" log = "0.4"
env_logger = "0.11" env_logger = "0.11"
@ -59,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

@ -38,19 +38,22 @@ struct JsonRpcError {
message: String, message: String,
} }
fn get_mcp_port() -> Result<u16, String> { fn get_data_dir() -> Result<std::path::PathBuf, String> {
// Check standard locations for the port file if let Ok(appdata) = std::env::var("APPDATA") {
let port_file = if let Ok(appdata) = std::env::var("APPDATA") { Ok(std::path::PathBuf::from(appdata).join("Wraith"))
std::path::PathBuf::from(appdata).join("Wraith").join("mcp-port")
} else if let Ok(home) = std::env::var("HOME") { } else if let Ok(home) = std::env::var("HOME") {
if cfg!(target_os = "macos") { if cfg!(target_os = "macos") {
std::path::PathBuf::from(home).join("Library").join("Application Support").join("Wraith").join("mcp-port") Ok(std::path::PathBuf::from(home).join("Library").join("Application Support").join("Wraith"))
} else { } else {
std::path::PathBuf::from(home).join(".local").join("share").join("wraith").join("mcp-port") Ok(std::path::PathBuf::from(home).join(".local").join("share").join("wraith"))
} }
} else { } else {
return Err("Cannot determine data directory".to_string()); Err("Cannot determine data directory".to_string())
}; }
}
fn get_mcp_port() -> Result<u16, String> {
let port_file = get_data_dir()?.join("mcp-port");
let port_str = std::fs::read_to_string(&port_file) let port_str = std::fs::read_to_string(&port_file)
.map_err(|e| format!("Cannot read MCP port file at {}: {} — is Wraith running?", port_file.display(), e))?; .map_err(|e| format!("Cannot read MCP port file at {}: {} — is Wraith running?", port_file.display(), e))?;
@ -59,6 +62,15 @@ fn get_mcp_port() -> Result<u16, String> {
.map_err(|e| format!("Invalid port in MCP port file: {}", e)) .map_err(|e| format!("Invalid port in MCP port file: {}", e))
} }
fn get_mcp_token() -> Result<String, String> {
let token_file = get_data_dir()?.join("mcp-token");
let token = std::fs::read_to_string(&token_file)
.map_err(|e| format!("Cannot read MCP token file at {}: {} — is Wraith running?", token_file.display(), e))?;
Ok(token.trim().to_string())
}
fn handle_initialize(id: Value) -> JsonRpcResponse { fn handle_initialize(id: Value) -> JsonRpcResponse {
JsonRpcResponse { JsonRpcResponse {
jsonrpc: "2.0".to_string(), jsonrpc: "2.0".to_string(),
@ -304,12 +316,13 @@ fn handle_tools_list(id: Value) -> JsonRpcResponse {
} }
} }
fn call_wraith(port: u16, endpoint: &str, body: Value) -> Result<Value, String> { fn call_wraith(port: u16, token: &str, endpoint: &str, body: Value) -> Result<Value, String> {
let url = format!("http://127.0.0.1:{}{}", port, endpoint); let url = format!("http://127.0.0.1:{}{}", port, endpoint);
let body_str = serde_json::to_string(&body).unwrap_or_default(); let body_str = serde_json::to_string(&body).unwrap_or_default();
let mut resp = ureq::post(url) let mut resp = ureq::post(url)
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.header("Authorization", &format!("Bearer {}", token))
.send(body_str.as_bytes()) .send(body_str.as_bytes())
.map_err(|e| format!("HTTP request to Wraith failed: {}", e))?; .map_err(|e| format!("HTTP request to Wraith failed: {}", e))?;
@ -327,40 +340,40 @@ fn call_wraith(port: u16, endpoint: &str, body: Value) -> Result<Value, String>
} }
} }
fn handle_tool_call(id: Value, port: u16, tool_name: &str, args: &Value) -> JsonRpcResponse { fn handle_tool_call(id: Value, port: u16, token: &str, tool_name: &str, args: &Value) -> JsonRpcResponse {
let result = match tool_name { let result = match tool_name {
"list_sessions" => call_wraith(port, "/mcp/sessions", serde_json::json!({})), "list_sessions" => call_wraith(port, token, "/mcp/sessions", serde_json::json!({})),
"terminal_type" => call_wraith(port, "/mcp/terminal/type", args.clone()), "terminal_type" => call_wraith(port, token, "/mcp/terminal/type", args.clone()),
"terminal_read" => call_wraith(port, "/mcp/terminal/read", args.clone()), "terminal_read" => call_wraith(port, token, "/mcp/terminal/read", args.clone()),
"terminal_execute" => call_wraith(port, "/mcp/terminal/execute", args.clone()), "terminal_execute" => call_wraith(port, token, "/mcp/terminal/execute", args.clone()),
"sftp_list" => call_wraith(port, "/mcp/sftp/list", args.clone()), "sftp_list" => call_wraith(port, token, "/mcp/sftp/list", args.clone()),
"sftp_read" => call_wraith(port, "/mcp/sftp/read", args.clone()), "sftp_read" => call_wraith(port, token, "/mcp/sftp/read", args.clone()),
"sftp_write" => call_wraith(port, "/mcp/sftp/write", args.clone()), "sftp_write" => call_wraith(port, token, "/mcp/sftp/write", args.clone()),
"network_scan" => call_wraith(port, "/mcp/tool/scan-network", args.clone()), "network_scan" => call_wraith(port, token, "/mcp/tool/scan-network", args.clone()),
"port_scan" => call_wraith(port, "/mcp/tool/scan-ports", args.clone()), "port_scan" => call_wraith(port, token, "/mcp/tool/scan-ports", args.clone()),
"ping" => call_wraith(port, "/mcp/tool/ping", args.clone()), "ping" => call_wraith(port, token, "/mcp/tool/ping", args.clone()),
"traceroute" => call_wraith(port, "/mcp/tool/traceroute", args.clone()), "traceroute" => call_wraith(port, token, "/mcp/tool/traceroute", args.clone()),
"dns_lookup" => call_wraith(port, "/mcp/tool/dns", args.clone()), "dns_lookup" => call_wraith(port, token, "/mcp/tool/dns", args.clone()),
"whois" => call_wraith(port, "/mcp/tool/whois", args.clone()), "whois" => call_wraith(port, token, "/mcp/tool/whois", args.clone()),
"wake_on_lan" => call_wraith(port, "/mcp/tool/wol", args.clone()), "wake_on_lan" => call_wraith(port, token, "/mcp/tool/wol", args.clone()),
"bandwidth_test" => call_wraith(port, "/mcp/tool/bandwidth", args.clone()), "bandwidth_test" => call_wraith(port, token, "/mcp/tool/bandwidth", args.clone()),
"subnet_calc" => call_wraith(port, "/mcp/tool/subnet", args.clone()), "subnet_calc" => call_wraith(port, token, "/mcp/tool/subnet", args.clone()),
"generate_ssh_key" => call_wraith(port, "/mcp/tool/keygen", args.clone()), "generate_ssh_key" => call_wraith(port, token, "/mcp/tool/keygen", args.clone()),
"generate_password" => call_wraith(port, "/mcp/tool/passgen", args.clone()), "generate_password" => call_wraith(port, token, "/mcp/tool/passgen", args.clone()),
"docker_ps" => call_wraith(port, "/mcp/docker/ps", args.clone()), "docker_ps" => call_wraith(port, token, "/mcp/docker/ps", args.clone()),
"docker_action" => call_wraith(port, "/mcp/docker/action", args.clone()), "docker_action" => call_wraith(port, token, "/mcp/docker/action", args.clone()),
"docker_exec" => call_wraith(port, "/mcp/docker/exec", args.clone()), "docker_exec" => call_wraith(port, token, "/mcp/docker/exec", args.clone()),
"service_status" => call_wraith(port, "/mcp/service/status", args.clone()), "service_status" => call_wraith(port, token, "/mcp/service/status", args.clone()),
"process_list" => call_wraith(port, "/mcp/process/list", args.clone()), "process_list" => call_wraith(port, token, "/mcp/process/list", args.clone()),
"git_status" => call_wraith(port, "/mcp/git/status", args.clone()), "git_status" => call_wraith(port, token, "/mcp/git/status", args.clone()),
"git_pull" => call_wraith(port, "/mcp/git/pull", args.clone()), "git_pull" => call_wraith(port, token, "/mcp/git/pull", args.clone()),
"git_log" => call_wraith(port, "/mcp/git/log", args.clone()), "git_log" => call_wraith(port, token, "/mcp/git/log", args.clone()),
"rdp_click" => call_wraith(port, "/mcp/rdp/click", args.clone()), "rdp_click" => call_wraith(port, token, "/mcp/rdp/click", args.clone()),
"rdp_type" => call_wraith(port, "/mcp/rdp/type", args.clone()), "rdp_type" => call_wraith(port, token, "/mcp/rdp/type", args.clone()),
"rdp_clipboard" => call_wraith(port, "/mcp/rdp/clipboard", args.clone()), "rdp_clipboard" => call_wraith(port, token, "/mcp/rdp/clipboard", args.clone()),
"ssh_connect" => call_wraith(port, "/mcp/ssh/connect", args.clone()), "ssh_connect" => call_wraith(port, token, "/mcp/ssh/connect", args.clone()),
"terminal_screenshot" => { "terminal_screenshot" => {
let result = call_wraith(port, "/mcp/screenshot", args.clone()); let result = call_wraith(port, token, "/mcp/screenshot", args.clone());
// Screenshot returns base64 PNG — wrap as image content for multimodal AI // Screenshot returns base64 PNG — wrap as image content for multimodal AI
return match result { return match result {
Ok(b64) => JsonRpcResponse { Ok(b64) => JsonRpcResponse {
@ -420,6 +433,14 @@ fn main() {
} }
}; };
let token = match get_mcp_token() {
Ok(t) => t,
Err(e) => {
eprintln!("wraith-mcp-bridge: {}", e);
std::process::exit(1);
}
};
let stdin = io::stdin(); let stdin = io::stdin();
let mut stdout = io::stdout(); let mut stdout = io::stdout();
@ -458,7 +479,7 @@ fn main() {
let args = request.params.get("arguments") let args = request.params.get("arguments")
.cloned() .cloned()
.unwrap_or(Value::Object(serde_json::Map::new())); .unwrap_or(Value::Object(serde_json::Map::new()));
handle_tool_call(request.id, port, tool_name, &args) handle_tool_call(request.id, port, &token, tool_name, &args)
} }
"notifications/initialized" | "notifications/cancelled" => { "notifications/initialized" | "notifications/cancelled" => {
// Notifications don't get responses // Notifications don't get responses

View File

@ -3,34 +3,16 @@ use tauri::State;
use crate::credentials::Credential; use crate::credentials::Credential;
use crate::AppState; use crate::AppState;
/// Guard helper: lock the credentials mutex and return a ref to the inner
/// `CredentialService`, or a "Vault is locked" error if the vault has not
/// been unlocked for this session.
///
/// This is a macro rather than a function because returning a `MutexGuard`
/// from a helper function would require lifetime annotations that complicate
/// the tauri command signatures unnecessarily.
macro_rules! require_unlocked {
($state:expr) => {{
let guard = $state
.credentials
.lock()
.map_err(|_| "Credentials mutex was poisoned".to_string())?;
if guard.is_none() {
return Err("Vault is locked — call unlock before accessing credentials".into());
}
// SAFETY: we just checked `is_none` above, so `unwrap` cannot panic.
guard
}};
}
/// Return all credentials ordered by name. /// Return all credentials ordered by name.
/// ///
/// Secret values (passwords, private keys) are never included — only metadata. /// Secret values (passwords, private keys) are never included — only metadata.
#[tauri::command] #[tauri::command]
pub fn list_credentials(state: State<'_, AppState>) -> Result<Vec<Credential>, String> { pub async fn list_credentials(state: State<'_, AppState>) -> Result<Vec<Credential>, String> {
let guard = require_unlocked!(state); let guard = state.credentials.lock().await;
guard.as_ref().unwrap().list() let svc = guard
.as_ref()
.ok_or_else(|| "Vault is locked — call unlock before accessing credentials".to_string())?;
svc.list()
} }
/// Store a new username/password credential. /// Store a new username/password credential.
@ -39,18 +21,18 @@ pub fn list_credentials(state: State<'_, AppState>) -> Result<Vec<Credential>, S
/// Returns the created credential record (without the plaintext password). /// Returns the created credential record (without the plaintext password).
/// `domain` is `None` for non-domain credentials; `Some("")` is treated as NULL. /// `domain` is `None` for non-domain credentials; `Some("")` is treated as NULL.
#[tauri::command] #[tauri::command]
pub fn create_password( pub async fn create_password(
name: String, name: String,
username: String, username: String,
password: String, password: String,
domain: Option<String>, domain: Option<String>,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<Credential, String> { ) -> Result<Credential, String> {
let guard = require_unlocked!(state); let guard = state.credentials.lock().await;
guard let svc = guard
.as_ref() .as_ref()
.unwrap() .ok_or_else(|| "Vault is locked — call unlock before accessing credentials".to_string())?;
.create_password(name, username, password, domain) svc.create_password(name, username, password, domain)
} }
/// Store a new SSH private key credential. /// Store a new SSH private key credential.
@ -59,18 +41,18 @@ pub fn create_password(
/// Pass `None` for `passphrase` when the key has no passphrase. /// Pass `None` for `passphrase` when the key has no passphrase.
/// Returns the created credential record without any secret material. /// Returns the created credential record without any secret material.
#[tauri::command] #[tauri::command]
pub fn create_ssh_key( pub async fn create_ssh_key(
name: String, name: String,
username: String, username: String,
private_key_pem: String, private_key_pem: String,
passphrase: Option<String>, passphrase: Option<String>,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<Credential, String> { ) -> Result<Credential, String> {
let guard = require_unlocked!(state); let guard = state.credentials.lock().await;
guard let svc = guard
.as_ref() .as_ref()
.unwrap() .ok_or_else(|| "Vault is locked — call unlock before accessing credentials".to_string())?;
.create_ssh_key(name, username, private_key_pem, passphrase) svc.create_ssh_key(name, username, private_key_pem, passphrase)
} }
/// Delete a credential by id. /// Delete a credential by id.
@ -78,21 +60,30 @@ pub fn create_ssh_key(
/// For SSH key credentials, the associated `ssh_keys` row is also deleted. /// For SSH key credentials, the associated `ssh_keys` row is also deleted.
/// Returns `Err` if the vault is locked or the id does not exist. /// Returns `Err` if the vault is locked or the id does not exist.
#[tauri::command] #[tauri::command]
pub fn delete_credential(id: i64, state: State<'_, AppState>) -> Result<(), String> { pub async fn delete_credential(id: i64, state: State<'_, AppState>) -> Result<(), String> {
let guard = require_unlocked!(state); let guard = state.credentials.lock().await;
guard.as_ref().unwrap().delete(id) let svc = guard
.as_ref()
.ok_or_else(|| "Vault is locked — call unlock before accessing credentials".to_string())?;
svc.delete(id)
} }
/// Decrypt and return the password for a credential. /// Decrypt and return the password for a credential.
#[tauri::command] #[tauri::command]
pub fn decrypt_password(credential_id: i64, state: State<'_, AppState>) -> Result<String, String> { pub async fn decrypt_password(credential_id: i64, state: State<'_, AppState>) -> Result<String, String> {
let guard = require_unlocked!(state); let guard = state.credentials.lock().await;
guard.as_ref().unwrap().decrypt_password(credential_id) let svc = guard
.as_ref()
.ok_or_else(|| "Vault is locked — call unlock before accessing credentials".to_string())?;
svc.decrypt_password(credential_id)
} }
/// Decrypt and return the SSH private key and passphrase. /// Decrypt and return the SSH private key and passphrase.
#[tauri::command] #[tauri::command]
pub fn decrypt_ssh_key(ssh_key_id: i64, state: State<'_, AppState>) -> Result<(String, String), String> { pub async fn decrypt_ssh_key(ssh_key_id: i64, state: State<'_, AppState>) -> Result<(String, String), String> {
let guard = require_unlocked!(state); let guard = state.credentials.lock().await;
guard.as_ref().unwrap().decrypt_ssh_key(ssh_key_id) let svc = guard
.as_ref()
.ok_or_else(|| "Vault is locked — call unlock before accessing credentials".to_string())?;
svc.decrypt_ssh_key(ssh_key_id)
} }

View File

@ -3,6 +3,8 @@
use tauri::State; use tauri::State;
use serde::Serialize; use serde::Serialize;
use crate::AppState; use crate::AppState;
use crate::ssh::exec::exec_on_session;
use crate::utils::shell_escape;
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@ -37,7 +39,7 @@ pub struct DockerVolume {
pub async fn docker_list_containers(session_id: String, all: Option<bool>, state: State<'_, AppState>) -> Result<Vec<DockerContainer>, String> { pub async fn docker_list_containers(session_id: String, all: Option<bool>, state: State<'_, AppState>) -> Result<Vec<DockerContainer>, String> {
let session = state.ssh.get_session(&session_id).ok_or("Session not found")?; let session = state.ssh.get_session(&session_id).ok_or("Session not found")?;
let flag = if all.unwrap_or(true) { "-a" } else { "" }; let flag = if all.unwrap_or(true) { "-a" } else { "" };
let output = exec(&session.handle, &format!("docker ps {} --format '{{{{.ID}}}}|{{{{.Names}}}}|{{{{.Image}}}}|{{{{.Status}}}}|{{{{.Ports}}}}|{{{{.CreatedAt}}}}' 2>&1", flag)).await?; let output = exec_on_session(&session.handle, &format!("docker ps {} --format '{{{{.ID}}}}|{{{{.Names}}}}|{{{{.Image}}}}|{{{{.Status}}}}|{{{{.Ports}}}}|{{{{.CreatedAt}}}}' 2>&1", flag)).await?;
Ok(output.lines().filter(|l| !l.is_empty() && !l.starts_with("CONTAINER")).map(|line| { Ok(output.lines().filter(|l| !l.is_empty() && !l.starts_with("CONTAINER")).map(|line| {
let p: Vec<&str> = line.splitn(6, '|').collect(); let p: Vec<&str> = line.splitn(6, '|').collect();
DockerContainer { DockerContainer {
@ -54,7 +56,7 @@ pub async fn docker_list_containers(session_id: String, all: Option<bool>, state
#[tauri::command] #[tauri::command]
pub async fn docker_list_images(session_id: String, state: State<'_, AppState>) -> Result<Vec<DockerImage>, String> { pub async fn docker_list_images(session_id: String, state: State<'_, AppState>) -> Result<Vec<DockerImage>, String> {
let session = state.ssh.get_session(&session_id).ok_or("Session not found")?; let session = state.ssh.get_session(&session_id).ok_or("Session not found")?;
let output = exec(&session.handle, "docker images --format '{{.ID}}|{{.Repository}}|{{.Tag}}|{{.Size}}|{{.CreatedAt}}' 2>&1").await?; let output = exec_on_session(&session.handle, "docker images --format '{{.ID}}|{{.Repository}}|{{.Tag}}|{{.Size}}|{{.CreatedAt}}' 2>&1").await?;
Ok(output.lines().filter(|l| !l.is_empty()).map(|line| { Ok(output.lines().filter(|l| !l.is_empty()).map(|line| {
let p: Vec<&str> = line.splitn(5, '|').collect(); let p: Vec<&str> = line.splitn(5, '|').collect();
DockerImage { DockerImage {
@ -70,7 +72,7 @@ pub async fn docker_list_images(session_id: String, state: State<'_, AppState>)
#[tauri::command] #[tauri::command]
pub async fn docker_list_volumes(session_id: String, state: State<'_, AppState>) -> Result<Vec<DockerVolume>, String> { pub async fn docker_list_volumes(session_id: String, state: State<'_, AppState>) -> Result<Vec<DockerVolume>, String> {
let session = state.ssh.get_session(&session_id).ok_or("Session not found")?; let session = state.ssh.get_session(&session_id).ok_or("Session not found")?;
let output = exec(&session.handle, "docker volume ls --format '{{.Name}}|{{.Driver}}|{{.Mountpoint}}' 2>&1").await?; let output = exec_on_session(&session.handle, "docker volume ls --format '{{.Name}}|{{.Driver}}|{{.Mountpoint}}' 2>&1").await?;
Ok(output.lines().filter(|l| !l.is_empty()).map(|line| { Ok(output.lines().filter(|l| !l.is_empty()).map(|line| {
let p: Vec<&str> = line.splitn(3, '|').collect(); let p: Vec<&str> = line.splitn(3, '|').collect();
DockerVolume { DockerVolume {
@ -84,32 +86,20 @@ pub async fn docker_list_volumes(session_id: String, state: State<'_, AppState>)
#[tauri::command] #[tauri::command]
pub async fn docker_action(session_id: String, action: String, target: String, state: State<'_, AppState>) -> Result<String, String> { pub async fn docker_action(session_id: String, action: String, target: String, state: State<'_, AppState>) -> Result<String, String> {
let session = state.ssh.get_session(&session_id).ok_or("Session not found")?; let session = state.ssh.get_session(&session_id).ok_or("Session not found")?;
let t = shell_escape(&target);
let cmd = match action.as_str() { let cmd = match action.as_str() {
"start" => format!("docker start {} 2>&1", target), "start" => format!("docker start {} 2>&1", t),
"stop" => format!("docker stop {} 2>&1", target), "stop" => format!("docker stop {} 2>&1", t),
"restart" => format!("docker restart {} 2>&1", target), "restart" => format!("docker restart {} 2>&1", t),
"remove" => format!("docker rm -f {} 2>&1", target), "remove" => format!("docker rm -f {} 2>&1", t),
"logs" => format!("docker logs --tail 100 {} 2>&1", target), "logs" => format!("docker logs --tail 100 {} 2>&1", t),
"remove-image" => format!("docker rmi {} 2>&1", target), "remove-image" => format!("docker rmi {} 2>&1", t),
"remove-volume" => format!("docker volume rm {} 2>&1", target), "remove-volume" => format!("docker volume rm {} 2>&1", t),
"builder-prune" => "docker builder prune -f 2>&1".to_string(), "builder-prune" => "docker builder prune -f 2>&1".to_string(),
"system-prune" => "docker system prune -f 2>&1".to_string(), "system-prune" => "docker system prune -f 2>&1".to_string(),
"system-prune-all" => "docker system prune -a -f 2>&1".to_string(), "system-prune-all" => "docker system prune -a -f 2>&1".to_string(),
_ => return Err(format!("Unknown docker action: {}", action)), _ => return Err(format!("Unknown docker action: {}", action)),
}; };
exec(&session.handle, &cmd).await exec_on_session(&session.handle, &cmd).await
} }
async fn exec(handle: &std::sync::Arc<tokio::sync::Mutex<russh::client::Handle<crate::ssh::session::SshClient>>>, cmd: &str) -> Result<String, String> {
let mut channel = { let h = handle.lock().await; h.channel_open_session().await.map_err(|e| format!("Exec failed: {}", e))? };
channel.exec(true, cmd).await.map_err(|e| format!("Exec failed: {}", e))?;
let mut output = String::new();
loop {
match channel.wait().await {
Some(russh::ChannelMsg::Data { ref data }) => { if let Ok(t) = std::str::from_utf8(data.as_ref()) { output.push_str(t); } }
Some(russh::ChannelMsg::Eof) | Some(russh::ChannelMsg::Close) | None => break,
_ => {}
}
}
Ok(output)
}

View File

@ -116,7 +116,10 @@ pub async fn mcp_terminal_execute(
return Ok(clean.trim().to_string()); return Ok(clean.trim().to_string());
} }
tokio::time::sleep(std::time::Duration::from_millis(50)).await; // Yield the executor before sleeping so other tasks aren't starved,
// then wait 200 ms — much cheaper than the original 50 ms busy-poll.
tokio::task::yield_now().await;
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
} }
} }

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

@ -4,6 +4,7 @@
//! delegate to the `RdpService` via `State<AppState>`. //! delegate to the `RdpService` via `State<AppState>`.
use tauri::{AppHandle, State}; use tauri::{AppHandle, State};
use tauri::ipc::Response;
use crate::rdp::{RdpConfig, RdpSessionInfo}; use crate::rdp::{RdpConfig, RdpSessionInfo};
use crate::AppState; use crate::AppState;
@ -18,16 +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 (binary IPC — no base64). /// Get the dirty region since last call as raw RGBA bytes via binary IPC.
/// ///
/// Pixel format: RGBA, 4 bytes per pixel, row-major, top-left origin. /// Binary format: 8-byte header + pixel data
/// Returns empty Vec if frame hasn't changed since last call. /// Header: [x: u16, y: u16, width: u16, height: u16] (little-endian)
/// If header is all zeros, the payload is a full frame (width*height*4 bytes).
/// If header is non-zero, payload contains only the dirty rectangle pixels.
/// Returns empty payload if nothing changed.
#[tauri::command] #[tauri::command]
pub async fn rdp_get_frame( pub fn rdp_get_frame(
session_id: String, session_id: String,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<Vec<u8>, String> { ) -> Result<Response, String> {
state.rdp.get_frame(&session_id).await let (region, pixels) = state.rdp.get_frame(&session_id)?;
if pixels.is_empty() {
return Ok(Response::new(Vec::new()));
}
// Prepend 8-byte dirty rect header
let mut out = Vec::with_capacity(8 + pixels.len());
match region {
Some(rect) => {
out.extend_from_slice(&rect.x.to_le_bytes());
out.extend_from_slice(&rect.y.to_le_bytes());
out.extend_from_slice(&rect.width.to_le_bytes());
out.extend_from_slice(&rect.height.to_le_bytes());
}
None => {
out.extend_from_slice(&[0u8; 8]); // full frame marker
}
}
out.extend_from_slice(&pixels);
Ok(Response::new(out))
} }
/// Send a mouse event to an RDP session. /// Send a mouse event to an RDP session.
@ -42,7 +64,7 @@ pub async fn rdp_get_frame(
/// - 0x0100 = negative wheel direction /// - 0x0100 = negative wheel direction
/// - 0x0400 = horizontal wheel /// - 0x0400 = horizontal wheel
#[tauri::command] #[tauri::command]
pub async fn rdp_send_mouse( pub fn rdp_send_mouse(
session_id: String, session_id: String,
x: u16, x: u16,
y: u16, y: u16,
@ -60,7 +82,7 @@ pub async fn rdp_send_mouse(
/// ///
/// `pressed` is `true` for key-down, `false` for key-up. /// `pressed` is `true` for key-down, `false` for key-up.
#[tauri::command] #[tauri::command]
pub async fn rdp_send_key( pub fn rdp_send_key(
session_id: String, session_id: String,
scancode: u16, scancode: u16,
pressed: bool, pressed: bool,
@ -71,7 +93,7 @@ pub async fn rdp_send_key(
/// Send clipboard text to an RDP session by simulating keystrokes. /// Send clipboard text to an RDP session by simulating keystrokes.
#[tauri::command] #[tauri::command]
pub async fn rdp_send_clipboard( pub fn rdp_send_clipboard(
session_id: String, session_id: String,
text: String, text: String,
state: State<'_, AppState>, state: State<'_, AppState>,
@ -79,11 +101,34 @@ pub async 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.
#[tauri::command] #[tauri::command]
pub async fn disconnect_rdp( pub fn disconnect_rdp(
session_id: String, session_id: String,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<(), String> { ) -> Result<(), String> {
@ -92,7 +137,7 @@ pub async fn disconnect_rdp(
/// List all active RDP sessions (metadata only). /// List all active RDP sessions (metadata only).
#[tauri::command] #[tauri::command]
pub async fn list_rdp_sessions( pub fn list_rdp_sessions(
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<Vec<RdpSessionInfo>, String> { ) -> Result<Vec<RdpSessionInfo>, String> {
Ok(state.rdp.list_sessions()) Ok(state.rdp.list_sessions())

View File

@ -4,6 +4,8 @@ use tauri::State;
use serde::Serialize; use serde::Serialize;
use crate::AppState; use crate::AppState;
use crate::ssh::exec::exec_on_session;
use crate::utils::shell_escape;
// ── Ping ───────────────────────────────────────────────────────────────────── // ── Ping ─────────────────────────────────────────────────────────────────────
@ -25,7 +27,7 @@ pub async fn tool_ping(
let session = state.ssh.get_session(&session_id) let session = state.ssh.get_session(&session_id)
.ok_or_else(|| format!("SSH session {} not found", session_id))?; .ok_or_else(|| format!("SSH session {} not found", session_id))?;
let n = count.unwrap_or(4); let n = count.unwrap_or(4);
let cmd = format!("ping -c {} {} 2>&1", n, target); let cmd = format!("ping -c {} {} 2>&1", n, shell_escape(&target));
let output = exec_on_session(&session.handle, &cmd).await?; let output = exec_on_session(&session.handle, &cmd).await?;
Ok(PingResult { target, output }) Ok(PingResult { target, output })
} }
@ -39,7 +41,8 @@ pub async fn tool_traceroute(
) -> Result<String, String> { ) -> Result<String, String> {
let session = state.ssh.get_session(&session_id) let session = state.ssh.get_session(&session_id)
.ok_or_else(|| format!("SSH session {} not found", session_id))?; .ok_or_else(|| format!("SSH session {} not found", session_id))?;
let cmd = format!("traceroute {} 2>&1 || tracert {} 2>&1", target, target); let t = shell_escape(&target);
let cmd = format!("traceroute {} 2>&1 || tracert {} 2>&1", t, t);
exec_on_session(&session.handle, &cmd).await exec_on_session(&session.handle, &cmd).await
} }
@ -65,14 +68,16 @@ pub async fn tool_wake_on_lan(
let cmd = format!( let cmd = format!(
r#"python3 -c " r#"python3 -c "
import socket, struct import socket, struct
mac = bytes.fromhex('{mac_clean}') mac = bytes.fromhex({mac_clean_escaped})
pkt = b'\xff'*6 + mac*16 pkt = b'\xff'*6 + mac*16
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
s.sendto(pkt, ('255.255.255.255', 9)) s.sendto(pkt, ('255.255.255.255', 9))
s.close() s.close()
print('WoL packet sent to {mac_address}') print('WoL packet sent to {mac_display_escaped}')
" 2>&1 || echo "python3 not available install python3 on remote host for WoL""# " 2>&1 || echo "python3 not available install python3 on remote host for WoL""#,
mac_clean_escaped = shell_escape(&mac_clean),
mac_display_escaped = shell_escape(&mac_address),
); );
exec_on_session(&session.handle, &cmd).await exec_on_session(&session.handle, &cmd).await
@ -181,32 +186,3 @@ pub fn tool_generate_password_inner(
Ok(password) Ok(password)
} }
// ── Helper ───────────────────────────────────────────────────────────────────
async fn exec_on_session(
handle: &std::sync::Arc<tokio::sync::Mutex<russh::client::Handle<crate::ssh::session::SshClient>>>,
cmd: &str,
) -> Result<String, String> {
let mut channel = {
let h = handle.lock().await;
h.channel_open_session().await.map_err(|e| format!("Exec channel failed: {}", e))?
};
channel.exec(true, cmd).await.map_err(|e| format!("Exec failed: {}", e))?;
let mut output = String::new();
loop {
match channel.wait().await {
Some(russh::ChannelMsg::Data { ref data }) => {
if let Ok(text) = std::str::from_utf8(data.as_ref()) {
output.push_str(text);
}
}
Some(russh::ChannelMsg::Eof) | Some(russh::ChannelMsg::Close) | None => break,
Some(russh::ChannelMsg::ExitStatus { .. }) => {}
_ => {}
}
}
Ok(output)
}

View File

@ -4,6 +4,8 @@ use tauri::State;
use serde::Serialize; use serde::Serialize;
use crate::AppState; use crate::AppState;
use crate::ssh::exec::exec_on_session;
use crate::utils::shell_escape;
// ── DNS Lookup ─────────────────────────────────────────────────────────────── // ── DNS Lookup ───────────────────────────────────────────────────────────────
@ -16,10 +18,11 @@ pub async fn tool_dns_lookup(
) -> Result<String, String> { ) -> Result<String, String> {
let session = state.ssh.get_session(&session_id) let session = state.ssh.get_session(&session_id)
.ok_or_else(|| format!("SSH session {} not found", session_id))?; .ok_or_else(|| format!("SSH session {} not found", session_id))?;
let rtype = record_type.unwrap_or_else(|| "A".to_string()); let d = shell_escape(&domain);
let rt = shell_escape(&record_type.unwrap_or_else(|| "A".to_string()));
let cmd = format!( let cmd = format!(
r#"dig {} {} +short 2>/dev/null || nslookup -type={} {} 2>/dev/null || host -t {} {} 2>/dev/null"#, r#"dig {} {} +short 2>/dev/null || nslookup -type={} {} 2>/dev/null || host -t {} {} 2>/dev/null"#,
domain, rtype, rtype, domain, rtype, domain d, rt, rt, d, rt, d
); );
exec_on_session(&session.handle, &cmd).await exec_on_session(&session.handle, &cmd).await
} }
@ -34,7 +37,7 @@ pub async fn tool_whois(
) -> Result<String, String> { ) -> Result<String, String> {
let session = state.ssh.get_session(&session_id) let session = state.ssh.get_session(&session_id)
.ok_or_else(|| format!("SSH session {} not found", session_id))?; .ok_or_else(|| format!("SSH session {} not found", session_id))?;
let cmd = format!("whois {} 2>&1 | head -80", target); let cmd = format!("whois {} 2>&1 | head -80", shell_escape(&target));
exec_on_session(&session.handle, &cmd).await exec_on_session(&session.handle, &cmd).await
} }
@ -50,9 +53,10 @@ pub async fn tool_bandwidth_iperf(
let session = state.ssh.get_session(&session_id) let session = state.ssh.get_session(&session_id)
.ok_or_else(|| format!("SSH session {} not found", session_id))?; .ok_or_else(|| format!("SSH session {} not found", session_id))?;
let dur = duration.unwrap_or(5); let dur = duration.unwrap_or(5);
let s = shell_escape(&server);
let cmd = format!( let cmd = format!(
"iperf3 -c {} -t {} --json 2>/dev/null || iperf3 -c {} -t {} 2>&1 || echo 'iperf3 not installed — run: apt install iperf3 / brew install iperf3'", "iperf3 -c {} -t {} --json 2>/dev/null || iperf3 -c {} -t {} 2>&1 || echo 'iperf3 not installed — run: apt install iperf3 / brew install iperf3'",
server, dur, server, dur s, dur, s, dur
); );
exec_on_session(&session.handle, &cmd).await exec_on_session(&session.handle, &cmd).await
} }
@ -178,27 +182,3 @@ fn to_ip(val: u32) -> String {
format!("{}.{}.{}.{}", val >> 24, (val >> 16) & 0xFF, (val >> 8) & 0xFF, val & 0xFF) format!("{}.{}.{}.{}", val >> 24, (val >> 16) & 0xFF, (val >> 8) & 0xFF, val & 0xFF)
} }
// ── Helper ───────────────────────────────────────────────────────────────────
async fn exec_on_session(
handle: &std::sync::Arc<tokio::sync::Mutex<russh::client::Handle<crate::ssh::session::SshClient>>>,
cmd: &str,
) -> Result<String, String> {
let mut channel = {
let h = handle.lock().await;
h.channel_open_session().await.map_err(|e| format!("Exec channel failed: {}", e))?
};
channel.exec(true, cmd).await.map_err(|e| format!("Exec failed: {}", e))?;
let mut output = String::new();
loop {
match channel.wait().await {
Some(russh::ChannelMsg::Data { ref data }) => {
if let Ok(text) = std::str::from_utf8(data.as_ref()) { output.push_str(text); }
}
Some(russh::ChannelMsg::Eof) | Some(russh::ChannelMsg::Close) | None => break,
Some(russh::ChannelMsg::ExitStatus { .. }) => {}
_ => {}
}
}
Ok(output)
}

View File

@ -1,4 +1,5 @@
use tauri::State; use tauri::State;
use zeroize::Zeroize;
use crate::vault::{self, VaultService}; use crate::vault::{self, VaultService};
use crate::credentials::CredentialService; use crate::credentials::CredentialService;
@ -21,28 +22,33 @@ pub fn is_first_run(state: State<'_, AppState>) -> bool {
/// Returns `Err` if the vault has already been set up or if any storage /// Returns `Err` if the vault has already been set up or if any storage
/// operation fails. /// operation fails.
#[tauri::command] #[tauri::command]
pub fn create_vault(password: String, state: State<'_, AppState>) -> Result<(), String> { pub async fn create_vault(mut password: String, state: State<'_, AppState>) -> Result<(), String> {
if !state.is_first_run() { let result = async {
return Err("Vault already exists — use unlock instead of create".into()); if !state.is_first_run() {
} return Err("Vault already exists — use unlock instead of create".into());
}
let salt = vault::generate_salt(); let salt = vault::generate_salt();
let key = vault::derive_key(&password, &salt); let key = vault::derive_key(&password, &salt);
let vs = VaultService::new(key); let vs = VaultService::new(key.clone());
// Persist the salt so we can re-derive the key on future unlocks. // Persist the salt so we can re-derive the key on future unlocks.
state.settings.set("vault_salt", &hex::encode(salt))?; state.settings.set("vault_salt", &hex::encode(salt))?;
// Persist a known-plaintext check so unlock can verify the password. // Persist a known-plaintext check so unlock can verify the password.
let check = vs.encrypt("wraith-vault-check")?; let check = vs.encrypt("wraith-vault-check")?;
state.settings.set("vault_check", &check)?; state.settings.set("vault_check", &check)?;
// Activate the vault and credentials service for this session. // Activate the vault and credentials service for this session.
let cred_svc = CredentialService::new(state.db.clone(), VaultService::new(key)); let cred_svc = CredentialService::new(state.db.clone(), VaultService::new(key));
*state.credentials.lock().unwrap() = Some(cred_svc); *state.credentials.lock().await = Some(cred_svc);
*state.vault.lock().unwrap() = Some(vs); *state.vault.lock().await = Some(vs);
Ok(()) Ok(())
}.await;
password.zeroize();
result
} }
/// Unlock an existing vault using the master password. /// Unlock an existing vault using the master password.
@ -52,42 +58,47 @@ pub fn create_vault(password: String, state: State<'_, AppState>) -> Result<(),
/// ///
/// Returns `Err("Incorrect master password")` if the password is wrong. /// Returns `Err("Incorrect master password")` if the password is wrong.
#[tauri::command] #[tauri::command]
pub fn unlock(password: String, state: State<'_, AppState>) -> Result<(), String> { pub async fn unlock(mut password: String, state: State<'_, AppState>) -> Result<(), String> {
let salt_hex = state let result = async {
.settings let salt_hex = state
.get("vault_salt") .settings
.ok_or_else(|| "Vault has not been set up — call create_vault first".to_string())?; .get("vault_salt")
.ok_or_else(|| "Vault has not been set up — call create_vault first".to_string())?;
let salt = hex::decode(&salt_hex) let salt = hex::decode(&salt_hex)
.map_err(|e| format!("Stored vault salt is corrupt: {e}"))?; .map_err(|e| format!("Stored vault salt is corrupt: {e}"))?;
let key = vault::derive_key(&password, &salt); let key = vault::derive_key(&password, &salt);
let vs = VaultService::new(key); let vs = VaultService::new(key.clone());
// Verify the password by decrypting the check value. // Verify the password by decrypting the check value.
let check_blob = state let check_blob = state
.settings .settings
.get("vault_check") .get("vault_check")
.ok_or_else(|| "Vault check value is missing — vault may be corrupt".to_string())?; .ok_or_else(|| "Vault check value is missing — vault may be corrupt".to_string())?;
let check_plain = vs let check_plain = vs
.decrypt(&check_blob) .decrypt(&check_blob)
.map_err(|_| "Incorrect master password".to_string())?; .map_err(|_| "Incorrect master password".to_string())?;
if check_plain != "wraith-vault-check" { if check_plain != "wraith-vault-check" {
return Err("Incorrect master password".into()); return Err("Incorrect master password".into());
} }
// Activate the vault and credentials service for this session. // Activate the vault and credentials service for this session.
let cred_svc = CredentialService::new(state.db.clone(), VaultService::new(key)); let cred_svc = CredentialService::new(state.db.clone(), VaultService::new(key));
*state.credentials.lock().unwrap() = Some(cred_svc); *state.credentials.lock().await = Some(cred_svc);
*state.vault.lock().unwrap() = Some(vs); *state.vault.lock().await = Some(vs);
Ok(()) Ok(())
}.await;
password.zeroize();
result
} }
/// Returns `true` if the vault is currently unlocked for this session. /// Returns `true` if the vault is currently unlocked for this session.
#[tauri::command] #[tauri::command]
pub fn is_unlocked(state: State<'_, AppState>) -> bool { pub async fn is_unlocked(state: State<'_, AppState>) -> Result<bool, String> {
state.is_unlocked() Ok(state.is_unlocked().await)
} }

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

@ -19,6 +19,7 @@ use crate::db::Database;
// ── domain types ────────────────────────────────────────────────────────────── // ── domain types ──────────────────────────────────────────────────────────────
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ConnectionGroup { pub struct ConnectionGroup {
pub id: i64, pub id: i64,
pub name: String, pub name: String,
@ -433,27 +434,49 @@ impl ConnectionService {
/// Batch-update sort_order for a list of connection IDs. /// Batch-update sort_order for a list of connection IDs.
pub fn reorder_connections(&self, ids: &[i64]) -> Result<(), String> { pub fn reorder_connections(&self, ids: &[i64]) -> Result<(), String> {
let conn = self.db.conn(); let conn = self.db.conn();
for (i, id) in ids.iter().enumerate() { conn.execute_batch("BEGIN")
conn.execute( .map_err(|e| format!("Failed to begin reorder transaction: {e}"))?;
"UPDATE connections SET sort_order = ?1 WHERE id = ?2", let result = (|| {
params![i as i64, id], for (i, id) in ids.iter().enumerate() {
) conn.execute(
.map_err(|e| format!("Failed to reorder connection {id}: {e}"))?; "UPDATE connections SET sort_order = ?1 WHERE id = ?2",
params![i as i64, id],
)
.map_err(|e| format!("Failed to reorder connection {id}: {e}"))?;
}
Ok(())
})();
if result.is_err() {
let _ = conn.execute_batch("ROLLBACK");
} else {
conn.execute_batch("COMMIT")
.map_err(|e| format!("Failed to commit reorder transaction: {e}"))?;
} }
Ok(()) result
} }
/// Batch-update sort_order for a list of group IDs. /// Batch-update sort_order for a list of group IDs.
pub fn reorder_groups(&self, ids: &[i64]) -> Result<(), String> { pub fn reorder_groups(&self, ids: &[i64]) -> Result<(), String> {
let conn = self.db.conn(); let conn = self.db.conn();
for (i, id) in ids.iter().enumerate() { conn.execute_batch("BEGIN")
conn.execute( .map_err(|e| format!("Failed to begin reorder transaction: {e}"))?;
"UPDATE groups SET sort_order = ?1 WHERE id = ?2", let result = (|| {
params![i as i64, id], for (i, id) in ids.iter().enumerate() {
) conn.execute(
.map_err(|e| format!("Failed to reorder group {id}: {e}"))?; "UPDATE groups SET sort_order = ?1 WHERE id = ?2",
params![i as i64, id],
)
.map_err(|e| format!("Failed to reorder group {id}: {e}"))?;
}
Ok(())
})();
if result.is_err() {
let _ = conn.execute_batch("ROLLBACK");
} else {
conn.execute_batch("COMMIT")
.map_err(|e| format!("Failed to commit reorder transaction: {e}"))?;
} }
Ok(()) result
} }
} }

View File

@ -31,10 +31,11 @@ impl Database {
/// Acquire a lock on the underlying connection. /// Acquire a lock on the underlying connection.
/// ///
/// Panics if the mutex was poisoned (which only happens if a thread /// Recovers gracefully from a poisoned mutex by taking the inner value.
/// panicked while holding the lock — a non-recoverable situation anyway). /// A poisoned mutex means a thread panicked while holding the lock; the
/// connection itself is still valid, so we can continue operating.
pub fn conn(&self) -> std::sync::MutexGuard<'_, Connection> { pub fn conn(&self) -> std::sync::MutexGuard<'_, Connection> {
self.conn.lock().unwrap() self.conn.lock().unwrap_or_else(|e| e.into_inner())
} }
/// Run all embedded SQL migrations. /// Run all embedded SQL migrations.

View File

@ -21,9 +21,9 @@ pub mod pty;
pub mod mcp; pub mod mcp;
pub mod scanner; pub mod scanner;
pub mod commands; pub mod commands;
pub mod utils;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Mutex;
use db::Database; use db::Database;
use vault::VaultService; use vault::VaultService;
@ -41,10 +41,10 @@ use mcp::error_watcher::ErrorWatcher;
pub struct AppState { pub struct AppState {
pub db: Database, pub db: Database,
pub vault: Mutex<Option<VaultService>>, pub vault: tokio::sync::Mutex<Option<VaultService>>,
pub settings: SettingsService, pub settings: SettingsService,
pub connections: ConnectionService, pub connections: ConnectionService,
pub credentials: Mutex<Option<CredentialService>>, pub credentials: tokio::sync::Mutex<Option<CredentialService>>,
pub ssh: SshService, pub ssh: SshService,
pub sftp: SftpService, pub sftp: SftpService,
pub rdp: RdpService, pub rdp: RdpService,
@ -60,17 +60,18 @@ impl AppState {
std::fs::create_dir_all(&data_dir)?; std::fs::create_dir_all(&data_dir)?;
let database = Database::open(&data_dir.join("wraith.db"))?; let database = Database::open(&data_dir.join("wraith.db"))?;
database.migrate()?; database.migrate()?;
let settings = SettingsService::new(database.clone());
Ok(Self { Ok(Self {
db: database.clone(), db: database.clone(),
vault: Mutex::new(None), vault: tokio::sync::Mutex::new(None),
settings: SettingsService::new(database.clone()),
connections: ConnectionService::new(database.clone()), connections: ConnectionService::new(database.clone()),
credentials: Mutex::new(None), credentials: tokio::sync::Mutex::new(None),
ssh: SshService::new(database.clone()), ssh: SshService::new(database.clone()),
sftp: SftpService::new(), sftp: SftpService::new(),
rdp: RdpService::new(), rdp: RdpService::new(),
theme: ThemeService::new(database.clone()), theme: ThemeService::new(database),
workspace: WorkspaceService::new(SettingsService::new(database.clone())), workspace: WorkspaceService::new(settings.clone()),
settings,
pty: PtyService::new(), pty: PtyService::new(),
scrollback: ScrollbackRegistry::new(), scrollback: ScrollbackRegistry::new(),
error_watcher: std::sync::Arc::new(ErrorWatcher::new()), error_watcher: std::sync::Arc::new(ErrorWatcher::new()),
@ -85,8 +86,8 @@ impl AppState {
self.settings.get("vault_salt").unwrap_or_default().is_empty() self.settings.get("vault_salt").unwrap_or_default().is_empty()
} }
pub fn is_unlocked(&self) -> bool { pub async fn is_unlocked(&self) -> bool {
self.vault.lock().unwrap().is_some() self.vault.lock().await.is_some()
} }
} }
@ -100,9 +101,24 @@ pub fn data_directory() -> PathBuf {
PathBuf::from(".") PathBuf::from(".")
} }
/// Cached log file handle — opened once on first use, reused for all subsequent
/// writes. Avoids the open/close syscall pair that the original implementation
/// paid on every `wraith_log!` invocation.
static LOG_FILE: std::sync::OnceLock<std::sync::Mutex<std::fs::File>> = std::sync::OnceLock::new();
fn write_log(path: &std::path::Path, msg: &str) -> std::io::Result<()> { fn write_log(path: &std::path::Path, msg: &str) -> std::io::Result<()> {
use std::io::Write; use std::io::Write;
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path)?;
let handle = LOG_FILE.get_or_init(|| {
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.expect("failed to open wraith.log");
std::sync::Mutex::new(file)
});
let mut f = handle.lock().unwrap_or_else(|e| e.into_inner());
let elapsed = std::time::SystemTime::now() let elapsed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default() .unwrap_or_default()
@ -208,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,
@ -218,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

@ -62,9 +62,9 @@ impl ErrorWatcher {
continue; continue;
} }
let raw = buf.read_raw(); // Only scan bytes written since the last check — avoids
let new_start = raw.len().saturating_sub(total - last_pos); // reading the entire 64 KB ring buffer on every 2-second tick.
let new_content = &raw[new_start..]; let new_content = buf.read_since(last_pos);
for line in new_content.lines() { for line in new_content.lines() {
for pattern in ERROR_PATTERNS { for pattern in ERROR_PATTERNS {

View File

@ -36,7 +36,7 @@ impl ScrollbackRegistry {
/// Get the scrollback buffer for a session. /// Get the scrollback buffer for a session.
pub fn get(&self, session_id: &str) -> Option<Arc<ScrollbackBuffer>> { pub fn get(&self, session_id: &str) -> Option<Arc<ScrollbackBuffer>> {
self.buffers.get(session_id).map(|entry| entry.clone()) self.buffers.get(session_id).map(|r| r.value().clone())
} }
/// Remove a session's scrollback buffer. /// Remove a session's scrollback buffer.

View File

@ -40,13 +40,25 @@ impl ScrollbackBuffer {
/// Append bytes to the buffer. Old data is overwritten when full. /// Append bytes to the buffer. Old data is overwritten when full.
pub fn push(&self, bytes: &[u8]) { pub fn push(&self, bytes: &[u8]) {
let mut buf = self.inner.lock().unwrap(); if bytes.is_empty() {
for &b in bytes { return;
let pos = buf.write_pos;
buf.data[pos] = b;
buf.write_pos = (pos + 1) % buf.capacity;
buf.total_written += 1;
} }
let mut buf = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let cap = buf.capacity;
// If input exceeds capacity, only keep the last `cap` bytes
let data = if bytes.len() > cap {
&bytes[bytes.len() - cap..]
} else {
bytes
};
let write_pos = buf.write_pos;
let first_len = (cap - write_pos).min(data.len());
buf.data[write_pos..write_pos + first_len].copy_from_slice(&data[..first_len]);
if first_len < data.len() {
buf.data[..data.len() - first_len].copy_from_slice(&data[first_len..]);
}
buf.write_pos = (write_pos + data.len()) % cap;
buf.total_written += bytes.len();
} }
/// Read the last `n` lines from the buffer, with ANSI escape codes stripped. /// Read the last `n` lines from the buffer, with ANSI escape codes stripped.
@ -60,7 +72,7 @@ impl ScrollbackBuffer {
/// Read all buffered content as raw bytes (ordered oldest→newest). /// Read all buffered content as raw bytes (ordered oldest→newest).
pub fn read_raw(&self) -> String { pub fn read_raw(&self) -> String {
let buf = self.inner.lock().unwrap(); let buf = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let bytes = if buf.total_written >= buf.capacity { let bytes = if buf.total_written >= buf.capacity {
// Buffer has wrapped — read from write_pos to end, then start to write_pos // Buffer has wrapped — read from write_pos to end, then start to write_pos
let mut out = Vec::with_capacity(buf.capacity); let mut out = Vec::with_capacity(buf.capacity);
@ -76,7 +88,47 @@ impl ScrollbackBuffer {
/// Total bytes written since creation. /// Total bytes written since creation.
pub fn total_written(&self) -> usize { pub fn total_written(&self) -> usize {
self.inner.lock().unwrap().total_written self.inner.lock().unwrap_or_else(|e| e.into_inner()).total_written
}
/// Read only the bytes written after `position` (total_written offset),
/// ordered oldest→newest, with ANSI codes stripped.
///
/// Returns an empty string when there is nothing new since `position`.
/// This is more efficient than `read_raw()` for incremental scanning because
/// it avoids copying the full 64 KB ring buffer when only a small delta exists.
pub fn read_since(&self, position: usize) -> String {
let buf = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let total = buf.total_written;
if total <= position {
return String::new();
}
let new_bytes = total - position;
let cap = buf.capacity;
// How many bytes are actually stored in the ring (max = capacity)
let stored = total.min(cap);
// Clamp new_bytes to what's actually in the buffer
let readable = new_bytes.min(stored);
// Write position is where the *next* byte would go; reading backwards
// from write_pos gives us the most recent `readable` bytes.
let write_pos = buf.write_pos;
let bytes = if readable <= write_pos {
// Contiguous slice ending at write_pos
buf.data[write_pos - readable..write_pos].to_vec()
} else {
// Wraps around: tail of buffer + head up to write_pos
let tail_len = readable - write_pos;
let tail_start = cap - tail_len;
let mut out = Vec::with_capacity(readable);
out.extend_from_slice(&buf.data[tail_start..]);
out.extend_from_slice(&buf.data[..write_pos]);
out
};
let raw = String::from_utf8_lossy(&bytes).to_string();
strip_ansi(&raw)
} }
} }
@ -192,4 +244,42 @@ mod tests {
buf.push(b"ABCD"); // 4 more, wraps buf.push(b"ABCD"); // 4 more, wraps
assert_eq!(buf.total_written(), 12); assert_eq!(buf.total_written(), 12);
} }
#[test]
fn push_empty_is_noop() {
let buf = ScrollbackBuffer::with_capacity(8);
buf.push(b"hello");
buf.push(b"");
assert_eq!(buf.total_written(), 5);
assert!(buf.read_raw().contains("hello"));
}
#[test]
fn push_larger_than_capacity() {
let buf = ScrollbackBuffer::with_capacity(4);
buf.push(b"ABCDEFGH"); // 8 bytes into 4-byte buffer
let raw = buf.read_raw();
assert_eq!(raw, "EFGH"); // only last 4 bytes kept
assert_eq!(buf.total_written(), 8);
}
#[test]
fn push_exact_capacity() {
let buf = ScrollbackBuffer::with_capacity(8);
buf.push(b"12345678");
let raw = buf.read_raw();
assert_eq!(raw, "12345678");
assert_eq!(buf.total_written(), 8);
}
#[test]
fn push_wrap_around_boundary() {
let buf = ScrollbackBuffer::with_capacity(8);
buf.push(b"123456"); // write_pos = 6
buf.push(b"ABCD"); // wraps: 2 at end, 2 at start
let raw = buf.read_raw();
// Buffer: [C, D, 3, 4, 5, 6, A, B], write_pos=2
// Read from pos 2: "3456AB" + wrap: no, read from write_pos to end then start
assert_eq!(raw, "3456ABCD");
}
} }

View File

@ -5,14 +5,23 @@
use std::sync::Arc; use std::sync::Arc;
use axum::{extract::State as AxumState, routing::post, Json, Router}; use axum::{
extract::State as AxumState,
http::{Request, StatusCode},
middleware::{self, Next},
response::Response,
routing::post,
Json, Router,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use crate::mcp::ScrollbackRegistry; use crate::mcp::ScrollbackRegistry;
use crate::rdp::RdpService; use crate::rdp::RdpService;
use crate::sftp::SftpService; use crate::sftp::SftpService;
use crate::ssh::exec::exec_on_session;
use crate::ssh::session::SshService; use crate::ssh::session::SshService;
use crate::utils::shell_escape;
/// Shared state passed to axum handlers. /// Shared state passed to axum handlers.
pub struct McpServerState { pub struct McpServerState {
@ -22,6 +31,27 @@ pub struct McpServerState {
pub scrollback: ScrollbackRegistry, pub scrollback: ScrollbackRegistry,
pub app_handle: tauri::AppHandle, pub app_handle: tauri::AppHandle,
pub error_watcher: std::sync::Arc<crate::mcp::error_watcher::ErrorWatcher>, pub error_watcher: std::sync::Arc<crate::mcp::error_watcher::ErrorWatcher>,
pub bearer_token: String,
}
/// Middleware that validates the `Authorization: Bearer <token>` header.
async fn auth_middleware(
AxumState(state): AxumState<Arc<McpServerState>>,
req: Request<axum::body::Body>,
next: Next,
) -> Result<Response, StatusCode> {
let auth_header = req
.headers()
.get("authorization")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let expected = format!("Bearer {}", state.bearer_token);
if auth_header != expected {
return Err(StatusCode::UNAUTHORIZED);
}
Ok(next.run(req).await)
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -157,7 +187,7 @@ async fn handle_screenshot(
AxumState(state): AxumState<Arc<McpServerState>>, AxumState(state): AxumState<Arc<McpServerState>>,
Json(req): Json<ScreenshotRequest>, Json(req): Json<ScreenshotRequest>,
) -> Json<McpResponse<String>> { ) -> Json<McpResponse<String>> {
match state.rdp.screenshot_png_base64(&req.session_id).await { match state.rdp.screenshot_png_base64(&req.session_id) {
Ok(b64) => ok_response(b64), Ok(b64) => ok_response(b64),
Err(e) => err_response(e), Err(e) => err_response(e),
} }
@ -279,30 +309,32 @@ struct ToolPassgenRequest { length: Option<usize>, uppercase: Option<bool>, lowe
async fn handle_tool_ping(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionTarget>) -> Json<McpResponse<String>> { async fn handle_tool_ping(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionTarget>) -> Json<McpResponse<String>> {
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) }; let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
match tool_exec(&session.handle, &format!("ping -c 4 {} 2>&1", req.target)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } match exec_on_session(&session.handle, &format!("ping -c 4 {} 2>&1", shell_escape(&req.target))).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
} }
async fn handle_tool_traceroute(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionTarget>) -> Json<McpResponse<String>> { async fn handle_tool_traceroute(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionTarget>) -> Json<McpResponse<String>> {
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) }; let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
match tool_exec(&session.handle, &format!("traceroute {} 2>&1 || tracert {} 2>&1", req.target, req.target)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } let t = shell_escape(&req.target);
match exec_on_session(&session.handle, &format!("traceroute {} 2>&1 || tracert {} 2>&1", t, t)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
} }
async fn handle_tool_dns(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolDnsRequest>) -> Json<McpResponse<String>> { async fn handle_tool_dns(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolDnsRequest>) -> Json<McpResponse<String>> {
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) }; let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
let rt = req.record_type.unwrap_or_else(|| "A".to_string()); let rt = shell_escape(&req.record_type.unwrap_or_else(|| "A".to_string()));
match tool_exec(&session.handle, &format!("dig {} {} +short 2>/dev/null || nslookup -type={} {} 2>/dev/null || host -t {} {} 2>/dev/null", req.domain, rt, rt, req.domain, rt, req.domain)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } let d = shell_escape(&req.domain);
match exec_on_session(&session.handle, &format!("dig {} {} +short 2>/dev/null || nslookup -type={} {} 2>/dev/null || host -t {} {} 2>/dev/null", d, rt, rt, d, rt, d)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
} }
async fn handle_tool_whois(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionTarget>) -> Json<McpResponse<String>> { async fn handle_tool_whois(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionTarget>) -> Json<McpResponse<String>> {
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) }; let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
match tool_exec(&session.handle, &format!("whois {} 2>&1 | head -80", req.target)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } match exec_on_session(&session.handle, &format!("whois {} 2>&1 | head -80", shell_escape(&req.target))).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
} }
async fn handle_tool_wol(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolWolRequest>) -> Json<McpResponse<String>> { async fn handle_tool_wol(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolWolRequest>) -> Json<McpResponse<String>> {
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) }; let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
let mac_clean = req.mac_address.replace([':', '-'], ""); let mac_clean = req.mac_address.replace([':', '-'], "");
let cmd = format!(r#"python3 -c "import socket;mac=bytes.fromhex('{}');pkt=b'\xff'*6+mac*16;s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM);s.setsockopt(socket.SOL_SOCKET,socket.SO_BROADCAST,1);s.sendto(pkt,('255.255.255.255',9));s.close();print('WoL sent to {}')" 2>&1"#, mac_clean, req.mac_address); let cmd = format!(r#"python3 -c "import socket;mac=bytes.fromhex({});pkt=b'\xff'*6+mac*16;s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM);s.setsockopt(socket.SOL_SOCKET,socket.SO_BROADCAST,1);s.sendto(pkt,('255.255.255.255',9));s.close();print('WoL sent to {}')" 2>&1"#, shell_escape(&mac_clean), shell_escape(&req.mac_address));
match tool_exec(&session.handle, &cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } match exec_on_session(&session.handle, &cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
} }
async fn handle_tool_scan_network(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolScanNetworkRequest>) -> Json<McpResponse<serde_json::Value>> { async fn handle_tool_scan_network(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolScanNetworkRequest>) -> Json<McpResponse<serde_json::Value>> {
@ -333,7 +365,7 @@ async fn handle_tool_subnet(_state: AxumState<Arc<McpServerState>>, Json(req): J
async fn handle_tool_bandwidth(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionOnly>) -> Json<McpResponse<String>> { async fn handle_tool_bandwidth(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionOnly>) -> Json<McpResponse<String>> {
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) }; let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
let cmd = r#"if command -v speedtest-cli >/dev/null 2>&1; then speedtest-cli --simple 2>&1; elif command -v curl >/dev/null 2>&1; then curl -o /dev/null -w "Download: %{speed_download} bytes/sec\n" https://speed.cloudflare.com/__down?bytes=25000000 2>/dev/null; else echo "No speedtest tool found"; fi"#; let cmd = r#"if command -v speedtest-cli >/dev/null 2>&1; then speedtest-cli --simple 2>&1; elif command -v curl >/dev/null 2>&1; then curl -o /dev/null -w "Download: %{speed_download} bytes/sec\n" https://speed.cloudflare.com/__down?bytes=25000000 2>/dev/null; else echo "No speedtest tool found"; fi"#;
match tool_exec(&session.handle, cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } match exec_on_session(&session.handle, cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
} }
async fn handle_tool_keygen(_state: AxumState<Arc<McpServerState>>, Json(req): Json<ToolKeygenRequest>) -> Json<McpResponse<serde_json::Value>> { async fn handle_tool_keygen(_state: AxumState<Arc<McpServerState>>, Json(req): Json<ToolKeygenRequest>) -> Json<McpResponse<serde_json::Value>> {
@ -350,20 +382,6 @@ async fn handle_tool_passgen(_state: AxumState<Arc<McpServerState>>, Json(req):
} }
} }
async fn tool_exec(handle: &std::sync::Arc<tokio::sync::Mutex<russh::client::Handle<crate::ssh::session::SshClient>>>, cmd: &str) -> Result<String, String> {
let mut channel = { let h = handle.lock().await; h.channel_open_session().await.map_err(|e| format!("Exec failed: {}", e))? };
channel.exec(true, cmd).await.map_err(|e| format!("Exec failed: {}", e))?;
let mut output = String::new();
loop {
match channel.wait().await {
Some(russh::ChannelMsg::Data { ref data }) => { if let Ok(t) = std::str::from_utf8(data.as_ref()) { output.push_str(t); } }
Some(russh::ChannelMsg::Eof) | Some(russh::ChannelMsg::Close) | None => break,
_ => {}
}
}
Ok(output)
}
// ── Docker handlers ────────────────────────────────────────────────────────── // ── Docker handlers ──────────────────────────────────────────────────────────
#[derive(Deserialize)] #[derive(Deserialize)]
@ -377,41 +395,43 @@ struct DockerExecRequest { session_id: String, container: String, command: Strin
async fn handle_docker_ps(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<DockerListRequest>) -> Json<McpResponse<String>> { async fn handle_docker_ps(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<DockerListRequest>) -> Json<McpResponse<String>> {
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) }; let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
match tool_exec(&session.handle, "docker ps -a --format '{{.Names}}|{{.Image}}|{{.Status}}|{{.Ports}}' 2>&1").await { Ok(o) => ok_response(o), Err(e) => err_response(e) } match exec_on_session(&session.handle, "docker ps -a --format '{{.Names}}|{{.Image}}|{{.Status}}|{{.Ports}}' 2>&1").await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
} }
async fn handle_docker_action(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<DockerActionRequest>) -> Json<McpResponse<String>> { async fn handle_docker_action(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<DockerActionRequest>) -> Json<McpResponse<String>> {
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) }; let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
let t = shell_escape(&req.target);
let cmd = match req.action.as_str() { let cmd = match req.action.as_str() {
"start" => format!("docker start {} 2>&1", req.target), "start" => format!("docker start {} 2>&1", t),
"stop" => format!("docker stop {} 2>&1", req.target), "stop" => format!("docker stop {} 2>&1", t),
"restart" => format!("docker restart {} 2>&1", req.target), "restart" => format!("docker restart {} 2>&1", t),
"remove" => format!("docker rm -f {} 2>&1", req.target), "remove" => format!("docker rm -f {} 2>&1", t),
"logs" => format!("docker logs --tail 100 {} 2>&1", req.target), "logs" => format!("docker logs --tail 100 {} 2>&1", t),
"builder-prune" => "docker builder prune -f 2>&1".to_string(), "builder-prune" => "docker builder prune -f 2>&1".to_string(),
"system-prune" => "docker system prune -f 2>&1".to_string(), "system-prune" => "docker system prune -f 2>&1".to_string(),
_ => return err_response(format!("Unknown action: {}", req.action)), _ => return err_response(format!("Unknown action: {}", req.action)),
}; };
match tool_exec(&session.handle, &cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } match exec_on_session(&session.handle, &cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
} }
async fn handle_docker_exec(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<DockerExecRequest>) -> Json<McpResponse<String>> { async fn handle_docker_exec(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<DockerExecRequest>) -> Json<McpResponse<String>> {
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) }; let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
let cmd = format!("docker exec {} {} 2>&1", req.container, req.command); let cmd = format!("docker exec {} {} 2>&1", shell_escape(&req.container), shell_escape(&req.command));
match tool_exec(&session.handle, &cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } match exec_on_session(&session.handle, &cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
} }
// ── Service/process handlers ───────────────────────────────────────────────── // ── Service/process handlers ─────────────────────────────────────────────────
async fn handle_service_status(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionTarget>) -> Json<McpResponse<String>> { async fn handle_service_status(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionTarget>) -> Json<McpResponse<String>> {
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) }; let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
match tool_exec(&session.handle, &format!("systemctl status {} --no-pager 2>&1 || service {} status 2>&1", req.target, req.target)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } let t = shell_escape(&req.target);
match exec_on_session(&session.handle, &format!("systemctl status {} --no-pager 2>&1 || service {} status 2>&1", t, t)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
} }
async fn handle_process_list(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionTarget>) -> Json<McpResponse<String>> { async fn handle_process_list(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionTarget>) -> Json<McpResponse<String>> {
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) }; let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
let filter = if req.target.is_empty() { "aux --sort=-%cpu | head -30".to_string() } else { format!("aux | grep -i {} | grep -v grep", req.target) }; let filter = if req.target.is_empty() { "aux --sort=-%cpu | head -30".to_string() } else { format!("aux | grep -i {} | grep -v grep", shell_escape(&req.target)) };
match tool_exec(&session.handle, &format!("ps {}", filter)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } match exec_on_session(&session.handle, &format!("ps {}", filter)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
} }
// ── Git handlers ───────────────────────────────────────────────────────────── // ── Git handlers ─────────────────────────────────────────────────────────────
@ -421,17 +441,17 @@ struct GitRequest { session_id: String, path: String }
async fn handle_git_status(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<GitRequest>) -> Json<McpResponse<String>> { async fn handle_git_status(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<GitRequest>) -> Json<McpResponse<String>> {
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) }; let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
match tool_exec(&session.handle, &format!("cd {} && git status --short --branch 2>&1", req.path)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } match exec_on_session(&session.handle, &format!("cd {} && git status --short --branch 2>&1", shell_escape(&req.path))).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
} }
async fn handle_git_pull(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<GitRequest>) -> Json<McpResponse<String>> { async fn handle_git_pull(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<GitRequest>) -> Json<McpResponse<String>> {
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) }; let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
match tool_exec(&session.handle, &format!("cd {} && git pull 2>&1", req.path)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } match exec_on_session(&session.handle, &format!("cd {} && git pull 2>&1", shell_escape(&req.path))).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
} }
async fn handle_git_log(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<GitRequest>) -> Json<McpResponse<String>> { async fn handle_git_log(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<GitRequest>) -> Json<McpResponse<String>> {
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) }; let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
match tool_exec(&session.handle, &format!("cd {} && git log --oneline -20 2>&1", req.path)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } match exec_on_session(&session.handle, &format!("cd {} && git log --oneline -20 2>&1", shell_escape(&req.path))).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
} }
// ── Session creation handlers ──────────────────────────────────────────────── // ── Session creation handlers ────────────────────────────────────────────────
@ -533,7 +553,15 @@ pub async fn start_mcp_server(
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
error_watcher: std::sync::Arc<crate::mcp::error_watcher::ErrorWatcher>, error_watcher: std::sync::Arc<crate::mcp::error_watcher::ErrorWatcher>,
) -> Result<u16, String> { ) -> Result<u16, String> {
let state = Arc::new(McpServerState { ssh, rdp, sftp, scrollback, app_handle, error_watcher }); // Generate a cryptographically random bearer token for authentication
use rand::Rng;
let bearer_token: String = rand::rng()
.sample_iter(&rand::distr::Alphanumeric)
.take(64)
.map(char::from)
.collect();
let state = Arc::new(McpServerState { ssh, rdp, sftp, scrollback, app_handle, error_watcher, bearer_token: bearer_token.clone() });
let app = Router::new() let app = Router::new()
.route("/mcp/sessions", post(handle_list_sessions)) .route("/mcp/sessions", post(handle_list_sessions))
@ -567,6 +595,7 @@ pub async fn start_mcp_server(
.route("/mcp/rdp/type", post(handle_rdp_type)) .route("/mcp/rdp/type", post(handle_rdp_type))
.route("/mcp/rdp/clipboard", post(handle_rdp_clipboard)) .route("/mcp/rdp/clipboard", post(handle_rdp_clipboard))
.route("/mcp/ssh/connect", post(handle_ssh_connect)) .route("/mcp/ssh/connect", post(handle_ssh_connect))
.layer(middleware::from_fn_with_state(state.clone(), auth_middleware))
.with_state(state); .with_state(state);
let listener = TcpListener::bind("127.0.0.1:0").await let listener = TcpListener::bind("127.0.0.1:0").await
@ -577,10 +606,23 @@ pub async fn start_mcp_server(
.port(); .port();
// Write port to well-known location // Write port to well-known location
let port_file = crate::data_directory().join("mcp-port"); let data_dir = crate::data_directory();
let port_file = data_dir.join("mcp-port");
std::fs::write(&port_file, port.to_string()) std::fs::write(&port_file, port.to_string())
.map_err(|e| format!("Failed to write MCP port file: {}", e))?; .map_err(|e| format!("Failed to write MCP port file: {}", e))?;
// Write bearer token to a separate file with restrictive permissions
let token_file = data_dir.join("mcp-token");
std::fs::write(&token_file, &bearer_token)
.map_err(|e| format!("Failed to write MCP token file: {}", e))?;
// Set owner-only read/write permissions (Unix)
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&token_file, std::fs::Permissions::from_mode(0o600));
}
tokio::spawn(async move { tokio::spawn(async move {
axum::serve(listener, app).await.ok(); axum::serve(listener, app).await.ok();
}); });

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,15 +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: Arc<TokioMutex<Vec<u8>>>, /// Frame buffer: RDP thread writes via RwLock write, IPC reads via RwLock read.
front_buffer: Arc<std::sync::RwLock<Vec<u8>>>,
/// Accumulated dirty region since last get_frame — union of all GraphicsUpdate rects.
dirty_region: Arc<std::sync::Mutex<Option<DirtyRect>>>,
frame_dirty: Arc<AtomicBool>, frame_dirty: Arc<AtomicBool>,
input_tx: mpsc::UnboundedSender<InputEvent>, input_tx: mpsc::UnboundedSender<InputEvent>,
} }
@ -99,7 +113,8 @@ 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 frame_buffer = Arc::new(TokioMutex::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();
@ -109,7 +124,8 @@ impl RdpService {
hostname: hostname.clone(), hostname: hostname.clone(),
width, width,
height, height,
frame_buffer: frame_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,
}); });
@ -156,7 +172,8 @@ impl RdpService {
if let Err(e) = run_active_session( if let Err(e) = run_active_session(
connection_result, connection_result,
framed, framed,
frame_buffer, front_buffer,
dirty_region,
frame_dirty, frame_dirty,
input_rx, input_rx,
width as u16, width as u16,
@ -200,27 +217,57 @@ impl RdpService {
Ok(session_id) Ok(session_id)
} }
pub async 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::Relaxed) { 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 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()))
}
} }
let buf = handle.frame_buffer.lock().await;
Ok(buf.clone())
} }
pub async 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> {
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 buf = handle.frame_buffer.lock().await; let buf = handle.front_buffer.read().unwrap_or_else(|e| e.into_inner());
Ok(buf.clone()) Ok(buf.clone())
} }
/// Capture the current RDP frame as a base64-encoded PNG. /// Capture the current RDP frame as a base64-encoded PNG.
pub async fn screenshot_png_base64(&self, session_id: &str) -> Result<String, String> { pub fn screenshot_png_base64(&self, session_id: &str) -> Result<String, String> {
let handle = self.sessions.get(session_id).ok_or_else(|| format!("RDP session {} not found", session_id))?; let handle = self.sessions.get(session_id).ok_or_else(|| format!("RDP session {} not found", session_id))?;
let width = handle.width as u32; let width = handle.width as u32;
let height = handle.height as u32; let height = handle.height as u32;
let buf = handle.frame_buffer.lock().await; let buf = handle.front_buffer.read().unwrap_or_else(|e| e.into_inner());
// Encode RGBA raw bytes to PNG (fast compression for speed) // Encode RGBA raw bytes to PNG (fast compression for speed)
let mut png_data = Vec::new(); let mut png_data = Vec::new();
@ -253,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);
@ -306,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,
@ -336,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, frame_buffer: Arc<TokioMutex<Vec<u8>>>, frame_dirty: Arc<AtomicBool>, mut input_rx: mpsc::UnboundedReceiver<InputEvent>, width: u16, height: u16, app_handle: tauri::AppHandle, session_id: String) -> Result<(), String> { 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);
@ -388,18 +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) => {
let mut buf = frame_buffer.lock().await; let rx = region.left as usize;
let src = image.data(); let ry = region.top as usize;
if src.len() == buf.len() { buf.copy_from_slice(src); } else { *buf = src.to_vec(); } let rr = (region.right as usize).saturating_add(1).min(width as usize);
frame_dirty.store(true, Ordering::Relaxed); let rb = (region.bottom as usize).saturating_add(1).min(height as usize);
// Push frame notification to frontend — no data, just a signal to fetch let stride = width as usize * 4;
// Copy only the dirty rectangle rows from decoded image → front buffer
{
let src = image.data();
let mut front = front_buffer.write().unwrap_or_else(|e| e.into_inner());
for row in ry..rb {
let src_start = row * stride + rx * 4;
let src_end = row * stride + rr * 4;
if src_end <= src.len() && src_end <= front.len() {
front[src_start..src_end].copy_from_slice(&src[src_start..src_end]);
}
}
}
// Accumulate dirty region (union of all rects since last get_frame)
{
let new_rect = DirtyRect { x: rx as u16, y: ry as u16, width: (rr - rx) as u16, height: (rb - ry) as u16 };
let mut dr = dirty_region.lock().unwrap_or_else(|e| e.into_inner());
*dr = Some(match dr.take() {
None => new_rect,
Some(prev) => {
let x = prev.x.min(new_rect.x);
let y = prev.y.min(new_rect.y);
let r = (prev.x + prev.width).max(new_rect.x + new_rect.width);
let b = (prev.y + prev.height).max(new_rect.y + new_rect.height);
DirtyRect { x, y, width: r - x, height: b - y }
}
});
}
frame_dirty.store(true, Ordering::Release);
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(()); }

View File

@ -12,6 +12,7 @@ use serde::Serialize;
use tokio::sync::Mutex as TokioMutex; use tokio::sync::Mutex as TokioMutex;
use crate::ssh::session::SshClient; use crate::ssh::session::SshClient;
use crate::utils::shell_escape;
#[derive(Debug, Serialize, Clone)] #[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@ -63,18 +64,44 @@ fn service_name(port: u16) -> &'static str {
} }
} }
/// Validate that `subnet` contains exactly three dot-separated octet groups,
/// each consisting only of 13 ASCII digits (e.g. "192.168.1").
/// Returns an error string if the format is invalid.
fn validate_subnet(subnet: &str) -> Result<(), String> {
let parts: Vec<&str> = subnet.split('.').collect();
if parts.len() != 3 {
return Err(format!(
"Invalid subnet '{}': expected three octets (e.g. 192.168.1)",
subnet
));
}
for part in &parts {
if part.is_empty() || part.len() > 3 || !part.chars().all(|c| c.is_ascii_digit()) {
return Err(format!(
"Invalid subnet '{}': each octet must be 13 decimal digits",
subnet
));
}
}
Ok(())
}
/// Discover hosts on the remote network using ARP table and ping sweep. /// Discover hosts on the remote network using ARP table and ping sweep.
pub async fn scan_network( pub async fn scan_network(
handle: &Arc<TokioMutex<Handle<SshClient>>>, handle: &Arc<TokioMutex<Handle<SshClient>>>,
subnet: &str, subnet: &str,
) -> Result<Vec<DiscoveredHost>, String> { ) -> Result<Vec<DiscoveredHost>, String> {
// Validate subnet format before using it in remote shell commands.
validate_subnet(subnet)?;
// Script that works on Linux and macOS: // Script that works on Linux and macOS:
// 1. Ping sweep the subnet to populate ARP cache // 1. Ping sweep the subnet to populate ARP cache
// 2. Read ARP table for IP/MAC pairs // 2. Read ARP table for IP/MAC pairs
// 3. Try reverse DNS for hostnames // 3. Try reverse DNS for hostnames
let escaped_subnet = shell_escape(subnet);
let script = format!(r#" let script = format!(r#"
OS=$(uname -s 2>/dev/null) OS=$(uname -s 2>/dev/null)
SUBNET="{subnet}" SUBNET={escaped_subnet}
# Ping sweep (background, fast) # Ping sweep (background, fast)
if [ "$OS" = "Linux" ]; then if [ "$OS" = "Linux" ]; then
@ -151,6 +178,12 @@ pub async fn scan_ports(
target: &str, target: &str,
ports: &[u16], ports: &[u16],
) -> Result<Vec<PortResult>, String> { ) -> Result<Vec<PortResult>, String> {
// Validate target — /dev/tcp requires a bare hostname/IP, not a shell-quoted value.
// Only allow alphanumeric, dots, hyphens, and colons (for IPv6).
if !target.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == ':') {
return Err(format!("Invalid target for port scan: {}", target));
}
// Use bash /dev/tcp for port scanning — no nmap required // Use bash /dev/tcp for port scanning — no nmap required
let port_checks: Vec<String> = ports.iter() let port_checks: Vec<String> = ports.iter()
.map(|p| format!( .map(|p| format!(

View File

@ -8,6 +8,7 @@ use crate::db::Database;
/// ///
/// All operations acquire the shared DB mutex for their duration and /// All operations acquire the shared DB mutex for their duration and
/// return immediately — no async needed for a local SQLite store. /// return immediately — no async needed for a local SQLite store.
#[derive(Clone)]
pub struct SettingsService { pub struct SettingsService {
db: Database, db: Database,
} }

View File

@ -5,7 +5,6 @@
//! provides all file operations needed by the frontend. //! provides all file operations needed by the frontend.
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, UNIX_EPOCH};
use dashmap::DashMap; use dashmap::DashMap;
use log::{debug, info}; use log::{debug, info};
@ -35,9 +34,6 @@ pub struct FileEntry {
/// Format a Unix timestamp (seconds since epoch) as "Mon DD HH:MM". /// Format a Unix timestamp (seconds since epoch) as "Mon DD HH:MM".
fn format_mtime(unix_secs: u32) -> String { fn format_mtime(unix_secs: u32) -> String {
// Build a SystemTime from the raw epoch value.
let st = UNIX_EPOCH + Duration::from_secs(unix_secs as u64);
// Convert to seconds-since-epoch for manual formatting. We avoid pulling // Convert to seconds-since-epoch for manual formatting. We avoid pulling
// in chrono just for this; a simple manual decomposition is sufficient for // in chrono just for this; a simple manual decomposition is sufficient for
// the "Mar 17 14:30" display format expected by the frontend. // the "Mar 17 14:30" display format expected by the frontend.
@ -54,12 +50,10 @@ fn format_mtime(unix_secs: u32) -> String {
let era = if z >= 0 { z } else { z - 146_096 } / 146_097; let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097; let doe = z - era * 146_097;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153; let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1; let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 }; let m = if mp < 10 { mp + 3 } else { mp - 9 };
let _y = if m <= 2 { y + 1 } else { y };
let month = match m { let month = match m {
1 => "Jan", 1 => "Jan",
@ -77,9 +71,6 @@ fn format_mtime(unix_secs: u32) -> String {
_ => "???", _ => "???",
}; };
// Suppress unused variable warning — st is only used as a sanity anchor.
let _ = st;
format!("{} {:2} {:02}:{:02}", month, d, hours, minutes) format!("{} {:2} {:02}:{:02}", month, d, hours, minutes)
} }
@ -319,7 +310,7 @@ impl SftpService {
) -> Result<Arc<TokioMutex<SftpSession>>, String> { ) -> Result<Arc<TokioMutex<SftpSession>>, String> {
self.clients self.clients
.get(session_id) .get(session_id)
.map(|r| r.clone()) .map(|r| r.value().clone())
.ok_or_else(|| format!("No SFTP client for session {}", session_id)) .ok_or_else(|| format!("No SFTP client for session {}", session_id))
} }
} }

View File

@ -16,6 +16,7 @@ use russh::ChannelMsg;
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
use tokio::sync::watch; use tokio::sync::watch;
use tokio::sync::Mutex as TokioMutex; use tokio::sync::Mutex as TokioMutex;
use tokio_util::sync::CancellationToken;
use crate::ssh::session::SshClient; use crate::ssh::session::SshClient;
@ -39,13 +40,15 @@ impl CwdTracker {
/// Spawn a background tokio task that polls `pwd` every 2 seconds on a /// Spawn a background tokio task that polls `pwd` every 2 seconds on a
/// separate exec channel. /// separate exec channel.
/// ///
/// The task runs until the SSH connection is closed or the channel cannot /// The task runs until cancelled via the `CancellationToken`, or until the
/// be opened. CWD changes are emitted as `ssh:cwd:{session_id}` events. /// SSH connection is closed or the channel cannot be opened.
/// CWD changes are emitted as `ssh:cwd:{session_id}` events.
pub fn start( pub fn start(
&self, &self,
handle: Arc<TokioMutex<Handle<SshClient>>>, handle: Arc<TokioMutex<Handle<SshClient>>>,
app_handle: AppHandle, app_handle: AppHandle,
session_id: String, session_id: String,
cancel: CancellationToken,
) { ) {
let sender = self._sender.clone(); let sender = self._sender.clone();
@ -56,6 +59,10 @@ impl CwdTracker {
let mut previous_cwd = String::new(); let mut previous_cwd = String::new();
loop { loop {
if cancel.is_cancelled() {
break;
}
// Open a fresh exec channel for each `pwd` invocation. // Open a fresh exec channel for each `pwd` invocation.
// Some SSH servers do not allow multiple exec requests on a // Some SSH servers do not allow multiple exec requests on a
// single channel, so we open a new one each time. // single channel, so we open a new one each time.
@ -119,8 +126,11 @@ impl CwdTracker {
} }
} }
// Wait 2 seconds before the next poll. // Wait 2 seconds before the next poll, or cancel.
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; tokio::select! {
_ = tokio::time::sleep(tokio::time::Duration::from_secs(2)) => {}
_ = cancel.cancelled() => { break; }
}
} }
debug!("CWD tracker for session {} stopped", session_id); debug!("CWD tracker for session {} stopped", session_id);

51
src-tauri/src/ssh/exec.rs Normal file
View File

@ -0,0 +1,51 @@
//! Shared SSH exec-channel helper used by commands, MCP handlers, and tools.
//!
//! Opens a one-shot exec channel on an existing SSH handle, runs `cmd`, collects
//! all stdout/stderr, and returns it as a `String`. The caller is responsible
//! for ensuring the session is still alive.
use std::sync::Arc;
use tokio::sync::Mutex as TokioMutex;
use crate::ssh::session::SshClient;
/// Execute `cmd` on a separate exec channel and return all output as a `String`.
///
/// Locks the handle for only as long as it takes to open the channel, then
/// releases it before reading — this avoids holding the lock while waiting on
/// remote I/O.
pub async fn exec_on_session(
handle: &Arc<TokioMutex<russh::client::Handle<SshClient>>>,
cmd: &str,
) -> Result<String, String> {
let mut channel = {
let h = handle.lock().await;
h.channel_open_session()
.await
.map_err(|e| format!("Exec channel failed: {}", e))?
};
channel
.exec(true, cmd)
.await
.map_err(|e| format!("Exec failed: {}", e))?;
let mut output = String::new();
loop {
match channel.wait().await {
Some(russh::ChannelMsg::Data { ref data }) => {
if let Ok(text) = std::str::from_utf8(data.as_ref()) {
output.push_str(text);
}
}
Some(russh::ChannelMsg::Eof)
| Some(russh::ChannelMsg::Close)
| None => break,
Some(russh::ChannelMsg::ExitStatus { .. }) => {}
_ => {}
}
}
Ok(output)
}

View File

@ -2,3 +2,4 @@ pub mod session;
pub mod host_key; pub mod host_key;
pub mod cwd; pub mod cwd;
pub mod monitor; pub mod monitor;
pub mod exec;

View File

@ -6,11 +6,13 @@
use std::sync::Arc; use std::sync::Arc;
use log::warn;
use russh::client::Handle; use russh::client::Handle;
use russh::ChannelMsg; use russh::ChannelMsg;
use serde::Serialize; use serde::Serialize;
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
use tokio::sync::Mutex as TokioMutex; use tokio::sync::Mutex as TokioMutex;
use tokio_util::sync::CancellationToken;
use crate::ssh::session::SshClient; use crate::ssh::session::SshClient;
@ -30,26 +32,53 @@ pub struct SystemStats {
} }
/// Spawn a background task that polls system stats every 5 seconds. /// Spawn a background task that polls system stats every 5 seconds.
///
/// The task runs until cancelled via the `CancellationToken`, or until the
/// SSH connection is closed.
pub fn start_monitor( pub fn start_monitor(
handle: Arc<TokioMutex<Handle<SshClient>>>, handle: Arc<TokioMutex<Handle<SshClient>>>,
app_handle: AppHandle, app_handle: AppHandle,
session_id: String, session_id: String,
cancel: CancellationToken,
) { ) {
tokio::spawn(async move { tokio::spawn(async move {
// Brief delay to let the shell start up // Brief delay to let the shell start up
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
loop { let mut consecutive_timeouts: u32 = 0;
let stats = collect_stats(&handle).await;
if let Some(stats) = stats { loop {
let _ = app_handle.emit( if cancel.is_cancelled() {
&format!("ssh:monitor:{}", session_id), break;
&stats,
);
} }
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; let stats = collect_stats(&handle).await;
match stats {
Some(stats) => {
consecutive_timeouts = 0;
let _ = app_handle.emit(
&format!("ssh:monitor:{}", session_id),
&stats,
);
}
None => {
consecutive_timeouts += 1;
if consecutive_timeouts >= 3 {
warn!(
"SSH monitor for session {}: 3 consecutive failures, stopping",
session_id
);
break;
}
}
}
// Wait 5 seconds before the next poll, or cancel.
tokio::select! {
_ = tokio::time::sleep(tokio::time::Duration::from_secs(5)) => {}
_ = cancel.cancelled() => { break; }
}
} }
}); });
} }
@ -125,7 +154,24 @@ fn parse_stats(raw: &str) -> Option<SystemStats> {
}) })
} }
/// Execute a command on a separate exec channel with a 10-second timeout.
async fn exec_command(handle: &Arc<TokioMutex<Handle<SshClient>>>, cmd: &str) -> Option<String> { async fn exec_command(handle: &Arc<TokioMutex<Handle<SshClient>>>, cmd: &str) -> Option<String> {
let result = tokio::time::timeout(
std::time::Duration::from_secs(10),
exec_command_inner(handle, cmd),
)
.await;
match result {
Ok(output) => output,
Err(_) => {
warn!("SSH monitor exec_command timed out after 10s");
None
}
}
}
async fn exec_command_inner(handle: &Arc<TokioMutex<Handle<SshClient>>>, cmd: &str) -> Option<String> {
let mut channel = { let mut channel = {
let h = handle.lock().await; let h = handle.lock().await;
h.channel_open_session().await.ok()? h.channel_open_session().await.ok()?

View File

@ -17,6 +17,7 @@ use crate::mcp::error_watcher::ErrorWatcher;
use crate::sftp::SftpService; use crate::sftp::SftpService;
use crate::ssh::cwd::CwdTracker; use crate::ssh::cwd::CwdTracker;
use crate::ssh::host_key::{HostKeyResult, HostKeyStore}; use crate::ssh::host_key::{HostKeyResult, HostKeyStore};
use tokio_util::sync::CancellationToken;
pub enum AuthMethod { pub enum AuthMethod {
Password(String), Password(String),
@ -47,6 +48,7 @@ pub struct SshSession {
pub handle: Arc<TokioMutex<Handle<SshClient>>>, pub handle: Arc<TokioMutex<Handle<SshClient>>>,
pub command_tx: mpsc::UnboundedSender<ChannelCommand>, pub command_tx: mpsc::UnboundedSender<ChannelCommand>,
pub cwd_tracker: Option<CwdTracker>, pub cwd_tracker: Option<CwdTracker>,
pub cancel_token: CancellationToken,
} }
pub struct SshClient { pub struct SshClient {
@ -135,10 +137,11 @@ impl SshService {
let channel_id = channel.id(); let channel_id = channel.id();
let handle = Arc::new(TokioMutex::new(handle)); let handle = Arc::new(TokioMutex::new(handle));
let (command_tx, mut command_rx) = mpsc::unbounded_channel::<ChannelCommand>(); let (command_tx, mut command_rx) = mpsc::unbounded_channel::<ChannelCommand>();
let cancel_token = CancellationToken::new();
let cwd_tracker = CwdTracker::new(); let cwd_tracker = CwdTracker::new();
cwd_tracker.start(handle.clone(), app_handle.clone(), session_id.clone()); cwd_tracker.start(handle.clone(), app_handle.clone(), session_id.clone(), cancel_token.clone());
let session = Arc::new(SshSession { id: session_id.clone(), hostname: hostname.to_string(), port, username: username.to_string(), channel_id, handle: handle.clone(), command_tx: command_tx.clone(), cwd_tracker: Some(cwd_tracker) }); let session = Arc::new(SshSession { id: session_id.clone(), hostname: hostname.to_string(), port, username: username.to_string(), channel_id, handle: handle.clone(), command_tx: command_tx.clone(), cwd_tracker: Some(cwd_tracker), cancel_token: cancel_token.clone() });
self.sessions.insert(session_id.clone(), session); self.sessions.insert(session_id.clone(), session);
{ let h = handle.lock().await; { let h = handle.lock().await;
@ -158,7 +161,7 @@ impl SshService {
error_watcher.watch(&session_id); error_watcher.watch(&session_id);
// Start remote monitoring if enabled (runs on a separate exec channel) // Start remote monitoring if enabled (runs on a separate exec channel)
crate::ssh::monitor::start_monitor(handle.clone(), app_handle.clone(), session_id.clone()); crate::ssh::monitor::start_monitor(handle.clone(), app_handle.clone(), session_id.clone(), cancel_token.clone());
// Inject OSC 7 CWD reporting hook into the user's shell. // Inject OSC 7 CWD reporting hook into the user's shell.
// This enables SFTP CWD following on all platforms (Linux, macOS, FreeBSD). // This enables SFTP CWD following on all platforms (Linux, macOS, FreeBSD).
@ -246,6 +249,8 @@ impl SshService {
pub async fn disconnect(&self, session_id: &str, sftp_service: &SftpService) -> Result<(), String> { pub async fn disconnect(&self, session_id: &str, sftp_service: &SftpService) -> Result<(), String> {
let (_, session) = self.sessions.remove(session_id).ok_or_else(|| format!("Session {} not found", session_id))?; let (_, session) = self.sessions.remove(session_id).ok_or_else(|| format!("Session {} not found", session_id))?;
// Cancel background tasks (CWD tracker, monitor) before tearing down the connection.
session.cancel_token.cancel();
let _ = session.command_tx.send(ChannelCommand::Shutdown); let _ = session.command_tx.send(ChannelCommand::Shutdown);
{ let handle = session.handle.lock().await; let _ = handle.disconnect(Disconnect::ByApplication, "", "en").await; } { let handle = session.handle.lock().await; let _ = handle.disconnect(Disconnect::ByApplication, "", "en").await; }
sftp_service.remove_client(session_id); sftp_service.remove_client(session_id);
@ -253,7 +258,7 @@ impl SshService {
} }
pub fn get_session(&self, session_id: &str) -> Option<Arc<SshSession>> { pub fn get_session(&self, session_id: &str) -> Option<Arc<SshSession>> {
self.sessions.get(session_id).map(|entry| entry.clone()) self.sessions.get(session_id).map(|r| r.value().clone())
} }
pub fn list_sessions(&self) -> Vec<SessionInfo> { pub fn list_sessions(&self) -> Vec<SessionInfo> {
@ -400,22 +405,23 @@ fn extract_osc7_cwd(data: &[u8]) -> Option<String> {
} }
fn percent_decode(input: &str) -> String { fn percent_decode(input: &str) -> String {
let mut output = String::with_capacity(input.len()); let mut bytes: Vec<u8> = Vec::with_capacity(input.len());
let mut chars = input.chars(); let mut chars = input.chars();
while let Some(ch) = chars.next() { while let Some(ch) = chars.next() {
if ch == '%' { if ch == '%' {
let hex: String = chars.by_ref().take(2).collect(); let hex: String = chars.by_ref().take(2).collect();
if let Ok(byte) = u8::from_str_radix(&hex, 16) { if let Ok(byte) = u8::from_str_radix(&hex, 16) {
output.push(byte as char); bytes.push(byte);
} else { } else {
output.push('%'); bytes.extend_from_slice(b"%");
output.push_str(&hex); bytes.extend_from_slice(hex.as_bytes());
} }
} else { } else {
output.push(ch); let mut buf = [0u8; 4];
bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
} }
} }
output String::from_utf8_lossy(&bytes).into_owned()
} }
/// Resolve a private key string — if it looks like PEM content, return as-is. /// Resolve a private key string — if it looks like PEM content, return as-is.

View File

@ -59,6 +59,7 @@ struct BuiltinTheme {
// ── service ─────────────────────────────────────────────────────────────────── // ── service ───────────────────────────────────────────────────────────────────
#[derive(Clone)]
pub struct ThemeService { pub struct ThemeService {
db: Database, db: Database,
} }
@ -253,7 +254,7 @@ impl ThemeService {
t.bright_blue, t.bright_magenta, t.bright_cyan, t.bright_white, t.bright_blue, t.bright_magenta, t.bright_cyan, t.bright_white,
], ],
) { ) {
eprintln!("theme::seed_builtins: failed to seed '{}': {}", t.name, e); wraith_log!("theme::seed_builtins: failed to seed '{}': {}", t.name, e);
} }
} }
} }
@ -272,7 +273,7 @@ impl ThemeService {
) { ) {
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
eprintln!("theme::list: failed to prepare query: {}", e); wraith_log!("theme::list: failed to prepare query: {}", e);
return vec![]; return vec![];
} }
}; };
@ -280,12 +281,12 @@ impl ThemeService {
match stmt.query_map([], map_theme_row) { match stmt.query_map([], map_theme_row) {
Ok(rows) => rows Ok(rows) => rows
.filter_map(|r| { .filter_map(|r| {
r.map_err(|e| eprintln!("theme::list: row error: {}", e)) r.map_err(|e| wraith_log!("theme::list: row error: {}", e))
.ok() .ok()
}) })
.collect(), .collect(),
Err(e) => { Err(e) => {
eprintln!("theme::list: query failed: {}", e); wraith_log!("theme::list: query failed: {}", e);
vec![] vec![]
} }
} }

19
src-tauri/src/utils.rs Normal file
View File

@ -0,0 +1,19 @@
//! Shared utility functions.
/// Escape a string for safe interpolation into a POSIX shell command.
///
/// Wraps the input in single quotes and escapes any embedded single quotes
/// using the `'\''` technique. This prevents command injection when building
/// shell commands from user-supplied values.
///
/// # Examples
///
/// ```
/// # use wraith_lib::utils::shell_escape;
/// assert_eq!(shell_escape("hello"), "'hello'");
/// assert_eq!(shell_escape("it's"), "'it'\\''s'");
/// assert_eq!(shell_escape(";rm -rf /"), "';rm -rf /'");
/// ```
pub fn shell_escape(input: &str) -> String {
format!("'{}'", input.replace('\'', "'\\''"))
}

View File

@ -4,6 +4,7 @@ use aes_gcm::{
Aes256Gcm, Key, Nonce, Aes256Gcm, Key, Nonce,
}; };
use argon2::{Algorithm, Argon2, Params, Version}; use argon2::{Algorithm, Argon2, Params, Version};
use zeroize::Zeroizing;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// VaultService // VaultService
@ -21,18 +22,18 @@ use argon2::{Algorithm, Argon2, Params, Version};
/// The version prefix allows a future migration to a different algorithm /// The version prefix allows a future migration to a different algorithm
/// without breaking existing stored blobs. /// without breaking existing stored blobs.
pub struct VaultService { pub struct VaultService {
key: [u8; 32], key: Zeroizing<[u8; 32]>,
} }
impl VaultService { impl VaultService {
pub fn new(key: [u8; 32]) -> Self { pub fn new(key: Zeroizing<[u8; 32]>) -> Self {
Self { key } Self { key }
} }
/// Encrypt `plaintext` and return a `v1:{iv_hex}:{sealed_hex}` blob. /// Encrypt `plaintext` and return a `v1:{iv_hex}:{sealed_hex}` blob.
pub fn encrypt(&self, plaintext: &str) -> Result<String, String> { pub fn encrypt(&self, plaintext: &str) -> Result<String, String> {
// Build the AES-256-GCM cipher from our key. // Build the AES-256-GCM cipher from our key.
let key = Key::<Aes256Gcm>::from_slice(&self.key); let key = Key::<Aes256Gcm>::from_slice(&*self.key);
let cipher = Aes256Gcm::new(key); let cipher = Aes256Gcm::new(key);
// Generate a random 12-byte nonce (96-bit is the GCM standard). // Generate a random 12-byte nonce (96-bit is the GCM standard).
@ -71,7 +72,7 @@ impl VaultService {
)); ));
} }
let key = Key::<Aes256Gcm>::from_slice(&self.key); let key = Key::<Aes256Gcm>::from_slice(&*self.key);
let cipher = Aes256Gcm::new(key); let cipher = Aes256Gcm::new(key);
let nonce = Nonce::from_slice(&iv_bytes); let nonce = Nonce::from_slice(&iv_bytes);
@ -95,7 +96,7 @@ impl VaultService {
/// t = 3 iterations /// t = 3 iterations
/// m = 65536 KiB (64 MiB) memory /// m = 65536 KiB (64 MiB) memory
/// p = 4 parallelism lanes /// p = 4 parallelism lanes
pub fn derive_key(password: &str, salt: &[u8]) -> [u8; 32] { pub fn derive_key(password: &str, salt: &[u8]) -> Zeroizing<[u8; 32]> {
let params = Params::new( let params = Params::new(
65536, // m_cost: 64 MiB 65536, // m_cost: 64 MiB
3, // t_cost: iterations 3, // t_cost: iterations
@ -106,9 +107,9 @@ pub fn derive_key(password: &str, salt: &[u8]) -> [u8; 32] {
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let mut output_key = [0u8; 32]; let mut output_key = Zeroizing::new([0u8; 32]);
argon2 argon2
.hash_password_into(password.as_bytes(), salt, &mut output_key) .hash_password_into(password.as_bytes(), salt, &mut *output_key)
.expect("Argon2id key derivation failed"); .expect("Argon2id key derivation failed");
output_key output_key

View File

@ -24,6 +24,7 @@ pub struct WorkspaceSnapshot {
const SNAPSHOT_KEY: &str = "workspace_snapshot"; const SNAPSHOT_KEY: &str = "workspace_snapshot";
const CLEAN_SHUTDOWN_KEY: &str = "clean_shutdown"; const CLEAN_SHUTDOWN_KEY: &str = "clean_shutdown";
#[derive(Clone)]
pub struct WorkspaceService { pub struct WorkspaceService {
settings: SettingsService, settings: SettingsService,
} }
@ -47,7 +48,7 @@ impl WorkspaceService {
pub fn load(&self) -> Option<WorkspaceSnapshot> { pub fn load(&self) -> Option<WorkspaceSnapshot> {
let json = self.settings.get(SNAPSHOT_KEY)?; let json = self.settings.get(SNAPSHOT_KEY)?;
serde_json::from_str(&json) serde_json::from_str(&json)
.map_err(|e| eprintln!("workspace::load: failed to deserialize snapshot: {e}")) .map_err(|e| wraith_log!("workspace::load: failed to deserialize snapshot: {e}"))
.ok() .ok()
} }

View File

@ -18,13 +18,14 @@
"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": null "csp": null
}, },
"withGlobalTauri": true "withGlobalTauri": false
}, },
"bundle": { "bundle": {
"active": true, "active": true,

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

@ -116,9 +116,9 @@ const connectionStore = useConnectionStore();
const sessionStore = useSessionStore(); const sessionStore = useSessionStore();
const emit = defineEmits<{ const emit = defineEmits<{
(e: "open-import"): void; "open-import": [];
(e: "open-settings"): void; "open-settings": [];
(e: "open-new-connection", protocol?: "ssh" | "rdp"): void; "open-new-connection": [protocol?: "ssh" | "rdp"];
}>(); }>();
const actions: PaletteAction[] = [ const actions: PaletteAction[] = [

View File

@ -422,9 +422,16 @@ watch(
() => settings.value.defaultProtocol, () => settings.value.defaultProtocol,
(val) => invoke("set_setting", { key: "default_protocol", value: val }).catch(console.error), (val) => invoke("set_setting", { key: "default_protocol", value: val }).catch(console.error),
); );
let sidebarWidthDebounce: ReturnType<typeof setTimeout>;
watch( watch(
() => settings.value.sidebarWidth, () => settings.value.sidebarWidth,
(val) => invoke("set_setting", { key: "sidebar_width", value: String(val) }).catch(console.error), (val) => {
clearTimeout(sidebarWidthDebounce);
sidebarWidthDebounce = setTimeout(
() => invoke("set_setting", { key: "sidebar_width", value: String(val) }).catch(console.error),
300,
);
},
); );
watch( watch(
() => settings.value.terminalTheme, () => settings.value.terminalTheme,

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">
@ -47,7 +47,7 @@ const connectionStore = useConnectionStore();
const activeThemeName = ref("Default"); const activeThemeName = ref("Default");
const emit = defineEmits<{ const emit = defineEmits<{
(e: "open-theme-picker"): void; "open-theme-picker": [];
}>(); }>();
const connectionInfo = computed(() => { const connectionInfo = computed(() => {

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

@ -28,7 +28,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, 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<{
@ -42,8 +43,8 @@ const containerRef = ref<HTMLElement | null>(null);
const canvasWrapper = ref<HTMLElement | null>(null); const canvasWrapper = ref<HTMLElement | null>(null);
const canvasRef = ref<HTMLCanvasElement | null>(null); const canvasRef = ref<HTMLCanvasElement | null>(null);
const rdpWidth = props.width ?? 1920; const rdpWidth = computed(() => props.width ?? 1920);
const rdpHeight = props.height ?? 1080; const rdpHeight = computed(() => props.height ?? 1080);
const { const {
connected, connected,
@ -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 / rect.width; const scaleX = canvas.width / rect.width;
const scaleY = rdpHeight / 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, rdpHeight); 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

@ -88,7 +88,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from "vue"; import { ref, onMounted, onBeforeUnmount } from "vue";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { useSessionStore, type Session } from "@/stores/session.store"; import { useSessionStore, type Session } from "@/stores/session.store";
import { useConnectionStore } from "@/stores/connection.store"; import { useConnectionStore } from "@/stores/connection.store";
@ -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 {
@ -151,16 +149,10 @@ function closeMenuTab(): void {
if (session) sessionStore.closeSession(session.id); if (session) sessionStore.closeSession(session.id);
} }
// Listen for reattach events from detached windows
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
listen<{ sessionId: string; name: string; protocol: string }>("session:reattach", (event) => { import type { UnlistenFn } from "@tauri-apps/api/event";
const { sessionId } = event.payload;
const session = sessionStore.sessions.find(s => s.id === sessionId); let unlistenReattach: UnlistenFn | null = null;
if (session) {
session.active = true;
sessionStore.activateSession(sessionId);
}
});
onMounted(async () => { onMounted(async () => {
try { try {
@ -168,6 +160,19 @@ onMounted(async () => {
} catch { } catch {
availableShells.value = []; availableShells.value = [];
} }
unlistenReattach = await listen<{ sessionId: string; name: string; protocol: string }>("session:reattach", (event) => {
const { sessionId } = event.payload;
const session = sessionStore.sessions.find(s => s.id === sessionId);
if (session) {
session.active = true;
sessionStore.activateSession(sessionId);
}
});
});
onBeforeUnmount(() => {
unlistenReattach?.();
}); });
// Drag-and-drop tab reordering // Drag-and-drop tab reordering

View File

@ -371,6 +371,31 @@ function handleFileSelected(event: Event): void {
failTransfer(transferId); failTransfer(transferId);
}; };
// Guard: the backend sftp_write_file command accepts a UTF-8 string only.
// Binary files (images, archives, executables, etc.) will be corrupted if
// sent as text. Warn and abort for known binary extensions or large files.
const BINARY_EXTENSIONS = new Set([
"png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "tiff", "svg",
"zip", "tar", "gz", "bz2", "xz", "7z", "rar", "zst",
"exe", "dll", "so", "dylib", "bin", "elf",
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx",
"mp3", "mp4", "avi", "mkv", "mov", "flac", "wav", "ogg",
"ttf", "otf", "woff", "woff2",
"db", "sqlite", "sqlite3",
]);
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
const isBinary = BINARY_EXTENSIONS.has(ext);
const isLarge = file.size > 1 * 1024 * 1024; // 1 MB
if (isBinary || isLarge) {
const reason = isBinary
? `"${ext}" files are binary and cannot be safely uploaded as text`
: `file is ${(file.size / (1024 * 1024)).toFixed(1)} MB — only text files under 1 MB are supported`;
alert(`Upload blocked: ${reason}.\n\nBinary file upload support will be added in a future release.`);
failTransfer(transferId);
return;
}
reader.readAsText(file); reader.readAsText(file);
} }

View File

@ -52,11 +52,15 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { ref, watch } from "vue";
import { useTransfers } from "@/composables/useTransfers"; import { useTransfers } from "@/composables/useTransfers";
const expanded = ref(false); const expanded = ref(false);
const { transfers } = useTransfers();
// Auto-expand when transfers become active, collapse when all are gone // Auto-expand when transfers become active, collapse when all are gone
const { transfers } = useTransfers(); watch(() => transfers.value.length, (newLen, oldLen) => {
if (newLen > 0 && oldLen === 0) expanded.value = true;
if (newLen === 0) expanded.value = false;
});
</script> </script>

View File

@ -110,7 +110,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { ref, watch } from "vue";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { useConnectionStore, type Connection, type Group } from "@/stores/connection.store"; import { useConnectionStore, type Connection, type Group } from "@/stores/connection.store";
import { useSessionStore } from "@/stores/session.store"; import { useSessionStore } from "@/stores/session.store";
@ -224,6 +224,15 @@ const expandedGroups = ref<Set<number>>(
new Set(connectionStore.groups.map((g) => g.id)), new Set(connectionStore.groups.map((g) => g.id)),
); );
// Auto-expand groups added after initial load
watch(() => connectionStore.groups, (newGroups) => {
for (const group of newGroups) {
if (!expandedGroups.value.has(group.id)) {
expandedGroups.value.add(group.id);
}
}
}, { deep: true });
function toggleGroup(groupId: number): void { function toggleGroup(groupId: number): void {
if (expandedGroups.value.has(groupId)) { if (expandedGroups.value.has(groupId)) {
expandedGroups.value.delete(groupId); expandedGroups.value.delete(groupId);

View File

@ -5,11 +5,11 @@
:key="tab.id" :key="tab.id"
class="flex-1 py-2 text-xs font-medium text-center transition-colors cursor-pointer" class="flex-1 py-2 text-xs font-medium text-center transition-colors cursor-pointer"
:class=" :class="
modelValue === tab.id model === tab.id
? 'text-[var(--wraith-accent-blue)] border-b-2 border-[var(--wraith-accent-blue)]' ? 'text-[var(--wraith-accent-blue)] border-b-2 border-[var(--wraith-accent-blue)]'
: 'text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-secondary)]' : 'text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-secondary)]'
" "
@click="emit('update:modelValue', tab.id)" @click="model = tab.id"
> >
{{ tab.label }} {{ tab.label }}
</button> </button>
@ -24,11 +24,5 @@ const tabs = [
{ id: "sftp" as const, label: "SFTP" }, { id: "sftp" as const, label: "SFTP" },
]; ];
defineProps<{ const model = defineModel<SidebarTab>();
modelValue: SidebarTab;
}>();
const emit = defineEmits<{
"update:modelValue": [tab: SidebarTab];
}>();
</script> </script>

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">
@ -55,6 +55,7 @@ interface SystemStats {
const stats = ref<SystemStats | null>(null); const stats = ref<SystemStats | null>(null);
let unlistenFn: UnlistenFn | null = null; let unlistenFn: UnlistenFn | null = null;
let subscribeGeneration = 0;
function colorClass(value: number, warnThreshold: number, critThreshold: number): string { function colorClass(value: number, warnThreshold: number, critThreshold: number): string {
if (value >= critThreshold) return "text-[#f85149]"; // red if (value >= critThreshold) return "text-[#f85149]"; // red
@ -70,10 +71,17 @@ function formatBytes(bytes: number): string {
} }
async function subscribe(): Promise<void> { async function subscribe(): Promise<void> {
const gen = ++subscribeGeneration;
if (unlistenFn) unlistenFn(); if (unlistenFn) unlistenFn();
unlistenFn = await listen<SystemStats>(`ssh:monitor:${props.sessionId}`, (event) => { const fn = await listen<SystemStats>(`ssh:monitor:${props.sessionId}`, (event) => {
stats.value = event.payload; stats.value = event.payload;
}); });
if (gen !== subscribeGeneration) {
// A newer subscribe() call has already taken over discard this listener
fn();
return;
}
unlistenFn = fn;
} }
onMounted(subscribe); onMounted(subscribe);

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

@ -1,6 +1,6 @@
<template> <template>
<div class="flex flex-col h-full p-4 gap-3"> <ToolShell ref="shell" placeholder="Select a mode and click Run Test">
<div class="flex items-center gap-2"> <template #default="{ running }">
<select v-model="mode" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none cursor-pointer"> <select v-model="mode" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none cursor-pointer">
<option value="speedtest">Internet Speed Test</option> <option value="speedtest">Internet Speed Test</option>
<option value="iperf">iperf3 (LAN)</option> <option value="iperf">iperf3 (LAN)</option>
@ -13,32 +13,31 @@
<button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer disabled:opacity-40" :disabled="running" @click="run"> <button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer disabled:opacity-40" :disabled="running" @click="run">
{{ running ? "Testing..." : "Run Test" }} {{ running ? "Testing..." : "Run Test" }}
</button> </button>
</div> </template>
<pre class="flex-1 overflow-auto bg-[#161b22] border border-[#30363d] rounded p-3 text-xs font-mono whitespace-pre-wrap text-[#e0e0e0]">{{ output || "Select a mode and click Run Test" }}</pre> </ToolShell>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { ref } from "vue";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import ToolShell from "./ToolShell.vue";
const props = defineProps<{ sessionId: string }>(); const props = defineProps<{ sessionId: string }>();
const mode = ref("speedtest"); const mode = ref("speedtest");
const server = ref(""); const server = ref("");
const duration = ref(5); const duration = ref(5);
const output = ref(""); const shell = ref<InstanceType<typeof ToolShell> | null>(null);
const running = ref(false);
async function run(): Promise<void> { async function run(): Promise<void> {
running.value = true; if (mode.value === "iperf" && !server.value) {
output.value = mode.value === "iperf" ? `Running iperf3 to ${server.value}...\n` : "Running speed test...\n"; shell.value?.setOutput("Enter an iperf3 server IP");
try { return;
}
shell.value?.execute(() => {
if (mode.value === "iperf") { if (mode.value === "iperf") {
if (!server.value) { output.value = "Enter an iperf3 server IP"; running.value = false; return; } return invoke<string>("tool_bandwidth_iperf", { sessionId: props.sessionId, server: server.value, duration: duration.value });
output.value = await invoke<string>("tool_bandwidth_iperf", { sessionId: props.sessionId, server: server.value, duration: duration.value });
} else {
output.value = await invoke<string>("tool_bandwidth_speedtest", { sessionId: props.sessionId });
} }
} catch (err) { output.value = String(err); } return invoke<string>("tool_bandwidth_speedtest", { sessionId: props.sessionId });
running.value = false; });
} }
</script> </script>

View File

@ -1,31 +1,29 @@
<template> <template>
<div class="flex flex-col h-full p-4 gap-3"> <ToolShell ref="shell" placeholder="Enter a domain and click Lookup">
<div class="flex items-center gap-2"> <template #default="{ running }">
<input v-model="domain" type="text" placeholder="Domain name" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] flex-1" @keydown.enter="lookup" /> <input v-model="domain" type="text" placeholder="Domain name" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] flex-1" @keydown.enter="lookup" />
<select v-model="recordType" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none cursor-pointer"> <select v-model="recordType" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none cursor-pointer">
<option v-for="t in ['A','AAAA','MX','NS','TXT','CNAME','SOA','SRV','PTR']" :key="t" :value="t">{{ t }}</option> <option v-for="t in ['A','AAAA','MX','NS','TXT','CNAME','SOA','SRV','PTR']" :key="t" :value="t">{{ t }}</option>
</select> </select>
<button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer disabled:opacity-40" :disabled="running" @click="lookup">Lookup</button> <button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer disabled:opacity-40" :disabled="running" @click="lookup">Lookup</button>
</div> </template>
<pre class="flex-1 overflow-auto bg-[#161b22] border border-[#30363d] rounded p-3 text-xs font-mono whitespace-pre-wrap text-[#e0e0e0]">{{ output || "Enter a domain and click Lookup" }}</pre> </ToolShell>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { ref } from "vue";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import ToolShell from "./ToolShell.vue";
const props = defineProps<{ sessionId: string }>(); const props = defineProps<{ sessionId: string }>();
const domain = ref(""); const domain = ref("");
const recordType = ref("A"); const recordType = ref("A");
const output = ref(""); const shell = ref<InstanceType<typeof ToolShell> | null>(null);
const running = ref(false);
async function lookup(): Promise<void> { async function lookup(): Promise<void> {
if (!domain.value) return; if (!domain.value) return;
running.value = true; shell.value?.execute(() =>
try { invoke<string>("tool_dns_lookup", { sessionId: props.sessionId, domain: domain.value, recordType: recordType.value })
output.value = await invoke<string>("tool_dns_lookup", { sessionId: props.sessionId, domain: domain.value, recordType: recordType.value }); );
} catch (err) { output.value = String(err); }
running.value = false;
} }
</script> </script>

View File

@ -81,12 +81,16 @@
import { ref, onMounted } from "vue"; import { ref, onMounted } from "vue";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
interface DockerContainer { id: string; name: string; image: string; status: string; ports: string; }
interface DockerImage { repository: string; tag: string; id: string; size: string; }
interface DockerVolume { name: string; driver: string; mountpoint: string; }
const props = defineProps<{ sessionId: string }>(); const props = defineProps<{ sessionId: string }>();
const tab = ref("containers"); const tab = ref("containers");
const containers = ref<any[]>([]); const containers = ref<DockerContainer[]>([]);
const images = ref<any[]>([]); const images = ref<DockerImage[]>([]);
const volumes = ref<any[]>([]); const volumes = ref<DockerVolume[]>([]);
const output = ref(""); const output = ref("");
async function refresh(): Promise<void> { async function refresh(): Promise<void> {

View File

@ -85,5 +85,6 @@ function exportCsv(): void {
a.href = URL.createObjectURL(blob); a.href = URL.createObjectURL(blob);
a.download = `wraith-scan-${subnet.value}-${Date.now()}.csv`; a.download = `wraith-scan-${subnet.value}-${Date.now()}.csv`;
a.click(); a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
} }
</script> </script>

View File

@ -1,32 +1,28 @@
<template> <template>
<div class="flex flex-col h-full p-4 gap-3"> <ToolShell ref="shell" placeholder="Enter a host and click Ping">
<div class="flex items-center gap-2"> <template #default="{ running }">
<input v-model="target" type="text" placeholder="Host to ping" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] flex-1" @keydown.enter="ping" /> <input v-model="target" type="text" placeholder="Host to ping" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] flex-1" @keydown.enter="ping" />
<input v-model.number="count" type="number" min="1" max="100" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] w-16" /> <input v-model.number="count" type="number" min="1" max="100" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] w-16" />
<button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer disabled:opacity-40" :disabled="running" @click="ping">Ping</button> <button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer disabled:opacity-40" :disabled="running" @click="ping">Ping</button>
</div> </template>
<pre class="flex-1 overflow-auto bg-[#161b22] border border-[#30363d] rounded p-3 text-xs font-mono whitespace-pre-wrap text-[#e0e0e0]">{{ output || "Enter a host and click Ping" }}</pre> </ToolShell>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { ref } from "vue";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import ToolShell from "./ToolShell.vue";
const props = defineProps<{ sessionId: string }>(); const props = defineProps<{ sessionId: string }>();
const target = ref(""); const target = ref("");
const count = ref(4); const count = ref(4);
const output = ref(""); const shell = ref<InstanceType<typeof ToolShell> | null>(null);
const running = ref(false);
async function ping(): Promise<void> { async function ping(): Promise<void> {
if (!target.value) return; if (!target.value) return;
running.value = true; shell.value?.execute(async () => {
output.value = `Pinging ${target.value}...\n`;
try {
const result = await invoke<{ target: string; output: string }>("tool_ping", { sessionId: props.sessionId, target: target.value, count: count.value }); const result = await invoke<{ target: string; output: string }>("tool_ping", { sessionId: props.sessionId, target: target.value, count: count.value });
output.value = result.output; return result.output;
} catch (err) { output.value = String(err); } });
running.value = false;
} }
</script> </script>

View File

@ -0,0 +1,37 @@
<script setup lang="ts">
import { ref } from "vue";
defineProps<{
placeholder?: string;
}>();
const output = ref("");
const running = ref(false);
async function execute(fn: () => Promise<string>): Promise<void> {
running.value = true;
output.value = "";
try {
output.value = await fn();
} catch (err: unknown) {
output.value = `Error: ${err instanceof Error ? err.message : String(err)}`;
} finally {
running.value = false;
}
}
function setOutput(value: string): void {
output.value = value;
}
defineExpose({ execute, setOutput, output, running });
</script>
<template>
<div class="flex flex-col h-full p-4 gap-3">
<div class="flex items-center gap-2">
<slot :running="running" />
</div>
<pre class="flex-1 overflow-auto bg-[#161b22] border border-[#30363d] rounded p-3 text-xs font-mono whitespace-pre-wrap text-[#e0e0e0]">{{ output || placeholder || "Ready." }}</pre>
</div>
</template>

View File

@ -1,29 +1,25 @@
<template> <template>
<div class="flex flex-col h-full p-4 gap-3"> <ToolShell ref="shell" placeholder="Enter a host and click Trace">
<div class="flex items-center gap-2"> <template #default="{ running }">
<input v-model="target" type="text" placeholder="Host to trace" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] flex-1" @keydown.enter="trace" /> <input v-model="target" type="text" placeholder="Host to trace" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] flex-1" @keydown.enter="trace" />
<button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer disabled:opacity-40" :disabled="running" @click="trace">Trace</button> <button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer disabled:opacity-40" :disabled="running" @click="trace">Trace</button>
</div> </template>
<pre class="flex-1 overflow-auto bg-[#161b22] border border-[#30363d] rounded p-3 text-xs font-mono whitespace-pre-wrap text-[#e0e0e0]">{{ output || "Enter a host and click Trace" }}</pre> </ToolShell>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { ref } from "vue";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import ToolShell from "./ToolShell.vue";
const props = defineProps<{ sessionId: string }>(); const props = defineProps<{ sessionId: string }>();
const target = ref(""); const target = ref("");
const output = ref(""); const shell = ref<InstanceType<typeof ToolShell> | null>(null);
const running = ref(false);
async function trace(): Promise<void> { async function trace(): Promise<void> {
if (!target.value) return; if (!target.value) return;
running.value = true; shell.value?.execute(() =>
output.value = `Tracing route to ${target.value}...\n`; invoke<string>("tool_traceroute", { sessionId: props.sessionId, target: target.value })
try { );
output.value = await invoke<string>("tool_traceroute", { sessionId: props.sessionId, target: target.value });
} catch (err) { output.value = String(err); }
running.value = false;
} }
</script> </script>

View File

@ -1,26 +1,25 @@
<template> <template>
<div class="flex flex-col h-full p-4 gap-3"> <ToolShell ref="shell" placeholder="Enter a domain or IP and click Whois">
<div class="flex items-center gap-2"> <template #default="{ running }">
<input v-model="target" type="text" placeholder="Domain or IP" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] flex-1" @keydown.enter="lookup" /> <input v-model="target" type="text" placeholder="Domain or IP" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] flex-1" @keydown.enter="lookup" />
<button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer disabled:opacity-40" :disabled="running" @click="lookup">Whois</button> <button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer disabled:opacity-40" :disabled="running" @click="lookup">Whois</button>
</div> </template>
<pre class="flex-1 overflow-auto bg-[#161b22] border border-[#30363d] rounded p-3 text-xs font-mono whitespace-pre-wrap text-[#e0e0e0]">{{ output || "Enter a domain or IP and click Whois" }}</pre> </ToolShell>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { ref } from "vue";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import ToolShell from "./ToolShell.vue";
const props = defineProps<{ sessionId: string }>(); const props = defineProps<{ sessionId: string }>();
const target = ref(""); const target = ref("");
const output = ref(""); const shell = ref<InstanceType<typeof ToolShell> | null>(null);
const running = ref(false);
async function lookup(): Promise<void> { async function lookup(): Promise<void> {
if (!target.value) return; if (!target.value) return;
running.value = true; shell.value?.execute(() =>
try { output.value = await invoke<string>("tool_whois", { sessionId: props.sessionId, target: target.value }); } invoke<string>("tool_whois", { sessionId: props.sessionId, target: target.value })
catch (err) { output.value = String(err); } );
running.value = false;
} }
</script> </script>

View File

@ -0,0 +1,106 @@
import { onMounted, onBeforeUnmount } from "vue";
import type { Ref } from "vue";
import type { useSessionStore } from "@/stores/session.store";
interface KeyboardShortcutActions {
sessionStore: ReturnType<typeof useSessionStore>;
sidebarVisible: Ref<boolean>;
copilotVisible: Ref<boolean>;
openCommandPalette: () => void;
openActiveSearch: () => void;
}
export function useKeyboardShortcuts(actions: KeyboardShortcutActions): void {
const { sessionStore, sidebarVisible, copilotVisible, openCommandPalette, openActiveSearch } = actions;
function handleKeydown(event: KeyboardEvent): void {
const target = event.target as HTMLElement;
const isInputFocused =
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT";
const ctrl = event.ctrlKey || event.metaKey;
// Ctrl+K — command palette (fires even when input is focused)
if (ctrl && event.key === "k") {
event.preventDefault();
openCommandPalette();
return;
}
if (isInputFocused) return;
// Ctrl+W — close active tab
if (ctrl && event.key === "w") {
event.preventDefault();
const active = sessionStore.activeSession;
if (active) sessionStore.closeSession(active.id);
return;
}
// Ctrl+Tab — next tab
if (ctrl && event.key === "Tab" && !event.shiftKey) {
event.preventDefault();
const sessions = sessionStore.sessions;
if (sessions.length < 2) return;
const idx = sessions.findIndex((s) => s.id === sessionStore.activeSessionId);
const next = sessions[(idx + 1) % sessions.length];
sessionStore.activateSession(next.id);
return;
}
// Ctrl+Shift+Tab — previous tab
if (ctrl && event.key === "Tab" && event.shiftKey) {
event.preventDefault();
const sessions = sessionStore.sessions;
if (sessions.length < 2) return;
const idx = sessions.findIndex((s) => s.id === sessionStore.activeSessionId);
const prev = sessions[(idx - 1 + sessions.length) % sessions.length];
sessionStore.activateSession(prev.id);
return;
}
// Ctrl+1-9 — jump to tab by index
if (ctrl && event.key >= "1" && event.key <= "9") {
const tabIndex = parseInt(event.key, 10) - 1;
const sessions = sessionStore.sessions;
if (tabIndex < sessions.length) {
event.preventDefault();
sessionStore.activateSession(sessions[tabIndex].id);
}
return;
}
// Ctrl+B — toggle sidebar
if (ctrl && event.key === "b") {
event.preventDefault();
sidebarVisible.value = !sidebarVisible.value;
return;
}
// Ctrl+Shift+G — toggle AI copilot
if (ctrl && event.shiftKey && event.key.toLowerCase() === "g") {
event.preventDefault();
copilotVisible.value = !copilotVisible.value;
return;
}
// Ctrl+F — terminal search (SSH sessions only)
if (ctrl && event.key === "f") {
const active = sessionStore.activeSession;
if (active?.protocol === "ssh") {
event.preventDefault();
openActiveSearch();
}
return;
}
}
onMounted(() => {
document.addEventListener("keydown", handleKeydown);
});
onBeforeUnmount(() => {
document.removeEventListener("keydown", handleKeydown);
});
}

View File

@ -1,4 +1,5 @@
import { ref, onBeforeUnmount } from "vue"; import { ref, onBeforeUnmount } from "vue";
import type { Ref } from "vue";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
/** /**
@ -152,13 +153,13 @@ export function jsKeyToScancode(code: string): number | null {
export interface UseRdpReturn { export interface UseRdpReturn {
/** Whether the RDP session is connected (first frame received) */ /** Whether the RDP session is connected (first frame received) */
connected: ReturnType<typeof ref<boolean>>; connected: Ref<boolean>;
/** Whether keyboard capture is enabled */ /** Whether keyboard capture is enabled */
keyboardGrabbed: ReturnType<typeof ref<boolean>>; keyboardGrabbed: Ref<boolean>;
/** Whether clipboard sync is enabled */ /** Whether clipboard sync is enabled */
clipboardSync: ReturnType<typeof 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 */
@ -184,7 +185,7 @@ export interface UseRdpReturn {
* Composable that manages an RDP session's rendering and input. * Composable that manages an RDP session's rendering and input.
* *
* Uses Tauri's invoke() to call Rust commands: * Uses Tauri's invoke() to call Rust commands:
* rdp_get_frame base64 RGBA string * rdp_get_frame raw RGBA ArrayBuffer (binary IPC)
* rdp_send_mouse fire-and-forget * rdp_send_mouse fire-and-forget
* rdp_send_key fire-and-forget * rdp_send_key fire-and-forget
* rdp_send_clipboard fire-and-forget * rdp_send_clipboard fire-and-forget
@ -195,40 +196,53 @@ export function useRdp(): UseRdpReturn {
const clipboardSync = ref(false); const clipboardSync = ref(false);
let animFrameId: number | null = null; let animFrameId: number | 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,
let raw: number[]; ): Promise<boolean> {
let raw: ArrayBuffer;
try { try {
raw = await invoke<number[]>("rdp_get_frame", { sessionId }); raw = await invoke<ArrayBuffer>("rdp_get_frame", { sessionId });
} catch { } catch {
return null; return false;
} }
if (!raw || raw.length === 0) return null; if (!raw || raw.byteLength <= 8) return false;
// Binary IPC — Tauri returns Vec<u8> as number array 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;
} }
/** /**
@ -297,31 +311,35 @@ export function useRdp(): UseRdpReturn {
canvas.height = height; canvas.height = height;
let fetchPending = false; let fetchPending = 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.
async function onFrameReady(): Promise<void> { // Uses rAF to coalesce rapid events into one fetch per display frame.
if (fetchPending) return; // Don't stack fetches function scheduleFrameFetch(): void {
fetchPending = true; if (rafScheduled) return;
const imageData = await fetchFrame(sessionId, width, height); rafScheduled = true;
fetchPending = false; animFrameId = requestAnimationFrame(async () => {
if (imageData && ctx) { rafScheduled = false;
ctx.putImageData(imageData, 0, 0); if (fetchPending) return;
if (!connected.value) connected.value = true; fetchPending = true;
} if (!ctx) return;
const rendered = await fetchAndRender(sessionId, width, height, ctx);
fetchPending = false;
if (rendered && !connected.value) connected.value = true;
});
} }
// Listen for frame events from the backend (push model) // Listen for frame events from the backend (push model)
import("@tauri-apps/api/event").then(({ listen }) => { import("@tauri-apps/api/event").then(({ listen }) => {
listen(`rdp:frame:${sessionId}`, () => { listen(`rdp:frame:${sessionId}`, () => {
onFrameReady(); scheduleFrameFetch();
}).then((unlisten) => { }).then((unlisten) => {
// Store unlisten so we can clean up unlistenFrame = unlisten;
(canvas as any).__wraith_unlisten = unlisten;
}); });
}); });
// Also do an initial poll in case frames arrived before listener was set up // Initial poll in case frames arrived before listener was set up
onFrameReady(); scheduleFrameFetch();
} }
/** /**
@ -332,6 +350,10 @@ export function useRdp(): UseRdpReturn {
cancelAnimationFrame(animFrameId); cancelAnimationFrame(animFrameId);
animFrameId = null; animFrameId = null;
} }
if (unlistenFrame !== null) {
unlistenFrame();
unlistenFrame = null;
}
connected.value = false; connected.value = false;
} }
@ -351,7 +373,7 @@ export function useRdp(): UseRdpReturn {
connected, connected,
keyboardGrabbed, keyboardGrabbed,
clipboardSync, clipboardSync,
fetchFrame, fetchAndRender,
sendMouse, sendMouse,
sendKey, sendKey,
sendClipboard, sendClipboard,

View File

@ -24,6 +24,11 @@ export interface UseSftpReturn {
// Persist the last browsed path per session so switching tabs restores position // Persist the last browsed path per session so switching tabs restores position
const sessionPaths: Record<string, string> = {}; const sessionPaths: Record<string, string> = {};
/** Remove a session's saved path from the module-level cache. Call on session close. */
export function cleanupSession(sessionId: string): void {
delete sessionPaths[sessionId];
}
/** /**
* Composable that manages SFTP file browsing state. * Composable that manages SFTP file browsing state.
* Accepts a reactive session ID ref so it reinitializes on tab switch * Accepts a reactive session ID ref so it reinitializes on tab switch

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

@ -308,6 +308,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue"; import { ref, computed, onMounted, onUnmounted } from "vue";
import { useKeyboardShortcuts } from "@/composables/useKeyboardShortcuts";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { useAppStore } from "@/stores/app.store"; import { useAppStore } from "@/stores/app.store";
@ -366,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> {
@ -389,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 },
@ -411,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> {
@ -440,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); }
} }
@ -479,20 +469,13 @@ async function handleQuickConnect(): Promise<void> {
} catch (err) { console.error("Quick connect failed:", err); } } catch (err) { console.error("Quick connect failed:", err); }
} }
function handleKeydown(event: KeyboardEvent): void { useKeyboardShortcuts({
const target = event.target as HTMLElement; sessionStore,
const isInputFocused = target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT"; sidebarVisible,
const ctrl = event.ctrlKey || event.metaKey; copilotVisible,
if (ctrl && event.key === "k") { event.preventDefault(); commandPalette.value?.toggle(); return; } openCommandPalette: () => commandPalette.value?.toggle(),
if (isInputFocused) return; openActiveSearch: () => sessionContainer.value?.openActiveSearch(),
if (ctrl && event.key === "w") { event.preventDefault(); const active = sessionStore.activeSession; if (active) sessionStore.closeSession(active.id); return; } });
if (ctrl && event.key === "Tab" && !event.shiftKey) { event.preventDefault(); const sessions = sessionStore.sessions; if (sessions.length < 2) return; const idx = sessions.findIndex((s) => s.id === sessionStore.activeSessionId); const next = sessions[(idx + 1) % sessions.length]; sessionStore.activateSession(next.id); return; }
if (ctrl && event.key === "Tab" && event.shiftKey) { event.preventDefault(); const sessions = sessionStore.sessions; if (sessions.length < 2) return; const idx = sessions.findIndex((s) => s.id === sessionStore.activeSessionId); const prev = sessions[(idx - 1 + sessions.length) % sessions.length]; sessionStore.activateSession(prev.id); return; }
if (ctrl && event.key >= "1" && event.key <= "9") { const tabIndex = parseInt(event.key, 10) - 1; const sessions = sessionStore.sessions; if (tabIndex < sessions.length) { event.preventDefault(); sessionStore.activateSession(sessions[tabIndex].id); } return; }
if (ctrl && event.key === "b") { event.preventDefault(); sidebarVisible.value = !sidebarVisible.value; return; }
if (ctrl && event.shiftKey && event.key.toLowerCase() === "g") { event.preventDefault(); copilotVisible.value = !copilotVisible.value; return; }
if (ctrl && event.key === "f") { const active = sessionStore.activeSession; if (active?.protocol === "ssh") { event.preventDefault(); sessionContainer.value?.openActiveSearch(); } return; }
}
let workspaceSaveInterval: ReturnType<typeof setInterval> | null = null; let workspaceSaveInterval: ReturnType<typeof setInterval> | null = null;
@ -503,13 +486,24 @@ function handleBeforeUnload(e: BeforeUnloadEvent): void {
} }
onMounted(async () => { onMounted(async () => {
document.addEventListener("keydown", handleKeydown);
// Confirm before closing if sessions are active (synchronous won't hang) // Confirm before closing if sessions are active (synchronous won't hang)
window.addEventListener("beforeunload", handleBeforeUnload); window.addEventListener("beforeunload", handleBeforeUnload);
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 {
@ -546,7 +540,6 @@ onMounted(async () => {
}); });
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener("keydown", handleKeydown);
window.removeEventListener("beforeunload", handleBeforeUnload); window.removeEventListener("beforeunload", handleBeforeUnload);
if (workspaceSaveInterval !== null) { if (workspaceSaveInterval !== null) {
clearInterval(workspaceSaveInterval); clearInterval(workspaceSaveInterval);

View File

@ -50,68 +50,25 @@ const displayError = computed(() => localError.value ?? app.error);
</script> </script>
<template> <template>
<div <div class="h-full flex items-center justify-center bg-[var(--wraith-bg-primary)]">
class="unlock-root" <div class="w-full max-w-[400px] p-10 bg-[var(--wraith-bg-secondary)] border border-[var(--wraith-border)] rounded-xl shadow-[0_8px_32px_rgba(0,0,0,0.5)]">
style="
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--wraith-bg-primary);
"
>
<div
class="unlock-card"
style="
width: 100%;
max-width: 400px;
padding: 2.5rem;
background-color: var(--wraith-bg-secondary);
border: 1px solid var(--wraith-border);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
"
>
<!-- Logo --> <!-- Logo -->
<div style="text-align: center; margin-bottom: 2rem"> <div class="text-center mb-8">
<span <span class="text-[2rem] font-extrabold tracking-[0.3em] text-[var(--wraith-accent-blue)] uppercase font-['Inter',monospace]">
style="
font-size: 2rem;
font-weight: 800;
letter-spacing: 0.3em;
color: var(--wraith-accent-blue);
text-transform: uppercase;
font-family: 'Inter', monospace;
"
>
WRAITH WRAITH
</span> </span>
<p <p class="mt-2 text-[0.8rem] text-[var(--wraith-text-muted)] tracking-[0.15em] uppercase">
style="
margin: 0.5rem 0 0;
font-size: 0.8rem;
color: var(--wraith-text-muted);
letter-spacing: 0.15em;
text-transform: uppercase;
"
>
{{ isFirstRun ? "Initialize Secure Vault" : "Secure Desktop" }} {{ isFirstRun ? "Initialize Secure Vault" : "Secure Desktop" }}
</p> </p>
</div> </div>
<!-- Form --> <!-- Form -->
<form @submit.prevent="handleSubmit" style="display: flex; flex-direction: column; gap: 1rem"> <form @submit.prevent="handleSubmit" class="flex flex-col gap-4">
<!-- Master password --> <!-- Master password -->
<div> <div>
<label <label
for="master-password" for="master-password"
style=" class="block mb-[0.4rem] text-[0.8rem] text-[var(--wraith-text-secondary)] tracking-[0.05em]"
display: block;
margin-bottom: 0.4rem;
font-size: 0.8rem;
color: var(--wraith-text-secondary);
letter-spacing: 0.05em;
"
> >
MASTER PASSWORD MASTER PASSWORD
</label> </label>
@ -122,20 +79,7 @@ const displayError = computed(() => localError.value ?? app.error);
autocomplete="current-password" autocomplete="current-password"
placeholder="Enter master password" placeholder="Enter master password"
:disabled="loading" :disabled="loading"
style=" class="w-full px-[0.9rem] py-[0.65rem] bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] rounded-[6px] text-[var(--wraith-text-primary)] text-[0.95rem] outline-none transition-colors duration-150 box-border focus:border-[var(--wraith-accent-blue)]"
width: 100%;
padding: 0.65rem 0.9rem;
background-color: var(--wraith-bg-tertiary);
border: 1px solid var(--wraith-border);
border-radius: 6px;
color: var(--wraith-text-primary);
font-size: 0.95rem;
outline: none;
transition: border-color 0.15s ease;
box-sizing: border-box;
"
@focus="($event.target as HTMLInputElement).style.borderColor = 'var(--wraith-accent-blue)'"
@blur="($event.target as HTMLInputElement).style.borderColor = 'var(--wraith-border)'"
/> />
</div> </div>
@ -143,13 +87,7 @@ const displayError = computed(() => localError.value ?? app.error);
<div v-if="isFirstRun"> <div v-if="isFirstRun">
<label <label
for="confirm-password" for="confirm-password"
style=" class="block mb-[0.4rem] text-[0.8rem] text-[var(--wraith-text-secondary)] tracking-[0.05em]"
display: block;
margin-bottom: 0.4rem;
font-size: 0.8rem;
color: var(--wraith-text-secondary);
letter-spacing: 0.05em;
"
> >
CONFIRM PASSWORD CONFIRM PASSWORD
</label> </label>
@ -160,28 +98,9 @@ const displayError = computed(() => localError.value ?? app.error);
autocomplete="new-password" autocomplete="new-password"
placeholder="Confirm master password" placeholder="Confirm master password"
:disabled="loading" :disabled="loading"
style=" class="w-full px-[0.9rem] py-[0.65rem] bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] rounded-[6px] text-[var(--wraith-text-primary)] text-[0.95rem] outline-none transition-colors duration-150 box-border focus:border-[var(--wraith-accent-blue)]"
width: 100%;
padding: 0.65rem 0.9rem;
background-color: var(--wraith-bg-tertiary);
border: 1px solid var(--wraith-border);
border-radius: 6px;
color: var(--wraith-text-primary);
font-size: 0.95rem;
outline: none;
transition: border-color 0.15s ease;
box-sizing: border-box;
"
@focus="($event.target as HTMLInputElement).style.borderColor = 'var(--wraith-accent-blue)'"
@blur="($event.target as HTMLInputElement).style.borderColor = 'var(--wraith-border)'"
/> />
<p <p class="mt-[0.4rem] text-[0.75rem] text-[var(--wraith-text-muted)]">
style="
margin: 0.4rem 0 0;
font-size: 0.75rem;
color: var(--wraith-text-muted);
"
>
Minimum 12 characters. This password cannot be recovered. Minimum 12 characters. This password cannot be recovered.
</p> </p>
</div> </div>
@ -189,14 +108,7 @@ const displayError = computed(() => localError.value ?? app.error);
<!-- Error message --> <!-- Error message -->
<div <div
v-if="displayError" v-if="displayError"
style=" class="px-[0.9rem] py-[0.6rem] bg-[rgba(248,81,73,0.1)] border border-[rgba(248,81,73,0.3)] rounded-[6px] text-[var(--wraith-accent-red)] text-[0.85rem]"
padding: 0.6rem 0.9rem;
background-color: rgba(248, 81, 73, 0.1);
border: 1px solid rgba(248, 81, 73, 0.3);
border-radius: 6px;
color: var(--wraith-accent-red);
font-size: 0.85rem;
"
> >
{{ displayError }} {{ displayError }}
</div> </div>
@ -205,22 +117,8 @@ const displayError = computed(() => localError.value ?? app.error);
<button <button
type="submit" type="submit"
:disabled="loading" :disabled="loading"
style=" class="w-full py-[0.7rem] mt-2 bg-[var(--wraith-accent-blue)] text-[#0d1117] font-bold text-[0.9rem] tracking-[0.08em] uppercase border-none rounded-[6px] transition-[opacity,background-color] duration-150"
width: 100%; :class="loading ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'"
padding: 0.7rem;
margin-top: 0.5rem;
background-color: var(--wraith-accent-blue);
color: #0d1117;
font-weight: 700;
font-size: 0.9rem;
letter-spacing: 0.08em;
text-transform: uppercase;
border: none;
border-radius: 6px;
cursor: pointer;
transition: opacity 0.15s ease, background-color 0.15s ease;
"
:style="{ opacity: loading ? '0.6' : '1', cursor: loading ? 'not-allowed' : 'pointer' }"
> >
<span v-if="loading"> <span v-if="loading">
{{ isFirstRun ? "Creating vault..." : "Unlocking..." }} {{ isFirstRun ? "Creating vault..." : "Unlocking..." }}
@ -232,14 +130,7 @@ const displayError = computed(() => localError.value ?? app.error);
</form> </form>
<!-- Footer hint --> <!-- Footer hint -->
<p <p class="mt-6 text-center text-[0.75rem] text-[var(--wraith-text-muted)]">
style="
margin: 1.5rem 0 0;
text-align: center;
font-size: 0.75rem;
color: var(--wraith-text-muted);
"
>
<template v-if="isFirstRun"> <template v-if="isFirstRun">
Your vault will be encrypted with AES-256-GCM. Your vault will be encrypted with AES-256-GCM.
</template> </template>

View File

@ -51,22 +51,33 @@ export const useConnectionStore = defineStore("connection", () => {
); );
}); });
/** Memoized map of groupId → filtered connections. Recomputes only when connections or searchQuery change. */
const connectionsByGroupMap = computed<Record<number, Connection[]>>(() => {
const q = searchQuery.value.toLowerCase().trim();
const map: Record<number, Connection[]> = {};
for (const c of connections.value) {
if (c.groupId === null) continue;
if (q) {
const match =
c.name.toLowerCase().includes(q) ||
c.hostname.toLowerCase().includes(q) ||
c.tags?.some((t) => t.toLowerCase().includes(q));
if (!match) continue;
}
if (!map[c.groupId]) map[c.groupId] = [];
map[c.groupId].push(c);
}
return map;
});
/** Get connections belonging to a specific group. */ /** Get connections belonging to a specific group. */
function connectionsByGroup(groupId: number): Connection[] { function connectionsByGroup(groupId: number): Connection[] {
const q = searchQuery.value.toLowerCase().trim(); return connectionsByGroupMap.value[groupId] ?? [];
const groupConns = connections.value.filter((c) => c.groupId === groupId);
if (!q) return groupConns;
return groupConns.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.hostname.toLowerCase().includes(q) ||
c.tags?.some((t) => t.toLowerCase().includes(q)),
);
} }
/** Check if a group has any matching connections (for search filtering). */ /** Check if a group has any matching connections (for search filtering). */
function groupHasResults(groupId: number): boolean { function groupHasResults(groupId: number): boolean {
return connectionsByGroup(groupId).length > 0; return (connectionsByGroupMap.value[groupId]?.length ?? 0) > 0;
} }
/** Load connections from the Rust backend. */ /** Load connections from the Rust backend. */
@ -101,6 +112,7 @@ export const useConnectionStore = defineStore("connection", () => {
groups, groups,
searchQuery, searchQuery,
filteredConnections, filteredConnections,
connectionsByGroupMap,
connectionsByGroup, connectionsByGroup,
groupHasResults, groupHasResults,
loadConnections, loadConnections,

View File

@ -2,6 +2,7 @@ import { defineStore } from "pinia";
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import type { UnlistenFn } from "@tauri-apps/api/event";
import { useConnectionStore } from "@/stores/connection.store"; import { useConnectionStore } from "@/stores/connection.store";
import type { ThemeDefinition } from "@/components/common/ThemePicker.vue"; import type { ThemeDefinition } from "@/components/common/ThemePicker.vue";
@ -39,10 +40,14 @@ export const useSessionStore = defineStore("session", () => {
const sessionCount = computed(() => sessions.value.length); const sessionCount = computed(() => sessions.value.length);
const sessionUnlisteners = new Map<string, Array<UnlistenFn>>();
// Listen for backend close/exit events to update session status // Listen for backend close/exit events to update session status
function setupStatusListeners(sessionId: string): void { async function setupStatusListeners(sessionId: string): Promise<void> {
listen(`ssh:close:${sessionId}`, () => markDisconnected(sessionId)); const unlisteners: UnlistenFn[] = [];
listen(`ssh:exit:${sessionId}`, () => markDisconnected(sessionId)); unlisteners.push(await listen(`ssh:close:${sessionId}`, () => markDisconnected(sessionId)));
unlisteners.push(await listen(`ssh:exit:${sessionId}`, () => markDisconnected(sessionId)));
sessionUnlisteners.set(sessionId, unlisteners);
} }
function markDisconnected(sessionId: string): void { function markDisconnected(sessionId: string): void {
@ -92,6 +97,12 @@ export const useSessionStore = defineStore("session", () => {
console.error("Failed to disconnect session:", err); console.error("Failed to disconnect session:", err);
} }
const unlisteners = sessionUnlisteners.get(id);
if (unlisteners) {
unlisteners.forEach((fn) => fn());
sessionUnlisteners.delete(id);
}
sessions.value.splice(idx, 1); sessions.value.splice(idx, 1);
if (activeSessionId.value === id) { if (activeSessionId.value === id) {
@ -325,7 +336,8 @@ export const useSessionStore = defineStore("session", () => {
}); });
// Listen for PTY close // Listen for PTY close
listen(`pty:close:${sessionId}`, () => markDisconnected(sessionId)); const unlistenPty = await listen(`pty:close:${sessionId}`, () => markDisconnected(sessionId));
sessionUnlisteners.set(sessionId, [unlistenPty]);
activeSessionId.value = sessionId; activeSessionId.value = sessionId;
} catch (err: unknown) { } catch (err: unknown) {

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